diff --git a/docs/reference.md b/docs/reference.md index e052dc5..a6674b8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -31,6 +31,7 @@ The quick lookup surface: one line and a minimal snippet per construct. For rule | [`view`](/spec/presentation#view-calendar-range-slots) | calendar / range / slot-booking pages | | [`documentItemsLayout: chat`](/spec/presentation#documentitemslayout-chat-conversation-threads) | render a document's items as a chat thread | | [`reports`](/spec/presentation#reports) | aggregations, charts, dashboard KPI tiles, balance reports | +| [`scope`](/spec/presentation#lifecycle-scope) | which lifecycle rows an aggregating report counts | | [`widgets`](/spec/presentation#widgets-custom-dashboard-tiles) | custom KPI / embedded-page dashboard tiles | | [`notifications`](/spec/glue#notifications) | email on create / update / delete | | [`notify.forEach`](/spec/glue#one-message-per-related-row-foreach) | fan the block out over a related collection: one message per row | @@ -124,6 +125,7 @@ reports: dimensions: ["month(orderDate)"] measures: ["count(*)", "sum(total)"] filter: "total > 0" + scope: live # all | draft | live | cancelled | void chart: bar widget: { value: "sum(total)", at: { "month(orderDate)": now }, label: Revenue (this month) } ``` @@ -135,7 +137,7 @@ generates: - { name: invoice-from-order, from: Order, to: Invoice, map: { Customer: Customer }, sourceStatus: 3 } transitions: - - { name: VoidInvoice, forEntity: Invoice, from: [3, 4], setStatus: 8, when: "Paid == 0", label: Void, icon: ban } + - { name: VoidInvoice, forEntity: Invoice, from: [ISSUED, SENT], setStatus: VOIDED, when: "Paid == 0", label: Void, icon: ban } ``` ### seeds @@ -145,8 +147,8 @@ seeds: - name: statuses entity: OrderStatus rows: - - { id: 1, name: DRAFT } - - { id: 2, name: POSTED } + - { id: 1, name: DRAFT, stage: draft } + - { id: 2, name: POSTED, stage: live } - name: countries entity: Country file: data/countries.csv @@ -157,6 +159,7 @@ seeds: 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`). +- **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. - **`manyToMany`** — parsed but never materialised; the supported shape is the [explicit intermediate entity](/spec/relations#many-to-many). - **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). diff --git a/docs/spec/data.md b/docs/spec/data.md index f90d264..d28763d 100644 --- a/docs/spec/data.md +++ b/docs/spec/data.md @@ -12,8 +12,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: @@ -39,6 +41,40 @@ Row keys must match a field or relation name **exactly** (case-sensitive). A key A seed with `language: ` 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`](/spec/presentation#lifecycle-scope). + +::: info 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](/spec/glue#transitions-guarded-status-flips) `from` and `setStatus`, a relation's `init`, a status-setting step's `value`, [`abortOn`](/spec/processes#aborton-cancel-the-instance-on-a-terminal-status)'s `status`, a [check's](/spec/entities#checks-declarative-validations) `status` / `setStatus`, [`immutableWhen`](/spec/entities#immutablewhen-immutable-user-write-immutability), a [posting's](/spec/glue#postings-source-document-to-ledger) event guard, a [report's](/spec/presentation#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. + +::: info 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`](/spec/presentation#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**. diff --git a/docs/spec/presentation.md b/docs/spec/presentation.md index bb93949..a3eaf7b 100644 --- a/docs/spec/presentation.md +++ b/docs/spec/presentation.md @@ -31,6 +31,38 @@ 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](/spec/data#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)"] +``` + +::: info 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: