Skip to content

feat(intent): a retired target returns its source, so an event-driven reissue can fire - #6893

Merged
delchev merged 1 commit into
masterfrom
fix/generates-reissue
Aug 21, 2026
Merged

feat(intent): a retired target returns its source, so an event-driven reissue can fire#6893
delchev merged 1 commit into
masterfrom
fix/generates-reissue

Conversation

@delchev

@delchev delchev commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

#6850 made the event-driven create-from's at-most-once guard state-aware: a target retired into a cancelled/void stage is stepped over, so the source's one-shot slot is freed. For the sourceStatus: combination it half-delivered — the door is unlocked and nobody can knock.

The completion hook moves the source off the status its own trigger qualifies on. That is deliberate: it is how the guard-claimed source stops matching. But the usual lifecycle graph declares no edge back, so after voiding the target:

This takes the second direction the issue weighed — a declared reopen — because it is explicit in the model, uses only existing vocabulary, and makes the reissue the ordinary path rather than a second creation route:

generates:
  - name: invoice-from-proforma
    from: Proforma
    to: Invoice
    event: { onTransition: Proforma, when: "Status == APPROVED" }
    map: { Proforma: id }
    sourceStatus: INVOICED           # forward: the proforma is done once the invoice exists
    sourceStatusOnRetire: APPROVED   # back: voiding the invoice returns it - and the trigger re-fires

Retiring the invoice returns the proforma to APPROVED. That is a real lifecycle move of the proforma, announced on its own -transitioned channel, so the trigger re-fires, the guard steps over the retired invoice, and the replacement is minted by machinery that already existed. The retired document is kept, never edited or re-pointed.

Emission

GlueIntentGenerator.putSupersededTarget pre-renders hasReopen / reopenStatusValue / reopenRetiredCondition from the same retiring-stage: resolution the guard uses — the guard asks whether the target that exists is retired, the reopen asks whether the transition it just saw is what retired it, and one resolution is what stops them drifting. GenerateReopen.java.template (collection generateReopens, the same filtered descriptors and the same bindGenerate as generates/generateEvents) renders a MessageHandler on the target's -transitioned topic that flips the source with

sourceRepository.updateProperties(target.Proforma, java.util.Map.of("Status", 2),
        "<project>-Proforma-Proforma-transitioned");

— the notice riding the write into the outbox exactly as transitions: does, so the flip and its announcement commit together and <X>GenerateOnEvent cannot miss the moment that frees it.

Idempotence is by state, with no marker column

Two conditions carry it:

  • the source must still stand at this rule's sourceStatus, so a source that has moved on down its own lifecycle is never overruled; and
  • no target of that source may still count.

The second is the half that closes redelivery, and it is worth spelling out because the first alone looks sufficient and is not. A retirement is delivered at-least-once, so a void can arrive again after the reissue — and by then the source is standing at sourceStatus once more, because the reissue put it there. Without the free-slot scan the reopen would return a source that already has a live target against it, and create() would then return at its own guard, so nothing would ever put the status back. With it, the reopen runs exactly when a creation would be allowed through.

Refused where it could never fire

validateGeneratesReopen rejects, at parse: no sourceStatus (nothing to invert), the same status (a write that changes nothing announces nothing), mode: append (no guard, so no slot), no event: (the emission is gated on event-driven, so a button-only reopen would be authored and silently dropped — and there the button is the reissue), a cross-model target (its stages are classified in the owner model, the same limit scope: has), a target with no lifecycle, an unclassified nomenclature.

And validateStatusWritesAgainstLifecycle pins the source's graph to the one edge sourceStatus → reopen, rather than to reachability: that is exactly where the source stands when the retirement arrives, so a missing edge is an authoring error instead of a runtime ValidationException.

