Skip to content

Latest commit

 

History

History
1681 lines (1283 loc) · 106 KB

File metadata and controls

1681 lines (1283 loc) · 106 KB

The Intent File Specification

Version 1.2

This version adds to 1.1:

  • Lifecycle-aware aggregationstage classifies what a status seed means (draft / live / cancelled / void), a report's scope states 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).
  • generates prompted input — a create-from action may collect the values the source cannot derive before the create; and generates.items gains a computed list form building synthetic lines from expressions over the source.
  • defaultValue semantics completed — a default is 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.
  • Print-template row filteringfilter / match render 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 view adds a page and never takes the entity's editing surface away, including a document's line items rendered on a calendar; now defaults render in the target field's own shape; |format in a label formats month values too.
  • Aligned with deployed practicenumber: references a series by name only, with the shape declared outside the model; the format: named input preset; a schedule's source may be cross-model via model:.

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.

Table of contents

Overview

A single .intent file at a project root is the source of truth for a whole application. It is authored one altitude above the models a platform generates from: instead of hand-authoring a data model, process definitions, forms, reports, roles and seed data separately, you author all of them from one YAML document, and a conforming generator produces them for you.

The intent never emits application code. It stops at the model layer. Schema, persistence, APIs, user interface, jobs, listeners, processes and security are produced from those models by the platform's own generation step. That boundary is non-negotiable.

The three altitudes

Altitude Artefact Authored by Transform below it
1 — Intent one *.intent per project a human, or an AI assistant proposing patches deterministic generation
2 — Models the platform's model artefacts (data model, processes, forms, reports, roles, seed data) the intent generators the platform's template engine
3 — Application schema, persistence, APIs, UI, jobs, listeners, processes, security the platform's application templates brought live by the runtime

Each layer is the deterministic input to the one below it. The only fallible, supervised step is turning natural language into an Intent File; every transform below the top layer is a pure function.

The scope boundary

The altitude table is also a statement of what the format deliberately does not model. Three kinds of requirement recur in every real application and are how, not what — they belong below the model layer, and each has a designated hand-off point that the intent wires in rather than describes:

Beyond the boundary Why it is not intent The hand-off
Protocol adaptation — conversation-shaped integration with an external system: certificates, acknowledgments, retries and their backoff, batch and file transports integrations 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.

Editor-first, not a runtime artefact

The Intent File is an authoring artefact, not a runtime one. It gets an editor and an explicit Generate step; it is not silently reconciled from a repository behind your back.

  • Generation happens in your workspace project, visible immediately, before anything is published.
  • A published Intent File is inert source, exactly like any other authored model. The generated models and code are what run.
  • There is no intent daemon, no intent database table - the file is read only when you ask the generator to run.

The workflow

1. Create a project.
2. Author app.intent (any *.intent) at the project root - by hand or with an
   AI assistant that proposes reviewable patches.
3. Open it in the intent editor: structured YAML, a live read-only diagram, and
   inline validation.
4. Generate. The generators write the derived model artefacts NEXT TO app.intent:
       the data model            entities + relations + UI metadata
       process definitions        workflows
       forms                      task data-entry pages
       reports                    aggregations, charts, dashboard tiles
       roles                      permissions
       glue                       triggers, notifications, schedules, roll-ups, ...
       seed data                  initial / reference rows
       custom-action descriptors  actions, generates, transitions (buttons)
       document templates         printable documents
       a test manifest            UI-test descriptor
5. Generate once more, one level down: the template engine turns the models into
   the full-stack application.
6. Publish. The runtime brings it live exactly as for any hand-modelled project.

Project layout

The folders layer cleanly, each owned by exactly one tool:

Folder Owned by Lifecycle
app.intent you (and the AI assistant) the only hand-authored artefact
project-root model files the intent generators' Generate re-emitted and scrubbed on every Generate
the generated code folder the template engine wiped wholesale on every regeneration
the custom folder you the escape hatch - touched by nobody

Do not hand-edit the generated model files. Changes are overwritten, and a file no longer backed by the intent is scrubbed on the next Generate. Adding an app.intent to a classic project hands ownership of its root-level model files to the intent generators; migrate them into the intent first.

The file is YAML, not JSON

Comments, multi-line strings and friendly diffs matter for an artefact a human reviews and an AI patches. The parser loads the document safely - type tags (!!type) are blocked, because an Intent File often arrives from generated output or paste and must never be a code-execution surface.

Every top-level collection defaults to empty, so a partial file (entities only) is valid. Field names are camelCase; entity names are PascalCase.

A minimal complete file

name: orders
description: Order management with an approval workflow
version: 1

entities:
  - name: Customer
    fields:
      - { name: id,   type: integer, primaryKey: true, generated: true }
      - { name: name, type: string,  required: true, length: 200 }
    relations:
      - { name: orders, kind: oneToMany, to: Order }

  - name: Order
    fields:
      - { name: id,        type: integer, primaryKey: true, generated: true }
      - { name: orderDate, type: date,    required: true }
      - { name: total,     type: decimal }
    relations:
      - { name: customer, kind: manyToOne, to: Customer }
      - { name: items,    kind: oneToMany, to: OrderItem }

  - name: OrderItem
    fields:
      - { name: id,       type: integer, primaryKey: true, generated: true }
      - { name: quantity, type: integer, required: true }
    relations:
      - { name: order, kind: manyToOne, to: Order, composition: true }

processes:
  - name: OrderApproval
    trigger: { onCreate: Order }
    steps:
      - { name: managerReview, kind: userTask, args: { assignee: manager, form: ApproveOrder } }
      - { name: done,          kind: end }

