Skip to content

fix(templates): a locked document's lines are locked with it (#6695) - #6739

Merged
delchev merged 1 commit into
masterfrom
fix/child-locks-with-master-rest
Aug 15, 2026
Merged

fix(templates): a locked document's lines are locked with it (#6695)#6739
delchev merged 1 commit into
masterfrom
fix/child-locks-with-master-rest

Conversation

@delchev

@delchev delchev commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #6695.

The hole

immutableWhen guarded only the entity that declared it. A composition child declares none of its own — the lock belongs to the document — but the generated child repository writes through to the master, resumming its aggregate columns on every save / update / delete. So POST / PUT / DELETE on a line of an ISSUED invoice succeeded over REST and silently rewrote that document's net / vat / total: after the number was stamped, after the immutable PDF snapshot was taken, after it posted to the ledger. The stored copy, the journal entry and the settlement state then all disagreed with the record — and nothing reported it, because the write returned 200.

The generated UI already forbade it (a locked document renders as preview, its panels read-only), so the two surfaces disagreed, and the operation REST permitted was the one immutableWhen exists to prevent.

The fix

ModelParameterProcessor.inheritMasterLock propagates the master's immutableAlways / immutableStatusProperty + ids onto each direct composition child as a masterLock map (master entity, the child's FK to it, and the master's …Entity / …Repository classes resolved through that FK's perspective) — the pass modelled on the personal / partner inheritance right beside it, which resolves the same parent the same way.

All three generated controllers then emit requireMasterMutable:

  • create — against the payload's master;
  • update — against the STORED master and the incoming one, so a line can neither be edited inside a locked document nor moved into one;
  • delete, and an attachment upload.

The partner and personal surfaces get it too: reaching a locked master through the customer's portal, or the record owner's My pages, is the same write. Each already holds the parent repository when its scope is inherited through the parent, so the guard reuses that field rather than injecting a second one; a see-only personal surface (personalReadOnly) refuses every write with 403 already and is untouched.

Engine writers stay exempt by construction — they go through the repository, not a controller — so auto-settlement, roll-ups, workflow delegates, the void transition and the issue-time snapshot generator keep writing to a locked document's children. That is what makes flow-generated corrections possible at all, and it is the same assumption the master's own guard already makes.

The opt-out is the declaration #6700 already added: locksWithMaster: false (settlement is a different lifecycle from content). One flag now governs the affordance and the endpoint together, so the screen and the server cannot drift apart — a model that offers the panel keeps its writes, a model that says nothing has both closed. It also composes with the prompted generates action (#6685), whose create runs through the target's repository: a guided create against a post-issue child keeps working on a locked document, exactly as its per-record button (deliberately not gated on mutability) implies.

Tests

IntentEmissionCoverageIT carries both controls end-to-end:

  • the fixture's Bill (a real document master with aggregate totals) freezes when it is sent, and the line write that would move the totals the mailed PDF was rendered from is refused — with the header's amount asserted UNMOVED, which is the point of the issue;
  • EntryLine (a plain master-detail child that says nothing) is refused create, edit and delete on a POSTED entry, and its collection comes back unchanged;
  • CampaignNote, which declares locksWithMaster: false, still posts to a frozen campaign.

The new ChildLockControllerTemplateIT renders all three controllers through the platform's Velocity engine to cover the branches an intent cannot reach from that fixture (the partner / personal guards, the append-only master), and fails on a ${...} that survives into the emitted guard — the typo class that otherwise only surfaces as a compile error in a user's project. It boots no application context. Plus four ModelParameterProcessorTest cases for the derivation itself.

Verified locally: both ITs green in one run, ModelParameterProcessorTest 24 green, formatter:validate clean, and the release-profile javadoc build clean on ide-template.

Docs

  • In-repo: the engine-intent module guide and the intent assistant guide.
  • dirigible-io/dirigible-io.github.io#180 — the DSL reference described the child controller as one that "already accepts the writes", i.e. documented the hole as intended behaviour.
  • Vendor-neutral spec: IntentFile/intent-specification#19 and IntentFile/intentfile.github.io#18 — 1.2 already made the default normative ("a child that says nothing keeps freezing with its master") but only ever spelled out what freezing means for the opt-out; that ambiguity is what this implementation read as affordances-only.

🤖 Generated with Claude Code

`immutableWhen` guarded only the entity that declared it. A composition child declares
none of its own - the lock belongs to the document - but the generated child repository
writes THROUGH to the master, resumming its aggregate columns on every save, update and
delete. So POST / PUT / DELETE on a line of an ISSUED invoice succeeded over REST and
silently rewrote that document's net / vat / total: after the number was stamped, after
the immutable PDF snapshot was taken, after it posted to the ledger. The stored copy, the
journal entry and the settlement state then all disagreed with the record, and nothing
said so - the write returned 200, and the document's page showed the new total beside a
printed copy carrying the old one.

The generated UI already forbade it (a locked document renders as preview, its panels
read-only), so the two surfaces disagreed - and the one REST permitted was the operation
`immutableWhen` exists to prevent.

`ModelParameterProcessor.inheritMasterLock` propagates the master's `immutableAlways` /
`immutableStatusProperty` + ids onto each direct composition child as a `masterLock` map
(master entity, the child's FK to it, and the master's Entity/Repository classes resolved
through that FK's perspective) - the pass modelled on the personal/partner inheritance
right beside it, which resolves the same parent the same way. All three generated
controllers then emit `requireMasterMutable`: on create against the payload's master, on
update against the STORED master AND the incoming one (a line can neither be edited
inside a locked document nor moved into one), on delete, and on an attachment upload.

The partner and personal surfaces get it too. Reaching a locked master through the
customer's own portal, or through the record owner's My pages, is the same write; each
controller already holds the parent repository when its scope is inherited through the
parent, so the guard reuses that field rather than injecting a second one. A see-only
personal surface refuses every write with 403 already and stays as it was.

Engine writers are exempt by construction: they go through the repository, not a
controller - so auto-settlement, roll-ups, workflow delegates, the void transition and
the issue-time snapshot generator keep writing to a locked document's children, which is
what makes flow-generated corrections possible at all. The opt-out is the declaration
#6700 already added: `locksWithMaster: false` (settlement is a different lifecycle from
content). One flag now governs the affordance and the endpoint together, so the screen
and the server cannot drift apart - a model that offers the panel keeps its writes, a
model that says nothing has both closed.

Tests. `IntentEmissionCoverageIT` carries both controls end-to-end: the fixture's Bill
freezes when it is sent, and the line write that would move the totals the mailed PDF was
rendered from is refused - with the header's amount asserted UNMOVED, which is the whole
point; `EntryLine` (a plain master-detail child, silent) is refused create, edit and
delete on a POSTED entry and its collection is unchanged; `CampaignNote`, which declares
`locksWithMaster: false`, still posts to a frozen campaign. The new
`ChildLockControllerTemplateIT` renders all three controllers through the application's
own Velocity engine to cover the branches an intent cannot reach from that fixture (the
partner / personal guards, the append-only master) and to fail on a `${...}` that
survives into the emitted guard - the typo class that otherwise only shows up as a
compile error in a user's project. Plus four `ModelParameterProcessorTest` cases for the
derivation itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@delchev
delchev merged commit eab4336 into master Aug 15, 2026
10 checks passed
@delchev
delchev deleted the fix/child-locks-with-master-rest branch August 15, 2026 09:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

intent: immutableWhen does not reach composition children, so a line-item write rewrites a locked document's totals over REST

1 participant