Two decisions worth recording

  • The directions not taken. Having the target's retirement re-deliver the source's qualifying event fabricates a transition nobody made — and since the source stands at the post-generation status it would not even match the trigger's guard without bypassing it — while re-firing every other consumer of that channel. Documenting-and-warning is honest but leaves the automation inexpressible.
  • sourceStatus and sourceStatusOnRetire both join the sites StatusSymbolResolver rewrites, so both take a seeded NAME or an id. sourceStatus was id-only; leaving the pair asymmetric while documenting the inverse with names would have been a wart of its own. Numeric values pass through untouched.
  • Not added to IntentEmissionCoverageIT's voucher-from-slip on purpose: that fixture's The event-driven create-from guard is existence-only - a voided target blocks its replacement forever #6814 test reissues by POSTing the endpoint, and an automatic reissue racing that POST could mint a third voucher and turn a green test flaky. The end-to-end coverage lives in IntentEngineIT instead.

Verification

  • engine-intent 806/806 — ten new parser cases (every rejection parses silently without the validator, which is the reported defect) and two new glue cases (the emitted inverse, and byte-identical output without the key).
  • IntentEngineIT 58/58, IntentEmissionCoverageIT 1/1, ModelGenerationIT 1/1 — the last two matter most, since the change touches the shared putSupersededTarget and bindGenerate paths every create-from renders through.
  • mvn formatter:validate clean; mvn -P release javadoc clean on both changed modules.
  • The local PR-gate smoke run deadlocked rather than failed, in DatabasePerspectiveIT's @DirtiesContext teardown: main and FileSystemWatcher in a two-thread lock cycle over PollingWatchService's key/HashMap pair. That is The registry watcher's shutdown deadlock is back (#6437 fixed the wrong half) - every @DirtiesContext IT class wedges on macOS #6856, macOS-only (CI uses inotify), unrelated to this change — the six UI classes that ran before it all passed. CI is the authority for that slice.

What issues does this PR fix or reference?

Release Notes

An event-driven create-from that flips its source's status once the target exists could not be reissued automatically: voiding the target freed the source's slot, but the source could never re-qualify, so nothing fired into it and only a button could raise the replacement. A create-from may now declare sourceStatusOnRetire: — the status its source returns to when the target is retired — so void-and-reissue completes by itself.

Documentation

intent-assistant-guide.md, components/engine/engine-intent/README.md and components/engine/engine-intent/CLAUDE.md are updated in this PR (the DSL reference lives in-tree). Doc sites: dirigible-io/dirigible-io.github.io#211 and, vendor-neutral, IntentFile/intent-specification#48 (proposal 0025) + IntentFile/intentfile.github.io#38.

… reissue can fire

Closes #6868.

