Entities & fields
Every entity becomes a table, a generated data-access layer + API, and a UI page. Primary keys are integers; composition is opt-in.
entities:
- name: Customer # PascalCase entity name
description: Buyer account
icon: user # an icon name for the generated navigation
group: master-data # navigation group in a shared shell
audit: true # adds CreatedAt / CreatedBy / UpdatedAt / UpdatedBy
fields: [ ... ]
relations: [ ... ]fields
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 200 }
- { name: total, type: decimal }
- { name: active, type: boolean, defaultValue: "true" }| Key | Meaning |
|---|---|
name | field name, camelCase (PascalCased in the generated model) |
type | logical type (see below) |
primaryKey | marks the PK; must be an integer type |
generated | auto-increment (integer PKs only) |
required | NOT NULL; the generated required-value validation keys on this. A field that also carries a default (defaultValue, or init on a relation) is NOT demanded from the caller - the default satisfies it |
length | column length for string types |
pattern | an input-format regular expression the value must match (string / text fields only) |
defaultValue | the field's default: the column default, the reason a required field is not demanded from the caller, and the value a new row is seeded with in the UI (see Field defaults) |
unique | a UNIQUE constraint (e.g. a code or business key) |
label | what the field is called in the UI, replacing the humanized name (see what a field is called) |
countryLabels | label variants resolved from the tenant's country rather than the reader's language (see what a field is called) |
visibleTo | an allow-list of roles that may read the field, enforced where the data leaves the server (see Role-scoped field visibility) |
precision / scale | override the decimal default (16, 2) |
readOnly | rendered read-only in the UI (e.g. a calculated total) |
major: false | keep the column off the compact list table (still on the detail page) |
size | form control width on a 12-column grid |
calculatedOnCreate / calculatedOnUpdate | an expression assigned to the property on insert / update |
calculatedActionOnCreate / calculatedActionOnUpdate | a server-side action call-out (see Calculated fields) |
number | turn a string field into a platform-numbered document field (see Document numbering) |
sensitive | strip this field from scoped (personal / partner) surfaces (see Scoped surfaces) |
renamedFrom | the field's former name: applying the file renames the stored column, so the values keep up with the rename (see renamedFrom / dropped) |
Logical types
string, text, integer, int, long, decimal, double, boolean, date, timestamp, uuid, month, week.
Generators map each logical type to a physical column type. text is a large-object column; uuid is a 36-character string. month (a YYYY-MM value) and week (a YYYY-Www ISO-week value) are stored as short strings and render as month / week pickers.
Normative
Primary keys must be an integer type (integer / int / long). A non-integer auto-increment column is invalid, so a uuid or string primary key is rejected. uuid is valid for non-PK fields.
Entity-level attributes
| Attribute | Effect |
|---|---|
audit: true | adds the four standard audit columns, populated automatically |
history: true | records every write as field-level deltas in a shadow history table (see history) |
multilingual: true | makes string properties translatable (see multilingual data) |
label: | a stored, read-only display name (see label) |
function: | an explicit presentation role (see function) |
order: | sequences form controls and list columns |
duplicable: | adds a Duplicate button that clones a document through the normal create path (see what a copy does not carry over) |
imports: | injects import lines into the generated data-access layer (pairs with calculated actions) |
aggregate: true | on a document master's numeric field, keeps it equal to the sum of the items' same-named field |
kind: setting | marks the entity as nomenclature / configuration (see Setting entities) |
dropped: | former field / to-one relation names whose columns applying the file removes, data included (see renamedFrom / dropped) |
Control order
By default the generated UI controls follow declaration order - all fields first, then to-one relations last. Give an entity an order: list of property names to sequence them explicitly, interleaving fields and relations for a better layout:
- name: OrderItem
order: [Id, Order, Product, Name, Quantity, UoM, Price, Total]
fields: [ ... ]
relations: [ ... ]Names match field / relation names (case-insensitive). A partial order is fine - any property not listed keeps its default position and is appended after the listed ones.
What a copy does not carry over
duplicable: true adds a Duplicate button to a document entity. It clones the current document - header plus line items - into a new draft and opens it, through the normal create path, so the document number, the initial status, the audit columns and every calculated or aggregate field are reassigned. Everything else is copied.
Copying everything else is wrong for the fields a business rule says must be fresh - the date of an invoice, its due date, its tax-event date. A create-time rule cannot correct them, because such a rule fills an empty value and respects a present one, which is exactly what makes the copied value stick. The object form of duplicable states which fields the copy does not carry over:
- name: SalesInvoice
duplicable:
defaults: { date: now } # constants written into the clone
reset: [due, taxEventDate] # dropped, so the entity's own create-time rule refills themreset: is for a field that has a create-time rule (calculatedActionOnCreate, defaultValue): each name is dropped from the clone, so the create fills it exactly as it would on a hand-made document. defaults: is for a field that has none: each entry is assigned into the clone after the resets. now is today in the field's own shape - YYYY-MM-DD for a date field, YYYY-MM for a month field, YYYY-Www for a week field - and any other value is a literal coerced to the property's declared type. Both keys name the entity's own fields and to-one relations.
The clone is built in this order: the built-in drops (primary key, audit columns, the function: EntityStatus relation, the number: field, every readOnly and aggregate property), then the reset: names, then the defaults: assignments, then every remaining property copied from the source. now resolves against the acting user's local calendar, never UTC - east of Greenwich a UTC-derived date is yesterday for the last hours of every day. duplicable: true alone behaves as it always did.
Invalid, and reported when the model is read: a reset or defaults name that is not a field or a to-one relation of the entity; a name that is one of the built-in drops, which the Duplicate decided long before reading this block; the same name in both keys; now on a property that is not a date, month or week; a reset of a required value the create cannot fill on its own - a field with neither a defaultValue nor a create-time rule, or a to-one relation that declares no init: - which would make every copy fail; and a duplicable value that is neither true, false nor a mapping.
label / countryLabels — what a field is called
A field's caption is the humanized form of its name, which is right most of the time and cannot produce an acronym, a unit or a term of art: nationalId reads as "National Id". State it instead:
fields:
- { name: nationalId, type: string, label: National ID }
- { name: iban, type: string, label: IBAN }The label is what every surface renders - the form caption, the list column header, the read-only details block - and it seeds the field's entry in the default-language catalog, so it is translated like any other label.
Some terms are fixed by the company's country, not by the language its users read the interface in. A national identification number is called ЕГН in Bulgaria and Steuer-ID in Germany; an English-reading accountant at a Bulgarian company needs the Bulgarian term, and a Bulgarian-reading user at a German company does not. Declare such a label per country and let the tenant's country resolve it:
- name: nationalId
type: string
label: National ID
countryLabels:
BG: ЕГН
DE: Steuer-IDKeys are ISO 3166-1 alpha-2 country codes. Which country a deployment serves is a property of the tenant, not of the model - the intent declares which term applies where, and nothing else.
Normative
A field's label: MUST replace the humanized field name wherever the field is rendered, and MUST seed its entry in the default-language catalog so it is translated like any other label. countryLabels: declares label variants keyed by ISO 3166-1 alpha-2 country code, resolved from the tenant's country and applied in every language: a label resolved by country is not a translation, and MUST NOT be treated as one. A country that declares no variant MUST fall back to label:, and a field that declares no label to the humanized name, so a model declaring neither behaves as before. A countryLabels key that is not a country MUST be rejected - it can never match a tenant, and would leave the base label rendering with nothing reported. A blank label:, and a variant with no label, MUST be rejected for the same reason.
unique — a business key over more than one field
unique: true on a field constrains one column. When what makes a row unique spans several, declare it on the entity instead:
entities:
- name: TenantApplication
unique:
- { fields: [tenant, application], message: "This application is already provisioned for the tenant" }fields names fields or to-one relations of the same entity; a to-one relation contributes its foreign-key column. The key is the combination of those columns - the declared order is how it reads, and a conforming implementation is not required to give it any physical meaning. message is what a caller is told when a write collides; omitted, an implementation derives one from the names.
Normative
The key MUST be enforced by the data store, so that it holds for every writer — a form, an import, an arriving message, a scheduled creation — and not only for the ones that route through the application. A colliding write MUST be reported as a conflict that a caller can distinguish from a generic failure, carrying the authored message when one was given. Every name MUST resolve to a field or a to-one relation of the same entity; a to-many MUST be rejected, having no column on this side to constrain. A key naming a single field MUST be rejected, naming the field-level unique it duplicates. A name repeated within one key, and a key declared twice on one entity, MUST be rejected. An implementation is NOT required to add the constraint to a table that already exists.
renamedFrom / dropped — evolving a table that already holds data
An entity's table outlives the version of the file that created it. Once the application holds data, renaming a field and retiring one look alike from the file alone - a name disappears and, for a rename, another appears - so the file says which it is:
entities:
- name: Invoice
dropped: [legacyCode]
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: issueDate, type: date, renamedFrom: invoiceDate }renamedFrom: on a field names the field it was called before. Applying the file renames the stored column, so the existing values move with the new name instead of staying behind in the old column while the new field starts empty. It acts only while the old column exists and the new one does not, so it is harmless to leave in the file after the rename has run.
dropped: on an entity lists former field and to-one relation names whose columns applying the file removes, data included. It is the explicit contract step of an expand/contract change: a field is first removed from the file - its column and values are kept - and dropped once nothing reads it any more.
A field simply deleted from the file is not dropped: its column and its values are kept.
Normative
Applying a file MUST NOT remove the stored column, or the values, of a field or to-one relation that the file no longer declares, unless its name is listed under the entity's dropped:; a name listed there MUST have its column removed, values included. For a field declaring renamedFrom:, while the column of the former name exists and the field's own does not, applying the file MUST make the former column's values readable under the field's name; once either condition no longer holds, renamedFrom: MUST have no effect.
Each of the following MUST be reported as an authoring error: a dropped: name or a renamedFrom: source that the entity still declares as a field or a relation, compared by the column the name maps to; a field renamedFrom its own name; a renamedFrom: source also listed under dropped:; two fields renamedFrom the same name; and an empty name in either key.
defaultValue — field defaults
defaultValue states what a field holds when nobody supplies a value:
fields:
- { name: hours, type: decimal, required: true, defaultValue: 8 }
- { name: billable, type: boolean, defaultValue: true }It has three effects at once, which are deliberately one key rather than three:
- it is the column default, so a row inserted without the column gets it;
- it satisfies
required, so the caller is not asked for a value the model already guarantees; - it seeds a new row in the UI, so an editor opens on the default instead of on a blank.
Normative
A generator MUST apply the default when creating a new record and MUST NOT re-apply it to an existing one: a value the user cleared is a value the user chose, and re-defaulting it on the next edit would silently undo an intentional change. The default is a starting value, not a constraint — the user may replace it, and nothing revalidates a stored row against it. On a to-one relation the equivalent key is init, which names a seeded record.
A default is what makes a bulk affordance one action rather than several: a dialog that creates one line per working day is only useful if the line it creates already carries the usual values.
Calculated fields
A field value can be derived instead of entered:
calculatedOnCreate/calculatedOnUpdate— an expression assigned to the property. Prefer a neutral arithmetic expression for numeric totals ("Quantity * Price","round(Net * 0.2, 2)"): the server evaluates it and the UI previews it live with the same evaluator. Date helpers such asdaysBetween,businessDaysBetweenandmonthsBetweenare available.
- { name: net, type: decimal, calculatedOnCreate: "Quantity * Price", calculatedOnUpdate: "Quantity * Price" }
- { name: days, type: decimal, readOnly: true, calculatedOnCreate: "businessDaysBetween(FromDate, ToDate)" }calculatedActionOnCreate/calculatedActionOnUpdate— a server-side call-out for logic beyond an expression. The value names a hand-written component; the intent emits no code for it. It runs server-side only (no live preview) and takes precedence over an expression on the same slot. To reference it by simple name, declareimports:on the entity:
entities:
- name: Invoice
imports: |
import example.invoices.InvoiceBarcodeAction;
fields:
- { name: barcode, type: string, calculatedActionOnCreate: InvoiceBarcodeAction }The implementation lives in the project's custom (escape-hatch) folder, never in the generated folder. For document numbers, use the first-class number attribute instead of a calculated action.
Document numbering
number: turns a string field into a platform-numbered document field. The platform owns a gap-free sequence per series and stamps the field automatically - no hand-written number generator. The intent declares only a reference to a series - never how the number looks.
# 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:
- 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 | issueseries(mandatory) — the sequence identity. Give several document types the same series to share one running number (a sales invoice, a credit note and a debit note drawing from one legal range).per(optional) — a to-one relation of the entity whose value partitions the series: each partition value owns its sequence, prefix and width. The canonical use isper: Company— two legal entities in one deployment each owe their own sequential range and must never share a counter. Identical numbers across partitions are correct (a number must be unique within a company's book, not across companies). The partition selects which sequence to draw from; its value never appears in the number. A status relation cannot partition a series.stampOn—createstamps the real number on insert;issueputs a placeholder on the field at create and stamps the real number when the process reaches the wired step. Stamping is idempotent - re-issuing after an amend keeps the same number.
The series is configuration, not model
A number series is a deployment-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. 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.
Instead, a module ships a series declaration — a requirement, exactly as it declares the roles it needs: "I need series X; if this deployment has none yet, provision it with this default prefix and width" (in the reference implementation a .numbers file at the project root):
{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}Declaring never overwrites: an existing series keeps its live counter and whatever shape its administrators configured. Two modules may declare the same series only identically (a shared range provisions once); a differing re-declaration is an error naming both declarations. Removing a module never removes a series or its counter — allocated ranges are business history.
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) — visible and auditable, rather than a hidden reset rule that could mint the same number twice. Allocating from a series no declaration provisioned is an error — a document must never carry a number in a shape nobody chose.
The field is read-only in the UI. Each series' prefix, total width and next value are visible and adjustable per deployment in the generated application's document-numbering settings.
label — a stored display name
A stored, read-only Name recomputed on every write, so lookups and dropdowns show a meaningful label instead of a raw id:
- name: SalesInvoice
label: "{Number} - {Date|yyyy MMMM} - {Customer.name}"Tokens are the entity's own fields or one-hop to-one relation properties ({Customer.name}); |format is a date pattern for temporal values. Deeper paths are rejected — compose by referencing the related entity's own label ({Parent.Name}). It is not allowed next to an authored name field, and a token must never reference a sensitive field.
function — the presentation role
Optional, and authoritative when set; inferred from structure otherwise.
- name: SalesInvoice
function: Document # header + line items + status pill + totals
- name: SalesInvoiceItem
function: DocumentItem # its line items (no "*Item" naming needed)Entity roles: Document, DocumentItem, Master, Detail, List, Setting, Calendar, Attachment, Snapshot. Field role: DocumentTitle. Relation role: EntityStatus (a managed status badge). Board, Gantt and Timeline are reserved and rejected until those presentations are supported.
A DocumentTitle is not necessarily platform-assigned. Paired with number the platform assigns the value and presents the field read-only. WITHOUT a number the title is authored by the user — the counterparty's own reference on an incoming document, for instance — and the create page must offer it as an editable control like any other field. Treating every title as assigned leaves a required field with no way to fill it, and the record cannot be created at all.
Attachments and snapshots
Two function roles attach files to a record. Both are composition children of the record they belong to.
function: Attachment gives the master a Files panel — upload, download, delete. The entity's rows carry the file metadata; the binary content lives in the platform's document store:
- name: CaseAttachment
function: Attachment
relations:
- { name: Case, kind: manyToOne, to: Case, composition: true, required: true }function: Snapshot is the immutable, versioned printed copy of a document master — the frozen artefact regulations and audits want. Each generation renders the master through its print template and stores the result as the next version; the copies appear in the same panel, download-only (never uploaded or deleted by the user):
- name: SalesInvoiceCopy
function: Snapshot
relations:
- { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true }A snapshot requires a document master (only a document has a print template to render from). Minting a copy is wired into the workflow: bind the generated snapshot handler (named <Master>SnapshotGenerator) as the delegate: of a service task at the step that finalises the document — typically right after issue. Re-issuing after an amendment keeps the document's number and mints the next version, which pairs naturally with immutableWhen and an issue-stamped document number.
The language a copy is minted in is a knob on the snapshot child — language: <code> fixes the print-template language, or languageFrom: <relation>.<field> reads it per record from a one-hop path on the document master (a to-one relation and a string field of its target holding the code — the customer decides the language their invoice is issued in; the path may cross model boundaries like any other reference):
- name: SalesInvoiceCopy
function: Snapshot
languageFrom: customer.language
relations:
- { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true }The two knobs are mutually exclusive. Absent both — or when the resolved value is blank — the mint falls back to the first entry of the application's configured language set, resolved at mint time. An unresolvable languageFrom path is a generation error, never a silent wrong-language copy. Interactive printing is a separate concern: the print action always renders live current data in the language the user picks, while the snapshot panel serves the frozen issued copies — the two coexist on the document's page.
Setting entities
- name: Country
kind: settingkind: setting marks an entity as nomenclature / configuration. It is placed under a global Settings area instead of getting its own top-level perspective, and any relation targeting it resolves its dropdown there. Settings are still real entities (own table, seeds, FK columns) — only their UI placement differs.
checks — declarative validations
Row-level and document-level validations, enforced on write / on a status transition, with an authored message:
- name: JournalEntry
checks:
- { kind: itemsMin, count: 1, status: 2, message: "An entry needs at least one line" }
- { kind: itemsSumEqual, over: [debit, credit], status: 2, message: "Debits must equal credits" }
- name: JournalEntryItem
checks:
- { kind: exactlyOne, fields: [debit, credit], message: "Exactly one of debit / credit" }
- name: SalesInvoice
checks:
- { kind: compare, field: due, op: ge, than: date, message: "Due cannot be before the invoice date" }
- { kind: compare, field: paid, op: le, than: total, message: "Paid cannot exceed the total" }
- { kind: requiredWhen, field: Customer.email, when: "sentMethod == 1", status: SENT,
message: "Sent Method is E-mail but the customer has no e-mail address" }exactlyOne and compare run on every user write, and so does a requiredWhen that names no status; itemsMin / itemsSumEqual are gated on a status transition, so drafting stays unconstrained and a failing transition aborts with the message.
kind: compare — two values of one row
compare relates two of the record's own fields, read as field <op> than, where op is ge, gt, le, lt, eq or ne. It is the rule every business document has and none of the other kinds can state: a due date never before the invoice date, a validity to never before its from, a delivery date never before the order date, an amount paid never above the total.
Being row-level it holds from the first save and takes no status gate — a rule about two values of one row is not something a transition switches on. Both operands are the entity's own fields (a comparison of two foreign keys means nothing) and must be in one comparison family: both dates, both timestamps, or both numbers of any width, where numbers compare by value so a decimal against a long is exact. A date against a timestamp is refused rather than coerced, as are a string / boolean / month operand and a field compared with itself.
An absent operand is not a violation: a comparison is about two values that exist, and whether a field may be empty at all is required:. A record carrying no due passes, and starts failing the moment a due date is entered behind the date.
The alternative — a hand-written calculated action that recomputes the offending value — corrects instead of refusing, so the person who typed the date is never told it was overruled.
kind: requiredWhen — a value required only under a condition
required: true says a value must always be there. Most rules about a missing value are not like that: the value is needed for ONE way of handling the record and meaningless for the others. An invoice sent by e-mail needs the customer's e-mail address; one sent by post does not, and one handed over needs neither — so required on the address is not the rule, and without a way to say the real one an e-mailed invoice reaches "sent" with nobody to send it to, silently.
- name: SalesInvoice
checks:
# the value lives on the related customer, and is needed at the status that sends the document
- { kind: requiredWhen, field: Customer.email, when: "sentMethod == 1", status: SENT,
message: "Sent Method is E-mail but the customer has no e-mail address" }
# ...and a rule about the record's own field, holding from the first save
- { kind: requiredWhen, field: reference, when: "kind == 'export'",
message: "An export needs a reference" }field: is the value that must be present: a field of the record, or a one-hop Relation.field over a to-one — including a relation whose target is owned by another model. The related record is loaded and the field read from it, so a relation that is not set counts as an absent value and the check fires: the value the rule is about cannot be reached.
when: is the condition — one or more <Property> ==|!= <literal> comparisons over the record's own properties, ANDed. It compares strings, integers, booleans and a to-one's key, the types an equality is exact on; a decimal, a double or a date is refused rather than compared for equality. A literal that is not a value of the compared property's type is refused too, as is a condition without the comparison shape at all: reading an uninterpretable condition as "always true" would turn the entry into an unconditional required nobody authored, and as "always false" would switch the rule off — both silently. A status may be named by its seeded name, as everywhere else.
status: is optional, and its presence decides WHEN the rule is evaluated. Without it the rule holds on every user write. With it the rule is evaluated when the record is persisted carrying that status — the transition that sends the document, not the drafting before it, which is what makes the rule expressible at all: a record being typed has not chosen how it will be sent.
severity: warn — warn and confirm instead of refuse
Every other check refuses the write. Some rules are not refusals: a second customer with the same name is legitimate (two companies may share a registered name under different registration numbers), yet the person typing it should be asked first; a document line at price zero may be a gift or a typing mistake. The soft tier states those rules — the write stays possible, and the person saving it is told and confirms.
- name: Customer
checks:
- { kind: duplicate, fields: [name], message: "A customer with this name already exists: {match}" }
- { kind: compare, field: discount, op: le, value: 50, severity: warn, message: "A discount above 50%" }
- name: SalesInvoice
checks:
# asked ONCE per document save, for all the lines that break it
- { kind: itemsCompare, field: price, op: gt, value: 0, message: "{count} line(s) at price zero" }severity: warnsoftens an ungated row-level check (compare,requiredWhen,forbidWhen,exactlyOne,agree). A check with astatusgate runs inside a workflow transition, where nobody can answer a question, so the two are refused together.duplicate— another record already carries the samefields(own fields or to-one relations). Astringortextfield is compared ignoring case and surrounding spaces, so "ACME Ltd" is asked about when "Acme Ltd " exists: the rule is the same name typed again, which rarely matches byte for byte, and a false positive only costs a confirmation. A to-one and a number compare exactly.{match}in the message is the existing record's label (itsname, else its identifier), so the warning says which record it collides with. Always a warning; the hard version isunique:.itemsCompare— on the document: every item'sfieldcompared withopto thevalueliteral.{count}in the message is the number of lines that break it. Always a warning; the hard version is acompareon the items entity.
A warning is enforced where the record is written, not only in a form: an unconfirmed write is answered with the list of warnings (each a stable code and its message) and nothing is written; the same write repeated with the codes it confirms goes through. A confirmation covers only the codes it names. A process step or a scheduled job writes on nobody's behalf and is never stopped by a warning.
kind: guard — a precondition over an aggregate
A guard compares a keyed aggregate against a minimum and decides what a violating write does:
- name: StockMovement
checks:
- kind: guard
aggregate: onHand # an `aggregates` entry whose `of` is THIS entity
minimum: 0 # recomputed total (prior rows + this row) must stay >= minimum
message: "Insufficient stock"
enabledBy: BLOCK_NEGATIVE_STOCK # optional: enforced only while this configuration key is "true"
- name: SalesOrder
checks:
- kind: guard
aggregate: openExposure
minimum: 0
outcome: task # accept the write, mark it for a human step
marker: withinCredit
- name: LeaveRequest
checks:
- kind: guard
aggregate: remaining
minimum: 0
outcome: reject # accept the write, file it already rejected
setStatus: 4outcome | Companion attribute | A violating write |
|---|---|---|
block (default) | - | is rejected with message; nothing is stored |
task | marker: a boolean field | is stored; marker is set false (and true whenever the guard holds) |
reject | setStatus: a status seed id | is stored; the record's status relation is set to that value |
One outcome would have fitted exactly one real rule. Negative stock wants the write refused; a credit-limit breach wants the order accepted and parked for a human; an over-allowance leave request wants to be filed already rejected.
The total is recomputed from the guarded entity's own rows for the incoming record's key-tuple - excluding the record being updated - rather than read from the materialised aggregate, so the decision cannot race the aggregate's maintenance. The guarded entity must be the aggregate's own source.
A record with any of the aggregate's grouping keys unset is not guarded at all. It belongs to no key-tuple - the aggregate itself ignores such a row, and materialises no target row for it - so it contributes to no total and can breach no minimum: the write proceeds, nothing is refused, a marker reads as holding and a setStatus is not written. This is the guard's half of a rule the aggregate already states, and it keeps the two a pair of computations of the same total rather than of two different ones. A grouping key that must always be there is declared required, which is the better statement where it is true.
outcome: task stamps a flag; it does not create or route to a task. A workflow decision reads the marker and routes the record - the two constructs compose, and the guard is the part that computes.
Role-scoped field visibility — visibleTo
A field is normally as visible as its entity. visibleTo narrows one field to the callers holding any one of the listed roles — the salary on an employee, the cost price on an order line, the credit limit on a customer — without splitting the record into a satellite entity:
permissions:
- { role: Payroll }
- { role: Administrator }
entities:
- name: Employee
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true }
- { name: dailyRate, type: decimal, visibleTo: [Payroll, Administrator] }Absent (the default), nothing changes: the field is visible to every caller who may read the entity. The inverse spelling (hiddenFor:) is deliberately not part of the format — a deny-list fails open (a role added later, or misspelled, would see the value); an allow-list fails closed.
Normative
A conforming generator MUST enforce the list where the data leaves the server, never only in the presentation layer. On a read, the property is absent (or null) in every response of every generated surface — the main one and the scoped ones — unless the caller holds one of the roles; owning the record, or being the partner it belongs to, grants nothing. On a write, a create ignores the submitted value and an update keeps the stored one — the rest of the write is legitimate and MUST NOT be refused for carrying a field that is not the caller's to set. Where the format records a field-level history, a restricted property's entries are withheld from a caller who may not read it. A derived value fed by a restricted field (a roll-up, an aggregated master total, a keyed aggregate) inherits the same allow-list unless it declares its own — a sum of hidden figures is that figure one entity out. The generated UI SHOULD omit the column or input for a caller who cannot read the field, and MUST derive that from what the server actually withheld, not from a role list evaluated in the client.
Edge rules: every listed role must be granted by the file's permissions (a role nothing grants hides the field from everybody, which is a typo far more often than an intention); an empty list is rejected rather than read as "no restriction"; the primary key, the entity's identity field and a document-title field cannot be restricted (hiding them breaks the page, not the figure); a restricted field cannot be a label token. A report over a restricted field is a warning, not a rejection — a report carries no field-level scoping, so the author is told which report re-serves which figure and scopes the report's own roles accordingly. sensitive and visibleTo are independent and compose: the first is about a surface, the second about a role. A relation (a foreign key) cannot be restricted this way.
immutableWhen / immutable — user-write immutability
- name: JournalEntry
immutableWhen: "Status == 2" # while POSTED, user update / delete are rejected (join terms with ||)
- name: InvoiceSnapshot
immutable: true # append-only: a frozen copy stored when a record is finalisedimmutableWhen requires a function: EntityStatus relation; immutable: true needs none and is mutually exclusive with it. System / workflow writes stay possible — corrections to an immutable record are flow-generated reversals, never edits.
The lock also covers the entity's composition children. A child declares no immutability of its own, but its writes maintain the master's derived values — a line resums the document's totals — so a line write on a locked document reaches exactly what the lock protects: the totals a stamped number, a frozen copy and a posted ledger entry were all taken from. locksWithMaster is how a collection opts out.
Normative
A generator MUST refuse a user create, update or delete of a composition child whose master is currently immutable, unless that child declares locksWithMaster: false. The refusal MUST cover every user surface it generates, not only the affordances it renders — permitting the write through a different door undoes the lock as surely as removing it. It MUST NOT extend to system / workflow writes, which are what corrects an immutable record.
lifecycle — the legal status graph
Everything else about statuses is stated 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 check files a rejected record in another. Nowhere does the file say which moves are legal at all — so any writer that is not a transition button (a workflow branch, a glue action, an API call) can move a document from any status to any other, and nothing notices.
lifecycle: states the whole graph, once:
- name: SalesInvoice
lifecycle:
edges:
- { from: DRAFT, to: [ISSUED, CANCELLED] }
- { from: ISSUED, to: [PAID, VOIDED] }- One entry per source status, listing every status reachable from it. Both sides accept a seeded status name or its id.
- The graph is always over the entity's
function: EntityStatusrelation, so it names no column; the nomenclature must be seeded in the same file (a status entity owned by another model is seeded there, and so is its lifecycle). - A status not listed as any
fromis terminal; a status listed nowhere is simply unreachable through this entity.
Normative
A conforming generator MUST validate every status write against the graph — user, workflow, glue, transition button alike — and reject a move no edge declares, with a message naming both statuses. Enforcement therefore belongs to the layer every writer passes through (the generated persistence layer), never to the transition endpoints alone, which would leave every other writer unguarded. Where the status relation declares init:, a record MUST also be created in that status: entering the lifecycle anywhere else skips the graph rather than travelling it.
Normative
With a lifecycle declared, transitions become presentation over its edges: each from status of a transition MUST reach its setStatus along a declared edge, and a status written by a workflow step or forced by a check's rejection MUST be one that some edge reaches. A conforming generator reports the disagreement when the file is read, not when the button is pressed — a reject path transiting through an approved status is exactly the mistake the graph exists to catch.
It composes with the stage: classification: a stage says what a status means (draft, live, cancelled, void) and scopes reports by it; the lifecycle says how a record may move between statuses.
phases — a moment an enrichment announces
Not everything a record needs is known when its row is inserted. An inventory ledger values stock at a moving average: the movement row is created when a goods document posts, and a listener on that create event reads the pool, computes the movement's cost, and writes it back.
That write-back must not raise an update event. An enrichment is not a user's edit, and publishing it as one re-fires every consumer that reacts to an edit. So the enrichment is silent — and a declarative consumer of the enriched value has no moment to bind. Bound to the create event it runs as a sibling of the enriching listener, with nothing ordering the two: it may read the row before the value is there and produce a perfectly balanced journal entry for a null amount, with the parse, the generation and the deployment all green.
phases: declares the moments an entity announces, giving that silent write a channel of its own:
entities:
- name: StockMovement
phases: [costed]
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: costValue, type: decimal, precision: 18, scale: 2 }For each declared phase the generated data-access layer exposes an operation that applies the enriched values and announces the phase in one write. The enriching listener calls it instead of a plain silent write, so a consumer can never observe the announcement without the values, nor the values without an announcement.
A glue consumer then binds the phase instead of the insert:
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" }A phase-bound consumer receives the record exactly as a lifecycle-bound one does: the same payload, the same recipient paths, the same placeholders, the same optional when: guard. Only the moment differs — and the guard is optional here, because a phase already names one moment where a status transition is any status write.
Rules. A phase name is a lower-camel identifier, unique within its entity, and may not be named after a channel the platform itself publishes (an update, a delete, a status transition) — announcing one would re-fire that channel's consumers. An onPhase binding must name a declared phase of the bound entity; an undeclared one is rejected, since it would bind a channel nothing publishes to and the consumer would simply never fire. A phase: key on a binding of another axis is rejected rather than ignored. When the bound entity is owned by another model its phases are declared there, so the name is not resolvable from the consumer's side — the same limit a cross-model status nomenclature has.
Declare a phase only for what a listener adds after the insert. A calculated expression, a calculated action, a stamped document number and a document's own totals are already in the row the create event carries.
locksWithMaster — a child collection that outlives its master's lock
An entity's immutability covers that entity and the collections composed into it. For some children that is wrong — a master that freezes its content says nothing about a collection recording what happens to the document afterwards:
- name: Invoice
immutableWhen: "Status == 3" # ISSUED: the document's own content freezes
- name: InvoiceAllocation
locksWithMaster: false # ...but money keeps being recorded against it
relations:
- { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true }The canonical case is settlement: an issued invoice's lines are frozen — that is the audit trail — while payment allocations against it go on being recorded for months. Content and settlement are different lifecycles on the same document.
Normative
locksWithMaster defaults to true, so a child that says nothing keeps freezing with its master — in the affordances a generator renders for that collection AND in the writes it accepts for it.
A generator MUST NOT extend a master's user-write immutability to a child collection declared locksWithMaster: false — including the affordances it renders for that collection, not merely the writes it accepts. A read-only rendering that the server would have permitted is the same defect as a refused write. One declaration governs both halves, so a generator's screen and its server can never disagree about a given collection.
The declaration is only meaningful on a composition child whose master actually declares immutability; a generator MUST reject it elsewhere rather than ignore it, since an inert declaration is indistinguishable from a working one until someone needs it. It does not apply to a document's own line items, which ARE the document's content.
history — the change trail
audit: true records only the last writer and time, in four columns of the row itself. Where a domain has to answer what changed, from what to what, by whom, when — for every write, for years — declare a history:
- name: Contract
audit: true
history: true # every write is recorded as field-level deltas
fields:
- { name: id, type: integer, primaryKey: true }
- { name: amount, type: decimal }The entity gains a shadow history table — a sibling of its own table, like the multilingual language table — carrying one entry per property whose value actually changed on a write: the property, its old value, its new value, who wrote it, when, and whether the write came from a user or from the system (a roll-up total, a workflow write-back, a recomputed document total). A create is recorded as null -> value and a delete as value -> null, so the trail alone reconstructs the row at any point in its life. The record's own form shows it as a read-only History panel.
The source matters as much as the delta. Once a total the application recomputed and an amount a person typed sit in the same column, nothing downstream can tell them apart — and "who changed this" is the first question asked of a trail.
Normative
The shadow table is append-only by construction: a generator MUST NOT emit any create, update or delete path to it — not a service, not an endpoint, not a UI affordance. Append-only enforced by policy is not append-only.
Every write path the generated data-access layer offers MUST append, including the targeted single-column and multi-column writes the system uses; a path that writes silently is worse than no trail, because the trail then reads as complete.
An entry MUST record whether the write was a user write or a system write.
Only properties whose value actually changed are recorded. Values that differ solely in representation (a decimal of a different scale, a translated overlay of a stored value) are NOT changes, and a generator MUST NOT record them as such.
The primary key and the audit columns are NOT tracked — the key never changes and the audit columns restate what the entry already carries.
A scoped surface that hides sensitive: fields MUST NOT be given a history it cannot filter: either the trail it exposes excludes those properties, or it exposes none. Leaking a hidden field's old and new values defeats the scoping exactly.
Rows written outside the generated data-access layer — seeds, direct database writes — have no history, and a conforming tool documents that rather than implying completeness.
hierarchy / leafOnly — tree entities
- name: Account
hierarchy: Parent # the tree edge (a self-relation)
relations:
- { name: Parent, kind: manyToOne, to: Account }
# elsewhere - only leaf accounts are referenceable (server-enforced):
- { name: Account, kind: manyToOne, to: Account, model: accounts, leafOnly: true }The list renders as an expandable tree; the server rejects cycles and leaf-only references to a node that has children.
See also
- Relations & multi-model — how entities reference one another, within and across models.
- Data, seeds & naming — how the physical table and column names are derived.
- DSL reference — one-line lookup for every construct.