Skip to content

Declarative glue ​

Beyond the model artefacts, the intent declares glue: the common integrations and background activities that would otherwise be hand-written code. The abstraction is one line:

glue = on <event> do <action>, with action parameters bound by resolver paths.

Three axes:

  • 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.

Glue is generated integration code ​

Unlike the model generators, each glue activity is generated as an annotated integration class against the platform's SDK, placed in the generated events folder. The annotated class is the artefact: the runtime synchronises and runs it, it is deterministic and regenerated with the app, and it is replaceable by a hand-written override.

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 and process-step events ​

A glue entry that reacts (notifications, integrations) declares exactly one event:, on one of three axes:

AxisShapeFires 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 of the record
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.

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.

An event-driven create-from binds to the same axis, narrowed by one rule of its own: the process must run on its from: entity, since the step event is about the record its process runs on and that record is the one the create-from reads.

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 event of the axis above.

yaml
notifications:
  - name: orderUpdated
    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."

to and every {placeholder} resolve a literal, a direct field, or a one-hop relation.field of a to-one relation. when: supports a single field ==|!= literal guard. Multi-hop paths (a.b.c) are rejected with a clear message. Everything below about the shared notify block — attach: print, forEach, the failure semantics — applies to a notifications[] entry too.

The notify block — and attach: print ​

to / subject / body (+ channel) is one reusable notify block, not a shape peculiar to notifications. The same block is authored at every place an intent can act on a record:

WhereThe record it is aboutIt sends
notifications[]the event recordon create / update / delete
schedules[].notifyeach matched rowon every cron tick, per row
transitions[].notifythe transitioned recordafter the status flip commits
a serviceTask's args.notifythe process's trigger recordwhen the flow reaches that step

Add attach: print and the message carries the record's own document — the record rendered through its print template and attached. This is the declarative form of the most common outbound action a business document has: the invoice to its customer, the payslip to its employee, a reminder that carries the invoice it is about.

The render language: language: fixes it; languageFrom: <relation>.<field> reads it per record from a one-hop to-one path of the entity the message is about (mutually exclusive with language:). Absent both — or when the resolved value is blank — the render falls back to the first entry of the application's configured language set at send time. It selects the print template and the language the attached document's own data is read in, both: see the language of a render. It does not govern the message's subject and body — an attachment is a contractual document and belongs in the document's language, while the covering message belongs in the recipient's.

yaml
    notify:
      to: Customer.email                 # literal / direct field / one-hop relation.field
      subject: "Invoice {number}"        # {field} and {relation.field} interpolation
      body: "Dear {Customer.name}, please find invoice {number} attached."
      attach: print                      # render THIS record's print template and attach it
      language: bg                       # optional FIXED print-template language
      # or per record: languageFrom: Customer.locale  (a one-hop relation.field holding the code)

attach is print — the record the block is about — or, inside a fan-out, recordPrint. With print the entity must be a document (a header with a line-items child) — that is the shape a print template exists for. Attaching the print of a plain entity is rejected up front rather than silently sending a message without the document it promised. The attachment comes from the record's own data through the same path the interactive print takes, so a document mailed and a document printed are the same document.

Failure semantics, per call site

A recipient that resolves to no address is a no-op — recorded and skipped, so a record with nobody to notify never stalls a flow. A transitions[].notify can never fail its transition: the status flip is the contract and is already applied when the message is attempted, so a delivery failure is recorded and the transition still succeeds. A sending process step, whose whole purpose is the message, fails instead — so the platform's own retry applies.

A notification that cannot be acted on is a notification that gets ignored. "You have an approval waiting" is only useful if it carries the way back to the record, so subject and body accept three reserved link placeholders alongside the field ones:

PlaceholderResolves to
{recordUrl}the record the message is about, opened in the application
{inboxUrl}the recipient's task inbox
{appUrl}the application's external base URL — the origin only
yaml
    notify:
      to: Approver.email
      subject: "Approval needed: invoice {number}"
      body: "Open it here: {recordUrl}\nEverything waiting on you: {inboxUrl}"

All three names are reserved at every notify call site, so an entity field of the same name never shadows them. {recordUrl} and {inboxUrl} are resolved to a complete address — an intent never spells a route. {appUrl} yields the origin alone; reach for it only for an address the other two cannot express, and append the rest as authored text.

Why the intent never writes the path

The routes belong to whatever renders the application. An intent that named one would encode a layout it does not own — correct only until that layout changes, and silently wrong afterwards. {recordUrl} states the destination; the implementation states the address.

Inside a forEach fan-out {recordUrl} links the ROW, like every other bare path in the block — the row is what that message is about.

Some sends are per-row rather than per-record — a payroll run mails every payslip to its own employee. forEach: names a related entity and the block sends one message per row of it; every path (recipient, placeholders, attach) then resolves against the row.

yaml
    notify:
      forEach: Payslip                              # rows whose to-one FK points at this record
      to: Employee.email                            # the ROW's employee
      subject: "Payslip {PayrollRun.month}"         # one hop from the ROW
      body: "Dear {Employee.name}, net pay {net}."  # the ROW's own field
      attach: print                                 # the ROW's own document

The named entity must have exactly one to-one relation back to the record: none means the rows are unrelated, several make the intended set ambiguous, and both are rejected rather than mailing a silently wrong set of recipients.

A fan-out is authored on a transitions[].notify or a serviceTask's args.notify. A schedules[].notify already runs once per matched row and a notifications[] entry is about the event record, so a forEach on either is rejected rather than ignored — an accepted declaration that changes nothing sends a different message than the one that was written down.

One document, many recipients: attach: recordPrint ​

