Skip to content

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 ​

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

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

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

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

Lifecycle scope ​

An aggregation over an entity that carries a lifecycle (function: EntityStatus) is wrong by default: drafts nobody has issued, cancelled documents and voided ones all land in the sum. scope states which lifecycle rows the report counts, in terms of the stages the nomenclature declares — not a predicate over positional ids:

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

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

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

Normative

scope is all or a single stage name, and is only meaningful over a source declaring a function: EntityStatus relation. A stage scope restricts the query to the statuses that stage classifies; all adds no restriction.

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

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

Parameters ​

A report is usually read for a period, a threshold or a name the reader chooses. filter cannot express that — it is fixed when the report is generated — so parameters declares the report's own inputs:

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

Each entry becomes an input rendered above the report, whose value is bound into the report's WHERE:

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

A parameter is bound on every read, so initial is what makes the untouched report the unfiltered one. Two comparisons have a neutral "any value" default and therefore need no initial — a date ge/le bound (widened to all time) and a like search (the empty pattern, which matches every value). An eq selector and a numeric bound have none: the declaration must say what the report opens with.

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

The target is a field: a relation itself is not one (name a field of it), and boolean and long-text fields are not parameterizable. A balance report declares its own fromDate/toDate window and may add further parameters, but not redeclare those two; a ledger report additionally declares its account.

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:

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

yaml
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"

statement reports ​

kind: statement reads the same signed ledger as a balance report, but its rows are the fixed lines of a statutory form — a balance sheet, an income statement — where every line is a formula over the chart of accounts and some lines are subtotals of others. It yields three columns, code, label and amount, over the same runtime From/To window:

yaml
reports:
  - name: BalanceSheet
    kind: statement
    source: JournalEntryItem
    date: journalEntry.entryDate
    debit: debit
    credit: credit
    account: account.code              # the account CODE the lines select on (a string field)
    filter: "journalEntry.status == 2"
    lines:
      - { code: A.I,  label: Fixed assets, accounts: "20*,21*", measure: closingNetDebit }
      - { code: A.II, label: Receivables,  accounts: "41*",     measure: closingNetDebit }
      - { code: A,    label: Total assets, sum: [A.I, A.II] }
      - { code: B.I,  label: Payables,     accounts: "40-49",   measure: closingNetCredit }
      - { code: B,    label: Net assets,   sum: [A], less: [B.I] }

A line either reads the ledger (accounts + measure) or is arithmetic over other lines by code (sum adds, less subtracts) — never both, since the same amount would count twice. Codes are unique, references resolve, and the reference graph is acyclic. The accounts selector is comma-separated over the account code: 20* a prefix, 4110 one account, 60-69 an inclusive range of equally long code prefixes (it selects 601 and 6999). The measure is opening / period / closing × Debit / Credit / NetDebit / NetCredit; a Net measure nets each account's two sides before the line sums it, which is what files a both-type settlement account under assets when it is in debit and under liabilities when it is in credit. A statement declares no dimensions and no measures: its rows are its lines, in the authored order.

The cash-flow statement — cashAccounts ​

The direct-method cash-flow statement classifies cash movements by their counter-side: receipts from customers are the credits to the receivable accounts in entries that debit cash. cashAccounts (the selector grammar above) names the cash accounts and puts the statement on the cash basis, with the same lines / sum / less:

yaml
reports:
  - name: CashFlow
    kind: statement
    source: JournalEntryItem
    date: journalEntry.entryDate
    debit: debit
    credit: credit
    account: account.code
    cashAccounts: "50*"
    lines:
      - { code: A.1, label: Receipts from customers, accounts: "41*", measure: periodCredit }
      - { code: A.2, label: Payments to suppliers,   accounts: "40*", measure: periodDebit }
      - { code: A,   label: Operating activities,    sum: [A.1], less: [A.2] }
      - { code: B.1, label: Purchases of equipment,  accounts: "20*", measure: periodDebit }
      - { code: B,   label: Investing activities,    less: [B.1] }
      - { code: C.1, label: Borrowings,              accounts: "15*", measure: periodCredit }
      - { code: C,   label: Financing activities,    sum: [C.1] }
      - { code: N,   label: Net cash flow,           sum: [A, B, C] }

