diff --git a/docs/reference.md b/docs/reference.md index 48d8eae..67ab9c1 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -45,6 +45,7 @@ The quick lookup surface: one line and a minimal snippet per construct. For rule | [`settlements`](/spec/glue#settlements-payment-allocation) | auto-allocation of payments across open invoices | | [`expansions`](/spec/glue#expansions-child-rows-from-a-date-span) | generated child rows per day / week / month | | [`generates`](/spec/glue#generates-create-from) | one-click document-from-document cloning | +| [`generates.event`](/spec/glue#event-driven-creation-event) | mint the document on a source event instead of a click, at most once | | [`transitions`](/spec/glue#transitions-guarded-status-flips) | guarded on-demand status flips (void / cancel / reopen) | | [`postings`](/spec/glue#postings-source-document-to-ledger) | declarative source-document to balanced-document posting - on a status transition, or on create for a lifecycle-less source | | [`aggregates`](/spec/glue#aggregates-keyed-cross-entity-totals) | keyed cross-entity totals materialised into their own entity | @@ -137,6 +138,12 @@ reports: ```yaml generates: - { name: invoice-from-order, from: Order, to: Invoice, map: { Customer: Customer }, sourceStatus: 3 } + # ...or minted with no click at all, at most once per source: + - name: delivery-from-order + from: Order + to: Delivery + event: { onTransition: Order, when: "Status == CONFIRMED" } + map: { Order: id } transitions: - { name: VoidInvoice, forEntity: Invoice, from: [ISSUED, SENT], setStatus: VOIDED, when: "Paid == 0", label: Void, icon: ban } @@ -163,5 +170,5 @@ The following are parsed (or reserved) but not yet materialised by a generator; - Reserved `function` values for upcoming presentations (`Board`, `Gantt`, `Timeline`). - **Cross-model status names and stage scopes** — a nomenclature owned by another model is seeded there, so its stages and names cannot be resolved from the referencing file; such references are rejected with the numeric-id fallback named. - **`manyToMany`** — parsed but never materialised; the supported shape is the [explicit intermediate entity](/spec/relations#many-to-many). -- Event-driven document generation (produce a document on an event), a declarative state machine, and shadow audit-history entities (audit *columns* via `audit: true` ship today). +- A declarative state machine, and shadow audit-history entities (audit *columns* via `audit: true` ship today). - Arbitrary resolver-path task assignment beyond `assignee: personal`. diff --git a/docs/spec/glue.md b/docs/spec/glue.md index 0de97c5..4bc6288 100644 --- a/docs/spec/glue.md +++ b/docs/spec/glue.md @@ -291,6 +291,40 @@ generates: Adds a button on the source view; the clone saves through the target's own layer, so numbering, status init and calculated fields fire. An optional `sourceStatus` flips the source record's status once the target exists. +### Event-driven creation — `event:` + +A create-from may declare an `event:` instead of relying on the button — the follow-up document is minted the moment the source reaches a state, with nobody clicking. The canonical case is a document that arrives from the outside and is completed by an earlier step: a fine ingested by a webhook, whose responsible person is identified by a transition, must produce a declaration document from the fine and that person. + +```yaml +generates: + - name: declaration-from-fine + from: Fine + to: Declaration + event: { onTransition: Fine, when: "Status == IDENTIFIED" } # or { onCreate: Fine } + map: + Fine: id # REQUIRED with an event — the back-reference, i.e. the guard + Vehicle: Vehicle + defaults: { declaredAt: now } + items: # a whole document — header AND items + - { name: "Fine {number}", amount: Amount } +``` + +::: info Normative +Exactly one trigger must be declared: `onTransition` (a status write — a `when: " == "` guard is mandatory, the status named or numbered) or `onCreate` (the source's insert — the guard is optional, for a source with no status lifecycle). The entity named there must be the entity `from:` declares; the owning model is never repeated (`fromUses:` declares it). The guard must be evaluated against the source as re-read at delivery, not against the event payload, which is as-of the event and lacks anything a later step wrote. +::: + +::: info Normative +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. +::: + +::: 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. +::: + +`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. + +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. + ## transitions — guarded status flips A per-record button that flips an entity's `function: EntityStatus` relation on demand — void, cancel, close, reopen — guarded by allowed source statuses and an optional condition. A flip from any other status (or a failing guard) is rejected; a successful flip publishes a `-transitioned` event that `postings` and integrations can observe.