Skip to content

DSL reference

The quick lookup surface for the intent DSL: one line and one minimal snippet per construct. The in-product assistant guide (shipped with the engine) remains the authoritative reference for rules, edge cases and validation messages; The .intent file walks a complete worked example.

ConstructWhat it gives you
entitiestables + CRUD UI + generated Java repository/REST
field / relation attributesuniqueness, layout, read-only, dropdown filtering, cascades
uniquea business key spanning more than one field or relation
functionexplicit presentation role (Document, Setting, ...)
labela stored, read-only display name for lookups and dropdowns
checkscross-field / cross-line validations
immutableWhen / immutable409 on user writes in a status / append-only snapshots
period / immutableInPeriod409 on user writes to a record dated in a closed fiscal period
lifecyclethe whole legal status graph, enforced on every status write
hierarchy / leafOnlytree entities, leaf-only references
personal / partnerper-user and per-partner row-scoped surfaces (+ sensitive stripping)
visibleToa field only some roles may read or write, enforced in the REST responses
multilingual / languages_LANG tables + read-time translation overlay, on entity reads and report columns alike
calculated fieldsserver+UI-evaluated expressions, date functions, Java call-outs
viewan additional calendar / range page, or a slot-booking page
documentItemsLayout: chatrender a document's items as a chat thread
usesreuse entities owned by another intent model
processesBPM workflows with user tasks, decisions, delegates, waits and boundary timers
formstask data-entry pages
actionsdeveloper-defined buttons opening custom pages
generatesone-click document-from-document cloning
generates.eventmint the document on a source event - a status write, a create, or a process step
generates.event.modeone target per source (once, default) or one per delivered event (append)
generates.promptcollect a couple of values in a dialog before the create
transitionsguarded on-demand status flips (void / cancel / reopen)
postingsdeclarative source-document to balanced-document posting
expansionsgenerated child rows per day/week/month
rollupscounts, sums, balance + status maintenance, transitive chains
settlementsauto-allocation of payments across open invoices
reportsaggregations, charts, dashboard KPI tiles, balance reports, user-set parameters
widgetscustom KPI / embedded-page dashboard tiles
seedsinitial data, CSV-backed sets, translations
notificationsemail on create/update/delete
notify link placeholders{recordUrl} / {inboxUrl} / {appUrl} - a message that carries the way back into the application
notify.forEachfan a notify block out over a related collection: one message per row, every bare path resolved against the row
attach: recordPrintin a fan-out: attach the ANCHOR record's document, rendered once, to every recipient ({record.<field>} addresses that record)
the notify block / attach: printsend a message about a record - with the record's own document attached - from a process step, a transition or a schedule
schedulescron: notify or generate records per matching row
integrationsoutbound HTTP on a data change
integrations.payloadthe declared envelope a message carries, instead of the record as stored
the event axiswhat a notification / integration binds to: an entity lifecycle event, or a process step reached / completed
inboundrecords arriving from outside: a webhook, a queue/topic message, a dropped file
outbounda record emitted on a queue or a topic when an event fires
permissionsroles
Plannedrecognised, not yet implemented

entities

The data model - every entity becomes a table, a generated Java repository + REST controller, and a UI page. Integer primary keys only; composition is opt-in.

yaml
entities:
  - name: Member
    icon: user
    audit: true                # adds CreatedAt/CreatedBy/UpdatedAt/UpdatedBy
    history: true              # every write recorded as field-level deltas (see below)
    group: master-data         # nav group in the shared application shell
    fields:
      - { name: id,   type: integer, primaryKey: true, generated: true }
      - { name: name, type: string,  required: true, length: 200 }
    relations:
      - { name: loans, kind: oneToMany, to: Loan }
  - name: Loan
    fields:
      - { name: id,    type: integer, primaryKey: true, generated: true }
      - { name: dueOn, type: date }
    relations:
      - { name: member, kind: manyToOne, to: Member, composition: true }  # detail of Member

Field / relation attributes

yaml
- { name: code,  type: string, unique: true, length: 30 }              # UNIQUE constraint
- { name: uuid,  type: uuid, major: false }                            # auto-filled on create, off the list table
- { name: Number, type: string, number: { series: Sales Invoice, per: Company, stampOn: create } }  # document number
- { name: total, type: decimal, precision: 18, scale: 2, readOnly: true }
- { name: hours, type: decimal, required: true, defaultValue: 8 }        # default value (see below)
- { name: period, type: month }                                        # YYYY-MM month picker
- { name: sprint, type: week }                                         # YYYY-Www ISO-week picker
- { name: number, type: string, function: DocumentTitle }              # the document title/number
- { name: Currency, kind: manyToOne, to: Currency, size: 4 }           # form width (12-col grid)
- { 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, 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 } }
# Conditional auto-populate (field only): the copied property picked by a classifier - an own
# property, a one-hop Relation.property, or a path starting at the composition parent relation
# (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 auto-populate (field on a document item): a two-segment relation path
# <composition parent>.<parent relation> copies from the record the open HEADER points at,
# so a line defaults from the document's counterparty. valueFrom is required (no option list).
- { name: discount, type: decimal, dependsOn: { relation: SalesOrder.Customer, valueFrom: standardDiscount } }
# Input format (string / text fields only): the regex reaches the HTML input's pattern attribute
# AND a server-side check in the generated controller, so an API caller cannot bypass the form.
- { name: email, type: string, length: 320, pattern: '^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$' }
- { name: iban,  type: string, length: 34,  pattern: '^[A-Z]{2}[0-9]{2}[A-Za-z0-9]{11,30}$' }
# Static option filter - e.g. only stock-tracked products:
- { name: Product, kind: manyToOne, to: Product, where: { Type: 1 } }

pattern is a FORMAT check: it says what a value must look like, not what it must mean. A rule that differs per jurisdiction (a national identifier) or that needs a checksum is not expressible as one regular expression - leave those fields unpatterned rather than encode one country's rule as if it were universal. On a numeric field the attribute already means the DISPLAY format, so the parser rejects a regex there. The emitted controller splices the regex into a Java string literal, so backslashes are escaped for you.

A required field that also carries a default (defaultValue, or init: on a relation) is NOT demanded from the caller: the database default supplies the value, and create validation that also insisted on it in the payload would make the record uncreatable through the API. The create response echoes the PERSISTED row, so the defaulted values come back to the caller.

A header-mediated dependsOn copies once, when a NEW line is opened - an existing line is never re-copied, so changing the header later leaves already-entered lines untouched.

unique - a business key over more than one field

unique: true on a field covers one column. When what makes a row unique spans several - one row per (tenant, application), one assignment per (tenant, user), one price per (product, priceList, validFrom) - declare it on the entity:

yaml
entities:
  - name: TenantApplication
    unique:
      - { fields: [tenant, application], message: "This application is already provisioned for the tenant" }
    fields:
      - { name: id,   type: integer, primaryKey: true, generated: true }
      - { name: plan, type: string }
    relations:
      - { name: tenant,      kind: manyToOne, to: Tenant, required: true }
      - { name: application, kind: manyToOne, to: Application, required: true }

fields: names fields or to-one relations - a relation contributes its foreign-key column, which is what a pair like (tenant, application) means. The key is the combination of those columns, so the order you write them is how the key reads, not which rows collide. A colliding write is answered with 409 Conflict carrying your message, so a caller can tell a duplicate from a generic failure; omit message: and one is derived from the field names.

The point of declaring it is that the rule ends up in the database, where it holds for every writer at once. Left unmodelled, it lives in a read-then-write in hand-written code - a race, and one that every writer (an import, an inbound message, a scheduled create) has to repeat - or in a constraint added to the generated schema by hand, which the next Generate knows nothing about.

Rules Generate enforces

Every name must resolve to a field or a to-one relation of the same entity: a to-many has no column on this side to constrain. A cross-model relation is rejected. A single-name key is rejected too, naming unique: true on the field itself - two ways to say one thing is how the two drift apart.

Existing tables are not altered

The constraint is created with the table. A key added to an entity whose table already exists does not retrofit itself onto that table - the same caveat every schema change carries.

Entity-level extras: order: [Id, Product, Quantity, ...] sequences form controls and list columns; duplicable: true adds a Duplicate button on a document (clones header + items through the normal create path); imports: | injects Java import lines into the generated repository (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 (the totals footer).

defaultValue - field defaults

yaml
- { name: hours,    type: decimal, required: true, defaultValue: 8 }
- { name: billable, type: boolean, defaultValue: true }

One key, three effects:

  • the column's DB DEFAULT;
  • it satisfies required - the generated controller does not demand a value the model already guarantees (isRequiredProperty && !dataDefaultValue);
  • it seeds a new row in the generated UI - the document item dialog opens on the default instead of on a blank (def on the column in the detail registry).

Applied on create only. An existing row is never re-defaulted: a value the user cleared is a value the user chose, and re-applying the default on the next edit would silently undo it.

The to-one relation equivalent is init: <seed id>, which names a seeded record rather than a literal.

function - presentation role

Optional and authoritative when set; inferred from structure otherwise.

yaml
- name: ProjectTimesheet
  function: Document           # header + line items + status pill + totals
- name: EmployeeTimesheet
  function: DocumentItem       # its line items (no "*Item" naming needed)

Values: Document, DocumentItem, Master, Detail, List, Setting, Calendar (entity - Calendar is the role alias for view: calendar); DocumentTitle (field); EntityStatus (relation). Board / Gantt / Timeline are reserved and rejected until those templates ship.

label - stored display name

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

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

Tokens are 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}). Not allowed next to an authored name field, and a token must never reference a sensitive field.

checks - declarative validations

Row-level exactlyOne on every user write; document-level itemsMin / itemsSumEqual gated on a status transition - drafting stays unconstrained, and a failing transition aborts with the authored message.

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

checks: kind: guard - precondition over an aggregate

