Skip to content

intent: deleting a document deletes the composition children it owns - whenMasterDeleted: cascade | refuse (#7100) - #7111

Merged
delchev merged 1 commit into
masterfrom
issue-7100-composition-cascade-delete
Sep 7, 2026
Merged

intent: deleting a document deletes the composition children it owns - whenMasterDeleted: cascade | refuse (#7100)#7111
delchev merged 1 commit into
masterfrom
issue-7100-composition-cascade-delete

Conversation

@delchev

@delchev delchev commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Deleting a header left its lines behind. The child rows kept pointing at a parent id that no longer existed - invisible in the UI, since no parent page renders them, and still counted by every report and roll-up over the child: a deleted vacation request left its five days charging the entitlement, which stayed EXHAUSTED with the request gone.

The cascade, for every composition master

It is emitted whatever the intent says, for an intent-authored and a hand-authored .edm alike, because it is what composition MEANS - a DEPENDENT row cannot exist without its parent.

  • CompositionChildren (ide-template) builds the reverse index the model does not carry: the edge is authored on the CHILD, from which ModelParameterProcessor derived masterEntity / masterEntityId. A projection on either side is skipped - it has no local table and no repository.
  • The DAO template's deleteOwnedChildren(id) runs at the head of delete / deleteById, deleting each child through its own repository - so the child's -deleted event (hence the roll-up relinquishing), its history trail and its own cascade all run, and a deep chain unwinds level by level. A Store / raw-SQL delete would silently skip all of it.
  • Both overrides run inside UnitOfWork.run: a repository call is otherwise its own transaction, so a failure half-way through would commit some of the children and keep the record - the very orphan state the cascade exists to prevent.

The author's alternative: whenMasterDeleted: refuse

- name: EntryCopy
  relations:
    - { name: Entry, kind: manyToOne, to: Entry, composition: true, required: true, whenMasterDeleted: refuse }

cascade is the default and needs no key. refuse rides the child's composition property as relationshipMasterDeleteRefused (an .edm attribute; absent = cascade, so an untouched model generates byte-identically) and the same method rejects the master's delete while any child exists, naming both entities:

This Entry still has Entry Copy records - delete those first

That is a ValidationException - 400 with the message, like every other repository-enforced refusal (checks:, the lifecycle graph) rather than the controller-side 409 the process whenDeleted: refuse answers. Deliberate: the enforcement has to hold for a reaction and a cascade too, not only for the REST surface, and the repository is the one choke point every writer passes through.

Refused at parse (validateWhenMasterDeleted, closed vocabulary): an unknown value; the key on a relation that is not a composition; and the key on a SECOND composition, which the EDM emits as a plain association - the cascade would be asked for and never run.

This is the data-side half of the process-side whenDeleted: abort | refuse (#7074), which the issue asked for.

Tests

  • Unit: WhenMasterDeletedIntentTest, CompositionChildrenTest, EdmIntentGeneratorTest (the refusal attribute, and its absence by default).
  • IT: IntentEmissionCoverageIT - Entry owns two composition children with different decisions (EntryLine cascades, EntryCopy refuses), so one repository carries both shapes; it also asserts the unit of work, and compiles the generated client Java. ModelGenerationIT green (the hand-authored .model fixtures).
  • mvn formatter:validate and the release-profile javadoc clean on both changed modules.

Fixes #7100

🤖 Generated with Claude Code

…- `whenMasterDeleted: cascade | refuse` (#7100)

Deleting a header left its lines behind. The child rows kept pointing at a
parent id that no longer existed - invisible in the UI, since no parent page
renders them, and still counted by every report and roll-up over the child: a
deleted vacation request left its five days charging the entitlement, which
stayed EXHAUSTED with the request gone.

The cascade is now emitted for EVERY composition master, intent-authored or
hand-authored `.edm`, because it is what composition MEANS - the child cannot
exist without its parent, so the DEPENDENT rows go with it. `CompositionChildren`
(ide-template) builds the reverse index the model does not carry: the edge is
authored on the CHILD, from which `ModelParameterProcessor` derived
`masterEntity`/`masterEntityId`. The DAO template's `deleteOwnedChildren(id)`
runs at the head of `delete`/`deleteById`, deleting each child through ITS OWN
repository - so the child's `-deleted` event (hence the roll-up relinquishing),
its history trail and its own cascade all run, and a deep chain unwinds level by
level. A `Store`/SQL delete would silently skip all of it. Both overrides run
inside `UnitOfWork.run`: a repository call is otherwise its own transaction, so a
failure half-way through would commit some children and keep the record.

The author's alternative is a new relation key on the composition,
`whenMasterDeleted: cascade | refuse` (default `cascade`, no key needed).
`refuse` rides the child's composition property as `relationshipMasterDeleteRefused`
(an `.edm` attribute; absent = cascade, so an untouched model is byte-identical)
and the same method rejects the master's delete while any child exists, naming
both entities - `ValidationException`, i.e. 400 with the message, like every other
repository-enforced refusal (`checks:`, the lifecycle graph) rather than the
controller-side 409 the process `whenDeleted: refuse` answers, because the
enforcement has to hold for a reaction and a cascade too, not only for REST.

Parser: `validateWhenMasterDeleted` - closed vocabulary, refused on a
non-composition and on a SECOND composition, which the EDM emits as a plain
association and would silently drop.

This is the data-side half of the process-side `whenDeleted: abort | refuse`
(#7074).

Unit: `WhenMasterDeletedIntentTest`, `CompositionChildrenTest`,
`EdmIntentGeneratorTest`. IT: `IntentEmissionCoverageIT` asserts the cascade, the
unit of work and the refusal on a master owning two children with different
decisions - and compiles the generated client Java. Guide + CLAUDE.md.

Fixes #7100

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

templates: deleting a document header leaves its composition children as orphans that roll-ups keep counting

1 participant