forms:
  - name: ApproveOrder
    forEntity: Order
    fields: [orderDate, total]
    actions: [approve, reject]

reports:
  - name: OrdersByCustomer
    source: Order
    dimensions: [customer]
    measures: ["count(*)", "sum(total)"]

permissions:
  - { role: Sales,   can: [Customer:read, Order:create] }
  - { role: Manager, can: [Order:approve] }

seeds:
  - name: order-statuses
    entity: OrderStatus
    rows:
      - { id: 1, name: DRAFT }
      - { id: 2, name: ISSUED }

Authoring rules

Normative. These rules keep the file diff-stable, safe to parse, and friendly for both human review and AI patching.

  • Comments are encouraged. No tool rewrites the file, so developer comments stay put; an AI patch path is expected to preserve them.
  • No anchors or aliases (&foo / *foo). They make diffs harder to read and harder for an AI to patch minimally. Prefer a defaults: block if duplication hurts.
  • No multi-document YAML (---). One file, one document.
  • No type tags. Blocked by the safe parser.
  • Quote unquoted braces in scalars. to: {member.email} is parsed by YAML as an object, not a string - write to: member.email. Braces are only for {...} interpolation inside subject / body text.
  • An event-binding key is event:, never on: - YAML 1.1 resolves a bare on (and off / yes / no) to a boolean. An action key is do:.

Entities & fields

Every entity becomes a table, a generated data-access layer + API, and a UI page. Primary keys are integers; composition is opt-in.

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

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)

Logical types

string, text, integer, int, long, decimal, double, boolean, date, timestamp, uuid, month, week.

Generators map each logical type to a physical column type. text is a large-object column; uuid is a 36-character string. month (a YYYY-MM value) and week (a YYYY-Www ISO-week value) are stored as short strings and render as month / week pickers.

Normative. Primary keys must be an integer type (integer / int / long). A non-integer auto-increment column is invalid, so a uuid or string primary key is rejected. uuid is valid for non-PK fields.

Entity-level attributes

Attribute Effect
audit: true adds the four standard audit columns, populated automatically
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)

Control order

By default the generated UI controls follow declaration order - all fields first, then to-one relations last. Give an entity an order: list of property names to sequence them explicitly, interleaving fields and relations for a better layout:

- 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 — field defaults

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.

Calculated fields

A field value can be derived instead of entered:

  • calculatedOnCreate / calculatedOnUpdate — an expression assigned to the property. Prefer a neutral arithmetic expression for numeric totals ("Quantity * Price", "round(Net * 0.2, 2)"): the server evaluates it and the UI previews it live with the same evaluator. Date helpers such as daysBetween, businessDaysBetween and monthsBetween are available.
- { name: net,  type: decimal, calculatedOnCreate: "Quantity * Price", calculatedOnUpdate: "Quantity * Price" }
- { name: days, type: decimal, readOnly: true, calculatedOnCreate: "businessDaysBetween(FromDate, ToDate)" }
  • calculatedActionOnCreate / calculatedActionOnUpdate — a server-side call-out for logic beyond an expression. The value names a hand-written component; the intent emits no code for it. It runs server-side only (no live preview) and takes precedence over an expression on the same slot. To reference it by simple name, declare imports: on the entity:
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.

Document numbering

number: turns a string field into a platform-numbered document field. The intent references a series by name only — the number's shape and counter live outside the model, and the platform stamps the field automatically; no hand-written number generator.

# stamped on create (the number exists the moment the record is saved):
- { name: Number, type: string, number: { series: Proforma, stampOn: create } }

# stamped at a modeled issue step (a placeholder holds the field until then):
- name: Number
  type: string
  number:
    series: Sales Invoice         # documents sharing a sequence pass the same series
    per: Company                  # optional: a to-one relation whose value partitions the series
    stampOn: issue                # create | issue
  • series (mandatory) — the sequence identity. Give several document types the same series to share one running number (a sales invoice, credit note and debit note drawing one legal range).
  • per — the name of a to-one relation of the same entity whose value partitions the series: each distinct value gets its own sequence (the canonical case is per: Company — two legal entities never share a counter). The value never appears in the number; it only selects which sequence to draw from.
  • stampOncreate stamps the real number on insert; issue puts a placeholder on the field at create and stamps the real number when the process reaches the wired step. Stamping is idempotent - re-issuing after an amend keeps the same number.

The shape is not the model's to declare. A number's rendering — a literal prefix plus the sequence zero-padded to a total width — is declared once per module in a numbering declaration artefact (a requirement declaration, like roles) and configured per deployment/tenant afterwards, where an operator can adjust prefix, width and the next value. Baking a format into the model was rejected deliberately: it forced a country or customer that wants a different prefix to fork and regenerate the application, when the number's shape is configuration, not intent. Sequences are continuous and never auto-reset; allocating from an undeclared series fails loudly rather than minting an unconfigured number.

The field is read-only in the UI. Counters are visible and adjustable in the generated application's document-numbering settings, including seeding a partition's starting number before its first allocation.

Normative. number.series is mandatory; per must name a to-one relation of the declaring entity. The removed keys of earlier drafts — format, scope, resetOn — MUST be rejected at parse with a message naming the numbering declaration as the new home of the shape: accepting and ignoring them would quietly lose an authored format. Two modules re-declaring the same series differently MUST fail the declaration naming both.

label — a stored display name

A stored, read-only Name recomputed on every write, so lookups and dropdowns show a meaningful label instead of a raw id:

- name: SalesInvoice
  label: "{Number} - {Date|yyyy MMMM} - {Customer.name}"

