fix(templates): a locked document's lines are locked with it (#6695) - #6739
Merged
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6695.
The hole
immutableWhenguarded 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 everysave/update/delete. SoPOST/PUT/DELETEon a line of an ISSUED invoice succeeded over REST and silently rewrote that document'snet/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
immutableWhenexists to prevent.The fix
ModelParameterProcessor.inheritMasterLockpropagates the master'simmutableAlways/immutableStatusProperty+ ids onto each direct composition child as amasterLockmap (master entity, the child's FK to it, and the master's…Entity/…Repositoryclasses 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: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 promptedgeneratesaction (#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
IntentEmissionCoverageITcarries both controls end-to-end: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 declareslocksWithMaster: false, still posts to a frozen campaign.The new
ChildLockControllerTemplateITrenders 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 fourModelParameterProcessorTestcases for the derivation itself.Verified locally: both ITs green in one run,
ModelParameterProcessorTest24 green,formatter:validateclean, and the release-profile javadoc build clean onide-template.Docs
engine-intentmodule 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.IntentFile/intent-specification#19andIntentFile/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