yaml
- name: StockMovement
  checks:
    - kind: guard
      aggregate: onHand                 # an `aggregates` entry whose `of` is THIS entity
      minimum: 0                        # recomputed total (prior rows + this row) stays >= minimum
      message: "Insufficient stock"
      enabledBy: BLOCK_NEGATIVE_STOCK   # optional: enforced only while the config 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
outcomeCompanionA violating write
block (default)-throws ValidationException, mapped to 4xx; nothing stored
taskmarker: boolean fieldstored; the marker is set false (true whenever the guard holds)
rejectsetStatus: status seed idstored; the function: EntityStatus FK is set to that value

Emitted into the generated repository's save and update paths. The total is recomputed SYNCHRONOUSLY from the guarded entity's own rows for the incoming key-tuple (excluding the row being updated), not read from the materialised aggregate target, so the decision cannot race the aggregate handler. Guard and aggregate are therefore two independent computations of the same total, and the guard is the authoritative one.

outcome: task stamps a flag; it does not create or route to a task. A process decision step reads the marker and routes the record. outcome: reject requires an EntityStatus relation. A companion attribute belonging to another outcome is a generation error, not ignored.

immutableWhen / immutable - user-write immutability

yaml
- name: JournalEntry
  immutableWhen: "Status == 2"   # while POSTED, REST update/delete return 409 (join terms with ||)
- name: InvoiceSnapshot
  immutable: true                # append-only: e.g. the frozen copy stored when an invoice is SENT

immutableWhen requires a function: EntityStatus relation; immutable: true needs none and is mutually exclusive with it. Workflow/system writes through the repository stay possible - corrections to an immutable record are flow-generated reversals, never edits. (immutableIn: is the pre-rename spelling, rejected with a migration message.)

The lock reaches the document's lines. A composition child declares no immutability of its own - the lock belongs to the document - but its writes recompute the master's totals, so a line write on a locked document would rewrite exactly what the lock protects: after the number was stamped, after the immutable PDF snapshot was taken, after the entry posted to the ledger. Creating, editing or deleting a line of a locked master is therefore refused with the same 409 by the child's own controller, on every REST surface (the power controller and the partner / personal ones). The generated UI already withheld the affordance; this is the server agreeing with it. Declare locksWithMaster: false on a collection that must go on being recorded past the lock.

period / immutableInPeriod - date-based immutability

immutableWhen freezes a record for what it is: a journal entry stops being editable because its own status says POSTED. Accounting also needs the other axis - freezing a record for when it falls. Once the accountant closes March, nothing dated in March may be created, edited or deleted any more, whatever status the individual record carries: not a draft nobody posted, not a document someone was still correcting, not a line added to an already-issued invoice. The close is the point at which the month's figures were reported, and they have to stop moving.

Two declarations, because the two facts live in two places. A fiscal period is an ordinary entity - two dates and a lifecycle - so the register states what only the register knows:

yaml
- name: AccountingPeriod
  period:
    start: startDate
    end: endDate                       # inclusive
    closedWhen: "Status == CLOSED"     # the immutableWhen grammar; seeded names or ids
  fields:
    - { name: id,        type: integer, primaryKey: true, generated: true }
    - { name: name,      type: string, length: 100 }
    - { name: startDate, type: date, required: true }
    - { name: endDate,   type: date, required: true }
  relations:
    - { name: Status, kind: manyToOne, to: PeriodStatus, function: EntityStatus, init: OPEN }

...and each guarded entity spends one line naming the register plus which of its own dates decides the window it falls in:

yaml
- name: JournalEntry
  immutableInPeriod: { period: AccountingPeriod, date: entryDate }

Which date counts is a modelling decision, not a convention - the issue date, the tax event date and the posting date are three different answers, and different jurisdictions pick different ones - so it is authored, never inferred.

Closing a period needs no new machinery: it is a status transition, so a transitions button, a lifecycle edge or a workflow step does it. Nothing in this construct ever writes the register.

What the lock refuses

While the register row covering the record's date is closed, the REST surface answers 409:

operationrefused because
update / delete of a record dated inside a closed windowits figures were reported
create dated inside a closed windowthis is most of what closing a period means - and unlike immutableWhen, which has no create to guard (a fresh record has no status yet)
update that moves a record into a closed windowchecked against the incoming date as well as the stored one, or the guard would protect only what was already there

Workflow and system writes through the repository stay possible, exactly as for immutableWhen: a correction to a closed period is a reversal booked in an open one, never an edit of the original, and the flow that books it has to be able to write.

The GET /{id}/mutable pre-check the status lock already exposes answers for this guard too, so a directly typed /edit URL opens read-only instead of failing on Save. The browse tables keep their baked per-row status check, which a data-driven period lock cannot join - a row's Edit opens a read-only form rather than being hidden.

Rules

  • A date covered by no period is open. Periods are opened as they are needed, and an undeclared future month must not freeze what is booked into it, so "no covering row" can only mean open. A record whose date is unset falls in no period and stays writable.
  • Both bounds are date fields and both are required. A timestamp bound would make "the period covering this date" depend on a time of day nobody authored. The end bound is inclusive.
  • closedWhen is the immutableWhen grammar over the register's own function: EntityStatus relation - terms <Status> == <seed id> joined with ||, seeded names accepted. A register with no such relation, or with no closedWhen, is a validation error: nothing would ever close it.
  • More than one covering row is not an error. If any covering row is closed, the record is frozen. Overlapping periods are a data question, not a modelling one.
  • The guarded date must be a date field of the guarded entity itself.
  • The lock reaches the document's lines, exactly as the status lock does - a line write recomputes the master's totals, so a document dated in a closed period freezes its lines with it. locksWithMaster: false is the same opt-out.
  • immutableInPeriod and immutableWhen compose: an entity may declare either, both or neither, and a record frozen by either is frozen.
  • The register is an entity of the same model. The guard is generated alongside what it guards and reads the register directly; a register owned by another model is emitted as a read-only projection with no repository to query, so it is refused at generate time rather than silently producing a guard that never fires.

Every other status construct states one edge at a time: init: says where a record starts, a transitions button guards the flips a user performs through that button, a workflow step sets a status, a checks rejection files a record in another. Nothing states which moves are legal at all - so a workflow branch, a glue action or a plain REST call can move a document from any status to any other, and the model has no opinion about it.

lifecycle: states the whole graph, once:

yaml
- name: SalesInvoice
  lifecycle:
    edges:
      - { from: DRAFT,  to: [ISSUED, CANCELLED] }
      - { from: ISSUED, to: [PAID, VOIDED] }
  relations:
    - { name: status, kind: manyToOne, to: SalesInvoiceStatus, function: EntityStatus, init: DRAFT }
  • One entry per source status, listing every status reachable from it. Either side accepts a seeded status name or its id (see Statuses by name).
  • The graph is always over the entity's function: EntityStatus relation, so it names no column. (An on: key would be redundant - and YAML reads a bare on as the boolean true, so it could never bind; it is rejected rather than silently dropped.)
  • The nomenclature must be seeded in the same intent. A status entity owned by another model is seeded there, and so is its lifecycle.
  • A status that is no edge's from is terminal.

Where it is enforced. In the generated repository - the one place every status write passes through, whoever performs it: the REST update, the transition controller's targeted write, a workflow setRelationField, a rollup or a hand-written custom action. An unmodeled move is rejected with HTTP 400 and a message naming both statuses ("This SalesInvoice cannot move from ISSUED to DRAFT

  • that is not a step its lifecycle allows"), and the record is left untouched. Guarding the transition endpoints alone would leave every other writer free to jump anywhere, which is the hole the declaration closes.

Where the status relation declares init:, the graph also fixes where a record may enter it: a create carrying any other status is refused, since entering mid-lifecycle skips the graph rather than travelling it. (The system's own create-time filing - an aggregate guard's outcome: reject - runs after that check, so it still files the record where the model says it belongs.)

What it makes impossible at generate time. With a lifecycle declared, transitions become presentation over its edges: each from status of a button must reach its setStatus along a declared edge, and a status written by a workflow step or forced by a check's rejection must be one some edge reaches. A reject path transiting through an approved status is reported when the intent is read, not discovered in production.

lifecycle: composes with stage:: a stage says what a status means (draft / live / cancelled / void) and keeps a draft or voided document out of a revenue total; the lifecycle says how a record may move.

phases - a moment an enrichment announces

Not everything a record needs is known when the row is inserted. A stock movement's cost comes from a moving-average pool, a snapshot column is copied from a register, an identifier comes back from an external system - all computed by a listener on the record's create event and written back afterwards. That write-back must not publish an update event, or it re-fires every onUpdate consumer for a change the user never made; so it is silent, and a consumer bound to onCreate races it. Two listeners on one event have no order between them, so the consumer may read the row before the value is there and produce a plausible-looking record computed from a null.

phases: declares the moments an entity announces, and the enrichment gets a channel of its own:

yaml
entities:
  - name: StockMovement
    phases: [costed]
    fields:
      - { name: id,        type: integer, primaryKey: true, generated: true }
      - { name: costValue, type: decimal, precision: 18, scale: 2 }

The generated repository gains one announce<Phase>(id, values) method per declared phase. The enriching listener writes through it - one targeted write carrying both the values and the notice, so they commit together:

java
new StockMovementRepository().announceCosted(movement.Id, java.util.Map.of("CostValue", cost));

Consumers bind the phase instead of the insert:

yaml
postings:
  - name: cogsPosting
    event: { onPhase: StockMovement, phase: costed }
    creates: JournalEntry
    backReference: StockMovement
    rule: { entity: PostingRule, match: { documentType: "Goods Issue" } }
    items:
      - { Account: rule(costOfSalesAccount), debit: "CostValue" }
      - { Account: rule(inventoryAccount),   credit: "CostValue" }

onPhase is accepted by postings, notifications, integrations, outbound and an event-driven generates; its when: guard is optional there, the phase already being one moment. A phase name is a lower-camel identifier and may not be one of the platform channels (updated, deleted, transitioned, rekeyed). A consumer binding a phase the entity does not declare fails the parse; a cross-model source declares its phases in its own model, so the name is not checked from the consumer's side there.