On the cash basis a leaf line's accounts are counter-accounts: periodCredit is the cash received from them, periodDebit the cash paid to them. Each posting of an entry touching a cash account MUST be attributed its proportional share of that entry's cash movement over the whole counter side — the cash debited, spread over the entry's credit side by amount, and the cash credited, spread over its debit side. Over a balanced entry the non-cash shares then add up to exactly the entry's net cash movement, so operating + investing + financing equals the change in the cash accounts' balance for the window; a generator MUST preserve that reconciliation. A transfer between two cash accounts is no cash flow, and an entry touching no cash account contributes nothing. Only the period* measures are valid on the cash basis (a cash flow is a movement within the window), and date MUST reach the entry through a to-one relation (<relation>.<field>), since the cash an entry moved is a fact about the entry. Anything else is an authoring error.

ledger reports ​

kind: ledger lists the posted lines of ONE account in date order with a running balance - the account analysis (account card). It reads the same ledger as a balance report and adds the account to list and the per-line columns:

yaml
reports:
  - name: AccountAnalysis
    kind: ledger
    source: JournalEntryItem
    date: journalEntry.entryDate
    debit: debit
    credit: credit
    account: account                    # the to-one relation to the account, chosen at runtime
    columns: [journalEntry.number, journalEntry.reason, Customer, note]
    correspondence: account.code        # optional: each line's counter-side accounts
    filter: "journalEntry.status == 2"

The rows are an opening row (the balance of everything strictly before the From date), the lines of the window ordered by date, entry and line - each with its debit, its credit and the running balance (the opening balance plus the running sum of debit minus credit) - and a closing row carrying the window's debit and credit turnover and the closing balance. The windows are the balance report's, so for any account opening + debit - credit = closing, and a conforming implementation MUST produce the same opening and closing balance a kind: balance report produces for that account and window.

The account is a runtime parameter (the account's key) declared by the report itself, beside the From/To window; with no account chosen the report is empty. When the account entity declares a hierarchy, a group account lists the lines of every account below it at any depth, so a group's balance equals the sum of its leaves'. The running balance is a property of the whole window: an implementation that pages the lines MUST give every page the running balance computed over all earlier lines, not restart it per page.

columns resolve like dimensions (a field of the source, a one-hop relation.field, or a to-one relation shown by its label). correspondence names, per line, the distinct accounts on the opposite side of the same entry.

A ledger report MUST name account as a to-one relation of the source, its source MUST have a primary key, and it MUST NOT declare dimensions, measures, statement lines, a chart or a dashboard widget.

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:

yaml
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 }

  - name: RequestsByStatus
    source: VacationRequest
    dimensions: [Status]
    measures: ["count(*)", "sum(days)"]   # count(*) is what the count tile sums
    widget: { kind: count, label: Vacation Requests }
  • kind: count (default) — the number of records the report yields. An aggregating report (one declaring measures) yields one row per group, so its record count is its count(*) measure summed over those rows — a report dimensioned by status shows the fourteen requests, not the four statuses they fall into. Such a report must declare that measure; an aggregating report with a count tile and no count(*) is rejected at generation, because the only number left to show is the number of groups. A report that does not aggregate needs nothing: one of its rows is one record.
  • 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:

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

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

view — calendar, range, slots ​

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

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

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

A view adds a page ​

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

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

Both browse pages offer a switch to the other, and choosing a day, an event or a free slot opens the entity's own editor. So a document master may be browsed on a calendar — or booked from a slot picker — and still be edited as a document, with its line items, printing and workflow tasks intact: declaring a view never costs an entity its editing surface. A picker is how a record is created; the list or document page is how it is worked with afterwards, and an author needs both.

A document's line items on a calendar ​

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

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

The document keeps its header, totals and printing; only the items pane changes. Clicking an event edits that line, clicking an empty day adds one with that date filled in. It cannot be combined with documentItemsLayout: chat, which claims the same pane.

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:

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

