From 87a3f3659cafb47e1a8f8c2081fb81f114ba3471 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 21 Aug 2026 15:52:32 +0300 Subject: [PATCH 1/3] docs(intent): reissuing automatically with sourceStatusOnRetire Superseding a retired target frees the source's slot, but a source flipped by the sourceStatus completion hook can never re-qualify on its own - so an event-only create-from stopped being event-driven after its first document. sourceStatusOnRetire is the hook's inverse: retiring the target returns the source, the ordinary trigger re-fires, and the replacement is minted. Documents the key, the state-based idempotence, the parse-time refusals, the lifecycle edge it needs, and the generated listener - plus the pointer from the existing sourceStatus bullet. Co-Authored-By: Claude Opus 5 --- docs/help/intent/dsl-reference.md | 62 ++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/help/intent/dsl-reference.md b/docs/help/intent/dsl-reference.md index 62cc5f2fb..1cc83dd5a 100644 --- a/docs/help/intent/dsl-reference.md +++ b/docs/help/intent/dsl-reference.md @@ -1058,7 +1058,9 @@ generates: generated create-from, and therefore one guard. `button: false` without an `event` is rejected - the action would have no trigger at all. - `sourceStatus:` composes unchanged (the flip happens once the target exists, and cannot re-trigger the - create-from because the guard has already claimed the source). + create-from because the guard has already claimed the source) - but note that a source it has flipped + can no longer re-qualify on its own, so an event-only rule needs + [`sourceStatusOnRetire:`](#reissuing-automatically-sourcestatusonretire) to be reissuable at all. Generated artifacts: `gen/events//GenerateOnEvent.java`, a `MessageHandler` on the source's `---transitioned` topic (or its bare create topic for @@ -1138,6 +1140,64 @@ seeds: - A **cross-model** target is seeded in its owner model, so no classification is resolvable at the consumer - the same limit [`scope:`](#scope-which-lifecycle-rows-an-aggregate-counts) has. +#### Reissuing automatically - `sourceStatusOnRetire` + +Superseding frees the source's slot. Where the rule also declares a `sourceStatus:` completion hook, +nothing can refill it - and this is the one combination where an event-driven create-from stops being +event-driven after its first document. + +The hook exists to move the source **off** the status its own trigger qualifies on, so that the +guard-claimed source stops matching. Once it has run the source stands at the post-generation status, and +the [lifecycle graph](#lifecycle-the-legal-status-graph) declares no edge back, so the source never +transitions through the qualifying status again and no qualifying event is ever published. Void the +target and the slot is free with nobody able to knock. With `button: true` a person can click; an +event-only rule has no reissue path at all. + +`sourceStatusOnRetire:` is the hook's **inverse** - where the source returns when a target this rule +produced is retired: + +```yaml +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 +``` + +Voiding the invoice returns the proforma to APPROVED - a real transition of the proforma, published on +its own [`-transitioned` channel](./glue.md#the-event-axis-lifecycle-and-process-step-events) like a +transition somebody performed. The ordinary trigger re-fires, the guard steps over the retired invoice, +and the replacement is minted. **The reissue is the ordinary path**, built from machinery that was +already there - and nothing new declares what "retired" means: it is the same +[`stage:`](#stage-what-a-status-means-to-the-lifecycle) classification the guard reads, asked from the +other end. + +- It **acts only while the source still stands at this rule's `sourceStatus`.** That is the whole + guard - which is what makes it idempotent with no marker column: a redelivered retirement finds the + source already returned, and a source that has travelled further down its own lifecycle is not dragged + back. +- It writes **only the status**, through the targeted single-column primitive, with the `-transitioned` + notice riding that write - so the flip and its announcement commit together and the create-from's own + listener cannot miss the moment that frees it. The retired document is left exactly as it is. +- **Whether the replacement is immediate is the trigger's decision, not the reopen's.** Return the source + to the status the trigger qualifies on and the reissue happens at once. Return it to an earlier status + (a `DRAFT` for correction) and nothing fires until a person moves it forward - which is how a reissue + that should be reviewed is modelled. + +Refused where it could never fire, at parse: without `sourceStatus:` (there is nothing to invert), when +it names the same status (a write that changes nothing announces nothing), on `mode: append` (no guard, +so no slot), for a target with no status lifecycle or an unclassified nomenclature, and for a cross-model +target (its statuses are classified in the owner model). And when the source declares a `lifecycle:`, the +graph must declare the edge from `sourceStatus` back - that is exactly where the source stands when the +retirement arrives, so a missing edge would fail the flip at runtime. + +Generated artifact: `gen/events//GenerateReopen.java`, a `MessageHandler` on the +**target's** `-transitioned` topic. An intent that declares no `sourceStatusOnRetire:` regenerates +byte-identical output. + #### Cardinality - `mode: once|append` `mode:` inside the `event:` map declares how many targets the trigger may produce. From afa53beae373637b63071c689a37ce853ac1e593 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 21 Aug 2026 15:56:11 +0300 Subject: [PATCH 2/3] the reopen also requires a free slot, which is what closes redelivery A retirement is delivered at-least-once, so it can arrive again after the replacement already exists - and by then the source is standing at sourceStatus once more, because the reissue put it there. The standing-status test alone would re-open a source with a live target against it. The reopen now runs exactly when a creation would be allowed through. Co-Authored-By: Claude Opus 5 --- docs/help/intent/dsl-reference.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/help/intent/dsl-reference.md b/docs/help/intent/dsl-reference.md index 1cc83dd5a..72e7fa871 100644 --- a/docs/help/intent/dsl-reference.md +++ b/docs/help/intent/dsl-reference.md @@ -1175,10 +1175,13 @@ already there - and nothing new declares what "retired" means: it is the same [`stage:`](#stage-what-a-status-means-to-the-lifecycle) classification the guard reads, asked from the other end. -- It **acts only while the source still stands at this rule's `sourceStatus`.** That is the whole - guard - which is what makes it idempotent with no marker column: a redelivered retirement finds the - source already returned, and a source that has travelled further down its own lifecycle is not dragged - back. +- It **acts only while the source still stands at this rule's `sourceStatus`**, so a source that has + travelled further down its own lifecycle is never dragged back - **and only while no target of that + source still counts**, which is the create-from's own guard asked from this end, over the same + classification. That second condition is what closes redelivery: lifecycle events are delivered + at-least-once, so a void can arrive again *after* the replacement exists - and by then the source is + standing at `sourceStatus` once more, because the reissue put it there. The reopen runs exactly when a + creation would be allowed through, which is what makes it idempotent with no marker column. - It writes **only the status**, through the targeted single-column primitive, with the `-transitioned` notice riding that write - so the flip and its announcement commit together and the create-from's own listener cannot miss the moment that frees it. The retired document is left exactly as it is. From f198f46dc1978a5fc6584931a6503ac8a03583c2 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 21 Aug 2026 16:24:38 +0300 Subject: [PATCH 3/3] the reopen requires an event trigger A create-from triggered only by a button carries no at-most-once guard, so nothing blocks a replacement - the button is itself the reissue, and there is no trigger for a reopen to re-fire. Co-Authored-By: Claude Opus 5 --- docs/help/intent/dsl-reference.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/help/intent/dsl-reference.md b/docs/help/intent/dsl-reference.md index 72e7fa871..1bc9184d8 100644 --- a/docs/help/intent/dsl-reference.md +++ b/docs/help/intent/dsl-reference.md @@ -1190,9 +1190,10 @@ other end. (a `DRAFT` for correction) and nothing fires until a person moves it forward - which is how a reissue that should be reviewed is modelled. -Refused where it could never fire, at parse: without `sourceStatus:` (there is nothing to invert), when -it names the same status (a write that changes nothing announces nothing), on `mode: append` (no guard, -so no slot), for a target with no status lifecycle or an unclassified nomenclature, and for a cross-model +Refused where it could never fire, at parse: without an `event:` (a button-only create-from carries no +guard, so nothing blocks a replacement and the button is already the reissue - there is no trigger to +re-fire), without `sourceStatus:` (there is nothing to invert), when it names the same status (a write +that changes nothing announces nothing), on `mode: append` (no guard, so no slot), for a target with no status lifecycle or an unclassified nomenclature, and for a cross-model target (its statuses are classified in the owner model). And when the source declares a `lifecycle:`, the graph must declare the edge from `sourceStatus` back - that is exactly where the source stands when the retirement arrives, so a missing edge would fail the flip at runtime.