#6850 made the event-driven create-from's at-most-once guard state-aware: a
target retired into a `cancelled`/`void` stage is stepped over, so the source's
one-shot slot is freed. With `sourceStatus:` nothing could refill it. The
completion hook moves the source OFF the status its own trigger qualifies on -
deliberately, so the guard-claimed source stops matching - and the usual
lifecycle graph declares no edge back, so no qualifying `-transitioned` is ever
published again: the door was unlocked and nobody could knock. An event-only rule
(`button: false`, the shape #6711 introduced the axis for) had no reissue path at
all.

`sourceStatusOnRetire:` declares the hook's inverse on the same rule, so the
reissue becomes the ORDINARY path rather than a second creation route:

    generates:
      - name: invoice-from-proforma
        from: Proforma
        to: Invoice
        event: { onTransition: Proforma, when: "Status == APPROVED" }
        map: { Proforma: id }
        sourceStatus: INVOICED           # forward
        sourceStatusOnRetire: APPROVED   # back - and the trigger re-fires

`GenerateReopen.java.template` (collection `generateReopens`, the same filtered
descriptors and the same `bindGenerate`) renders a MessageHandler on the TARGET's
`-transitioned` topic that re-loads the target, tests the SAME retiring-`stage:`
set the guard uses, reads the source through the same back-reference, and flips
it with `updateProperties(id, {status: reopen}, "<source>-transitioned")` - the
notice riding the write into the outbox, as `transitions:` does, so flip and
announcement commit together. The trigger then re-fires, the guard steps over the
retired document, and the replacement is minted by machinery that already
existed. The retired document is kept, never edited or re-pointed.

Idempotence is by STATE, with no marker column. Two conditions carry it: the
source must still stand at this rule's own `sourceStatus`, so a source that has
moved on down its lifecycle is never overruled; and no target of that source may
still count. The second is the half that closes redelivery - a retirement is
delivered at-least-once, so a void can arrive again AFTER the reissue, and by
then the source stands at `sourceStatus` once more because the reissue put it
there. The reopen runs exactly when a creation would be allowed through.

`validateGeneratesReopen` refuses every combination that could never fire: no
`sourceStatus`, the same status, `mode: append` (no guard, so no slot), no
`event:` (the emission is gated on event-driven, so a button-only reopen would be
authored and silently dropped - and there the button is itself the reissue), a
cross-model target (its stages are classified in the owner model), a target with
no lifecycle, an unclassified nomenclature. And
`validateStatusWritesAgainstLifecycle` pins the source's graph to the ONE edge
`sourceStatus` -> reopen - exactly where the source stands when the retirement
arrives, so a missing edge is an authoring error rather than a runtime failure.

`sourceStatus` and `sourceStatusOnRetire` also join the sites
`StatusSymbolResolver` rewrites, so both take a seeded NAME or an id; leaving the
pair asymmetric would have been a wart of its own.

Docs: `intent-assistant-guide.md`, the module README and the module CLAUDE.md in
this PR (the DSL reference lives in-tree). Companion doc-site PRs cover
intentfile.org and dirigible.io.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@delchev

delchev commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

One coverage gap worth stating rather than leaving implicit.

GenerateReopen.java.template is the one event template with no fixture that compiles its output. The others get that for free from IntentEmissionCoverageIT, which publishes its project so the client-Java compiler runs over every generated file; I deliberately kept the reopen out of that fixture, because its #6814 block reissues by POSTing the create-from endpoint and an automatic reissue racing that POST could mint a third voucher and turn a green test flaky.

So I verified the rendered listener by hand instead: dumped the generated source from the IntentEngineIT run and ran javac over it. All 28 diagnostics are resolution-only — package gen… does not exist, cannot find symbol, and the knock-on does not override or implement a method from a supertype — and none syntactic, i.e. javac parsed the whole file. That establishes the template renders parseable Java, which nothing in the suite did before; it does not establish that it type-checks against the real SDK.

The residual risk is low, since every symbol the listener uses appears in the same position in an already-compiled sibling template (Criteria.create().eq, findById, updateProperties(id, Map, topic), MessageHandler / ListenerKind). But the honest fix is a fixture that compiles it, and it needs a source/target pair not entangled with the #6814 flow — a small addition to the emission fixture rather than a change to voucher-from-slip. Happy to add that here if you would rather not merge without it; otherwise I will file it as a follow-up.

Separately, for the record: the local PR-gate smoke run deadlocked rather than failed, in DatabasePerspectiveIT's @DirtiesContext teardown — main blocked in PollingWatchService$PollingWatchKey.disable holding the service's HashMap, FileSystemWatcher holding the key and waiting for that map. A genuine two-thread cycle, i.e. #6856, macOS-only (CI uses inotify). The six UI classes that ran before it passed, and this PR's own coverage (IntentEngineIT 58/58, IntentEmissionCoverageIT, ModelGenerationIT, engine-intent 806/806) was run separately and is green.

@delchev
delchev merged commit b8fa6c6 into master Aug 21, 2026
10 checks passed
@delchev
delchev deleted the fix/generates-reissue branch August 21, 2026 19:27
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: with sourceStatus:, a voided target frees the one-shot slot but nothing can re-fire the event-driven create-from

1 participant