Tokens are the entity's own fields or one-hop to-one relation properties ({Customer.name}); |format is a date pattern for temporal values — a month field's YYYY-MM value formats through it too ({period|yyyy MMMM} renders "2026 July"). Deeper paths are rejected — compose by referencing the related entity's own label ({Parent.Name}). It is not allowed next to an authored name field, and a token must never reference a sensitive field.

function — the presentation role

Optional, and authoritative when set; inferred from structure otherwise.

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

Attachments and snapshots

Two function roles attach files to a record. Both are composition children of the record they belong to.

function: Attachment gives the master a Files panel — upload, download, delete. The entity's rows carry the file metadata; the binary content lives in the platform's document store:

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

Setting entities

- name: Country
  kind: setting

kind: setting marks an entity as nomenclature / configuration. It is placed under a global Settings area instead of getting its own top-level perspective, and any relation targeting it resolves its dropdown there. Settings are still real entities (own table, seeds, FK columns) — only their UI placement differs.

checks — declarative validations

Row-level and document-level validations, enforced on write / on a status transition, with an authored message:

- name: JournalEntry
  checks:
    - { kind: itemsMin,      count: 1, status: 2, message: "An entry needs at least one line" }
    - { kind: itemsSumEqual, over: [debit, credit], status: 2, message: "Debits must equal credits" }
- name: JournalEntryItem
  checks:
    - { kind: exactlyOne, fields: [debit, credit], message: "Exactly one of debit / credit" }

exactlyOne runs on every user write; itemsMin / itemsSumEqual are gated on a status transition, so drafting stays unconstrained and a failing transition aborts with the message.

kind: guard — a precondition over an aggregate

A guard compares a keyed aggregate 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: 4

outcome selects what a violation does:

outcome Companion attribute A violating write
block (default) - is rejected with message; nothing is stored
task marker: a boolean field is stored; marker is set to false (and to true whenever the guard holds)
reject setStatus: a status seed id is stored; the record's status relation is set to that value

Normative. The guarded entity MUST be the aggregate's own source (of). The total MUST be recomputed from the source rows themselves for the incoming record's key-tuple - excluding the record being updated - rather than read from the materialised target, so the decision cannot race the aggregate's maintenance. Consequently a guard and its aggregate are two independent computations of the same total, and the guard is the authoritative one. outcome: task MUST name a boolean field of the entity; outcome: reject MUST name a status seed value and the entity MUST declare a status relation. A companion attribute that does not belong to the declared outcome (a marker on a blocking guard, a setStatus on a marking one) MUST be reported as an authoring error rather than ignored.

outcome: task sets a flag; it does not itself create or route to a task. A workflow decision reads the marker and routes the record - the two constructs compose, and the guard is the part that computes.

immutableWhen / immutable — user-write immutability

- name: JournalEntry
  immutableWhen: "Status == 2"   # while POSTED, user update / delete are rejected (join terms with ||)
- name: InvoiceSnapshot
  immutable: true                # append-only: a frozen copy stored when a record is finalised

immutableWhen requires a function: EntityStatus relation; immutable: true needs none and is mutually exclusive with it. System / workflow writes stay possible — corrections to an immutable record are flow-generated reversals, never edits.

locksWithMaster — a child collection that outlives its master's lock

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. locksWithMaster defaults 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 declared locksWithMaster: false — including the affordances it renders for that collection, not merely the writes it accepts. A read-only rendering that the server would have permitted is the same defect as a refused write. The declaration is only meaningful on a composition child whose master actually declares immutability; a generator MUST reject it elsewhere rather than ignore it, since an inert declaration is indistinguishable from a working one until someone needs it. It does not apply to a document's own line items, which ARE the document's content.

hierarchy / leafOnly — tree entities

- name: Account
  hierarchy: Parent                                     # the tree edge (a self-relation)
  relations:
    - { name: Parent, kind: manyToOne, to: Account }
# elsewhere - only leaf accounts are referenceable (server-enforced):
- { name: Account, kind: manyToOne, to: Account, model: accounts, leafOnly: true }

The list renders as an expandable tree; the server rejects cycles and leaf-only references to a node that has children.

Relations & multi-model

relations

relations:
  - { name: customer, kind: manyToOne, to: Customer }
  - { name: orders,   kind: oneToMany, to: Order }
  - { name: order,    kind: manyToOne, to: Order, composition: true }

Relation kinds: oneToMany, manyToOne, oneToOne, manyToMany. The foreign key lives on the to-one side; the oneToMany / manyToMany sides are navigation-only (the column is on the child).

  • required: true on a to-one makes the FK NOT NULL but keeps the entity top-level with its own perspective (a plain dropdown).
  • composition: true on a to-one makes it a master-detail composition: the owning entity becomes dependent (managed as details under its parent's perspective), and the FK is NOT NULL. Only a manyToOne / oneToOne can be a composition; an entity's first composition to-one is its composition parent. Declare the inverse oneToMany on the master so the child is managed as its detail.

Composition is opt-in — most required FKs are plain associations, and composition is explicit.

Relation attributes

- { name: Currency, kind: manyToOne, to: Currency, size: 4 }              # form control width
- { name: Payment,  kind: manyToOne, to: Payment,  show: [date, number] } # extra read-only lookup columns
- { name: Status,   kind: manyToOne, to: OrderStatus, function: EntityStatus, init: 1 }  # managed badge, seeded default

# Depends-on - cascade, narrow-to-referenced, or auto-populate:
- { name: City,  kind: manyToOne, to: City,    dependsOn: { relation: Country, filterBy: Country } }
- { name: UoM,   kind: manyToOne, to: UoM,     dependsOn: { relation: Product, valueFrom: UoM } }
- { name: price, type: decimal,                dependsOn: { relation: Product, valueFrom: price } }

# Static option filter - e.g. only stock-tracked products:
- { name: Product, kind: manyToOne, to: Product, where: { Type: 1 } }
  • function: EntityStatus marks the relation as the entity's managed status badge; init: seeds its default at the database level (a race-free start). This relation is what immutableWhen, transitions and postings key on.
  • dependsOn links one dropdown to another: filterBy narrows the options to those matching the parent selection; valueFrom copies a value from the referenced record (a snapshot).
  • Conditional source (field only): valueFrom may be { by: <path>, cases: { <literal>: <property> }, default: <property>? } — the copied property is picked by a classifier resolved from the by path: 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 no default = 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): relation may 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.

  • where filters the dropdown to options matching a static condition.

