The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 RFC2119 RFC8174 when, and only when, they appear in all capitals, as shown here. Rules highlighted as Normative bind every conforming file and generator; all other text is informative.
This document is licensed under The Apache License, Version 2.0.
A rendered, navigable version of this specification is published at intentfile.org. This file is the normative source.
- Overview
- Entities & fields
- Relations & multi-model
- Processes & forms
- Presentation
- Declarative glue
- Scoped surfaces & roles
- Data, seeds & naming
- Appendix A: DSL index
A single .intent file at a project root is the source of truth for a whole application. It is authored one altitude above the models a platform generates from: instead of hand-authoring a data model, process definitions, forms, reports, roles and seed data separately, you author all of them from one YAML document, and a conforming generator produces them for you.
The intent never emits application code. It stops at the model layer. Schema, persistence, APIs, user interface, jobs, listeners, processes and security are produced from those models by the platform's own generation step. That boundary is non-negotiable.
| Altitude | Artefact | Authored by | Transform below it |
|---|---|---|---|
| 1 — Intent | one *.intent per project |
a human, or an AI assistant proposing patches | deterministic generation |
| 2 — Models | the platform's model artefacts (data model, processes, forms, reports, roles, seed data) | the intent generators | the platform's template engine |
| 3 — Application | schema, persistence, APIs, UI, jobs, listeners, processes, security | the platform's application templates | brought live by the runtime |
Each layer is the deterministic input to the one below it. The only fallible, supervised step is turning natural language into an Intent File; every transform below the top layer is a pure function.
The Intent File is an authoring artefact, not a runtime one. It gets an editor and an explicit Generate step; it is not silently reconciled from a repository behind your back.
- Generation happens in your workspace project, visible immediately, before anything is published.
- A published Intent File is inert source, exactly like any other authored model. The generated models and code are what run.
- There is no intent daemon, no intent database table - the file is read only when you ask the generator to run.
1. Create a project.
2. Author app.intent (any *.intent) at the project root - by hand or with an
AI assistant that proposes reviewable patches.
3. Open it in the intent editor: structured YAML, a live read-only diagram, and
inline validation.
4. Generate. The generators write the derived model artefacts NEXT TO app.intent:
the data model entities + relations + UI metadata
process definitions workflows
forms task data-entry pages
reports aggregations, charts, dashboard tiles
roles permissions
glue triggers, notifications, schedules, roll-ups, ...
seed data initial / reference rows
custom-action descriptors actions, generates, transitions (buttons)
document templates printable documents
a test manifest UI-test descriptor
5. Generate once more, one level down: the template engine turns the models into
the full-stack application.
6. Publish. The runtime brings it live exactly as for any hand-modelled project.
The folders layer cleanly, each owned by exactly one tool:
| Folder | Owned by | Lifecycle |
|---|---|---|
app.intent |
you (and the AI assistant) | the only hand-authored artefact |
| project-root model files | the intent generators' Generate | re-emitted and scrubbed on every Generate |
| the generated code folder | the template engine | wiped wholesale on every regeneration |
| the custom folder | you | the escape hatch - touched by nobody |
Do not hand-edit the generated model files. Changes are overwritten, and a file no longer backed by the intent is scrubbed on the next Generate. Adding an app.intent to a classic project hands ownership of its root-level model files to the intent generators; migrate them into the intent first.
Comments, multi-line strings and friendly diffs matter for an artefact a human reviews and an AI patches. The parser loads the document safely - type tags (!!type) are blocked, because an Intent File often arrives from generated output or paste and must never be a code-execution surface.
Every top-level collection defaults to empty, so a partial file (entities only) is valid. Field names are camelCase; entity names are PascalCase.
name: orders
description: Order management with an approval workflow
version: 1
entities:
- name: Customer
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 200 }
relations:
- { name: orders, kind: oneToMany, to: Order }
- name: Order
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: orderDate, type: date, required: true }
- { name: total, type: decimal }
relations:
- { name: customer, kind: manyToOne, to: Customer }
- { name: items, kind: oneToMany, to: OrderItem }
- name: OrderItem
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: quantity, type: integer, required: true }
relations:
- { name: order, kind: manyToOne, to: Order, composition: true }
processes:
- name: OrderApproval
trigger: { onCreate: Order }
steps:
- { name: managerReview, kind: userTask, args: { assignee: manager, form: ApproveOrder } }
- { name: done, kind: end }
forms:
- name: ApproveOrder
forEntity: Order
fields: [orderDate, total]
actions: [approve, reject]
reports:
- name: OrdersByCustomer
source: Order
dimensions: [customer]
measures: ["count(*)", "sum(total)"]
permissions:
- { role: Sales, can: [Customer:read, Order:create] }
- { role: Manager, can: [Order:approve] }
seeds:
- name: order-statuses
entity: OrderStatus
rows:
- { id: 1, name: DRAFT }
- { id: 2, name: ISSUED }Normative. These rules keep the file diff-stable, safe to parse, and friendly for both human review and AI patching.
- Comments are encouraged. No tool rewrites the file, so developer comments stay put; an AI patch path is expected to preserve them.
- No anchors or aliases (
&foo/*foo). They make diffs harder to read and harder for an AI to patch minimally. Prefer adefaults:block if duplication hurts. - No multi-document YAML (
---). One file, one document. - No type tags. Blocked by the safe parser.
- Quote unquoted braces in scalars.
to: {member.email}is parsed by YAML as an object, not a string - writeto: member.email. Braces are only for{...}interpolation insidesubject/bodytext. - An event-binding key is
event:, neveron:- YAML 1.1 resolves a bareon(andoff/yes/no) to a boolean. An action key isdo:.
Every entity becomes a table, a generated data-access layer + API, and a UI page. Primary keys are integers; composition is opt-in.
entities:
- name: Customer # PascalCase entity name
description: Buyer account
icon: user # an icon name for the generated navigation
group: master-data # navigation group in a shared shell
audit: true # adds CreatedAt / CreatedBy / UpdatedAt / UpdatedBy
fields: [ ... ]
relations: [ ... ]fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 200 }
- { name: total, type: decimal }
- { name: active, type: boolean, defaultValue: "true" }| Key | Meaning |
|---|---|
name |
field name, camelCase (PascalCased in the generated model) |
type |
logical type (see below) |
primaryKey |
marks the PK; must be an integer type |
generated |
auto-increment (integer PKs only) |
required |
NOT NULL; the generated required-value validation keys on this |
length |
column length for string types |
defaultValue |
column default |
unique |
a UNIQUE constraint (e.g. a code or business key) |
precision / scale |
override the decimal default (16, 2) |
readOnly |
rendered read-only in the UI (e.g. a calculated total) |
major: false |
keep the column off the compact list table (still on the detail page) |
size |
form control width on a 12-column grid |
calculatedOnCreate / calculatedOnUpdate |
an expression assigned to the property on insert / update |
calculatedActionOnCreate / calculatedActionOnUpdate |
a server-side action call-out (see Calculated fields) |
number |
turn a string field into a platform-numbered document field (see Document numbering) |
sensitive |
strip this field from scoped (personal / partner) surfaces (see Scoped surfaces) |
string, text, integer, int, long, decimal, double, boolean, date, timestamp, uuid, month, week.
Generators map each logical type to a physical column type. text is a large-object column; uuid is a 36-character string. month (a YYYY-MM value) and week (a YYYY-Www ISO-week value) are stored as short strings and render as month / week pickers.
Normative. Primary keys must be an integer type (
integer/int/long). A non-integer auto-increment column is invalid, so auuidor string primary key is rejected.uuidis valid for non-PK fields.
| Attribute | Effect |
|---|---|
audit: true |
adds the four standard audit columns, populated automatically |
multilingual: true |
makes string properties translatable (see multilingual data) |
label: |
a stored, read-only display name (see label) |
function: |
an explicit presentation role (see function) |
order: |
sequences form controls and list columns |
duplicable: true |
adds a Duplicate button that clones a document through the normal create path |
imports: |
injects import lines into the generated data-access layer (pairs with calculated actions) |
aggregate: true |
on a document master's numeric field, keeps it equal to the sum of the items' same-named field |
kind: setting |
marks the entity as nomenclature / configuration (see Setting entities) |
By default the generated UI controls follow declaration order - all fields first, then to-one relations last. Give an entity an order: list of property names to sequence them explicitly, interleaving fields and relations for a better layout:
- name: OrderItem
order: [Id, Order, Product, Name, Quantity, UoM, Price, Total]
fields: [ ... ]
relations: [ ... ]Names match field / relation names (case-insensitive). A partial order is fine - any property not listed keeps its default position and is appended after the listed ones.
A field value can be derived instead of entered:
calculatedOnCreate/calculatedOnUpdate— an expression assigned to the property. Prefer a neutral arithmetic expression for numeric totals ("Quantity * Price","round(Net * 0.2, 2)"): the server evaluates it and the UI previews it live with the same evaluator. Date helpers such asdaysBetween,businessDaysBetweenandmonthsBetweenare available.
- { name: net, type: decimal, calculatedOnCreate: "Quantity * Price", calculatedOnUpdate: "Quantity * Price" }
- { name: days, type: decimal, readOnly: true, calculatedOnCreate: "businessDaysBetween(FromDate, ToDate)" }calculatedActionOnCreate/calculatedActionOnUpdate— a server-side call-out for logic beyond an expression. The value names a hand-written component; the intent emits no code for it. It runs server-side only (no live preview) and takes precedence over an expression on the same slot. To reference it by simple name, declareimports:on the entity:
entities:
- name: Invoice
imports: |
import example.invoices.InvoiceBarcodeAction;
fields:
- { name: barcode, type: string, calculatedActionOnCreate: InvoiceBarcodeAction }The implementation lives in the project's custom (escape-hatch) folder, never in the generated folder. For document numbers, use the first-class number attribute instead of a calculated action.
number: turns a string field into a platform-numbered document field. The platform owns a gap-free sequence per series, renders it through a format, and stamps the field automatically - no hand-written number generator.
# stamped on create (the number exists the moment the record is saved):
- { name: Number, type: string, number: { series: Proforma, format: "PF{seq:08}", stampOn: create } }
# stamped at a modeled issue step (a placeholder holds the field until then):
- name: Number
type: string
number:
series: SalesInvoice # documents sharing a sequence pass the same series
format: "SI-{year}-{seq:05}" # {seq} / {seq:0N} / {series} / scope tokens {year}, {<Field>}
scope: [year] # partitions the counter; omit for one continuous sequence
stampOn: issue # create | issueseries(default: the entity name) — the sequence identity. Give several document types the same series to share one running number.format(default{series}-{seq:06}) —{seq},{seq:0N}(zero-padded),{series}, and scope tokens{year}/{<Field>}.scope—yearand/or sibling field names; partitions the counter and supplies the format's scope tokens.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 field is read-only in the UI. Counters are visible and adjustable in the generated application's document-numbering settings.
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.
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.
Two function roles attach files to a record. Both are composition children of the record they belong to.
function: Attachment gives the master a Files panel — upload, download, delete. The entity's rows carry the file metadata; the binary content lives in the platform's document store:
- name: CaseAttachment
function: Attachment
relations:
- { name: Case, kind: manyToOne, to: Case, composition: true, required: true }function: Snapshot is the immutable, versioned printed copy of a document master — the frozen artefact regulations and audits want. Each generation renders the master through its print template and stores the result as the next version; the copies appear in the same panel, download-only (never uploaded or deleted by the user):
- name: SalesInvoiceCopy
function: Snapshot
relations:
- { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true }A snapshot requires a document master (only a document has a print template to render from). Minting a copy is wired into the workflow: bind the generated snapshot handler (named <Master>SnapshotGenerator) as the delegate: of a service task at the step that finalises the document — typically right after issue. Re-issuing after an amendment keeps the document's number and mints the next version, which pairs naturally with immutableWhen and an issue-stamped document number.
- name: Country
kind: settingkind: setting marks an entity as nomenclature / configuration. It is placed under a global Settings area instead of getting its own top-level perspective, and any relation targeting it resolves its dropdown there. Settings are still real entities (own table, seeds, FK columns) — only their UI placement differs.
Row-level and document-level validations, enforced on write / on a status transition, with an authored message:
- name: JournalEntry
checks:
- { kind: itemsMin, count: 1, status: 2, message: "An entry needs at least one line" }
- { kind: itemsSumEqual, over: [debit, credit], status: 2, message: "Debits must equal credits" }
- name: JournalEntryItem
checks:
- { kind: exactlyOne, fields: [debit, credit], message: "Exactly one of debit / credit" }exactlyOne runs on every user write; itemsMin / itemsSumEqual are gated on a status transition, so drafting stays unconstrained and a failing transition aborts with the message.
- 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.
- name: Account
hierarchy: Parent # the tree edge (a self-relation)
relations:
- { name: Parent, kind: manyToOne, to: Account }
# elsewhere - only leaf accounts are referenceable (server-enforced):
- { name: Account, kind: manyToOne, to: Account, model: accounts, leafOnly: true }The list renders as an expandable tree; the server rejects cycles and leaf-only references to a node that has children.
relations:
- { name: customer, kind: manyToOne, to: Customer }
- { name: orders, kind: oneToMany, to: Order }
- { name: order, kind: manyToOne, to: Order, composition: true }Relation kinds: oneToMany, manyToOne, oneToOne, manyToMany. The foreign key lives on the to-one side; the oneToMany / manyToMany sides are navigation-only (the column is on the child).
required: trueon a to-one makes the FK NOT NULL but keeps the entity top-level with its own perspective (a plain dropdown).composition: trueon a to-one makes it a master-detail composition: the owning entity becomes dependent (managed as details under its parent's perspective), and the FK is NOT NULL. Only amanyToOne/oneToOnecan be a composition; an entity's first composition to-one is its composition parent. Declare the inverseoneToManyon the master so the child is managed as its detail.
Composition is opt-in — most required FKs are plain associations, and composition is explicit.
- { name: Currency, kind: manyToOne, to: Currency, size: 4 } # form control width
- { name: Payment, kind: manyToOne, to: Payment, show: [date, number] } # extra read-only lookup columns
- { name: Status, kind: manyToOne, to: OrderStatus, function: EntityStatus, init: 1 } # managed badge, seeded default
# Depends-on - cascade, narrow-to-referenced, or auto-populate:
- { name: City, kind: manyToOne, to: City, dependsOn: { relation: Country, filterBy: Country } }
- { name: UoM, kind: manyToOne, to: UoM, dependsOn: { relation: Product, valueFrom: UoM } }
- { name: price, type: decimal, dependsOn: { relation: Product, valueFrom: price } }
# Static option filter - e.g. only stock-tracked products:
- { name: Product, kind: manyToOne, to: Product, where: { Type: 1 } }function: EntityStatusmarks the relation as the entity's managed status badge;init:seeds its default at the database level (a race-free start). This relation is whatimmutableWhen,transitionsandpostingskey on.dependsOnlinks one dropdown to another:filterBynarrows the options to those matching the parent selection;valueFromcopies a value from the referenced record (a snapshot).wherefilters the dropdown to options matching a static condition.
There is no manyToMany materialisation - the kind is parsed but never turned into a join table. Model n:m as an explicit intermediate entity holding a composition to one side, a manyToOne to the other (which may be cross-model via model:), plus any bridge fields:
- name: SalesInvoiceCustomerPayment
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: amount, type: decimal, precision: 18, scale: 2, required: true } # partial allocation
relations:
- { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true }
- { name: CustomerPayment, kind: manyToOne, to: CustomerPayment, model: customer-payments, required: true }The intermediate entity is a real entity you can read, seed and report on — which is usually what a real n:m relationship needs anyway.
A non-trivial domain is rarely one project. The intent layer lets you split it into several intent projects — one *.intent each — that reference each other across models, reuse single master-data entities instead of redefining them, and contribute their screens to one shared shell.
Each module can be its own repository, versioned and shipped independently as a build artefact and consumed by others as a dependency — so a currencies or customers module is published once and reused across many applications.
Master / reference data (Customer, Country, Currency, UoM) is owned by one project. Every other project that needs it stores an integer FK and renders a dropdown sourced from the owner's service — it does not generate the owner's table or API.
Declare the dependencies in a top-level uses: block, then point a manyToOne / oneToOne relation at the alias with model::
name: customers
uses:
- { model: countries } # project defaults to the model alias
- { model: currencies, project: currencies } # set project only when it differs from the alias
entities:
- name: Customer
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true }
relations:
- { name: Country, kind: manyToOne, to: Country, model: countries }
- { name: Currency, kind: manyToOne, to: Currency, model: currencies }Normative. A cross-model relation must be
manyToOne/oneToOne, itsmodel:must be listed inuses:, and it cannot becomposition: true— a detail cannot be owned across models. The consumer stores a projection of the owner entity so the FK dropdown resolves against the owner's live service.
Each project generates its own standalone shell (handy to run one domain in isolation). They also contribute their entities as grouped perspectives to a single shared shell, so the user never jumps between per-project UIs. Two pieces drive this:
1. group: on an entity places its perspective under a named navigation group:
entities:
- name: Customer
group: partners # appears under the "Partners" group in the shared shellThe entity references the group id only.
2. A navigation project defines each group once. Group ids are declared in one dedicated project so they are not redeclared per domain (the shell drops duplicate group ids). The domain entities then reference these ids (group: sales, group: settings, ...), and the shared shell aggregates every contributed perspective into its sidebar, ordered by each group's declared order.
Cross-model dropdowns read the owner's already-generated model at generation time and call the owner's live service at runtime, so order matters:
- Generate the owners (leaves) first, then their consumers.
- Publish everything — every owner must be live for a consumer's cross-model dropdown to resolve.
- Open the shared shell — one grouped sidebar over every module.
Because table names are intent-prefixed, the projects share one schema without colliding.
processes:
- name: OrderApproval
trigger: { onCreate: Order, when: "total > 0" }
steps:
- { name: managerReview, kind: userTask, args: { assignee: manager, form: ApproveOrder } }
- { name: bigOrder, kind: decision, args: { if: "customer.creditLimit > 10000", then: cfoReview, else: activate } }
- { name: cfoReview, kind: userTask, args: { assignee: cfo, form: ApproveOrder } }
- { name: activate, kind: serviceTask, args: { setRelationField: Status, value: 2, next: done } }
- { name: done, kind: end }Generates one process definition per processes[] entry (a standard workflow model plus its diagram layout, so a modeller renders it).
Step kinds: userTask, serviceTask, decision, script, wait, end.
Steps flow linearly in declaration order. Any step may override its successor with args: { next: <step | end> } — this is how two decision branches converge instead of the first falling through into the second (an activate branch routes to done so it never falls into the cancel branch declared after it). next must name a declared step or the literal end.
Service-task shapes: setField / setRelationField (generated handlers that write a field or flip a status relation on a branch), and delegate (a handler referenced by name with injected fields — hand-written, or a generated one such as a snapshot generator). Set a status on the branch that reaches it, never on the shared task, so a reject path does not transit through the approved status.
if + then are mandatory, else optional. then / else must name a declared step or the literal end; the parser validates this, so a typo fails at parse time rather than producing an invalid workflow. Without else, the gateway default falls through to the next step.
A decision condition may walk one hop off the trigger entity (customer.creditLimit > 10000): a resolver step is generated before the gateway to load the related entity and rewrite the condition.
A wait step parks the process until an entity lifecycle event resumes it — a case waiting for a reply, a flow waiting for a payment, an order waiting for its goods receipt:
steps:
- { name: requestInfo, kind: serviceTask, args: { setRelationField: Status, value: 4, next: awaitReply } }
- { name: awaitReply, kind: wait, args: { onCreate: CaseMessage, via: case, when: "internal == false", next: work } }
- { name: work, kind: userTask, args: { assignee: agent, form: WorkCase } }onCreate | onUpdate: <Entity>(exactly one;onDeleteis rejected — a deleted record cannot resume a wait) names the resuming event.via: <relation>— when the event entity is not the trigger entity itself: the event entity's to-one relation that walks back to the trigger entity (hereCaseMessage.case). Omitted when the event entity is the trigger entity; same-model relations only.when:— a single-comparison guard over the event record (field ==|!= literal), so e.g. an internal note does not resume the wait.
Correlation rides an identifier the trigger listener already writes back, so a wait requires the process to declare a trigger:. It is fail-soft: no parked instance, or an instance already past the wait, is a no-op — never an error.
Two optional attributes on a userTask's args give a flow a notion of time. Both route then like a decision branch:
steps:
- name: approve
kind: userTask
args:
assignee: approver
form: ApproveQuotation
timeout: { after: P3D, then: remind } # non-cancelling: the task STAYS claimable
expire: { until: validUntil, then: markExpired } # cancelling: the task is WITHDRAWN
next: donetimeout: { after: <ISO-8601 duration>, then: <step> }— a non-cancelling boundary timer (PT4H,P3D): after the duration thethenbranch runs (a reminder / escalation) while the task stays claimable.expire: { until: <field>, then: <step> }— a cancelling boundary timer driven by adate/timestampfield of the trigger entity: when the moment passes, the task is withdrawn and the flow continues atthen. The date is re-read at task entry, so editing it mid-flow moves the timer. Adatenames the last valid day (the timer fires at the start of the next day); anullarms a far-future date so the timer never effectively fires.
A running process should not outlive its document. abortOn: on the process cancels the whole in-flight instance — pending user tasks withdrawn, parked waits and armed boundary timers cancelled — the moment the trigger entity transitions into any of the listed status ids (the same transition event a transitions button or a workflow status set publishes):
processes:
- name: QuotationFollowUp
trigger: { onCreate: Quotation }
abortOn: { status: [3, 4, 6], then: markVoid } # accepted / rejected / expired
steps:
- { name: followUp, kind: userTask, args: { assignee: sales, form: FollowUp } }
- { name: done, kind: end }
# abort-only cleanup - never routed to from the main flow:
- { name: markVoid, kind: serviceTask, args: { setRelationField: Status, value: 6 } }status:— one or more status ids of the trigger entity'sfunction: EntityStatusrelation; reaching any of them aborts.then:(optional) — a single cleanupserviceTask(setField/setRelationField) that runs only on the abort path; it must not be reachable from the main flow. Omitted (orend) means terminate with no cleanup.
Like wait, abortOn requires the process to declare a trigger: (correlation rides the instance identifier stamped on the record) and is fail-soft — no running instance is a no-op. This is the structural answer to orphaned inbox tasks: cancel a review the moment its document is voided elsewhere.
trigger: { onCreate | onUpdate | onDelete: <Entity>, when: "<expr>" } starts the process on that entity's lifecycle event:
- the parser validates at most one event kind, and that the target is a declared entity;
- the entity gains a back-reference column so the process starts at most once;
- a generated listener loads the entity, applies the
whenguard (a singlefield ==|!= literal), starts the process, and writes the instance identifier back.
The business key defaults to the entity PK but is configurable:
trigger: { onCreate: Order, businessKey: orderNo, businessKeyStrategy: timestamp }businessKey names which field becomes the started instance's business key; businessKeyStrategy: timestamp mints a yyyyMMddHHmmss value into that field when it is blank (the field must be string / text).
A user task's assignee is a role / candidate-group name, or the literal assignee: personal to route the task to the record owner's inbox (requires the trigger entity to declare a personal: relation — see scoped surfaces).
forms:
- name: ApproveOrder
forEntity: Order
fields: [orderDate, total, customer.name] # fields or one-hop relation.field
actions: [approve, reject] # complete the taskGenerates one form per forms[] entry. Controls are typed by looking each field up against the bound entity (string to a text input, integer / decimal to a number input, boolean to a checkbox, date to a date picker, and so on). Actions become buttons, coloured by name (approve to positive; reject / decline / delete / cancel to negative; save / submit to emphasised).
Developer-defined buttons that open a custom page — the escape hatch when a workflow or a generated screen is not enough:
actions:
- name: OpenPortal
forEntity: Order
scope: entity # per-record; 'page' = a whole-view toolbar button
page: /custom/portal.htmlBeyond the CRUD screen every entity gets, the intent declares richer read surfaces: aggregating reports, dashboard tiles, time-based views, conversation-threaded documents, and printable documents.
reports:
- name: OrdersByCustomer
source: Order
dimensions: [customer] # a bare to-one shows the target's label, not the FK id
measures: ["count(*)", "sum(total)"]
- name: BigOrderItems
source: OrderItem
dimensions: [order.orderDate, quantity] # a relation.field path adds a join
filter: "quantity > 1" # becomes the WHEREGenerates one report per reports[] entry, rooted at source, with a fully materialised query:
- a plain field resolves to a source column;
- a
relation.fieldpath (order.orderDate) joins the related entity and adds a column on it; - a bare to-one relation (
customer) joins and shows the target's label field, not the raw FK id — usecustomer.idfor the id. A cross-model relation joins the owning model's table, so a report can group by an entity another module owns; - a time bucket
month(field)(a sortableYYYYMMinteger) oryear(field); - a measure
count(*)/sum(...)/avg/min/maxbecomes an aggregate, and the dimensions become the grouping.
filter becomes the WHERE, with field names rewritten to qualified physical columns. Report names, descriptions and column labels are emitted into the translation catalogue, so they localise alongside the rest of the UI.
chart: renders the report page as a chart instead of a table (the page keeps a table / chart toggle, so filters, export and print still work). A chart wants exactly one dimension and one or more measures — the dimension labels the axis and each measure becomes a series:
reports:
- name: MonthlyRevenue
source: Order
dimensions: ["month(orderDate)"]
measures: ["sum(net)", "sum(vat)", "sum(total)"]
chart: bar # bar | line | pie | doughnut | polarArea | radarkind: balance produces an opening / period / closing debit + credit report per dimension, with runtime From/To date pickers:
reports:
- name: TrialBalance
kind: balance
source: JournalEntryItem
date: journalEntry.entryDate # the date the runtime pickers apply to
debit: debit
credit: credit
dimensions: [account.code, account.name]
filter: "journalEntry.status == 2"A report may declare a widget block that turns it into a KPI tile on the generated home dashboard — a meaningful business number instead of a raw record count:
reports:
- name: OverdueInvoices
source: Invoice
dimensions: [number, customer.name, due, total]
filter: "due <= CURRENT_DATE AND balance > 0"
widget: { kind: count, label: Overdue Invoices, icon: alert-triangle }
- name: RevenueByMonth
source: Invoice
dimensions: ["month(date)"]
measures: ["sum(total)"]
widget:
value: "sum(total)" # names a declared measure => kind: value
at: { "month(date)": now } # pin dimensions: the `now` token, or a literal
label: Revenue (this month)
icon: banknote
- name: SalesByProduct
source: SalesInvoiceItem
dimensions: [Product]
measures: ["sum(quantity)", "sum(total)"]
widget: { kind: list, limit: 5, label: Sales by Product }kind: count(default) — the number of records the report yields.kind: value— one aggregate cell:valuenames a measure;atpins dimension columns. Thenowtoken resolves at view time, type-aware (currentYYYYMMon amonth(x)dimension, current year onyear(x), today on a date column).kind: list— the report's firstlimitrows (default 5) as a compact table tile.
Declaring any widget replaces the automatic per-entity count tiles; dashboard: false hides both tiles for a report.
The dashboard's escape hatch, when the report machinery cannot express the content:
widgets:
- { name: SystemHealth, kind: kpi, url: /custom/health.js, icon: activity } # a number from a REST endpoint
- { name: SalesFunnel, kind: page, url: /custom/funnel/index.html } # an embedded HTML pagekind: kpi (default) renders a number tile whose value comes from the developer's REST endpoint; kind: page embeds the page in a tile. The url must be a same-origin path.
view: (with a calendar: / slots: descriptor) renders an entity as a time-based page instead of a table:
- name: DayAllocation
view: calendar # a month / week calendar of records
calendar: { start: day, title: note } # start (date/timestamp) required; end/title/color optional
- name: VacationRequest
view: range # from-to bars (a leave calendar)
calendar: { start: fromDate, end: toDate }
- name: Appointment
view: slots # a slot-picker booking page
slots: { start: startTime }view: calendar is also expressible as the role alias function: Calendar.
A document master can render its line-items child as a chat thread (message bubbles + a composer) instead of an editable items table — support cases, tickets, comment threads. The header, status pill, workflow tasks and print stay as in a normal document:
- name: Case
function: Document
documentItemsLayout: chat
- name: CaseMessage
function: DocumentItem
audit: true # the bubble author + timestamp come from audit
fields:
- { name: body, type: text, messageBody: true } # the bubble text (exactly one)
- { name: internal, type: boolean, messageInternal: true } # an internal memo (hidden from partners)Every document (header-items) master — one with an *Item composition child — gets a printable document template on Generate, written in a small layout language and rendered to a document on demand from the entity's own data.
doc/Templates/<Entity>/Print/en/standard.print
The template is a tree of layout tags (page, header / footer, section / stack, row, field, text, a table bound to the items, total, line, and if), with values as placeholders:
{{document.<Property>}} the document's own field
{{document.<Relation>}} a to-one relation's display label
{{document.<Relation>.<Field>}} a field of a related record
{{<Property>}} a line-item field (inside a table bound to the items)
Normative. The print template is written create-if-absent and never regenerated over. A printed document is a formatted, audited artefact you adapt by hand, and a newly added model field must not silently appear on an already-designed document.
To add a language, add a file under a sibling language folder (.../Print/bg/standard.print); the print action asks which to use when several exist.
Beyond the model artefacts, the intent declares glue: the common integrations and background activities that would otherwise be hand-written code. The abstraction is one line:
glue = on
<event>do<action>, with action parameters bound by resolver paths.
Three axes:
- Event — an entity
onCreate/onUpdate/onDelete(with an optionalwhen:guard), a schedule (cron), or an inbound webhook. - Action — notify (email), call out (HTTP), ingest into an entity, recompute a counter, start a process, create a document.
- Binding — the resolver-path grammar (
customer.name,member.email): one-hop relation walks off the triggering entity, validated at parse time.
Unlike the model generators, each glue activity is generated as an annotated integration class against the platform's SDK, placed in the generated events folder. The annotated class is the artefact: the runtime synchronises and runs it, it is deterministic and regenerated with the app, and it is replaceable by a hand-written override.
Event-key gotcha. An event-binding key is
event:, neveron:— YAML 1.1 resolves a bareon(alsooff/yes/no) to a boolean, so anon:key is silently swallowed. An action key isdo:.
Email on an entity lifecycle event.
notifications:
- name: orderUpdated
event: { onUpdate: Order } # exactly one of onCreate / onUpdate / onDelete
to: ops@example.com # a literal, a direct field, or a one-hop relation.field
subject: "Order {id} for {customer.name}, total {total}"
body: "The order changed."to and every {placeholder} resolve a literal, a direct field, or a one-hop relation.field of a to-one relation. when: supports a single field ==|!= literal guard. Multi-hop paths (a.b.c) are rejected with a clear message.
Cron reminders / cleanups — query an entity and act per matching row. Exactly one of notify or generate per row.
schedules:
- name: staleOrders
cron: "0 0 9 * * ?"
entity: Order # the schedule's SOURCE must be local
where:
- { field: orderDate, op: lt, value: CURRENT_DATE } # eq / ne / gt / ge / lt / le / like
notify:
to: ops@example.com
subject: "Stale order {id} for {customer.name}"
body: "This order is stale."The generate variant creates a record through the target's own layer (so numbering, status init and calculated fields fire); the target may be cross-model via a uses: alias, and it may fan out children:
schedules:
- name: monthlyTimesheets
cron: "0 0 1 1 * ?"
entity: Employee
where:
- { field: status, op: eq, value: ACTIVE }
generate:
to: EmployeeTimesheet # cross-model target via a uses: alias
map: { Employee: id }
defaults: { Period: now }
children:
- to: DayAllocation
parent: EmployeeTimesheet
forEach: { days: workingDays } # one child per working day
dayField: dayTell another system on an event.
integrations:
- { name: pushNewOrder, event: { onCreate: Order }, method: POST, url: "@config:WAREHOUSE_URL" }The @config:KEY sugar resolves to a configuration lookup, so endpoints and secrets stay out of the source.
Another system tells us — a webhook that ingests a JSON payload into an entity.
inbound:
- { name: leadHook, path: /webhooks/lead, create: Lead }Generates an endpoint that deserialises the request body into the entity and saves it. The v1 action is create (ingest).
rollups:
- { name: memberLoanCount, entity: Loan, via: member, field: loanCount } # count
- { name: invoicePaid, entity: Allocation, via: SalesInvoice, field: paid, # sum + balance + status
op: sum, of: amount, capacity: total, balance: balance,
status: Status, statusWhenFull: 7, statusWhenPartial: 6 }A count roll-up keeps a counter on a parent current on the child's create / delete. With op: sum the roll-up keeps field equal to the sum of the children's of field, can maintain a balance (= capacity - sum), and can flip a status relation to statusWhenFull / statusWhenPartial. Sum roll-ups compose transitively across a multi-level composition (a leaf edit updates the mid total, then the top total); recomputation stops when values stop changing.
Roll-ups are recompute-on-event (self-healing), so they are eventually consistent, not transactionally exact under heavy concurrency.
Auto-allocate payments across open invoices — the accounts-receivable pattern. Pair it with a rollups sum entry that maintains paid / balance / status.
settlements:
- name: autoAllocate
junction: SalesInvoiceCustomerPayment
invoice: SalesInvoice
payment: CustomerPayment
amount: amount
total: total
paid: paid
pot: amount
order: date # allocate oldest first
match: [Customer, Currency]
status: Status
payableStatuses: [3, 4, 6]Generate one child row per day / week / month of a span on the parent:
expansions:
- name: installments
from: Loan
into: LoanInstallment
unit: month # day (default) | week | month
between: { start: startDate, end: endDate }
map: { dueDate: period }
spread: { total: principal, into: amount, round: 2 } # last row absorbs the remainder
count: periodsA span change replaces the generated child set — never mix hand-entered rows into an expanded child.
One-click "create a document from this document":
generates:
- name: invoice-from-timesheet
from: ProjectTimesheet
to: SalesInvoice
uses: sales # model alias when the target is cross-model
map: { Customer: Customer }
defaults: { InvoiceDate: now }
items: { from: ProjectTimesheetItem, to: SalesInvoiceItem, map: { Description: Description } }
sourceStatus: 3 # optional: flip the source's status after the target is createdAdds a button on the source view; the clone saves through the target's own layer, so numbering, status init and calculated fields fire. An optional sourceStatus flips the source record's status once the target exists.
A per-record button that flips an entity's function: EntityStatus relation on demand — void, cancel, close, reopen — guarded by allowed source statuses and an optional condition. A flip from any other status (or a failing guard) is rejected; a successful flip publishes a -transitioned event that postings and integrations can observe.
transitions:
- name: VoidInvoice
forEntity: Invoice # must declare a function: EntityStatus relation
from: [3, 4] # allowed source status ids
setStatus: 8 # the target status id (not one of `from`)
when: "Paid == 0" # optional guard: <Field> ==|!= <number>
label: Void
icon: banWhen a (usually cross-model) source document reaches a status, create one local document with computed multi-line content. Idempotent via the back-reference; a missing rule or account skips (an unposted worklist), never throws.
postings:
- name: salesInvoicePosting
event: { onTransition: SalesInvoice, model: sales-invoices, when: "Status == 3" }
creates: JournalEntry
backReference: SalesInvoice
map: { entryDate: date, reason: "Sales invoice {number}" }
rule: { entity: PostingRule, match: { documentType: "Sales Invoice" } }
items:
- { Account: rule(receivableAccount), debit: "Net + Vat" }
- { Account: rule(revenueAccount), credit: "Net" }
- { Account: rule(vatAccount), credit: "Vat", when: "Vat != 0" }A second posting can reverse the first (a reversal / credit) when the source is voided — pair it with the transitions void that flips the source into its void status. The reversal inherits creates / backReference / rule / map / items from the sibling it names, negates every item amount on the same side, links back to the original through a storno self-relation, and is fail-soft:
postings:
- name: docPosting
event: { onTransition: Doc, when: "Status == 2" } # posted
creates: Entry
backReference: Doc
items:
- { debit: "Amount" }
- { credit: "Amount" }
- name: docStorno
event: { onTransition: Doc, when: "Status == 3" } # voided
reverses: docPosting # inherit + negate the sibling's items
storno: Storno # the self-link field on the created Entry- Curated vocabulary, not a general DSL. Real logic is a
scriptstep or a hand-written hook — the escape hatch is non-negotiable. - Every generated glue artefact has an override switch, so a hand-written class can replace any single generated one.
- Secrets and endpoints via
@config:, never inline. - Bindings validated at parse — a dangling
customer.namezfails fast, not at runtime.
On top of the regular screen (which is unaffected), an entity's records can be scoped to the logged-in user or to an external business partner. Each scope adds a second generated controller that filters rows server-side — never merely hiding them in the UI.
entities:
- name: Employee
identity: email # the field matched against the login username
- name: Timesheet
relations:
- { name: Employee, kind: manyToOne, to: Employee, personal: true } # the record owner
- { name: Customer, kind: manyToOne, to: Customer, partner: true } # an external-partner owner
fields:
- { name: rate, type: decimal, sensitive: true } # hidden + ignored on the scoped surfacesidentity: <field>on the owner entity names the string field (conventionally a unique e-mail) matched against the login username. With no matching record, the scoped surface is simply empty — never an error.personal: trueon a record-owning to-one relation generates a personal controller: reads are filtered to the caller's mapped record, the owner FK is forced server-side on writes, and a foreign record is not found. At most onepersonal:relation per entity; the target must declareidentity; never put it on a composition parent — composition children inherit the owner's scope through their parent.partner: trueis the exact mirror for external parties (customers, suppliers) on a partner surface, gated by the corresponding partner roles. An entity may carry both apersonal:(staff) owner and apartner:(external) owner at once.personalReadOnly: true(withpersonal: true) makes the personal surface see-only: create / update / delete are refused and the scoped pages render without new / edit / delete. Use it for records an owner may see but never author — a balance, a payslip. Composition children inherit it through the parent.sensitive: trueon a field (never the PK, the identity field, or the owner FK) strips it from the scoped responses and ignores it on scoped writes — use it for billing rates and amounts the owner must not see. It is enforced server-side, not just hidden.
Normative. A scope's safety is by construction, not by a filter: the scoped controller only ever queries the caller's own rows, and a sensitive field is on an allow-list the scoped serialiser never includes. A field hidden only in the UI is cosmetic;
sensitiveis a server-side guarantee.
A user task can also be routed to the record owner's inbox with the literal assignee: personal, which resolves the owner through the personal: relation (see processes).
permissions:
- { role: Sales, can: [Customer:read, Order:create] }
- { role: Manager, can: [Order:approve] }Generates a deduplicated set of roles. It deliberately does not emit URL-shaped access rules — those belong to whichever downstream template materialises the UI, because only that template knows the paths it publishes. The can: [Resource:action] tokens are an authoring hint to those downstream generators about which actions each role may invoke.
seeds:
- name: order-statuses
entity: OrderStatus
rows: # inline rows: small nomenclatures
- { id: 1, name: DRAFT }
- { id: 2, name: ISSUED }
- name: cities
entity: City
rows:
- { id: 1, name: Sofia, Country: 34 } # a foreign key by the relation's authored name (case-sensitive)
- name: countries
entity: Country
file: data/countries.csv # large sets: a developer-owned CSV in a subfolder
- name: uoms-bg
entity: UoM
language: bg # a translation seed for a multilingual entity
rows:
- { id: 8, name: "Килограм" }Generates a seed-import descriptor + CSV per seed. Two shapes:
rows:— inline seed data, right for small nomenclatures whose values are part of the flow (statuses, methods).file: data/<name>.csv— an authored CSV under adata/subfolder, right for bulk nomenclatures and prepopulated demo data. A foreign key is set by the relation name (Country: 34).
Normative. Row keys must match a field or relation name exactly (case-sensitive). A key matching neither is an authoring error — a silently dropped column becomes a NOT NULL failure at import time.
A seed with language: <code> is a translation seed: it fills the per-language values of a multilingual: true entity, carrying the base row's id plus the translatable fields only.
Two independent things get translated: the data in multilingual entities, and the generated UI labels.
Mark an entity multilingual: true and its string-typed properties gain per-language values in a sibling translation table. Every read overlays the translated values for the caller's requested language; untranslated content falls back to the default language. Author the translations as seeds with a language: code.
languages: [en, bg] # top level: the languages THIS module provides translations for
entities:
- name: UoM
kind: setting
multilingual: true
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 100 }The set of languages the whole stack supports is a platform concern, never defined per module. The top-level languages: only declares which languages this module provides.
Generation also emits a per-project translation catalogue for every generated label: entity names (a humanised singular plus a plural form), field labels, form and report names, and report column headers. The default locale is generated for you; a translator adds a sibling locale folder with the same keys. The UI renders through these keys, falling back to the baked default label for any key a locale has not translated.
- The top-level
name:is the intent's identity. Single-file outputs are named after it; the physical table prefix is its upper-snake form. - Physical table names are intent-prefixed:
<INTENT>_<ENTITY>in upper-snake (ORDERS_ORDER), applied consistently across the data model, reports and seed imports. This dodges reserved words and cross-project collisions in a shared schema. - Property names are PascalCase in the generated model (
loanedOn→LoanedOn); physical columns stayUPPER_SNAKE. You author in lower camelCase. - A multilingual entity's translations land in a sibling
<TABLE>_LANGtable.
Because every table is intent-prefixed, many independent intent models share one schema without colliding — the foundation of a multi-model application.
One line per construct, linking into the chapters above.
| Construct | What it gives you |
|---|---|
entities |
tables + CRUD UI + a generated data layer & API |
| field / relation attributes | uniqueness, layout, read-only, dropdown filtering, cascades |
function |
an explicit presentation role (Document, Setting, ...) |
label |
a stored, read-only display name for lookups |
number |
a platform-numbered, gap-free document field |
checks |
cross-field / cross-line validations |
immutableWhen / immutable |
reject user writes in a status / append-only |
hierarchy / leafOnly |
tree entities, leaf-only references |
| calculated fields | server + UI-evaluated expressions, date helpers, call-outs |
relations / composition |
associations and master-detail compositions |
uses |
reuse entities owned by another intent model |
processes |
workflows: user tasks, decisions, waits, boundary timers |
abortOn |
cancel the running instance when the document reaches a terminal status |
function: Attachment / Snapshot |
a Files panel / immutable versioned printed copies |
forms |
task data-entry pages |
actions |
developer-defined buttons opening custom pages |
view |
calendar / range / slot-booking pages |
documentItemsLayout: chat |
render a document's items as a chat thread |
reports |
aggregations, charts, dashboard KPI tiles, balance reports |
widgets |
custom KPI / embedded-page dashboard tiles |
notifications |
email on create / update / delete |
schedules |
cron: notify or generate records per matching row |
integrations |
outbound HTTP on a data change |
inbound |
a webhook that creates records |
rollups |
counts, sums, balance + status maintenance |
settlements |
auto-allocation of payments across open invoices |
expansions |
generated child rows per day / week / month |
generates |
one-click document-from-document cloning |
transitions |
guarded on-demand status flips (void / cancel / reopen) |
postings |
declarative source-document to balanced-document posting |
personal / partner |
per-user and per-partner row-scoped surfaces |
seeds |
initial data, CSV-backed sets, translations |
multilingual / languages |
translation tables + read-time translation overlay |
permissions |
roles |
The following are parsed (or reserved) but not yet materialised by a generator; a conforming tool rejects or ignores them with a clear message rather than failing obscurely:
- Reserved
functionvalues for upcoming presentations (Board,Gantt,Timeline). manyToMany— parsed but never materialised; the supported shape is the explicit intermediate entity.- Cross-model schedule source — a schedule's
entitymust be local (the generate target may be cross-model). - Event-driven document generation (produce a document on an event), a declarative state machine, and shadow audit-history entities (audit columns via
audit: trueship today). - Arbitrary resolver-path task assignment beyond
assignee: personal.