From 1f505d23ea7039ff1518167f86f3a224149f335e Mon Sep 17 00:00:00 2001 From: delchev Date: Sat, 15 Aug 2026 14:13:56 +0300 Subject: [PATCH] spec: the glue event axis - process-step events and inbound message/file arrivals A reacting glue entry (notifications, integrations) could only bind to an entity lifecycle event, and `inbound` could only be a webhook. Two things every real application needs were therefore inexpressible: reacting to a moment IN a process ("when this task becomes available, tell the assignee's manager"), and ingesting a record that arrives on a queue/topic or as a dropped file. Both are added without a second action vocabulary: - `onStepReached` / `onStepCompleted: { process, step }` join the event axis. A step event is an event about the record the process runs on - its trigger entity - so every recipient path, placeholder, guard and forwarded body reads exactly as it does for a lifecycle event. Normative: the process and step must be declared, the step must be one that occupies an observable moment (a task), the process must have a trigger, a completed step must be observed after its writes are persisted, and one moment publishes once however many entries observe it. - `inbound` declares exactly one arrival: `path` (HTTP), `source: { queue | topic }`, or `source: { folder, cron }`. All three save through the entity's ordinary write path - the arrival is a transport, not a second data path. A folder is polled, not watched, and a read file leaves the drop folder so nothing is ingested twice. --- versions/1.2.md | 64 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/versions/1.2.md b/versions/1.2.md index f321557..0e8fea2 100644 --- a/versions/1.2.md +++ b/versions/1.2.md @@ -65,7 +65,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`](#integrations--outbound-http) and [`inbound`](#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`](#integrations--outbound-http) and [`inbound`](#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`](#fields): a format check, not a semantic one | a [calculated-field call-out](#calculated-fields) or a [service-task `delegate`](#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](#printable-documents) is written create-if-absent *by design*: a formatted, audited artefact adapted by hand | the authored template itself | @@ -1068,7 +1068,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. @@ -1079,14 +1079,50 @@ Unlike the model generators, each glue activity is generated as an annotated **i > **Event-key gotcha.** > 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 events 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 of that entity 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" +``` + +> **Normative.** +> A step event is an event **about the record the process runs on** — the process's `trigger` entity. Every action parameter therefore 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. A conforming generator MUST reject a step event whose process is not declared, whose step is not declared in that process, whose step is not a `userTask` or a `serviceTask` (no other step kind occupies an observable moment), or whose process declares no `trigger` (there is then no record for the event to be about). `onStepReached` MUST be observable before the step's own work begins, and `onStepCompleted` after it has finished and after any writes that step performs (a task's edits, a `setField`) are persisted — an observer of a completed step never sees a stale record. Any number of entries may observe the same step moment; the record is published once. A `then`/`else` jump 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." @@ -1215,16 +1251,23 @@ 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). +> **Normative.** +> An `inbound` entry declares **exactly one arrival**: a `path` (HTTP) or a `source` naming exactly one of `queue` / `topic` / `folder`; declaring both, neither, or two channels is an error. The ingested record MUST be 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. A `folder` source is **polled**, not watched, so its `cron` is required (and is an error on the other sources); a file MUST hold either one record or an array of them, MUST NOT be read while it is still being written, and MUST leave the drop folder once read — successfully ingested and rejected files kept apart — so that no file is ever ingested twice and a rejected one stays inspectable. Conversation-shaped transports (acknowledgements, retries with backoff, certificates) are out of scope by design; see [the scope boundary](#the-scope-boundary). ### rollups — denormalised parent totals @@ -1641,12 +1684,13 @@ One line per construct, linking into the chapters above. | [`reports`](#reports) | aggregations, charts, dashboard KPI tiles, balance reports | | [`scope`](#lifecycle-scope) | which lifecycle rows an aggregating report counts | | [`widgets`](#widgets--custom-dashboard-tiles) | custom KPI / embedded-page dashboard tiles | -| [`notifications`](#notifications) | email on create / update / delete | +| [the event axis](#the-event-axis--lifecycle-events-and-process-step-events) | what a reacting glue entry binds to: an entity lifecycle event or a process step reached / completed | +| [`notifications`](#notifications) | email on an event of the axis | | [the notify block / `attach: print`](#the-notify-block--and-attach-print-sending-the-document-itself) | send a message about a record - with the record's own document attached - from a process step, a transition or a schedule | | [`notify.forEach`](#one-message-per-related-row-foreach) | fan the block out over a related collection: one message per row, every path resolved against the row | | [`schedules`](#schedules) | cron: notify or generate records per matching row | | [`integrations`](#integrations--outbound-http) | outbound HTTP on a data change | -| [`inbound`](#inbound--webhooks) | a webhook that creates records | +| [`inbound`](#inbound--arrivals-from-outside) | records arriving from outside: a webhook, a queue/topic message, a dropped file | | [`rollups`](#rollups--denormalised-parent-totals) | counts, sums, balance + status maintenance, onto a local or cross-model parent | | [`settlements`](#settlements--payment-allocation) | auto-allocation of payments across open invoices | | [`expansions`](#expansions--child-rows-from-a-date-span) | generated child rows per day / week / month |