fix(intent): a voided target no longer blocks its replacement (#6814) - #6850
Merged
Conversation
This was referenced Aug 19, 2026
Merged
The event-driven create-from's at-most-once guard asked EXISTENCE only - findAll(eq(backRef, sourceId)) - and a voided target answers yes forever: it keeps existing and keeps back-referencing the source, so the source's one-shot slot was consumed at the first creation and nothing that later happened to the target released it. "Void and reissue" - an ordinary business flow - was inexpressible, and the failure was silent: the caller got a 200 and the retired document back. What a status MEANS is already declared once, where the nomenclature is seeded - the `stage:` classification a report's `scope:` resolves through. The guard now reads it: a target classified `cancelled` or `void` is retired and is stepped over, so the next qualifying event (or a click) mints a replacement, while a `draft` or `live` one still blocks and redelivery idempotence is untouched. The retired document is kept, never edited or re-pointed - both stay on the trail. Reusing the classification rather than adding a key on the create-from is deliberate: two vocabularies for "this row no longer counts" could only drift, and an author who has already classified their statuses should not repeat it per rule. - GlueIntentGenerator.putSupersededTarget pre-renders hasRetiredStatus / retiredStatusProperty / retiredStatusCondition; Generate.java.template turns the `if (!existing.isEmpty())` into a loop that steps over a retired candidate. - Gated on the boolean, so a .glue written before this key existed keeps the existence-only guard it always had, and a model that adopts no `stage:` regenerates byte-identical output. - `mode: append` (#6800) is the ABSENCE of a guard, not a state-aware one, so an appending create-from is skipped entirely: there is nothing for a retired target to release, and warning about an unclassified nomenclature there would be noise about a guard that does not exist. - A target with no lifecycle keeps the existence-only guard silently (nothing can retire it); one that HAS a lifecycle whose nomenclature nobody classified keeps it with a generation warning - that is the silent combination, where the guard reads as state-aware and is not. A cross-model target is seeded in its owner model, so no classification is resolvable there, the same limit `scope:` has. - IntentEmissionCoverageIT classifies its shared nomenclature and proves the flow end to end: the voucher the slip's transition minted is voided through a transition, and generating from the same slip mints a SECOND voucher while the voided one is kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
delchev
force-pushed
the
fix/generate-supersede-guard
branch
from
August 19, 2026 14:19
a5fbf29 to
a969e43
Compare
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 #6814.
The problem
The event-driven create-from's at-most-once guard queries row existence only —
findAll(eq(backRef, sourceId)), no state — and a voided target answers yes forever: it keeps existing and keeps back-referencing the source. The source's one-shot slot is spent at the first creation and nothing that later happens to the target releases it:transitions:Void moves it to VOIDED. It still exists, still references Timesheet 12."Void and reissue" — a document retired while keeping its number, a fresh one raised — was inexpressible for an event-driven create-from, and the failure was silent (a 200 carrying the retired document).
The fix — reuse the classification, add no key
What a status MEANS is already declared once, where the nomenclature is seeded: the
stage:classification a report'sscope:resolves through (#6645). The guard now reads it.cancelledorvoidis retired: the guard steps over it, so the next qualifying event — or a click — mints a replacement. The retired document is kept, never edited or re-pointed; both stay on the audit trail.draftorlivetarget still blocks, so redelivery idempotence is untouched: a redelivered event finds the document it created.The alternative — a
supersedeWhen:key on the rule — was rejected: two vocabularies for "this row no longer counts" could only drift, and an author who has already classified their statuses should not repeat it per rule.mode: append(#6800) is not this fix and could be misread as one — it is the absence of a guard, so it would also mint a document on every later qualifying event.Emission
GlueIntentGenerator.putSupersededTargetpre-rendershasRetiredStatus/retiredStatusProperty/retiredStatusCondition(the template stays shape-only);Generate.java.templateturnsinto
Gated on the boolean, so a
.gluewritten before this key existed keeps the existence-only guard it always had, and a model that adopts nostage:regenerates byte-identical output.What is deliberately left alone
scope:has.posts:keeps its own guard: a register row has no lifecycle of its own, and a bad post is unwound bypostings:'reverses:/storno:compensation.Tests
GlueGeneratesTest— the classified case (both retiring stages, in seed order, and not the draft/live ones), the unclassified case (existence-only + the warning naming the create-from, the nomenclature andstage:), and the no-lifecycle case (neither retires nor warns).IntentEmissionCoverageIT— classifies its sharedEntryStatusnomenclature and asserts both layers: the emitted guard steps over a cancelled candidate, and at runtime the voucher the slip's transition minted is voided through a newVoidVouchertransition, after which generating from the same slip mints a second voucher while the voided one is kept.IntentEmissionCoverageIT,IntentEngineIT(50),ModelGenerationIT, the wholeengine-intentunit suite (694),formatter:validate, javadoc under-P release.Docs: dirigible.io PR and the intentfile specification proposal + site PR are open alongside.
🤖 Generated with Claude Code