fix(intent): an expansion's generated rows do not outlive their master (#6821) - #6838
Merged
Conversation
#6821) `expansions` states that the generated child set is OWNED by the expansion - a span change replaces every row pointing at the master - and then binds only two of the master's three events. There was no handler for the master's delete, so the rows the expansion generated were the one part of a deleted record that survived it: orphans pointing at an id that no longer resolves, and still counted by every roll-up, report and balance that counted them while the record existed. A leave request's day rows kept spending the balance of a request that was gone from the list. Nothing else closed the gap. The issue read it as deployment-dependent (a refused delete where the constraint is enforced, orphans where it is not), but on this platform there is only one outcome: `SchemasSynchronizer.parseImpl` drops every foreign key a `.schema` declares BY DESIGN - referential integrity is a business-layer check - so the rows were always silently orphaned, never protected. A composition does not imply the cleanup either: it governs how a child is reached and edited, not what becomes of generated rows when the thing that generated them ceases to exist. Each expansion now also contributes an `OnDelete` handler on the master's `-deleted` topic, removing the rows selected by the expansion's own back-reference and nothing else. It deletes them one at a time THROUGH the child repository, so each row's delete event fires and whatever reacts to a hand-deleted row - a roll-up on the child, a capacity guard, a downstream aggregate - reacts identically; a bulk statement would leave exactly the stale totals this exists to prevent. It is idempotent by construction: the delete event is published after the master row is gone, so a redelivery finds an empty child set. No write-back to the master, whose row (and `count` field) is gone. The database cascade the issue offers as an alternative is deliberately not taken: it would fire for rows the expansion does not own, apply to hand-entered children too, bypass the child's layer so the events the totals depend on never fire, and encode a rule the model neither states nor can see. The cleanup is its own glue collection (`expansionCleanups`) and its own template, because a template source renders once per collection entry and the cleanup's body shares nothing with the regeneration's. Registering it in `GlueGenerator` is not optional bookkeeping - an unregistered collection falls through to the whole-model branch and emits one file of unresolved placeholders. `IntentEngineIT` asserts the emitted handler (topic, criteria, per-row delete through the child repository, no master write-back, every placeholder resolved). `IntentEmissionCoverageIT` asserts the promise at the only layer that can show it: a real published app whose retainer expands into three monthly rows on create and has none left once the retainer is deleted - the emitted source can be perfect and still be subscribed to a topic nothing publishes to. Also corrects the module guide's note on the DELETE-to-409 mapping, which still described FK constraint emission as pending work rather than as the decision it is, and mis-stated the mechanism (the schema template does emit `foreignKey` structures; the synchronizer drops them).
This was referenced Aug 19, 2026
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.
Closes #6821.
What was wrong
expansionsstates that the generated child set is owned by the expansion — a span change replaces every row pointing at the master — and then binds only two of the master's three events. There was no handler for the master's delete, so the rows the expansion generated were the one part of a deleted record that survived it: orphans pointing at an id that no longer resolves, still counted by every roll-up, report and balance that counted them while the record existed. A leave request's day rows kept spending the balance of a request that was gone from the list.Not visibly broken anywhere; wrong everywhere that counts.
One correction to the issue
The issue reads the outcome as deployment-dependent — a refused delete (409) where the FK constraint is enforced, orphans where it is not. On this platform there is only one outcome. The schema template does emit
type: "foreignKey"structures, butSchemasSynchronizer.parseImpldrops every one of them by design (carryUniqueConstraintscarries the unique keys and nothing else): a foreign key never becomes a database constraint here, because referential integrity is a business-layer check. So the rows were always silently orphaned — never protected, and the "undeletable" variant cannot occur.A composition does not imply the cleanup either: it governs how a child is reached and edited, not what becomes of generated rows when the thing that generated them ceases to exist.
The fix
Each expansion now also contributes an
OnDeletehandler on the master's-deletedtopic, removing the rows selected by the expansion's own back-reference and nothing else:countfield) is gone.The database cascade the issue offers as an alternative is deliberately not taken: it would fire for rows the expansion does not own, apply to hand-entered children too, bypass the child's layer so the events the totals depend on never fire, and encode a rule the model neither states nor can see.
No new DSL key — this completes an ownership rule the construct already declares. An existing intent gains the behaviour by re-generating.
Shape
The cleanup is its own glue collection (
expansionCleanups) and its own template, because a template source renders once per collection entry and the cleanup's body shares nothing with the regeneration's. Registering it inGlueGeneratoris not bookkeeping — an unregistered collection falls through to the whole-model branch and emits one file of unresolved placeholders.Verification
IntentEngineIT(50/50) — the emitted handler:-deletedtopic, the back-reference criteria, the per-row delete through the child repository, no master write-back, every placeholder resolved.IntentEmissionCoverageIT— the promise at the only layer that can show it: a real published app whoseRetainerexpands into three monthly rows on create and has none left once the retainer is deleted. The emitted source can be perfect and still be subscribed to a topic nothing publishes to; the log confirmsgen.events.emission.RetainerPeriodsExpansionOnDeletecompiled in the client-Java batch and connected toemission-test-Retainer-Retainer-deleted.formatter:validateclean repo-wide; 689 unit tests green on the two changed modules.Also corrects the module guide's note on the DELETE-to-409 mapping, which still described FK constraint emission as pending work rather than as the decision it is, and mis-stated the mechanism.
Documentation