Skip to content

fix(intent): an expansion's generated rows do not outlive their master (#6821) - #6838

Merged
delchev merged 1 commit into
masterfrom
fix/expansion-master-delete
Aug 19, 2026
Merged

fix(intent): an expansion's generated rows do not outlive their master (#6821)#6838
delchev merged 1 commit into
masterfrom
fix/expansion-master-delete

Conversation

@delchev

@delchev delchev commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Closes #6821.

What was wrong

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, 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, but SchemasSynchronizer.parseImpl drops every one of them by design (carryUniqueConstraints carries 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 OnDelete handler on the master's -deleted topic, removing the rows selected by the expansion's own back-reference and nothing else:

  • Per-row, through the child repository — each row's delete event fires, so 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.
  • Idempotent by construction — the delete event is published after the master row is gone, so a redelivery finds an empty child set. No guard needed.
  • No write-back to the master, whose row (and its count field) is gone.
  • Engine-writer exemption preserved — it goes through the repository, not the controller, exactly like the other generated writers.

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 in GlueGenerator is 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: -deleted topic, 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 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; the log confirms gen.events.emission.RetainerPeriodsExpansionOnDelete compiled in the client-Java batch and connected to emission-test-Retainer-Retainer-deleted.
  • formatter:validate clean 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

#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).
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.

expansions: has no master-delete handler - a deleted master leaves orphaned or undeletable children

1 participant