text
{{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)

A placeholder may list several paths separated by |, and the first one resolving to a non-blank value is rendered, left to right:

text
<field label="Customer">{{document.Customer.NameLocal|document.Customer.Name}}</field>

This is the fallback an optional twin field needs. A partner record carries a locally registered name beside its canonical one - filled for some partners and not for others - and a template that had to name a single field printed a hole in the document for every record with the other one filled.

Normative

Every operand resolves by the same rules as a single path, including row-scope resolution inside a table. A value counts as blank when it is null, missing, or whitespace-only. The last operand is rendered whatever it holds, so a single path is the one-operand case of the rule and a placeholder whose operands are all blank renders empty - exactly as an unresolved single path does; an existing template is byte-identical until it adopts the syntax. Any number of operands is allowed, and no other syntax is introduced: an operand is a path, never a literal or an expression.

A table (or a row-expanding for) can filter the collection it renders — a declarative value match, never an expression: filter="<path>" keeps the rows whose path (resolved per row) is truthy, and adding match="A | B" keeps only the rows whose value equals one of the |-separated literals. The same match on an if compares its resolved source against the listed values instead of testing truthiness. One bound collection can this way render into several purpose-grouped tables — the two-column payslip (earnings left, deductions right), a journal entry's debit and credit sides, a VAT summary per rate:

text
<row gap="16">
    <stack>
        <text style="subtitle">Earnings</text>
        <table source="items" filter="Kind" match="BASE | ENTRY">
            <column width="3*" label="Earning">{{Name}}</column>
            <column width="*" align="right" label="Amount">{{Amount}}</column>
        </table>
    </stack>
    <stack>
        <text style="subtitle">Deductions</text>
        <table source="items" filter="Kind" match="CONTRIBUTION | TAX">
            <column width="3*" label="Deduction">{{Name}}</column>
            <column width="*" align="right" label="Amount">{{Amount}}</column>
        </table>
    </stack>
</row>

A generator that predates the attributes ignores them and renders all rows — a filtered template never fails on an older platform.

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. The chosen language selects the template and the language the document's data is read in, so a document rendered in one language is in that language throughout — see the language of a render. The same holds wherever a render's language is declared rather than chosen: the language a versioned copy is minted in, and the language: / languageFrom: of a notify block that attaches one.

Naming the rendered file - fileName ​

A document is rendered to a file in two places - the versioned copy a function: Snapshot child mints, and the copy a notify block attaches. Both accept a fileName pattern: literal text and {token} interpolations over the record being rendered.

yaml
entities:
  - name: SalesInvoiceCopy
    function: Snapshot
    fileName: "{number}_{date:yyyyMMdd}_{company.shortName|company.name}"
    relations:
      - { name: salesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true }
TokenRenders
{field}a field of the rendered record
{relation.field}a field of a one-hop to-one relation of it
{field:pattern}a date or timestamp field in the given date-format pattern
{A|B}the first non-blank of the listed operands, left to right
{Version}the copy's version (a snapshot only)

Paths use the authored field and relation names - the same vocabulary a notify subject interpolates. A snapshot's pattern resolves against its document master (the copy row itself holds only the stored file's coordinates); a notify block's against the record that block renders.

The name is the only thing a file carries once it leaves the application: an archive is browsed and searched by it, an attachment arrives in a mailbox with no application around it, and a hand-off to an accountant or an auditor is a folder plus a naming convention agreed in advance. A folder of Order 42 v1.pdf, Order 43 v1.pdf is unusable at the exact moment it matters.

Normative

An interpolated value is sanitized before it enters the name: trimmed, internal whitespace collapsed to a single separator, and characters the storage or transport cannot carry removed. Non-ASCII characters are preserved - whether a name is transliterated is an application's data convention, not the format's. Literal text between tokens is emitted verbatim: the author owns the separators.

A token whose value is blank renders empty. A token naming a field or relation the entity does not have is rejected at authoring time, not rendered empty - a silently dropped token produces exactly the indistinguishable names this construct removes. Also rejected: a pattern that interpolates nothing, unbalanced or nested braces, a date format on a field that is not a date or timestamp, a format the formatter does not accept, and a multi-hop path.

{Version} is rejected where no version exists; a snapshot pattern that does not place it has the version appended, because two versions of one copy must never share a name. On a notify block fileName requires an attach, and where the attached document is rendered once for a whole fan-out only fields of that record are readable.

Absent a pattern, an implementation supplies a default - and the same default for both renders; a document's own number is used where it declares one.

See also ​

Released under the Apache License 2.0.