This version adds to 1.1:
- Lifecycle-aware aggregation —
stageclassifies what a status seed means (draft / live / cancelled / void), a report'sscopestates which lifecycle rows it counts (an aggregating report over a stage-classified nomenclature defaults to the live rows), and every site that names a status accepts the seeded name instead of its positional id. locksWithMaster— a composition child collection that stays writable while its master is locked (settlement against an issued invoice).generatesprompted input — a create-from action may collect the values the source cannot derive before the create; andgenerates.itemsgains a computed list form building synthetic lines from expressions over the source.defaultValuesemantics completed — a default is the column default, the reason arequiredfield is not demanded from the caller, and the value a new row is seeded with in the UI.- Print-template row filtering —
filter/matchrender one items collection into several purpose-grouped tables (earnings beside deductions, debit beside credit). - The scope boundary — what a conforming file deliberately never models (protocol, algorithm, statutory form), each category's designated hand-off, and the honesty a conforming authoring assistant owes when a requirement falls outside the format.
- Clarifications — a
viewadds a page and never takes the entity's editing surface away, including a document's line items rendered on a calendar;nowdefaults render in the target field's own shape;|formatin a label formatsmonthvalues too. - Aligned with deployed practice —
number:references a series by name only, with the shape declared outside the model; theformat:named input preset; a schedule's source may be cross-model viamodel:.
Version 1.1 and version 1.0 remain unchanged. A conforming 1.1 file is a
conforming 1.2 file with one deliberate exception: the earlier drafts' number.format /
number.scope keys, which no implementation adopted, are rejected at parse — a number's shape
is configuration, not intent, and it moved out of the model (see
Document numbering).
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 RFC2119 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.
A rendered, navigable version of this specification is published at intentfile.org. This file is the normative source.
- Overview
- Entities & fields
- Relations & multi-model
- Processes & forms
- Presentation
- Declarative glue
- Scoped surfaces & roles
- Data, seeds & naming
- Appendix A: DSL index
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.
| 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 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 and inbound 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: a format check, not a semantic one |
a calculated-field call-out or a service-task delegate, hand-written in the project's custom folder |
| Statutory and designed form — the exact mandated layout of a printed document | the print template 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.
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.
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.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.
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.
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 }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 adefaults: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 - writeto: member.email. Braces are only for{...}interpolation insidesubject/bodytext. - An event-binding key is
event:, neveron:- YAML 1.1 resolves a bareon(andoff/yes/no) to a boolean. An action key isdo:.
Every entity becomes a table, a generated data-access layer + API, and a UI page. Primary keys are integers; composition is opt-in.
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:
- { 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) |
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) |
number |
turn a string field into a platform-numbered document field (see Document numbering) |
sensitive |
strip this field from scoped (personal / partner) surfaces (see Scoped surfaces) |
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 auuidor string primary key is rejected.uuidis valid for non-PK fields.
| Attribute | Effect |
|---|---|
audit: true |
adds the four standard audit columns, populated automatically |
multilingual: true |
makes string properties translatable (see multilingual data) |
label: |
a stored, read-only display name (see label) |
function: |
an explicit presentation role (see function) |
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) |
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:
- 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.
defaultValue states what a field holds when nobody supplies a value:
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, 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.
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 asdaysBetween,businessDaysBetweenandmonthsBetweenare available.
- { 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, declareimports:on the entity:
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 attribute instead of a calculated action.
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.
# 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 | issueseries(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 isper: Company— two legal entities never share a counter). The value never appears in the number; it only selects which sequence to draw from.stampOn—createstamps the real number on insert;issueputs 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.seriesis mandatory;permust 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.
A stored, read-only Name recomputed on every write, so lookups and dropdowns show a meaningful label instead of a raw id:
- 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.
Optional, and authoritative when set; inferred from structure otherwise.
- 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 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.
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:
- 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 and stores the result as the next version; the copies appear in the same panel, download-only (never uploaded or deleted by the user):
- 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 <Master>SnapshotGenerator) as the delegate: of a service task 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 and an issue-stamped document number.
- name: Country
kind: settingkind: 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.
Row-level and document-level validations, enforced on write / on a status transition, with an authored message:
- 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.
A guard compares a keyed aggregate against a minimum and decides what a violating write does:
- 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: 4outcome 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 reads the marker and routes the record - the two constructs compose, and the guard is the part that computes.
- 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 finalisedimmutableWhen 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.
An entity's immutability covers that entity. A composition child is a different entity, so a master that locks says nothing about whether its child collections should:
- 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.
locksWithMasterdefaults to true, so a child that says nothing keeps freezing with its master. A generator MUST NOT extend a master's user-write immutability to a child collection declaredlocksWithMaster: 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.
- 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:
- { 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: trueon a to-one makes the FK NOT NULL but keeps the entity top-level with its own perspective (a plain dropdown).composition: trueon 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 amanyToOne/oneToOnecan be a composition; an entity's first composition to-one is its composition parent. Declare the inverseoneToManyon the master so the child is managed as its detail.
Composition is opt-in — most required FKs are plain associations, and composition is explicit.
- { 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: EntityStatusmarks the relation as the entity's managed status badge;init:seeds its default at the database level (a race-free start). This relation is whatimmutableWhen,transitionsandpostingskey on.dependsOnlinks one dropdown to another:filterBynarrows the options to those matching the parent selection;valueFromcopies a value from the referenced record (a snapshot).- Conditional source (field only):
valueFrommay be{ by: <path>, cases: { <literal>: <property> }, default: <property>? }— the copied property is picked by a classifier resolved from thebypath: an own property, a one-hop<Relation>.<property>(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 nodefault= no copy.
- 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):
relationmay be a two-segment path<Parent>.<Relation>— 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:
- 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.
wherefilters the dropdown to options matching a static condition.
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:
- 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.
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.
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::
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, itsmodel:must be listed inuses:, and it cannot becomposition: 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.
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:
entities:
- name: Customer
group: partners # appears under the "Partners" group in the shared shellThe 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.
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:
- Generate the owners (leaves) first, then their consumers.
- Publish everything — every owner must be live for a consumer's cross-model dropdown to resolve.
- Open the shared shell — one grouped sidebar over every module.
Because table names are intent-prefixed, the projects share one schema without colliding.
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.
Steps flow linearly in declaration order. Any step may override its successor with args: { next: <step | end> } — 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-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), and delegate (a handler referenced by name with injected fields — hand-written, or a generated one such as a snapshot generator). 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
notifyservice 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.
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.
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:
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: <Entity>(exactly one;onDeleteis rejected — a deleted record cannot resume a wait) names the resuming event.via: <relation>— when the event entity is not the trigger entity itself: the event entity's to-one relation that walks back to the trigger entity (hereCaseMessage.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.
Two optional attributes on a userTask's args give a flow a notion of time. Both route then like a decision branch:
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: donetimeout: { after: <ISO-8601 duration>, then: <step> }— a non-cancelling boundary timer (PT4H,P3D): after the duration thethenbranch runs (a reminder / escalation) while the task stays claimable.expire: { until: <field>, then: <step> }— a cancelling boundary timer driven by adate/timestampfield of the trigger entity: when the moment passes, the task is withdrawn and the flow continues atthen. The date is re-read at task entry, so editing it mid-flow moves the timer. Adatenames the last valid day (the timer fires at the start of the next day); anullarms a far-future date so the timer never effectively fires.
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 button or a workflow status set publishes):
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'sfunction: EntityStatusrelation; reaching any of them aborts.then:(optional) — a single cleanupserviceTask(setField/setRelationField) that runs only on the abort path; it must not be reachable from the main flow. Omitted (orend) 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: { onCreate | onUpdate | onDelete: <Entity>, when: "<expr>" } 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
whenguard (a singlefield ==|!= literal), starts the process, and writes the instance identifier back.
The business key defaults to the entity PK but is configurable:
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).
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).
forms:
- name: ApproveOrder
forEntity: Order
fields: [orderDate, total, customer.name] # fields or one-hop relation.field
actions: [approve, reject] # complete the taskGenerates 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).
Developer-defined buttons that open a custom page — the escape hatch when a workflow or a generated screen is not enough:
actions:
- name: OpenPortal
forEntity: Order
scope: entity # per-record; 'page' = a whole-view toolbar button
page: /custom/portal.htmlBeyond 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:
- 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 WHEREGenerates one report per reports[] entry, rooted at source, with a fully materialised query:
- a plain field resolves to a source column;
- a
relation.fieldpath (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 — usecustomer.idfor 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 sortableYYYYMMinteger) oryear(field); - a measure
count(*)/sum(...)/avg/min/maxbecomes 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.
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 the nomenclature declares — not a predicate over positional ids:
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.
scopeisallor a single stage name, and is only meaningful over a source declaring afunction: EntityStatusrelation. A stage scope restricts the query to the statuses that stage classifies;alladds no restriction.With no
scope, a report counts every row except when all of the following hold, in which case it counts theliverows: it aggregates (declares measures, or is a balance report); its source's nomenclature is stage-classified; and neither its dimensions nor itsfilterreference 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.
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:
reports:
- name: MonthlyRevenue
source: Order
dimensions: ["month(orderDate)"]
measures: ["sum(net)", "sum(vat)", "sum(total)"]
chart: bar # bar | line | pie | doughnut | polarArea | radarkind: balance produces an opening / period / closing debit + credit report per dimension, with runtime From/To date pickers:
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"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:
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:valuenames a measure;atpins dimension columns. Thenowtoken resolves at view time, type-aware (currentYYYYMMon amonth(x)dimension, current year onyear(x), today on a date column).kind: list— the report's firstlimitrows (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.
The dashboard's escape hatch, when the report machinery cannot express the content:
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 pagekind: 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: (with a calendar: / slots: descriptor) places an entity's records on a time surface:
- 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.
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 |
|---|---|
/<Entity> |
the calendar, or the slot picker |
/<Entity>/list |
the entity's own browse page (list / master / document list) |
/<Entity>/create, /<Entity>/<id>/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.
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:
- 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.
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:
- 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)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/<Entity>/Print/en/standard.printThe 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:
{{document.<Property>}} the document's own field
{{document.<Relation>}} a to-one relation's display label
{{document.<Relation>.<Field>}} a field of a related record
{{<Property>}} 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.
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:
<row gap="16">
<stack>
<text style="subtitle">Earnings</text>
<table source="items" filter="Kind" match="BASE | ENTRY">
<column width="3*" label="Earning">{{Name}}</column>
<column width="*" align="right" label="Amount">{{Amount}}</column>
</table>
</stack>
<stack>
<text style="subtitle">Deductions</text>
<table source="items" filter="Kind" match="CONTRIBUTION | TAX">
<column width="3*" label="Deduction">{{Name}}</column>
<column width="*" align="right" label="Amount">{{Amount}}</column>
</table>
</stack>
</row>
<if source="status" match="POSTED | SENT">
<text>Final document</text>
</if>
filter="<path>"on atable(or a row-expandingfor) names a path resolved in each row's own scope. Withoutmatch, 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.matchon anifcompares the node's resolvedsourceagainst the listed literals instead of testing truthiness.
Normative. A filtered
tablerenders its column definitions unchanged and one row per kept element, in source order; elements failing the filter are skipped entirely. A filteredforexpands its children only for the kept elements. An unresolved or null filter value never matches amatchlist, and is falsy without one. Comparison is by the value's plain string form (numbers by their canonical rendering, booleans astrue/false) — no coercion beyond that, no operators, no expressions.filterabsent renders every element;matchwithoutfilteron atable/forhas no effect; an emptymatchis treated as absent;matchon anifwithout asourcekeeps 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.
Beyond the model artefacts, the intent declares glue: the common integrations and background activities that would otherwise be hand-written code. The abstraction is one line:
glue = on
<event>do<action>, with action parameters bound by resolver paths.
Three axes:
- Event — an entity
onCreate/onUpdate/onDelete(with an optionalwhen:guard), a schedule (cron), or an inbound webhook. - 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.
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:, neveron:— YAML 1.1 resolves a bareon(alsooff/yes/no) to a boolean, so anon:key is silently swallowed. An action key isdo:.
Email on an entity lifecycle event.
notifications:
- name: orderUpdated
event: { onUpdate: Order } # exactly one of onCreate / onUpdate / onDelete
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.
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 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.
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 languageNormative.
attach's only value isattach: printon any other entity rather than send a message without the document it promised.languagenames 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[].notifyMUST 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.
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.
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 documentNormative. 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: printthen refers to the ROW's document, and the row MUST satisfy the document requirement above.
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.
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 }Cron reminders / cleanups — query an entity and act per matching row. Exactly one of notify or generate per row.
schedules:
- name: staleOrders
cron: "0 0 9 * * ?"
entity: Order # add model: <alias> 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 outThe schedule's entity may be owned by another model: add model: <alias> (an alias declared in 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:
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: daymap 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).
Tell another system on an event.
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.
Another system tells us — a webhook that ingests a JSON payload into an entity.
inbound:
- { name: leadHook, path: /webhooks/lead, create: Lead }Generates an endpoint that deserialises the request body into the entity and saves it. The v1 action is create (ingest).
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 on the child's create / delete. 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.
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. 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, 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.
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:
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 totalWhere rollups denormalise a total onto the parent of a composition - one key, the child's own parent relation - an aggregate is keyed by several relations and lands in its own entity, 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.
Changing a grouping key MOVES a source row between tuples, and both sides MUST be repaired: the tuple the row moved into is recomputed from the row's own change, and the tuple it left MUST be recomputed as well, so it no longer carries a contribution from a row that is no longer in it. A conforming generator therefore has to observe the row's PREVIOUS grouping keys - they are not recoverable after the write. A tuple whose last contributing row leaves keeps its target row with a zero total; the row is not removed.
Auto-allocate payments across open invoices — the accounts-receivable pattern. Pair it with a rollups sum entry that maintains paid / balance / status.
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]Generate one child row per day / week / month of a span on the parent:
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: periodsA span change replaces the generated child set — never mix hand-entered rows into an expanded child.
One-click "create a document from this document":
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 createdAdds 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):
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: <SourceField> ==|!= <number>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.
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).
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: trueMUST 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 toforEntity; the action's scope MUST beentity; atimestampfield MUST NOT be prompted. - The create goes through the target's ordinary write layer, so its
-createdevent, roll-ups and validations fire unchanged.
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.
transitions:
- name: VoidInvoice
forEntity: Invoice # must declare a function: EntityStatus relation
from: [3, 4] # allowed source status ids
setStatus: 8 # the target status id (not one of `from`)
when: "Paid == 0" # optional guard: <Field> ==|!= <number>
label: Void
icon: ban
notify: # optional: tell the counterparty once the flip has committed
to: Customer.email
subject: "Invoice {number} was voided"
body: "The invoice has been cancelled."A transition MAY carry a notify block — "on Void, tell the customer" — which is attempted after the flip has committed and can never fail it.
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.
postings:
- name: salesInvoicePosting
event: { onTransition: SalesInvoice, model: sales-invoices, when: "Status == 3" }
creates: JournalEntry
backReference: SalesInvoice
map: { entryDate: date, reason: "Sales invoice {number}" }
rule: { entity: PostingRule, match: { documentType: "Sales Invoice" } }
items:
- { Account: rule(receivableAccount), debit: "Net + Vat" }
- { Account: rule(revenueAccount), credit: "Net" }
- { Account: rule(vatAccount), credit: "Vat", when: "Vat != 0" }The trigger is onTransition — a status write, with the when status guard mandatory — or onCreate, for a source document with no status lifecycle at all: a booked payment's only event is being created, and it is exactly the document an accountant expects posted. when stays optional there as a plain <Property> == <number> guard; an onCreate posting reacts to the source's create event.
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:
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 EntryEmit rows into a ledger or journal entity when a document reaches a status, mapped from the document and its line items:
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.Idevent 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, <Source>.<field>, item.<field>, 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 in intent: generates creates ONE document from a user action, while posts emits N mapped rows automatically and idempotently on an event.
- Curated vocabulary, not a general DSL. Real logic is a
scriptstep 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.namezfails fast, not at runtime. - The boundary is stated, not discovered. What deliberately lives outside the format — protocol, algorithm, statutory form — and the hand-off each one takes is specified in the scope boundary, and an authoring assistant is required to say when a requirement crosses it.
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.
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 surfacesidentity: <field>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: trueon 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 onepersonal:relation per entity; the target must declareidentity; never put it on a composition parent — composition children inherit the owner's scope through their parent.partner: trueis the exact mirror for external parties (customers, suppliers) on a partner surface, gated by the corresponding partner roles. An entity may carry both apersonal:(staff) owner and apartner:(external) owner at once.personalReadOnly: true(withpersonal: 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: trueon 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;
sensitiveis 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 (see processes).
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.
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/<name>.csv— an authored CSV under adata/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 or relation name exactly (case-sensitive). A key matching neither is an authoring error — a silently dropped column becomes a NOT NULL failure at import time.
A seed with language: <code> 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.
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.
Normative.
stageis metadata, not data: it MUST NOT be emitted as a column of the seeded table. A row carryingstageMUST 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 ownstageproperty cannot be classified this way — the collision MUST be reported rather than resolved by guessing.
Everywhere the file names a status — a transition's from and setStatus, a relation's init, a status-setting step's value, abortOn's status, a check's status / setStatus, immutableWhen, a posting's event guard, a report's filter — the seeded name may be written instead of the id:
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 ascope. 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.
Two independent things get translated: the data in multilingual entities, and the generated UI labels.
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 with a language: code.
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.
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.
- 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:
<INTENT>_<ENTITY>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 stayUPPER_SNAKE. You author in lower camelCase. - A multilingual entity's translations land in a sibling
<TABLE>_LANGtable.
Because every table is intent-prefixed, many independent intent models share one schema without colliding — the foundation of a multi-model application.
One line per construct, linking into the chapters above.
| Construct | What it gives you |
|---|---|
entities |
tables + CRUD UI + a generated data layer & API |
| field / relation attributes | uniqueness, layout, read-only, dropdown filtering, cascades |
pattern |
an input-format regular expression enforced in the UI and server-side |
defaultValue |
a field default: column default, satisfies required, and seeds a new row in the UI |
dependsOn |
link a dropdown to another, copy a value from the referenced record, or default a line from the open document header |
function |
an explicit presentation role (Document, Setting, ...) |
label |
a stored, read-only display name for lookups |
number |
a platform-numbered document field drawing from a named series |
format |
a named input-format preset (email) over pattern |
checks |
cross-field / cross-line validations |
immutableWhen / immutable |
reject user writes in a status / append-only |
locksWithMaster |
a child collection that stays writable while its master is locked |
hierarchy / leafOnly |
tree entities, leaf-only references |
| calculated fields | server + UI-evaluated expressions, date helpers, call-outs |
relations / composition |
associations and master-detail compositions |
uses |
reuse entities owned by another intent model |
processes |
workflows: user tasks, decisions, waits, boundary timers |
abortOn |
cancel the running instance when the document reaches a terminal status |
function: Attachment / Snapshot |
a Files panel / immutable versioned printed copies |
forms |
task data-entry pages |
actions |
developer-defined buttons opening custom pages |
view |
an additional calendar / range page, or a slot-booking page |
documentItemsLayout: chat |
render a document's items as a chat thread |
print filter / match |
render one items collection into several purpose-grouped tables |
reports |
aggregations, charts, dashboard KPI tiles, balance reports |
scope |
which lifecycle rows an aggregating report counts |
widgets |
custom KPI / embedded-page dashboard tiles |
notifications |
email on create / update / delete |
the notify block / attach: print |
send a message about a record - with the record's own document attached - from a process step, a transition or a schedule |
notify.forEach |
fan the block out over a related collection: one message per row, every path resolved against the row |
schedules |
cron: notify or generate records per matching row |
integrations |
outbound HTTP on a data change |
inbound |
a webhook that creates records |
rollups |
counts, sums, balance + status maintenance, onto a local or cross-model parent |
settlements |
auto-allocation of payments across open invoices |
expansions |
generated child rows per day / week / month |
generates |
one-click document-from-document cloning, mirrored or computed lines |
generates.prompt |
collect the input the source cannot derive before the create |
transitions |
guarded on-demand status flips (void / cancel / reopen) |
postings |
declarative source-document to balanced-document posting |
aggregates |
keyed cross-entity totals materialised into their own entity |
checks: kind: guard |
a precondition over an aggregate: block, mark for a task, or reject |
posts |
derived ledger rows emitted idempotently on an event |
personal / partner |
per-user and per-partner row-scoped surfaces |
seeds |
initial data, CSV-backed sets, translations |
stage |
classify a status: draft / live / cancelled / void |
| status names | reference a status by its seeded name, not its positional id |
multilingual / languages |
translation tables + read-time translation overlay |
permissions |
roles |
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
functionvalues for upcoming presentations (Board,Gantt,Timeline). manyToMany— parsed but never materialised; the supported shape is the explicit intermediate entity.- 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.
- Event-driven document generation (produce a whole document, rather than the mapped rows of
posts, on an event), a declarative state machine, and shadow audit-history entities (audit columns viaaudit: trueship today). - Arbitrary resolver-path task assignment beyond
assignee: personal. - A fan-out that attaches the RECORD's document rather than the row's.
forEach+attach: printattaches each row's own document, which is what a per-row document (a payslip) needs. The mirror shape - one document sent to many recipients, where the rows ARE the recipients and the attachment belongs to the record they hang off (a request for quotation mailed to each invited supplier) - has no syntax yet; a conforming generator therefore rejectsattach: printwhen the row entity is not itself a document, rather than guessing which record was meant.