Skip to content

intent: a forbidWhen refuses the DELETE of the row it guards (#7372) - #7417

Merged
delchev merged 1 commit into
masterfrom
issue-7372-forbidwhen-delete
Sep 17, 2026
Merged

delchev merged 1 commit into
masterfrom
issue-7372-forbidwhen-delete

Conversation

@delchev

@delchev delchev commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

What was wrong

checks: forbidWhen (#7275) refused a create and an update while its condition held, and its master guard hides the child panel's Add, row edit AND row delete — but the server half covered only the first two:

# Quotation 1 is SENT; QuotationItem declares
#   { kind: forbidWhen, when: "Quotation.Status != DRAFT", message: "..." }
POST   .../QuotationItemController      -> 400   (as designed)
PUT    .../QuotationItemController/2    -> 400   (as designed)
DELETE .../QuotationItemController/2    -> 200   the row is gone

So a rule reading "no line may change on a sent quotation" let the line be removed — the largest of the three changes — through anything that is not the generated page. The hidden affordance is what made it silent: a reviewer clicking through the pages sees all three gone and concludes the rule holds.

The fix

forbidWhen is the only check kind about the write happening rather than about the values it carries, so it is the only one with anything to say about a delete. ModelParameterProcessor.splitChecks puts every forbidWhen into a new deleteChecks list (requiredWhen, compare, exactlyOne, itemsMin, itemsSumEqual reach it never), and all three generated controllers emit requireDeletable(<Name>Entity entity) from it and call it on their delete verb with the stored row, since a delete carries no payload. An entity with no forbidWhen generates byte-identically.

The gate rides along on that verb rather than routing the rule to the repository as it does for the write half, which deviates slightly from the issue's expectation and is deliberate:

  • a delete is nobody's transition — no process step deletes a record, and whenDeleted: only reacts to one — so there is no repository-side write for a gated check to sit on;
  • enforcing it in the repository would fire on the composition cascade, since a master sweeping its own children away calls each child's delete(entity). Deleting a SENT quotation would then be refused by its own guarded lines — and whether that delete is allowed is what whenMasterDeleted: declares, not a child check's business.

Every delete a person performs arrives over REST, so the three controllers are where the gap actually closes — the same place immutableWhen / locksWithMaster already enforce theirs.

Also fixed while in the same branch of the same construct: its three existing write sites wrote the raw message into a Java string literal instead of the escaped twin, so an authored message quoting a field name — which the DSL's own examples suggest — failed the compile of the whole module's generated code (#7241). Now messageJavaLiteral everywhere.

Verified

  • ModelParameterProcessorTest — 57/57 green, including the extended split assertion and a new noOtherCheckKindReachesTheDeleteList.
  • New ForbidWhenDeleteTemplateIT — renders all three controller templates through the platform's own Velocity engine: the guard, its call site on each surface, the gated variant carrying its status condition, an unguarded entity emitting nothing at all, the escaped message, and no unresolved ${...}.
  • IntentEmissionCoverageIT — green (333 s), and extended end-to-end rather than only at the source level: a new CampaignTask fixture carries the ungated forbidWhen with locksWithMaster: false, so the inherited 409 is out of the way and a 400 can only be the check. The task created before the campaign closed is the control; after it closes, POST / PUT / DELETE are each a 400 with the authored message and the row is still readable afterwards. Plus the emitted-source assertions on EntryLineController (guard + call site) and on DocController (a requiredWhen emits no delete guard).
  • mvn formatter:validate over the whole reactor with the formatter cache wiped — BUILD SUCCESS.

Not run: the rest of the integration suite, and the full unit suite outside ide-template.

Fixes #7372

🤖 Generated with Claude Code

`checks: forbidWhen` refused a create and an update while its condition
held, and its master guard hides the child panel's Add, row edit AND row
delete - but the server half covered only the first two. A rule reading
"no line may change on a sent quotation" therefore let the line be
REMOVED, the largest of the three changes, through anything that is not
the generated page. The hidden affordance is what made it silent: a
reviewer clicking through the pages sees all three gone and concludes the
rule holds.

forbidWhen is the only check kind about the write HAPPENING rather than
about the values it carries, so it is the only one with anything to say
about a delete: `splitChecks` puts every forbidWhen into `deleteChecks`,
and the three generated controllers emit `requireDeletable(<Name>Entity)`
from it and call it on their delete verb with the STORED row, since a
delete carries no payload.

The gate rides along on that verb instead of routing the rule to the
repository as it does for the write half: a delete is nobody's transition
- no process step deletes a record, `whenDeleted:` only reacts to one - so
there is no repository-side write for a gated check to sit on. Keeping it
off the repository is also what leaves the composition cascade alone: a
master sweeping its own children away goes through their repositories, and
whether THAT delete is allowed is what `whenMasterDeleted:` declares.

Also fixes the same construct's message interpolation on the three write
sites it already had: the raw `message` was written into a Java literal
instead of its escaped twin, so an authored message quoting a field name
- which the DSL's own examples suggest - failed the compile of the whole
module's generated code (#7241).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@delchev
delchev merged commit c5a433c into master Sep 17, 2026
10 checks passed
@delchev
delchev deleted the issue-7372-forbidwhen-delete branch September 17, 2026 10:15
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.

intent: a forbidWhen check refuses create and update but not DELETE, so the server contradicts the affordance it hid

1 participant