Skip to content

Latest commit

 

History

History
1178 lines (889 loc) · 62.4 KB

File metadata and controls

1178 lines (889 loc) · 62.4 KB

The Intent File Specification

Version 1.0

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 RFC2119 RFC8174 when, and only when, they appear in all capitals, as shown here. Rules highlighted as Normative bind every conforming file and generator; all other text is informative.

This document is licensed under The Apache License, Version 2.0.

A rendered, navigable version of this specification is published at intentfile.org. This file is the normative source.

Table of contents

Overview

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

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

The three altitudes

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

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

Editor-first, not a runtime artefact

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

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

The workflow

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

Project layout

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

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

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

The file is YAML, not JSON

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

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

A minimal complete file

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

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

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

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

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

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

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

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

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

Authoring rules

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

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

Entities & fields

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

entities:
  - name: Customer          # PascalCase entity name
    description: Buyer account
    icon: user              # an icon name for the generated navigation
    group: master-data      # navigation group in a shared shell
    audit: true             # adds CreatedAt / CreatedBy / UpdatedAt / UpdatedBy
    fields: [ ... ]
    relations: [ ... ]

fields

fields:
  - { name: id,     type: integer, primaryKey: true, generated: true }
  - { name: name,   type: string,  required: true, length: 200 }
  - { name: total,  type: decimal }
  - { name: active, type: boolean, defaultValue: "true" }
Key Meaning
name field name, camelCase (PascalCased in the generated model)
type logical type (see below)
primaryKey marks the PK; must be an integer type
generated auto-increment (integer PKs only)
required NOT NULL; the generated required-value validation keys on this
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)

Logical types

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

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

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

Entity-level attributes

Attribute Effect
audit: true adds the four standard audit columns, populated automatically
multilingual: true makes string properties translatable (see multilingual data)
label: a stored, read-only display name (see label)
function: an explicit presentation role (see function)
order: sequences form controls and list columns
duplicable: true adds a Duplicate button that clones a document through the normal create path
imports: injects import lines into the generated data-access layer (pairs with calculated actions)
aggregate: true on a document master's numeric field, keeps it equal to the sum of the items' same-named field
kind: setting marks the entity as nomenclature / configuration (see Setting entities)

Control order

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

- name: OrderItem
  order: [Id, Order, Product, Name, Quantity, UoM, Price, Total]
  fields: [ ... ]
  relations: [ ... ]

Names match field / relation names (case-insensitive). A partial order is fine - any property not listed keeps its default position and is appended after the listed ones.

Calculated fields

A field value can be derived instead of entered:

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

The implementation lives in the project's custom (escape-hatch) folder, never in the generated folder. For document numbers, use the first-class number attribute instead of a calculated action.

Document numbering

