Skip to content

fix(intent): a voided target no longer blocks its replacement (#6814) - #6850

Merged
delchev merged 1 commit into
masterfrom
fix/generate-supersede-guard
Aug 19, 2026
Merged

fix(intent): a voided target no longer blocks its replacement (#6814)#6850
delchev merged 1 commit into
masterfrom
fix/generate-supersede-guard

Conversation

@delchev

@delchev delchev commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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:

  1. An event mints Invoice INV-001 from Timesheet 12.
  2. INV-001 is wrong; the transitions: Void moves it to VOIDED. It still exists, still references Timesheet 12.
  3. The source re-qualifies (or the shared button is clicked): the guard finds VOIDED INV-001 and returns it. The UI toasts the voided document; no replacement is ever created.

"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's scope: resolves through (#6645). The guard now reads it.

seeds:
  - name: declaration-states
    entity: DeclarationState
    rows:
      - { id: 1, name: DRAFT,     stage: draft }
      - { id: 2, name: FILED,     stage: live }
      - { id: 3, name: CANCELLED, stage: cancelled }   # a target in either of these
      - { id: 4, name: VOIDED,    stage: void }        # no longer blocks its source
  • A target classified cancelled or void is 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.
  • A draft or live target 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.putSupersededTarget pre-renders hasRetiredStatus / retiredStatusProperty / retiredStatusCondition (the template stays shape-only); Generate.java.template turns

if (!existing.isEmpty()) { return existing.get(0); }

into

for (VoucherEntity candidate : existing) {
    if (candidate.Status == null || !(candidate.Status == 3)) {
        return 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.

What is deliberately left alone

  • No lifecycle on the target → existence-only guard, silently: nothing can retire that document.
  • A lifecycle nobody classified → existence-only guard plus a generation warning. That is the silent combination the issue is about — the guard reads as state-aware and is not — so Generate names the create-from, the nomenclature and the key to classify it with.
  • A cross-model target is seeded in its owner model, so no classification is resolvable at the consumer; same limit scope: has.
  • posts: keeps its own guard: a register row has no lifecycle of its own, and a bad post is unwound by postings:' 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 and stage:), and the no-lifecycle case (neither retires nor warns).
  • IntentEmissionCoverageIT — classifies its shared EntryStatus nomenclature 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 new VoidVoucher transition, after which generating from the same slip mints a second voucher while the voided one is kept.
  • Green locally: IntentEmissionCoverageIT, IntentEngineIT (50), ModelGenerationIT, the whole engine-intent unit 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

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
delchev force-pushed the fix/generate-supersede-guard branch from a5fbf29 to a969e43 Compare August 19, 2026 14:19
@delchev
delchev merged commit e50c69c into master Aug 19, 2026
10 checks passed
@delchev
delchev deleted the fix/generate-supersede-guard branch August 19, 2026 17:38
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.

The event-driven create-from guard is existence-only - a voided target blocks its replacement forever

1 participant