The mirror shape: the related rows are only the recipient list, and the document belongs to the record they hang off — a request for quotation mailed to each invited supplier, an agenda mailed to each participant. attach: print cannot express it (it renders the row, which is nobody's document); attach: recordPrint renders the fan-out's anchor record — the record the block is about — once, for everybody.

yaml
    notify:
      forEach: InvitedSupplier                      # the rows: the recipient list
      to: Supplier.email                            # the ROW's supplier - the rows ARE the recipients
      subject: "RFQ {record.number}"                # {record.<field>} = the ANCHOR RECORD's field
      body: "Dear {Supplier.name}, please quote by {record.deadline}."   # bare = the ROW
      attach: recordPrint                           # the RECORD's document, rendered once

recordPrint is only meaningful inside a fan-out and is rejected without one — outside a fan-out attach: print already renders that very record. It is the anchor that must be a document (the row need not be), and language / languageFrom then select the anchor's render language, read off the anchor: there is exactly one render for the whole fan-out, and the same result is attached to every message.

Which record a path reads is written down, never inferred

Inside a fan-out a bare path — the recipient, {field}, {Relation.field} — resolves against the ROW, and the reserved prefix record. is the only way to address the anchor: {record.<field>} names one field of it, and a longer path is rejected. The recipient can never be record-scoped: the rows are the recipients, so a record-scoped address would send the same message to the same address once per row. record. outside a fan-out is rejected too, since there every bare path already resolves against the record. Nothing in a rendered message would reveal that the wrong record had been read — so the scope is authored, not guessed.

A fan-out never fails its activity

It is fail-soft per row at every call site, including the ones that otherwise fail: a row with no recipient is skipped, a delivery failure is recorded, and the activity completes with a per-row summary. Retrying would resend to every recipient already served — a partial fan-out cannot be made idempotent, so the summary is the report.

A sending serviceTask stands alone: notify cannot be combined with another action (setField, setRelationField, call, delegate) on the same step — give the send its own step and route to it.

yaml
processes:
  - name: InvoiceIssue
    trigger: { onCreate: Invoice }
    steps:
      - { name: issue, kind: userTask, args: { assignee: issuer, setRelationField: Status, value: 3, next: mailIt } }
      - name: mailIt
        kind: serviceTask
        args:
          notify: { to: Customer.email, subject: "Invoice {number}", body: "Attached.", attach: print }
          next: end
      - { name: end, kind: end }

schedules ​

Cron reminders / cleanups — query an entity and act per matching row. Exactly one of notify or generate per row.

yaml
schedules:
  - name: staleOrders
    cron: "0 0 9 * * ?"
    entity: Order                                           # SOURCE - local by default (see model: below)
    where:
      - { field: orderDate, op: lt, value: CURRENT_DATE }   # eq / ne / gt / ge / lt / le / like
    notify:
      to: ops@example.com
      subject: "Stale order {id} for {customer.name}"
      body: "This order is stale."
      # the full notify block applies here: attach: print for the row's document, forEach to fan out

A where value relative to now — the staleness sweep ​

The archetypal schedule is a staleness sweep: rows still provisioning after 30 minutes, quotations unanswered for a week, carts abandoned for an hour. Everything about such a sweep is a schedule already — except how old is too old, which needs a moment relative to now. Write the moment token with one signed ISO-8601 duration:

yaml
schedules:
  - name: stuckProvisioning
    cron: "0 */5 * * * ?"
    entity: TenantApplication
    where:
      - { field: provisioningStatus, op: eq, value: Provisioning }
      - { field: changedAt,          op: lt, value: "CURRENT_TIMESTAMP-PT30M" }   # stuck for 30 minutes
    notify: { to: ops@example.com, subject: "Application {id} has been provisioning for over 30 minutes" }

  - name: unansweredQuotations
    cron: "0 0 8 * * ?"
    entity: Quotation
    where:
      - { field: status, op: eq, value: Sent }
      - { field: sentOn, op: lt, value: "CURRENT_DATE-P7D" }                      # no answer for a week
    notify: { to: owner.email, subject: "Quotation {id} has had no answer for a week" }

The comparison happens in the queried field's own shape: a date field takes CURRENT_DATE and a date-only amount (P7D, P1W, P1M, P1Y), a timestamp field takes CURRENT_TIMESTAMP (or NOW) and any amount (PT30M, PT12H, P7D, P1M). The forward form (+) is admitted symmetrically, for "falls due within the next week".

Normative

The offset MUST resolve against the clock of each firing, never be baked into the generated artifact: a sweep generated on Monday must not still be asking about Monday.

This is a moment vocabulary, not an expression language: exactly one offset on one token. No arithmetic between fields, no nesting, no further operators.

Each of these MUST be an authoring error rather than a comparison that silently never matches — the very failure this construct removes: an offset the shape cannot carry (a time offset on a date), an offset that is not a single ISO-8601 duration (-30M, or a second offset), a moment compared with a non-temporal field, and a token of the other shape than the field's. The last applies to a bare token too: a timestamp moment handed to a date column is the same defect whether or not it carries an offset.

A field the queried entity does not itself declare — a generated audit column, or a field of a source owned by another model — is exempt from the shape and temporality checks, since its properties are not resolvable at that point; the token's own shape then decides. An audit column is where a staleness sweep most often looks.

Existing files are unaffected: a value without an offset keeps its meaning exactly.

What this replaces is worth naming: a stored "deadline" column that every writer has to keep current — modelling the clock into the data — or a hand-written job that re-runs the query the format already describes and calls the notification it would already have generated.

The generate variant creates a record through the target's own layer (so numbering, status init and calculated fields fire); the target may be cross-model via a uses: alias, and it may fan out children:

yaml
schedules:
  - name: monthlyTimesheets
    cron: "0 0 1 1 * ?"
    entity: Employee
    where:
      - { field: status, op: eq, value: ACTIVE }
    generate:
      to: EmployeeTimesheet          # cross-model target via a uses: alias
      map: { Employee: id }
      defaults: { Period: now }
      children:
        - to: DayAllocation
          parent: EmployeeTimesheet
          forEach: { days: workingDays }   # one child per working day
          dayField: day

Cross-model source (model:) ​

The source entity is local by default. Add model: <uses alias> to read it from another (owner) model, so a schedule can live with the module that owns the CREATED rows instead of being forced into the source's module. The source is read-only (a schedule never writes it); a forEach collection may likewise be cross-model with its own model: alias. Both aliases must be declared under uses:.

yaml
uses:
  - { model: projects }

schedules:
  - name: monthlyProjectTimesheets
    cron: "0 0 2 1 * ?"
    entity: Project
    model: projects                    # the source Project lives in the projects model
    where:
      - { field: Status, op: eq, value: 2 }
    generate:
      to: ProjectTimesheet             # LOCAL - owned by this model
      map: { Project: id, Customer: Customer }
      defaults: { Period: now }
      children:
        - to: EmployeeTimesheet
          parent: ProjectTimesheet
          forEach:
            entity: EmployeeProjectAssignment
            model: projects            # the forEach collection is also cross-model
            match: { Project: id }
          map: { Employee: Employee }
  • A cross-model source supports the generate action only. notify needs the source's relation metadata, which only a local entity carries, and is rejected.
  • Validation splits the same way relations do: that model: names a declared uses: alias is checked when the intent is parsed; the source entity's existence and every where / map / match field reference are checked when the model is generated, against the owner model. An unresolvable owner or a mistyped field drops that schedule with a warning — never generated code that cannot compile.

integrations — outbound HTTP ​

Tell another system on an event.

yaml
integrations:
  - { name: pushNewOrder, event: { onCreate: Order }, method: POST, url: "@config:WAREHOUSE_URL" }

The @config:KEY sugar resolves to a configuration lookup, so endpoints and secrets stay out of the source.

payload — the declared envelope ​

Without a payload, the request body is the record as stored. That is only right when the receiver accepts the entity, and it has a cost even then: every column becomes part of a public contract, so adding a field silently changes what the outside world receives. A real integration contract is usually an envelope — a type, a version, an idempotency key, a timestamp, an identifier of the sender — which no arrangement of entity columns can produce.

payload declares that envelope, key by key:

yaml
integrations:
  - name: requestUserAssignment
    event: { onCreate: UserInvitation }
    method: POST
    url: "@config:ASSIGNMENT_URL"
    payload:
      type: "user.assignment.requested"     # literal
      version: 1
      messageId: "{uuid}"                   # minted per message
      tenantId: "{tenant}"                  # execution context
      appId: "@config:APP_ID"               # configuration
      email: email                          # a field of the record
      role: role.name                       # one hop off a to-one relation
      requestedAt: "{now}"

The value forms are the ones notify already resolves, deliberately borrowed rather than invented: a literal, a direct field, or a one-hop relation.field of a to-one relation, which the generated sender reads from the related record it loads once. @config:KEY reads the configuration, as it does in url.

The context tokens are a closed set of four:

tokenvalue
{uuid}a fresh identifier, minted per message — the idempotency key a receiver deduplicates on
{now}the send time, as an ISO-8601 instant
{tenant}the tenant the send runs for
{user}the user behind the change that raised the event

Normative

A payload value MUST be one whole value in one of the declared forms. Interpolated text ("Order {id} placed"), a nested object and a list are NOT payload values and MUST be reported as authoring errors — a payload is a contract, not a template.

A path MUST resolve at most one hop; a.b.c MUST be rejected.

An unknown context token MUST be an authoring error, never an empty value in a sent message.

A payload MUST be rejected on a method that carries no request body.

Keys MUST be sent in the order they were declared.

A bare word that names no field and no to-one relation of the record is a literal — the only way to carry a one-word constant. A value braced as "{name}" is a reference and MUST resolve.

Three value forms and four tokens is the cap, and the cap is the point: it expresses a frozen contract without the construct becoming a transformation language. A payload that needs more than this is an algorithm, and belongs in a hand-written handler — the honest hand-off.

inbound — arrivals from outside ​

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:
  # 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 }

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.

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: they have state and failure semantics no one-line declaration should pretend to carry.

Reading an arrival as an envelope — accept / map ​

An arrival may declare how its payload is read. Without these keys the payload is the record; with them it is an envelope, which is what an integration contract normally is.

yaml
inbound:
  - name: orders
    source: { queue: orders }
    accept: { type: order.placed, version: 1 }   # anything else: acknowledged and ignored
    create: Order
    map:
      orderNumber: orderNumber                    # property <- envelope key
      customer:    { lookup: Customer, by: code, from: customerCode }   # business key -> relation

accept gates the arrival on the envelope keys it names. map fills a property of create from an envelope key, or — given { lookup, by, from } — from the record of another entity that a business key identifies: the envelope carries a name, the entity stores a reference.

Normative

accept and map are independent, both optional, and both valid on every arrival — they describe the payload, not the transport. An arrival declaring neither behaves exactly as one specified before them.

A message matching every key of accept is ingested. A message that does not match MUST be acknowledged and ignored, with a diagnostic; it MUST NOT be failed, since redelivery cannot change the outcome. accept values are scalars.

Each key of map MUST name a field or a to-one relation of create, or — as a collection, below — a one-to-many relation of create whose target is a composition child of it; the entity's key MUST NOT be mapped, being assigned on creation. A value is an envelope key, or a lookup declaring exactly lookup, by and from. A lookup MUST fill a to-one relation, lookup MUST name that relation's target, and by MUST name a field of the target that identifies at most one record — a field declared unique, or the target's key — and one that a business key can travel as, namely text or an integer. A generator MUST refuse a by that does not, rather than resolve a lookup that could match several records.

A lookup matching exactly one record fills the relation. A lookup matching none MUST reject the arrival, reporting the unresolved value; it MUST NOT store the record with the relation unset. A key declared with no value at all — in either block — MUST be reported as an authoring error rather than read as an empty one.

A mapped record MUST be saved through the entity's ordinary write path, exactly as an unmapped one is: mapping changes what the record is built from, never how it is stored.

An envelope array as composition children — collections ​

An envelope often carries a set: an order with its lines, a request with the items it applies to. A map key naming a one-to-many relation of create whose target is a composition child takes a collection, and the array becomes the master's children — one child per element, stored with the master as one unit.

yaml
inbound:
  - name: orders
    source: { queue: orders }
    create: Order
    map:
      orderNumber: orderNumber
      lines:
        from: lines
        max: 100
        map:
          quantity: qty
          product:  { lookup: Product, by: sku, from: sku }
      tags: { from: tags, map: { label: "." } }

from names the envelope key holding the array, map fills one child from one element, and "." is the element itself, for an array of plain values.

Normative

A collection declares exactly from and map, and optionally max. Its key MUST name a one-to-many relation of create whose target is a composition child of create, declared in the same model; at most one collection of an arrival fills a given child entity. from names an envelope key and MUST NOT be ".". max, when declared, is a whole number from 1 to 10 000.

Each key of the collection's map MUST name a field or a to-one relation of the child, and MUST NOT name the child's key or its back-reference to the master, which is filled from the master the arrival creates. A value is an element key, "." — the element itself — or a lookup as above, whose from is an element key or ".". A collection MUST NOT combine "." with element keys, and MUST NOT contain a collection.

Every element MUST be resolved before anything of the arrival is stored. The arrival MUST be rejected, with a diagnostic naming the collection and the element's position, when the key holds anything but an array, when an element is null or not of the declared shape (an object for element keys, a plain value for "."), when the array holds more than max elements, when an element lacks the key a lookup reads, or when an element lookup matches no record. A rejected arrival stores nothing.

An absent or empty array creates no children. Elements are kept as sent: the construct removes no duplicate and promises no order.

The master and its children MUST be stored as one atomic unit, each through its entity's ordinary write path: either every one of them is stored or none is, and a reaction to the master's creation MUST observe all of its children. A child the store refuses fails the whole arrival.

imports — a file uploaded into rows ​

inbound takes a record another system sends. imports takes a file a person brings — a bank statement, a price list, another system's export — and makes each of its records a row of an entity. Three formats are defined: delimited text (csv) read by declared locale rules, the SWIFT customer statement mt940, and the ISO 20022 bank-to-customer statement camt053.

yaml
imports:
  - name: bankCsv
    into: StatementLine            # the entity whose rows the file becomes
    format: csv                    # csv | mt940 | camt053
    encoding: windows-1251         # csv / mt940; default UTF-8 (camt053 declares its own)
    csv:                           # csv only; every key optional
      separator: ";"               # default ","
      decimal: ","                 # default "."
      grouping: "."                # default none
      dateFormat: dd.MM.yyyy       # default yyyy-MM-dd
      skipLines: 1                 # lines before the header
    map:                           # <entity field>: <source>
      statementId: _file           # the file's name
      lineNumber:  _line           # the record's position in the file
      bookedOn:    Date            # a header column
      amount:      Amount
    duplicateKey: [statementId, lineNumber]
  - name: swift
    into: StatementLine
    format: mt940
    map: { statementId: statementId, lineNumber: lineNumber, bookedOn: valueDate, amount: amount }
    duplicateKey: [statementId, lineNumber]

A csv source is a header column, or a 1-based column position under csv: { header: false }. An mt940 or camt053 file is read into one record per statement line, and both formats share one vocabulary: statementId, sequence, account, currency, lineNumber (counted from 1 in each statement), valueDate, bookingDate, mark (C / D), amount (signed — a debit is negative), transactionType, reference, bankReference and details; mt940 adds supplementary, camt053 adds counterpartyName and counterpartyAccount. _line and _file are sources of every format.

An import offers an upload to the users who may write the entity's rows, and an operation a scheduled job can call with a file it fetched itself. Its semantics:

  • Whole or nothing. Every record is read and converted, and the duplicateKey checked against the rows already imported and against the file's other records, before any row is written — and then all of them are written in one transaction. An unreadable value rejects the file; a repeated key refuses it as a duplicate; either way nothing is written, so importing the same statement twice is harmless.
  • Strict reading. Bytes that are not valid in the declared encoding reject the file rather than being replaced, and a number using a separator the rules do not declare rejects it rather than being guessed.
  • A ledger. Every attempt — imported, duplicate or rejected — is recorded against the entity: the file's name and checksum, the number of records written, the outcome and its reason, who and when.
  • A hook after the import. Rules that act on the imported rows — matching statement lines to open invoices — are logic, not intent: a conforming generator offers an extension point called with the rows once they are committed, whose failure does not undo the import.

An import fills fields only: its target is an entity of the same model that is not a composition child and has no required to-one relation; map names fields (never the generated primary key) and covers every required field without a default; duplicateKey lists mapped fields. Each of these is an error at generation, not at the first upload.

outbound — departures on a queue or a topic ​

The mirror of inbound: the application raises a business event for something outside it, on a message channel rather than over HTTP.

yaml
outbound:
  # the record's own representation, on a queue — one consumer takes each message
  - { name: publishOrder, event: { onCreate: Order }, to: { queue: "orders.outbound" } }

  # a declared envelope, on a topic — every subscriber receives it
  - name: announceActivation
    event: { onUpdate: Order, when: "channel != internal" }
    to: { topic: "order-activations" }
    payload:
      type: "order.activated"
      version: 1
      messageId: "{uuid}"
      tenantId: "{tenant}"
      reference: number
      customer: customer.name

to: names the channel; payload: is the same declared envelope an integration sends, and without it the body is the record as stored — exactly what an integration forwards today.

Use integrations when you are calling another system's API and want its answer; use outbound when you are announcing that something happened and nobody answers. The two are separate constructs rather than one with a transport switch precisely because their failure semantics differ: a failed call is a failed call, a failed announcement is a missed announcement.

Normative

An entry MUST declare exactly one of queue / topic. Two channels are two departures wearing one name; none is a promise with nowhere to land — both MUST be reported as authoring errors, mirroring the arrival rule.

A departure binds to the same event axis as every other reacting block, and takes the same when: guard.

The message MUST be published after the write that raised the event is persisted, and MUST NOT be transactional with it: a failure MUST be recorded and MUST NOT fail that write.

Ordering, exactly-once delivery and an outbox are NOT promised. A conforming implementation MUST state this rather than leave an author to assume otherwise — an author who believes there is an outbox writes a different application than one who knows there is none.

An implementation MUST NOT add fields to a declared payload that the author did not name: the point of declaring the envelope is that adding a column does not change what leaves.

A destination name in the model is a name the application owns. Whether two separate deployments sharing one broker can meet on it is a property of the implementation's isolation between tenants, not of the format — so an implementation that renames destinations per tenant MUST document how an author declares a destination that is a contract with someone else, or a departure quietly means less than it reads.

Conversation-shaped transports — acknowledgement protocols, request-reply correlation, backoff policy — stay beyond the boundary, as they are for an integration.

exports — a file in an exacting format over a query ​

Every statutory filing an application must produce — a VAT ledger, a payroll declaration, an annual return — and every feed into a third-party ledger is the same shape: a file in an exacting format over a query. exports declares one, so a format is data rather than a hand-written writer per format per country.

yaml
entities:
  - name: ExportRun                # the run ledger: declared with no fields of its own

exports:
  - name: VatPurchaseLedger
    source: PurchaseInvoice        # an entity, or a report of the same model
    format: fixedWidth             # fixedWidth | delimited | xml
    encoding: windows-1251         # UTF-8 when omitted
    ledger: ExportRun
    fileName: "VAT-{fromDate}.txt"
    orderBy: [documentDate, number]
    parameters:
      - { name: fromDate, field: documentDate, op: ge, required: true }
      - { name: toDate,   field: documentDate, op: le }
    records:
      - type: header               # once, before the rows
        fields:
          - { literal: 'H', width: 1 }
          - { param: fromDate, width: 8, pattern: ddMMyyyy }
      - type: line                 # once per row
        fields:
          - { from: supplier.vatNumber, width: 15 }
          - { from: documentDate, width: 8, pattern: ddMMyyyy }
          - { from: taxBase, width: 15, scale: 2, align: right, pad: '0' }
      - type: trailer              # once, after the rows
        fields:
          - { literal: 'TOTAL', width: 5 }
          - { from: count(), width: 6, align: right, pad: '0' }
          - { from: sum(taxBase), width: 15, scale: 2, align: right }

A field takes its value from exactly one of from: — a field of the source, a one-hop relation.field, or a total over the rows, sum(x) / count() — literal: or param:, and says how it is written: a decimal scale (with a decimalSeparator, empty for an implied point), a date pattern, and for a positional file its width, align, pad and whether a longer value is truncated. A delimited file takes a delimiter, a quote and quoting: minimal | all; an XML document a root, an element per record and a name (or attribute: true) per field.

Over a report, a field names one of the report's declared dimensions or measures exactly as the report declares it, a total over a measure is a sum of it (sum(sum(taxBase))), and a parameter names one of the report's own parameters. Over an entity, a parameter filters a field by op (eq ne gt ge lt le), and orderBy fixes the order of the rows.

An export is offered two ways — as a download that answers the file, and as a function a scheduled job or extension code calls — and every run is recorded on the ledger: which export, its parameters, the stored file, a checksum of it, how many rows it wrote, who ran it and when, and how it ended.

Normative

Every reference an export makes — a field, a hop, a report column, a parameter, a fileName token, the ledger — MUST be resolved when the model is read, and an unresolvable one MUST be reported as an authoring error. A header or a trailer is written once, so a field of one MUST NOT read a row value; it reads literals, parameters and totals.

Nothing is written approximately. A value wider than its positional field (unless the field truncates), a character the declared encoding cannot represent, or one the format does not allow MUST fail the run rather than be replaced, shifted or dropped — a filing with a silently substituted character is rejected by its recipient long after the run reported success.

A run that fails MUST be recorded on the ledger with its reason, and its file MUST NOT remain stored. A run refused for a missing or unreadable parameter fails before anything is produced and records nothing.

The rows of an entity source MUST be written in a deterministic order: the declared orderBy, or the primary key.

Who may run an export is an ordinary permission on its name, read access being the right to obtain the file.

The signed submission of the file to an authority — a qualified signature, the upload, the receipt recorded on the run, the correction file a re-export of a filed period produces — is the construct's planned second step and is not part of it yet. Structures beyond header, line and trailer records — nested hierarchies, several row sets in one file, validation against a schema — stay outside it for now.

rollups — denormalised parent totals ​

yaml
rollups:
  - { name: memberLoanCount, entity: Loan, via: member, field: loanCount }        # count
  - { name: invoicePaid, entity: Allocation, via: SalesInvoice, field: paid,      # sum + balance + status
      op: sum, of: amount, capacity: total, balance: balance,
      status: Status, statusWhenFull: 7, statusWhenPartial: 6 }

A count roll-up keeps a counter on a parent current as its children change. With op: sum the roll-up keeps field equal to the sum of the children's of field, can maintain a balance (= capacity - sum), and can drive a status relation: statusWhenFull once the sum reaches the capacity, statusWhenPartial while it is positive but below it - and, when the sum returns to zero, back to the status the roll-up displaced. The first move into one of the roll-up's two statuses records the status the parent held until then; a sum back at zero restores it, so a document whose only allocation is deleted, amended to zero or moved to another parent returns to where the allocation found it - not to a declared constant, which is wrong for every document that entered from another status. Only a status the roll-up itself set is relinquished; a status set by hand (a void, a cancellation) is never touched. Sum roll-ups compose transitively across a multi-level composition (a leaf edit updates the mid total, then the top total); recomputation stops when values stop changing.

Roll-ups are recompute-on-event (self-healing), so they are eventually consistent, not transactionally exact under heavy concurrency.

Every roll-up recomputes on the child's create, update and delete, whatever its op reduces the children to - the event set belongs to the construct, not to the aggregation. The update pass is what a plain counter needs most: a child changes parents by an ordinary edit of its own parent relation. Moving a child to another parent - an edit of its via relation and nothing else - leaves BOTH parents right: the parent that received the child recomputes, and so does the parent it left, which does not go on counting a child that is no longer its own. That holds however the relation was written, including by a process step's field setter or a task form, which write one column and raise no ordinary change event.

The parent may be owned by another model. When the roll-up's via relation is a cross-model reference, the child stays local - it owns the event that drives the recompute - while the parent's coordinates come from the owner's model, so a time-tracking model can maintain an actualHours total on a project the projects model owns. The referenced model must be declared in uses, and the parent's field, capacity and balance are validated against the owner's model at generation time; an unresolvable roll-up is reported rather than dropped silently.

capacity and balance work on a cross-model parent, overdraw check included: the capacity is read off the parent, the balance is written alongside the sum, and the check that refuses a child row overdrawing the parent belongs to the child, which is local on this direction. That is what lets both sides of an n:m allocation be guarded from the module that owns the link rows - an invoice cannot be paid past its payable, and the payment it is paid from cannot be applied past its amount. A balance declared without a capacity is refused (the balance is capacity - sum). Only status stays local-only: it moves the parent through the owner's own status values.

aggregates — keyed cross-entity totals ​

A running total over one entity's rows, grouped by one or more of its to-one relations and materialised into a separate entity keyed by the same relations:

yaml
aggregates:
  - name: onHand
    of: StockMovement           # the source rows
    op: sum                     # sum (default) | count
    sum: quantity               # the summed field
    by: [Product, Store]        # the grouping keys
    into: ProductAvailability   # the target entity, keyed by the same relations
    field: onHand               # the target field holding the total

Where rollups denormalise a total onto the parent of a composition - one key, the child's own parent relation - an aggregate is keyed by several relations and lands in its own entity. That makes the result a first-class row: other records can reference it, lists can show it, and a picker can point at it. On-hand stock per product and store, open exposure per customer, remaining allowance per employee and year.

Every name in by must be a to-one relation of both the source and the target. On each create, update and delete of a source row the target row for that row's key-tuple is upserted and the total recomputed from every source row sharing the tuple, so a re-delivered event converges instead of accumulating. A source row with a grouping key unset belongs to no tuple and is ignored.

Aggregates are recompute-on-event, so like roll-ups they are eventually consistent, not transactionally exact. The recompute writes only the aggregate column, so it never reverts a concurrent edit to another column of the target row. An aggregate of a sensitive field is itself sensitive wherever its target carries a personal surface - hiding a value and publishing its total would be a distinction without a difference.

Changing a grouping key MOVES a source row between tuples, and both sides are repaired: the tuple it joined is recomputed, and so is the tuple it left, so no tuple keeps a contribution from a row that is no longer in it - whichever write moved the key, a form submit or a targeted one that raises no change event. The previous keys cannot be recovered after the write, so a conforming generator observes them before it. A tuple whose last contributing row leaves keeps its target row with a zero total rather than disappearing.

settlements — payment allocation ​

Auto-allocate payments across open invoices — the accounts-receivable pattern. Pair it with a rollups sum entry that maintains paid / balance / status.

yaml
settlements:
  - name: autoAllocate
    junction: SalesInvoiceCustomerPayment
    invoice: SalesInvoice
    payment: CustomerPayment
    amount: amount
    total: total
    paid: paid
    pot: amount
    order: date                       # allocate oldest first
    match: [Customer, Currency]
    status: Status
    payableStatuses: [3, 4, 6]

A payment is rarely final the moment it is typed in: one booked for the wrong amount is corrected the next day, one entered from a bank statement is completed once the counter-party is identified. Allocation therefore follows the payment for its whole life, not only its arrival — and it is a recompute of what the payment has left to give, so the same change arriving twice settles the same invoices once.

Normative

Allocation MUST run on the payment's creation and on every subsequent change to the payment record. Each run MUST allocate the payment's unallocated balance — its pot less every amount already allocated to it through the junction — rather than allocate the pot again: a positive balance is spread over the payer's open invoices in order, a zero balance MUST leave the allocation untouched, and a negative balance — the payment now covers less than it is allocated to — MUST release the excess so the allocations sum to the payment again.

The excess MUST be released from the most recently created allocations first, reducing rather than removing the last allocation it touches when only part of it is excess, so the invoices settled earliest stay settled. Releases are ordinary junction writes, so the invoice paid roll-up follows them down as it followed the allocation up.

Because every run recomputes, a re-delivered or replayed change MUST leave the allocation unchanged.

Allocation is eventually consistent, not transactionally exact, under concurrency — like every other recompute-on-event construct here.

expansions — child rows from a date span ​

Generate one child row per day / week / month of a span on the parent:

yaml
expansions:
  - name: installments
    from: Loan
    into: LoanInstallment
    unit: month                                       # day (default) | week | month
    between: { start: startDate, end: endDate }
    map: { dueDate: period }
    spread: { total: principal, into: amount, round: 2 }   # last row absorbs the remainder
    count: periods

A span change is reconciled against the rows that exist rather than rebuilt: the periods that are missing are added, the rows whose period the span no longer covers are deleted, and the rows it still covers are kept — the same rows, with the same identifiers, and with whatever was edited on them. Never mix hand-entered rows into an expanded child: the expansion owns the set, so a row on a period the span does not cover is deleted as stale, and a second row on a covered period as a duplicate.

Normative

A generator MUST apply a span change to the generated child set as a diff: it MUST create a row for each period the new span covers that has none, MUST delete each row whose period the new span does not cover, and MUST NOT delete or recreate a row whose period the new span still covers. Where spread is declared, it MUST recompute a kept row's share for the new row count. A reconciliation that resolves to the set already present MUST write nothing.

The rule exists because the reconciliation's individual writes are not one atomic step: an implementation that deletes the whole set before recreating it destroys committed rows whenever the recreation is interrupted, whereas a diff leaves the set incomplete at worst — repaired by the next reconciliation, which resolves duplicate rows on one period down to one.

Normative

Deleting the master removes the rows the expansion generated for it. Only rows selected by the expansion's own back-reference are removed, and each is removed through the child's layer, so the child's delete event fires for every row and anything reacting to a deleted child reacts exactly as it would for a hand-deleted one. The removal is idempotent.

The generated rows are the expansion's, not the author's, and that is what settles the question: without this rule they would be the one part of a deleted record that survives it — still pointing at an id that no longer resolves, still counted by every roll-up, report and balance that counted them while the record existed.

generates — create-from ​

One-click "create a document from this document":

yaml
generates:
  - name: invoice-from-timesheet
    from: ProjectTimesheet
    to: SalesInvoice
    uses: sales                       # model alias when the target is cross-model
    map: { Customer: Customer }
    defaults: { InvoiceDate: now }
    items: { from: ProjectTimesheetItem, to: SalesInvoiceItem, map: { Description: Description } }
    sourceStatus: 3                   # optional: flip the source's status after the target is created

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.

Which source rows become lines — where: / refuse: ​

The items: block above clones every row of the source document, which is only right when the whole document qualifies. Usually it does not: an unapproved member timesheet must not reach the customer's invoice, and one with no hours at all is a line the target refuses outright — so a single unfinished row stopped the whole month from being invoiced, with nothing the model able to say otherwise. where: is the source-row rule:

yaml
    items:
      from: EmployeeTimesheet
      to: SalesInvoiceItem
      where:
        - { field: Status,     op: eq, value: APPROVED }   # only approved member timesheets
        - { field: totalHours, op: gt, value: 0 }          # an empty one is not a line
      map: { Name: employeeName, Quantity: totalHours, Price: rate }

Every condition must hold for a row to become a line. It is the same { field, op, value } triple a scheduled query's where carries: op is one of eq / ne / gt / ge / lt / le / like, and the value may be a moment (CURRENT_DATE, CURRENT_TIMESTAMP-PT30M) resolved against the clock of the run that generates. field: names a field or a to-one relation of the items from: entity, and a condition on its own function: EntityStatus relation may use the seeded status name — on the item's nomenclature, not the document header's. An absent value never matches, which is what makes gt 0 say "an empty row is not a line" directly.

Skipping is the default; refuse: is the other reading. A rejected line quietly dropped from an invoice and a rejected line quietly billed are both wrong, for different months, so the document declares which it means:

yaml
      refuse: "Member timesheet is not approved"

With it, an unqualified row refuses the whole create-from with the authored message and the keys of the offending rows — which of a hundred lines to go and fix is the caller's whole question. Nothing is created: the header, its lines and the source's sourceStatus flip are one unit. refuse: without where: is rejected — with no conditions no row is ever unqualified.

A rule that qualifies no row refuses either way. A document of no lines is not the document that was asked for, and it is the harder failure to notice: it exists, it counts as the period's billing, and it is empty.

The rule belongs to the mirror form of items:. The computed form — items as a list of synthetic lines over the source record — has no source rows to select from and guards each line with its own when cell. An items block that declares no where: behaves exactly as it did.

Some targets are recorded from a document and belong to it: a payment entered on the invoice it pays. Two things make that one action rather than two.

prompt: asks for what the source cannot supply — which method, which reference, how much — before the target is created. Each entry names a field or a to-one relation of the target; required: true refuses the action without it, and default: names a property of the source the input starts from:

yaml
generates:
  - name: record-payment
    from: SalesInvoice
    to: CustomerPayment
    uses: customer-payments
    map: { Customer: Customer, Currency: Currency }
    defaults: { date: now }
    prompt:
      - { field: amount, required: true, default: balance }   # starts from the invoice's balance
      - { field: Method, required: true }
      - { field: reference }
    link:
      entity: SalesInvoiceCustomerPayment    # a composition child of SalesInvoice
      map: { amount: amount }                # link field <- created target field

link: writes one row of a composition child of the source that points at the source and at the target just created, its fields copied from the target through map. The link entity lives in the source's model and declares the composition relation to the source and one to-one relation to the target; relation: names that one when there are several.

Normative

The target, its lines and the link row MUST be written as one unit: either all of them become durable or none does. The link row MUST be written through the link entity's own layer, so every rule that layer enforces holds for it, and a refusal of the link row MUST leave no target behind. Consumers of the target's creation MUST observe the link row: the target's creation event MUST NOT be delivered before the unit is durable.

An input left empty whose entry declares default: MUST take the source property's value, wherever the action is invoked from; a required input with a default is satisfied by it.

Without the shared unit the two writes race whatever reacts to the target. A settlement spreads a newly created payment over the payer's open invoices, oldest first: a payment recorded on the second invoice and allocated to it by a separate write lands on the first, and the allocation the clerk meant is then refused. Written as one unit, the settlement sees a payment with nothing left to allocate and leaves it where it was recorded.

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 trigger comes from either axis of the event vocabulary: the source's own lifecycle, or a process step. 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 }

Normative

Exactly one trigger must be declared: onTransition (a status write — a when: "<StatusRelation> == <status>" 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.

The process-step axis ​

The event: map also takes the step binding the other consumers of the axis declare — onStepReached / onStepCompleted: { process, step }. It expresses a follow-up document that belongs to a moment in a flow rather than to a status write, and it is the route around a source whose write publishes no transition at all.

yaml
generates:
  - name: log-activation
    from: Claim
    to: LogEntry
    event: { onStepCompleted: { process: ClaimApproval, step: activate }, mode: append }
    map:
      Claim: id                # the back-reference — required on both axes and in both modes
      amount: amount
    defaults:
      step: "activate"         # which moment this row records
      date: now

Normative

A step binding must name an existing process that declares a trigger, and an existing step that occupies a moment in the flow (a user task or a service task). The process's trigger entity must be the entity from: declares — a step event is about the record its process runs on, and that record is the one the create-from reads. A step binding whose source is owned by another model must be rejected: a process and its steps belong to the model that declares them. On the step axis the when: guard is optional; the step is the moment.

Cardinality — mode: once|append ​

The event: map may declare mode: — how many targets one trigger may produce. once (the default) is the at-most-once behaviour below; append creates one target per delivered event, which is what expresses a log entry per step, a protocol line per transition, an activity record per delivery.

Normative

mode is once or append; any other value must be rejected rather than read as the default. The default is once. Under append no existing-target lookup is performed and every delivered event must create another target row. The back-reference map entry is required in both cardinalities — the dedup key under once, the created row's provenance under append. mode declared without a trigger must be rejected.

mode: append is the absence of a guard, not a state-aware one: event delivery is at-least-once, so a redelivery appends a duplicate row, and a replacement for a target that was voided is not what this cardinality expresses. Anything that must exist at most once per source keeps mode: once. Two appending rules may deliberately share a target and a back-reference — each records a different moment.

Normative

An event-driven create-from under the default mode: once 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.

A retired target stops blocking its source ​

"At most once" is a claim about targets that still count, not about rows that exist. A voided document keeps existing and keeps back-referencing its source, so a guard that asks existence alone spends the source's one shot at the first creation and never gives it back — and "void and reissue", a document retired while keeping its number and a fresh one raised, becomes inexpressible.

Nothing on the create-from says which statuses retire a document: that is what the target's stage classification already means, and the guard reads it.

yaml
seeds:
  - name: declaration-states
    entity: DeclarationState
    rows:
      - { id: 1, name: DRAFT,     stage: draft }
      - { id: 2, name: FILED,     stage: live }
      - { id: 3, name: CANCELLED, stage: cancelled }   # a target in either of these
      - { id: 4, name: VOIDED,    stage: void }        # no longer blocks its source

Normative

The at-most-once guard must be satisfied only by an existing target that is not retired — one whose function: EntityStatus value is a seed row classified cancelled or void. A retired target must be left as it is: superseding creates a new record, and never edits, deletes or re-points the retired one. When the target carries no status relation, or its nomenclature carries no stage classification, the guard remains satisfied by existence alone, and a generator must report the unclassified case — that is where the guard reads as state-aware and is not. A file that adopts no classification regenerates unchanged.

A draft or live target still blocks, so idempotence under redelivery is untouched: a redelivered event finds the document it created. Note what this is not: a cardinality. Removing the guard would also mint a document on every later qualifying event; the question here is not how many targets a source may have, but which existing one still counts.

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.

A retired target returns its source ​

A target that has been retired — its status classified cancelled or void — stops satisfying the at-most-once guard, so the source may produce a replacement. That is what makes "void and reissue" expressible. But where the rule also declares a completion hook, the freed slot cannot be refilled by an event.

The hook exists precisely to move the source OFF the status its own trigger qualifies on. So once it has run, the source stands at the post-generation status, a lifecycle graph declares no edge back, and no qualifying transition is ever published again. Voiding the target frees the slot and nothing can knock. Where a button is also declared, a person can click it; an event-only rule — the shape the event axis exists for — has no reissue path at all, and nothing says so.

sourceStatusOnRetire is the hook's inverse: where the source returns when a target this rule produced is retired.

yaml
generates:
  - name: invoice-from-proforma
    from: Proforma
    to: Invoice
    event: { onTransition: Proforma, when: "Status == APPROVED" }
    map: { Proforma: id }
    sourceStatus: INVOICED           # forward: the proforma is done once the invoice exists
    sourceStatusOnRetire: APPROVED   # back: voiding the invoice returns it — and the trigger re-fires

Retiring the invoice returns the proforma to APPROVED. That is a real transition of the proforma, announced on the source's transition channel like any other, so the ordinary trigger re-fires, the guard steps over the retired invoice, and the replacement is minted. The reissue is the ordinary path, not a second creation route with semantics of its own — and nothing new declares what "retired" means: it is the same classification the guard reads, asked from the other end.

Normative

sourceStatusOnRetire requires an event to re-fire and sourceStatus, of which it is the inverse, and must name a different status — a write that leaves the status where it stands is not a transition and announces nothing. It must be rejected on a rule triggered only by a button, which carries no guard, so nothing blocks a replacement and the button is already the reissue; on an appending rule, which likewise frees no slot; and wherever the target's retirement cannot be recognised where the rule is declared: a target with no status relation, a nomenclature with no cancelled or void classification, or a target belonging to another model. When a target of this rule becomes retired and the source still stands at sourceStatus, the source must be moved to sourceStatusOnRetire and that move announced on the source's transition channel; only the status may be written, and the retired target must be left exactly as it is. When the source stands anywhere else, nothing happens. The move is a transition, so where the source declares a lifecycle the edge from sourceStatus to it must be declared. A file that declares no sourceStatusOnRetire regenerates unchanged.

Two conditions make the reopen idempotent without a marker of its own. The source must still stand at sourceStatus, so a source that has travelled further down its own lifecycle is not dragged back. And no target of that source may still count — the at-most-once guard asked from this end. The second is the one that closes redelivery: delivery is at-least-once, so a retirement can arrive again after the replacement already exists, and by then the source is standing at sourceStatus once more. The reopen runs exactly when a creation would be allowed through.

Whether a replacement follows immediately is decided by the trigger's own when:, not by the reopen — which only publishes the transition. Return the source to the status the trigger qualifies on and the reissue is immediate; return it to an earlier one (a draft, for correction) and it waits for a person to move it forward again.

Prefer this over posts 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 step when the step is really waiting for a person to remember to click: an unclicked record parks its process instance indefinitely.

reversals — reverse a booked document (red storno) ​

A manual Reverse on a posted document that has no source event to void — the hand-written journal entry. One action creates the document's negated twin: every line copied onto the same side with the declared amounts negated, the twin pointing at the original and the original at the twin, so the pair nets to zero and each record names the other. A document a posting created is reversed on its source's void instead.

yaml
reversals:
  - name: reverseEntry
    forEntity: JournalEntry
    when: "Status == POSTED"            # offered and accepted only in this status
    label: Reverse
    link: Storno                        # the twin's to-one to the original
    backLink: ReversedBy                # optional: the original's to-one to its twin
    negate: [debit, credit, currencyAmount]   # decimal fields of the header and/or its lines
    defaults: { date: now }             # header values set instead of copied
    refuse:
      - { when: "ReversedBy != null", message: "This entry is already reversed" }
      - { when: "Storno != null",     message: "A reversal cannot be reversed - reverse the original entry" }

Normative. A reversal is a create-from whose source and target are forEntity. It MUST create the twin, its lines and the back-link write as one atomic unit, through the same write path as any create of that entity, so its validations, its document numbering, its initial status, its processes and any date-based period lock all apply: a twin dated into a closed period MUST be rejected with nothing written. The twin MUST start at the entity's initial status rather than the original's.

Normative. Every field and to-one of the header and of its line items MUST be copied as it stands, except the primary key, unique fields, numbered fields, calculated and aggregated fields, the status relation, a personal owner, link, backLink, the line's composition reference (which points at the twin) and every defaults: key. Each negate name MUST be a decimal field of the header or its lines and is copied negated, on the same side; a rate or a quantity that multiplies an amount is not negated.

Normative. link and backLink MUST be to-ones of forEntity to itself. The back-link MUST be written on the original as a system write that a user-write immutability rule (immutableWhen) does not refuse; nothing else of the original changes.

Normative. when MUST compare the entity's status relation with one status (a status name or its id); the action is offered in that status only and MUST be rejected in any other. Each refuse entry is a condition over the original's own fields and to-ones — one comparison or an ANDed list, <field> ==|!= <value | null> — and MUST reject the reversal with its message. Refusals MUST be decided under exclusive access to the original, so two concurrent reversals of one document cannot both pass them.

resolves — fill a relation from a register valid on a date ​

Set a to-one from the row of a register whose validity period covers a date the record carries. The register says "X applied to Y from A to B" — a vehicle assignment, a price list, a contract in force, an org assignment — and the record carries the match key(s) and the date:

yaml
resolves:
  - name: identifyDriver
    event: { onCreate: Fine }               # onCreate or onUpdate, optional `when` guard
    set: driver                             # the to-one of Fine this fills
    from: VehicleAssignment                 # the register
    match: { vehicle: vehicle }             # register property <- record property (one or more)
    between: { start: validFrom, end: validTo, value: violationAt }
    outcome: resolution                     # optional string field: found / notFound / ambiguous
    found:     { setStatus: IDENTIFIED }
    notFound:  { setStatus: UNRESOLVED }
    ambiguous: { setStatus: UNRESOLVED }

Nothing else in the format reaches this shape: dependsOn is an authoring-time copy matched by equality, a decision condition is a single comparison, and a setField step writes a constant. Without it every application hand-writes the same query-and-classify code.

Normative. A lookup MUST declare exactly one of onCreate / onUpdate naming a declared entity; onDelete MUST be rejected, since there is no record left to fill. set MUST name a to-one relation of that entity, from a declared register entity, and match at least one pair whose left side is a property of the register and whose right side a property of the record. between.value MUST name a date field of the record; between.start and between.end name date fields of the register and MAY each be omitted, in which case that side of the period is open. The end of a period is inclusive, and a bound expressed as a date (rather than an instant) covers its whole day.

Normative. The register MUST carry exactly one to-one relation to the entity set points at; that relation is the value the lookup copies. Zero or more than one MUST be rejected — a register offering a choice of columns to copy is a modelling ambiguity, and guessing one would defeat the construct's purpose.

Normative. All three outcomes are first-class and MUST be distinguished. Exactly one covering row fills the relation. No covering row (notFound) and more than one covering row (ambiguous) MUST both leave the relation unset: a conforming generator MUST NOT choose between candidate rows. Each outcome MAY carry a setStatus routing the record, which requires the record to declare a function: EntityStatus relation and accepts a status name as well as an id.

Normative. The attempt MUST be observable. When outcome names a string field of the record, that field MUST be stamped with found, notFound or ambiguous, so unresolved records form a filterable worklist a person can finish and a process decision can branch on the result. A conforming generator SHOULD additionally log the keys and the date it checked.

Normative. A record that already carries the relation MUST be skipped, so a manual correction is never overwritten and a re-delivered event is a no-op. The resolved relation, the outcome and the status MUST be written as one targeted update of those columns only, leaving every other column of the record — and any concurrent write to it — untouched.

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.

yaml
transitions:
  - name: VoidInvoice
    forEntity: Invoice            # must declare a function: EntityStatus relation
    from: [3, 4]                  # allowed source status ids
    setStatus: 8                  # the target status id (not one of `from`)
    when: "Paid == 0"             # optional guard: <Field> ==|!= <number>
    label: Void
    icon: ban
    notify:                       # optional: tell the counterparty once the flip has committed
      to: Customer.email
      subject: "Invoice {number} was voided"
      body: "The invoice has been cancelled."

When the entity declares a lifecycle, a transition is presentation over its edges: its from/setStatus pair must be one, and the graph — not the button — is what every other writer is held to as well.

A transition may carry a notify block — "on Void, tell the customer" — attempted after the flip has committed, and unable to fail it.

postings — source document to ledger ​

When a (usually cross-model) source document reaches a status, create one local document with computed multi-line content. Idempotent via the back-reference; a missing rule or account skips (an unposted worklist), never throws.

yaml
postings:
  - name: salesInvoicePosting
    event: { onTransition: SalesInvoice, model: sales-invoices, when: "Status == 3" }
    creates: JournalEntry
    backReference: SalesInvoice
    map: { entryDate: date, reason: "Sales invoice {number}" }
    rule: { entity: PostingRule, match: { documentType: "Sales Invoice" } }
    items:
      - { Account: rule(receivableAccount), debit: "Net + Vat" }
      - { Account: rule(revenueAccount),    credit: "Net" }
      - { Account: rule(vatAccount),        credit: "Vat", when: "Vat != 0" }

The trigger is onTransition — a status write, with the when status guard mandatory — or onCreate, for a source document with no status lifecycle at all: a booked payment's only event is being created, and it is exactly the document an accountant expects posted. when stays optional there as a plain <Property> == <number> guard; an onCreate posting reacts to the source's create event.

yaml
postings:
  - name: customerPaymentPosting
    event: { onCreate: CustomerPayment, model: customer-payments }   # no status, no guard
    creates: JournalEntry
    backReference: CustomerPayment
    map: { entryDate: date, reason: "Payment {number}" }
    rule: { entity: PostingRule, match: { documentType: "Customer Payment" } }
    items:
      - { Account: rule(bankAccount),       debit: "Amount" }
      - { Account: rule(receivableAccount), credit: "Amount" }

rule.match is a single column: literal selector, and the literal has to say something: it is matched against the rule row's own column as authored, so a blank one looks up the empty value, matches no rule row, and sends every source document to the unposted worklist while the model, the generation and the deployment all stay healthy. An absent or blank match value is an authoring error, reported when the intent is read. A rule row whose match column is empty is a different thing - an ordinary row of the rule table, matched by no non-blank literal.

Conditional rule column ​

When the account column must be chosen by a source value — a payment posts to the bank account for a transfer, the cash account for cash — a single item row selects the rule column by a classifier instead of duplicating the row per case (the same by / cases / default shape a conditional value-copy uses). Quote it, since it carries colons and braces:

yaml
    items:
      - { Account: "rule(by: Method, cases: { 1: BankAccount, 2: CashAccount }, default: SuspenseAccount)", debit: "Amount" }

by is a source field or to-one relation, compared as a number (like a when guard); cases keys are the classifier's ids and values are columns of the rule entity; default (optional) is the fallback column. When no case matches and there is no default — or the selected column is null — the posting skips to the unposted worklist, exactly as a null rule(<column>) does. A conditional cell already branches the account, so it cannot also carry a row when.

An amended source rewrites its post. A document that is rejected, corrected and issued again reaches the posting's moment a second time, and the post made the first time no longer describes it. So the derived content — the header assignments and every guarded item row — is compared with the post the back-reference finds: identical is a redelivery and writes nothing, different rewrites that post in place. One source has at most one post, before and after. The comparison is order-insensitive (stored rows have no declared order) and compares numbers by value, so a rescaled amount is not a change; a half-post — an item write that failed after the document was saved — is the same case, and completes.

The rewrite stops where the created document's own lifecycle says somebody has taken it over: it is rewritable while its function: EntityStatus relation still holds the init: the posting's own create wrote, and a created document with no status lifecycle is always rewritable. Past that the divergence is reported, naming both documents, and the correction is left to a reversing entry — overwriting a document an accountant has acted on is worse than the divergence it repairs.

A second posting can reverse the first (a reversal / credit) when the source is voided — pair it with the transitions void that flips the source into its void status. The reversal inherits creates / backReference / rule / map / items from the sibling it names, negates every item amount on the same side, links back to the original through a storno self-relation, and is fail-soft:

yaml
postings:
  - name: docPosting
    event: { onTransition: Doc, when: "Status == 2" }   # posted
    creates: Entry
    backReference: Doc
    items:
      - { debit: "Amount" }
      - { credit: "Amount" }
  - name: docStorno
    event: { onTransition: Doc, when: "Status == 3" }   # voided
    reverses: docPosting                                 # inherit + negate the sibling's items
    storno: Storno                                       # the self-link field on the created Entry

posts — derived rows on an event ​

Emit rows into a ledger or journal when a document reaches a status, mapped from the document and its line items:

yaml
posts:
  - name: goodsReceiptLedger
    event: POSTED               # a status value of the source, or `create`
    forEach: items              # the composition child to iterate (omit for one row per record)
    into: StockMovement         # the target entity
    idempotentBy: GoodsReceipt  # the target's back-reference to the source
    set:
      Date:         Receipt.Date
      Store:        Receipt.Store
      Product:      item.Product
      Quantity:     item.Quantity
      Direction:    1
      GoodsReceipt: Receipt.Id

A set value is a constant, <Source>.<field>, item.<field>, or an expression over those - so a sign flip (-item.Quantity) or a derived amount needs no code. Several entries under one event emit several rows per item: a stock transfer posts an outgoing and an incoming movement from one document.

idempotentBy names the target's to-one relation back to the source. It is both written and used as the skip condition, so a re-delivered event does not double-post. Rows go through the target's ordinary write path, so the target's own numbering, validations and derived fields still apply.

Compare with generates: that creates ONE document from a user action, while posts emits N mapped rows automatically and idempotently on an event.

Guardrails ​

  • Curated vocabulary, not a general DSL. Real logic is a script step or a hand-written hook — the escape hatch is non-negotiable.
  • Every generated glue artefact has an override switch, so a hand-written class can replace any single generated one.
  • Secrets and endpoints via @config:, never inline.
  • Bindings validated at parse — a dangling customer.namez fails fast, not at runtime.
  • The boundary is stated, not discovered. What deliberately lives outside the format — protocol, algorithm, statutory form — and the hand-off each one takes is specified in the scope boundary, and an authoring assistant is required to say when a requirement crosses it.

See also ​

Released under the Apache License 2.0.