number: turns a string field into a platform-numbered document field. The 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 | issue
  • series (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 — year and/or sibling field names; partitions the counter and supplies the format's scope tokens.
  • stampOn — create stamps the real number on insert; issue puts a placeholder on the field at create and stamps the real number when the process reaches the wired step. Stamping is idempotent - re-issuing after an amend keeps the same number.

The field is read-only in the UI. Counters are visible and adjustable 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.

Attachments and snapshots

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

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

- name: CaseAttachment
  function: Attachment
  relations:
    - { name: Case, kind: manyToOne, to: Case, composition: true, required: true }

function: Snapshot is the immutable, versioned printed copy of a document master — the frozen artefact regulations and audits want. Each generation renders the master through its print template and stores the result as the next version; the copies appear in the same panel, download-only (never uploaded or deleted by the user):

- name: SalesInvoiceCopy
  function: Snapshot
  relations:
    - { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true }

A snapshot requires a document master (only a document has a print template to render from). Minting a copy is wired into the workflow: bind the generated snapshot handler (named <Master>SnapshotGenerator) as the delegate: of a service task at the step that finalises the document — typically right after issue. Re-issuing after an amendment keeps the document's number and mints the next version, which pairs naturally with immutableWhen and an issue-stamped document number.

Setting entities

- name: Country
  kind: setting

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

checks — declarative validations

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

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

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

immutableWhen / immutable — user-write immutability

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

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

hierarchy / leafOnly — tree entities

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

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

Relations & multi-model

relations

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

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

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

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

Relation attributes

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

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

# Static option filter - e.g. only stock-tracked products:
- { name: Product, kind: manyToOne, to: Product, where: { Type: 1 } }
  • function: EntityStatus marks the relation as the entity's managed status badge; init: seeds its default at the database level (a race-free start). This relation is what immutableWhen, transitions and postings key on.
  • dependsOn links one dropdown to another: filterBy narrows the options to those matching the parent selection; valueFrom copies a value from the referenced record (a snapshot).
  • where filters the dropdown to options matching a static condition.

Many-to-many

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

- name: SalesInvoiceCustomerPayment
  fields:
    - { name: id,     type: integer, primaryKey: true, generated: true }
    - { name: amount, type: decimal, precision: 18, scale: 2, required: true }  # partial allocation
  relations:
    - { name: SalesInvoice,    kind: manyToOne, to: SalesInvoice,    composition: true, required: true }
    - { name: CustomerPayment, kind: manyToOne, to: CustomerPayment, model: customer-payments, required: true }

The intermediate entity is a real entity you can read, seed and report on — which is usually what a real n:m relationship needs anyway.

Multi-model applications

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

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

Reuse, don't redefine — uses

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

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

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

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

One shared shell — contributions, not app-hopping

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

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

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

The entity references the group id only.

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

Generate leaf-first, then publish everything

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

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

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

Processes & forms

processes

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

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

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

Step routing — the linear chain and next:

Steps flow linearly in declaration order. Any step may override its successor with args: { next: <step | end> } — this is how two decision branches converge instead of the first falling through into the second (an activate branch routes to done so it never falls into the cancel branch declared after it). next must name a declared step or the literal end.

Service tasks

Service-task shapes: setField / setRelationField (generated handlers that write a field or flip a status relation on a branch), 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.

Decision steps

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

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

wait — park the process on a data event

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

steps:
  - { name: requestInfo, kind: serviceTask, args: { setRelationField: Status, value: 4, next: awaitReply } }
  - { name: awaitReply,  kind: wait, args: { onCreate: CaseMessage, via: case, when: "internal == false", next: work } }
  - { name: work,        kind: userTask, args: { assignee: agent, form: WorkCase } }
  • onCreate | onUpdate: <Entity> (exactly one; onDelete is rejected — a deleted record cannot resume a wait) names the resuming event.
  • via: <relation> — when the event entity is not the trigger entity itself: the event entity's to-one relation that walks back to the trigger entity (here CaseMessage.case). Omitted when the event entity is the trigger entity; same-model relations only.
  • when: — a single-comparison guard over the event record (field ==|!= literal), so e.g. an internal note does not resume the wait.

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

timeout / expire — boundary timers on a user task

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

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

abortOn — cancel the instance on a terminal status

A running process should not outlive its document. abortOn: on the process cancels the whole in-flight instance — pending user tasks withdrawn, parked waits and armed boundary timers cancelled — the moment the trigger entity transitions into any of the listed status ids (the same transition event a transitions button or a workflow status set publishes):

processes:
  - name: QuotationFollowUp
    trigger: { onCreate: Quotation }
    abortOn: { status: [3, 4, 6], then: markVoid }   # accepted / rejected / expired
    steps:
      - { name: followUp, kind: userTask, args: { assignee: sales, form: FollowUp } }
      - { name: done,     kind: end }
      # abort-only cleanup - never routed to from the main flow:
      - { name: markVoid, kind: serviceTask, args: { setRelationField: Status, value: 6 } }
  • status: — one or more status ids of the trigger entity's function: EntityStatus relation; reaching any of them aborts.
  • then: (optional) — a single cleanup serviceTask (setField / setRelationField) that runs only on the abort path; it must not be reachable from the main flow. Omitted (or end) means terminate with no cleanup.

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

trigger

trigger: { onCreate | onUpdate | onDelete: <Entity>, when: "<expr>" } starts the process on that entity's lifecycle event:

  • the parser validates at most one event kind, and that the target is a declared entity;
  • the entity gains a back-reference column so the process starts at most once;
  • a generated listener loads the entity, applies the when guard (a single field ==|!= literal), starts the process, and writes the instance identifier back.

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

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

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

Task assignment

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

forms

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

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

actions — custom buttons

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

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

Presentation

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

reports

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

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

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

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

Chart

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

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

balance reports

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

reports:
  - name: TrialBalance
    kind: balance
    source: JournalEntryItem
    date: journalEntry.entryDate        # the date the runtime pickers apply to
    debit: debit
    credit: credit
    dimensions: [account.code, account.name]
    filter: "journalEntry.status == 2"

Dashboard KPI widgets

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

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

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

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

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

widgets — custom dashboard tiles

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

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

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

view — calendar, range, slots

view: (with a calendar: / slots: descriptor) 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.

documentItemsLayout: chat — conversation threads

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

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

Printable documents

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

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

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

{{document.<Property>}}            the document's own field
{{document.<Relation>}}            a to-one relation's display label
{{document.<Relation>.<Field>}}    a field of a related record
{{<Property>}}                     a line-item field (inside a table bound to the items)

Normative. The print template is written create-if-absent and never regenerated over. A printed document is a formatted, audited artefact you adapt by hand, and a newly added model field must not silently appear on an already-designed document.

To add a language, add a file under a sibling language folder (.../Print/bg/standard.print); the print action asks which to use when several exist.

Declarative glue

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

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

Three axes:

  • Event — an entity onCreate / onUpdate / onDelete (with an optional when: guard), a schedule (cron), or an inbound webhook.
  • Action — notify (email), call out (HTTP), ingest into an entity, recompute a counter, start a process, create a document.
  • Binding — the resolver-path grammar (customer.name, member.email): one-hop relation walks off the triggering entity, validated at parse time.

Glue is generated integration code

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

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

notifications

Email on an entity lifecycle event.

notifications:
  - name: orderUpdated
    event: { onUpdate: Order }     # exactly one of onCreate / onUpdate / onDelete
    to: ops@example.com            # a literal, a direct field, or a one-hop relation.field
    subject: "Order {id} for {customer.name}, total {total}"
    body: "The order changed."

to and every {placeholder} resolve a literal, a direct field, or a one-hop relation.field of a to-one relation. when: supports a single field ==|!= literal guard. Multi-hop paths (a.b.c) are rejected with a clear message.

schedules

Cron reminders / cleanups — query an entity and act per matching row. Exactly one of notify or generate per row.

schedules:
  - name: staleOrders
    cron: "0 0 9 * * ?"
    entity: Order                                           # 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: day

integrations — outbound HTTP

Tell another system on an event.

integrations:
  - { name: pushNewOrder, event: { onCreate: Order }, method: POST, url: "@config:WAREHOUSE_URL" }

The @config:KEY sugar resolves to a configuration lookup, so endpoints and secrets stay out of the source.

inbound — webhooks

Another system tells us — a webhook that ingests a JSON payload into an entity.

inbound:
  - { name: leadHook, path: /webhooks/lead, create: Lead }

Generates an endpoint that deserialises the request body into the entity and saves it. The v1 action is create (ingest).

rollups — denormalised parent totals

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

A count roll-up keeps a counter on a parent current on the child's create / delete. With op: sum the roll-up keeps field equal to the sum of the children's of field, can maintain a balance (= capacity - sum), and can flip a status relation to statusWhenFull / statusWhenPartial. Sum roll-ups compose transitively across a multi-level composition (a leaf edit updates the mid total, then the top total); recomputation stops when values stop changing.

Roll-ups are recompute-on-event (self-healing), so they are eventually consistent, not transactionally exact under heavy concurrency.

settlements — payment allocation

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

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

expansions — child rows from a date span

Generate one child row per day / week / month of a span on the parent:

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

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

generates — create-from

One-click "create a document from this document":

generates:
  - name: invoice-from-timesheet
    from: ProjectTimesheet
    to: SalesInvoice
    uses: sales                       # model alias when the target is cross-model
    map: { Customer: Customer }
    defaults: { InvoiceDate: now }
    items: { from: ProjectTimesheetItem, to: SalesInvoiceItem, map: { Description: Description } }
    sourceStatus: 3                   # optional: flip the source's status after the target is created

Adds a button on the source view; the clone saves through the target's own layer, so numbering, status init and calculated fields fire. An optional sourceStatus flips the source record's status once the target exists.

transitions — guarded status flips

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

transitions:
  - name: VoidInvoice
    forEntity: Invoice            # must declare a function: EntityStatus relation
    from: [3, 4]                  # allowed source status ids
    setStatus: 8                  # the target status id (not one of `from`)
    when: "Paid == 0"             # optional guard: <Field> ==|!= <number>
    label: Void
    icon: ban

postings — source document to ledger

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

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

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

Guardrails

  • Curated vocabulary, not a general DSL. Real logic is a script step or a hand-written hook — the escape hatch is non-negotiable.
  • Every generated glue artefact has an override switch, so a hand-written class can replace any single generated one.
  • Secrets and endpoints via @config:, never inline.
  • Bindings validated at parse — a dangling customer.namez fails fast, not at runtime.

Scoped surfaces & roles

Personal and partner surfaces

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

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

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

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

permissions

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

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

Data, seeds & naming

seeds

seeds:
  - name: order-statuses
    entity: OrderStatus
    rows:                                   # inline rows: small nomenclatures
      - { id: 1, name: DRAFT }
      - { 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 a data/ subfolder, right for bulk nomenclatures and prepopulated demo data. A foreign key is set by the relation name (Country: 34).

Normative. Row keys must match a field or relation name exactly (case-sensitive). A key matching neither is an authoring error — a silently dropped column becomes a NOT NULL failure at import time.

A seed with language: <code> is a translation seed: it fills the per-language values of a multilingual: true entity, carrying the base row's id plus the translatable fields only.

Multilingual data

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

Data

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

languages: [en, bg]        # top level: the languages THIS module provides translations for
entities:
  - name: UoM
    kind: setting
    multilingual: true
    fields:
      - { name: id,   type: integer, primaryKey: true, generated: true }
      - { name: name, type: string,  required: true, length: 100 }

The set of languages the whole stack supports is a platform concern, never defined per module. The top-level languages: only declares which languages this module provides.

UI labels

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

Naming and tables

  • The top-level name: is the intent's identity. Single-file outputs are named after it; the physical table prefix is its upper-snake form.
  • Physical table names are intent-prefixed: <INTENT>_<ENTITY> in upper-snake (ORDERS_ORDER), applied consistently across the data model, reports and seed imports. This dodges reserved words and cross-project collisions in a shared schema.
  • Property names are PascalCase in the generated model (loanedOn → LoanedOn); physical columns stay UPPER_SNAKE. You author in lower camelCase.
  • A multilingual entity's translations land in a sibling <TABLE>_LANG table.

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

Appendix A: DSL index

One line per construct, linking into the chapters above.

Construct What it gives you
entities tables + CRUD UI + a generated data layer & API
field / relation attributes uniqueness, layout, read-only, dropdown filtering, cascades
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

Planned — recognised but not yet implemented

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

  • Reserved function values for upcoming presentations (Board, Gantt, Timeline).
  • manyToMany — parsed but never materialised; the supported shape is the explicit intermediate entity.
  • Cross-model schedule source — a schedule's entity must 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: true ship today).
  • Arbitrary resolver-path task assignment beyond assignee: personal.