From f6fe7140aa0bbcd065f74680c96cd87d20a16791 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 21 Aug 2026 15:52:31 +0300 Subject: [PATCH 1/3] docs(glue): a retired target returns its source Documents the declared reopen: sourceStatusOnRetire, the inverse of the create-from's completion hook. Superseding frees the source's slot; where the hook has moved the source off the status its own trigger qualifies on, nothing could refill it, and an event-only rule had no reissue path at all. Co-Authored-By: Claude Opus 5 --- docs/spec/glue.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/spec/glue.md b/docs/spec/glue.md index a2290ec..fc50c5c 100644 --- a/docs/spec/glue.md +++ b/docs/spec/glue.md @@ -553,6 +553,35 @@ Declaring an `event` drops the button unless `button: true` is declared as well; `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. +#### A retired target returns its source + +A target that has been retired — its status classified `cancelled` or `void` — stops satisfying the at-most-once guard, so the source may produce a replacement. That is what makes "void and reissue" expressible. But where the rule also declares a completion hook, the freed slot cannot be refilled by an event. + +The hook exists precisely to move the source OFF the status its own trigger qualifies on. So once it has run, the source stands at the post-generation status, a lifecycle graph declares no edge back, and no qualifying transition is ever published again. Voiding the target frees the slot and nothing can knock. Where a button is also declared, a person can click it; an event-only rule — the shape the event axis exists for — has no reissue path at all, and nothing says so. + +`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 +``` + +Retiring the invoice returns the proforma to APPROVED. That is a real transition of the proforma, announced on the source's transition channel like any other, so the ordinary trigger re-fires, the guard steps over the retired invoice, and the replacement is minted. The reissue is the **ordinary path**, not a second creation route with semantics of its own — and nothing new declares what "retired" means: it is the same classification the guard reads, asked from the other end. + +::: info Normative +`sourceStatusOnRetire` requires `sourceStatus`, of which it is the inverse, and must name a different status — a write that leaves the status where it stands is not a transition and announces nothing. It must be rejected on an appending rule, which keeps no guard and therefore frees no slot, and wherever the target's retirement cannot be recognised where the rule is declared: a target with no status relation, a nomenclature with no `cancelled` or `void` classification, or a target belonging to another model. When a target of this rule becomes retired and the source still stands at `sourceStatus`, the source must be moved to `sourceStatusOnRetire` and that move announced on the source's transition channel; only the status may be written, and the retired target must be left exactly as it is. When the source stands anywhere else, nothing happens. The move is a transition, so where the source declares a lifecycle the edge from `sourceStatus` to it must be declared. A file that declares no `sourceStatusOnRetire` regenerates unchanged. +::: + +The condition "the source still stands at `sourceStatus`" is the whole guard, which is what makes the reopen idempotent without a marker of its own: a redelivered retirement finds the source already returned, and a source that has travelled further down its own lifecycle is not dragged back. + +Whether a replacement follows immediately is decided by the trigger's own `when:`, not by the reopen — which only publishes the transition. Return the source to the status the trigger qualifies on and the reissue is immediate; return it to an earlier one (a draft, for correction) and it waits for a person to move it forward again. + Prefer this over [`posts`](#posts-derived-rows-on-an-event) when the result is a document with line items — `posts` emits flat mapped rows and cannot reference the freshly created header. Prefer it over a button plus a [`wait`](/spec/processes#wait-park-the-process-on-a-data-event) step when the step is really waiting for a person to remember to click: an unclicked record parks its process instance indefinitely. ## resolves — fill a relation from a register valid on a date From b152c86be24fc980a5d314293e8b0387c51297da Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 21 Aug 2026 15:56:09 +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/spec/glue.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/spec/glue.md b/docs/spec/glue.md index fc50c5c..305fe48 100644 --- a/docs/spec/glue.md +++ b/docs/spec/glue.md @@ -578,7 +578,7 @@ Retiring the invoice returns the proforma to APPROVED. That is a real transition `sourceStatusOnRetire` requires `sourceStatus`, of which it is the inverse, and must name a different status — a write that leaves the status where it stands is not a transition and announces nothing. It must be rejected on an appending rule, which keeps no guard and therefore frees no slot, and wherever the target's retirement cannot be recognised where the rule is declared: a target with no status relation, a nomenclature with no `cancelled` or `void` classification, or a target belonging to another model. When a target of this rule becomes retired and the source still stands at `sourceStatus`, the source must be moved to `sourceStatusOnRetire` and that move announced on the source's transition channel; only the status may be written, and the retired target must be left exactly as it is. When the source stands anywhere else, nothing happens. The move is a transition, so where the source declares a lifecycle the edge from `sourceStatus` to it must be declared. A file that declares no `sourceStatusOnRetire` regenerates unchanged. ::: -The condition "the source still stands at `sourceStatus`" is the whole guard, which is what makes the reopen idempotent without a marker of its own: a redelivered retirement finds the source already returned, and a source that has travelled further down its own lifecycle is not dragged back. +Two conditions make the reopen idempotent without a marker of its own. The source must still stand at `sourceStatus`, so a source that has travelled further down its own lifecycle is not dragged back. And no target of that source may still count — the at-most-once guard asked from this end. The second is the one that closes redelivery: delivery is at-least-once, so a retirement can arrive again after the replacement already exists, and by then the source is standing at `sourceStatus` once more. The reopen runs exactly when a creation would be allowed through. Whether a replacement follows immediately is decided by the trigger's own `when:`, not by the reopen — which only publishes the transition. Return the source to the status the trigger qualifies on and the reissue is immediate; return it to an earlier one (a draft, for correction) and it waits for a person to move it forward again. From cf730881fa1d56f8b3508e79879296c862e904dd Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 21 Aug 2026 16:24:36 +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/spec/glue.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/spec/glue.md b/docs/spec/glue.md index 305fe48..72f3c67 100644 --- a/docs/spec/glue.md +++ b/docs/spec/glue.md @@ -575,7 +575,7 @@ generates: Retiring the invoice returns the proforma to APPROVED. That is a real transition of the proforma, announced on the source's transition channel like any other, so the ordinary trigger re-fires, the guard steps over the retired invoice, and the replacement is minted. The reissue is the **ordinary path**, not a second creation route with semantics of its own — and nothing new declares what "retired" means: it is the same classification the guard reads, asked from the other end. ::: info Normative -`sourceStatusOnRetire` requires `sourceStatus`, of which it is the inverse, and must name a different status — a write that leaves the status where it stands is not a transition and announces nothing. It must be rejected on an appending rule, which keeps no guard and therefore frees no slot, and wherever the target's retirement cannot be recognised where the rule is declared: a target with no status relation, a nomenclature with no `cancelled` or `void` classification, or a target belonging to another model. When a target of this rule becomes retired and the source still stands at `sourceStatus`, the source must be moved to `sourceStatusOnRetire` and that move announced on the source's transition channel; only the status may be written, and the retired target must be left exactly as it is. When the source stands anywhere else, nothing happens. The move is a transition, so where the source declares a lifecycle the edge from `sourceStatus` to it must be declared. A file that declares no `sourceStatusOnRetire` regenerates unchanged. +`sourceStatusOnRetire` requires an `event` to re-fire and `sourceStatus`, of which it is the inverse, and must name a different status — a write that leaves the status where it stands is not a transition and announces nothing. It must be rejected on a rule triggered only by a button, which carries no guard, so nothing blocks a replacement and the button is already the reissue; on an appending rule, which likewise frees no slot; and wherever the target's retirement cannot be recognised where the rule is declared: a target with no status relation, a nomenclature with no `cancelled` or `void` classification, or a target belonging to another model. When a target of this rule becomes retired and the source still stands at `sourceStatus`, the source must be moved to `sourceStatusOnRetire` and that move announced on the source's transition channel; only the status may be written, and the retired target must be left exactly as it is. When the source stands anywhere else, nothing happens. The move is a transition, so where the source declares a lifecycle the edge from `sourceStatus` to it must be declared. A file that declares no `sourceStatusOnRetire` regenerates unchanged. ::: Two conditions make the reopen idempotent without a marker of its own. The source must still stand at `sourceStatus`, so a source that has travelled further down its own lifecycle is not dragged back. And no target of that source may still count — the at-most-once guard asked from this end. The second is the one that closes redelivery: delivery is at-least-once, so a retirement can arrive again after the replacement already exists, and by then the source is standing at `sourceStatus` once more. The reopen runs exactly when a creation would be allowed through.