Many-to-many

There is no manyToMany materialisation - the kind is parsed but never turned into a join table. Model n:m as an explicit intermediate entity holding a composition to one side, a manyToOne to the other (which may be cross-model via model:), plus any bridge fields:

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

Multi-model applications

A non-trivial domain is rarely one project. The intent layer lets you split it into several intent projects — one *.intent each — that reference each other across models, reuse single master-data entities instead of redefining them, and contribute their screens to one shared shell.

Each module can be its own repository, versioned and shipped independently as a build artefact and consumed by others as a dependency — so a currencies or customers module is published once and reused across many applications.

Reuse, don't redefine — uses

Master / reference data (Customer, Country, Currency, UoM) is owned by one project. Every other project that needs it stores an integer FK and renders a dropdown sourced from the owner's service — it does not generate the owner's table or API.

Declare the dependencies in a top-level uses: block, then point a manyToOne / oneToOne relation at the alias with model::

name: customers
uses:
  - { model: countries }                        # project defaults to the model alias
  - { model: currencies, project: currencies }  # set project only when it differs from the alias
entities:
  - name: Customer
    fields:
      - { name: id,   type: integer, primaryKey: true, generated: true }
      - { name: name, type: string,  required: true }
    relations:
      - { name: Country,  kind: manyToOne, to: Country,  model: countries }
      - { name: Currency, kind: manyToOne, to: Currency, model: currencies }

Normative. A cross-model relation must be manyToOne / oneToOne, its model: must be listed in uses:, and it cannot be composition: true — a detail cannot be owned across models. The consumer stores a projection of the owner entity so the FK dropdown resolves against the owner's live service.

One shared shell — contributions, not app-hopping

Each project generates its own standalone shell (handy to run one domain in isolation). They also contribute their entities as grouped perspectives to a single shared shell, so the user never jumps between per-project UIs. Two pieces drive this:

1. group: on an entity places its perspective under a named navigation group:

entities:
  - name: Customer
    group: partners        # appears under the "Partners" group in the shared shell

The entity references the group id only.

2. A navigation project defines each group once. Group ids are declared in one dedicated project so they are not redeclared per domain (the shell drops duplicate group ids). The domain entities then reference these ids (group: sales, group: settings, ...), and the shared shell aggregates every contributed perspective into its sidebar, ordered by each group's declared order.

Generate leaf-first, then publish everything

Cross-model dropdowns read the owner's already-generated model at generation time and call the owner's live service at runtime, so order matters:

  1. Generate the owners (leaves) first, then their consumers.
  2. Publish everything — every owner must be live for a consumer's cross-model dropdown to resolve.
  3. Open the shared shell — one grouped sidebar over every module.

Because table names are intent-prefixed, the projects share one schema without colliding.

Processes & forms

processes

processes:
  - name: OrderApproval
    trigger: { onCreate: Order, when: "total > 0" }
    steps:
      - { name: managerReview, kind: userTask,    args: { assignee: manager, form: ApproveOrder } }
      - { name: bigOrder,      kind: decision,    args: { if: "customer.creditLimit > 10000", then: cfoReview, else: activate } }
      - { name: cfoReview,     kind: userTask,    args: { assignee: cfo, form: ApproveOrder } }
      - { name: activate,      kind: serviceTask, args: { setRelationField: Status, value: 2, next: done } }
      - { name: done,          kind: end }

Generates one process definition per processes[] entry (a standard workflow model plus its diagram layout, so a modeller renders it).

Step kinds: userTask, serviceTask, decision, script, wait, end.

Step routing — the linear chain and next:

