diff --git a/docs/reference.md b/docs/reference.md index 48d8eae..7e20903 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -35,12 +35,13 @@ The quick lookup surface: one line and a minimal snippet per construct. For rule | [`reports`](/spec/presentation#reports) | aggregations, charts, dashboard KPI tiles, balance reports | | [`scope`](/spec/presentation#lifecycle-scope) | which lifecycle rows an aggregating report counts | | [`widgets`](/spec/presentation#widgets-custom-dashboard-tiles) | custom KPI / embedded-page dashboard tiles | -| [`notifications`](/spec/glue#notifications) | email on create / update / delete | +| [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 | +| [`notifications`](/spec/glue#notifications) | email on an event of the axis | | [`notify.forEach`](/spec/glue#one-message-per-related-row-foreach) | fan the block out over a related collection: one message per row | | [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 | | [`schedules`](/spec/glue#schedules) | cron: notify or generate records per matching row | | [`integrations`](/spec/glue#integrations-outbound-http) | outbound HTTP on a data change | -| [`inbound`](/spec/glue#inbound-webhooks) | a webhook that creates records | +| [`inbound`](/spec/glue#inbound-arrivals-from-outside) | records arriving from outside: a webhook, a queue/topic message, a dropped file | | [`rollups`](/spec/glue#rollups-denormalised-parent-totals) | counts, sums, balance + status maintenance | | [`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 | diff --git a/docs/spec/glue.md b/docs/spec/glue.md index 0de97c5..03a601a 100644 --- a/docs/spec/glue.md +++ b/docs/spec/glue.md @@ -1,6 +1,6 @@ --- title: Declarative glue -description: notifications, schedules, integrations, inbound webhooks, roll-ups, keyed aggregates, settlements, expansions, generates, transitions, postings and event-driven row posting - declared in the intent, generated as integration code, never hand-written. +description: notifications, schedules, integrations, inbound arrivals (webhook, message, file), roll-ups, keyed aggregates, settlements, expansions, generates, transitions, postings and event-driven row posting - declared in the intent, generated as integration code, never hand-written. --- # Declarative glue @@ -11,7 +11,7 @@ Beyond the model artefacts, the intent declares **glue**: the common integration Three axes: -- **Event** — an entity `onCreate` / `onUpdate` / `onDelete` (with an optional `when:` guard), a schedule (`cron`), or an inbound webhook. +- **Event** — an entity `onCreate` / `onUpdate` / `onDelete` (with an optional `when:` guard), a process step reached or completed, a schedule (`cron`), or an inbound arrival (a webhook, a message, a dropped file). - **Action** — notify (email), call out (HTTP), ingest into an entity, recompute a counter, start a process, create a document. - **Binding** — the **resolver-path grammar** (`customer.name`, `member.email`): one-hop relation walks off the triggering entity, validated at parse time. @@ -23,14 +23,55 @@ Unlike the model generators, each glue activity is generated as an annotated **i An event-binding key is `event:`, never `on:` — YAML 1.1 resolves a bare `on` (also `off` / `yes` / `no`) to a boolean, so an `on:` key is silently swallowed. An action key is `do:`. ::: +## The event axis — lifecycle and process-step events + +A glue entry that reacts (`notifications`, `integrations`) declares **exactly one** `event:`, on one of two axes: + +| Axis | Shape | Fires when | +| --- | --- | --- | +| entity lifecycle | `{ onCreate\|onUpdate\|onDelete: }` | a record is created / updated / deleted | +| process step | `{ onStepReached\|onStepCompleted: { process, step } }` | a running process arrives at that step / has just finished it | + +```yaml +processes: + - name: LoanApproval + trigger: { onCreate: Loan } + steps: + - { name: librarianReview, kind: userTask, args: { assignee: librarian, next: activate } } + - { name: activate, kind: serviceTask, args: { setField: status, value: ACTIVE } } + +notifications: + # "when the review task becomes available, tell the member's branch manager" + - name: reviewPending + event: { onStepReached: { process: LoanApproval, step: librarianReview } } + to: member.branch.managerEmail + subject: "Loan {id} is waiting for review" + body: "A librarian must approve it." + +integrations: + # "when the loan has been activated, tell the partner system" + - name: pushActivation + event: { onStepCompleted: { process: LoanApproval, step: activate } } + method: POST + url: "@config:PARTNER_URL" +``` + +A step event is an event **about the record the process runs on** — the process's `trigger` entity — so every action parameter resolves exactly as it does for a lifecycle event: the same recipient rule, the same `{placeholder}` interpolation, the same `when:` guard, the same forwarded body. No action needs to know which axis fired it. + +::: tip What is rejected at parse +An undeclared process or step; a step that occupies no observable moment (only a `userTask` or a `serviceTask` does — not a decision, a wait or the end); a process with no `trigger`, since there is then no record the event could be about. +::: + +`onStepReached` fires before the step's own work begins — the moment a task becomes available in the inbox. `onStepCompleted` fires after the step finished **and** after its writes are persisted (a task's edits, a `setField`), so an observer never sees a stale record. Any number of entries may observe the same moment: the record is published once. A branch that jumps back into an observed step re-enters it, so its `onStepReached` observers fire again. + ## notifications -Email on an entity lifecycle event. +Email on an event of the axis above. ```yaml notifications: - name: orderUpdated - event: { onUpdate: Order } # exactly one of onCreate / onUpdate / onDelete + event: { onUpdate: Order } # one event of the event axis to: ops@example.com # a literal, a direct field, or a one-hop relation.field subject: "Order {id} for {customer.name}, total {total}" body: "The order changed." @@ -185,16 +226,28 @@ integrations: The `@config:KEY` sugar resolves to a configuration lookup, so endpoints and secrets stay out of the source. -## inbound — webhooks +## inbound — arrivals from outside -Another system tells us — a webhook that ingests a JSON payload into an entity. +Another system tells us — a JSON record shaped like the entity, ingested into it. What differs between the three forms is only **where the record arrives**; the action is the same `create`. ```yaml inbound: - - { name: leadHook, path: /webhooks/lead, create: Lead } + # HTTP — an endpoint the other system posts to + - { name: leadHook, path: /webhooks/lead, create: Lead } + # message — every record arriving on a queue (point-to-point) or a topic (broadcast) + - { name: leadQueue, source: { queue: leads.inbound }, create: Lead } + - { name: leadFeed, source: { topic: crm.leads }, create: Lead } + # file — every file dropped into a folder, polled on the cron + - { name: leadDrop, source: { folder: /data/inbox/leads, cron: "0 */5 * * * ?" }, create: Lead } ``` -Generates an endpoint that deserialises the request body into the entity and saves it. The v1 action is `create` (ingest). +An entry declares **exactly one arrival**: a `path`, or a `source` naming exactly one of `queue` / `topic` / `folder` — both, neither, or two channels is an error. Whichever it is, the record is saved through the entity's **ordinary write path**, so validations, translations and the create event behave exactly as for any other write: the arrival is a transport, not a second data path. + +::: warning A folder is polled, not watched +That is why a `folder` source requires its `cron` (and why a `cron` is an error on the others). A file holds one record or an array of them, is not read while it is still being written, and leaves the drop folder once read — ingested and rejected files kept apart — so nothing is ingested twice and a rejection stays inspectable. +::: + +Conversation-shaped transports — acknowledgements, retries with backoff, certificates — stay [beyond the boundary](/spec/#the-scope-boundary): they have state and failure semantics no one-line declaration should pretend to carry. ## rollups — denormalised parent totals diff --git a/docs/spec/index.md b/docs/spec/index.md index 7160085..21bd62e 100644 --- a/docs/spec/index.md +++ b/docs/spec/index.md @@ -27,7 +27,7 @@ The altitude table is also a statement of what the format deliberately does **no | Beyond the boundary | Why it is not intent | The hand-off | | --- | --- | --- | -| **Protocol adaptation** — conversation-shaped integration with an external system: certificates, acknowledgments, retries and their backoff, batch and file transports | [`integrations`](/spec/glue#integrations-outbound-http) and [`inbound`](/spec/glue#inbound-webhooks) are one-line call-outs by design; a protocol has state and failure semantics no declaration should pretend to carry | an integration route in the platform's integration technology, feeding the entity's ordinary write path | +| **Protocol adaptation** — conversation-shaped integration with an external system: certificates, acknowledgments, retries and their backoff, batch and file transports | [`integrations`](/spec/glue#integrations-outbound-http) and [`inbound`](/spec/glue#inbound-arrivals-from-outside) are one-line call-outs by design; a protocol has state and failure semantics no declaration should pretend to carry | an integration route in the platform's integration technology, feeding the entity's ordinary write path | | **Algorithms** — checksums, fuzzy matching, scoring, policy-driven tie-breaking | the format already draws this line for [`pattern`](/spec/entities#fields): a format check, not a semantic one | a [calculated-field call-out](/spec/entities#calculated-fields) or a [service-task `delegate`](/spec/processes#service-tasks), hand-written in the project's custom folder | | **Statutory and designed form** — the exact mandated layout of a printed document | the [print template](/spec/presentation#printable-documents) is written create-if-absent *by design*: a formatted, audited artefact adapted by hand | the authored template itself |