Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.<field>}` 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 |
Expand All @@ -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 |
Expand Down
39 changes: 39 additions & 0 deletions docs/spec/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion docs/spec/glue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Entity> }` | a record is created / updated / deleted |
| entity enrichment | `{ onPhase: <Entity>, phase: <name> }` | 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
Expand Down