Steps flow linearly in declaration order. Any step may override its successor with args: { next: <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 tasks

Service-task shapes: setField / setRelationField (generated handlers that write a field or flip a status relation on a branch), notify (the step's work IS an outbound message — see the notify block), 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 notify service task stands alone: it MUST NOT carry another action (setField, setRelationField, call, delegate) on the same step. Sending is the step's whole purpose, and a step that both writes and sends hides which of the two failed.

Decision steps

if + then are mandatory, else optional. then / else must name a declared step or the literal end; the parser validates this, so a typo fails at parse time rather than producing an invalid workflow. Without else, the gateway default falls through to the next step.

A decision condition may walk one hop off the trigger entity (customer.creditLimit > 10000): a resolver step is generated before the gateway to load the related entity and rewrite the condition.

wait — park the process on a data event

A wait step parks the process until an entity lifecycle event resumes it — a case waiting for a reply, a flow waiting for a payment, an order waiting for its goods receipt:

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; onDelete is 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 (here CaseMessage.case). Omitted when the event entity is the trigger entity; same-model relations only.
  • when: — a single-comparison guard over the event record (field ==|!= literal), so e.g. an internal note does not resume the wait.

Correlation rides an identifier the trigger listener already writes back, so a wait requires the process to declare a trigger:. It is fail-soft: no parked instance, or an instance already past the wait, is a no-op — never an error.

timeout / expire — boundary timers on a user task

Two optional attributes on a userTask's args give a flow a notion of time. Both route then like a decision branch:

steps:
  - name: approve
    kind: userTask
    args:
      assignee: approver
      form: ApproveQuotation
      timeout: { after: P3D, then: remind }              # non-cancelling: the task STAYS claimable
      expire:  { until: validUntil, then: markExpired }  # cancelling: the task is WITHDRAWN
      next: done
  • timeout: { after: <ISO-8601 duration>, then: <step> } — a non-cancelling boundary timer (PT4H, P3D): after the duration the then branch runs (a reminder / escalation) while the task stays claimable.
  • expire: { until: <field>, then: <step> } — a cancelling boundary timer driven by a date / timestamp field of the trigger entity: when the moment passes, the task is withdrawn and the flow continues at then. The date is re-read at task entry, so editing it mid-flow moves the timer. A date names the last valid day (the timer fires at the start of the next day); a null arms a far-future date so the timer never effectively fires.

abortOn — cancel the instance on a terminal status

A running process should not outlive its document. abortOn: on the process cancels the whole in-flight instance — pending user tasks withdrawn, parked waits and armed boundary timers cancelled — the moment the trigger entity transitions into any of the listed status ids (the same transition event a transitions 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's function: EntityStatus relation; reaching any of them aborts.
  • then: (optional) — a single cleanup serviceTask (setField / setRelationField) that runs only on the abort path; it must not be reachable from the main flow. Omitted (or end) means terminate with no cleanup.

Like wait, abortOn requires the process to declare a trigger: (correlation rides the instance identifier stamped on the record) and is fail-soft — no running instance is a no-op. This is the structural answer to orphaned inbox tasks: cancel a review the moment its document is voided elsewhere.

trigger

trigger: { onCreate | onUpdate | onDelete: <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 when guard (a single field ==|!= literal), starts the process, and writes the instance identifier back.

The business key defaults to the entity PK but is configurable:

trigger: { onCreate: Order, businessKey: orderNo, businessKeyStrategy: timestamp }

businessKey names which field becomes the started instance's business key; businessKeyStrategy: timestamp mints a yyyyMMddHHmmss value into that field when it is blank (the field must be string / text).

Task assignment

A user task's assignee is a role / candidate-group name, or the literal assignee: personal to route the task to the record owner's inbox (requires the trigger entity to declare a personal: relation — see scoped surfaces).

forms

forms:
  - name: ApproveOrder
    forEntity: Order
    fields: [orderDate, total, customer.name]   # fields or one-hop relation.field
    actions: [approve, reject]                  # complete the task

Generates one form per forms[] entry. Controls are typed by looking each field up against the bound entity (string to a text input, integer / decimal to a number input, boolean to a checkbox, date to a date picker, and so on). Actions become buttons, coloured by name (approve to positive; reject / decline / delete / cancel to negative; save / submit to emphasised).

actions — custom buttons

Developer-defined buttons that open a custom page — the escape hatch when a workflow or a generated screen is not enough:

actions:
  - name: OpenPortal
    forEntity: Order
    scope: entity            # per-record; 'page' = a whole-view toolbar button
    page: /custom/portal.html

Presentation

Beyond the CRUD screen every entity gets, the intent declares richer read surfaces: aggregating reports, dashboard tiles, time-based views, conversation-threaded documents, and printable documents.

reports

reports:
  - name: OrdersByCustomer
    source: Order
    dimensions: [customer]                  # a bare to-one shows the target's label, not the FK id
    measures: ["count(*)", "sum(total)"]
  - name: BigOrderItems
    source: OrderItem
    dimensions: [order.orderDate, quantity] # a relation.field path adds a join
    filter: "quantity > 1"                   # becomes the WHERE

Generates one report per reports[] entry, rooted at source, with a fully materialised query:

  • a plain field resolves to a source column;
  • a relation.field path (order.orderDate) joins the related entity and adds a column on it;
  • a bare to-one relation (customer) joins and shows the target's label field, not the raw FK id — use customer.id for the id. A cross-model relation joins the owning model's table, so a report can group by an entity another module owns;
  • a time bucket month(field) (a sortable YYYYMM integer) or year(field);
  • a measure count(*) / sum(...) / avg / min / max becomes an aggregate, and the dimensions become the grouping.

filter becomes the WHERE, with field names rewritten to qualified physical columns. Report names, descriptions and column labels are emitted into the translation catalogue, so they localise alongside the rest of the UI.

Lifecycle scope

An aggregation over an entity that carries a lifecycle (function: EntityStatus) is wrong by default: drafts nobody has issued, cancelled documents and voided ones all land in the sum. scope states which lifecycle rows the report counts, in terms of the stages 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. scope is all or a single stage name, and is only meaningful over a source declaring a function: EntityStatus relation. A stage scope restricts the query to the statuses that stage classifies; all adds no restriction.

With no scope, a report counts every row except when all of the following hold, in which case it counts the live rows: it aggregates (declares measures, or is a balance report); its source's nomenclature is stage-classified; and neither its dimensions nor its filter reference the status. The last condition keeps a breakdown by status complete and leaves an authored predicate authoritative — a generator MUST NOT combine an implicit scope with either.

A report that aggregates over a lifecycle-carrying source while declaring no scope, filtering on no status, and resolving no stage classification is the case this construct exists to eliminate: a generator MUST report it as a diagnostic naming the report and its status relation. Emitting the unrestricted aggregation silently is non-conforming.

Chart

chart: renders the report page as a chart instead of a table (the page keeps a table / chart toggle, so filters, export and print still work). A chart wants exactly one dimension and one or more measures — the dimension labels the axis and each measure becomes a series:

reports:
  - name: MonthlyRevenue
    source: Order
    dimensions: ["month(orderDate)"]
    measures: ["sum(net)", "sum(vat)", "sum(total)"]
    chart: bar          # bar | line | pie | doughnut | polarArea | radar

balance reports

kind: balance produces an opening / period / closing debit + credit report per dimension, with runtime From/To date pickers:

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"

Dashboard KPI widgets

A report may declare a widget block that turns it into a KPI tile on the generated home dashboard — a meaningful business number instead of a raw record count:

reports:
  - name: OverdueInvoices
    source: Invoice
    dimensions: [number, customer.name, due, total]
    filter: "due <= CURRENT_DATE AND balance > 0"
    widget: { kind: count, label: Overdue Invoices, icon: alert-triangle }

  - name: RevenueByMonth
    source: Invoice
    dimensions: ["month(date)"]
    measures: ["sum(total)"]
    widget:
      value: "sum(total)"          # names a declared measure => kind: value
      at: { "month(date)": now }   # pin dimensions: the `now` token, or a literal
      label: Revenue (this month)
      icon: banknote

  - name: SalesByProduct
    source: SalesInvoiceItem
    dimensions: [Product]
    measures: ["sum(quantity)", "sum(total)"]
    widget: { kind: list, limit: 5, label: Sales by Product }
  • kind: count (default) — the number of records the report yields.
  • kind: value — one aggregate cell: value names a measure; at pins dimension columns. The now token resolves at view time, type-aware (current YYYYMM on a month(x) dimension, current year on year(x), today on a date column).
  • kind: list — the report's first limit rows (default 5) as a compact table tile.

Declaring any widget replaces the automatic per-entity count tiles; dashboard: false hides both tiles for a report.

widgets — custom dashboard tiles

The dashboard's escape hatch, when the report machinery cannot express the content:

widgets:
  - { name: SystemHealth, kind: kpi,  url: /custom/health.js,          icon: activity }  # a number from a REST endpoint
  - { name: SalesFunnel,  kind: page, url: /custom/funnel/index.html }                   # an embedded HTML page

kind: kpi (default) renders a number tile whose value comes from the developer's REST endpoint; kind: page embeds the page in a tile. The url must be a same-origin path.

view — calendar, range, slots

view: (with a calendar: / slots: descriptor) places an entity's records on a time surface:

- name: DayAllocation
  view: calendar                          # a month / week calendar of records
  calendar: { start: day, title: note }   # start (date/timestamp) required; end/title/color optional
- name: VacationRequest
  view: range                             # from-to bars (a leave calendar)
  calendar: { start: fromDate, end: toDate }
- name: Appointment
  view: slots                             # a slot-picker booking page
  slots: { start: startTime }

view: calendar is also expressible as the role alias function: Calendar.

A view adds a page

view: calendar, view: range and view: slots add a page; they never take one away. The entity keeps the page family its structure already implies — a list, a master-detail, or a document editor — and the view joins it:

Route Page
/<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.

A document's line items on a calendar

When the entity declaring view: calendar is a document's line-items child, the document's items pane is the calendar instead of the row grid — the shape for a day-grained line, such as a booked day or an allocated hour:

- name: Roster
  function: Document
- name: RosterItem
  function: DocumentItem
  view: calendar
  calendar: { start: day, title: Person }
  fields:
    - { name: day,   type: date, required: true }
    - { name: hours, type: decimal, precision: 18, scale: 2 }

The document keeps its header, totals and printing; only the items pane changes. Clicking an event edits that line, clicking an empty day adds one with that date filled in. A line-items child cannot be both a calendar and a chat thread (documentItemsLayout: chat) — the two claim the same pane, and declaring both is an error.

documentItemsLayout: chat — conversation threads

A document master can render its line-items child as a chat thread (message bubbles + a composer) instead of an editable items table — support cases, tickets, comment threads. The header, status pill, workflow tasks and print stay as in a normal document:

- name: Case
  function: Document
  documentItemsLayout: chat
- name: CaseMessage
  function: DocumentItem
  audit: true                                    # the bubble author + timestamp come from audit
  fields:
    - { name: body,     type: text,    messageBody: true }      # the bubble text (exactly one)
    - { name: internal, type: boolean, messageInternal: true }  # an internal memo (hidden from partners)

Printable documents

Every document (header-items) master — one with an *Item composition child — gets a printable document template on Generate, written in a small layout language and rendered to a document on demand from the entity's own data.

doc/Templates/<Entity>/Print/en/standard.print

The template is a tree of layout tags (page, header / footer, section / stack, row, field, text, a table bound to the items, total, line, and if), with values as placeholders:

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

Row filtering — filter / match

One collection often has to render into several purpose-grouped tables: a payslip prints earnings beside deductions from the same fiche lines, a journal entry its debit side next to its credit side, a VAT summary the same items grouped per rate. The line items carry the discriminator (a kind, a side, a rate group); filter / match let a table select on it:

<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 a table (or a row-expanding for) names a path resolved in each row's own scope. Without match, a row is kept when the resolved value is truthy.
  • match="A | B" lists accepted literal values, |-separated, surrounding whitespace trimmed. A row is kept when the resolved value's string form equals one of the literals.
  • match on an if compares the node's resolved source against the listed literals instead of testing truthiness.

Normative. A filtered table renders its column definitions unchanged and one row per kept element, in source order; elements failing the filter are skipped entirely. A filtered for expands its children only for the kept elements. An unresolved or null filter value never matches a match list, and is falsy without one. Comparison is by the value's plain string form (numbers by their canonical rendering, booleans as true/false) — no coercion beyond that, no operators, no expressions. filter absent renders every element; match without filter on a table/for has no effect; an empty match is treated as absent; match on an if without a source keeps the children hidden. A template using these attributes rendered by an implementation that predates them MUST degrade to rendering all rows — unknown attributes are ignored, never a parse failure.

Declarative glue

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

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

Three axes:

  • Event — an entity onCreate / onUpdate / onDelete (with an optional when: guard), a 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.

Glue is generated integration code

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

Event-key gotcha. An event-binding key is event:, never on: — YAML 1.1 resolves a bare on (also off / yes / no) to a boolean, so an on: key is silently swallowed. An action key is do:.

notifications

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.

The notify block — and attach: print, sending the document itself

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

Where The record it is about It sends
notifications[] the event record on create / update / delete
schedules[].notify each matched row on every cron tick, per row
transitions[].notify the transitioned record after the status flip commits
a serviceTask's args.notify the process's trigger record when the flow reaches that step

An optional attach: print makes the message carry the record's own document: the record is rendered through its print template 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 language

Normative. attach's only value is print. A block declaring it MUST be about an entity with a line-items child — the shape a print template is generated for; a generator MUST reject attach: print on any other entity rather than send a message without the document it promised. language names one of the document's print-template languages; when omitted, the default language is used. The attachment MUST be produced from the record's own data through the same path the interactive print takes, so a document mailed and a document printed are the same document.

Normative. A block whose recipient resolves to no address is a no-op: the send is skipped and recorded, never an error — a record with nobody to notify must not stall a flow. A transitions[].notify MUST NOT be able to fail its transition: the status flip is the transition's contract and is already applied when the message is attempted, so a delivery failure is recorded and the transition still reports success. At the other call sites a delivery failure MAY fail the activity so the platform's own retry applies; a sending process step SHOULD, since the message is that step's whole purpose.

One message per related row: forEach

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

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

Normative. The named entity MUST have exactly ONE to-one relation back to the record the block is about: none means the rows are unrelated, several make the intended set ambiguous, and a conforming generator MUST reject both rather than send to a silently wrong set of recipients. attach: print then refers to the ROW's document, and the row MUST satisfy the document requirement above.

Normative. 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 }

schedules

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 out

The 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: day

map copies a value of the queried row; defaults sets a constant — now means "today", rendered in the target field's own shape (a date field gets today's date, a month field the current YYYY-MM, a week field the current YYYY-Www).

integrations — outbound HTTP

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.

inbound — webhooks

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 — denormalised parent totals

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.

aggregates — keyed cross-entity totals

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

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

Where rollups 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.

settlements — payment allocation

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

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]

expansions — child rows from a date span

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: periods

A span change replaces the generated child set — never mix hand-entered rows into an expanded child.

generates — create-from

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 created

Adds a button on the source view; the clone saves through the target's own layer, so numbering, status init and calculated fields fire. map copies a source value; defaults sets a constant — now means "today", rendered in the target field's own shape (a date field gets today's date, a month field the current YYYY-MM, a week field the current YYYY-Www). An optional sourceStatus flips the source record's status once the target exists.

items has two mutually-exclusive shapes. As an object (above) it mirrors each source child row 1:1. As a list it builds computed synthetic lines whose cells are expressions over the source record — use it when a create-from must produce a computed line (e.g. one invoice line carrying a period's rolled-up total) rather than a 1:1 clone. The target's line-items child is resolved automatically (it is never named):

    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.

Prompted input — prompt:

A generates action MAY declare prompt: — a small input form shown before the target is created, for the values the source cannot derive. The canonical case is manual payment allocation on an issued invoice: which payment, and how much (an allocation is often partial). It is also the sanctioned way to create a child record on an immutable document — the document's panels are read-only by design, while per-record action buttons are not gated on mutability (the same affordance that lets Void work on an issued document).

generates:
  - name: allocate-payment
    from: SalesInvoice
    to: SalesInvoiceCustomerPayment  # a composition child of forEntity
    label: Allocate Payment
    map:
      SalesInvoice: id               # the clicked record becomes the child's master FK
      Customer: Customer             # derived values stay mapped — prompt only what cannot be derived
    prompt:
      - { field: CustomerPayment, required: true }   # a to-one relation of the target → a dropdown
      - { field: amount, required: true }            # a field of the target → a typed input
  • Each entry names a field or to-one relation of the target entity. Controls are typed from the target's own definitions, and the target's dependsOn: declarations apply unchanged in the dialog — the cascade narrowing and value defaults are authored once, on the target.
  • required: true MUST be enforced both in the dialog and by the generated endpoint (a missing required input fails the request before anything is written).
  • A property MUST NOT be both prompted and mapped/defaulted — every target value has exactly one writer. Prompted values are applied after map/defaults.
  • Constraints: the target MUST be a local entity (no uses:) declaring a composition to-one relation to forEntity; the action's scope MUST be entity; a timestamp field MUST NOT be prompted.
  • The create goes through the target's ordinary write layer, so its -created event, roll-ups and validations fire unchanged.

transitions — guarded status flips

A per-record button that flips an entity's function: EntityStatus relation on demand — void, cancel, close, reopen — guarded by allowed source statuses and an optional condition. A flip from any other status (or a failing guard) is rejected; a successful flip publishes a -transitioned event that postings and integrations can observe.

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.

postings — source document to ledger

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

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 Entry

posts — derived rows on an event

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

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

event is either a status value of the source entity - the rows are emitted when a record transitions into it - or create. A set value is a constant, <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.

Guardrails

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

Scoped surfaces & roles

Personal and partner surfaces

On top of the regular screen (which is unaffected), an entity's records can be scoped to the logged-in user or to an external business partner. Each scope adds a second generated controller that filters rows server-side — never merely hiding them in the UI.

entities:
  - name: Employee
    identity: email                       # the field matched against the login username
  - name: Timesheet
    relations:
      - { name: Employee, kind: manyToOne, to: Employee, personal: true }   # the record owner
      - { name: Customer, kind: manyToOne, to: Customer, partner:  true }   # an external-partner owner
    fields:
      - { name: rate, type: decimal, sensitive: true }   # hidden + ignored on the scoped surfaces
  • identity: <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: true on a record-owning to-one relation generates a personal controller: reads are filtered to the caller's mapped record, the owner FK is forced server-side on writes, and a foreign record is not found. At most one personal: relation per entity; the target must declare identity; never put it on a composition parent — composition children inherit the owner's scope through their parent.
  • partner: true is the exact mirror for external parties (customers, suppliers) on a partner surface, gated by the corresponding partner roles. An entity may carry both a personal: (staff) owner and a partner: (external) owner at once.
  • personalReadOnly: true (with personal: true) makes the personal surface see-only: create / update / delete are refused and the scoped pages render without new / edit / delete. Use it for records an owner may see but never author — a balance, a payslip. Composition children inherit it through the parent.
  • sensitive: true on a field (never the PK, the identity field, or the owner FK) strips it from the scoped responses and ignores it on scoped writes — use it for billing rates and amounts the owner must not see. It is enforced server-side, not just hidden.

Normative. A scope's safety is by construction, not by a filter: the scoped controller only ever queries the caller's own rows, and a sensitive field is on an allow-list the scoped serialiser never includes. A field hidden only in the UI is cosmetic; sensitive is a server-side guarantee.

A user task can also be routed to the record owner's inbox with the literal assignee: personal, which resolves the owner through the personal: relation (see processes).

permissions

permissions:
  - { role: Sales,   can: [Customer:read, Order:create] }
  - { role: Manager, can: [Order:approve] }

Generates a deduplicated set of roles. It deliberately does not emit URL-shaped access rules — those belong to whichever downstream template materialises the UI, because only that template knows the paths it publishes. The can: [Resource:action] tokens are an authoring hint to those downstream generators about which actions each role may invoke.

Data, seeds & naming

seeds

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 a data/ subfolder, right for bulk nomenclatures and prepopulated demo data. A foreign key is set by the relation name (Country: 34).

Normative. Row keys must match a field 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.

stage — what a status means to the lifecycle

A seed row of a status nomenclature (the target of a function: EntityStatus relation) may classify itself with stage, a closed vocabulary:

Stage Meaning
draft Nobody has issued it yet — visible to its author, not yet economically real.
live It counts: issued, sent, paid — anything in normal circulation.
cancelled Withdrawn before it ever became live.
void Deliberately retired while keeping its number — out of circulation by design.

The classification exists because a status id is data, but its meaning is not: without it, "the rows that count" can only be expressed as a predicate over positional ids, repeated in every report and guard that needs it. With it, the meaning is declared once, where the nomenclature is defined, and consumers resolve it — chiefly a report's scope.

Normative. stage is metadata, not data: it MUST NOT be emitted as a column of the seeded table. A row carrying stage MUST also carry the entity's primary key (the stage classifies that id). A value outside the vocabulary is an authoring error. An entity that declares its own stage property cannot be classified this way — the collision MUST be reported rather than resolved by guessing.

Status references — name, not number

Everywhere the file names a status — a transition's 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 a scope. A nomenclature owned by another model is seeded there, so a name cannot be resolved against it — such a reference is an authoring error directing the author to the numeric id.

Multilingual data

Two independent things get translated: the data in multilingual entities, and the generated UI labels.

Data

Mark an entity multilingual: true and its string-typed properties gain per-language values in a sibling translation table. Every read overlays the translated values for the caller's requested language; untranslated content falls back to the default language. Author the translations as seeds 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.

UI labels

Generation also emits a per-project translation catalogue for every generated label: entity names (a humanised singular plus a plural form), field labels, form and report names, and report column headers. The default locale is generated for you; a translator adds a sibling locale folder with the same keys. The UI renders through these keys, falling back to the baked default label for any key a locale has not translated.

Naming and tables

  • The top-level name: is the intent's identity. Single-file outputs are named after it; the physical table prefix is its upper-snake form.
  • Physical table names are intent-prefixed: <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 (loanedOnLoanedOn); physical columns stay UPPER_SNAKE. You author in lower camelCase.
  • A multilingual entity's translations land in a sibling <TABLE>_LANG table.

Because every table is intent-prefixed, many independent intent models share one schema without colliding — the foundation of a multi-model application.

Appendix A: DSL index

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

Planned — recognised but not yet implemented

The following are parsed (or reserved) but not yet materialised by a generator; a conforming tool rejects or ignores them with a clear message rather than failing obscurely:

  • Reserved function values for upcoming presentations (Board, Gantt, Timeline).
  • manyToMany — parsed but never materialised; the supported shape is the explicit intermediate entity.
  • 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 via audit: true ship 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: print attaches 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 rejects attach: print when the row entity is not itself a document, rather than guessing which record was meant.