Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions versions/1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,37 @@ Generates one report per `reports[]` entry, rooted at `source`, with a fully mat

`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](#stage--what-a-status-means-to-the-lifecycle) 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.

#### 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:
Expand Down Expand Up @@ -1066,8 +1097,10 @@ seeds:
- name: order-statuses
entity: OrderStatus
rows: # inline rows: small nomenclatures
- { id: 1, name: DRAFT }
- { id: 2, name: ISSUED }
- { id: 1, name: DRAFT, stage: draft } # what the status MEANS to the lifecycle
- { id: 2, name: ISSUED, stage: live }
- { id: 8, name: CANCELLED, stage: cancelled }
- { id: 9, name: VOIDED, stage: void }
- name: cities
entity: City
rows:
Expand All @@ -1092,6 +1125,38 @@ Generates a seed-import descriptor + CSV per seed. Two shapes:

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.

#### stage — what a status means to the lifecycle

A seed row of a **status nomenclature** (the target of a `function: EntityStatus` relation) may classify itself with `stage`, a closed vocabulary:

| Stage | Meaning |
| --- | --- |
| `draft` | Nobody has issued it yet — visible to its author, not yet economically real. |
| `live` | It counts: issued, sent, paid — anything in normal circulation. |
| `cancelled` | Withdrawn before it ever became live. |
| `void` | Deliberately retired while keeping its number — out of circulation by design. |

The classification exists because a status **id is data, but its meaning is not**: without it, "the rows that count" can only be expressed as a predicate over positional ids, repeated in every report and guard that needs it. With it, the meaning is declared once, where the nomenclature is defined, and consumers resolve it — chiefly a [report's `scope`](#lifecycle-scope).

> **Normative.**
> `stage` is **metadata, not data**: it MUST NOT be emitted as a column of the seeded table. A row carrying `stage` MUST also carry the entity's primary key (the stage classifies that id). A value outside the vocabulary is an authoring error. An entity that declares its own `stage` property cannot be classified this way — the collision MUST be reported rather than resolved by guessing.

#### Status references — name, not number

Everywhere the file names a status — a [transition's](#transitions--guarded-status-flips) `from` and `setStatus`, a relation's `init`, a status-setting step's `value`, [`abortOn`](#aborton--cancel-the-instance-on-a-terminal-status)'s `status`, a [check's](#checks--declarative-validations) `status` / `setStatus`, [`immutableWhen`](#immutablewhen--immutable--user-write-immutability), a [posting's](#postings--source-document-to-ledger) event guard, a [report's](#reports) `filter` — the seeded **name** may be written instead of the id:

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

A status id is **positional**. Inserting a status into the middle of a nomenclature shifts every later id, and every guard authored against the old numbering keeps producing well-formed output that now means a different status — a defect no downstream check can see, because the emitted constant is valid. A name cannot be silently retargeted.

> **Normative.**
> A status name is resolved against the seed rows of the nomenclature it belongs to, and the resolution happens before any other validation, so every later rule sees the resolved id. An unresolvable name is an authoring error naming the known statuses — never a silently-kept token. Numeric ids remain valid everywhere. A name has no ordering, so an ordering comparison against one (`Status >= ISSUED`) is an authoring error; express "the rows that count" as a [`scope`](#lifecycle-scope). A nomenclature owned by another model is seeded there, so a name cannot be resolved against it — such a reference is an authoring error directing the author to the numeric id.

### Multilingual data

Two independent things get translated: the **data** in multilingual entities, and the generated **UI labels**.
Expand Down Expand Up @@ -1151,6 +1216,7 @@ One line per construct, linking into the chapters above.
| [`view`](#view--calendar-range-slots) | calendar / range / slot-booking pages |
| [`documentItemsLayout: chat`](#documentitemslayout-chat--conversation-threads) | render a document's items as a chat thread |
| [`reports`](#reports) | aggregations, charts, dashboard KPI tiles, balance reports |
| [`scope`](#lifecycle-scope) | which lifecycle rows an aggregating report counts |
| [`widgets`](#widgets--custom-dashboard-tiles) | custom KPI / embedded-page dashboard tiles |
| [`notifications`](#notifications) | email on create / update / delete |
| [`schedules`](#schedules) | cron: notify or generate records per matching row |
Expand All @@ -1164,6 +1230,8 @@ One line per construct, linking into the chapters above.
| [`postings`](#postings--source-document-to-ledger) | declarative source-document to balanced-document posting |
| [`personal` / `partner`](#personal-and-partner-surfaces) | per-user and per-partner row-scoped surfaces |
| [`seeds`](#seeds) | initial data, CSV-backed sets, translations |
| [`stage`](#stage--what-a-status-means-to-the-lifecycle) | classify a status: draft / live / cancelled / void |
| [status names](#status-references--name-not-number) | reference a status by its seeded name, not its positional id |
| [`multilingual` / `languages`](#multilingual-data) | translation tables + read-time translation overlay |
| [`permissions`](#permissions) | roles |

Expand All @@ -1174,5 +1242,6 @@ The following are parsed (or reserved) but not yet materialised by a generator;
- Reserved `function` values for upcoming presentations (`Board`, `Gantt`, `Timeline`).
- **`manyToMany`** — parsed but never materialised; the supported shape is the [explicit intermediate entity](#many-to-many).
- **Cross-model schedule source** — a schedule's `entity` must be local (the generate target may be cross-model).
- **Cross-model status names and stage scopes** — a nomenclature owned by another model is seeded there, so its stages and names cannot be resolved from the referencing file; such references are rejected with the numeric-id fallback named.
- 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`.