feat(intent): a retired target returns its source, so an event-driven reissue can fire - #6893
Conversation
… 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>
|
One coverage gap worth stating rather than leaving implicit.
So I verified the rendered listener by hand instead: dumped the generated source from the The residual risk is low, since every symbol the listener uses appears in the same position in an already-compiled sibling template ( Separately, for the record: the local PR-gate smoke run deadlocked rather than failed, in |
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/voidstage is stepped over, so the source's one-shot slot is freed. For thesourceStatus: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:
-transitioned;button: truecan reissue. An event-only rule (button: false, the shape intent: event-driven generates - create a document from a source document on an entity event, not only a button #6711 introduced the axis for) has no reissue path at all.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:
Retiring the invoice returns the proforma to APPROVED. That is a real lifecycle move of the proforma, announced on its own
-transitionedchannel, 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.putSupersededTargetpre-rendershasReopen/reopenStatusValue/reopenRetiredConditionfrom 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(collectiongenerateReopens, the same filtered descriptors and the samebindGenerateasgenerates/generateEvents) renders aMessageHandleron the target's-transitionedtopic that flips the source with— the notice riding the write into the outbox exactly as
transitions:does, so the flip and its announcement commit together and<X>GenerateOnEventcannot miss the moment that frees it.Idempotence is by state, with no marker column
Two conditions carry it:
sourceStatus, so a source that has moved on down its own lifecycle is never overruled; andThe 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
sourceStatusonce 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, andcreate()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
validateGeneratesReopenrejects, at parse: nosourceStatus(nothing to invert), the same status (a write that changes nothing announces nothing),mode: append(no guard, so no slot), noevent:(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 limitscope:has), a target with no lifecycle, an unclassified nomenclature.And
validateStatusWritesAgainstLifecyclepins the source's graph to the one edgesourceStatus→ 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 runtimeValidationException.Two decisions worth recording
sourceStatusandsourceStatusOnRetireboth join the sitesStatusSymbolResolverrewrites, so both take a seeded NAME or an id.sourceStatuswas 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.IntentEmissionCoverageIT'svoucher-from-slipon 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 inIntentEngineITinstead.Verification
engine-intent806/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).IntentEngineIT58/58,IntentEmissionCoverageIT1/1,ModelGenerationIT1/1 — the last two matter most, since the change touches the sharedputSupersededTargetandbindGeneratepaths every create-from renders through.mvn formatter:validateclean;mvn -P releasejavadoc clean on both changed modules.DatabasePerspectiveIT's@DirtiesContextteardown:mainandFileSystemWatcherin a two-thread lock cycle overPollingWatchService'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.mdandcomponents/engine/engine-intent/CLAUDE.mdare 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.