Declare a phase only for what a listener adds after the insert - a calculatedOnCreate expression, a calculatedActionOnCreate action, a number: stamp and a document's own totals are already in the row the create event carries. See the enrichment axis.

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

yaml
- name: SalesInvoice
  immutableWhen: "Status == 3"        # ISSUED: the document's own content freezes
- name: SalesInvoiceCustomerPayment
  locksWithMaster: false              # ...but money keeps being recorded against it
  relations:
    - { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true }

A master's immutability covers the document's own content, and by default it reaches the document's lines - a child collection freezes with its master, in the panel and at the REST layer. For some collections that is wrong: the Add button and row actions on an invoice's allocations panel then exist only while the invoice is DRAFT, i.e. never in the state where allocations matter.

locksWithMaster: false says so in the model: this collection keeps its user writes past the master's lock, affordances and endpoints together. Content and settlement are different lifecycles on the same document - an issued invoice's lines are frozen while money keeps arriving against it for months.

One declaration governs both halves, so the screen and the server can never disagree about a given collection. Engine-level writers are unaffected either way: auto-settlement, roll-ups, workflow delegates and the void transition write through the repository rather than the controller, exactly as the master's own guard already assumes.

Default true. Parse-validated on both halves - it must be a composition child, and its master must actually declare immutableWhen / immutable, so an inert declaration fails at generate time instead of quietly doing nothing. A document's own line items are unaffected by the flag: they render in the items pane, not a child panel, and stay locked with the document.

history - the shadow change trail

yaml
- name: Contract
  audit: true
  history: true                  # every write recorded as field-level deltas
  fields:
    - { name: id,     type: integer, primaryKey: true }
    - { name: amount, type: decimal }

audit: true keeps only the LAST writer and time, in four columns of the row itself. history: true keeps the whole trail: the entity gains a sibling <TABLE>_HISTORY shadow table - the same pattern as the multilingual <TABLE>_LANG table - shaped GUID, Id, Operation, Property, OldValue, NewValue, ChangedAt, ChangedBy, Source, and the generated repository appends one row per property whose value actually changed on every write path it owns: create (null -> value), update, the event-free system update, the targeted updateProperty / updateProperties writes, a document master's totals recalculation, and delete (value -> null).

Source is USER or SYSTEM. The user-facing paths record USER; every targeted / system write - a roll-up total, a workflow write-back, a process trigger stamping ProcessId - records SYSTEM. Once a number the application moved and an amount a person typed sit in the same column, nothing downstream can tell them apart, and "who changed this" is the first question asked of a trail.

The trail is read-only end to end. The entity's own controller exposes GET /{id}/history (404 on an unknown record - never an empty trail a caller could read as "nothing happened here"), and the generated manage form and document view render it as a History card in the right sidebar. No create, update or delete verb exists for the shadow table on any surface, which is what makes it append-only by construction rather than by policy.

What is deliberately NOT recorded:

  • the primary key (it never changes) and the audit columns (they restate what the entry already carries - who and when);
  • values that differ only in representation - a recomputed decimal of a different scale, a translated overlay of a stored value. The before-image is read WITHOUT the multilingual overlay, so a translated read never reports an edit nobody made;
  • rows written outside the generated repository: CSVIM seeds and direct database writes have no history, which is correct - nobody wrote them.

The personal and partner surfaces expose no history endpoint. A scoped controller strips sensitive: fields from its responses, so handing it a trail carrying those fields' old and new values would leak exactly what the scoping hides. When a scoped History panel is wanted it arrives with its per-property filter.

The append happens after the entity write, on its own connection - the store commits every operation in its own transaction, so there is no enclosing transaction to join. A failure to append is logged at ERROR and does not fail the business write, which has already been persisted.

Use it for the entities a regulated domain must be able to reconstruct, and only for those: it multiplies the write volume of the entity.

hierarchy / leafOnly - tree entities

yaml
- name: Account
  hierarchy: Parent                                        # the tree edge (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 with children.

multilingual - translated master data

yaml
languages: [en, bg]            # the languages this module PROVIDES translations for
entities:
  - name: UoM
    kind: setting
    multilingual: true         # sibling <TABLE>_LANG table; reads overlay per Accept-Language

Translations are seeds with a language: code (see seeds). The platform's supported language set is DIRIGIBLE_APPLICATION_LANGUAGES.

A report column bound to a translatable property is served in the caller's language too - the generated query LEFT-joins <TABLE>_LANG on a bound :language parameter and falls back to the base value - so a report grouping by a multilingual nomenclature shows the same term as the list page beside it. Report filters (filter:, scope:, per-column conditions) stay on the base table, so translating content never changes which rows a report returns.

Calculated fields / actions

Neutral arithmetic expressions run on the server and preview live in the UI; date functions included. For logic beyond an expression, a hand-written CalculatedField component is called out.

yaml
- { name: net, type: decimal, calculatedOnCreate: "Quantity * Price", calculatedOnUpdate: "Quantity * Price" }
- { name: days, type: decimal, readOnly: true,
    calculatedOnCreate: "businessDaysBetween(FromDate, ToDate)" }     # also daysBetween, monthsBetween
- { name: Barcode, type: string, calculatedActionOnCreate: BarcodeAction }  # + entity imports:

For document numbers use the first-class number attribute below, not a calculated action - the platform owns a gap-free sequence for you.

number - document numbering

Turns a string field into a platform-numbered document field. The platform owns a gap-free, per-tenant sequence per series and stamps the field automatically - no hand-written number action or delegate. The intent declares only a reference to a series - never how the number looks.

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

# partitioned per company, stamped at a modeled issue step (a UUID placeholder holds the field until then):
- name: Number
  type: string
  number:
    series: Sales Invoice   # documents sharing one legal range pass the same series
    per: Company            # optional: a to-one relation whose value partitions the sequence
    stampOn: issue          # create | issue
  • series (mandatory) - the sequence identity. Give several document types (invoice, credit note, debit note) the same series to share one running number.
  • per (optional) - a to-one relation of the entity whose value partitions the series: each partition value gets its own sequence, prefix and width. The canonical use is per: Company - two legal entities in one tenant each owe their own sequential range and must never share a counter. Identical numbers across partitions are correct; the partition only selects which sequence to draw from and never appears in the number. An EntityStatus relation cannot partition a series.
  • stampOn - create stamps the real number on insert; issue puts a UUID placeholder on the field at create and generates a gen/events/<Entity>NumberStamp delegate that stamps the real number when the process reaches the step wired with delegate: gen.events.<Entity>NumberStamp. Stamping is idempotent - re-issuing after an amend keeps the same number.

The series is tenant configuration, not model

A number series is a tenant-level business object, not a module asset. A number renders as a literal prefix plus the sequence zero-padded to a total width (SI00000042) - there is no token grammar, and neither the prefix nor the width is authored in the intent: baking a format into the model would force a market that numbers documents differently to fork and regenerate the application.

A module declares the series it needs in a .numbers artefact at the project root - a requirement declaration, exactly as .roles declares roles (authored by hand, never generated):

json
{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}

At publish, the .numbers synchronizer provisions each declared series per tenant when the tenant has none yet - an existing series keeps its live counter and whatever shape its administrator configured. Two modules may declare the same series only identically (a shared legal range provisions once); a differing re-declaration fails that artefact loudly, naming both modules. Unpublishing a module never removes a series or its counter - allocated ranges are business history.

A partitioned series (per:) may additionally declare its partition source - the physical table its partition values come from, with the key and display-label columns (authored physical coordinates, exactly like a .csvim's table and columns):

json
{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10,
             "partitions": {"table": "CRM_COMPANY", "key": "COMPANY_ID", "label": "COMPANY_NAME"}}]}

