From 3b8e1d10a541196be03f976b064defdc87c9afe5 Mon Sep 17 00:00:00 2001 From: delchev Date: Mon, 24 Aug 2026 18:24:56 +0300 Subject: [PATCH] spec: version 1.6 - the fourth released proposal wave becomes a new version Folds the fourteen accepted proposals of the 2026-08-24 wave into versions/1.6.md (copied from 1.5, spec text inserted at each proposal's declared anchor, Appendix A rows added, version header written): New constructs: reports.parameters, kind: statement (financial statement definitions), kind: subset, print placeholder fallback {{a|b}}, fileName patterns, generates step-axis + mode: append, the retired-target guard, sourceStatusOnRetire, inbound accept/map. Stated-precisely (no new construct): rollup update/re-parent recompute, settlement recompute on correction, expansion reconcile + master delete, aggregate moved-key repair bound to every writer (glue preamble). Release-editorial notes: - 0021-financial-statement-definitions, 0024 and 0025 carried no formal 'Specification text' section; their version prose is assembled verbatim from their own Expected behaviour / Edge rules normative blocks, restructured only. - 0021-rollups... and 0022 amend the same rollups paragraph; 0022 (merged later) tightens 0021's SHOULD on the vacated parent to MUST, so the folded text carries 0021's general event-set rule and 0022's both-parents MUST, without the superseded SHOULD clause. - 0028-period-locking and 0028-cross-model-rollup-child are NOT folded: their reference implementations are not merged (dirigible#6928 in review; #6930 open). Statuses set to 'accepted - awaiting the reference implementation'. Statuses of the fourteen folded proposals flipped to 'released in 1.6'; README version table and normative-file pointer updated. All 188 intra-document anchors verified resolving. Co-Authored-By: Claude Fable 5 --- README.md | 6 +- proposals/0021-arrival-mapping.md | 2 +- proposals/0021-expansion-master-delete.md | 2 +- .../0021-financial-statement-definitions.md | 2 +- .../0021-generates-step-axis-and-append.md | 2 +- proposals/0021-report-parameters.md | 2 +- .../0021-rollups-recompute-on-child-update.md | 2 +- ...0021-settlement-recompute-on-correction.md | 2 +- proposals/0022-rekey-repairs-both-groups.md | 2 +- ...0023-expansion-reconciles-its-child-set.md | 2 +- proposals/0024-supersede-a-retired-target.md | 2 +- proposals/0025-declared-reopen-on-retire.md | 2 +- proposals/0026-naming-a-rendered-document.md | 2 +- proposals/0026-subset-relation.md | 2 +- proposals/0027-print-placeholder-fallback.md | 2 +- proposals/0028-cross-model-rollup-child.md | 2 +- proposals/0028-period-locking.md | 2 +- versions/1.6.md | 2685 +++++++++++++++++ 18 files changed, 2705 insertions(+), 18 deletions(-) create mode 100644 versions/1.6.md diff --git a/README.md b/README.md index 7c73519..7091c27 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,14 @@ manifesto — is published at **[intentfile.org](https://intentfile.org)** (sour | [1.2](versions/1.2.md) | superseded - added lifecycle stages + report `scope` + status names, `locksWithMaster`, `generates` prompted input and computed lines, `defaultValue` row seeding, print-template row filtering ([proposal](proposals/0002-print-template-row-filtering.md)), the scope boundary and the authoring assistant's honesty rule ([proposal](proposals/0003-the-scope-boundary.md)), and aligns document numbering with deployed practice | | [1.3](versions/1.3.md) | superseded - added event-driven creation (`generates.event`, [proposal](proposals/0005-event-driven-generates.md)), `resolves` register lookup ([proposal](proposals/0006-register-lookup-resolves.md)), the `lifecycle` status graph ([proposal](proposals/0007-lifecycle-state-machine.md)), the `history` change trail ([proposal](proposals/0008-history-change-trail.md)), resolver-path task assignment ([proposal](proposals/0009-resolver-path-task-assignment.md)), and immutability covering composed collections ([proposal](proposals/0010-child-locks-with-master.md)) | | [1.4](versions/1.4.md) | superseded - added the glue event axis: process-step events and queue/topic/folder arrivals ([proposal](proposals/0012-glue-event-axis.md)), `attach: recordPrint` fan-out ([proposal](proposals/0011-record-print-fan-out.md)), notify link placeholders `{recordUrl}` / `{inboxUrl}` / `{appUrl}` ([proposal](proposals/0013-notify-link-placeholders.md)), and role-scoped field visibility `visibleTo` ([proposal](proposals/0004-role-scoped-field-visibility.md)) | -| [1.5](versions/1.5.md) | current - adds unrecognised-key errors ([proposal](proposals/0014-unknown-keys-are-errors.md)), multilingual report columns ([proposal](proposals/0015-multilingual-report-columns.md)), the `related` register ([proposal](proposals/0016-related-registers.md)), the declared `payload` envelope ([proposal](proposals/0017-declared-payload.md)), `outbound` departures ([proposal](proposals/0018-outbound-departures.md)), relative moments in a schedule's `where` ([proposal](proposals/0019-schedule-relative-moments.md)), and entity-level `unique` ([proposal](proposals/0020-entity-level-unique.md)) | +| [1.5](versions/1.5.md) | superseded - adds unrecognised-key errors ([proposal](proposals/0014-unknown-keys-are-errors.md)), multilingual report columns ([proposal](proposals/0015-multilingual-report-columns.md)), the `related` register ([proposal](proposals/0016-related-registers.md)), the declared `payload` envelope ([proposal](proposals/0017-declared-payload.md)), `outbound` departures ([proposal](proposals/0018-outbound-departures.md)), relative moments in a schedule's `where` ([proposal](proposals/0019-schedule-relative-moments.md)), and entity-level `unique` ([proposal](proposals/0020-entity-level-unique.md)) | + +| [1.6](versions/1.6.md) | current - adds report `parameters` ([proposal](proposals/0021-report-parameters.md)), `kind: statement` financial statements ([proposal](proposals/0021-financial-statement-definitions.md)), the `subset` relation ([proposal](proposals/0026-subset-relation.md)), print placeholder fallbacks `{{a|b}}` ([proposal](proposals/0027-print-placeholder-fallback.md)), `fileName` patterns ([proposal](proposals/0026-naming-a-rendered-document.md)), step-axis + `mode: append` create-froms ([proposal](proposals/0021-generates-step-axis-and-append.md)), the retired-target guard ([proposal](proposals/0024-supersede-a-retired-target.md)), `sourceStatusOnRetire` ([proposal](proposals/0025-declared-reopen-on-retire.md)), and `inbound` `accept`/`map` ([proposal](proposals/0021-arrival-mapping.md)); states precisely: roll-up update/re-parent recompute ([proposal](proposals/0021-rollups-recompute-on-child-update.md), [proposal](proposals/0022-rekey-repairs-both-groups.md)), settlement recompute on correction ([proposal](proposals/0021-settlement-recompute-on-correction.md)), and expansion reconcile + master delete ([proposal](proposals/0023-expansion-reconciles-its-child-set.md), [proposal](proposals/0021-expansion-master-delete.md)) | ## Structure ``` -versions/ the specification, one file per version - the newest version file is normative (currently versions/1.5.md) +versions/ the specification, one file per version - the newest version file is normative (currently versions/1.6.md) written once, at a release, and never edited again proposals/ every pending semantic change, in the open, until a release folds it into a version examples/ complete example .intent files diff --git a/proposals/0021-arrival-mapping.md b/proposals/0021-arrival-mapping.md index 8efb36f..145c071 100644 --- a/proposals/0021-arrival-mapping.md +++ b/proposals/0021-arrival-mapping.md @@ -1,6 +1,6 @@ # Mapping on arrival — a business key becomes a relation, and a gate ignores what is not understood -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** [IntentFile/intent-specification#30](https://github.com/IntentFile/intent-specification/issues/30) - **Implementation:** [eclipse-dirigible/dirigible#6769](https://github.com/eclipse-dirigible/dirigible/issues/6769) ([PR #6839](https://github.com/eclipse-dirigible/dirigible/pull/6839)) diff --git a/proposals/0021-expansion-master-delete.md b/proposals/0021-expansion-master-delete.md index 3842d0c..9543204 100644 --- a/proposals/0021-expansion-master-delete.md +++ b/proposals/0021-expansion-master-delete.md @@ -1,6 +1,6 @@ # An expansion's generated rows do not outlive their master -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** - **Implementation:** https://github.com/eclipse-dirigible/dirigible/issues/6821 diff --git a/proposals/0021-financial-statement-definitions.md b/proposals/0021-financial-statement-definitions.md index 9e13380..b2176aa 100644 --- a/proposals/0021-financial-statement-definitions.md +++ b/proposals/0021-financial-statement-definitions.md @@ -1,6 +1,6 @@ # Financial statement definitions: declarative account-to-line mappings over the balance ledger -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** [eclipse-dirigible/dirigible#6909](https://github.com/eclipse-dirigible/dirigible/issues/6909) ## The problem diff --git a/proposals/0021-generates-step-axis-and-append.md b/proposals/0021-generates-step-axis-and-append.md index ee1d05e..c5d0352 100644 --- a/proposals/0021-generates-step-axis-and-append.md +++ b/proposals/0021-generates-step-axis-and-append.md @@ -1,6 +1,6 @@ # A create-from on the process-step axis, and an opt-in append cardinality -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** - **Implementation:** [eclipse-dirigible/dirigible#6800](https://github.com/eclipse-dirigible/dirigible/issues/6800) diff --git a/proposals/0021-report-parameters.md b/proposals/0021-report-parameters.md index 60e5eac..e6a94f0 100644 --- a/proposals/0021-report-parameters.md +++ b/proposals/0021-report-parameters.md @@ -1,6 +1,6 @@ # A report may declare its own user-set parameters -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** https://github.com/IntentFile/intent-specification/issues/53 - **Reference implementation:** https://github.com/eclipse-dirigible/dirigible/issues/6357 diff --git a/proposals/0021-rollups-recompute-on-child-update.md b/proposals/0021-rollups-recompute-on-child-update.md index 20b60c7..43e52d4 100644 --- a/proposals/0021-rollups-recompute-on-child-update.md +++ b/proposals/0021-rollups-recompute-on-child-update.md @@ -1,6 +1,6 @@ # A roll-up recomputes on the child's update, whatever it aggregates -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** https://github.com/eclipse-dirigible/dirigible/issues/6820 - **Implementation:** https://github.com/eclipse-dirigible/dirigible/pull/PENDING diff --git a/proposals/0021-settlement-recompute-on-correction.md b/proposals/0021-settlement-recompute-on-correction.md index b399ca7..d186cf8 100644 --- a/proposals/0021-settlement-recompute-on-correction.md +++ b/proposals/0021-settlement-recompute-on-correction.md @@ -1,6 +1,6 @@ # Settlement allocation recomputes when the payment changes -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** - **Implementation:** https://github.com/eclipse-dirigible/dirigible/issues/6818 diff --git a/proposals/0022-rekey-repairs-both-groups.md b/proposals/0022-rekey-repairs-both-groups.md index bfacd22..c23ebcc 100644 --- a/proposals/0022-rekey-repairs-both-groups.md +++ b/proposals/0022-rekey-repairs-both-groups.md @@ -1,6 +1,6 @@ # Re-keying repairs both groups, whatever wrote the key -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** https://github.com/eclipse-dirigible/dirigible/issues/6819 - **Implementation:** https://github.com/eclipse-dirigible/dirigible/pull/PENDING diff --git a/proposals/0023-expansion-reconciles-its-child-set.md b/proposals/0023-expansion-reconciles-its-child-set.md index 3074fce..4d62c89 100644 --- a/proposals/0023-expansion-reconciles-its-child-set.md +++ b/proposals/0023-expansion-reconciles-its-child-set.md @@ -1,6 +1,6 @@ # An expansion reconciles its child set instead of rebuilding it -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** https://github.com/eclipse-dirigible/dirigible/issues/6817 - **Implementation:** https://github.com/eclipse-dirigible/dirigible/pull/6841 diff --git a/proposals/0024-supersede-a-retired-target.md b/proposals/0024-supersede-a-retired-target.md index 919ac0e..17a0c86 100644 --- a/proposals/0024-supersede-a-retired-target.md +++ b/proposals/0024-supersede-a-retired-target.md @@ -1,6 +1,6 @@ # A retired target stops blocking its source -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** https://github.com/eclipse-dirigible/dirigible/issues/6814 ## Why diff --git a/proposals/0025-declared-reopen-on-retire.md b/proposals/0025-declared-reopen-on-retire.md index 9efae4f..6f721a8 100644 --- a/proposals/0025-declared-reopen-on-retire.md +++ b/proposals/0025-declared-reopen-on-retire.md @@ -1,6 +1,6 @@ # A declared reopen: a retired target returns its source -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** https://github.com/eclipse-dirigible/dirigible/issues/6868 ## Why diff --git a/proposals/0026-naming-a-rendered-document.md b/proposals/0026-naming-a-rendered-document.md index ecd086a..ac6efe2 100644 --- a/proposals/0026-naming-a-rendered-document.md +++ b/proposals/0026-naming-a-rendered-document.md @@ -1,6 +1,6 @@ # Naming a rendered document: `fileName` -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** - **Reference implementation:** https://github.com/eclipse-dirigible/dirigible/issues/6899 diff --git a/proposals/0026-subset-relation.md b/proposals/0026-subset-relation.md index 8be6c93..6b7bc12 100644 --- a/proposals/0026-subset-relation.md +++ b/proposals/0026-subset-relation.md @@ -1,6 +1,6 @@ # 0025 — Subset relation (`subset`) -- **Status:** proposed +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** _(the proposal issue, opened first)_ - **Implementation:** [eclipse-dirigible/dirigible#6878](https://github.com/eclipse-dirigible/dirigible/issues/6878) _(+ the platform PR once opened)_ diff --git a/proposals/0027-print-placeholder-fallback.md b/proposals/0027-print-placeholder-fallback.md index 92e18b8..7ea4143 100644 --- a/proposals/0027-print-placeholder-fallback.md +++ b/proposals/0027-print-placeholder-fallback.md @@ -1,6 +1,6 @@ # Alternative operands in a print placeholder -- **Status:** draft +- **Status:** released in [1.6](../versions/1.6.md) - **Issue:** - **Reference implementation:** https://github.com/eclipse-dirigible/dirigible/issues/6900 diff --git a/proposals/0028-cross-model-rollup-child.md b/proposals/0028-cross-model-rollup-child.md index 94dbc73..84dfad2 100644 --- a/proposals/0028-cross-model-rollup-child.md +++ b/proposals/0028-cross-model-rollup-child.md @@ -1,6 +1,6 @@ # A roll-up whose counted child is owned by another model -- **Status:** draft +- **Status:** accepted - awaiting the reference implementation - **Issue:** https://github.com/eclipse-dirigible/dirigible/issues/6930 ## The problem diff --git a/proposals/0028-period-locking.md b/proposals/0028-period-locking.md index 7d02b78..ce78a51 100644 --- a/proposals/0028-period-locking.md +++ b/proposals/0028-period-locking.md @@ -1,6 +1,6 @@ # Period locking - records dated in a closed period become read-only -- **Status:** draft +- **Status:** accepted - awaiting the reference implementation - **Issue:** https://github.com/eclipse-dirigible/dirigible/issues/6535 ## The problem diff --git a/versions/1.6.md b/versions/1.6.md new file mode 100644 index 0000000..b30abe5 --- /dev/null +++ b/versions/1.6.md @@ -0,0 +1,2685 @@ +# The Intent File Specification + +#### Version 1.6 + +This version adds to [1.5](1.5.md): + +- **[`reports.parameters`](#parameters--user-set-inputs)** — a report declares the inputs its reader chooses at the moment of reading: a period, a threshold, a name — bound comparisons over fields the report need not display, with the untouched-parameter-never-narrows rule. +- **[`kind: statement`](#statement-reports--account-to-line-mappings)** — statutory statement definitions over the balance ledger: declared lines with account selectors (`20*`, `60-69`), per-account net-by-side measures, and arithmetic over other lines. The balance sheet and the income statement become declarations. +- **[`kind: subset`](#subset--a-value-set-not-a-row-set)** — the principled exception to the explicit-intermediate-entity rule: a set-valued reference to a small same-model lookup, stored as one normatively shaped value. +- **[Alternative operands in a placeholder](#alternative-operands-in-a-placeholder)** — `{{a|b}}` in a print template renders the first non-blank path; the fallback an optional twin field needs. +- **[`fileName`](#naming-the-rendered-file--filename)** — the pattern a snapshot's minted copies and a notify block's attached render are named by, with sanitisation and reject-at-authoring rules. +- **[Step-axis and appending create-froms](#event-driven-creation--event)** — `generates.event` binds to a process step (`onStepReached` / `onStepCompleted`) as well as a lifecycle event, and `mode: append` declares the one-row-per-event cardinality. +- **[A retired target stops blocking its source](#a-retired-target-stops-blocking-its-source)** — the at-most-once guard reads the `stage:` classification, so "void and reissue" is expressible without a second vocabulary. +- **[`sourceStatusOnRetire`](#a-declared-reopen--sourcestatusonretire)** — the completion hook's inverse: retiring the generated target returns the source on its ordinary transition channel, and the trigger re-fires. +- **[`inbound.accept` / `inbound.map`](#reading-an-arrival-as-an-envelope--accept--map)** — an arrival read as an envelope: gate on its declared keys, map envelope keys onto the record, resolve a business key to a relation. + +Four behaviours are stated more precisely rather than added, aligning the document with what a correct +implementation must already do: a [roll-up](#rollups--denormalised-parent-totals) recomputes on the +child's update too, and moving a child repairs **both** parents; an +[aggregate](#aggregates--keyed-cross-entity-totals)'s moved-key repair binds every writer the generator +emits, targeted writers included (stated once, in the [glue preamble](#declarative-glue)); +a [settlement](#settlements--payment-allocation) recomputes on the payment's change, allocating the +unallocated balance and releasing an excess from the newest allocations first; and an +[expansion](#expansions--child-rows-from-a-date-span) reconciles its child set as a diff — kept +periods keep their rows — and its master's delete removes the generated rows through the child's own +layer. + +Version [1.5](1.5.md) and its predecessors remain unchanged; the new constructs are opt-in, and the +precision items above change no correct implementation's observable behaviour. + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", +"RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as +described in [BCP 14](https://tools.ietf.org/html/bcp14) [RFC2119](https://tools.ietf.org/html/rfc2119) +[RFC8174](https://tools.ietf.org/html/rfc8174) when, and only when, they appear in all capitals, as +shown here. Rules highlighted as **Normative** bind every conforming file and generator; all other +text is informative. + +This document is licensed under [The Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.html). + +A rendered, navigable version of this specification is published at +[intentfile.org](https://intentfile.org/spec/). This file is the normative source. + +## Table of contents + +- [Overview](#overview) +- [Entities & fields](#entities--fields) +- [Relations & multi-model](#relations--multi-model) +- [Processes & forms](#processes--forms) +- [Presentation](#presentation) +- [Declarative glue](#declarative-glue) +- [Scoped surfaces & roles](#scoped-surfaces--roles) +- [Data, seeds & naming](#data-seeds--naming) +- [Appendix A: DSL index](#appendix-a-dsl-index) + +## Overview + +A single `.intent` file at a project root is the source of truth for a whole application. It is authored one **altitude above** the models a platform generates from: instead of hand-authoring a data model, process definitions, forms, reports, roles and seed data separately, you author **all of them** from one YAML document, and a conforming generator produces them for you. + +The intent **never emits application code**. It stops at the model layer. Schema, persistence, APIs, user interface, jobs, listeners, processes and security are produced from those models by the platform's own generation step. That boundary is non-negotiable. + +### The three altitudes + +| Altitude | Artefact | Authored by | Transform below it | +| --- | --- | --- | --- | +| 1 — Intent | one `*.intent` per project | a human, or an AI assistant proposing patches | deterministic generation | +| 2 — Models | the platform's model artefacts (data model, processes, forms, reports, roles, seed data) | the intent generators | the platform's template engine | +| 3 — Application | schema, persistence, APIs, UI, jobs, listeners, processes, security | the platform's application templates | brought live by the runtime | + +Each layer is the deterministic input to the one below it. The only fallible, supervised step is turning natural language into an Intent File; every transform below the top layer is a pure function. + +### The scope boundary + +The altitude table is also a statement of what the format deliberately does **not** model. Three kinds of requirement recur in every real application and are *how*, not *what* — they belong below the model layer, and each has a designated hand-off point that the intent wires in rather than describes: + +| 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--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 | + +The boundary is a feature, not a shortfall. Everything inside it is deterministic, regenerable and reviewable; everything outside it enters through a first-class, documented hand-off instead of a workaround. A format that models the *what* completely and hands the *how* to explicit extension points is more trustworthy than one that pretends to cover everything — and a requirement that falls outside the line is exactly the signal worth reporting, because that is how the vocabulary learns which construct to grow next. + +The altitude table names two authors: a human, and an AI assistant proposing patches. The assistant is held to the same honesty this specification demands of generators, which must report what they cannot resolve rather than ignore it: + +> **Normative.** +> An authoring assistant that cannot express a requirement in this format MUST say so rather than +> silently substituting weaker semantics — a manual step proposed where automation was requested is +> a changed contract, not a smaller change. It MUST NOT drop a stated requirement from a proposal +> without reporting it. It SHOULD name the category of the gap and the designated hand-off point, +> and it MUST NOT imply that hand-off code will be generated when it is the developer's to write. + +### Editor-first, not a runtime artefact + +The Intent File is an **authoring** artefact, not a runtime one. It gets an editor and an explicit *Generate* step; it is not silently reconciled from a repository behind your back. + +- Generation happens **in your workspace project**, visible immediately, before anything is published. +- A published Intent File is inert source, exactly like any other authored model. The generated models and code are what run. +- There is no intent daemon, no intent database table - the file is read only when you ask the generator to run. + +### The workflow + +``` +1. Create a project. +2. Author app.intent (any *.intent) at the project root - by hand or with an + AI assistant that proposes reviewable patches. +3. Open it in the intent editor: structured YAML, a live read-only diagram, and + inline validation. +4. Generate. The generators write the derived model artefacts NEXT TO app.intent: + the data model entities + relations + UI metadata + process definitions workflows + forms task data-entry pages + reports aggregations, charts, dashboard tiles + roles permissions + glue triggers, notifications, schedules, roll-ups, ... + seed data initial / reference rows + custom-action descriptors actions, generates, transitions (buttons) + document templates printable documents + a test manifest UI-test descriptor +5. Generate once more, one level down: the template engine turns the models into + the full-stack application. +6. Publish. The runtime brings it live exactly as for any hand-modelled project. +``` + +### Project layout + +The folders layer cleanly, each owned by exactly one tool: + +| Folder | Owned by | Lifecycle | +| --- | --- | --- | +| `app.intent` | you (and the AI assistant) | the only hand-authored artefact | +| project-root model files | the intent generators' *Generate* | re-emitted and scrubbed on every *Generate* | +| the generated code folder | the template engine | wiped wholesale on every regeneration | +| the custom folder | you | the escape hatch - touched by nobody | + +**Do not hand-edit the generated model files.** Changes are overwritten, and a file no longer backed by the intent is scrubbed on the next *Generate*. Adding an `app.intent` to a classic project hands ownership of its root-level model files to the intent generators; migrate them into the intent first. + +### The file is YAML, not JSON + +Comments, multi-line strings and friendly diffs matter for an artefact a human reviews and an AI patches. The parser loads the document safely - type tags (`!!type`) are blocked, because an Intent File often arrives from generated output or paste and must never be a code-execution surface. + +Every top-level collection defaults to empty, so a partial file (entities only) is valid. Field names are camelCase; entity names are PascalCase. + +### A minimal complete file + +```yaml +name: orders +description: Order management with an approval workflow +version: 1 + +entities: + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 200 } + relations: + - { name: orders, kind: oneToMany, to: Order } + + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: orderDate, type: date, required: true } + - { name: total, type: decimal } + relations: + - { name: customer, kind: manyToOne, to: Customer } + - { name: items, kind: oneToMany, to: OrderItem } + + - name: OrderItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: integer, required: true } + relations: + - { name: order, kind: manyToOne, to: Order, composition: true } + +processes: + - name: OrderApproval + trigger: { onCreate: Order } + steps: + - { name: managerReview, kind: userTask, args: { assignee: manager, form: ApproveOrder } } + - { name: done, kind: end } + +forms: + - name: ApproveOrder + forEntity: Order + fields: [orderDate, total] + actions: [approve, reject] + +reports: + - name: OrdersByCustomer + source: Order + dimensions: [customer] + measures: ["count(*)", "sum(total)"] + +permissions: + - { role: Sales, can: [Customer:read, Order:create] } + - { role: Manager, can: [Order:approve] } + +seeds: + - name: order-statuses + entity: OrderStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: ISSUED } +``` + +### Authoring rules + +> **Normative.** +> These rules keep the file diff-stable, safe to parse, and friendly for both human review and AI patching. + +- **Comments are encouraged.** No tool rewrites the file, so developer comments stay put; an AI patch path is expected to preserve them. +- **No anchors or aliases** (`&foo` / `*foo`). They make diffs harder to read and harder for an AI to patch minimally. Prefer a `defaults:` block if duplication hurts. +- **No multi-document YAML** (`---`). One file, one document. +- **No type tags.** Blocked by the safe parser. +- **Quote unquoted braces in scalars.** `to: {member.email}` is parsed by YAML as an object, not a string - write `to: member.email`. Braces are only for `{...}` interpolation inside `subject` / `body` text. +- **An event-binding key is `event:`, never `on:`** - YAML 1.1 resolves a bare `on` (and `off` / `yes` / `no`) to a boolean. An action key is `do:`. +- **Only the keys this specification declares exist, and they are case-sensitive.** An invented key, or a case slip (`Required:` for `required:`), is an authoring error - never a key that is accepted and ignored. + +#### Unrecognised keys + +A typed mapping normally drops a key it does not know. That silence is the worst failure this format can have: the file is accepted, generation succeeds, the application deploys, and the only symptom is that the promise the author wrote is absent at runtime - with every step of the pipeline reporting success. The rule is therefore the same one the format applies to a reference it cannot resolve. + +> **Normative.** +> A conforming generator MUST report a key it does not recognise as an authoring error rather than +> ignoring it, and the report MUST name the key, where it appears, and - where one exists - the +> nearest declared name. Key names are **case-sensitive**: a key differing from a declared one only +> in case is unrecognised, and the report SHOULD say so, since it is the slip hardest to see by eye. +> This applies equally to a [seed row](#seeds), whose keys are the target entity's own names rather +> than this specification's. A map whose keys are drawn from the model being described (a `map:` +> projection, a relation's `where:`, a widget's `at:`) is validated against that model, not against +> this vocabulary. + +## Entities & fields + +Every entity becomes a table, a generated data-access layer + API, and a UI page. Primary keys are integers; composition is opt-in. + +```yaml +entities: + - name: Customer # PascalCase entity name + description: Buyer account + icon: user # an icon name for the generated navigation + group: master-data # navigation group in a shared shell + audit: true # adds CreatedAt / CreatedBy / UpdatedAt / UpdatedBy + fields: [ ... ] + relations: [ ... ] +``` + +### fields + +```yaml +fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 200 } + - { name: total, type: decimal } + - { name: active, type: boolean, defaultValue: "true" } +``` + +| Key | Meaning | +| --- | --- | +| `name` | field name, camelCase (PascalCased in the generated model) | +| `type` | logical type (see below) | +| `primaryKey` | marks the PK; must be an integer type | +| `generated` | auto-increment (integer PKs only) | +| `required` | NOT NULL; the generated required-value validation keys on this. A field that also carries a default (`defaultValue`, or `init` on a relation) is NOT demanded from the caller - the default satisfies it | +| `length` | column length for string types | +| `pattern` | an input-format regular expression the value must match (string / text fields only) | +| `format` | a **named input-format preset** over `pattern` (today: `email`) — supplies the canonical regex and the matching input control, enforced server-side like an authored `pattern`; mutually exclusive with `pattern` | +| `defaultValue` | the field's default: the column default, the reason a `required` field is not demanded from the caller, and the value a **new** row is seeded with in the UI (see [Field defaults](#defaultvalue--field-defaults)) | +| `unique` | a UNIQUE constraint (e.g. a code or business key) | +| `precision` / `scale` | override the decimal default (16, 2) | +| `readOnly` | rendered read-only in the UI (e.g. a calculated total) | +| `major: false` | keep the column off the compact list table (still on the detail page) | +| `size` | form control width on a 12-column grid | +| `calculatedOnCreate` / `calculatedOnUpdate` | an expression assigned to the property on insert / update | +| `calculatedActionOnCreate` / `calculatedActionOnUpdate` | a server-side action call-out (see [Calculated fields](#calculated-fields)) | +| `number` | turn a string field into a platform-numbered document field (see [Document numbering](#document-numbering)) | +| `sensitive` | strip this field from scoped (personal / partner) surfaces (see [Scoped surfaces](#scoped-surfaces--roles)) | +| `visibleTo` | an allow-list of roles that may read the field, enforced where the data leaves the server (see [Role-scoped field visibility](#role-scoped-field-visibility--visibleto)) | + +#### Logical types + +`string`, `text`, `integer`, `int`, `long`, `decimal`, `double`, `boolean`, `date`, `timestamp`, `uuid`, `month`, `week`. + +Generators map each logical type to a physical column type. `text` is a large-object column; `uuid` is a 36-character string. `month` (a `YYYY-MM` value) and `week` (a `YYYY-Www` ISO-week value) are stored as short strings and render as month / week pickers. + +> **Normative.** +> **Primary keys must be an integer type** (`integer` / `int` / `long`). A non-integer auto-increment column is invalid, so a `uuid` or string primary key is rejected. `uuid` is valid for non-PK fields. + +#### Entity-level attributes + +| Attribute | Effect | +| --- | --- | +| `audit: true` | adds the four standard audit columns, populated automatically | +| `history: true` | records every write as field-level deltas in a shadow history table (see [history](#history--the-change-trail)) | +| `multilingual: true` | makes string properties translatable (see [multilingual data](#multilingual-data)) | +| `label:` | a stored, read-only display name (see [label](#label--a-stored-display-name)) | +| `function:` | an explicit presentation role (see [function](#function--the-presentation-role)) | +| `order:` | sequences form controls and list columns | +| `duplicable: true` | adds a *Duplicate* button that clones a document through the normal create path | +| `imports:` | injects import lines into the generated data-access layer (pairs with calculated actions) | +| `aggregate: true` | on a document master's numeric field, keeps it equal to the sum of the items' same-named field | +| `kind: setting` | marks the entity as nomenclature / configuration (see [Setting entities](#setting-entities)) | + +#### Control order + +By default the generated UI controls follow declaration order - all fields first, then to-one relations last. Give an entity an `order:` list of property names to sequence them explicitly, interleaving fields and relations for a better layout: + +```yaml +- name: OrderItem + order: [Id, Order, Product, Name, Quantity, UoM, Price, Total] + fields: [ ... ] + relations: [ ... ] +``` + +Names match field / relation names (case-insensitive). A partial order is fine - any property not listed keeps its default position and is appended after the listed ones. + +### Role-scoped field visibility — `visibleTo` + +A field is normally as visible as its entity. `visibleTo` narrows one field to the callers holding +**any one** of the listed roles — the salary on an employee, the cost price on an order line, the +credit limit on a customer — without splitting the record into a satellite entity: + +```yaml +permissions: + - { role: Payroll } + - { role: Administrator } + +entities: + - name: Employee + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true } + - { name: dailyRate, type: decimal, visibleTo: [Payroll, Administrator] } +``` + +Absent (the default), nothing changes: the field is visible to every caller who may read the +entity. The inverse spelling (`hiddenFor:`) is deliberately not part of the format — a deny-list +fails open (a role added later, or misspelled, would see the value); an allow-list fails closed. + +> **Normative.** +> A conforming generator MUST enforce the list where the data leaves the server, never only in the +> presentation layer. On a **read**, the property is absent (or null) in every response of every +> generated surface — the main one and the scoped ones — unless the caller holds one of the roles; +> owning the record, or being the partner it belongs to, grants nothing. On a **write**, a create +> ignores the submitted value and an update keeps the stored one — the rest of the write is +> legitimate and MUST NOT be refused for carrying a field that is not the caller's to set. Where +> the format records a field-level [history](#history--the-change-trail), a restricted property's +> entries are withheld from a caller who may not read it. A derived value fed by a restricted field +> (a [roll-up](#rollups--denormalised-parent-totals), an aggregated master total, a +> [keyed aggregate](#aggregates--keyed-cross-entity-totals)) inherits the same allow-list unless it +> declares its own — a sum of hidden figures is that figure one entity out. The generated UI SHOULD +> omit the column or input for a caller who cannot read the field, and MUST derive that from what +> the server actually withheld, not from a role list evaluated in the client. + +Edge rules: every listed role must be granted by the file's [`permissions`](#permissions) (a role +nothing grants hides the field from everybody, which is a typo far more often than an intention); +an empty list is rejected rather than read as "no restriction"; the primary key, the entity's +`identity` field and a document-title field cannot be restricted (hiding them breaks the page, not +the figure); a restricted field cannot be a [`label`](#label--a-stored-display-name) token. A +**report** over a restricted field is a warning, not a rejection — a report carries no field-level +scoping, so the author is told which report re-serves which figure and scopes the report's own +roles accordingly. `sensitive` and `visibleTo` are independent and compose: the first is about a +surface, the second about a role. A relation (a foreign key) cannot be restricted this way. + +#### unique — a business key over more than one field + +`unique: true` on a field constrains one column. When what makes a row unique spans several, +declare it on the entity instead: + +```yaml +entities: + - name: TenantApplication + unique: + - { fields: [tenant, application], message: "This application is already provisioned for the tenant" } +``` + +`fields` names fields or to-one relations of the same entity; a to-one relation contributes its +foreign-key column. The key is the combination of those columns - the declared order is how it +reads, and a conforming implementation is not required to give it any physical meaning. `message` is what a caller is told when a +write collides; omitted, an implementation derives one from the names. + +> **Normative.** +> The key MUST be enforced by the data store, so that it holds for every writer — a form, an import, +> an arriving message, a scheduled creation — and not only for the ones that route through the +> application. +> A colliding write MUST be reported as a conflict that a caller can distinguish from a generic +> failure, carrying the authored `message` when one was given. +> Every name MUST resolve to a field or a to-one relation of the same entity; a to-many MUST be +> rejected, having no column on this side to constrain. +> A key naming a single field MUST be rejected, naming the field-level `unique` it duplicates. +> A name repeated within one key, and a key declared twice on one entity, MUST be rejected. +> An implementation is NOT required to add the constraint to a table that already exists. + +### defaultValue — field defaults + +`defaultValue` states what a field holds when nobody supplies a value: + +```yaml +fields: + - { name: hours, type: decimal, required: true, defaultValue: 8 } + - { name: billable, type: boolean, defaultValue: true } +``` + +It has three effects at once, which are deliberately one key rather than three: + +- it is the **column default**, so a row inserted without the column gets it; +- it **satisfies `required`**, so the caller is not asked for a value the model already guarantees; +- it **seeds a new row in the UI**, so an editor opens on the default instead of on a blank. + +> **Normative.** +> A generator MUST apply the default when creating a new record and MUST NOT re-apply it to an +> existing one: a value the user cleared is a value the user chose, and re-defaulting it on the next +> edit would silently undo an intentional change. +> The default is a *starting value*, not a constraint — the user may replace it, and nothing +> revalidates a stored row against it. +> On a to-one relation the equivalent key is [`init`](#relations), which names a seeded record. + +A default is what makes a bulk affordance one action rather than several: a dialog that creates one +line per working day is only useful if the line it creates already carries the usual values. + +### Calculated fields + +A field value can be derived instead of entered: + +- **`calculatedOnCreate` / `calculatedOnUpdate`** — an expression assigned to the property. Prefer a **neutral arithmetic expression** for numeric totals (`"Quantity * Price"`, `"round(Net * 0.2, 2)"`): the server evaluates it and the UI previews it live with the same evaluator. Date helpers such as `daysBetween`, `businessDaysBetween` and `monthsBetween` are available. + +```yaml +- { name: net, type: decimal, calculatedOnCreate: "Quantity * Price", calculatedOnUpdate: "Quantity * Price" } +- { name: days, type: decimal, readOnly: true, calculatedOnCreate: "businessDaysBetween(FromDate, ToDate)" } +``` + +- **`calculatedActionOnCreate` / `calculatedActionOnUpdate`** — a server-side call-out for logic beyond an expression. The value names a hand-written component; the intent emits no code for it. It runs server-side only (no live preview) and takes precedence over an expression on the same slot. To reference it by simple name, declare `imports:` on the entity: + +```yaml +entities: + - name: Invoice + imports: | + import example.invoices.InvoiceBarcodeAction; + fields: + - { name: barcode, type: string, calculatedActionOnCreate: InvoiceBarcodeAction } +``` + +The implementation lives in the project's **custom** (escape-hatch) folder, never in the generated folder. For document numbers, use the first-class [`number`](#document-numbering) attribute instead of a calculated action. + +### Document numbering + +`number:` turns a string field into a platform-numbered document field. The intent references a **series by name only** — the number's shape and counter live **outside the model**, and the platform stamps the field automatically; no hand-written number generator. + +```yaml +# stamped on create (the number exists the moment the record is saved): +- { name: Number, type: string, number: { series: Proforma, stampOn: create } } + +# stamped at a modeled issue step (a placeholder holds the field until then): +- name: Number + type: string + number: + series: Sales Invoice # documents sharing a sequence pass the same series + per: Company # optional: a to-one relation whose value partitions the series + stampOn: issue # create | issue +``` + +- **`series`** (mandatory) — the sequence identity. Give several document types the **same series** to share one running number (a sales invoice, credit note and debit note drawing one legal range). +- **`per`** — the name of a to-one relation of the same entity whose value **partitions** the series: each distinct value gets its own sequence (the canonical case is `per: Company` — two legal entities never share a counter). The value never appears *in* the number; it only selects which sequence to draw from. +- **`stampOn`** — `create` stamps the real number on insert; `issue` puts a placeholder on the field at create and stamps the real number when the process reaches the wired step. Stamping is **idempotent** - re-issuing after an amend keeps the same number. + +**The shape is not the model's to declare.** A number's rendering — a literal prefix plus the sequence zero-padded to a total width — is declared once per module in a **numbering declaration artefact** (a requirement declaration, like roles) and configured per deployment/tenant afterwards, where an operator can adjust prefix, width and the next value. Baking a format into the model was rejected deliberately: it forced a country or customer that wants a different prefix to fork and regenerate the application, when the number's shape is configuration, not intent. Sequences are **continuous and never auto-reset**; allocating from an undeclared series fails loudly rather than minting an unconfigured number. + +The field is read-only in the UI. Counters are visible and adjustable in the generated application's document-numbering settings, including seeding a partition's starting number before its first allocation. + +> **Normative.** +> `number.series` is mandatory; `per` must name a to-one relation of the declaring entity. The removed keys of earlier drafts — `format`, `scope`, `resetOn` — MUST be **rejected at parse** with a message naming the numbering declaration as the new home of the shape: accepting and ignoring them would quietly lose an authored format. Two modules re-declaring the same series differently MUST fail the declaration naming both. + +### label — a stored display name + +A stored, read-only `Name` recomputed on every write, so lookups and dropdowns show a meaningful label instead of a raw id: + +```yaml +- name: SalesInvoice + label: "{Number} - {Date|yyyy MMMM} - {Customer.name}" +``` + +Tokens are the entity's own fields or **one-hop** to-one relation properties (`{Customer.name}`); `|format` is a date pattern for temporal values — a `month` field's `YYYY-MM` value formats through it too (`{period|yyyy MMMM}` renders "2026 July"). Deeper paths are rejected — compose by referencing the related entity's own label (`{Parent.Name}`). It is not allowed next to an authored `name` field, and a token must never reference a `sensitive` field. + +### function — the presentation role + +Optional, and authoritative when set; inferred from structure otherwise. + +```yaml +- name: SalesInvoice + function: Document # header + line items + status pill + totals +- name: SalesInvoiceItem + function: DocumentItem # its line items (no "*Item" naming needed) +``` + +Entity roles: `Document`, `DocumentItem`, `Master`, `Detail`, `List`, `Setting`, `Calendar`, `Attachment`, `Snapshot`. Field role: `DocumentTitle`. Relation role: `EntityStatus` (a managed status badge). `Board`, `Gantt` and `Timeline` are reserved and rejected until those presentations are supported. + +**A `DocumentTitle` is not necessarily platform-assigned.** Paired with [`number`](#document-numbering) the platform assigns the value and presents the field read-only. WITHOUT a `number` the title is authored by the user — the counterparty's own reference on an incoming document, for instance — and the create page must offer it as an editable control like any other field. Treating every title as assigned leaves a required field with no way to fill it, and the record cannot be created at all. + +### Attachments and snapshots + +Two `function` roles attach **files** to a record. Both are composition children of the record they belong to. + +**`function: Attachment`** gives the master a *Files* panel — upload, download, delete. The entity's rows carry the file metadata; the binary content lives in the platform's document store: + +```yaml +- name: CaseAttachment + function: Attachment + relations: + - { name: Case, kind: manyToOne, to: Case, composition: true, required: true } +``` + +**`function: Snapshot`** is the immutable, **versioned printed copy** of a document master — the frozen artefact regulations and audits want. Each generation renders the master through its [print template](#printable-documents) and stores the result as the next version; the copies appear in the same panel, download-only (never uploaded or deleted by the user): + +```yaml +- name: SalesInvoiceCopy + function: Snapshot + relations: + - { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true } +``` + +A snapshot requires a **document** master (only a document has a print template to render from). Minting a copy is wired into the workflow: bind the generated snapshot handler (named `SnapshotGenerator`) as the `delegate:` of a [service task](#service-tasks) at the step that finalises the document — typically right after *issue*. Re-issuing after an amendment keeps the document's number and mints the next version, which pairs naturally with [`immutableWhen`](#immutablewhen--immutable--user-write-immutability) and an issue-stamped [document number](#document-numbering). + +### Setting entities + +```yaml +- name: Country + kind: setting +``` + +`kind: setting` marks an entity as nomenclature / configuration. It is placed under a global **Settings** area instead of getting its own top-level perspective, and any relation targeting it resolves its dropdown there. Settings are still real entities (own table, seeds, FK columns) — only their UI placement differs. + +### checks — declarative validations + +Row-level and document-level validations, enforced on write / on a status transition, with an authored message: + +```yaml +- name: JournalEntry + checks: + - { kind: itemsMin, count: 1, status: 2, message: "An entry needs at least one line" } + - { kind: itemsSumEqual, over: [debit, credit], status: 2, message: "Debits must equal credits" } +- name: JournalEntryItem + checks: + - { kind: exactlyOne, fields: [debit, credit], message: "Exactly one of debit / credit" } +``` + +`exactlyOne` runs on every user write; `itemsMin` / `itemsSumEqual` are gated on a status transition, so drafting stays unconstrained and a failing transition aborts with the message. + +#### kind: guard — a precondition over an aggregate + +A guard compares a keyed [aggregate](#aggregates--keyed-cross-entity-totals) against a minimum and decides what a violating write does: + +```yaml +- name: StockMovement + checks: + - kind: guard + aggregate: onHand # an `aggregates` entry whose `of` is THIS entity + minimum: 0 # recomputed total (prior rows + this row) must stay >= minimum + message: "Insufficient stock" + enabledBy: BLOCK_NEGATIVE_STOCK # optional: enforced only while this configuration key is "true" +- name: SalesOrder + checks: + - kind: guard + aggregate: openExposure + minimum: 0 + outcome: task # accept the write, mark it for a human step + marker: withinCredit +- name: LeaveRequest + checks: + - kind: guard + aggregate: remaining + minimum: 0 + outcome: reject # accept the write, file it already rejected + setStatus: 4 +``` + +`outcome` selects what a violation does: + +| `outcome` | Companion attribute | A violating write | +| --- | --- | --- | +| `block` (default) | - | is rejected with `message`; nothing is stored | +| `task` | `marker:` a boolean field | is stored; `marker` is set to `false` (and to `true` whenever the guard holds) | +| `reject` | `setStatus:` a status seed id | is stored; the record's status relation is set to that value | + +**Normative.** The guarded entity MUST be the aggregate's own source (`of`). The total MUST be recomputed from the source rows themselves for the incoming record's key-tuple - excluding the record being updated - rather than read from the materialised target, so the decision cannot race the aggregate's maintenance. Consequently a guard and its aggregate are two independent computations of the same total, and the guard is the authoritative one. `outcome: task` MUST name a boolean field of the entity; `outcome: reject` MUST name a status seed value and the entity MUST declare a status relation. A companion attribute that does not belong to the declared outcome (a `marker` on a blocking guard, a `setStatus` on a marking one) MUST be reported as an authoring error rather than ignored. + +`outcome: task` sets a flag; it does not itself create or route to a task. A workflow [decision](#decision-steps) reads the marker and routes the record - the two constructs compose, and the guard is the part that computes. + +### immutableWhen / immutable — user-write immutability + +```yaml +- name: JournalEntry + immutableWhen: "Status == 2" # while POSTED, user update / delete are rejected (join terms with ||) +- name: InvoiceSnapshot + immutable: true # append-only: a frozen copy stored when a record is finalised +``` + +`immutableWhen` requires a `function: EntityStatus` relation; `immutable: true` needs none and is mutually exclusive with it. System / workflow writes stay possible — corrections to an immutable record are flow-generated reversals, never edits. + +### lifecycle — the legal status graph + +Everything else about statuses is stated one edge at a time: `init:` names where a record starts, a [`transitions`](#transitions--guarded-status-flips) button guards the flips a user performs through it, a workflow step sets one, a [check](#checks--declarative-validations) files a rejected record in another. Nowhere does the file say which moves are legal *at all* — so any writer that is not a transition button (a workflow branch, an API call, a custom action) can move a document from any status to any other, and nothing notices. + +`lifecycle:` states the whole graph, once: + +```yaml +- name: SalesInvoice + lifecycle: + edges: + - { from: DRAFT, to: [ISSUED, CANCELLED] } + - { from: ISSUED, to: [PAID, VOIDED] } +``` + +- One entry per **source** status, listing every status reachable from it. Both sides accept a [seeded status name or its id](#status-references--name-not-number). +- The graph is always over the entity's `function: EntityStatus` relation, so it names no column; the nomenclature MUST be seeded in the same file (a status entity owned by another model is seeded there, and so is its lifecycle). +- A status not listed as any `from` is **terminal**; a status listed nowhere is simply unreachable through this entity. + +> **Normative.** A conforming generator MUST validate every status write against the graph — user, workflow, glue, transition button alike — and reject a move no edge declares, with a message naming both statuses. Enforcement therefore belongs to the layer every writer passes through (the generated persistence layer), never to the transition endpoints alone, which would leave every other writer unguarded. Where the status relation declares `init:`, a record MUST also be *created* in that status: entering the lifecycle anywhere else skips the graph rather than travelling it. + +> **Normative.** With a lifecycle declared, `transitions` become **presentation over its edges**: each `from` status of a transition MUST reach its `setStatus` along a declared edge, and a status written by a workflow step or forced by a check's rejection MUST be one that some edge reaches. A conforming generator reports the disagreement when the file is read, not when the button is pressed — a reject path transiting through an approved status is exactly the mistake the graph exists to catch. + +It composes with the [`stage:` classification](#stage--what-a-status-means-to-the-lifecycle): a stage says what a status *means* (draft, live, cancelled, void) and scopes reports by it; the lifecycle says how a record may *move* between statuses. +An entity's immutability also covers its **composition children**, which declare none of their own and whose writes maintain the master's derived values — see [`locksWithMaster`](#lockswithmaster--a-child-collection-that-outlives-its-masters-lock) for the collection that must outlive the lock. + +> **Normative.** +> A generator MUST refuse a user create, update or delete of a composition child whose master is +> currently immutable, unless that child declares `locksWithMaster: false`. The refusal MUST cover +> every user surface it generates, not only the affordances it renders: a child write that +> maintains the master's derived values — a line that resums the document's totals — reaches +> exactly what the lock protects, and permitting it undoes the lock through a different door. It +> MUST NOT extend to system / workflow writes, which are what corrects an immutable record. + +### locksWithMaster — a child collection that outlives its master's lock + +An entity's immutability covers that entity **and the collections composed into it**. For some +children that default is wrong — a master that freezes its own content says nothing about a +collection recording what happens to the document afterwards: + +```yaml +- name: Invoice + immutableWhen: "Status == 3" # ISSUED: the document's own content freezes +- name: InvoiceAllocation + locksWithMaster: false # ...but money keeps being recorded against it + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } +``` + +The canonical case is settlement: an issued invoice's lines are frozen — that is the audit trail — +while payment allocations against it go on being recorded for months. Content and settlement are +different lifecycles on the same document. + +> **Normative.** +> `locksWithMaster` defaults to **true**, so a child that says nothing keeps freezing with its +> master — in the affordances a generator renders for that collection AND in the writes it accepts +> for it (see [immutability](#immutablewhen--immutable--user-write-immutability)). +> A generator MUST NOT extend a master's user-write immutability to a child collection declared +> `locksWithMaster: false` — including the affordances it renders for that collection, not merely +> the writes it accepts. A read-only rendering that the server would have permitted is the same +> defect as a refused write. +> The declaration is only meaningful on a composition child whose master actually declares +> immutability; a generator MUST reject it elsewhere rather than ignore it, since an inert +> declaration is indistinguishable from a working one until someone needs it. +> It does not apply to a document's own line items, which ARE the document's content. + +### history — the change trail + +`audit: true` records only the **last** writer and time, in four columns of the row itself. Where a +domain has to answer *what changed, from what to what, by whom, when* — for every write, for years — +declare a history: + +```yaml +- name: Contract + audit: true + history: true # every write is recorded as field-level deltas + fields: + - { name: id, type: integer, primaryKey: true } + - { name: amount, type: decimal } +``` + +The entity gains a **shadow history table** — a sibling of its own table, like the +[multilingual](#multilingual-data) language table — carrying one entry per property whose value +actually changed on a write: the property, its old value, its new value, who wrote it, when, and +whether the write came from a **user** or from the **system** (a roll-up total, a workflow +write-back, a recomputed document total). A create is recorded as `null -> value` and a delete as +`value -> null`, so the trail alone reconstructs the row at any point in its life. The record's own +form shows it as a read-only **History** panel. + +The source matters as much as the delta. Once a total the application recomputed and an amount a +person typed sit in the same column, nothing downstream can tell them apart — and "who changed this" +is the first question asked of a trail. + +> **Normative.** +> The shadow table is **append-only by construction**: a generator MUST NOT emit any create, update +> or delete path to it — not a service, not an endpoint, not a UI affordance. Append-only enforced by +> policy is not append-only. +> Every write path the generated data-access layer offers MUST append, including the targeted +> single-column and multi-column writes the system uses; a path that writes silently is worse than +> no trail, because the trail then reads as complete. +> An entry MUST record whether the write was a user write or a system write. +> Only properties whose value actually changed are recorded. Values that differ solely in +> representation (a decimal of a different scale, a translated overlay of a stored value) are NOT +> changes, and a generator MUST NOT record them as such. +> The primary key and the audit columns are NOT tracked — the key never changes and the audit columns +> restate what the entry already carries. +> A [scoped surface](#personal-and-partner-surfaces) that hides `sensitive:` fields MUST NOT be given +> a history it cannot filter: either the trail it exposes excludes those properties, or it exposes +> none. Leaking a hidden field's old and new values defeats the scoping exactly. +> Rows written outside the generated data-access layer — [seeds](#seeds), direct database writes — +> have no history, and a conforming tool documents that rather than implying completeness. + +### hierarchy / leafOnly — tree entities + +```yaml +- name: Account + hierarchy: Parent # the tree edge (a self-relation) + relations: + - { name: Parent, kind: manyToOne, to: Account } +# elsewhere - only leaf accounts are referenceable (server-enforced): +- { name: Account, kind: manyToOne, to: Account, model: accounts, leafOnly: true } +``` + +The list renders as an expandable tree; the server rejects cycles and leaf-only references to a node that has children. + +## Relations & multi-model + +### relations + +```yaml +relations: + - { name: customer, kind: manyToOne, to: Customer } + - { name: orders, kind: oneToMany, to: Order } + - { name: order, kind: manyToOne, to: Order, composition: true } +``` + +Relation kinds: `oneToMany`, `manyToOne`, `oneToOne`, `manyToMany`. The foreign key lives on the to-one side; the `oneToMany` / `manyToMany` sides are navigation-only (the column is on the child). + +- **`required: true` on a to-one** makes the FK NOT NULL but keeps the entity top-level with its own perspective (a plain dropdown). +- **`composition: true` on a to-one** makes it a master-detail composition: the owning entity becomes **dependent** (managed as details under its parent's perspective), and the FK is NOT NULL. Only a `manyToOne` / `oneToOne` can be a composition; an entity's first composition to-one is its composition parent. Declare the inverse `oneToMany` on the master so the child is managed as its detail. + +Composition is **opt-in** — most required FKs are plain associations, and composition is explicit. + +#### Relation attributes + +```yaml +- { name: Currency, kind: manyToOne, to: Currency, size: 4 } # form control width +- { name: Payment, kind: manyToOne, to: Payment, show: [date, number] } # extra read-only lookup columns +- { name: Status, kind: manyToOne, to: OrderStatus, function: EntityStatus, init: 1 } # managed badge, seeded default + +# Depends-on - cascade, narrow-to-referenced, or auto-populate: +- { name: City, kind: manyToOne, to: City, dependsOn: { relation: Country, filterBy: Country } } +- { name: UoM, kind: manyToOne, to: UoM, dependsOn: { relation: Product, valueFrom: UoM } } +- { name: price, type: decimal, dependsOn: { relation: Product, valueFrom: price } } + +# Static option filter - e.g. only stock-tracked products: +- { name: Product, kind: manyToOne, to: Product, where: { Type: 1 } } +``` + +- **`function: EntityStatus`** marks the relation as the entity's managed status badge; `init:` seeds its default at the database level (a race-free start). This relation is what [`immutableWhen`](#immutablewhen--immutable--user-write-immutability), [`transitions`](#transitions--guarded-status-flips) and [`postings`](#postings--source-document-to-ledger) key on. +- **`dependsOn`** links one dropdown to another: `filterBy` narrows the options to those matching the parent selection; `valueFrom` copies a value from the referenced record (a snapshot). +- **Conditional source** (field only): `valueFrom` may be `{ by: , cases: { : }, default: ? }` — the copied property is picked by a classifier resolved from the `by` path: an own property, a one-hop `.` (the related record is fetched), or — on a document item — a path starting at the composition parent relation, i.e. the open document header. No matching case and no `default` = no copy. + +```yaml +- name: price + type: decimal + dependsOn: + relation: Product + valueFrom: + by: SalesOrder.Customer.priceLevel # the open document's customer carries the classifier + cases: { 1: wholesalePrice, 2: retailPrice } + default: retailPrice +``` + +- **Header-mediated source** (field on a document item): `relation` may be a two-segment path `.` — the first segment is the item's composition parent, the second a to-one relation of that parent. The value is copied from the record the OPEN DOCUMENT HEADER points at, so a line defaults from the document's counterparty rather than from a relation of its own: + +```yaml +- name: discount + type: decimal + dependsOn: { relation: SalesOrder.Customer, valueFrom: standardDiscount } +``` + + Fields only, and `valueFrom` is required (there is no option list to `filterBy`). The copy happens when a NEW line is opened; an existing line is never re-copied, so a later change to the header leaves already-entered lines untouched. + +- **`where`** filters the dropdown to options matching a static condition. + +### Many-to-many + +There is no `manyToMany` materialisation - the kind is parsed but never turned into a join table. Model n:m as an **explicit intermediate entity** holding a `composition` to one side, a `manyToOne` to the other (which may be cross-model via `model:`), plus any bridge fields: + +```yaml +- name: SalesInvoiceCustomerPayment + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal, precision: 18, scale: 2, required: true } # partial allocation + relations: + - { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true } + - { name: CustomerPayment, kind: manyToOne, to: CustomerPayment, model: customer-payments, required: true } +``` + +The intermediate entity is a real entity you can read, seed and report on — which is usually what a real n:m relationship needs anyway. + +### Subset — a value set, not a row set + +The Many-to-many section holds that a real n:m is an explicit intermediate entity — a real +entity you can read, seed and report on, which is usually what an n:m relationship needs anyway. +*Usually* has one principled exception: the record that merely holds a SUBSET of a small +lookup's rows. There is no data on the pairing, no navigation from the lookup back, and no +consumer of the pairs as rows — the intermediate entity would be pure overhead, and the author +would be writing a real entity to express a checkbox group. + +`subset` says that directly: + +```yaml +relations: + - { name: payerTypes, kind: subset, to: PayerType, required: true } +``` + +The record stores the selected target keys as one value — comma-separated, in ascending numeric +order, de-duplicated; an empty selection is the absent value, never an empty string. That shape +is normative and not authorable: no attribute may set the delimiter, the ordering or a length, +and `required` means at least one selected (the stored value is non-null). A seed row sets the +value by the relation's authored name in the same shape (`payerTypes: "1,3"`). + +The target must be an entity of this model; `where` narrows the offered rows exactly as it does +on a to-one; `major` and `size` keep their meanings. Everything that describes a to-one foreign +key or a row set is rejected at parse: `composition`, `init`, `function`, `dependsOn`, +`through`, `personal` / `partner`, calculated actions, `show`, `leafOnly`, and a cross-model +`model:`. + +A conforming generator SHOULD render a multi-select over the target's rows and SHOULD resolve +the stored keys to the target's labels wherever the value is read. The moment the pairing +carries data, needs reverse navigation, or must be consumed as rows — per-row fan-out, reverse +registers, roll-ups, reports — it has outgrown a value: model the intermediate entity. A report +dimension or filter over a `subset`, and a uniqueness key spanning it, are rejected for the +same reason. + +### Multi-model applications + +A non-trivial domain is rarely one project. The intent layer lets you split it into **several intent projects** — one `*.intent` each — that reference each other across models, **reuse** single master-data entities instead of redefining them, and contribute their screens to one **shared shell**. + +Each module can be its own repository, versioned and shipped independently as a build artefact and consumed by others as a dependency — so a `currencies` or `customers` module is published once and reused across many applications. + +#### Reuse, don't redefine — `uses` + +Master / reference data (`Customer`, `Country`, `Currency`, `UoM`) is owned by **one** project. Every other project that needs it stores an **integer FK** and renders a dropdown sourced from the owner's service — it does **not** generate the owner's table or API. + +Declare the dependencies in a top-level `uses:` block, then point a `manyToOne` / `oneToOne` relation at the alias with `model:`: + +```yaml +name: customers +uses: + - { model: countries } # project defaults to the model alias + - { model: currencies, project: currencies } # set project only when it differs from the alias +entities: + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true } + relations: + - { name: Country, kind: manyToOne, to: Country, model: countries } + - { name: Currency, kind: manyToOne, to: Currency, model: currencies } +``` + +> **Normative.** +> A cross-model relation must be `manyToOne` / `oneToOne`, its `model:` must be listed in `uses:`, and it **cannot** be `composition: true` — a detail cannot be owned across models. The consumer stores a projection of the owner entity so the FK dropdown resolves against the owner's live service. + +#### One shared shell — contributions, not app-hopping + +Each project generates its own standalone shell (handy to run one domain in isolation). They **also** contribute their entities as grouped perspectives to a single **shared shell**, so the user never jumps between per-project UIs. Two pieces drive this: + +**1. `group:` on an entity** places its perspective under a named navigation group: + +```yaml +entities: + - name: Customer + group: partners # appears under the "Partners" group in the shared shell +``` + +The entity references the group **id** only. + +**2. A navigation project defines each group once.** Group ids are declared in one dedicated project so they are not redeclared per domain (the shell drops duplicate group ids). The domain entities then reference these ids (`group: sales`, `group: settings`, ...), and the shared shell aggregates every contributed perspective into its sidebar, ordered by each group's declared order. + +#### Generate leaf-first, then publish everything + +Cross-model dropdowns read the **owner's already-generated model** at generation time and call the **owner's live service** at runtime, so order matters: + +1. **Generate the owners (leaves) first**, then their consumers. +2. **Publish everything** — every owner must be live for a consumer's cross-model dropdown to resolve. +3. **Open the shared shell** — one grouped sidebar over every module. + +Because table names are [intent-prefixed](#naming-and-tables), the projects share one schema without colliding. + +### related — list the records that reference this entity + +An entity page shows its own fields, and a document shows its composition items. An entity that is +the **target** of associations has no way to show the records pointing at it — a project-month and +its per-employee timesheet lines, a customer and its invoices, an account and its journal entries, +a supplier and its purchase orders. `related:` declares that register, on the referenced entity: + +```yaml +- name: ProjectTimesheet + related: + - entity: EmployeeTimesheet # the referencing entity + model: employee-timesheets # omit when it is declared in this model + via: projectTimesheet # omit when it points here exactly once + label: Employee Timesheets # omit for the pluralised entity name + show: [number, employee, totalHours, status] # omit for the source's own list columns +``` + +The register renders on the referenced record's page, filtered to that record, and each row opens +the referencing record's own page. + +It is a **window, not an owner**. The listed records belong to their own entity — their own +lifecycle, their own pages, their own processes — so the register lists them and stops there. That +is what separates it from a composition child, which *is* edited in place as a detail or +document-items collection. + +**Why the referenced side declares it.** Generation is per model and leaf-first: the model being +referenced is generated before, and generally knows nothing about, the models that reference it. +A declaration on the referencing side could therefore never reach the page it wants to appear on. + +> **Normative.** +> `entity` is required; a `model:` must be listed in `uses:`. +> `via:` is required when — and only when — the referencing entity reaches this one through more +> than one relation (an invoice naming the same company as both issuer and recipient). A generator +> MUST reject an ambiguous register rather than choose a relation for it. +> Every `show:` name MUST be a field or relation of the referencing entity. +> A generator MUST NOT offer create, update or delete affordances in a register — the referencing +> entity's own pages own those. +> A composition child MUST be rejected rather than listed: it is already rendered as an editable +> collection, and a second read-only rendering of the same rows is two panels over one collection. +> A generator MUST resolve a cross-model register against the owner model, and MUST fail loudly when +> that model cannot be found, rather than emitting a register with no columns. +> Field visibility rules (a role-scoped or otherwise withheld property) apply to a register's columns +> exactly as they apply to the referencing entity's own lists. + + +## Processes & forms + +### processes + +```yaml +processes: + - name: OrderApproval + trigger: { onCreate: Order, when: "total > 0" } + steps: + - { name: managerReview, kind: userTask, args: { assignee: manager, form: ApproveOrder } } + - { name: bigOrder, kind: decision, args: { if: "customer.creditLimit > 10000", then: cfoReview, else: activate } } + - { name: cfoReview, kind: userTask, args: { assignee: cfo, form: ApproveOrder } } + - { name: activate, kind: serviceTask, args: { setRelationField: Status, value: 2, next: done } } + - { name: done, kind: end } +``` + +Generates one process definition per `processes[]` entry (a standard workflow model plus its diagram layout, so a modeller renders it). + +Step kinds: `userTask`, `serviceTask`, `decision`, `script`, `wait`, `end`. + +#### Step routing — the linear chain and `next:` + +Steps flow **linearly in declaration order**. Any step may override its successor with `args: { next: }` — this is how two decision branches **converge** instead of the first falling through into the second (an `activate` branch routes to `done` so it never falls into the `cancel` branch declared after it). `next` must name a declared step or the literal `end`. + +#### Service tasks + +Service-task shapes: `setField` / `setRelationField` (generated handlers that write a field or flip a status relation on a branch), `notify` (the step's work IS an outbound message — see [the notify block](#the-notify-block--and-attach-print-sending-the-document-itself)), and `delegate` (a handler referenced by name with injected `fields` — hand-written, or a generated one such as a [snapshot generator](#attachments-and-snapshots)). Set a status on the *branch* that reaches it, never on the shared task, so a reject path does not transit through the approved status. + +> **Normative.** A `notify` service task stands alone: it MUST NOT carry another action (`setField`, `setRelationField`, `call`, `delegate`) on the same step. Sending is the step's whole purpose, and a step that both writes and sends hides which of the two failed. + +#### Decision steps + +`if` + `then` are mandatory, `else` optional. `then` / `else` must name a declared step or the literal `end`; the parser validates this, so a typo fails at parse time rather than producing an invalid workflow. Without `else`, the gateway default falls through to the next step. + +A decision condition may walk **one hop** off the trigger entity (`customer.creditLimit > 10000`): a resolver step is generated before the gateway to load the related entity and rewrite the condition. + +#### wait — park the process on a data event + +A `wait` step parks the process until an entity lifecycle event resumes it — a case waiting for a reply, a flow waiting for a payment, an order waiting for its goods receipt: + +```yaml +steps: + - { name: requestInfo, kind: serviceTask, args: { setRelationField: Status, value: 4, next: awaitReply } } + - { name: awaitReply, kind: wait, args: { onCreate: CaseMessage, via: case, when: "internal == false", next: work } } + - { name: work, kind: userTask, args: { assignee: agent, form: WorkCase } } +``` + +- `onCreate | onUpdate: ` (exactly one; `onDelete` is rejected — a deleted record cannot resume a wait) names the resuming event. +- `via: ` — when the event entity is not the trigger entity itself: the event entity's to-one relation that walks back to the trigger entity (here `CaseMessage.case`). Omitted when the event entity *is* the trigger entity; same-model relations only. +- `when:` — a single-comparison guard over the **event record** (`field ==|!= literal`), so e.g. an internal note does not resume the wait. + +Correlation rides an identifier the trigger listener already writes back, so a `wait` requires the process to declare a `trigger:`. It is **fail-soft**: no parked instance, or an instance already past the wait, is a no-op — never an error. + +#### timeout / expire — boundary timers on a user task + +Two optional attributes on a `userTask`'s args give a flow a notion of time. Both route `then` like a decision branch: + +```yaml +steps: + - name: approve + kind: userTask + args: + assignee: approver + form: ApproveQuotation + timeout: { after: P3D, then: remind } # non-cancelling: the task STAYS claimable + expire: { until: validUntil, then: markExpired } # cancelling: the task is WITHDRAWN + next: done +``` + +- **`timeout: { after: , then: }`** — a non-cancelling boundary timer (`PT4H`, `P3D`): after the duration the `then` branch runs (a reminder / escalation) while the task stays claimable. +- **`expire: { until: , then: }`** — a cancelling boundary timer driven by a `date` / `timestamp` field of the trigger entity: when the moment passes, the task is withdrawn and the flow continues at `then`. The date is **re-read at task entry**, so editing it mid-flow moves the timer. A `date` names the last valid day (the timer fires at the start of the next day); a `null` arms a far-future date so the timer never effectively fires. + +#### abortOn — cancel the instance on a terminal status + +A running process should not outlive its document. `abortOn:` on the process cancels the **whole in-flight instance** — pending user tasks withdrawn, parked waits and armed boundary timers cancelled — the moment the trigger entity **transitions into** any of the listed status ids (the same transition event a [`transitions`](#transitions--guarded-status-flips) button or a workflow status set publishes): + +```yaml +processes: + - name: QuotationFollowUp + trigger: { onCreate: Quotation } + abortOn: { status: [3, 4, 6], then: markVoid } # accepted / rejected / expired + steps: + - { name: followUp, kind: userTask, args: { assignee: sales, form: FollowUp } } + - { name: done, kind: end } + # abort-only cleanup - never routed to from the main flow: + - { name: markVoid, kind: serviceTask, args: { setRelationField: Status, value: 6 } } +``` + +- `status:` — one or more status ids of the trigger entity's `function: EntityStatus` relation; reaching any of them aborts. +- `then:` (optional) — a single cleanup `serviceTask` (`setField` / `setRelationField`) that runs **only** on the abort path; it must not be reachable from the main flow. Omitted (or `end`) means terminate with no cleanup. + +Like `wait`, `abortOn` requires the process to declare a `trigger:` (correlation rides the instance identifier stamped on the record) and is **fail-soft** — no running instance is a no-op. This is the structural answer to orphaned inbox tasks: cancel a review the moment its document is voided elsewhere. + +#### trigger + +`trigger: { onCreate | onUpdate | onDelete: , when: "" }` starts the process on that entity's lifecycle event: + +- the parser validates at most one event kind, and that the target is a declared entity; +- the entity gains a back-reference column so the process starts at most once; +- a generated listener loads the entity, applies the `when` guard (a single `field ==|!= literal`), starts the process, and writes the instance identifier back. + +The business key defaults to the entity PK but is configurable: + +```yaml +trigger: { onCreate: Order, businessKey: orderNo, businessKeyStrategy: timestamp } +``` + +`businessKey` names which field becomes the started instance's business key; `businessKeyStrategy: timestamp` mints a `yyyyMMddHHmmss` value into that field when it is blank (the field must be `string` / `text`). + +#### Task assignment + +A user task's `assignee` is a role / candidate-group name, or the literal **`assignee: personal`** to route the task to the **record owner's** inbox (requires the trigger entity to declare a `personal:` relation — see [scoped surfaces](#scoped-surfaces--roles)), or a **relation walk** off the trigger record: + +```yaml +- name: approve + kind: userTask + args: + assignee: { path: employee.manager, fallback: manager } + form: ApproveRequest +``` + +Every segment of `path` is a **to-one relation** — the first of the trigger entity, each further one of the previous target — and the walk ends at an entity that declares `identity`, which is what maps a record to a login. A **cross-model** relation may only be the **last** segment: a projection carries the target's own properties but not its relations, so there is nothing to walk on from there. A conforming generator validates every hop when the file is read, so a dangling segment is reported then rather than when the process runs. + +`fallback` is **required** and names the candidate group. The walk is resolved when the task is reached, not when the process starts — so a relation an earlier step of the same process set is visible — and when it resolves to nobody (a null hop, a missing record, a blank identity) the task is created **unassigned** and the fallback group can still claim it. That is what makes the unresolvable case total: a resolver path can never mint a task nobody can see. + +### forms + +```yaml +forms: + - name: ApproveOrder + forEntity: Order + fields: [orderDate, total, customer.name] # fields or one-hop relation.field + actions: [approve, reject] # complete the task +``` + +Generates one form per `forms[]` entry. Controls are typed by looking each field up against the bound entity (string to a text input, integer / decimal to a number input, boolean to a checkbox, date to a date picker, and so on). Actions become buttons, coloured by name (approve to positive; reject / decline / delete / cancel to negative; save / submit to emphasised). + +### actions — custom buttons + +Developer-defined buttons that open a custom page — the escape hatch when a workflow or a generated screen is not enough: + +```yaml +actions: + - name: OpenPortal + forEntity: Order + scope: entity # per-record; 'page' = a whole-view toolbar button + page: /custom/portal.html +``` + +## Presentation + +Beyond the CRUD screen every entity gets, the intent declares richer read surfaces: aggregating reports, dashboard tiles, time-based views, conversation-threaded documents, and printable documents. + +### reports + +```yaml +reports: + - name: OrdersByCustomer + source: Order + dimensions: [customer] # a bare to-one shows the target's label, not the FK id + measures: ["count(*)", "sum(total)"] + - name: BigOrderItems + source: OrderItem + dimensions: [order.orderDate, quantity] # a relation.field path adds a join + filter: "quantity > 1" # becomes the WHERE +``` + +Generates one report per `reports[]` entry, rooted at `source`, with a fully materialised query: + +- a plain field resolves to a source column; +- a `relation.field` path (`order.orderDate`) joins the related entity and adds a column on it; +- a **bare to-one relation** (`customer`) joins and shows the target's label field, not the raw FK id — use `customer.id` for the id. A cross-model relation joins the owning model's table, so a report can group by an entity another module owns; +- a time bucket `month(field)` (a sortable `YYYYMM` integer) or `year(field)`; +- a measure `count(*)` / `sum(...)` / `avg` / `min` / `max` becomes an aggregate, and the dimensions become the grouping. + +`filter` becomes the `WHERE`, with field names rewritten to qualified physical columns. Report names, descriptions and column labels are emitted into the translation catalogue, so they localise alongside the rest of the UI. + +#### Lifecycle scope + +An aggregation over an entity that carries a lifecycle (`function: EntityStatus`) is **wrong by default**: drafts nobody has issued, cancelled documents and voided ones all land in the sum. `scope` states which lifecycle rows the report counts, in terms of the [stages](#stage--what-a-status-means-to-the-lifecycle) the nomenclature declares — not a predicate over positional ids: + +```yaml +reports: + - name: RevenueByMonth + source: Invoice + # no scope: an aggregation over a stage-classified lifecycle counts the live rows + dimensions: ["month(date)"] + measures: ["sum(total)"] + + - name: InvoicesByStatus + source: Invoice + scope: all # the explicit opt-out: this report is ABOUT the lifecycle + dimensions: [Status] + measures: ["count(*)"] + + - name: VoidedInvoices + source: Invoice + scope: void # a stage name selects the statuses classified with it + measures: ["count(*)", "sum(total)"] +``` + +> **Normative.** +> `scope` is `all` or a single stage name, and is only meaningful over a source declaring a `function: EntityStatus` relation. A stage scope restricts the query to the statuses that stage classifies; `all` adds no restriction. +> +> With no `scope`, a report counts every row **except** when all of the following hold, in which case it counts the `live` rows: it aggregates (declares measures, or is a balance report); its source's nomenclature is stage-classified; and neither its dimensions nor its `filter` reference the status. The last condition keeps a breakdown **by** status complete and leaves an authored predicate authoritative — a generator MUST NOT combine an implicit scope with either. +> +> A report that aggregates over a lifecycle-carrying source while declaring no `scope`, filtering on no status, and resolving no stage classification is the case this construct exists to eliminate: a generator MUST report it as a diagnostic naming the report and its status relation. Emitting the unrestricted aggregation silently is non-conforming. + +#### parameters — user-set inputs + +A report read for a period, a threshold or a name the reader chooses declares those inputs: + +```yaml +reports: + - name: Revenue + source: Invoice + dimensions: [date, customer.name] + measures: ["sum(total)"] + parameters: + - { name: fromDate, target: date, op: ge } + - { name: toDate, target: date, op: le } + - { name: minTotal, target: total, op: ge, initial: "0" } + - { name: customer, target: customer.name, op: like } +``` + +Each entry is one input, offered wherever the report is read, whose value narrows the report. +`target` is the field it filters - a field of the source, or a one-hop `relation.field` path, which +joins the related entity exactly as a dimension does, so a report may be filtered by a field it does +not display. `op` is `ge`, `le`, `eq` or `like`. `type` is optional (`date`, `timestamp`, `number`, +`string`); the target field already types the parameter, so a declared `type` is checked against it. +`initial` is the value used when the reader leaves the input empty, and is therefore what the report +shows before anyone touches it. + +> **Normative.** +> A declared parameter MUST narrow the report on every read, using the reader's value when one was +> given and `initial` otherwise; a report MUST NOT vary the shape of its query according to which +> inputs the reader has touched. +> An untouched set of parameters MUST NOT change which rows the report contains, including rows +> holding no value in a target column. +> `initial` MUST be declared unless the comparison has a neutral value that admits every row - a +> date `ge`/`le` bound and a `like` search; `op: eq` and a numeric bound MUST be rejected without it. +> `op: like` MUST match the value anywhere within it, and MUST be rejected against a target that is +> not textual. +> A `timestamp` target MUST be compared by date, so that a `le` bound includes the whole of the day +> given. +> `target` MUST resolve to a field of the source or of the entity one to-one relation hop away; a +> relation itself, and a target whose type a parameter cannot bind, MUST be rejected. +> A declared `type` that disagrees with the target's type MUST be rejected. +> A duplicate parameter name within one report MUST be rejected, as MUST a name that is not usable +> as an identifier or that the implementation reserves. +> A balance report MAY declare further parameters, but MUST NOT redeclare the window bounds it owns. + +#### Chart + +`chart:` renders the report page as a chart instead of a table (the page keeps a table / chart toggle, so filters, export and print still work). A chart wants exactly one dimension and one or more measures — the dimension labels the axis and each measure becomes a series: + +```yaml +reports: + - name: MonthlyRevenue + source: Order + dimensions: ["month(orderDate)"] + measures: ["sum(net)", "sum(vat)", "sum(total)"] + chart: bar # bar | line | pie | doughnut | polarArea | radar +``` + +#### balance reports + +`kind: balance` produces an opening / period / closing debit + credit report per dimension, with runtime From/To date pickers: + +```yaml +reports: + - name: TrialBalance + kind: balance + source: JournalEntryItem + date: journalEntry.entryDate # the date the runtime pickers apply to + debit: debit + credit: credit + dimensions: [account.code, account.name] + filter: "journalEntry.status == 2" +``` + +#### statement reports — account-to-line mappings + +A statutory statement — a balance sheet, an income statement — is a **fixed line structure**: every +line is a formula over the chart of accounts, some lines are arithmetic over other lines, and the +structure is prescribed rather than derived from the data. `kind: statement` declares that structure +over the same signed ledger a balance report reads: + +```yaml +reports: + - name: BalanceSheet + kind: statement + source: JournalEntryItem # the ledger line items - as for kind: balance + date: journalEntry.entryDate # the date the runtime From/To pickers apply to + debit: debit + credit: credit + account: account.code # the account CODE the lines select on + filter: "journalEntry.status == 2" # only posted entries count + lines: + - { code: A.I, label: Fixed assets, accounts: "20*,21*", measure: closingNetDebit } + - { code: A.II, label: Receivables, accounts: "41*", measure: closingNetDebit } + - { code: A, label: Total assets, sum: [A.I, A.II] } + - { code: B.I, label: Payables, accounts: "40-49", measure: closingNetCredit } + - { code: B, label: Net assets, sum: [A], less: [B.I] } +``` + +A statement declares the same ledger inputs as a balance report, plus **`account`** — the field +holding the account code the lines select on — and **`lines`**, the statement's structure. A line is +either a **leaf**, which reads the ledger (`accounts` selects, `measure` says which balance to take), +or **computed**, which is arithmetic over other lines of the same statement referenced by their +`code` (`sum` adds, `less` subtracts) — never both. + +`accounts` is a comma-separated selector over the account code: `20*` selects every account whose +code starts with `20`, `4110` exactly that account, and `60-69` every account whose code starts +inside the range, the bounds being equally long prefixes. `measure` is one of twelve: `opening` / +`period` / `closing`, each in a `Debit`, `Credit`, `NetDebit` and `NetCredit` form. + +The report's rows are the declared lines, in the **authored order** (statutory line codes do not +sort lexicographically into their own structure), with three columns — the line's code, its label +and its amount — and the same runtime From/To date parameters a balance report declares, with the +same window semantics. A line whose accounts hold nothing renders as **zero**, not as a missing +row: the structure is the statement, and an empty line is information. The statement's numbers are +the format's contribution; the legally mandated print layout is not — it stays a hand-authored +[print template](#printable-documents) over the result. + +> **Normative.** +> The `Net` measures net an account's two sides **per account**, before the line sums it, keeping +> only what remains on the named side — so the same account may appear in a `closingNetDebit` line +> and a `closingNetCredit` line and contributes to whichever one its actual balance puts it in, +> never to both. The four plain measures sum the raw side and therefore report gross turnover. +> A computed line's amount is exactly the signed sum of the lines it names, evaluated on the same +> window as its leaves; computed lines compose. +> `filter` and any lifecycle `scope` restrict which ledger rows count, exactly as for a balance +> report, and apply before the per-account reduction. +> Each of the following MUST be rejected as an authoring error: `dimensions` or `measures` on a +> statement; a line that is both a leaf and computed, or neither; a duplicate line `code`; a +> `sum`/`less` reference to a code the statement does not declare, and a cycle in the references; +> an unknown `measure`; a malformed selector term (a range whose bounds are of different length, a +> range that ends before it starts, an empty term); an `account` that does not resolve to a string +> field, and `date` / `debit` / `credit` failing the rules a balance report already applies to +> them; `account` or `lines` without `kind: statement`, and `kind: statement` without `lines`. +> A selector's account codes are drawn from a closed character set — letters, digits, dot and +> underscore, with the hyphen reserved as the range separator and a trailing asterisk as the prefix +> marker; a code outside it MUST be rejected rather than passed through to the query. + +#### Dashboard KPI widgets + +A report may declare a `widget` block that turns it into a KPI tile on the generated home dashboard — a meaningful business number instead of a raw record count: + +```yaml +reports: + - name: OverdueInvoices + source: Invoice + dimensions: [number, customer.name, due, total] + filter: "due <= CURRENT_DATE AND balance > 0" + widget: { kind: count, label: Overdue Invoices, icon: alert-triangle } + + - name: RevenueByMonth + source: Invoice + dimensions: ["month(date)"] + measures: ["sum(total)"] + widget: + value: "sum(total)" # names a declared measure => kind: value + at: { "month(date)": now } # pin dimensions: the `now` token, or a literal + label: Revenue (this month) + icon: banknote + + - name: SalesByProduct + source: SalesInvoiceItem + dimensions: [Product] + measures: ["sum(quantity)", "sum(total)"] + widget: { kind: list, limit: 5, label: Sales by Product } +``` + +- `kind: count` (default) — the number of records the report yields. +- `kind: value` — one aggregate cell: `value` names a measure; `at` pins dimension columns. The `now` token resolves at view time, type-aware (current `YYYYMM` on a `month(x)` dimension, current year on `year(x)`, today on a date column). +- `kind: list` — the report's first `limit` rows (default 5) as a compact table tile. + +Declaring any widget replaces the automatic per-entity count tiles; `dashboard: false` hides both tiles for a report. + +### widgets — custom dashboard tiles + +The dashboard's escape hatch, when the report machinery cannot express the content: + +```yaml +widgets: + - { name: SystemHealth, kind: kpi, url: /custom/health.js, icon: activity } # a number from a REST endpoint + - { name: SalesFunnel, kind: page, url: /custom/funnel/index.html } # an embedded HTML page +``` + +`kind: kpi` (default) renders a number tile whose value comes from the developer's REST endpoint; `kind: page` embeds the page in a tile. The `url` must be a same-origin path. + +### view — calendar, range, slots + +`view:` (with a `calendar:` / `slots:` descriptor) places an entity's records on a time surface: + +```yaml +- name: DayAllocation + view: calendar # a month / week calendar of records + calendar: { start: day, title: note } # start (date/timestamp) required; end/title/color optional +- name: VacationRequest + view: range # from-to bars (a leave calendar) + calendar: { start: fromDate, end: toDate } +- name: Appointment + view: slots # a slot-picker booking page + slots: { start: startTime } +``` + +`view: calendar` is also expressible as the role alias `function: Calendar`. + +#### A view adds a page + +`view: calendar`, `view: range` and `view: slots` **add** a page; they never take one away. The entity keeps the page family its structure already implies — a list, a master-detail, or a document editor — and the view joins it: + +| Route | Page | +| --- | --- | +| `/` | the calendar, or the slot picker | +| `//list` | the entity's own browse page (list / master / document list) | +| `//create`, `///edit` | the entity's own editor | + +Both browse pages offer a switch to the other, and choosing a day, an event or a free slot opens the entity's own editor. So a document master may be browsed on a calendar — or booked from a slot picker — and still be edited as a document, with its line items, printing and workflow tasks intact: declaring a view never costs an entity its editing surface. A picker is how a record is *created*; the list or document page is how it is worked with afterwards, and an author needs both. + +#### A document's line items on a calendar + +When the entity declaring `view: calendar` is a document's **line-items** child, the document's items pane *is* the calendar instead of the row grid — the shape for a day-grained line, such as a booked day or an allocated hour: + +```yaml +- name: Roster + function: Document +- name: RosterItem + function: DocumentItem + view: calendar + calendar: { start: day, title: Person } + fields: + - { name: day, type: date, required: true } + - { name: hours, type: decimal, precision: 18, scale: 2 } +``` + +The document keeps its header, totals and printing; only the items pane changes. Clicking an event edits that line, clicking an empty day adds one with that date filled in. A line-items child cannot be both a calendar and a chat thread (`documentItemsLayout: chat`) — the two claim the same pane, and declaring both is an error. + +### documentItemsLayout: chat — conversation threads + +A document master can render its line-items child as a chat thread (message bubbles + a composer) instead of an editable items table — support cases, tickets, comment threads. The header, status pill, workflow tasks and print stay as in a normal document: + +```yaml +- name: Case + function: Document + documentItemsLayout: chat +- name: CaseMessage + function: DocumentItem + audit: true # the bubble author + timestamp come from audit + fields: + - { name: body, type: text, messageBody: true } # the bubble text (exactly one) + - { name: internal, type: boolean, messageInternal: true } # an internal memo (hidden from partners) +``` + +### Printable documents + +Every document (header-items) master — one with an `*Item` composition child — gets a **printable document template** on *Generate*, written in a small layout language and rendered to a document on demand from the entity's own data. + +``` +doc/Templates//Print/en/standard.print +``` + +The template is a tree of layout tags (`page`, `header` / `footer`, `section` / `stack`, `row`, `field`, `text`, a `table` bound to the items, `total`, `line`, and `if`), with values as placeholders: + +```text +{{document.}} the document's own field +{{document.}} a to-one relation's display label +{{document..}} a field of a related record +{{}} a line-item field (inside a table bound to the items) +``` + +> **Normative.** +> The print template is written **create-if-absent** and never regenerated over. A printed document is a formatted, audited artefact you adapt by hand, and a newly added model field must not silently appear on an already-designed document. + +To add a language, add a file under a sibling language folder (`.../Print/bg/standard.print`); the print action asks which to use when several exist. + +#### Alternative operands in a placeholder + +A placeholder may list several paths separated by `|`; the first one resolving to a non-blank value +is rendered, left to right: + +```text +{{document.Customer.NameLocal|document.Customer.Name}} +``` + +This is the fallback an optional twin field needs. A record carrying a locally registered name beside +its canonical one has one filled or both, and a template that had to name a single field printed a +hole in the document for every record with the other one. + +> **Normative.** +> Every operand MUST resolve by the same rules as a single path, including row-scope resolution +> inside a table or a row-expanding node. +> A value counts as blank when it is null, missing, or whitespace-only. +> The LAST operand MUST be rendered whatever it resolves to, so that a single path is the +> one-operand case of the rule and a placeholder whose operands are all blank renders empty - exactly +> as an unresolved single path does. +> Any number of operands is allowed. No other syntax is introduced: an operand is a path, never a +> literal or an expression. +> A template using this syntax rendered by an implementation that predates it MUST NOT fail to parse. + +#### Row filtering — `filter` / `match` + +One collection often has to render into several purpose-grouped tables: a payslip prints earnings beside deductions from the same fiche lines, a journal entry its debit side next to its credit side, a VAT summary the same items grouped per rate. The line items carry the discriminator (a kind, a side, a rate group); `filter` / `match` let a table select on it: + +```text + + + Earnings + + {{Name}} + {{Amount}} +
+
+ + Deductions + + {{Name}} + {{Amount}} +
+
+
+ + + Final document + +``` + +- **`filter=""`** on a `table` (or a row-expanding `for`) names a path resolved **in each row's own scope**. Without `match`, a row is kept when the resolved value is truthy. +- **`match="A | B"`** lists accepted literal values, `|`-separated, surrounding whitespace trimmed. A row is kept when the resolved value's string form equals one of the literals. +- **`match` on an `if`** compares the node's resolved `source` against the listed literals instead of testing truthiness. + +> **Normative.** +> A filtered `table` renders its column definitions unchanged and one row per kept element, in source order; elements failing the filter are skipped entirely. A filtered `for` expands its children only for the kept elements. An unresolved or null filter value never matches a `match` list, and is falsy without one. Comparison is by the value's plain string form (numbers by their canonical rendering, booleans as `true`/`false`) — no coercion beyond that, no operators, no expressions. `filter` absent renders every element; `match` without `filter` on a `table`/`for` has no effect; an empty `match` is treated as absent; `match` on an `if` without a `source` keeps the children hidden. A template using these attributes rendered by an implementation that predates them MUST degrade to rendering all rows — unknown attributes are ignored, never a parse failure. + +#### Naming the rendered file — `fileName` + +A document is rendered to a file in two places - the versioned copy a +[`function: Snapshot`](#attachments-and-snapshots) child mints, and the copy a +[notify block](#the-notify-block--and-attach-print-sending-the-document-itself) attaches. Both accept +a `fileName` pattern: literal text and `{token}` interpolations over the record being rendered. + +```yaml + fileName: "{number}_{date:yyyyMMdd}_{customer.shortName|customer.name}" +``` + +| Token | Renders | +| --- | --- | +| `{field}` | a field of the rendered record | +| `{relation.field}` | a field of a one-hop to-one relation of it | +| `{field:pattern}` | a date or timestamp field in the given date-format pattern | +| `{A\|B}` | the first non-blank of the listed operands, left to right | +| `{Version}` | the copy's version (a snapshot only) | + +Paths use the authored field and relation names - the same vocabulary a notify `subject` interpolates. +A snapshot's pattern resolves against its **document master**; a notify block's against the record +that block renders. + +> **Normative.** +> An interpolated value MUST be sanitized before it enters the name: trimmed, internal whitespace +> collapsed to a single separator, and characters the storage or transport cannot carry removed. +> Non-ASCII characters MUST be preserved - whether a name is transliterated is an application's data +> convention, not the format's. +> Literal text between tokens MUST be emitted verbatim: the author owns the separators. +> A token whose value is blank renders empty. A token naming a field or relation the entity does not +> have MUST be rejected at authoring time, not rendered empty - a silently dropped token produces +> exactly the indistinguishable names this construct removes. +> A pattern that interpolates nothing, has unbalanced or nested braces, applies a date format to a +> field that is not a date or timestamp, carries a format the implementation's formatter rejects, or +> names a multi-hop path MUST be rejected. +> `{Version}` MUST be rejected where no version exists. A snapshot pattern that does not place it +> MUST have the version appended: two versions of one copy MUST NOT share a name. +> On a notify block `fileName` requires an `attach` - a message with no attachment has no file to +> name. Where the attached document is rendered once for a whole fan-out, only fields of that record +> are readable, a relation hop MUST be rejected. +> Absent a pattern, an implementation supplies a default, and the SAME default for both renders; a +> document's own number is used where it declares one. + +## 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** `` **do** ``, 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. + +**Normative.** The repair of a moved record belongs to the WRITE, not to one writer. Where a generator +emits several ways to write a record — a form submit that carries the whole record, and targeted writers +such as a process step's field setter, a lookup that resolves a relation, or a task form that persists +what it edited — every one of them MUST observe the grouping columns and repair both groups. A targeted +writer raises no ordinary change event by design; the repair MUST reach the derived-total handlers +without turning into one. + +### 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 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 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`, sending the document itself + +`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: + +| Where | The record it is about | It sends | +| --- | --- | --- | +| `notifications[]` | the event record | on create / update / delete | +| `schedules[].notify` | each matched row | on every cron tick, per row | +| `transitions[].notify` | the transitioned record | after the status flip commits | +| a `serviceTask`'s `args.notify` | the process's trigger record | when the flow reaches that step | + +An optional **`attach: print`** makes the message carry the record's **own document**: the record is rendered through its [print template](#printable-documents) 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 payment reminder carrying the invoice it is about. + +```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 print-template language +``` + +> **Normative.** `attach` is `print` - the record the block is about - or, inside a fan-out, [`recordPrint`](#one-document-many-recipients-attach-recordprint). A block declaring `print` MUST be about an entity with a **line-items child** — the shape a print template is generated for; a generator MUST reject `attach: print` on any other entity rather than send a message without the document it promised. `language` names one of the document's print-template languages; when omitted, the default language is used. The attachment MUST be produced 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. + +> **Normative.** A block whose recipient resolves to no address is a **no-op**: the send is skipped and recorded, never an error — a record with nobody to notify must not stall a flow. A `transitions[].notify` MUST NOT be able to fail its transition: the status flip is the transition's contract and is already applied when the message is attempted, so a delivery failure is recorded and the transition still reports success. At the other call sites a delivery failure MAY fail the activity so the platform's own retry applies; a sending process step SHOULD, since the message is that step's whole purpose. + +#### Links back to the application: `{recordUrl}`, `{inboxUrl}`, `{appUrl}` + +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: + +| Placeholder | Resolves 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}" +``` + +> **Normative.** The three names are RESERVED at every notify call site: an entity field of the same +> name MUST NOT shadow them. `{recordUrl}` and `{inboxUrl}` MUST be resolved by the implementation to +> a complete address; an intent MUST NOT be required to spell a route, and a conforming implementation +> MUST NOT require one. `{appUrl}` yields the origin alone - it is the escape hatch for addresses the +> other two cannot express, and everything appended to it is authored text. + +Why the intent never writes the path: the routes belong to whatever renders the application, and 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. + +> **Normative.** Inside a [`forEach`](#one-message-per-related-row-foreach) fan-out `{recordUrl}` links +> the ROW, like every other bare path in the block - the row is what that message is about. The anchor +> record is addressable for VALUES only. + +#### One message per related row: `forEach` + +Some sends are per-row rather than per-record: a payroll run mails every payslip to its own employee, +an order confirmation goes to each listed contact. `forEach:` names a related entity and the block +sends one message per row of it; every path - the recipient, the placeholders, and `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 +``` + +> **Normative.** The named entity MUST have exactly ONE to-one relation back to the record the block is +> about: none means the rows are unrelated, several make the intended set ambiguous, and a conforming +> generator MUST reject both rather than send to a silently wrong set of recipients. `attach: print` +> then refers to the ROW's document, and the row MUST satisfy the document requirement above. + +> **Normative.** `forEach` 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 MUST be 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.} = 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 +``` + +> **Normative.** `attach: recordPrint` is only meaningful inside a fan-out and MUST be rejected without +> one - outside a fan-out `attach: print` already renders that very record. The **anchor record** MUST +> satisfy the document requirement; the row need not. `language` / `languageFrom` then select the +> anchor's render language, read off the anchor, because there is exactly one render: a conforming +> generator MUST render the document ONCE per fan-out and attach the same result to every message, +> never once per recipient. + +> **Normative.** 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: `{record.}` names ONE field of it, and a longer path MUST be rejected. The +> recipient MUST NOT 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 MUST be rejected +> too, since there every bare path already resolves against the record. Which record a path reads is +> therefore always written down and never inferred - nothing in a rendered message reveals that the +> wrong one was read. + +> **Normative.** A fan-out 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. A fan-out MUST NOT fail its activity, because a retry would resend +> to every recipient already served - a partial fan-out cannot be made idempotent. + +A sending `serviceTask` stands alone: `notify` MUST NOT be combined with another action (`setField`, `setRelationField`, `call`, `delegate`) on the same step — model the send as 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 # add model: for a cross-model source + 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 to carry the row's document, forEach to fan out +``` + +The schedule's `entity` may be owned by another model: add `model: ` (an alias declared in [`uses`](#reuse-dont-redefine--uses)) and the schedule queries the owner's entity — its `where` fields, `map` sources and notify paths are validated against the **owner's** model at generation time, exactly like a cross-model relation. The `generate` target and its `children` accept the same `model:` alias. + +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 +``` + +`map` copies a value of the queried row; `defaults` sets a constant — `now` means "today", rendered in the **target field's own shape** (a `date` field gets today's date, a `month` field the current `YYYY-MM`, a `week` field the current `YYYY-Www`). + +#### Relative moments — a `where` value offset from now + +The archetypal schedule is a **staleness sweep** — rows still provisioning after 30 minutes, +quotations unanswered for 7 days. A `where` value may therefore be a moment **relative to the +firing**: the current-moment token carrying one ISO-8601 duration offset. + +```yaml +schedules: + - name: unansweredQuotations + cron: "0 0 8 * * ?" + entity: Quotation + where: + - { field: status, op: eq, value: Sent } + - { field: sentOn, op: lt, value: "CURRENT_DATE-P7D" } + notify: { to: owner.email, subject: "Quotation {id} has had no answer for a week" } +``` + +`CURRENT_DATE-P7D` is seven days before today; `CURRENT_TIMESTAMP-PT30M` is thirty minutes ago. The +forward form (`+`) is admitted symmetrically, for "falls due within the next week". This is a moment +vocabulary, not an expression language: exactly one offset on one token — no arithmetic between +fields, no nesting, no further operators. Existing files are unaffected; today's values keep their +meaning exactly. + +> **Normative.** +> The value resolves at **each firing** against that run's clock; a conforming generator MUST NOT +> bake the resolved instant into the generated artifact. The comparison happens in the queried +> field's own shape — a date field against a date, a timestamp field against an instant — the rule +> a `now` default already follows. Each of the following MUST be an authoring error reported at +> generation, never a comparison that silently never matches: an offset the token's shape cannot +> carry (`CURRENT_DATE-PT30M` — a date has no time component); an offset that is not a single +> ISO-8601 duration (a bare `-30M`, a second offset); a moment compared with a non-temporal field; +> and a token of the other shape than the field's, offset or not. A field the queried entity does +> not itself declare — a generated audit column, or a field of a cross-model source — is exempt from +> the shape and temporality checks, which are not resolvable there; the token's own shape then +> decides. + +### 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`](#notifications) 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: + +| token | value | +|---|---| +| `{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 named in [the scope boundary](#the-scope-boundary). + +### 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 } +``` + +> **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). + +#### 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: userAssignments + source: { queue: "user-assignment-requests" } + accept: { type: user.assignment.requested, version: 1 } + create: TenantUserAssignment + map: + messageId: messageId + seats: seatCount + tenant: { lookup: Tenant, by: tenantId, from: tenantId } +``` + +`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`; 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. + +### outbound — departures on a queue or topic + +The mirror of [`inbound`](#inbound--arrivals-from-outside): a record **leaves** on a messaging +channel when an event fires — the announcement another system consumes, under a name both sides +agreed on, declared in the model instead of hidden in a hand-written publisher: + +```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: { onStepCompleted: { process: OrderApproval, step: activate }, when: "channel != internal" } + to: { topic: "order-activations" } + payload: + type: "order.activated" + version: 1 + messageId: "{uuid}" + tenantId: "{tenant}" + reference: number + customer: customer.name +``` + +The entry binds to [the event axis](#the-event-axis--lifecycle-events-and-process-step-events) every +reacting block uses — entity lifecycle events and process-step events — and takes the same `when:` +guard. With no `payload:` the body is the record's own representation, exactly what an HTTP +integration forwards; with one, it is the [declared envelope](#payload--the-declared-envelope), +resolved by the same rules. + +> **Normative.** +> `to:` MUST name exactly one of `queue` / `topic` — two channels are two departures wearing one +> name, none is a promise with nowhere to land; both are authoring errors, mirroring the arrival +> rule. The departure is published **after** the write it reacts to is persisted and is **not** +> transactional with it: a delivery failure MUST be recorded and MUST NOT fail the write — the rule +> the [notify block](#the-notify-block--and-attach-print-sending-the-document-itself) already sets. +> Ordering, exactly-once delivery and an outbox are NOT promised, and a conforming implementation +> MUST say so in its documentation rather than leave an author to assume otherwise. A declared +> payload MUST NOT be widened with fields the author did not name, and an unresolvable payload value +> MUST fail the departure at generation — emitting the record instead would put a different contract +> on the wire under the same name. + +Two departures on the same event have no defined relative order, and neither do two events on the +same record. 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, not +of the format — an implementation that renames destinations per tenant MUST document how an author +declares a destination that is a **contract with someone else**, because without that a departure is +unreachable from outside the deployment and the construct silently means less than it reads. +Conversation-shaped transports — acknowledgement protocols, request-reply correlation, backoff +policy — stay beyond [the scope boundary](#the-scope-boundary), as they are today. + +### 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 flip a `status` relation to `statusWhenFull` / `statusWhenPartial`. 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. + +**Normative.** A roll-up MUST recompute on the child's create, update and delete, whatever its `op` +reduces the children to — the event set is a property of the construct, not of the aggregation. On an +update the affected parent is the one the child names after the edit. The recompute MUST be a +reduction of the children read back for the parent rather than an applied delta, so recomputing after +an edit that touched nothing the roll-up reads writes nothing. + +**Normative.** Moving a child to another parent — an edit of its `via` relation and nothing else — MUST +leave both parents' totals right: the parent that received the child recomputes, and so does the parent +it left, which MUST NOT go on counting a child that is no longer its own. + +A roll-up writes onto the child's own parent. For a total grouped by SEVERAL relations, or one that must live in its own referenceable entity, use [`aggregates`](#aggregates--keyed-cross-entity-totals). Either way the recompute persists only the derived columns, so a concurrent edit to another column of the same row is never reverted. + +The parent MAY be owned by another model. A roll-up whose `via` relation is a cross-model reference maintains a field on an entity that model owns: 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 keep an `actualHours` total on a project the projects model owns. + +**Normative.** For a cross-model parent, the `via` relation's model MUST be declared in [`uses`](#reuse-dont-redefine--uses), and the parent field is validated against the owner's model at generation time rather than against the local document. A conforming generator MUST report a roll-up it cannot resolve (an undeclared model, or a field the owner does not have) instead of dropping it silently. The `capacity` / `balance` / `status` variants remain local-only: they read the parent's own limit and status values, which belong to the model that owns the parent. + +### aggregates — keyed cross-entity totals + +A running total over the rows of one entity, 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 (op: sum) + 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`](#rollups--denormalised-parent-totals) 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**, so the result is a first-class row that other records can reference and that surfaces can list: on-hand stock per product and store, open exposure per customer, remaining allowance per employee and year. + +**Normative.** Every name in `by` MUST be a to-one relation of both `of` and `into`. On each create, update and delete of a source row, a conforming generator MUST upsert the target row for that row's key-tuple and recompute `field` from every source row sharing the tuple - so a re-delivered or replayed event converges on the same value instead of accumulating. A source row with any grouping key unset belongs to no tuple and MUST be ignored. The recompute MUST persist only the aggregate column, leaving every other column of the target row untouched. + +Like roll-ups, aggregates are recompute-on-event: **eventually consistent, not transactionally exact** under concurrency. A target row is ordinary data, so a surface over it carries the same visibility obligations as its source - an aggregate of a `sensitive` field is itself sensitive wherever the target is [personally scoped](#personal-and-partner-surfaces). + +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. 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] +``` + +> **Normative.** A conforming generator MUST run the allocation 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 run with a positive balance spreads it over the payer's open invoices +> in `order`, a run with a zero balance MUST leave the allocation untouched, and a run with a +> negative balance - the payment now covers less than it is allocated to - MUST release the excess +> so that 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. Releases are ordinary junction writes, so the invoice `paid` +> roll-up follows them. Because every run recomputes, a re-delivered or replayed change MUST leave +> the allocation unchanged. + +Allocation remains eventually consistent, not transactionally exact, under concurrency. + +### 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. + +### 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. `map` copies a source value; `defaults` sets a constant — `now` means "today", rendered in the **target field's own shape** (a `date` field gets today's date, a `month` field the current `YYYY-MM`, a `week` field the current `YYYY-Www`). An optional `sourceStatus` flips the source record's status once the target exists. + +`items` has two mutually-exclusive shapes. As an **object** (above) it **mirrors** each source child row 1:1. As a **list** it builds **computed** synthetic lines whose cells are expressions over the **source** record — use it when a create-from must produce a computed line (e.g. one invoice line carrying a period's rolled-up total) rather than a 1:1 clone. The target's line-items child is resolved automatically (it is never named): + +```yaml + items: # computed synthetic lines over the SOURCE record + - name: "Services for {period}" # string: {field} interpolation (or a source-field copy / literal) + quantity: 1 # numeric: an arithmetic expression over the source, rounded to + price: BillableAmount # the target field's scale (a bare literal is a trivial one) + when: "BillableAmount != 0" # optional guard: ==|!= +``` + +A **numeric** cell is an arithmetic expression evaluated the same way as a calculated field or a posting item amount (source identifiers are the field names; a null reads as 0); a **string** cell interpolates `{field}` placeholders, copies a bare source property, or is a plain literal; a **to-one relation** cell copies the source foreign key; a `when` cell guards the whole line. The list form is not available on a scheduled generate. + +#### Prompted input — `prompt:` + +A generates action MAY declare `prompt:` — a small input form shown before the target is created, for the values the source cannot derive. The canonical case is manual payment allocation on an issued invoice: which payment, and how much (an allocation is often partial). It is also the sanctioned way to create a child record on an **immutable** document — the document's panels are read-only by design, while per-record action buttons are not gated on mutability (the same affordance that lets Void work on an issued document). + +```yaml +generates: + - name: allocate-payment + from: SalesInvoice + to: SalesInvoiceCustomerPayment # a composition child of forEntity + label: Allocate Payment + map: + SalesInvoice: id # the clicked record becomes the child's master FK + Customer: Customer # derived values stay mapped — prompt only what cannot be derived + prompt: + - { field: CustomerPayment, required: true } # a to-one relation of the target → a dropdown + - { field: amount, required: true } # a field of the target → a typed input +``` + +- Each entry names a **field or to-one relation of the target** entity. Controls are typed from the target's own definitions, and the target's `dependsOn:` declarations apply unchanged in the dialog — the cascade narrowing and value defaults are authored once, on the target. +- `required: true` MUST be enforced both in the dialog and by the generated endpoint (a missing required input fails the request before anything is written). +- A property MUST NOT be both prompted and mapped/defaulted — every target value has exactly one writer. Prompted values are applied after `map`/`defaults`. +- Constraints: the target MUST be a local entity (no `uses:`) declaring a composition to-one relation to `forEntity`; the action's scope MUST be `entity`; a `timestamp` field MUST NOT be prompted. +- The create goes through the target's ordinary write layer, so its `-created` event, roll-ups and validations fire unchanged. + +#### 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 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: " == "` 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. + +An event-driven create-from binds to **either axis** of the event vocabulary: the source's own +lifecycle (`onTransition` / `onCreate`) or a **process step** — `onStepReached` / +`onStepCompleted: { process, step }`, the same binding notifications, integrations and departures +declare. A step-bound create-from expresses a follow-up document that belongs to a moment in a flow +rather than to a status write. + +```yaml +generates: + - name: log-activation + from: Claim + to: LogEntry + event: { onStepCompleted: { process: ClaimApproval, step: activate }, mode: append } + map: { Claim: id, amount: amount } + defaults: { step: "activate", 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. + +**Normative.** An event-driven create-from 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. + +The `event` map MAY declare **`mode:`** — the cardinality of the trigger. + +**Normative.** `mode` is `once` or `append`; any other value MUST be rejected rather than read as +the default. The default is `once`, which is the at-most-once behaviour above. 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`. + +##### A retired target stops blocking its source + +What a status **means** is declared once, where the nomenclature is seeded — the +[`stage:`](#stage--what-a-status-means-to-the-lifecycle) classification a report's `scope:` resolves +through. The at-most-once guard reads the same classification, so "void and reissue" — a document +retired while keeping its number, and a fresh one raised — is expressible without a second vocabulary +for "this row no longer counts". + +**Normative.** +The at-most-once guard of an event-driven create-from MUST be satisfied only by an existing target +that is **not retired**. A target is retired when its `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, both of which remain readable. +When the target carries no `function: EntityStatus` relation, or its nomenclature carries no `stage:` +classification, the guard MUST remain satisfied by existence alone — the behaviour of a file that +predates this rule is unchanged. +A generator MUST report a warning when an event-driven create-from's target carries a lifecycle whose +nomenclature is unclassified: that is the case where the guard reads as state-aware and is not. +A file that adopts no `stage:` classification MUST regenerate byte-identical output. + +A redelivered event finds the live target it created and returns it; only a retired one is stepped +over. Several retired targets may accumulate over repeated void-and-reissue cycles; the guard is +satisfied by the non-retired one, of which there is at most one at any time. A `draft` target does +not retire — it is a document in progress, not a withdrawn one. A cross-model target is seeded in +its owner model, so its classification is not resolvable at the consumer; the guard stays +existence-only there, the same limit `scope:` has. + +**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 declared reopen — `sourceStatusOnRetire` + +The completion hook has an **inverse**: where the source returns when the target it produced is +retired. Without it, an event-only rule with a completion hook has no reissue path — the retired +target stops blocking, but the source stands at the post-generation status, no lifecycle edge takes +it back through the trigger's guard, and no qualifying event is ever published again. + +```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 source, so it +is announced on the source's transition channel like any other, the ordinary trigger re-fires, the +at-most-once guard steps over the retired invoice, and the replacement is minted — **the reissue is +the ordinary path**, assembled from rules already stated, not a second creation route. Returning to a +status a **person** must act on makes a reissue reviewable rather than immediate: the reopen only +announces the transition; the trigger's own guard decides whether anything follows. + +**Normative.** +A create-from MAY declare `sourceStatusOnRetire`, the status its SOURCE returns to when a target +generated by that rule becomes retired. +It MUST be rejected unless the rule also declares `sourceStatus`, of which it is the inverse, and it +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 unless the rule declares an `event`: a rule triggered only by a button carries no +at-most-once guard, so nothing blocks a replacement and the button is itself the reissue - there is no +trigger for the reopen to re-fire. +It MUST be rejected on an appending rule: such a rule keeps no at-most-once guard, so no slot is ever +consumed for a retired target to free. +It MUST be rejected when the target's retirement cannot be recognised where the rule is declared: +when the target carries no `function: EntityStatus` relation, when its nomenclature carries no +`cancelled` or `void` `stage:` classification, or when the target belongs to another model (its +nomenclature is seeded there). +When a target generated by the rule becomes retired, the source still stands at the rule's +`sourceStatus`, and no target of that source still satisfies the at-most-once guard, the source MUST be +moved to `sourceStatusOnRetire`, and that move MUST be announced on the source's transition channel - +the same channel every other routed status write uses, so a trigger bound to it observes the reopen +exactly as it observes a transition somebody performed. When a target of that source still satisfies +the guard, nothing MUST happen: a creation would hand that target back rather than mint a replacement, +so returning the source would leave it re-opened with a target that still counts against it. +Only the status MUST be written; no other value of the source may change, and the retired target MUST +be left exactly as it is. +When the source does NOT stand at that status, nothing MUST happen. +The move is a transition, so where the source declares a lifecycle graph the edge from `sourceStatus` +to `sourceStatusOnRetire` MUST be declared - that is where the source stands when the retirement +arrives. +A file that declares no `sourceStatusOnRetire` MUST regenerate byte-identical output. + +The reopen is idempotent by state, not by a marker: the source stands at `sourceStatus` and no target +of it still counts — the second condition is what closes at-least-once redelivery, and it equally +disposes of the retirement of an older target from a previous cycle. A source that has since travelled +further down its own lifecycle is not overruled. Several rules on one source each return it to their +own status and each acts only from their own `sourceStatus`, so they cannot fight over it. + +Prefer this over [`posts`](#posts--derived-rows-on-an-event) 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`](#wait--park-the-process-on-a-data-event) step when the step is really waiting for a person to remember to click: an unclicked record parks its process instance indefinitely. + +### 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: ==|!= + 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`](#lifecycle--the-legal-status-graph), 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](#the-notify-block--and-attach-print-sending-the-document-itself) — "on Void, tell the customer" — which is attempted after the flip has committed and can never 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 ` == ` 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" } +``` + +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 entity 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 +``` + +`event` is either a status value of the source entity - the rows are emitted when a record transitions into it - or `create`. A `set` value is a constant, `.`, `item.`, or an expression over those, so a sign flip (`-item.Quantity`) or a derived amount needs no hand-written code. Several entries under one event emit several rows per item: a stock transfer posts an outgoing and an incoming movement from one document. + +**Normative.** `idempotentBy` MUST name a to-one relation on the target pointing back at the source. A conforming generator MUST both write that back-reference and use it to skip an event whose rows already exist, so a re-delivered event does not double-post. Rows MUST be written through the target's ordinary write path, so the target's own numbering, validations and derived fields apply to them. + +`posts` differs from [`generates`](#generates--create-from) in intent: `generates` creates ONE document from a user action, while `posts` emits N mapped rows automatically and idempotently on an event. + +### 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`](#relations) is an authoring-time copy matched by equality, a [`decision`](#processes) 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](#status-references--name-not-number) 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`](#processes) 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. + +### 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](#the-scope-boundary), and an authoring assistant is required to say when a requirement crosses it. + +## Scoped surfaces & roles + +### Personal and partner surfaces + +On top of the regular screen (which is unaffected), an entity's records can be scoped to the **logged-in user** or to an **external business partner**. Each scope adds a second generated controller that filters rows server-side — never merely hiding them in the UI. + +```yaml +entities: + - name: Employee + identity: email # the field matched against the login username + - name: Timesheet + relations: + - { name: Employee, kind: manyToOne, to: Employee, personal: true } # the record owner + - { name: Customer, kind: manyToOne, to: Customer, partner: true } # an external-partner owner + fields: + - { name: rate, type: decimal, sensitive: true } # hidden + ignored on the scoped surfaces +``` + +- **`identity: `** on the owner entity names the string field (conventionally a unique e-mail) matched against the login username. With no matching record, the scoped surface is simply empty — never an error. +- **`personal: true`** on a record-owning to-one relation generates a personal controller: reads are filtered to the caller's mapped record, the owner FK is forced server-side on writes, and a foreign record is not found. At most one `personal:` relation per entity; the target must declare `identity`; never put it on a composition parent — composition children inherit the owner's scope through their parent. +- **`partner: true`** is the exact mirror for **external** parties (customers, suppliers) on a partner surface, gated by the corresponding partner roles. An entity may carry both a `personal:` (staff) owner and a `partner:` (external) owner at once. +- **`personalReadOnly: true`** (with `personal: true`) makes the personal surface see-only: create / update / delete are refused and the scoped pages render without new / edit / delete. Use it for records an owner may see but never author — a balance, a payslip. Composition children inherit it through the parent. +- **`sensitive: true`** on a field (never the PK, the identity field, or the owner FK) strips it from the scoped responses and ignores it on scoped writes — use it for billing rates and amounts the owner must not see. It is enforced server-side, not just hidden. + +> **Normative.** +> A scope's safety is by **construction, not by a filter**: the scoped controller only ever queries the caller's own rows, and a sensitive field is on an allow-list the scoped serialiser never includes. A field hidden only in the UI is cosmetic; `sensitive` is a server-side guarantee. + +A user task can also be routed to the record owner's inbox with the literal `assignee: personal`, which resolves the owner through the `personal:` relation, or to whoever a relation walk off the record names — `assignee: { path, fallback }`, whose walk likewise ends at an `identity`-declaring entity (see [processes](#task-assignment)). + +### permissions + +```yaml +permissions: + - { role: Sales, can: [Customer:read, Order:create] } + - { role: Manager, can: [Order:approve] } +``` + +Generates a deduplicated set of **roles**. It deliberately does **not** emit URL-shaped access rules — those belong to whichever downstream template materialises the UI, because only that template knows the paths it publishes. The `can: [Resource:action]` tokens are an authoring hint to those downstream generators about which actions each role may invoke. + +## Data, seeds & naming + +### seeds + +```yaml +seeds: + - name: order-statuses + entity: OrderStatus + rows: # inline rows: small nomenclatures + - { id: 1, name: DRAFT, stage: draft } # what the status MEANS to the lifecycle + - { id: 2, name: ISSUED, stage: live } + - { id: 8, name: CANCELLED, stage: cancelled } + - { id: 9, name: VOIDED, stage: void } + - name: cities + entity: City + rows: + - { id: 1, name: Sofia, Country: 34 } # a foreign key by the relation's authored name (case-sensitive) + - name: countries + entity: Country + file: data/countries.csv # large sets: a developer-owned CSV in a subfolder + - name: uoms-bg + entity: UoM + language: bg # a translation seed for a multilingual entity + rows: + - { id: 8, name: "Килограм" } +``` + +Generates a seed-import descriptor + CSV per seed. Two shapes: + +- **`rows:`** — inline seed data, right for small nomenclatures whose values are part of the flow (statuses, methods). +- **`file: data/.csv`** — an authored CSV under a `data/` subfolder, right for bulk nomenclatures and prepopulated demo data. A foreign key is set by the relation name (`Country: 34`). + +> **Normative.** +> Row keys must match a field name, a **to-one** relation name (a collection has no column to set), or the `stage` marker below, **exactly** (case-sensitive). A key matching none of those is an authoring error, reported with the nearest declared name — see [unrecognised keys](#unrecognised-keys). Accepting it would drop the column, and a dropped NOT NULL foreign key makes the import skip **every** row: a nomenclature that imports as zero rows, behind a fully green pipeline. + +A seed with `language: ` is a **translation** seed: it fills the per-language values of a `multilingual: true` entity, carrying the base row's `id` plus the translatable fields only. + +#### stage — what a status means to the lifecycle + +A seed row of a **status nomenclature** (the target of a `function: EntityStatus` relation) may classify itself with `stage`, a closed vocabulary: + +| Stage | Meaning | +| --- | --- | +| `draft` | Nobody has issued it yet — visible to its author, not yet economically real. | +| `live` | It counts: issued, sent, paid — anything in normal circulation. | +| `cancelled` | Withdrawn before it ever became live. | +| `void` | Deliberately retired while keeping its number — out of circulation by design. | + +The classification exists because a status **id is data, but its meaning is not**: without it, "the rows that count" can only be expressed as a predicate over positional ids, repeated in every report and guard that needs it. With it, the meaning is declared once, where the nomenclature is defined, and consumers resolve it — chiefly a [report's `scope`](#lifecycle-scope). + +> **Normative.** +> `stage` is **metadata, not data**: it MUST NOT be emitted as a column of the seeded table. A row carrying `stage` MUST also carry the entity's primary key (the stage classifies that id). A value outside the vocabulary is an authoring error. An entity that declares its own `stage` property cannot be classified this way — the collision MUST be reported rather than resolved by guessing. + +#### Status references — name, not number + +Everywhere the file names a status — a [transition's](#transitions--guarded-status-flips) `from` and `setStatus`, a relation's `init`, a status-setting step's `value`, [`abortOn`](#aborton--cancel-the-instance-on-a-terminal-status)'s `status`, a [check's](#checks--declarative-validations) `status` / `setStatus`, [`immutableWhen`](#immutablewhen--immutable--user-write-immutability), a [`lifecycle`](#lifecycle--the-legal-status-graph) edge, a [posting's](#postings--source-document-to-ledger) event guard, a [report's](#reports) `filter` — the seeded **name** may be written instead of the id: + +```yaml +transitions: + - { name: VoidInvoice, forEntity: Invoice, from: [ISSUED, SENT], setStatus: VOIDED, when: "Paid == 0" } +reports: + - { name: OverdueInvoices, source: Invoice, filter: "balance > 0 AND Status != VOIDED", measures: ["sum(total)"] } +``` + +A status id is **positional**. Inserting a status into the middle of a nomenclature shifts every later id, and every guard authored against the old numbering keeps producing well-formed output that now means a different status — a defect no downstream check can see, because the emitted constant is valid. A name cannot be silently retargeted. + +> **Normative.** +> A status name is resolved against the seed rows of the nomenclature it belongs to, and the resolution happens before any other validation, so every later rule sees the resolved id. An unresolvable name is an authoring error naming the known statuses — never a silently-kept token. Numeric ids remain valid everywhere. A name has no ordering, so an ordering comparison against one (`Status >= ISSUED`) is an authoring error; express "the rows that count" as a [`scope`](#lifecycle-scope). A nomenclature owned by another model is seeded there, so a name cannot be resolved against it — such a reference is an authoring error directing the author to the numeric id. + +### Multilingual data + +Two independent things get translated: the **data** in multilingual entities, and the generated **UI labels**. + +#### Data + +Mark an entity `multilingual: true` and its string-typed properties gain per-language values in a sibling translation table. Every read overlays the translated values for the caller's requested language; untranslated content falls back to the default language. Author the translations as [seeds](#seeds) with a `language:` code. + +```yaml +languages: [en, bg] # top level: the languages THIS module provides translations for +entities: + - name: UoM + kind: setting + multilingual: true + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } +``` + +The set of languages the whole stack supports is a platform concern, never defined per module. The top-level `languages:` only declares which languages this module provides. + +A [report](#reports) reads the same data, so it reads it in the same language: a column bound to a translatable property is shown translated, whether the report is rooted at the multilingual entity or reaches it through a relation. A report grouping by a multilingual nomenclature therefore shows the same term as the pages beside it — before this was stated, a status column could read `DRAFT` next to a list reading the translated word, from the same record. + +> **Normative.** +> Every read of a translatable property of a `multilingual: true` entity is served in the caller's requested language, and a report column bound to such a property is such a read. What a report **matches** is unaffected: a report's `filter:`, its [`scope`](#lifecycle-scope) and any condition applied to it are evaluated against the stored, untranslated values — so translating content can never change which rows a report returns, only how they read. A property with no translation for the requested language, and a caller who requested none, both read the stored value. + +#### UI labels + +Generation also emits a per-project translation catalogue for every generated label: entity names (a humanised singular plus a plural form), field labels, form and report names, and report column headers. The default locale is generated for you; a translator adds a sibling locale folder with the same keys. The UI renders through these keys, falling back to the baked default label for any key a locale has not translated. + +### Naming and tables + +- The top-level `name:` is the intent's identity. Single-file outputs are named after it; the physical table prefix is its upper-snake form. +- **Physical table names are intent-prefixed**: `_` in upper-snake (`ORDERS_ORDER`), applied consistently across the data model, reports and seed imports. This dodges reserved words and cross-project collisions in a shared schema. +- Property names are PascalCase in the generated model (`loanedOn` → `LoanedOn`); physical columns stay `UPPER_SNAKE`. You author in lower camelCase. +- A multilingual entity's translations land in a sibling `_LANG` table. + +Because every table is intent-prefixed, many independent intent models share one schema without colliding — the foundation of a [multi-model application](#multi-model-applications). + +## Appendix A: DSL index + +One line per construct, linking into the chapters above. + +| Construct | What it gives you | +| --- | --- | +| [`entities`](#entities--fields) | tables + CRUD UI + a generated data layer & API | +| [field / relation attributes](#fields) | uniqueness, layout, read-only, dropdown filtering, cascades | +| [`entities.unique`](#unique--a-business-key-over-more-than-one-field) | a business key spanning more than one field or to-one relation | +| [`visibleTo`](#role-scoped-field-visibility--visibleto) | an allow-list of roles that may read one field, enforced on the wire | +| [`pattern`](#fields) | an input-format regular expression enforced in the UI and server-side | +| [`defaultValue`](#defaultvalue--field-defaults) | a field default: column default, satisfies `required`, and seeds a new row in the UI | +| [`dependsOn`](#relations) | link a dropdown to another, copy a value from the referenced record, or default a line from the open document header | +| [`function`](#function--the-presentation-role) | an explicit presentation role (Document, Setting, ...) | +| [`label`](#label--a-stored-display-name) | a stored, read-only display name for lookups | +| [`number`](#document-numbering) | a platform-numbered document field drawing from a named series | +| [`format`](#fields) | a named input-format preset (`email`) over `pattern` | +| [`checks`](#checks--declarative-validations) | cross-field / cross-line validations | +| [`immutableWhen` / `immutable`](#immutablewhen--immutable--user-write-immutability) | reject user writes in a status / append-only | +| [`lifecycle`](#lifecycle--the-legal-status-graph) | the whole legal status graph, enforced on every status write | +| [`locksWithMaster`](#lockswithmaster--a-child-collection-that-outlives-its-masters-lock) | a child collection that stays writable while its master is locked | +| [`history`](#history--the-change-trail) | a shadow, append-only trail of every write: property, old and new value, who, when, user or system | +| [`hierarchy` / `leafOnly`](#hierarchy--leafonly--tree-entities) | tree entities, leaf-only references | +| [calculated fields](#calculated-fields) | server + UI-evaluated expressions, date helpers, call-outs | +| [`relations` / `composition`](#relations) | associations and master-detail compositions | +| [`kind: subset`](#subset--a-value-set-not-a-row-set) | a set-valued reference to a same-model lookup: the record stores the selected keys as one comma-separated, ascending, de-duplicated value; empty = absent | +| [`uses`](#multi-model-applications) | reuse entities owned by another intent model | +| [`related`](#related--list-the-records-that-reference-this-entity) | a read-only register of the records referencing this entity, on its own page | +| [`processes`](#processes) | workflows: user tasks, decisions, waits, boundary timers | +| [task assignment](#task-assignment) | route a user task to a role, the record owner, or a relation walk | +| [`abortOn`](#aborton--cancel-the-instance-on-a-terminal-status) | cancel the running instance when the document reaches a terminal status | +| [`function: Attachment` / `Snapshot`](#attachments-and-snapshots) | a Files panel / immutable versioned printed copies | +| [`forms`](#forms) | task data-entry pages | +| [`actions`](#actions--custom-buttons) | developer-defined buttons opening custom pages | +| [`view`](#view--calendar-range-slots) | an additional calendar / range page, or a slot-booking page | +| [`documentItemsLayout: chat`](#documentitemslayout-chat--conversation-threads) | render a document's items as a chat thread | +| [print `filter` / `match`](#row-filtering--filter--match) | render one items collection into several purpose-grouped tables | +| [`{{a\|b}}`](#alternative-operands-in-a-placeholder) | a print placeholder's alternative paths - first non-blank wins | +| [`fileName`](#naming-the-rendered-file--filename) | the name a snapshot's minted copies are stored under, and the name an attached render arrives under | +| [`reports`](#reports) | aggregations, charts, dashboard KPI tiles, balance reports | +| [`reports.parameters`](#parameters--user-set-inputs) | user-set inputs a report is read with - a period, a threshold, a name | +| [`kind: statement`](#statement-reports--account-to-line-mappings) | a statutory statement - declared lines over the balance ledger: account selectors, net-by-side measures, line arithmetic | +| [`scope`](#lifecycle-scope) | which lifecycle rows an aggregating report counts | +| [`widgets`](#widgets--custom-dashboard-tiles) | custom KPI / embedded-page dashboard tiles | +| [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 bare path resolved against the row | +| [`attach: recordPrint`](#one-document-many-recipients-attach-recordprint) | in a fan-out: attach the ANCHOR record's document, rendered once, to every recipient's message (`{record.}` addresses that record) | +| [notify link placeholders](#links-back-to-the-application-recordurl-inboxurl-appurl) | `{recordUrl}` / `{inboxUrl}` / `{appUrl}` - a message that carries the way back into the application | +| [`schedules`](#schedules) | cron: notify or generate records per matching row; `where` values may be moments relative to the firing | +| [`integrations`](#integrations--outbound-http) | outbound HTTP on a data change | +| [`inbound`](#inbound--arrivals-from-outside) | records arriving from outside: a webhook, a queue/topic message, a dropped file | +| [`inbound.accept` / `inbound.map`](#reading-an-arrival-as-an-envelope--accept--map) | read an arrival as an envelope: gate on its type and version, map its keys onto the record, resolve a business key to a relation | +| [`outbound`](#outbound--departures-on-a-queue-or-topic) | a record leaves on a queue or topic on an event - the record itself, or a declared payload | +| [`payload`](#payload--the-declared-envelope) | the declared envelope an outward-facing message carries (integrations and outbound alike), instead of the record as stored | +| [`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 | +| [`generates`](#generates--create-from) | one-click document-from-document cloning, mirrored or computed lines | +| [`generates.prompt`](#prompted-input--prompt) | collect the input the source cannot derive before the create | +| [`generates.event`](#event-driven-creation--event) | mint the document on a source event instead of a click, at most once - a lifecycle event or a process step | +| [`generates.event.mode`](#event-driven-creation--event) | one target per source (`once`, default) or one per delivered event (`append`) | +| [`generates.sourceStatusOnRetire`](#a-declared-reopen--sourcestatusonretire) | the completion hook's inverse: retiring the generated target returns the source, and the trigger re-fires | +| [`transitions`](#transitions--guarded-status-flips) | guarded on-demand status flips (void / cancel / reopen) | +| [`postings`](#postings--source-document-to-ledger) | declarative source-document to balanced-document posting | +| [`aggregates`](#aggregates--keyed-cross-entity-totals) | keyed cross-entity totals materialised into their own entity | +| [`checks: kind: guard`](#kind-guard--a-precondition-over-an-aggregate) | a precondition over an aggregate: block, mark for a task, or reject | +| [`resolves`](#resolves--fill-a-relation-from-a-register-valid-on-a-date) | fill a to-one from the register row valid on the record's date | +| [`posts`](#posts--derived-rows-on-an-event) | derived ledger rows emitted idempotently on an event | +| [`personal` / `partner`](#personal-and-partner-surfaces) | per-user and per-partner row-scoped surfaces | +| [`seeds`](#seeds) | initial data, CSV-backed sets, translations | +| [`stage`](#stage--what-a-status-means-to-the-lifecycle) | classify a status: draft / live / cancelled / void | +| [status names](#status-references--name-not-number) | reference a status by its seeded name, not its positional id | +| [`multilingual` / `languages`](#multilingual-data) | translation tables + read-time translation overlay, on entity reads and report columns alike | +| [`permissions`](#permissions) | roles | + +### Planned — recognised but not yet implemented + +The following are parsed (or reserved) but not yet materialised by a generator; a conforming tool rejects or ignores them with a clear message rather than failing obscurely: + +- Reserved `function` values for upcoming presentations (`Board`, `Gantt`, `Timeline`). +- **`manyToMany`** — parsed but never materialised; the supported shape is the [explicit intermediate entity](#many-to-many). +- **Cross-model status names and stage scopes** — a nomenclature owned by another model is seeded there, so its stages and names cannot be resolved from the referencing file; such references are rejected with the numeric-id fallback named.