From 7ea9110ac724e013294733eba6f80c51cd726781 Mon Sep 17 00:00:00 2001 From: delchev Date: Wed, 19 Aug 2026 15:13:33 +0300 Subject: [PATCH] docs(glue): a retired target stops blocking its source The at-most-once guard of an event-driven create-from asks existence only, so a voided target satisfies it forever. Documents the state-aware guard: a target classified `cancelled` or `void` is retired and stops blocking; draft and live still block; the retired row is kept. --- docs/spec/data.md | 2 +- docs/spec/glue.md | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/spec/data.md b/docs/spec/data.md index 0d582e6..1a4a327 100644 --- a/docs/spec/data.md +++ b/docs/spec/data.md @@ -52,7 +52,7 @@ A seed row of a **status nomenclature** (the target of a `function: EntityStatus | `cancelled` | Withdrawn before it ever became live. | | `void` | Deliberately retired while keeping its number — out of circulation by design. | -The classification exists because a status **id is data, but its meaning is not**: without it, "the rows that count" can only be expressed as a predicate over positional ids, repeated in every report and guard that needs it. With it, the meaning is declared once, where the nomenclature is defined, and consumers resolve it — chiefly a [report's `scope`](/spec/presentation#lifecycle-scope). +The classification exists because a status **id is data, but its meaning is not**: without it, "the rows that count" can only be expressed as a predicate over positional ids, repeated in every report and guard that needs it. With it, the meaning is declared once, where the nomenclature is defined, and consumers resolve it: a [report's `scope`](/spec/presentation#lifecycle-scope) decides what an aggregate counts, and an [event-driven create-from](/spec/glue#a-retired-target-stops-blocking-its-source) decides whether an already generated document still blocks its source. ::: info Normative `stage` is **metadata, not data**: it MUST NOT be emitted as a column of the seeded table. A row carrying `stage` MUST also carry the entity's primary key (the stage classifies that id). A value outside the vocabulary is an authoring error. An entity that declares its own `stage` property cannot be classified this way — the collision MUST be reported rather than resolved by guessing. diff --git a/docs/spec/glue.md b/docs/spec/glue.md index a2290ec..58fdbc5 100644 --- a/docs/spec/glue.md +++ b/docs/spec/glue.md @@ -547,6 +547,29 @@ Exactly one trigger must be declared: `onTransition` (a status write — a `when An event-driven create-from is **at-most-once**: `map` must copy the source's primary key onto a to-one relation of the target back to the source, and the generated creation must return the already existing target instead of creating a second one. A file declaring an `event` without that back-reference must be rejected — a redelivery would otherwise mint a duplicate document. A create-from with no `event` carries no such guard: producing several targets from one source by clicking twice is a legitimate manual act. ::: +#### A retired target stops blocking its source + +"At most once" is a claim about targets that still count, not about rows that exist. A voided document keeps existing and keeps back-referencing its source, so a guard that asks existence alone spends the source's one shot at the first creation and never gives it back — and "void and reissue", a document retired **while keeping its number** and a fresh one raised, becomes inexpressible. + +Nothing on the create-from says which statuses retire a document: that is what the target's [`stage` classification](/spec/data#stage-what-a-status-means-to-the-lifecycle) already means, and the guard reads it. + +```yaml +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 +``` + +::: info Normative +The at-most-once guard must be satisfied only by an existing target that is **not retired** — one whose `function: EntityStatus` value is a seed row classified `cancelled` or `void`. A retired target must be left as it is: superseding creates a new record, and never edits, deletes or re-points the retired one. When the target carries no status relation, or its nomenclature carries no `stage` classification, the guard remains satisfied by existence alone, and a generator must report the unclassified case — that is where the guard reads as state-aware and is not. A file that adopts no classification regenerates unchanged. +::: + +A `draft` or `live` target still blocks, so idempotence under redelivery is untouched: a redelivered event finds the document it created. Note what this is not: a cardinality. Removing the guard would also mint a document on every later qualifying event; the question here is not how many targets a source may have, but which existing one still counts. + ::: info Normative Declaring an `event` drops the button unless `button: true` is declared as well; `button: false` without an `event` must be rejected (the action would have no trigger at all). When both triggers are declared they must share one creation path, and therefore one at-most-once guard. :::