intent: deleting a document deletes the composition children it owns - whenMasterDeleted: cascade | refuse (#7100) - #7111
Merged
Conversation
…- `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>
This was referenced Sep 8, 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.
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
.edmalike, 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 whichModelParameterProcessorderivedmasterEntity/masterEntityId. A projection on either side is skipped - it has no local table and no repository.deleteOwnedChildren(id)runs at the head ofdelete/deleteById, deleting each child through its own repository - so the child's-deletedevent (hence the roll-up relinquishing), its history trail and its own cascade all run, and a deep chain unwinds level by level. AStore/ raw-SQL delete would silently skip all of it.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: refusecascadeis the default and needs no key.refuserides the child's composition property asrelationshipMasterDeleteRefused(an.edmattribute; 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: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 processwhenDeleted: refuseanswers. 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
WhenMasterDeletedIntentTest,CompositionChildrenTest,EdmIntentGeneratorTest(the refusal attribute, and its absence by default).IntentEmissionCoverageIT-Entryowns two composition children with different decisions (EntryLinecascades,EntryCopyrefuses), so one repository carries both shapes; it also asserts the unit of work, and compiles the generated client Java.ModelGenerationITgreen (the hand-authored.modelfixtures).mvn formatter:validateand the release-profile javadoc clean on both changed modules.Fixes #7100
🤖 Generated with Claude Code