From bce7053dc9de79b96e64fdbfe8ab6a904a668a33 Mon Sep 17 00:00:00 2001 From: delchev Date: Mon, 24 Aug 2026 18:00:33 +0300 Subject: [PATCH] docs: phases - a moment an enrichment announces A value a listener computes after the insert must be written back without raising an update event, since an enrichment is not a user's edit. So it announced nothing at all, and a declarative consumer of that value had no moment to bind: bound to the create event it runs as a sibling of the enriching listener, with nothing ordering the two, and can produce a perfectly balanced journal entry for a null amount with every step green. Document the entity-level `phases:` declaration and the `onPhase` event axis: the generated data-access layer applies the enriched values and announces the phase in ONE write, so no consumer can observe one without the other. - spec/entities.md: the `phases` section. - spec/glue.md: the enrichment row in the event-axis table. - reference.md: the index rows. --- docs/reference.md | 5 +++-- docs/spec/entities.md | 39 +++++++++++++++++++++++++++++++++++++++ docs/spec/glue.md | 3 ++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 913c4fb..aaa037c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -23,6 +23,7 @@ The quick lookup surface: one line and a minimal snippet per construct. For rule | [`checks: kind: guard`](/spec/entities#kind-guard-a-precondition-over-an-aggregate) | a precondition over an aggregate: block, mark for a task, or reject | | [`immutableWhen` / `immutable`](/spec/entities#immutablewhen-immutable-user-write-immutability) | reject user writes in a status / append-only | | [`lifecycle`](/spec/entities#lifecycle-the-legal-status-graph) | the whole legal status graph, enforced on every status write | +| [`phases`](/spec/entities#phases-a-moment-an-enrichment-announces) | the enrichment moments an entity announces, so a silent write has a channel a consumer can bind | | [`locksWithMaster`](/spec/entities#lockswithmaster-a-child-collection-that-outlives-its-masters-lock) | a child collection that stays writable while its master is locked | | [`history`](/spec/entities#history-the-change-trail) | a shadow, append-only trail of every write: property, old and new value, who, when, user or system | | [`hierarchy` / `leafOnly`](/spec/entities#hierarchy-leafonly-tree-entities) | tree entities, leaf-only references | @@ -44,7 +45,7 @@ The quick lookup surface: one line and a minimal snippet per construct. For rule | [`notify.forEach`](/spec/glue#one-message-per-related-row-foreach) | fan the block out over a related collection: one message per row, every bare path resolved against the row | | [`attach: recordPrint`](/spec/glue#one-document-many-recipients-attach-recordprint) | in a fan-out: attach the ANCHOR record's document, rendered once, to every recipient (`{record.}` addresses that record) | | [`payload`](/spec/glue#payload-the-declared-envelope) | the declared envelope an outward-facing message carries (integrations and outbound alike), instead of the record as stored | -| [the event axis](/spec/glue#the-event-axis-lifecycle-and-process-step-events) | what a reacting glue entry binds to: an entity lifecycle event, or a process step reached / completed | +| [the event axis](/spec/glue#the-event-axis-lifecycle-and-process-step-events) | what a reacting glue entry binds to: an entity lifecycle event, a declared enrichment phase, or a process step reached / completed | | [`notifications`](/spec/glue#notifications) | email on an event of the axis | | [notify link placeholders](/spec/glue#links-back-to-the-application-recordurl-inboxurl-appurl) | `{recordUrl}` / `{inboxUrl}` / `{appUrl}` - a message that carries the way back into the application | | [the notify block / `attach: print`](/spec/glue#the-notify-block-and-attach-print) | send a message about a record - with the record's own document attached - from a process step, a transition or a schedule | @@ -59,7 +60,7 @@ The quick lookup surface: one line and a minimal snippet per construct. For rule | [`generates.event`](/spec/glue#event-driven-creation-event) | mint the document on a source event instead of a click, at most once | | [`resolves`](/spec/glue#resolves-fill-a-relation-from-a-register-valid-on-a-date) | fill a to-one from the register row valid on the record's date | | [`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 | +| [`postings`](/spec/glue#postings-source-document-to-ledger) | declarative source-document to balanced-document posting - on a status transition, on create for a lifecycle-less source, or on a declared enrichment phase | | [`aggregates`](/spec/glue#aggregates-keyed-cross-entity-totals) | keyed cross-entity totals materialised into their own entity | | [`posts`](/spec/glue#posts-derived-rows-on-an-event) | derived ledger rows emitted idempotently on an event | | [`personal` / `partner`](/spec/surfaces#personal-and-partner-surfaces) | per-user and per-partner row-scoped surfaces | diff --git a/docs/spec/entities.md b/docs/spec/entities.md index 8e8b231..62f9c43 100644 --- a/docs/spec/entities.md +++ b/docs/spec/entities.md @@ -420,6 +420,45 @@ With a lifecycle declared, `transitions` become **presentation over its edges**: It composes with the [`stage:` classification](/spec/data#stage-what-a-status-means-to-the-lifecycle): a stage says what a status *means* (draft, live, cancelled, void) and scopes reports by it; the lifecycle says how a record may *move* between statuses. +## phases — a moment an enrichment announces + +Not everything a record needs is known when its row is inserted. An inventory ledger values stock at a moving average: the movement row is created when a goods document posts, and a listener on that create event reads the pool, computes the movement's cost, and writes it back. + +That write-back must not raise an update event. An enrichment is not a user's edit, and publishing it as one re-fires every consumer that reacts to an edit. So the enrichment is silent — and a declarative consumer of the enriched value has no moment to bind. Bound to the create event it runs as a **sibling** of the enriching listener, with nothing ordering the two: it may read the row before the value is there and produce a perfectly balanced journal entry for a null amount, with the parse, the generation and the deployment all green. + +`phases:` declares the moments an entity announces, giving that silent write a channel of its own: + +```yaml +entities: + - name: StockMovement + phases: [costed] + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: costValue, type: decimal, precision: 18, scale: 2 } +``` + +For each declared phase the generated data-access layer exposes an operation that **applies the enriched values and announces the phase in one write**. The enriching listener calls it instead of a plain silent write, so a consumer can never observe the announcement without the values, nor the values without an announcement. + +A glue consumer then binds the phase instead of the insert: + +```yaml +postings: + - name: cogsPosting + event: { onPhase: StockMovement, phase: costed } + creates: JournalEntry + backReference: StockMovement + rule: { entity: PostingRule, match: { documentType: "Goods Issue" } } + items: + - { Account: rule(costOfSalesAccount), debit: "CostValue" } + - { Account: rule(inventoryAccount), credit: "CostValue" } +``` + +A phase-bound consumer receives the record exactly as a lifecycle-bound one does: the same payload, the same recipient paths, the same placeholders, the same optional `when:` guard. Only the moment differs — and the guard is optional here, because a phase already names one moment where a status transition is any status write. + +**Rules.** A phase name is a lower-camel identifier, unique within its entity, and may not be named after a channel the platform itself publishes (an update, a delete, a status transition) — announcing one would re-fire that channel's consumers. An `onPhase` binding must name a declared phase of the bound entity; an undeclared one is rejected, since it would bind a channel nothing publishes to and the consumer would simply never fire. A `phase:` key on a binding of another axis is rejected rather than ignored. When the bound entity is owned by another model its phases are declared there, so the name is not resolvable from the consumer's side — the same limit a cross-model status nomenclature has. + +Declare a phase only for what a listener adds **after** the insert. A calculated expression, a calculated action, a stamped document number and a document's own totals are already in the row the create event carries. + ## locksWithMaster — a child collection that outlives its master's lock An entity's immutability covers that entity **and the collections composed into it**. For some children that is wrong — a master that freezes its content says nothing about a collection recording what happens to the document afterwards: diff --git a/docs/spec/glue.md b/docs/spec/glue.md index a2290ec..1756e2f 100644 --- a/docs/spec/glue.md +++ b/docs/spec/glue.md @@ -25,11 +25,12 @@ An event-binding key is `event:`, never `on:` — YAML 1.1 resolves a bare `on` ## The event axis — lifecycle and process-step events -A glue entry that reacts (`notifications`, `integrations`) declares **exactly one** `event:`, on one of two axes: +A glue entry that reacts (`notifications`, `integrations`) declares **exactly one** `event:`, on one of three axes: | Axis | Shape | Fires when | | --- | --- | --- | | entity lifecycle | `{ onCreate\|onUpdate\|onDelete: }` | a record is created / updated / deleted | +| entity enrichment | `{ onPhase: , phase: }` | a listener announces a declared [phase](/spec/entities#phases-a-moment-an-enrichment-announces) of the record | | process step | `{ onStepReached\|onStepCompleted: { process, step } }` | a running process arrives at that step / has just finished it | ```yaml