With a partition source declared, the Document Numbering settings label each partition row by the entity's display name ("Sales Invoice — ACME Ltd." instead of a raw id) and list a row for every partition value before its first allocation - so an operator seeds a company's starting number before its first document is issued; saving such a row provisions it exactly as the first allocation would have (the base row's shape, then the edit). The identifiers must be plain SQL names (validated at parse), and a differing cross-module re-declaration fails the artefact just like a differing shape.

Sequences are continuous and never auto-reset. A jurisdiction that restarts numbering each year does it by an administrator setting the prefix and the next value (e.g. prefix 2027-, next 1, in January) in the application shell's Document Numbering settings - visible and auditable, rather than a hidden reset rule that could mint the same number twice. Allocating from a series no .numbers artefact declares fails loudly - a document must never carry a number in a shape nobody chose.

The field is read-only in the UI, and the create-time UUID placeholder is hidden in document titles until the real number is stamped. Each series' prefix, total width and next value are configured per tenant in the Document Numbering settings, and series are reachable from bespoke code through the Java SDK DocumentNumbers facade.

view - calendar, range, slots

yaml
- name: EmployeeDayAllocation
  view: calendar                                   # 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 (leave calendar)
  calendar: { start: fromDate, end: toDate }
- name: Appointment
  view: slots                                      # slot-picker booking page
  slots: { start: startTime }

A view adds a page

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

RoutePage
/<Entity>the calendar, or the slot picker
/<Entity>/listthe entity's own browse page (list / master / document list)
/<Entity>/create, /<Entity>/{id}/editthe entity's own editor

Both browse pages carry a switch to the other, and choosing a day, an event or a free slot opens the entity's own editor. So function: Document composes with view: calendar and with view: slots: the documents are browsed on a calendar (or booked from a picker) and still edited on the document page, with their line items, Print button and inline process tasks. 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. The personal (My) surface mirrors the calendar - /my/<Entity> is the calendar, /my/<Entity>/list the list.

A document's line items on a calendar

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

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

The document keeps its header, totals and Print; only the items pane changes. Clicking an event edits that line in the usual line dialog, clicking an empty day adds one with that date filled in, and Delete moves into the dialog (a calendar has no per-row menu). It cannot be combined with documentItemsLayout: chat, which claims the same pane.

personal / partner - row-scoped surfaces

A record-owning to-one relation can scope an entity to the logged-in staff user or external partner, adding a second generated controller with server-side row-level filtering.

yaml
- name: Employee
  identity: email                    # field matched against the login username
- name: Expense
  relations:
    - { name: Employee, kind: manyToOne, to: Employee, personal: true }   # staff owner
    - { name: Supplier, kind: manyToOne, to: Supplier, partner: true }    # external-partner owner
  fields:
    - { name: rate, type: decimal, sensitive: true }   # stripped from the scoped surfaces

identity (on the person/partner entity) declares how a login maps to a record. personal: true (at most one per entity) generates an <Entity>MyController filtered to the caller's owned records on the personal shell; partner: true the mirror <Entity>PartnerController on the Partner shell (/services/web/partner/, gated by the Customer / Supplier / Partner IdP roles). A sensitive: true field is stripped from those scoped responses and ignored on their writes (enforced server-side, not merely hidden). The regular controller is unaffected; an entity may carry both. personalReadOnly: true (with personal: true) makes the personal surface see-only - the scoped writes are refused and the pages render no new/edit/delete - for records the owner may see but never author (a balance, a payslip); composition children inherit it through the parent.

Act as (delegated entry). At runtime an ADMINISTRATOR can arm an acting identity for their session (/services/core/actas; the shells offer it as "Enter data as..." / "Act as...") and work the personal surfaces in that identity's name - the manager-does-the-entry mode for users who never touch a computer. Only the identity resolution and the Inbox's assignee-task query read the override: authentication, roles and audit stamping stay the real user's, so a delegated record carries the acting person as its owner and the real user in CreatedBy/UpdatedBy; sensitive stripping and personalReadOnly refusals hold unchanged. Requires nothing in the intent - every personal: true surface inherits the mode.

visibleTo - role-scoped fields

A salary, a cost price, a margin is readable by everyone who may read the entity. visibleTo scopes a single field to a list of roles: it is stripped from every REST response and ignored on every write unless the caller holds one of them.

yaml
permissions:
  - { role: Payroll }
  - { role: Administrator }

- name: Employee
  fields:
    - { name: name,      type: string,  required: true }
    - { name: dailyRate, type: decimal, visibleTo: [Payroll, Administrator] }

Holding any one of the listed roles is enough. Every role must be declared in permissions: - a role no permission grants would hide the field from everybody, which is a typo far more often than an intention, so Generate refuses it and names the roles the model does declare.

It is an allow-list, never the inverse "hidden for these roles": a role added to the application later sees nothing until it is listed, and a misspelled role hides the value instead of exposing it.

What it does

  • Reads - the property comes back null for a caller outside the roles, on the regular controller and on the personal / partner ones (owning the record is not the same as being allowed to see every column of it).
  • Writes - a create drops the value, an update keeps the stored one. No error: the field simply is not the caller's to set.
  • Change history - a history: true entity leaves the field's entries out of the trail for that caller; it records the before/after of every write, so it would otherwise hand out exactly what the record withholds.
  • Derived totals - a roll-up, an aggregate: true master field or an aggregates: target fed by a restricted field inherits its allow-list. A sum of hidden figures is that same figure one entity out.
  • The generated UI - the pages ask the controller which fields it withholds from the caller (GET .../restricted) and leave those columns, inputs, totals, filters and CSV columns out. The browser is never told a role name, and the redaction on the wire remains the enforcement - the hiding is only so nobody stares at a permanently empty control.

Not allowed on the primary key, the entity's identity field or the document title: hiding those does not produce a restricted field, it produces a broken page. A field referenced by a label: is refused for the same reason - the generated Name is an ordinary column everyone gets.

Reports are not scoped. A report over a restricted field re-serves the figure to everyone who may open the report, so Generate emits a warning naming the report, the field and its roles. That is a legitimate thing to author - a payroll report over payroll data is the point - as long as the report's own roles say who may open it.

documentItemsLayout: chat - conversation threads

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

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

uses - cross-model references

Entities owned by another intent model are referenced read-only (a projection + FK + dropdown - no local table/DAO). Generate leaf-first so the owner's model exists. See Multi-model applications.

yaml
uses:
  - { model: countries }
entities:
  - name: Supplier
    relations:
      - { name: Country, kind: manyToOne, to: Country, model: countries }

manyToMany - the intermediate entity, materialized

An n:m is always an intermediate (link) entity - one row per link. kind: manyToMany writes that entity for you:

yaml
entities:
  - name: Order
    relations:
      - { name: products, kind: manyToMany, to: Product }        # through: OrderLine to name it

materializes, before validation and Generate:

yaml
  - name: OrderProduct                       # <Declaring><Target>, or the authored `through:`
    fields:
      - { name: id, type: integer, primaryKey: true, generated: true }
    relations:
      - { name: Order,   kind: manyToOne, to: Order, composition: true, required: true }
      - { name: Product, kind: manyToOne, to: Product, required: true }

so the link gets a real table, a detail grid under the declaring entity's page (dropdown for the target), and can be seeded, reported on and referenced like any other entity. The target may be cross-model (model:); the target-picker attributes (where / show / major / size / leafOnly) travel onto the link's target relation.

Author the intermediate entity yourself (composition to one side + manyToOne to the other, exactly as above) when the link carries bridge fields - a quantity, a partial amount, a valid-from date - or a lifecycle of its own; then drop the manyToMany. Declare an n:m on one side only, and note that a relation attribute describing a hand-authored to-one (composition, function, init, dependsOn, a calculated action, personal, partner) is rejected on a manyToMany rather than silently dropped.

A generated page shows its own fields, and a document shows its composition items. An entity that is the target of associations had no way to show the records pointing at it - a project-month and its per-employee timesheet lines, a customer and its invoices, an account and its journal entries. related: declares that register, on the referenced entity:

yaml
entities:
  - name: ProjectTimesheet
    related:
      - entity: EmployeeTimesheet          # the referencing entity
        model: employee-timesheets         # omit when it is declared in this model
        via: projectTimesheet              # omit when it points here exactly once
        label: Employee Timesheets         # omit for the pluralized entity name
        show: [number, employee, totalHours, status]   # omit for the source's own list columns

The register renders as a read-only grid on the referenced record's form / document / master page, filtered to that record, and each row opens the referencing record's own page (in the shared record dialog, so the open form keeps its unsaved edits - and so a source owned by another project works the same way).

It is a window, not an owner: the listed records have their own lifecycle, pages and processes, so there is no add, edit or delete. That is the whole difference from a composition child, which IS edited in place as a detail / document-items collection - and why a composition child is rejected here rather than rendered a second time.

The declaration lives on the referenced side because that is the only side that can know. Generation is per model and leaf-first, so the model being referenced is generated before - and generally knows nothing about - the models that reference it.

Rules: entity is required; a cross-model model: must be listed in uses:; via: is required only when the source reaches this entity through more than one relation (an invoice naming the same company as both issuer and recipient), which is refused rather than guessed; every show: name must be a field or relation of the source. A cross-model source is resolved against the owner model's generated .model (workspace, else the published registry copy) and fails loudly when it is not there, exactly like a cross-model relation.

Scope today: the power surfaces (the personal / partner surfaces render no registers yet), the filtered set without paging - like the detail panels - and sensitive: columns marked exactly as the source's own lists mark them.

processes - workflows

yaml
processes:
  - name: OrderApproval
    trigger: { onCreate: Order }
    steps:
      - { name: review,   kind: userTask, args: { assignee: manager, form: ApproveOrder } }
      - { name: decide,   kind: decision, args: { if: "action == 'approve'", then: activate, else: cancel } }
      - { name: activate, kind: serviceTask, args: { setRelationField: Status, value: 2, next: end } }
      - name: number
        kind: serviceTask
        args: { delegate: gen.events.OrderNumberStamp, next: end }   # generated by a number:{stampOn:issue} field
      - { name: cancel,   kind: serviceTask, args: { setRelationField: Status, value: 3, next: end } }
      - { name: end,      kind: end }

Service-task shapes: setField / setRelationField (generated handlers), delegate (a reusable hand-written client JavaDelegate with injected fields). Decisions may test relation.field paths (customer.creditLimit > 10000) - resolvers are generated. Tasks surface in the Inbox and inline on the record's page. 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 personal / partner), or a relation walk off the trigger record:

yaml
- name: approve
  kind: userTask
  args:
    assignee: { path: employee.manager, fallback: manager }
    form: ApproveRequest

Every segment of path is a to-one relation - the first of the trigger entity, each further one of the previous target - and the walk ends at an entity that declares identity:, which is what maps the record to a login. A cross-model relation may only be the last segment (a projection carries the target's own properties but not its relations). Every hop is validated at parse time, so a dangling segment fails Generate rather than the running process.

fallback is required and names the candidate group. The walk is resolved at task entry - later than assignee: personal, which is fixed at process start, so a relation an earlier step of the same process set is visible - and when it resolves to nobody (a null hop, a deleted record, a blank identity) the task is created unassigned and the fallback group can still claim it. That is what stops an unresolvable path from minting a task nobody can see.

A running process can also observe the outside world:

yaml
steps:
  - name: review
    kind: userTask
    args:
      assignee: reviewer
      timeout: { after: P3D, then: remind }              # non-cancelling reminder / SLA escalation
      expire:  { until: validUntil, then: markExpired }  # cancelling, date-field-driven expiry
      next: awaitReply
  - { name: awaitReply, kind: wait, args: { onCreate: CaseMessage, via: case, when: "internal == false", next: work } }

A wait step parks the flow on a message intermediate catch event until an entity event resumes it (a reply arrives, a payment lands, a goods receipt posts) - the generated listener correlates on the trigger entity's stamped ProcessId, through the via: back-reference when the event entity is a different one. timeout: / expire: are boundary timers on a user task: after: an ISO-8601 duration for a non-cancelling reminder, until: a date/timestamp field re-read at task entry for a cancelling expiry. Details: processes.

A delegate: step's failure is modelled too - retry: re-attempts it on a declared cycle, onError: routes the exhausted (or non-retried) failure like a decision branch, and a setField value of {error} records the final attempt's message on the record. Declared step data (vars: + produces: / uses:) makes the variables a delegate exchanges part of the model, and clearAfter: removes a produced secret once its consuming step completes:

yaml
vars:
  - { name: dbPassword, clearAfter: provisionApp }
steps:
  - name: createSchema
    kind: serviceTask
    args: { delegate: SchemaProvisioner, produces: [dbPassword], retry: { count: 3, every: PT30S }, onError: recordFailure }
  - name: provisionApp
    kind: serviceTask
    args: { delegate: AppProvisioner, uses: [dbPassword], retry: { count: 5, every: PT1M }, onError: recordFailure, next: done }
  - { name: recordFailure, kind: serviceTask, args: { setField: failureMessage, value: "{error}", next: end } }
  - { name: done, kind: end }

retry.count is how many further attempts follow the first (an integer >= 1), retry.every an ISO-8601 duration; an undeclared produces / uses name is a parse error, and {error} is valid only on a step reachable from an onError route. Both retry and onError apply to delegate: service tasks only. Details: retry / onError.

A parallel step runs branch steps concurrently and rejoins before next - two independent reviews of one order at once instead of one after the other:

yaml
steps:
  - { name: reviews, kind: parallel, args: { branches: [techReview, commercialReview], next: consolidate } }
  - { name: techReview,       kind: userTask, args: { assignee: engineer, form: ReviewOrder } }
  - { name: commercialReview, kind: userTask, args: { assignee: sales,    form: ReviewOrder } }
  - { name: consolidate,      kind: serviceTask, args: { setRelationField: Status, value: 2, next: end } }

It emits a BPMN parallel-gateway fork/join: the fork fans an unconditioned flow to each branch, and a synthesized converging gateway waits for all branches before the single flow to next.

A branch is a chain, not a single step: it continues through that step's own routing - its next, a decision's then/else, a boundary timeout/expire - and it may itself be a nested parallel with its own fork/join pair:

yaml
steps:
  - { name: reviews, kind: parallel, args: { branches: [techReview, commercial], next: consolidate } }
  # a two-step chain - the second step declares no routing, so it joins
  - { name: techReview,  kind: userTask,    args: { assignee: engineer, form: ReviewOrder, next: techSignoff } }
  - { name: techSignoff, kind: serviceTask, args: { setRelationField: TechStatus, value: 2 } }
  # a nested fork - no `next`, so its join flows into the enclosing one
  - { name: commercial,  kind: parallel,    args: { branches: [pricing, legal] } }
  - { name: pricing,     kind: decision,    args: { if: "amount > 1000", then: escalate, else: join } }
  - { name: escalate,    kind: userTask,    args: { assignee: manager, form: ReviewOrder } }
  - { name: legal,       kind: userTask,    args: { assignee: legal,   form: ReviewOrder } }

Everything a branch reaches is off the linear chain, so its declaration order carries no meaning - and inside a branch there is no positional fall-through: a step routes explicitly, or, declaring no routing at all, is a branch terminal and flows into the join. Route to the literal join to converge on the enclosing join gateway explicitly - that is how a decision inside a branch rejoins from both arms.

At least two distinct branches, each a declared step. join is valid only inside a branch, and no step may be named join. A branch must never route to end - the join would wait forever for a token that ended. A step may belong to only one branch, and a branch is entered through its fork only, so a branch converges on join, never on the fork's own next. A top-level fork declares next (a declared step or end); a nested fork may omit it, and then joins into its enclosing join.

forms - task UI

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

actions - custom buttons

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

generates - create-from

yaml
generates:
  - name: invoice-from-timesheet
    from: ProjectTimesheet
    to: SalesInvoice
    uses: sales                       # model alias when the target is cross-model
    map: { Customer: Customer }
    defaults: { InvoiceDate: now }
    items: { from: ProjectTimesheetItem, to: SalesInvoiceItem, map: { Description: Description } }

Adds a button on the source view; the clone saves through the target's repository 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). The same rule applies to a schedules[].generate defaults block.

Event-driven creation - event:

A create-from may declare an event: and run by itself when the source reaches a state, instead of waiting for someone to press the button. The canonical case is a document that arrives from outside and is completed by an earlier step: a fine ingested by an inbound arrival, whose responsible person is identified by a transition, must produce a declaration document from the fine and that person.

yaml
generates:
  - name: declaration-from-fine
    from: Fine
    to: Declaration
    event: { onTransition: Fine, when: "Status == IDENTIFIED" }   # or { onCreate: Fine }
    map:
      Fine: id                       # REQUIRED with an event - the back-reference, i.e. the guard
      Vehicle: Vehicle
    defaults: { declaredAt: now }
    items:                           # a whole document - header AND items
      - { name: "Fine {number}", amount: Amount }
  • Exactly one trigger, from either axis. The lifecycle axis: onTransition (a status write - the when: "<StatusRelation> == <status>" guard is mandatory; the status may be its seeded name) or onCreate (the source's insert - the guard is optional, for a source with no status lifecycle). The entity named there must be the one from: declares; the owning model is never repeated (fromUses: declares it). The process-step axis is described below.
  • map: must copy the source's id onto the target's to-one relation back to the source. Under the default cardinality that back-reference is the at-most-once guard: the create-from looks for a target already back-referencing the source and returns it instead of creating a second one, so a redelivered event - or a click afterwards - is a no-op. That holds for the lifetime of the target, not merely for a redelivery of one event, unless the target is retired - see superseding a retired target. Declaring an event without it is rejected at parse; see mode: for its second role.
  • The button is dropped by default; add button: true to keep both triggers. They share one generated create-from, and therefore one guard. button: false without an event is rejected - the action would have no trigger at all.
  • sourceStatus: composes unchanged (the flip happens once the target exists, and cannot re-trigger the create-from because the guard has already claimed the source) - but note that a source it has flipped can no longer re-qualify on its own, so an event-only rule needs sourceStatusOnRetire: to be reissuable at all.

Generated artifacts: gen/events/<module>/<ClassName>GenerateOnEvent.java, a MessageHandler on the source's <project>-<perspective>-<Entity>-transitioned topic (or its bare create topic for onCreate, or the step-scoped topic for a step binding) that re-reads the source by id, applies the guard, and calls the create-from in <ClassName>Generate.java. Without button: true that class carries no @Controller/@Post - there is no endpoint, because nothing links to one.

The process-step axis

The event: map also takes the process-step binding that notifications, integrations and departures use - onStepReached / onStepCompleted: { process: <Process>, step: <step> }. Use it when the follow-up document belongs to a moment in a flow rather than to a status, and as the route around a source whose write publishes no transition at all.

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

The process must run on the source: its trigger: entity has to be the from: entity, because a step event is delivered as a message about the record the process runs on, and that record is the one the create-from reads by id. The step must be a userTask or a serviceTask (a decision, a wait or an end occupies no moment), and the source must be local - a process and its steps belong to the model that declares them, so a fromUses: source is rejected. when: stays optional here: the step is the moment. The step emitter is generated once per observed moment even when a create-from is its only consumer.

Superseding a retired target

"At most one target per source" is a claim about targets that still count, not about rows that exist. Void the generated document and the source must be able to produce a replacement - "void and reissue" is an ordinary business flow, and the voided document is kept for the audit trail rather than edited back into shape.

Nothing on the create-from declares this. The guard reads what the target's statuses mean, which is already declared once where the nomenclature is seeded - the stage: classification a report's scope: resolves through:

yaml
entities:
  - name: Declaration
    relations:
      - { name: Fine,  kind: manyToOne, to: Fine }              # the back-reference / guard
      - { name: State, kind: manyToOne, to: DeclarationState, function: EntityStatus, init: DRAFT }

seeds:
  - name: declaration-states
    entity: DeclarationState
    rows:
      - { id: 1, name: DRAFT,     stage: draft }
      - { id: 2, name: FILED,     stage: live }
      - { id: 3, name: CANCELLED, stage: cancelled }   # a target in either of these
      - { id: 4, name: VOIDED,    stage: void }        # no longer blocks its source
  • A target whose status is classified cancelled or void is retired: the guard steps over it, so the next qualifying event - or a click - mints a fresh document. Both rows stay; the new one carries its own number and its own trail.
  • A draft or live target still blocks, so idempotence under redelivery is unchanged: a redelivered event keeps finding the document it created.
  • A target that carries no status lifecycle keeps the existence-only guard - there is no state that could retire it.
  • A target that carries a lifecycle whose nomenclature nobody classified also keeps the existence-only guard, and Generate says so: that is the case where the guard looks state-aware and is not, and a voided document would silently block its replacement forever. Classify the seeds to fix it.
  • A cross-model target is seeded in its owner model, so no classification is resolvable at the consumer - the same limit scope: has.

Reissuing automatically - sourceStatusOnRetire

Superseding frees the source's slot. Where the rule also declares a sourceStatus: completion hook, nothing can refill it - and this is the one combination where an event-driven create-from stops being event-driven after its first document.

The hook exists to move the source off the status its own trigger qualifies on, so that the guard-claimed source stops matching. Once it has run the source stands at the post-generation status, and the lifecycle graph declares no edge back, so the source never transitions through the qualifying status again and no qualifying event is ever published. Void the target and the slot is free with nobody able to knock. With button: true a person can click; an event-only rule has no reissue path at all.

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

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

Voiding the invoice returns the proforma to APPROVED - a real transition of the proforma, published on its own -transitioned channel like a transition somebody performed. The ordinary trigger re-fires, the guard steps over the retired invoice, and the replacement is minted. The reissue is the ordinary path, built from machinery that was already there - and nothing new declares what "retired" means: it is the same stage: classification the guard reads, asked from the other end.

  • It acts only while the source still stands at this rule's sourceStatus, so a source that has travelled further down its own lifecycle is never dragged back - and only while no target of that source still counts, which is the create-from's own guard asked from this end, over the same classification. That second condition is what closes redelivery: lifecycle events are delivered at-least-once, so a void can arrive again after the replacement exists - and by then the source is standing at sourceStatus once more, because the reissue put it there. The reopen runs exactly when a creation would be allowed through, which is what makes it idempotent with no marker column.
  • It writes only the status, through the targeted single-column primitive, with the -transitioned notice riding that write - so the flip and its announcement commit together and the create-from's own listener cannot miss the moment that frees it. The retired document is left exactly as it is.
  • Whether the replacement is immediate is the trigger's decision, not the reopen's. Return the source to the status the trigger qualifies on and the reissue happens at once. Return it to an earlier status (a DRAFT for correction) and nothing fires until a person moves it forward - which is how a reissue that should be reviewed is modelled.

Refused where it could never fire, at parse: without an event: (a button-only create-from carries no guard, so nothing blocks a replacement and the button is already the reissue - there is no trigger to re-fire), without sourceStatus: (there is nothing to invert), when it names the same status (a write that changes nothing announces nothing), on mode: append (no guard, so no slot), for a target with no status lifecycle or an unclassified nomenclature, and for a cross-model target (its statuses are classified in the owner model). And when the source declares a lifecycle:, the graph must declare the edge from sourceStatus back - that is exactly where the source stands when the retirement arrives, so a missing edge would fail the flip at runtime.

Generated artifact: gen/events/<module>/<ClassName>GenerateReopen.java, a MessageHandler on the target's -transitioned topic. An intent that declares no sourceStatusOnRetire: regenerates byte-identical output.

Cardinality - mode: once|append

mode: inside the event: map declares how many targets the trigger may produce.

modeBehaviour
once (default)at most one target per source - the existing-target lookup above
appendone target per delivered event - no lookup at all

append is what expresses a log entry per step, a protocol line per transition, an activity record per delivery: rows that accumulate by design. The back-reference stays required - it is the appended row's provenance rather than a dedup key - and two appending rules may deliberately share a target and a back-reference, each recording a different moment.

append is the absence of a guard, not a state-aware one

Step and lifecycle events are published after commit and are not transactional with the write, so delivery is at-least-once: under append a redelivery appends a duplicate row. It is therefore the wrong answer to "I voided the document and cannot regenerate it" - that is what superseding a retired target does, on mode: once, rather than a cardinality that would also mint a document on every later qualifying event. Anything that must exist at most once per source keeps mode: once.

An intent that declares no mode: and no step binding regenerates byte-identical output - the default is today's behaviour.

Prefer this over posts when the result is a document with line items: posts writes flat mapped rows and cannot reference the freshly created header. Prefer it over a button plus a wait step when the step is really waiting for a person to remember to click - an unclicked record parks its process instance indefinitely.

Prompted input - prompt:

When the target needs a value or two that cannot be derived from the source, prompt: declares a small input form shown before the target is created. The canonical case is manual payment allocation on an issued invoice - which payment, and how much (an allocation is often partial). It also reaches a child record on an immutable document, because per-record action buttons are not gated on mutability the way the document's own panels are (the same reason Void works on an issued invoice) - the action-shaped sibling of locksWithMaster: false, which reopens the child's own panel: use the panel when the rows are ordinary data entry, and a prompted action when the create is a guided one - a narrowed form over values the source mostly derives.

yaml
generates:
  - name: allocate-payment
    from: SalesInvoice
    to: SalesInvoiceCustomerPayment  # must be a composition child of forEntity (local, scope entity)
    label: Allocate Payment
    icon: link
    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 prompt entry names a field or to-one relation of the target, so the dialog's controls are typed from the target's own definitions and the target's dependsOn: declarations apply unchanged (the payment list narrows to the invoice's customer, amount defaults to the picked payment's amount). required: true is enforced in the dialog and again by the generated controller (HTTP 400 before anything is written). A property may not be both prompted and mapped/defaulted - exactly one writer. The create still goes through the target's repository, so the ordinary -created event, roll-ups and status flips fire unchanged.

prompt: cannot be combined with event: - an event-driven create-from runs with nobody there to answer the form.

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 (never named):

yaml
    items:                                 # computed synthetic lines over the SOURCE record
      - name: "Services for {period}"      # string: {field} interpolation (or a source-field copy / literal)
        quantity: 1                        # numeric: an arithmetic expression over the source, rounded to
        price: BillableAmount              #   the target field's scale (a bare literal is a trivial one)
        when: "BillableAmount != 0"        # optional guard: <SourceField> ==|!= <number>

A numeric cell is an arithmetic expression evaluated exactly as a calculated field or a posting item amount is (source identifiers are the PascalCase field names; a null reads as 0); a string cell interpolates {field} placeholders, copies a bare source property, or is a plain quoted literal; a to-one relation cell copies the raw source foreign key; a when cell guards the whole line. The list form is not available on a schedules[].generate.

transitions - guarded status flips

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

yaml
transitions:
  - name: VoidInvoice
    forEntity: Invoice            # must declare a function: EntityStatus relation
    from: [ISSUED, SENT]          # allowed source statuses (seeded names, or ids)
    setStatus: VOIDED             # the target status (not one of `from`)
    when: "Paid == 0"             # optional guard: <Field> ==|!= <number>
    label: Void
    icon: ban
    notify:                       # optional: mail the counterparty after the flip commits
      to: Customer.email          # (fail-soft - a mail problem cannot fail the flip)
      subject: "Invoice {number} was voided"
      body: "The invoice has been cancelled."
      attach: print               # optionally with the document itself attached

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

The notify: block is the same shape a notification or a schedule uses, and attach: print mails the record's own rendered document - see the notify block.

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 (the unposted worklist), never throws.

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

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

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

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

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.

A third trigger is onPhase: <Source>, phase: <name>, a declared enrichment moment of the source. It is the only trigger a posting may bind when the amount it posts is computed by a listener after the source row is inserted: bound to onCreate the posting races that listener and can post from a null. when is optional there too, the phase already being one moment.

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

A second posting can reverse the first (red storno) when the source document 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 (a red storno, not a swap of debit/credit), links back to the original through the storno self-relation, and is fail-soft (nothing to reverse when the source was never posted).

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

expansions - child rows from a date span

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

A span change is reconciled as a diff: the missing periods are inserted, the rows whose period fell out of the span are deleted, and every row the span still covers is kept - with its identifier and with whatever was edited on it. A generated handler has no transaction boundary (each write commits on its own), so deleting the whole set first and recreating it meant a failure partway through the recreation destroyed rows that were already committed; the diff only touches what actually changed. With spread, a kept row's share is recomputed for the new row count.

Never mix hand-entered rows into an expanded child: a row on a period the span does not cover is deleted as stale, and a second row on an already covered period is deleted as a duplicate.

Deleting the master deletes the rows it generated: the expansion owns that set, and a foreign key is never a database constraint in Dirigible (referential integrity is checked in the generated repository), so nothing else would stop the rows from outliving the record they belong to and going on feeding roll-ups and reports. The rows are removed one by one through the child's repository, so each row's delete event still fires and the roll-ups and guards downstream of it run exactly as they would for a hand-deleted row.

rollups - denormalised parent totals

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

Every op - count, sum and latest alike - recomputes on the child's create, update and delete. The update pass matters for a plain count too: a child changes parents by an ordinary edit of its parent relation, so without it the parent that received the child would never count it.

Roll-ups compose transitively across a multi-level composition (leaf edit → mid total → top total); recomputation stops when values stop changing.

The parent may live in ANOTHER model: when via is a cross-model relation the child stays local (it owns the event the handler binds to) and the parent's package + perspective are resolved from the owner's .model, so the generated handler imports gen.<owner>.data.<perspective>.<Parent>Repository and writes through it. The relation's model must be declared in uses:; the parent field is checked against the owner's model at generation time, and a roll-up that cannot be resolved (undeclared model, unknown field) is surfaced in the generate response's issues instead of being dropped silently. capacity / balance / status stay local-only in that direction - they read the parent's own limit and status seeds and stamp the capacity guard on the child.

The CHILD may be the foreign side instead, which is what an n:m allocation needs: the link entity belongs to the module that owns one side of the pairing, while the other side's total belongs to the module that owns it. Declare it on the parent's module with model: (the owner's uses: alias) plus parent: (the local entity the total lands on - authored, because a foreign child's relations are not in this document for via to be walked through):

yaml
uses:
  - { model: sales-invoices }
rollups:
  - { name: paymentAllocated, entity: SalesInvoiceCustomerPayment, model: sales-invoices,
      parent: CustomerPayment, via: CustomerPayment, field: allocated,
      op: sum, of: amount, capacity: amount, balance: unapplied }

parent: must be a local entity (a total landing in a third model is that model's roll-up to declare), via must be a to-one relation of the foreign child that references that parent, and via / of / by are validated against the owner's generated model - an unknown property, or a via pointing at another entity, drops the roll-up with an issue instead of keying the total on the wrong rows. Here capacity / balance / status DO work (they are writes on the local parent), but the overdraw guard is not installed - it belongs to the child's write path, which the owner module generates, and Generate reports that. Re-parenting a foreign row corrects the parent it moved to at once; the parent it left is corrected only when the owner model publishes a re-key notice for that relation.

aggregates - keyed cross-entity totals

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

Where rollups write 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 total is a real row other records can reference and pickers can point at: on-hand per product and store, open exposure per customer, remaining allowance per employee and year.

Emits three gen/events/<module>/<Name>AggregateOn{Create,Update,Delete}.java handlers on the source's topics. Each upserts the target row for the incoming row's key-tuple and recomputes the field from every source row sharing it (idempotent, self-healing), then writes ONLY the aggregate column through the target repository's updateDerived - so a concurrent edit to another column of the target row is never reverted. A source row with any grouping key null is ignored.

Eventually consistent, not transactionally exact.

Editing a grouping key MOVES the row between tuples and both sides are repaired. The tuple it moved into is recomputed off the -updated event; for the tuple it LEFT the generated DAO re-reads the row before the write, compares every grouping key, and - only when one actually moved - publishes the PREVIOUS row on <project>-<perspective>-<Entity>-rekeyed, which a fourth handler (<Name>AggregateOnRekey) recomputes. Only aggregate handlers listen on that topic, so no roll-up, notification or integration sees a phantom event. A tuple whose last contributing row leaves keeps its target row with a zero total. A grouping key changed through a TARGETED write (updateProperty / updateProperties, e.g. a workflow setter) moves no tuple, because those paths raise no entity event at all.

posts - derived rows on an event

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

A set value is a constant, <Source>.<field>, item.<field>, or a Calc expression over those (-item.Quantity for a sign flip). Several entries under one event emit several rows per item: a stock transfer posts an OUT and an IN movement from one document.

Emits a MessageHandler on the source's -transitioned topic (or the create topic for event: create) that re-loads the source, skips when target rows already carry the idempotentBy back-reference, and writes each row through the target repository - so the target's own numbering, checks: and derived fields fire. The declarative form of the hand-written document-to-ledger delegate; contrast generates, which creates ONE document from a user action.

resolves - fill a relation from a register valid on a date

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

The register says "X applied to Y from A to B" - a vehicle assignment, a price list, a contract in force, an org assignment - and the record carries the match key(s) and the date. dependsOn cannot express it (it is an authoring-time copy matched by equality), a decision condition is a single comparison, and a setField step writes a constant.

All three outcomes are first-class. Exactly one covering row fills the relation; NO covering row and MORE THAN ONE covering row both leave it unset - a lookup never picks one of two candidates, because a silently-wrong driver (or price, or approver) is worse than an unresolved record. Route each outcome with setStatus (a seed id or a status name) and record it with outcome:, so the unresolved records are a filterable worklist a person can finish and a process decision can branch on.

KeyMeaning
event{ onCreate: <Record> } or { onUpdate: <Record> }, plus an optional when: "<Field> == <value>" guard. onDelete is rejected - there is nothing left to fill
setthe to-one relation of the record the lookup fills
fromthe register entity (declared in this model)
matchequality keys, <registerProperty>: <recordProperty>; at least one
betweenstart / end are date/timestamp fields of the register (either may be omitted = open-ended), value the record's date the period must cover
outcomeoptional string field of the record stamped found / notFound / ambiguous
found / notFound / ambiguousoptional { setStatus: <id or name> }; needs a function: EntityStatus relation on the record

The value copied is derived, not authored: the register must have exactly ONE to-one relation to the same entity as set: - zero or two is a generation error naming the register, since a lookup with a choice of columns to copy is exactly the ambiguity this construct refuses. A record that already carries the relation is skipped, so a manual correction is never overwritten and a re-delivered event is a no-op. The end of a period is inclusive, and a date-only bound covers its whole day.

Generates a MessageHandler under gen/events that queries the register with a typed Criteria, keeps the covering rows, and writes the resolved relation, the outcome and the status in ONE targeted updateProperties - nothing else of the record is touched and no -updated event re-fires.

settlements - payment allocation

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

Generates the on-payment spread handler and an on-invoice pull delegate; pair with a rollups sum entry that maintains paid/balance/status.

The spread handler is bound to the payment's create and its update, and it allocates the payment's unallocated balance rather than appending to whatever is already allocated. A payment booked for the wrong amount and corrected the next day - or created incomplete and completed later - is therefore re-allocated for the amount it actually carries, instead of leaving the invoice settled at the original figure. An amount corrected below what the payment already covers gives the excess back, newest allocation first, and the paid roll-up follows it down. Because every delivery recomputes rather than appends, a re-delivered or replayed event changes nothing.

reports - read-only aggregations

yaml
reports:
  - name: OrdersByMonth
    source: Order
    dimensions: ["month(orderDate)"]          # month()/year() bucket dates; relation.field joins
    measures: ["count(*)", "sum(total)"]
    filter: "total > 0"
    parameters:                               # user-set inputs above the report - see below
      - { name: fromDate, target: orderDate, op: ge }
      - { name: toDate, target: orderDate, op: le }
    scope: live                               # which lifecycle rows to count - see below
    chart: bar                                # render as a chart page
    widget: { value: "sum(total)", at: { "month(orderDate)": now }, label: Revenue (this month) }
  - name: TrialBalance
    kind: balance                             # opening / period / closing debit+credit per dimension
    source: JournalEntryItem
    date: journalEntry.entryDate              # runtime From/To pickers
    debit: debit
    credit: credit
    dimensions: [account.code, account.name]
    filter: "journalEntry.status == 2"

In filter:, reference relations via relation.field (translated to a JOIN); a bare relation name passes into the SQL untranslated.

parameters - user-set inputs

A report is often read for a period, a threshold or a name the reader chooses. filter: cannot do that - it is fixed when the report is generated - and the report page's per-column filter panel can only narrow the columns the report already displays. parameters: declares the inputs:

yaml
reports:
  - name: Revenue
    source: SalesInvoice
    dimensions: [date, Customer.name]
    measures: ["sum(total)"]
    parameters:
      - { name: fromDate, target: date, op: ge }                 # From picker
      - { name: toDate, target: date, op: le }                   # To picker
      - { name: minTotal, target: total, op: ge, initial: "0" }  # amount threshold
      - { name: customer, target: Customer.name, op: like }      # name search

Each parameter renders as an input above the report and is bound into the generated query:

keymeaning
namethe input's label source and the name the value is sent under. A plain identifier.
targetthe field it filters: a field of the source, or a one-hop relation.field path (joined exactly like a dimension, so a parameter may filter by a column the report does not display)
opge, le, eq or like. like matches anywhere in the value.
typeoptional: date, timestamp, number or string. The target field already types the parameter; when declared, this is checked against it.
initialthe value bound when the input is left empty - what the report shows before the reader touches it

A parameter is bound on every call, so initial is what makes the untouched report the unfiltered one. Two comparisons have a neutral "any value" default and need no initial: a date ge/le bound (widened to all time) and a like search (the empty pattern, which matches everything). An eq selector and a numeric bound have none - declare the value the report opens with, e.g. initial: "0" for an amount threshold.

A timestamp target is compared as a date, so a le bound includes the whole day chosen. A row holding no value in the target column still appears while the input is empty; once a value is set, that row is outside the filter.

The target must be a field. A relation itself is not one (name a field of it: Customer.name), and boolean and text fields are not parameterizable. The name must be a plain identifier that is not a Java keyword and not one the platform already uses (language, filter, limit, offset, repository).

A balance report declares its own fromDate/toDate window; it may add further parameters, but not redeclare those two.

scope - which lifecycle rows an aggregate counts

An aggregation over an entity that carries a lifecycle (a function: EntityStatus relation) is wrong by default: drafts nobody has issued, cancelled documents and voided ones all land in the sum. Classify the status nomenclature with stage: and the report expresses "the rows that count" declaratively, instead of as a predicate over status ids:

yaml
reports:
  - name: RevenueByMonth
    source: SalesInvoice
    # no scope: an aggregation over a stage-classified lifecycle counts the LIVE rows
    dimensions: ["month(date)"]
    measures: ["sum(total)"]

  - name: InvoicesByStatus
    source: SalesInvoice
    scope: all                    # explicit opt-out: this report is ABOUT the lifecycle
    dimensions: [Status]
    measures: ["count(*)"]

  - name: VoidedInvoices
    source: SalesInvoice
    scope: void                   # a stage name selects the statuses classified with it
    measures: ["count(*)", "sum(total)"]

scope is all or one stage name, and requires the source to declare a function: EntityStatus relation. A stage scope adds WHERE <alias>."<STATUS FK>" IN (<the stage's seed ids>) to the generated query, ANDed onto any filter:.

With no scope, a report counts the live rows only when all three hold: it aggregates (has measures, or is a balance report); its nomenclature is stage-classified; and neither its dimensions nor its filter mention the status. The last condition matters - a breakdown by status keeps its draft rows, and a hand-written status predicate stays authoritative rather than being combined with an implicit one. Anything else counts every row, exactly as before.

Classify your statuses

When the nomenclature carries no stage: markers there is nothing to resolve, so Generate reports a warning naming the report and its status relation - shown in the Intent Editor's notes strip and in the Builder shell's publish panel, and returned as warnings from POST /services/ide/intent/generate. That warning is the only signal the omission has: the report still generates and the tile still renders a number. Treat it as a bug in the model.

A nomenclature owned by another model (uses:) is seeded there, so its stages cannot be resolved from this file; a scope over it is rejected at parse with a message pointing at an explicit filter:.

widgets - custom dashboard tiles

yaml
widgets:
  - { name: SystemHealth, kind: kpi,  url: /services/js/myapp/custom/health.js, icon: activity }
  - { name: SalesFunnel,  kind: page, url: /services/web/myapp/custom/funnel/index.html }

seeds - initial data

yaml
seeds:
  - name: statuses
    entity: OrderStatus
    rows:
      - { id: 1, name: DRAFT, stage: draft }  # what the status MEANS to the lifecycle
      - { id: 2, name: POSTED, stage: live }
  - name: cities
    entity: City
    rows:
      - { id: 1, name: Sofia, Country: 34 }   # FK by the relation's authored name (case-sensitive)
  - name: countries
    entity: Country
    file: data/countries.csv                  # large sets: developer-owned CSV in a subfolder
  - name: uoms-bg
    entity: UoM
    language: bg                              # translations for a multilingual entity (_LANG)
    rows:
      - { id: 1, name: "Килограм" }

Row keys must match a field or relation name exactly (case-sensitive).

stage - what a status means to the lifecycle

A seed row of a status nomenclature (the target of a function: EntityStatus relation) should classify itself with stage:

StageMeaning
draftNobody has issued it yet - visible to its author, not yet economically real.
liveIt counts: issued, sent, paid - anything in normal circulation.
cancelledWithdrawn before it ever became live.
voidDeliberately retired while keeping its number (анулиране) - out of circulation by design.

stage is metadata, not data: it never becomes a column, so the generated CSV and the imported table are unchanged. It exists so that "the rows that count" is declared once, where the nomenclature lives, instead of being re-derived as a magic-number predicate in every report and guard - see scope for what an aggregate counts, and superseding a retired target for when a generated document stops blocking its source.

A classified row must also carry its id (the stage classifies that id), the value must be one of the four, and an entity that declares its own stage field cannot be classified this way - the collision is reported rather than guessed at.

Statuses by name, not by id

Everywhere the intent names a status - transitions[].from / setStatus, a relation's init:, a setRelationField step's value:, abortOn.status, a check's status / setStatus, immutableWhen, a lifecycle edge, a posting's event.when, a report's filter: - write the seeded name instead of the number:

yaml
transitions:
  - { name: VoidInvoice, forEntity: Invoice, from: [ISSUED, SENT], setStatus: VOIDED, when: "Paid == 0" }
reports:
  - { name: OverdueInvoices, source: Invoice, filter: "balance > 0 AND Status != VOIDED", measures: ["sum(total)"] }

Names are resolved to ids at parse time, so nothing downstream changes and numeric ids keep working. Prefer names: 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 generating valid code that now means a different status - nothing can tell, because the emitted constant is well-formed. This is not hypothetical: it is how a red-storno posting guarded on the pre-insertion id stopped matching the Void it was written for, leaving a general ledger holding a receivable for a document that no longer existed.

An unknown name fails Generate with the known statuses listed. A name has no ordering, so Status >= ISSUED is rejected - use a report scope: for "the rows that count". A status owned by another model must still be referenced by its numeric id, since its seeds live in that model.

notifications - email on change

yaml
notifications:
  - name: welcomeMember
    event: { onCreate: Member }               # exactly one of onCreate/onUpdate/onDelete
    to: email                                 # a field, one-hop relation.field, or a literal
    subject: "Welcome"
    body: "Your membership is active."

schedules - cron

Per matching row, exactly one of notify or generate:

yaml
schedules:
  - name: monthlyTimesheets
    cron: "0 0 1 1 * ?"
    entity: Employee                          # SOURCE - local, or cross-model via `model:`
    where:
      - { field: status, op: eq, value: ACTIVE }
    generate:
      to: EmployeeTimesheet                   # cross-model target via `uses:` alias
      map: { Employee: id }
      defaults: { Period: now }

The source may live in another model via model: <uses alias> (generate action only; a forEach collection may carry its own model: too) - see glue › cross-model source.

A where value is a literal or a moment: CURRENT_DATE / CURRENT_TIMESTAMP (NOW), optionally offset by a single signed ISO-8601 duration resolved against the clock of the run that fires - which is what makes a staleness sweep expressible. Exactly one offset on one token, and the token's shape must match the queried field's (a date takes CURRENT_DATE and a date-only amount, a timestamp takes CURRENT_TIMESTAMP and any); a mismatch, a second offset, a non-ISO offset or a non-temporal field is an authoring error rather than a query that never matches. See glue › a where value relative to now.

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

integrations - outbound HTTP

yaml
integrations:
  - { name: pushNewMember, event: { onCreate: Member }, method: POST, url: "https://api.example.com/members" }

An optional payload: replaces the raw record with the envelope the receiver's contract actually specifies. Values are a literal, a direct field, a one-hop relation.field, @config:KEY, or one of the four context tokens {uuid} / {now} / {tenant} / {user}; interpolated text, nested values, multi-hop paths and unknown tokens are parse errors, and a payload needs a method that carries a body. See Declarative glue › payload.

yaml
integrations:
  - name: announceMember
    event: { onCreate: Member }
    method: POST
    url: "@config:ANNOUNCE_URL"
    payload:
      type: "member.registered"
      version: 1
      messageId: "{uuid}"
      tenantId: "{tenant}"
      email: email
      country: country.name
      registeredAt: "{now}"

inbound - arrivals from outside

Exactly one arrival per entry: an HTTP path, or a source naming exactly one of queue / topic / folder. All three deserialise the JSON into create: and save it through the entity's repository; a folder is polled (hence the mandatory cron), and every read file leaves the drop folder. See Declarative glue.

yaml
inbound:
  - { name: leadHook,  path: /webhooks/lead, create: Lead }
  - { name: leadQueue, source: { queue: leads.inbound }, create: Lead }
  - { name: leadFeed,  source: { topic: crm.leads }, create: Lead }
  - { name: leadDrop,  source: { folder: /data/inbox/leads, cron: "0 */5 * * * ?" }, create: Lead }
  # a contract with a system outside this deployment - never tenant-scoped
  - { name: leadFeedExternal, source: { queue: "global:codbex.leads" }, create: Lead }

A queue / topic name is scoped to the tenant on the broker unless it is prefixed global:, which resolves it to the bare name for every tenant and every deployment bound to it - see global destinations.

accept and map - when the payload is an envelope

The shape above only works when the sender's JSON already is the entity, field for field. A real arrival contract is an envelope, and these two optional keys read it. Both work on all three arrivals - what the payload looks like has nothing to do with what it travelled on - and omitting them keeps the behaviour above exactly.

yaml
inbound:
  - name: userAssignments
    source: { queue: "global:codbex.user-assignment-requests" }
    accept: { type: user.assignment.requested, version: 1 }   # anything else: warn and ignore
    create: TenantUserAssignment
    map:
      messageId: messageId                                     # entity field <- envelope key
      email:     email
      tenant:      { lookup: Tenant,         by: tenantId, from: tenantId }   # business key -> FK
      role:        { lookup: AssignmentRole, by: name,     from: role }
KeyMeans
accept: { <envelopeKey>: <value>, ... }Gate on the declared keys. A message that does not match is acknowledged and ignored with a warning - never failed, since failing it would only have it redelivered and a sender rolling out a new version must not fill this receiver's error queue. A webhook answers 202; a record in a drop file is skipped and the file still counts as processed.
map: { <field>: <envelopeKey> }Fill an entity field or relation from an envelope key. A key the map does not name is not the record's business.
map: { <relation>: { lookup, by, from } }Resolve a business key to a relation: read <envelopeKey> (from), find the lookup entity whose by field matches, store its id.

Rules worth knowing:

  • by: must be unique. It names a unique: true field of the looked-up entity (or its primary key), because a lookup that could match several rows would silently pick one - so a non-unique by fails at Generate rather than in production. It must be a string or integer field.
  • A lookup that matches nothing rejects the arrival, with a log naming the value it could not resolve - never a stored record with a null relation. A webhook answers 400; a drop file moves to failed/ whole, so it can be re-dropped rather than half-ingested.
  • Everything still saves through the entity's repository, so validations, translations and the create event fire exactly as for any other write.
  • Do not map the primary key - it is generated on insert. Give the arrival's own identifier a field of its own and declare unique: true on it, which is also what makes a redelivery refuse itself.
  • A lookup reads an entity declared in the same model; a cross-model lookup is not supported yet.

outbound - departures to another system

The mirror of inbound: on an event of the same axis, emit a message. to: names exactly one of queue / topic - both or neither fails at Generate. payload: is the same declared envelope integrations takes; without it the body is the record's own JSON. The publish happens after the write is persisted and is not transactional with it - a failure is logged and the write stands, and there is no outbox, exactly-once delivery or ordering guarantee. See Declarative glue.

yaml
outbound:
  - { name: publishOrder, event: { onCreate: Order }, to: { queue: "codbex.orders" } }
  - name: announceActivation
    event: { onStepCompleted: { process: OrderApproval, step: activate }, when: "channel != internal" }
    to: { topic: "codbex.order-activations" }
    payload:
      type: "order.activated"
      messageId: "{uuid}"
      tenantId: "{tenant}"
      reference: number
  # a contract with a system outside this deployment - never tenant-scoped
  - { name: publishOrderExternal, event: { onCreate: Order }, to: { topic: "global:codbex.orders" } }

The same global: prefix applies to a departure: without it the destination is scoped to the tenant that raised the event, which is right for a channel the application owns and wrong for one that is a contract with someone else - see global destinations.

permissions - roles

yaml
permissions:
  - { role: Librarian, can: [Member:read, Member:write, Loan:approve] }

Every document (header-items) master also gets a standard <Entity>.print template (the Print button renders PDF via the document-template engine, per-language via CMS folders - see Printing and documents), a <name>.test UI-test manifest, and its perspective in the generated SPA + the shared application shell (dashboard, Inbox, Documents, Reports, Settings including Region & Language).

Planned - recognised but not yet implemented

  • Other reserved function values for upcoming templates are recognised but rejected with a clear "not yet available" message. (function: Calendar is now first-class - the role alias for view: calendar.)
  • Bridge fields on a generated manyToMany link - the materialized link entity carries only its key and the two FKs; a link with data of its own is authored as an explicit intermediate entity.
  • Cross-model status names and stage scopes - a status nomenclature owned by another model is seeded there, so neither its stage: classification nor its names can be resolved from the referencing intent; both are rejected with the numeric-id fallback named.
  • Declarative glue actions beyond the current set: event-driven generateDocument (produce a PDF on an event). Today's implemented glue: triggers, decision/form resolvers, notifications, schedules (notify + generate), integrations, process-step events (landed: onStepReached / onStepCompleted on a notification, an integration or a departure), inbound arrivals (webhook, and - landed - message/file events queue/topic and polled-folder sources), publish a message on an event (landed: outbound), rollups, settlements, expansions, generates, transitions, postings, numbering (the number:{stampOn:issue} stamp delegate).
  • Cross-model schedule source - landed: a schedule's entity (and a forEach collection) may live in another model via model: <uses alias> (generate action only).
  • generates completion hook - landed: sourceStatus flips the source's status after the target is created.
  • Embedded calendar panel for a dependent composition child - landed: a calendar-view composition child renders as an embedded calendar in its master's detail pane (a scope: relation filters and prefills by the parent).
  • Owner-based user-task assignment - landed: assignee: personal routes a task to the record owner.
  • Resolver-path task assignment - landed: assignee: { path, fallback } routes a task to the person a to-one relation walk off the trigger record names, resolved at task entry.

Released under the EPL-2.0 License.