diff --git a/docs/superpowers/plans/2026-10-09-fr-044-zero-rows-and-measure-defaults.md b/docs/superpowers/plans/2026-10-09-fr-044-zero-rows-and-measure-defaults.md new file mode 100644 index 000000000..773b9d7a8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-fr-044-zero-rows-and-measure-defaults.md @@ -0,0 +1,915 @@ +# FR-044 — Zero rows from a dimension's entity, and a default for an empty measure + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two attributes to the FR-044 reporting vocabulary before 1.1.0 ships, in all five ports: `@spine` on `object.report` (the report's rows come from a dimension's entity, so a tuple with no fact rows still has a row) and `@default` on `measure.aggregate` and `measure.ratio` (the value a measure reads when it would be null). A model that uses neither generates byte-for-byte what it does now, and `metamodelVersion` stays `1.1`. + +**Architecture:** Both attributes are registered once in `spec/metamodel/` and copied or embedded into each port, as Plan 1 did. Four cross-node rules join the existing `validateReporting` pass in each port; the value type of `@default` is the registered `attr.int`, so the existing attribute type check enforces it. The derived read shape (`reportShape`, Plan 2 Table B) gains two nullability rules, copied to every port and pinned by `report-shapes.json`. TypeScript alone changes the SQL: with a spine the view selects `FROM` the spine entity's table and `LEFT JOIN`s the fact table, and a defaulted measure is wrapped in `COALESCE`. No other port emits SQL, no route generator changes, and no runtime changes: every port already reads a report view through its shape. + +**Tech Stack:** TypeScript (Bun), C# (.NET, EF Core), Java (Maven, OMDB), Kotlin (Exposed), Python (pytest). Postgres 16, SQLite ≥ 3.37, MySQL 8.4. + +**Spec:** `docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md`, amended by this plan's change: §3.2 (the ADR-0023 register amendment), R8 (`@spine`), R9 (`@default`), two §5 mapping rows, one §7 acceptance bullet and the §9 parked list. Earlier plans: [Plan 1](2026-10-03-fr-044-plan-1-reporting-vocabulary.md) (vocabulary and loader rules), [Plan 2](2026-10-03-fr-044-plan-2-report-view-lowering.md) (lowering; its contract tables A to H are amended here, not replaced), [Plan 3](2026-10-04-fr-044-plan-3-report-read-routes.md) (read routes). What those shipped: `docs/features/reporting.md`. + +**This plan sits between Plan 3 and Plan 4.** It is not one of the five: it closes two gaps a reference adopter's report inventory found (its gaps "no zero rows from a dimension's entity" and "no default for an empty measure") while the vocabulary can still change without a second metamodel move. Out of scope: cross-fact reports, anti-join, joins on a natural key, nested aggregation, renaming exposed fields, the calendar spine and the `reporting` library (R7), the Cube and dbt exporters, the client hook, `measure.derived`, #395, #222, #8, #393. + +## How this plan was verified + +Every path, function and test file cited below was read in the tree at `fff01da85`, by hand or by a read-only mapping pass over all five ports, unless it is marked **UNVERIFIED**. Every SQL expression in Tables D and E was executed on Postgres 16, SQLite 3.37.2 and MySQL 8.4 (default `ONLY_FULL_GROUP_BY`) against three programs, one of them with no weeks, plus a week whose reference is null, and returned the values Table E shows. The column types in Table E's last rows were read back from `information_schema` (Postgres, MySQL) and `typeof()` (SQLite). + +Things marked UNVERIFIED are collected in [Unverified items](#unverified-items). The first step of the task that touches one is to read the code and confirm or correct it. + +## Global Constraints + +- **Exactly two names are registered:** `spine` on `object.report`, and `default` on `measure.aggregate` and `measure.ratio`. Nothing else. `measure.derived` stays unregistered. +- **`metamodelVersion` stays `1.1`.** 1.1 has not shipped, so the additions join it. `node scripts/check-metamodel-version.mjs` must pass without `--set`, and `--explain` must classify every change ADDITIVE. +- **No-churn.** A model that declares neither attribute produces byte-identical output in every port: generated code, `canonical/schema.postgres.sql` statements, `report-shapes.json` entries, the api-contract `report/` schema, the `codegen-noop` corpus. Existing fixture files gain entries; no existing entry changes. +- **No query-time engine.** A report is still one compiled view. +- **Every measure belongs to `@from`, and every path is to-one.** `@spine` is a to-one path, as `@via` is. Do not admit a to-many hop, a cross-fact measure, an anti-join or a join on a natural key. +- **No SQL string enters the metamodel.** +- **View DDL is produced by TypeScript only** (ADR-0015). No other port emits SQL for a report. +- **A report lowers only with `source.rdb` `@kind: view`.** A sourceless report with `@spine` or a defaulted measure stays inert. +- ADR-0039: read effective properties with resolving accessors. Any `own*()` call carries a comment naming its sanctioned case. +- TS: named constants for metamodel strings, no `any`, never `instanceof` a node from another package. +- Public repo: no private project names, no absolute home paths, in code, fixtures, docs or commit messages. The adopter inventory that motivated this is private: write "a reference adopter" and generic entity names. +- **Another change is in flight** on the TypeScript report generators (a generated list hook), the spec's `daysEngaged` worked example and its fixtures, and a ratio-on-SQLite route check. Expect rebases. Do not edit R2's example in the spec, and add new fixture entries at the end of a file rather than between existing ones. +- This machine is loaded. Run scoped tests while iterating and the full `scripts/ci-local.sh` once, on the final tree. A lone timeout is rerun in isolation before it is believed. +- Do not push until every port is green. + +## Review Focus + +1. **A spine row whose facts are all filtered out keeps its row.** The report's `@segment` and `@filter` go into the join condition, never a `WHERE`. A `WHERE` on a fact column silently turns the outer join back into an inner one, and the zero rows vanish with no error. (Task 5 test `the report scope is in the join condition`; scenario `report-spine-scoped`.) +2. **A measure whose condition is true for a row of nulls.** `{ refundedAt: { isNull: true } }` is true for the null-extended row of a spine entity with no facts. Every aggregate must still read `0` or null there, because it ignores a null `@of` column, on all three engines. (Task 6 value test `an isNull condition does not count the empty row`.) +3. **A defaulted measure is never null, in the database and in every typed row.** `COALESCE` in the view, `required: true` in the shape, a non-nullable member in C#, Kotlin, Java, Python and the TypeScript read schema. A port that types it nullable compiles and passes the value scenarios, so only the shape artifact and the per-port row tests catch it. (Tasks 4 and 9 to 12.) +4. **A model that uses neither attribute is untouched.** No existing view statement, shape entry, generated file or fixture expectation changes. (Task 13, and the no-churn table at the end.) +5. **Hop names are compared as written.** A spine written with the relationship name (`Purchase.program`) and a dimension written with the reference name (`Purchase.fkProgram`) name the same join and are still refused, because the lowering merges joins by hop name. The error must say to write the same hops. (Task 3 fixture `error-report-spine-dimension-other-path`.) + +--- + +## The design, and what was rejected + +### What merging this plan decides + +Four choices here would be expensive to change after 1.1.0, because each changes what a report returns. They are stated first so they are ruled on, not discovered. + +1. **The grain does not change.** A report is still one row per distinct dimension tuple. `@spine` changes where the tuples come from (the spine entity's rows, not the fact rows). A report gets exactly one row per row of the spine entity when it lists a dimension over that entity's identity; if it lists only a title and two rows share the title, they are one row, as they would be in any report. +2. **The report's row scope filters facts, never spine rows.** `@segment` and `@filter` on a `@spine` report decide which rows of `@from` are aggregated. +3. **A fact row with no spine row is in no row of the report.** A null reference, or one that matches nothing, has no spine row to sit in. (Without `@spine`, the same rows form a null group.) +4. **A ratio's operand carries its own `@default` into the ratio.** `revenue` with `@default: 0` over `buyers` reads `0`, not null, for a group with buyers and no revenue. A ratio's own `@default` then applies to the quotient. + +### Decision 1 — where the zero rows are declared: `@spine` on the report + +`"@spine": "Purchase.program"`: a to-one path from `@from`, in `@via`'s grammar. The entity at its end supplies the rows. + +| Alternative | Why it was rejected | +|---|---| +| On the **dimension** (`dimension.attribute … "@zeroFill": true`) | One dimension is listed by both kinds of report. The adopter's sales table lists only programs that sold; its catalogue table lists every program. A flag on the dimension would force two dimensions that differ in nothing else, and two names for one field. | +| On the **dimension reference** (`"@dimensions": ["program!"]`, or `program:all`) | A third item grammar inside a string; `name:grain` already owns the colon. It also cannot say which entity when the dimension has no `@via` (a reference column names a row, it is not one). | +| On the **measure** | A measure does not know which report lists it or which rows that report shows. | +| The value is an **entity name** (`"@spine": "Program"`) | An entity can be reached from `@from` by more than one reference (`Transfer.fromAccount`, `Transfer.toAccount`), so the name does not say which rows are meant. A path does, and the grammar already exists. | +| The value is a **listed dimension's name** | A dimension with no `@via` reaches no entity; several dimensions can reach one entity, so naming one of them is arbitrary; and it hides the join the report depends on. | +| A new **subtype or `@kind`** of report | ADR-0037: the derived shape, the routes and the read path are the same. What differs is one reference, which is configuration: an attribute. | +| Leave it to `object.projection` with `origin.aggregate` (one row per base row already) | It restates every measure in a second vocabulary that has no ratio, no tuple distinct count, no relative date and no segment. That duplication is what FR-044 exists to remove. | +| Reverse the declaration (`@from` the dimension entity, measures reached to-many) | It breaks the standing rule that every measure belongs to `@from` and every path is to-one, which is what keeps `SUM` free of fan-out. | +| Group by the spine entity's key always, whatever is listed | Rows the caller cannot tell apart (two rows titled the same), and the one invariant every report has (a row is a distinct tuple) would have an exception. | + +Names considered: `@spine` (chosen), `@rowsFrom`, `@zeroRows`, `@per`, `@everyRowOf`. `@rowsFrom` reads as a second `@from`. `@zeroRows` names an effect and reads as a boolean. `@per` describes every dimension. "Spine" is the term the spec's own R7 uses ("time-spine"), the term the adopter inventory used, and the term in dbt MetricFlow, which this FR exports to. The registry description states it in one plain sentence for a reader who has never met the word. + +**What a `@spine` report may list.** Every listed dimension must be reached through the spine: its `@via` begins with the spine's hops. So it is a column of the spine entity or of an entity to-one from it. Three things follow, each refused at load so that admitting it later is additive: + +- **A dimension read from the fact row** (no `@via`, or a `@via` through another reference) is refused: it has no value in a row with no facts. +- **A time dimension alongside** follows the same rule and needs no rule of its own. Over a column of the spine entity or beyond (the month a program was published) it is legal. Over a fact column (the month of a purchase) it is refused; a program with no purchases would need a row with a null month, and zero-filled time buckets are R7's calendar spine. +- **More than one zero-row entity** is not expressible: `@spine` is one path. Entities to-one beyond the spine are reachable as ordinary dimensions (a workout's week, the week's program), which covers a chart of every scheduled day. Two independent spines would be a cross join the size of both tables multiplied; parked in spec §9. + +A report with `@spine` lists at least one dimension. With none it would be the totals row computed over a join, which differs from the plain totals report only by silently dropping facts whose reference is null. + +### Decision 2 — where the empty value is declared: `@default` on the measure + +`"@default": 0` on a `measure.aggregate` or a `measure.ratio`: an integer. + +| Alternative | Why it was rejected | +|---|---| +| On the **report** (`"@defaults": { "revenue": 0 }`) | The same measure would read null in one report and zero in another, and an exporter would have nowhere to put it: both Cube and MetricFlow attach the fill to the measure or metric. | +| On the **measure reference** (`"@measures": ["revenue=0"]`) | A new item grammar, and the same objection. | +| A **boolean** (`"@zeroWhenEmpty": true`) | It cannot say a sentinel (`-1` for "not applicable"), and it is no simpler to lower or to type. | +| A **new name** (`@whenEmpty`, `@ifNull`, `@fillNullsWith`, `@coalesce`) | ADR-0037: same concept, same attribute name. A field's `@default` is the value used when none is supplied; a measure's is the value used when there is none to aggregate. `@coalesce` and `@ifNull` also name SQL functions. | +| An **untyped** value following the measure's type, as a field's `@default` follows the field's | A fractional default needs a rule per measure type (an integer for a `long` or `currency` sum, a decimal for an `avg`) and a number spelling that five canonical serializers and three SQL dialects agree on. They do not agree today: no fixture round-trips a non-integer number through any attribute, C# and Python print a whole-number double as an integer, and Java has no such guard. Every case found is zero, MetricFlow's `fill_nulls_with` is an integer, and an integer is a valid value of every numeric type a measure can have. Registered as `attr.int`; widening is additive. | +| A default on **non-numeric** `min` / `max` (a date, a string) | "Last activity" with no activity is null, and a made-up date would be a wrong answer. It also needs a typed literal per dialect. Refused at load. | +| A default on `count` accepted and ignored | An attribute that can never apply is a false statement in the model. Refused at load. | + +--- + +## Contract tables (what the ports copy) + +Tables A, B and C are implemented in all five ports. Tables D and E are implemented in TypeScript only; the other ports never emit SQL, but their readers depend on the columns these tables produce. Each table amends the Plan 2 table named in its heading; a row not shown here is unchanged. + +### Table A — what is registered + +`spec/metamodel/object.json`, in the `object.report` children, after `filter`: + +```json +{ "type": "attr", "subType": "string", "name": "spine", "min": 0, "max": 1, "description": "Optional to-one path from @from to the entity whose rows supply the report's rows (e.g. 'Purchase.program'), written like a dimension's @via. With it the report has one row per distinct dimension tuple among THAT entity's rows, including the ones no row of @from refers to: a count there is 0 and any other measure is null unless it declares @default. Every listed dimension must be reached through this path. @segment and @filter still scope the rows of @from and never remove a row. A row of @from whose reference is null or matches nothing is in no row of the report." } +``` + +`spec/metamodel/reporting.json`, appended to the children of both `measure.aggregate` and `measure.ratio`: + +```json +{ "type": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null) and on min/max over a field that is not numeric. A ratio's operand carries its own @default into the ratio." } +``` + +No existing description is edited. No error code is added: the rules below reuse `ERR_INVALID_REPORT`, `ERR_INVALID_MEASURE` and `ERR_BAD_ATTR_VALUE`, so `fixtures/conformance/ERROR-CODES.json` is not touched. + +### Table B — loader rules (extends the Plan 1 rule table) + +| Id | Rule | Code | Fixture | +|---|---|---|---| +| R8 | A report's `@spine` is `Owner.hop[.hop…]`. `Owner` resolves in the report's package and is `@from` or an entity it extends. Every hop is a `relationship.*` with `@cardinality: one` or an `identity.reference`, and its target resolves. This is rule D2's walk, started at `@from`. | `ERR_INVALID_REPORT` | `error-report-spine-to-many`, `error-report-spine-not-from` | +| R9 | A report with `@spine` lists at least one dimension, and every listed dimension has an `@via` whose hop names begin with the spine's hop names. The names are compared as written; the owner segment is not compared. | `ERR_INVALID_REPORT` | `error-report-spine-no-dimensions`, `error-report-spine-dimension-off-spine`, `error-report-spine-dimension-other-path` | +| M7 | A `measure.aggregate` with `@agg: count` declares no `@default`. | `ERR_INVALID_MEASURE` | `error-measure-default-on-count` | +| M8 | A `measure.aggregate` with `@agg: min` or `max` over a field that is not numeric (rule M4's set: `int, long, double, float, decimal, currency`) declares no `@default`. | `ERR_INVALID_MEASURE` | `error-measure-default-non-numeric` | +| (type) | `@default` is an integer. Enforced by the registered `attr.int`, through the attribute type check every port already runs. | `ERR_BAD_ATTR_VALUE` | `error-measure-default-not-integer` | + +Order and skipping, so one mistake gives one error: + +- R8 and R9 run after R1 and need a resolved `@from`. R9 is skipped when R8 failed. R9 reports each offending dimension once, on the report node, naming the dimension and the spine. +- M7 and M8 run after M1 to M4 and only when none of them fired. A ratio is checked by neither (its `@default` needs only the type check). +- `error-report-spine-dimension-off-spine` lists a **time** dimension over a fact column (`purchasedAt:day`), so the "time dimension alongside" case is the fixture. `error-report-spine-dimension-other-path` writes the spine with the relationship's name (`Purchase.program`) and lists a dimension whose `@via` uses the reference's name for the same join (`Purchase.fkProgram`): the one case where a port that resolved joins instead of comparing names would accept what the others refuse. A dimension through a second reference to the same entity is a unit test in each port. + +Each error fixture's input differs from the positive fixture `reporting-spine-and-default` by the one change that breaks its rule. A second positive fixture, `reporting-spine-inherited`, declares the reference, the dimensions and a defaulted measure on an abstract base, writes the spine with the base as its owner (`BaseEvent.program`), and reports over the concrete entity. + +Message texts are part of the port contract (every port uses the same words). The TypeScript texts in Task 2 are the reference. + +### Table C — derived fields (amends Plan 2 Table B) + +Two rules change. Field names, order, subtypes and `typeSource` are untouched. + +| Item | `required` | +|---|---| +| A dimension of a report **without** `@spine` | unchanged: `true` only when the dimension has no `@via` and the `@of` field's effective `@required` is `true` | +| A dimension of a report **with** `@spine`, whose `@via` hops equal the spine's hops (a column of the spine entity itself) | `true` when the `@of` field's effective `@required` is `true`, **or** the field is one of the `identity.primary` `@fields` of the entity `@of` names (under rules D1 and R9 that is the spine entity, or one it extends) | +| A dimension of a report **with** `@spine`, reached beyond the spine | `false` (its join is `LEFT OUTER`) | +| `measure.aggregate` `count` | unchanged: `true` | +| Any other `measure.aggregate`, or a `measure.ratio`, **with** `@default` | `true` | +| Any other `measure.aggregate`, or a `measure.ratio`, without `@default` | unchanged: `false` | + +The identity clause exists because the dimension over the spine entity's key is the one every `@spine` report lists, a key column can never be null, and models rarely write `@required` on an `id`. It applies only under `@spine`: applying it to every report would change the shape of a report that exists today. + +The shape artifact `fixtures/persistence-conformance/report-shapes.json` keeps its format. Its existing six entries stay byte-identical; the new canonical reports append entries. + +### Table D — the view (amends Plan 2 Tables C and F) + +**With `@spine`.** `S` is the spine entity, `F` is `@from`. + +| Part | Rule | +|---|---| +| Aliases | computed exactly as for the same report without `@spine`: `F` takes `shortAliasFor(from.name)`, and every hop takes the alias the join tree gives it. No measure reference moves. | +| `FROM` | `S`'s table, under its join alias | +| The spine chain | the spine's hops walked **from `S` back to `F`**, one `LEFT OUTER JOIN` each, with the same `ON` predicate the forward join renders today. The last one introduces `F`'s table under the base alias. | +| Row scope | the report's `@segment` filter, then its `@filter`, ANDed onto the `ON` of the join that introduces `F`. **There is no `WHERE`.** | +| Onward joins | a dimension beyond the spine joins from `S`'s alias through the existing hop logic. In a `@spine` report every join is `LEFT OUTER`: the #209 `INNER` rule is not applied, so no join can drop a spine row. | +| `SELECT`, `GROUP BY` | unchanged: one column per item, grouped by each dimension's `SELECT` expression | + +The join tree has one root under `@spine` (rule R9 puts every dimension's path through it), and each node on the spine chain has exactly one child on that chain, so the chain is unambiguous. + +`meta migrate` refuses a derived `@spine` report, naming the report, in three more cases beside the existing ones: the spine entity or an entity on the chain has no table (abstract, or no writable `source.rdb`); one of them is a TPH subtype (it shares its base's table with every other subtype, so the report would get a row per row of all of them); a hop has no declared `identity.reference` (the existing hop error). + +**With `@default: n`.** `E` is the measure's full Plan 2 Table C expression, condition and cast included. + +| Measure | Postgres | SQLite / D1 | MySQL | +|---|---|---|---| +| `measure.aggregate` whose derived type is `int`, `long` or `currency` | `COALESCE(E, n)` | `COALESCE(E, n)` | `COALESCE(E, n)` | +| `measure.aggregate` whose derived type is `decimal`, `double` or `float` | `COALESCE(E, n)` | `COALESCE(E, n.0)` | `COALESCE(E, n)` | +| `measure.ratio` | `COALESCE( / NULLIF(, 0), n)` with the existing cast on `` | the same, with `n.0` | the same | + +`` and `` are the operands' full expressions **including their own `COALESCE`** when the operand declares a `@default`. SQLite gets `n.0` so a `REAL` column has one storage class in every row; without it the defaulted rows read `integer` from `typeof()` and the others `real`. + +Expected bodies for the canonical reports of Task 6, Postgres, `literal` naming (the goldens for Task 5): + +```sql +-- v_program_roster + SELECT + p."id" AS "programKey", + p."title" AS "programTitle", + COUNT(w."id") AS "weeks", + CAST(SUM(w."durationMinutes") AS BIGINT) AS "totalMinutes", + COALESCE(CAST(SUM(w."durationMinutes") AS BIGINT), 0) AS "totalMinutesOrZero", + CAST(COUNT(w."id") FILTER (WHERE w."durationMinutes" >= 60) AS NUMERIC) / NULLIF(COUNT(w."id"), 0) AS "longShare", + COALESCE(CAST(COUNT(w."id") FILTER (WHERE w."durationMinutes" >= 60) AS NUMERIC) / NULLIF(COUNT(w."id"), 0), 0) AS "longShareOrZero" + FROM "programs" p + LEFT OUTER JOIN "weeks" w ON p."id" = w."programId" + GROUP BY p."id", p."title" + +-- v_program_long_weeks (report @segment: long) + SELECT + p."id" AS "programKey", + COUNT(w."id") AS "weeks", + COALESCE(CAST(SUM(w."durationMinutes") AS BIGINT), 0) AS "totalMinutesOrZero" + FROM "programs" p + LEFT OUTER JOIN "weeks" w ON p."id" = w."programId" AND w."durationMinutes" >= 60 + GROUP BY p."id" + +-- v_fitness_totals_filled (no @spine: a default alone) + SELECT + COUNT(w."id") AS "weeks", + COALESCE(CAST(SUM(w."durationMinutes") AS BIGINT), 0) AS "totalMinutesOrZero", + COALESCE(CAST(COUNT(w."id") FILTER (WHERE w."durationMinutes" >= 60) AS NUMERIC) / NULLIF(COUNT(w."id"), 0), 0) AS "longShareOrZero" + FROM "weeks" w +``` + +`v_program_roster` on SQLite differs from Postgres only as Plan 2 Table C says (no `BIGINT` cast, `CASE WHEN` for the condition, `REAL` for the ratio's cast) and in the last column's literal: + +```sql + COALESCE(CAST(COUNT(CASE WHEN w."durationMinutes" >= 60 THEN w."id" END) AS REAL) / NULLIF(COUNT(w."id"), 0), 0.0) AS "longShareOrZero" +``` + +A two-hop spine (`Session` → `Week` → `Program`, spine `Session.week.program`) was run in the same shape and is the Task 5 unit case: + +```sql + FROM "programs" p + LEFT OUTER JOIN "weeks" w ON p."id" = w."programId" + LEFT OUTER JOIN "sessions" s ON w."id" = s."weekId" AND s."minutes" >= 10 +``` + +### Table E — what a measure reads + +Executed on Postgres 16, SQLite 3.37.2 and MySQL 8.4; all three agreed on every cell, including an operand's default inside a ratio and a row scope with an `or` inside the join condition. "Empty" is any case with nothing to aggregate: a spine row with no facts, a spine row whose facts the report scope filtered out, a totals report over an empty table, or a measure `@filter` / `@segment` that matched none of a group's rows. + +| Measure | Rows to aggregate | Empty, no `@default` | Empty, `@default: 0` | +|---|---|---|---| +| `count`, `count` + `@distinct`, tuple count | the count | `0` | not legal (M7) | +| a `count` whose condition is `isNull: true` on a fact column | the count | `0` (the null-extended row has a null `@of`) | not legal (M7) | +| `sum` | the sum | null | `0` | +| `avg` | the average | null | `0` | +| `min` / `max` of a numeric field | the value | null | `0` | +| `min` / `max` of any other field | the value | null | not legal (M8) | +| `measure.ratio`, denominator not zero | the quotient | null when the numerator is null | the numerator's `@default` over the denominator when the numerator declares one; else the ratio's `@default` | +| `measure.ratio`, denominator zero or null | n/a | null | the ratio's `@default` | + +Column types with a default, read back from the engines: a defaulted integral `sum` stays `bigint` on Postgres and MySQL; a defaulted `avg` and ratio stay `numeric` / `decimal`; MySQL reports every defaulted column `NOT NULL`. Row count: the view over three programs, one with no weeks, returned three rows on every engine, and the week whose reference is null appeared in none of them. + +### Table F — what each port's row type does with it + +No generator is edited for this. Each port's detached read model sets `@required` on a derived field from the shape (`report-read-model.ts:72`, `ReportRows.cs:162`, `ReportReadModel.java:199`, `report_read_model.py:79`), and each port's existing read-only generators already type a required field as not nullable. The port tasks prove it with a test per port; they do not change it. + +| Port | A measure without `@default` (other than `count`) | The same measure with `@default` | A dimension over the spine entity's key | +|---|---|---|---| +| TypeScript | Zod `.nullable()`; row type `T \| null` | no `.nullable()`; `T` | `T` | +| C# | nullable member of the keyless row class (`long?`, `decimal?`) | `long`, `decimal` | not nullable | +| Java | nullable DTO component | as the port types a required field | as the port types a required field | +| Kotlin | nullable Exposed column and nullable data class property | `Column`, `Long` | not nullable | +| Python | `Optional[...]` on the Pydantic row model | the bare type | the bare type | + +**UNVERIFIED:** the exact spelling each generator uses for a required field of a read model (a Java record component, a Pydantic field). Each port task's first step reads the generated output for an existing `count` measure, which is `required: true` today, and uses that as the expected form. + +### Table G — filter and sort on the derived fields + +Nothing is added to a filter or sort allowlist rule: every derived field with a filter band stays filterable and sortable, by the operators of its type. + +| Case | Behaviour | +|---|---| +| The empty rows of a `@spine` report | present in the list and counted by `withCount`. `?filter[purchases][gt]=0` removes them at request time, so one report serves the page that shows them and the page that does not. | +| A filter on a defaulted measure | the default is the value: `?filter[revenue][eq]=0` matches the empty rows. `isNull=true` matches nothing; `isNull=false` matches every row. | +| A filter on a measure without a default | unchanged: an empty row is null, matched by `isNull=true` and by no comparison. | +| A sort on a defaulted measure | deterministic on every engine: the empty rows sort as their default. | +| A sort on a measure without a default | unchanged, and engine-dependent for the null rows (Postgres puts nulls last ascending; SQLite and MySQL first). The corpus does not sort a nullable measure across a null row. | +| A filter on a spine-entity column that is not listed | refused as today (`400 invalid_filter_field`). A row scope on the spine entity is listed as a dimension and filtered on the request (spec §9). | + +### Table H — fixtures and gates + +| Gate | Path | Ports | +|---|---|---| +| Registry | `fixtures/registry-conformance/expected-registry.json` (two attributes added, three sites), `coverage-report.json`, `fixtures/metamodel-docs/expected/**` | all five byte-match | +| Loader, positive | `fixtures/conformance/reporting-spine-and-default/`, `fixtures/conformance/reporting-spine-inherited/` | all five | +| Loader, errors | the eight `error-*` fixtures of Table B | all five | +| Canonical reports (model) | `fixtures/persistence-conformance/canonical/meta.fitness.json`: `ProgramRoster`, `ProgramLongWeeks`, `FitnessTotalsFilled` | all (shared input) | +| Report shapes | `fixtures/persistence-conformance/report-shapes.json` (three entries appended) | TS produces; C#, Java, Kotlin (through Java), Python byte-match | +| Canonical schema | `fixtures/persistence-conformance/canonical/schema.postgres.sql` (three views appended) | all execute it | +| `queries/report-spine-zero-rows.yaml` | a program with no weeks has a row: `weeks` `0`, `totalMinutes` null, `totalMinutesOrZero` `0`, `longShare` null, `longShareOrZero` `0`; `count` equals the number of programs; filter and sort on the defaulted measure | all five | +| `queries/report-spine-scoped.yaml` | `ProgramLongWeeks`: a program whose only weeks are short keeps its row with `weeks` `0` | all five | +| `queries/report-default-empty.yaml` | `FitnessTotalsFilled` over an empty table: `{ weeks: "0", totalMinutesOrZero: "0", longShareOrZero: "0" }`, the twin of `report-totals-empty` | all five | +| Emitter goldens | `server/typescript/packages/codegen-ts/test/projection/report-ddl-emit.test.ts`, `extract-report-spec.test.ts` (Table D) | TS | +| Idempotence and values | `server/typescript/packages/integration-tests/test/report-views-pg.test.ts`, `report-views-sqlite.test.ts`, `report-views-mysql.test.ts` | TS | +| REST | `fixtures/api-contract-conformance/report/`: `Product`, `Sale`, report `ProductRevenue`; scenarios `list-spine.yaml`, `filter-on-defaulted-measure.yaml`, `sort-on-defaulted-measure.yaml` | generated lane, all five | +| Inert | `fixtures/codegen-noop/reporting/with/`: a sourceless `@spine` report and a defaulted measure | all five inert tests | +| Metamodel version | `node scripts/check-metamodel-version.mjs` (no `--set`) | gates lane | + +The conformance corpus goes from 364 fixtures to 374 (37 concern reporting, up from 27). The persistence corpus goes from 39 scenarios to 42. The `report/` REST sub-corpus goes from 13 scenarios to 16. + +--- + +## File structure + +**Shared, modified:** `spec/metamodel/object.json`, `spec/metamodel/reporting.json`; `fixtures/registry-conformance/expected-registry.json` and `coverage-report.json`; `fixtures/metamodel-docs/expected/**`; `site-reference/**`; `fixtures/persistence-conformance/canonical/meta.fitness.json`, `canonical/schema.postgres.sql`, `report-shapes.json`, `README.md`; `fixtures/api-contract-conformance/report/{meta.json,seed.json,schema.postgres.sql,README.md}`; `fixtures/codegen-noop/reporting/with/meta.shop.json` and `README.md`. + +**Shared, new:** ten directories under `fixtures/conformance/` (Table B); three files under `fixtures/persistence-conformance/queries/`; three under `fixtures/api-contract-conformance/report/scenarios/`. + +**TypeScript, modified** (all under `server/typescript/packages/`): + +| File | Change | +|---|---| +| `metadata/src/core/object/object-constants.ts` | `OBJECT_REPORT_ATTR_SPINE` | +| `metadata/src/core/reporting/reporting-constants.ts` | `REPORTING_ATTR_DEFAULT` | +| `metadata/src/core/reporting/meta-measure.ts` | `defaultValue()` | +| `metadata/src/core/reporting/report-accessors.ts` | `reportSpine()`, `reportSpineHops()` | +| `metadata/src/core/reporting/report-shape.ts` | Table C; exports `measureDerivedSubType()` | +| `metadata/src/core/reporting/report-describe.ts` | the wording for a spine and a default | +| `metadata/src/loader/reporting-validation.ts` | R8, R9, M7, M8 | +| `metadata/src/core/{object,reporting}/*-definition.embedded.ts` | regenerated | +| `codegen-ts/src/projection/report-spec.ts` | `defaultValue` on an aggregate and a ratio; `spineDepth` on the view spec | +| `codegen-ts/src/projection/extract-report-spec.ts` | the spine path, forced `LEFT OUTER`, the three refusals, defaults | +| `codegen-ts/src/projection/report-ddl-emit.ts` | the spine `FROM` and chain, the scope in `ON`, `COALESCE` | +| `codegen-ts/src/generators/report-doc.ts`, `docs-site/src/builders/report-data.ts` | print the new wording | + +No TypeScript file is created. No route, queries, hook, entity or runtime file is edited in any port. + +**Other ports:** listed in Tasks 9 to 12. + +## Task order and parallelism + +| Task | Depends on | Can run in parallel with | +|---|---|---| +| 1 register (TS) | none | none | +| 2 loader rules (TS) | 1 | 4 | +| 3 conformance fixtures | 2 | 4, 5 | +| 4 `reportShape` (TS) | 1 | 2, 3 | +| 5 lowering (TS) | 4 | 3 | +| 6 canonical reports, artifacts, value tests | 5 | none | +| 7 persistence scenarios, TS reads, docs wording | 6 | 8 | +| 8 REST sub-corpus, TS generated lane | 6 | 7 | +| 9 C# | 3, 7, 8 | 10, 12 | +| 10 Java | 3, 7, 8 | 9, 12 | +| 11 Kotlin | 10 (shares the Java loader and `ReportShape`) | 9, 12 | +| 12 Python | 3, 7, 8 | 9, 10, 11 | +| 13 no-churn proof | 9 to 12 | 14 | +| 14 docs, skills, changelog, counts | 8 | 9 to 13 | +| 15 full CI, review, gate | all | none | + +Tasks 1 to 8 are one TypeScript track and the critical path. Each task gets a fresh implementer and a reviewer. A subagent's claim of a "pre-existing failure" is re-run on a clean build of `origin/main` before it is accepted. + +--- + +### Task 1: Declare both attributes and register them in TypeScript + +**Files:** +- Modify: `spec/metamodel/object.json` (`object.report` children), `spec/metamodel/reporting.json` (`measure.aggregate` and `measure.ratio` children) — the two entries of Table A, verbatim +- Regenerate: `server/typescript/packages/metadata/src/core/object/object-definition.embedded.ts`, `server/typescript/packages/metadata/src/core/reporting/reporting-definition.embedded.ts` +- Modify: `server/typescript/packages/metadata/src/core/object/object-constants.ts`, `core/reporting/reporting-constants.ts`, `core/reporting/meta-measure.ts`, `core/reporting/report-accessors.ts`, and the `index.ts` / constants barrel exports beside the existing reporting ones +- Regenerate: `fixtures/registry-conformance/expected-registry.json`, `fixtures/metamodel-docs/expected/**`, `site-reference/**` +- Test: `server/typescript/packages/metadata/test/reporting-registry.test.ts`, `test/object-definition-completeness.test.ts` (`REPORT_ATTRS`, line 80), `test/report-accessors` cases beside the existing ones + +**Interfaces:** +- Produces: `OBJECT_REPORT_ATTR_SPINE = "spine"`; `REPORTING_ATTR_DEFAULT = "default"`. +- Produces: `MetaMeasure.defaultValue(): number | undefined` (resolving `attr()`; the value when it is an integer, else `undefined`). +- Produces: `reportSpine(obj: MetaData): string | undefined` (resolving `attr()`). + +- [ ] **Step 1: Write the failing tests.** In `reporting-registry.test.ts`, assert through the accessor that file already uses that `object.report` has an attr `spine` of value type `string`, not required, and that `measure.aggregate` and `measure.ratio` each have an attr `default` of value type `int`, not required; keep the existing `measure.derived` assertion. In `object-definition-completeness.test.ts` add `spine: { valueType: "string", required: false }` to `REPORT_ATTRS`. Add accessor cases: `defaultValue()` is `0` for `"@default": 0`, `-1` for `-1`, `undefined` when absent; `reportSpine` returns the string. +- [ ] **Step 2: Run and see them fail.** `cd server/typescript && bun test packages/metadata/test/reporting-registry.test.ts packages/metadata/test/object-definition-completeness.test.ts` +- [ ] **Step 3: Edit the two spec files** with Table A's entries. Do not change any existing description. +- [ ] **Step 4: Constants and accessors.** + +```ts +// meta-measure.ts +/** `@default`: the integer this measure reads when it would otherwise be null (rule M7/M8 + * decide where it is legal). ADR-0039: resolving, so an inherited measure keeps it. */ +defaultValue(): number | undefined { + const v = this.attr(REPORTING_ATTR_DEFAULT); + return typeof v === "number" && Number.isInteger(v) ? v : undefined; +} + +// report-accessors.ts +/** `@spine`: the to-one path to the entity whose rows supply the report's rows. */ +export function reportSpine(obj: MetaData): string | undefined { + const v = obj.attr(OBJECT_REPORT_ATTR_SPINE); + return typeof v === "string" && v !== "" ? v : undefined; +} +``` + +- [ ] **Step 5: Regenerate.** + +```bash +bun run scripts/generate-embedded-metamodel.ts +bun run scripts/regen-expected-registry.ts +bun run scripts/regen-metamodel-docs.ts +bun scripts/build-site-reference.ts +node scripts/check-metamodel-version.mjs # passes with no --set: 1.1 stays +node scripts/check-metamodel-version.mjs --explain # every change ADDITIVE +``` + +Read the `expected-registry.json` diff by hand: three attribute entries added (one `spine`, two `default`), nothing else. `default` on a measure must print `valueType` `int`, not `null` (`null` is what a field's untyped `@default` prints). + +- [ ] **Step 6: Run.** `cd server/typescript && bun test packages/metadata/test/reporting-registry.test.ts packages/metadata/test/object-definition-completeness.test.ts packages/metadata/test/registry-conformance.test.ts packages/metadata/test/metamodel-docs-conformance.test.ts packages/metadata/test/object-definition-embed.test.ts` — PASS. `registry-coverage.test.ts` lists the two attributes as untested until Task 3; confirm the ratchet passes. +- [ ] **Step 7: Commit (local).** `git commit -m "feat(metamodel): register @spine on object.report and @default on measures (TypeScript)"`. Stage the files by name; never `git add -A`. + +--- + +### Task 2: TypeScript loader rules R8, R9, M7, M8 + +**Files:** +- Modify: `server/typescript/packages/metadata/src/loader/reporting-validation.ts` (`checkReport`, `checkMeasure`, `checkAggregateColumns`, `walkToOneVia`) +- Test: `server/typescript/packages/metadata/test/reporting-validation.test.ts` + +**Interfaces:** +- Consumes: Task 1's constants and accessors. +- Produces: nothing exported. `walkToOneVia` gains a wording argument; `checkAggregateColumns` returns the single `@of` field when rules M1 to M4 all passed, else `undefined`. + +- [ ] **Step 1: Write the failing tests,** one per Table B row plus the order rules, using the builders the file already has. Assert the code and that the message names the report or measure. Cases: a to-many spine hop (R8); a spine whose owner is another entity (R8); a spine hop that names nothing (R8); no dimensions (R9); a dimension with no `@via` (R9); a time dimension over a fact column (R9); a dimension through a second reference to the same entity (R9); the same join written with the relationship name on one side and the reference name on the other (R9, Review Focus 5); a legal time dimension over a spine-entity column; a two-hop spine with a dimension beyond it; `@default` on a `count` (M7); on a `max` of a timestamp (M8); on a `sum`, an `avg`, a `min` of an int and a ratio (clean); a measure that breaks M4 **and** declares `@default` reports M4 only; a report whose `@spine` fails R8 does not also report R9; an inherited spine (owner written as the abstract base). +- [ ] **Step 2: Run and see them fail.** `cd server/typescript && bun test packages/metadata/test/reporting-validation.test.ts` +- [ ] **Step 3: Implement.** In `checkReport`, after R7 and before the `@filter` check: + +```ts + // R8 — @spine is a to-one path from @from: rule D2's walk, started at @from. + const spine = reportSpine(report); + if (spine !== undefined) { + const terminal = walkToOneVia( + { root, host: from, declaring: report, label, suffix: "", sink }, + spine, + (message) => err(`: ${message}`), + { attr: OBJECT_REPORT_ATTR_SPINE, start: `@from '${fromKey}'` }, + ); + // R9 — every listed dimension is reached through the spine. Skipped when R8 failed. + if (terminal !== undefined) { + const spineHops = splitDotted(spine)?.path ?? []; + const items = reportDimensionItems(report); + if (items.length === 0) { + err( + `: @spine '${spine}' needs at least one dimension. The report's rows are the dimension tuples of ` + + `'${terminal.resolutionKey()}'; with no dimension it would be one totals row.`, + ); + } + for (const item of items) { + const dim = childOfType(from, TYPE_DIMENSION, item.name); + if (!(dim instanceof MetaDimension)) continue; // R2 already reported it + const via = dim.via(); + const hops = via === undefined ? undefined : splitDotted(via)?.path; + if (hops !== undefined && spineHops.every((h, i) => hops[i] === h)) continue; + err( + via === undefined + ? `: dimension '${item.name}' is read from @from '${fromKey}', so it has no value in a row that has ` + + `no facts. With @spine '${spine}' every dimension must be reached through it: declare the ` + + `dimension over a field of '${terminal.resolutionKey()}' (or an entity to-one from it) with an ` + + `@via that begins '${spine}'.` + : `: dimension '${item.name}' is reached by @via '${via}', which does not begin with the hops of ` + + `@spine '${spine}'. Hop names are compared as written; write the same hops.`, + ); + } + } + } +``` + +`walkToOneVia`'s fourth argument replaces the two places it says `@via` and "the owning entity '…'". Its default is the present wording, and **every existing D2 message stays byte-identical** (the existing tests and the ports assert them). + +In `checkMeasure`, after `checkAggregateColumns`: + +```ts + // M7 / M8 — where a @default can apply. Only when M1–M4 passed: one mistake, one error. + if (clean && measure.attr(REPORTING_ATTR_DEFAULT) !== undefined) { + const agg = measure.agg(); + if (agg === AGG_COUNT) { + err(`@default cannot apply to @agg: count. A count is never null (it is 0 when nothing matches); remove @default.`); + } else if ((agg === AGG_MIN || agg === AGG_MAX) && ofField !== undefined && !NUMERIC_FIELD_SUBTYPES.includes(ofField.subType)) { + err( + `@default is a number, but @agg '${agg}' of '${measure.ofColumns()[0]}' is a field.${ofField.subType}. ` + + `A default is supported on numeric measures only.`, + ); + } + } +``` + +`clean` and `ofField` come from the changed return of `checkAggregateColumns`. The presence test reads the raw attribute, not `defaultValue()`, so a mistyped value on a `count` still reports M7 beside the type error. + +- [ ] **Step 4: Run.** `cd server/typescript && bun test packages/metadata` — PASS, every existing loader test included. +- [ ] **Step 5: Commit (local).** `git commit -m "feat(metadata): validate @spine and a measure @default at load (TypeScript)"`. + +--- + +### Task 3: Shared conformance fixtures + +**Files:** +- Create: `fixtures/conformance/reporting-spine-and-default/{input/meta.shop.json,expected.json,providers.json}` +- Create: `fixtures/conformance/reporting-spine-inherited/…` +- Create: the eight `fixtures/conformance/error-*/{input/…,expected-errors.json,providers.json}` of Table B +- Modify: `fixtures/registry-conformance/coverage-report.json`; `fixtures/conformance/CAPABILITIES.json` +- Modify (counts, 364 → 374): `AGENTS.md:78`, `README.md:264`, `docs/CONFORMANCE.md` (the metamodel row, its `###` heading, the totals line, and the reporting fixture-prefix row), `examples/showcase/site-payload.json` + +- [ ] **Step 1: Write the positive input,** package `acme::shop`, `providers.json` = `["metaobjects-core-types","metaobjects-db"]`: + - `Catalog` (`id`, `name` required), `Program` (`id`, `title` required, `publishedAt` timestamp, `catalogId`, `identity.reference` `fkCatalog`, `relationship.association` `catalog` to-one), `Purchase` (`id`, `programId`, `customerEmail`, `amountCents` currency, `status`, `purchasedAt`, `identity.reference` `fkProgram`, `relationship.association` `program` to-one). + - On `Purchase`: segment `active`; dimensions `programId` (`Program.id` via `Purchase.program`), `programTitle`, `publishedAt` (time, `Program.publishedAt` via `Purchase.program`, grains `month, year`), `purchasedAt` (time, on the fact, grains `day, month`), `programByRef` (`Program.title` via `Purchase.fkProgram`), `catalogName` (`Catalog.name` via `Purchase.program.catalog`); measures `purchases` (count), `revenue` (sum, `@default: 0`), `avgAmount` (avg, `@default: 0`), `smallest` (min of `amountCents`, `@default: -1`), `lastPurchaseAt` (max of `purchasedAt`, no default), `revenuePerPurchase` (ratio, `@default: 0`). + - Reports: `ProgramSales` (`@spine: "Purchase.program"`, dimensions `programId, programTitle, publishedAt:month, catalogName`, every measure, `@segment: active`, a `source.rdb` view); `CatalogSales` (`@spine: "Purchase.program.catalog"`, dimension `catalogName`, measures `purchases, revenue`); `SalesByDay` (no spine: `purchasedAt:day`, `revenue`), so a default without a spine is in the fixture. +- [ ] **Step 2: Produce `expected.json`** from the TypeScript canonical serializer as `spec/conformance-tests.md` describes, and read it by hand: `@spine` is a string, each `@default` is a JSON integer (`-1` included). +- [ ] **Step 3: `reporting-spine-inherited`:** an abstract `BaseEvent` declaring the reference, the two dimensions and a defaulted `sum`; `WorkoutEvent extends BaseEvent`; a report over `WorkoutEvent` with `@spine: "BaseEvent.program"`. +- [ ] **Step 4: Each error fixture** is the positive input with one change, and `expected-errors.json` in the format of `fixtures/conformance/error-report-segment-unresolved/expected-errors.json`: + +| Fixture | The one change | Code, on | +|---|---|---| +| `error-report-spine-to-many` | add a to-many relationship, declared as `error-dimension-via-to-many` declares its own, and point `@spine` at it | `ERR_INVALID_REPORT`, the report | +| `error-report-spine-not-from` | `@spine: "Program.catalog"` on `ProgramSales` | `ERR_INVALID_REPORT`, the report | +| `error-report-spine-no-dimensions` | remove `@dimensions` from `CatalogSales` | `ERR_INVALID_REPORT`, the report | +| `error-report-spine-dimension-off-spine` | add `purchasedAt:day` to `ProgramSales` | `ERR_INVALID_REPORT`, the report | +| `error-report-spine-dimension-other-path` | add `programByRef` to `ProgramSales` | `ERR_INVALID_REPORT`, the report | +| `error-measure-default-on-count` | `@default: 0` on `purchases` | `ERR_INVALID_MEASURE`, the measure | +| `error-measure-default-non-numeric` | `@default: 0` on `lastPurchaseAt` | `ERR_INVALID_MEASURE`, the measure | +| `error-measure-default-not-integer` | `revenue` `@default: 0.5` | `ERR_BAD_ATTR_VALUE`, the measure | + +**UNVERIFIED:** that a fractional number on an `attr.int` is `ERR_BAD_ATTR_VALUE` in TypeScript (the existing `error-attr-wrong-type` fixture covers a string on an `attr.int`, not a fraction). Run it first. If TypeScript accepts `0.5`, tighten its integer check in the same task and say so in the commit; every other port then meets the fixture in its own task. + +- [ ] **Step 5: Run.** `cd server/typescript && bun test packages/metadata/test/conformance.test.ts`. A jsonPath mismatch means the error is attached to the wrong node: fix the validator's `source`, not the fixture. +- [ ] **Step 6: Counts and coverage.** + +```bash +bun server/typescript/packages/conformance/bin/conformance.ts manifest fixtures/conformance +bun run site:payload && bun scripts/build-site-payload.ts --check +cd server/typescript && MO_UPDATE_COVERAGE_SNAPSHOT=1 bun test packages/metadata/test/registry-coverage.test.ts +``` + +Edit the four count sites to 374 first. `AGENTS.md` is loaded into every agent session: change the number and nothing else. + +- [ ] **Step 7: Commit (local).** `git commit -m "test(conformance): @spine and measure @default fixtures (2 positive, 8 error cases)"`. The other ports' conformance lanes are red from here until their own tasks. + +--- + +### Task 4: `reportShape` — Table C in TypeScript + +**Files:** +- Modify: `server/typescript/packages/metadata/src/core/reporting/report-shape.ts`, `core/reporting/report-accessors.ts` +- Test: `server/typescript/packages/metadata/test/report-shape.test.ts`, `test/report-read-model.test.ts` + +**Interfaces:** +- Produces: `reportSpineHops(report: MetaObject, from: MetaObject, root: MetaRoot): string[] | undefined` — the hop names of `@spine`, read as `reportingViaHops` reads a `@via` (owner resolved in the report's package, must be `from` or an entity it extends). `undefined` when there is no `@spine`. +- Produces: `measureDerivedSubType(measure: MetaMeasure, from: MetaObject, root: MetaRoot): string` — Table B's subtype for one measure, listed in a report or not. `measureField` calls it; Task 5 calls it for a ratio operand. +- `ReportShape` and `ReportField` keep their members. `required` changes value only as Table C says. + +- [ ] **Step 1: Write the failing tests:** each Table C row, with an inline model. Include: a dimension over the spine entity's `id` (no `@required`, a member of `identity.primary`) is required; over a `@required` title is required; over an optional column is not; a dimension one hop beyond the spine is not, even when its field is `@required`; the **same dimensions in a report without `@spine`** keep today's values (the `id` one is `false`); a `sum`, an `avg`, a `min` and a ratio with `@default` are required, without it are not; `count` is required either way. In `report-read-model.test.ts`: a defaulted measure's detached field has `@required: true`. +- [ ] **Step 2: Run and see them fail.** `cd server/typescript && bun test packages/metadata/test/report-shape.test.ts packages/metadata/test/report-read-model.test.ts` +- [ ] **Step 3: Implement.** In `dimensionField`: + +```ts + const spine = reportSpineHops(report, from, root); + let required: boolean; + if (spine === undefined) { + required = vialess && of.attr(FIELD_ATTR_REQUIRED) === true; // unchanged + } else { + const via = dim.via(); + const hops = via === undefined ? undefined : reportingViaHops(via, reportingMemberOwner(dim, from), from, root); + const onSpine = hops !== undefined && hops.length === spine.length && hops.every((h, i) => h === spine[i]); + required = onSpine && (of.attr(FIELD_ATTR_REQUIRED) === true || isPrimaryKeyField(named, of)); + } +``` + +`named` is the entity `@of` names (the object `resolveReportingFieldRef` already resolves; return it beside the field). `isPrimaryKeyField(named, of)` is true when `of.name` is in the `@fields` of `named`'s `identity.primary`, read through resolving accessors (ADR-0039: an identity inherited from an abstract base counts). In `measureField`, every non-`count` return becomes `required: m.defaultValue() !== undefined`. + +- [ ] **Step 4: Run.** `cd server/typescript && bun test packages/metadata` — PASS. `report-shapes.json` is not regenerated here; `report-shapes-artifact.test.ts` must still pass unchanged, which is the no-churn proof for the six existing shapes. +- [ ] **Step 5: Commit (local).** `git commit -m "feat(metadata): a spine key and a defaulted measure are not nullable in a report's shape"`. + +--- + +### Task 5: Lowering — Table D in TypeScript + +**Files:** +- Modify: `server/typescript/packages/codegen-ts/src/projection/report-spec.ts`, `extract-report-spec.ts`, `report-ddl-emit.ts` +- Test: `server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts`, `report-ddl-emit.test.ts` + +**Interfaces:** +- `ReportAggregate` gains `readonly defaultValue?: { readonly value: number; readonly real: boolean }` (`real`: the derived subtype is `decimal`, `double` or `float`). +- The `ratio` arm of `ReportColumn` gains `readonly defaultValue?: number` (a ratio is always `real`). +- `ReportViewSpec` gains `readonly spineDepth?: number`: how many hops of the join tree's single root chain are the spine. Absent for a report without `@spine`. +- `spec.where` keeps its meaning (the report scope); the emitter decides where it is written. + +- [ ] **Step 1: Write the failing tests.** Extract: a spine report yields `spineDepth`, one root join, every join `left`; aliases equal those of the same report with `@spine` removed; the three refusals of Table D each throw naming the report; a spine hop with no `identity.reference` throws the existing hop error; an aggregate and a ratio carry `defaultValue`; an operand that is not listed in `@measures` carries its own. Emit, per dialect: the three bodies of Table D as goldens; `the report scope is in the join condition` (a spine report with `@segment` and `@filter` has no `WHERE`, and an `or` scope is parenthesised inside the `ON`); the two-hop chain; an onward join from the spine alias is `LEFT OUTER` even over a required reference; `COALESCE` wraps the cast (`COALESCE(CAST(SUM(x) AS BIGINT), 0)`); a negative default (`-1`, and `-1.0` on SQLite for a `real` measure); a defaulted operand inside a ratio; a one-to-one hop whose reference is held by the far entity, reversed. And **every existing golden in both files passes unedited**. +- [ ] **Step 2: Run and see them fail.** `cd server/typescript && bun test packages/codegen-ts/test/projection/extract-report-spec.test.ts packages/codegen-ts/test/projection/report-ddl-emit.test.ts` +- [ ] **Step 3: `extractReportSpec`.** After the existing `@from` checks: read `reportSpineHops`; walk it with the existing `walkViaPath([from.name, ...hops].join("."), root, packageOf(from), ctx)` and require the whole chain (the existing `viaHopError` otherwise); for each step's target entity, refuse one with no table (`isAbstract || !hasWritableRdbSource`) and one that `isTphSubtype`, naming the report, the spine and the entity. Pass `[spinePath, ...dimensionPaths]` to `pathsToJoins`, then set `joinType: "left"` on every node of the tree. `aggregateOf` adds `defaultValue` from `measure.defaultValue()` and `measureDerivedSubType`. Set `spineDepth: spinePath.length`. +- [ ] **Step 4: `emitReportViewDdl`.** `aggregate()` ends with: + +```ts +function withDefault(sql: string, dv: { value: number; real: boolean } | undefined, d: ReportDialect): string { + if (dv === undefined) return sql; + // SQLite: a REAL column keeps one storage class, so its default is a REAL literal. + return `COALESCE(${sql}, ${d === "sqlite" && dv.real ? `${dv.value}.0` : String(dv.value)})`; +} +``` + +The ratio arm wraps its whole quotient the same way with `real: true`. For the `FROM`, when `spec.spineDepth !== undefined`: + +```ts + // The spine chain: the single root, then its child on the chain, down to the spine entity. + const chain: JoinNode[] = []; + for (let n = spec.joinTree.joins[0]; n !== undefined && chain.length < spec.spineDepth; n = n.children[0]) chain.push(n); + const spine = chain[chain.length - 1]!; + let sql = ` FROM ${q(tableOf(spine.targetEntity), d)} ${spine.alias}`; + for (let i = chain.length - 1; i >= 0; i--) { + const parentAlias = i === 0 ? spec.joinTree.baseAlias : chain[i - 1]!.alias; + const parentTable = i === 0 ? options.baseTableName : tableOf(chain[i - 1]!.targetEntity); + // The row scope rides on the join that introduces @from: it filters facts, never spine rows. + const scope = i === 0 && spec.where !== undefined ? ` AND ${cond(spec.where, d)}` : ""; + sql += `\n LEFT OUTER JOIN ${q(parentTable, d)} ${parentAlias} ON ${onPredicate(chain[i]!, parentAlias, d)}${scope}`; + } + for (const child of spine.children) sql += "\n" + renderJoin(child, spine.alias, options); +``` + +`onPredicate` is the `ON` text `renderJoin` builds today, extracted so both callers share it; `tableOf` is its table lookup with the same "no table registered" error. No `WHERE` is written in this branch. The branch without `spineDepth` is the present code, untouched. + +- [ ] **Step 5: Run.** `cd server/typescript && bun test packages/codegen-ts/test/projection` — PASS. +- [ ] **Step 6: Commit (local).** `git commit -m "feat(codegen-ts): lower a report @spine and a measure @default to view SQL"`. + +--- + +### Task 6: Canonical reports, artifacts, value and idempotence tests + +**Files:** +- Modify: `fixtures/persistence-conformance/canonical/meta.fitness.json` +- Regenerate: `fixtures/persistence-conformance/canonical/schema.postgres.sql` (`bun run gen:schema`), `fixtures/persistence-conformance/report-shapes.json` (`bun run gen:report-shapes`), both from `server/typescript/packages/integration-tests` +- Modify: `server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts` (the hard-coded "six", line 39), `report-views-pg.test.ts`, `report-views-sqlite.test.ts`, `report-views-mysql.test.ts` + +- [ ] **Step 1: Extend the canonical model.** Append to `Week`'s children, after the last existing member: + +```json +{ "dimension.attribute": { "name": "programKey", "@of": "Program.id", "@via": "Week.fkProgram" } }, +{ "measure.aggregate": { "name": "totalMinutesOrZero", "@agg": "sum", "@of": "Week.durationMinutes", "@default": 0 } }, +{ "measure.ratio": { "name": "longShareOrZero", "@numerator": "longWeeks", "@denominator": "weeks", "@default": 0 } } +``` + +Append after `AssetActivity`, the last report: + +```json +{ "object.report": { "name": "ProgramRoster", "@from": "Week", "@spine": "Week.fkProgram", + "@dimensions": ["programKey", "programTitle"], + "@measures": ["weeks", "totalMinutes", "totalMinutesOrZero", "longShare", "longShareOrZero"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_program_roster" } } ] } }, +{ "object.report": { "name": "ProgramLongWeeks", "@from": "Week", "@spine": "Week.fkProgram", + "@dimensions": ["programKey"], "@measures": ["weeks", "totalMinutesOrZero"], "@segment": "long", + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_program_long_weeks" } } ] } }, +{ "object.report": { "name": "FitnessTotalsFilled", "@from": "Week", + "@measures": ["weeks", "totalMinutesOrZero", "longShareOrZero"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_fitness_totals_filled" } } ] } } +``` + +No existing node is edited. + +- [ ] **Step 2: Regenerate both artifacts and read the diffs.** `schema.postgres.sql`: three views appended with their fingerprint comments, bodies equal to Table D, no existing line changed. `report-shapes.json`: three entries appended; `ProgramRoster` reads `programKey` long required, `programTitle` string required, `weeks` required, `totalMinutes` not, `totalMinutesOrZero` required, `longShare` not, `longShareOrZero` required. +- [ ] **Step 3: Value tests, hand-computed.** Seed: program 1 with weeks of 30, 60, 90 and 60 minutes; program 2 with one week of 45; program 3 with none. + +| View | Expected rows | +|---|---| +| `v_program_roster` | `(1, Foundations, 4, 240, 240, 0.75, 0.75)`, `(2, Strength, 1, 45, 45, 0, 0)`, `(3, , 0, null, 0, null, 0)` | +| `v_program_long_weeks` | `(1, 3, 210)`, `(2, 0, 0)`, `(3, 0, 0)` | +| `v_fitness_totals_filled`, empty `weeks` | `(0, 0, 0)` | + +Add to `report-views-pg.test.ts` and `report-views-sqlite.test.ts`: those three; `the view has one row per program` (its row count equals `SELECT count(*) FROM programs`); `an isNull condition does not count the empty row` (inline model: a `count` and a `sum` whose `@filter` is `{ label: { isNull: true } }` read `0` and null for program 3); `with @spine a fact whose reference is null is in no row` (inline model with a nullable reference, the twin of the existing "NULL group" test); `a two-hop spine`; column types of the defaulted columns (`bigint` and `numeric` on Postgres; one `typeof()` per column on SQLite). Update the convergence tests from six views to nine: migrate from empty, then a second and third migrate propose nothing. +- [ ] **Step 4: MySQL.** In `report-views-mysql.test.ts`, the roster rows through `buildReportViews(root, { dialect: "mysql" })`, and that the defaulted columns are `NOT NULL` in `information_schema`. +- [ ] **Step 5: Run.** `cd server/typescript && bun test packages/integration-tests/test/report-shapes-artifact.test.ts packages/integration-tests/test/schema-artifact.test.ts packages/integration-tests/test/report-views-sqlite.test.ts packages/integration-tests/test/report-views-pg.test.ts packages/integration-tests/test/report-views-mysql.test.ts` — PASS. The Postgres and MySQL files need a database (`METAOBJECTS_TEST_PG_URL` / `METAOBJECTS_TEST_MYSQL_URL`, or the docker CLI); run them one at a time on this machine. +- [ ] **Step 6: Commit (local).** `git commit -m "test(persistence): canonical @spine and @default reports, value and idempotence tests"`. C#'s committed row classes and Kotlin's reference tables are stale from here until Tasks 9 and 11. + +--- + +### Task 7: Shared persistence scenarios, the TypeScript read, and the docs wording + +**Files:** +- Create: `fixtures/persistence-conformance/queries/report-spine-zero-rows.yaml`, `report-spine-scoped.yaml`, `report-default-empty.yaml` +- Modify: `fixtures/persistence-conformance/README.md` (the scenario list and count) +- Modify: `fixtures/codegen-noop/reporting/with/meta.shop.json` and `README.md` +- Modify: `server/typescript/packages/metadata/src/core/reporting/report-describe.ts`, `server/typescript/packages/codegen-ts/src/generators/report-doc.ts`, `server/typescript/packages/docs-site/src/builders/report-data.ts` +- Test: `server/typescript/packages/integration-tests/test/query.test.ts` (runner, expected unchanged), `metadata/test/report-describe.test.ts` (the described-attr lists, lines 190 to 191), `docs-site/test/reporting-site.test.ts`, `docs-site/test/coverage.test.ts`, `cli/test/unit/reporting-inert.test.ts` + +- [ ] **Step 1: Write the scenarios** in the format of `report-grouped-measures.yaml`, seed inline. Wire values follow the column types as in that file (a `bigint` is a string, a decimal a canonical decimal string): + +```yaml +name: report-spine-zero-rows +queries: + - name: every-program-has-a-row + op: list + entity: ProgramRoster + sort: [{ field: programKey, dir: asc }] + expect: + - { programKey: "1", programTitle: "Foundations", weeks: "4", totalMinutes: "240", totalMinutesOrZero: "240", longShare: "0.75", longShareOrZero: "0.75" } + - { programKey: "2", programTitle: "Strength", weeks: "1", totalMinutes: "45", totalMinutesOrZero: "45", longShare: "0", longShareOrZero: "0" } + - { programKey: "3", programTitle: "Mobility", weeks: "0", totalMinutes: null, totalMinutesOrZero: "0", longShare: null, longShareOrZero: "0" } + - name: count-is-the-number-of-programs + op: count + entity: ProgramRoster + expect: 3 + - name: the-default-is-filterable + op: list + entity: ProgramRoster + filter: { totalMinutesOrZero: { eq: 0 } } + expect: [ <the program 3 row> ] + - name: a-count-filter-drops-the-empty-rows + op: count + entity: ProgramRoster + filter: { weeks: { gt: 0 } } + expect: 2 + - name: sort-on-the-defaulted-measure + op: list + entity: ProgramRoster + sort: [{ field: totalMinutesOrZero, dir: asc }] + limit: 1 + expect: [ <the program 3 row> ] +``` + +`report-spine-scoped.yaml` lists `ProgramLongWeeks` (the Task 6 rows). `report-default-empty.yaml` lists `FitnessTotalsFilled` over an empty `weeks` table and expects `{ weeks: "0", totalMinutesOrZero: "0", longShareOrZero: "0" }`. No scenario sorts a nullable measure across a null row (Table G). +- [ ] **Step 2: Run the TypeScript runner.** `cd server/typescript && bun test packages/integration-tests/test/query.test.ts` — the three new scenarios PASS with no runtime change. If one fails, the defect is in Tasks 4 to 6, not in the runtime. +- [ ] **Step 3: The inert model.** Add to `fixtures/codegen-noop/reporting/with/meta.shop.json` a `@default` on one existing non-count measure, one dimension reached by `@via`, and one **sourceless** report with `@spine`. Run `cd server/typescript && bun test packages/cli/test/unit/reporting-inert.test.ts` — PASS unchanged: the generated tree still equals `without/`. +- [ ] **Step 4: The wording.** `report-describe.ts` is the one home for what a report page says. Add, with tests first: a defaulted measure's description ends `; \`0\` when there is nothing to aggregate` (the declared integer), and a report with `@spine` gains a rows sentence, `one row per distinct dimension tuple among the rows of \`Program\`, reached by \`Purchase.program\`, including those no \`Purchase\` refers to`, and its row scope reads `aggregating only <scope>` instead of `scoped to <scope>`. `report-doc.ts` and `report-data.ts` print them. Update the described-attr lists in `report-describe.test.ts` and whatever `docs-site/test/coverage.test.ts` and `reporting-site.test.ts` need so both attributes count as rendered. + +**UNVERIFIED:** the exact present phrases in `report-describe.ts` for a row scope, and the docs goldens that pin them. Read the file and its test before writing the new sentences; keep every existing sentence byte-identical for a report that uses neither attribute. + +- [ ] **Step 5: Run.** `cd server/typescript && bun test packages/metadata/test/report-describe.test.ts packages/docs-site/test packages/codegen-ts/test/generators` — PASS. +- [ ] **Step 6: Commit (local).** `git commit -m "test(persistence): @spine and @default read scenarios; describe them in meta docs"`. + +--- + +### Task 8: The REST sub-corpus and the TypeScript generated lane + +**Files:** +- Modify: `fixtures/api-contract-conformance/report/meta.json`, `seed.json`, `README.md` +- Regenerate: `fixtures/api-contract-conformance/report/schema.postgres.sql` (`bun run gen:report-api-schema` from `server/typescript/packages/integration-tests`) +- Create: `fixtures/api-contract-conformance/report/scenarios/list-spine.yaml`, `filter-on-defaulted-measure.yaml`, `sort-on-defaulted-measure.yaml` +- Modify: `server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts` (the hard-coded "thirteen", line 48), `api-contract-report.test.ts` (the report → route table) + +- [ ] **Step 1: Extend the model** by appending, never editing: entities `Product` (`id`, `name` required, primary identity) and `Sale` (`id`, `productId` required, `amountCents` long required, `identity.reference` `fkProduct`), with on `Sale` the dimensions `productId` (`Product.id` via `Sale.fkProduct`) and `productName`, the measures `sales` (count), `revenueCents` (sum) and `revenueOrZero` (sum, `@default: 0`), and the report `ProductRevenue` (`@from: Sale`, `@spine: "Sale.fkProduct"`, dimensions `productId, productName`, the three measures, view `v_product_revenue`). The `Invoice` entity, its four reports and their views are untouched. +- [ ] **Step 2: Seed.** `seed.json` gains `products` (three, the third with no sale), `sales`, and `reports.ProductRevenue`: + +```json +[ { "productId": 1, "productName": "Atlas", "sales": 2, "revenueCents": 30000, "revenueOrZero": 30000 }, + { "productId": 2, "productName": "Beacon", "sales": 1, "revenueCents": 4500, "revenueOrZero": 4500 }, + { "productId": 3, "productName": "Cinder", "sales": 0, "revenueCents": null, "revenueOrZero": 0 } ] +``` + +- [ ] **Step 3: Scenarios,** using existing assertion keys only: + - `list-spine.yaml`: `GET /api/product_revenues?sort=productId:asc` → the three rows; `withCount=1` → `total: 3`. + - `filter-on-defaulted-measure.yaml`: `?filter[revenueOrZero][eq]=0` → the `Cinder` row; `?filter[revenueCents][isNull]=true` → the same row; `?filter[sales][gt]=0&sort=productId:asc` → the other two. + - `sort-on-defaulted-measure.yaml`: `?sort=revenueOrZero:asc&limit=1` → the `Cinder` row; `?sort=revenueOrZero:desc&limit=1` → the `Atlas` row. + +**UNVERIFIED:** the served segment for `ProductRevenue`. Read it from the generated route file; do not assume `/api/product_revenues`. + +**UNVERIFIED:** how each lane's harness loads the base tables of `seed.json` (the present file has one, `invoices`). If a harness names `invoices`, it learns to load every top-level key but `reports`, parents first (`products` before `sales`). + +- [ ] **Step 4: Run the TypeScript lane.** `cd server/typescript && bun test packages/integration-tests/test/api-contract-report-corpus.test.ts packages/integration-tests/test/api-contract-report.test.ts` and the generated-lane test that runs `report/` — sixteen scenarios PASS, and the test that holds `seed.json`'s `reports` equal to what the views return passes for `ProductRevenue`. No generator is edited: if the generated read schema types `revenueOrZero` nullable, the defect is in Task 4. +- [ ] **Step 5: Commit (local).** `git commit -m "test(api-contract): a @spine report with a defaulted measure in the report/ sub-corpus"`. + +--- + +### Task 9: C# port + +**Files:** +- Modify (byte copies): `server/csharp/MetaObjects/SpecMetamodel/reporting.json`, `SpecMetamodel/object.json` +- Modify: `server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs`, `Core/Object/ObjectConstants.cs`, `Core/Reporting/ReportingSchema.cs` (`MeasureAggregateAttrs`, `MeasureRatioAttrs`, `ReportAttrs`), `Meta/MetaMeasure.cs`, `Core/Reporting/ReportAccessors.cs`, `Core/Reporting/ReportShape.cs` (`DimensionField`, `MeasureField`), `Loader/ValidationPasses.Reporting.cs` (`WalkToOneVia`, `CheckMeasure`, `CheckAggregateColumns`, `CheckReport`) +- Regenerate: `server/csharp/MetaObjects.IntegrationTests/Generated/` (three new row classes and `AppDbContext.g.cs`) +- Modify: `server/csharp/MetaObjects.IntegrationTests/Api/ReportFixture.cs` and `ReportGeneratedServerFactory.cs` if they name the `invoices` table +- Test: `server/csharp/MetaObjects.Conformance.Tests/{ReportingValidationTests,ReportingAccessorsTests,ReportShapeTests}.cs` (the hard-coded "six", line 48), `server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs` + +- [ ] **Step 1:** Copy the two JSON files; run `dotnet test server/csharp --filter "FullyQualifiedName~Conformance"` — expected FAIL: `SpecMetamodelEmbedTests` passes, the registry manifest and the ten new fixtures fail. +- [ ] **Step 2: Register** the two attributes in `ReportingSchema.cs`; add the constants and the accessors (`DefaultValue`, `ReportSpine`). Registry-conformance PASS. +- [ ] **Step 3: Port R8, R9, M7, M8** rule for rule from `reporting-validation.ts`, with the same message texts. Add the unit cases of Task 2 Step 1 to `ReportingValidationTests.cs`. +- [ ] **Step 4: Port Table C** into `ReportShape.cs`. C# has no `reportingViaHops`: add the hop split (the owner ends at the first `.` after the last `::`) beside the existing field-reference resolvers. `ReportShapeTests` byte-matches the regenerated `report-shapes.json`. +- [ ] **Step 5: Row types.** First read the generated member for an existing `count` (required today). Then add to `ReportRowCodegenTests.cs`: a defaulted `sum` is `long`, not `long?`; a defaulted ratio is `decimal`; the spine key is not nullable; the same measure without `@default` stays nullable. Regenerate `Generated/` and confirm `IntegrationFixtureDriftTests` passes with only the three new files and the `AppDbContext` additions. +- [ ] **Step 6: Lanes.** `dotnet test server/csharp` — the persistence query lane (three new scenarios), the `report/` REST lane (sixteen) and `ReportingInertTests` PASS. +- [ ] **Step 7: Commit (local).** `git commit -m "feat(csharp): @spine and measure @default"`. + +--- + +### Task 10: Java port + +**Files** (under `server/java/`): +- Modify: `metadata/src/main/java/com/metaobjects/reporting/ReportingConstants.java`, `object/MetaObject.java` (the `object.report` registration, lines 250 to 263, and an `ATTR_REPORT_SPINE` constant), `reporting/AggregateMeasure.java`, `reporting/RatioMeasure.java` (registration), `reporting/MetaMeasure.java`, `reporting/ReportAccessors.java`, `reporting/ReportShape.java` (`dimensionField`, `measureField`), `loader/ReportingValidation.java` (`walkToOneVia`, `checkMeasure`, `checkReport`) +- Modify: `metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java` (see the trap below) +- Modify: `integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java` and `generated/InMemoryReportRepositorySource.java` if they name `InvoiceStatusTotals` and its siblings one by one +- Test: `metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java` (its fixture-to-code map, lines 150 to 176), `reporting/ReportingTest.java`, `reporting/ReportShapeTest.java`, `reporting/ReportReadModelTest.java`, `codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java`, `omdb/src/test/java/com/metaobjects/manager/db/ReportReadTest.java` + +**The trap.** `RegistryManifest.java` prints `valueType: null` for any attribute **named** `default` (lines 267 and 635 to 637), because a field's `@default` is registered as a string and re-inferred from its text. A measure's `default` is registered as an `int` and must print `int`. Narrow the special case to the field registration, by owner type and name, not by name alone. The registry-conformance test is what catches it. + +- [ ] **Step 1:** `cd server/java && mvn -q -pl metadata -am test -Dtest='*Registry*Conformance*,*Conformance*'` — expected FAIL (the spec JSON is copied at build, so the manifest and the ten fixtures fail at once). Use `MAVEN_ARGS` for a repo override, never `MAVEN_OPTS`. +- [ ] **Step 2: Register** both attributes; fix the manifest special case; add the accessors. Registry-conformance PASS. +- [ ] **Step 3: Port R8, R9, M7, M8** with the same message texts; extend the fixture-to-code map and the unit cases. +- [ ] **Step 4: Port Table C** into `ReportShape.java` (add the hop split, as in C#). `ReportShapeTest` byte-matches `report-shapes.json`. +- [ ] **Step 5: Row types and reads.** A DTO component is boxed and `@NotNull` is the marker (`SpringDtoGenerator.java`, `validationAnnotations`): assert it is present on a defaulted measure and the spine key and absent on the same measure without `@default`. `ReportReadTest` reads a `@spine` view with an empty row through OMDB. +- [ ] **Step 6: Lanes.** `mvn -q -pl metadata,omdb,codegen-spring,integration-tests -am test` — persistence (three new scenarios), `report/` REST (sixteen) and `ReportingInertTest` PASS. +- [ ] **Step 7: Commit (local).** `git commit -m "feat(java): @spine and measure @default"`. + +--- + +### Task 11: Kotlin port + +Kotlin has no loader and no report-shape code of its own: it calls Java's (`KotlinExposedTableGenerator.kt:392`, `KotlinGenUtil.kt:683`). So this task is tests and reference tables. + +**Files** (under `server/java/`): +- Create: `integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/{ProgramRoster,ProgramLongWeeks,FitnessTotalsFilled}View.kt`, hand-written like the six beside them, with nullability spelled out per Table C +- Modify: the Kotlin `report/` lane's seeded repositories under `integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/` if they name reports one by one +- Test: `codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/{KotlinReportTableGeneratorTest,KotlinReportRestSurfaceTest,ReportingInertTest,RegistryManifestConformanceTest}.kt`, `integration-tests-kotlin/.../KotlinCodegenMatchesReferenceTest.kt`, `QueryScenarioRunner.kt` + +- [ ] **Step 1:** Add to `KotlinReportTableGeneratorTest`: a defaulted measure's Exposed column has no `.nullable()` and its data class property is not nullable; the spine key likewise; the same measure without `@default` keeps `.nullable()`. +- [ ] **Step 2:** Write the three reference tables; `KotlinCodegenMatchesReferenceTest` PASS. +- [ ] **Step 3: Lanes.** `mvn -q -pl codegen-kotlin,integration-tests-kotlin -am test` — registry manifest, persistence (three new scenarios), `report/` REST (sixteen), inert PASS. +- [ ] **Step 4: Commit (local).** `git commit -m "test(kotlin): @spine and measure @default through Exposed"`. + +--- + +### Task 12: Python port + +**Files** (under `server/python/`): +- Modify (byte copies): `src/metaobjects/spec_metamodel/reporting.json`, `spec_metamodel/object.json` +- Modify: `src/metaobjects/meta/core/reporting/reporting_constants.py`, `meta/core/object/object_constants.py`, `core_types.py` (the hand-declared report, `measure.aggregate` and `measure.ratio` attrs), `meta/core/reporting/meta_measure.py`, `report_accessors.py`, `report_shape.py` (`_dimension_field`, `_measure_field`), `loader/validate_reporting.py` (`_walk_to_one_via`, `_check_measure`, `_check_report`) +- Modify: `tests/integration/generated_report_app.py` if it names the `invoices` table or the reports one by one +- Test: `tests/unit/test_reporting_accessors.py`, `tests/test_report_shape.py` (the hard-coded "six", line 121), `tests/test_report_read_model.py`, `tests/codegen/test_report_router.py`, `tests/runtime/test_object_manager_report.py`, `tests/test_reporting_inert.py`; a new `tests/unit/test_reporting_validation_spine_default.py` for the Task 2 unit cases (Python has no reporting validation unit file today) + +Watch the naming inversion: Python `attr()` is OWN. Use the resolving form everywhere the TypeScript code calls `attr()`. And `bool` is a subclass of `int`: `default_value()` must refuse `True`. + +- [ ] **Step 1:** Copy the two JSON files; `cd server/python && uv run pytest -q tests/conformance` — expected FAIL (registry, ten fixtures). +- [ ] **Step 2: Register** both attributes in `core_types.py` (`value_type` `int` for `default`); add constants and accessors. Registry-conformance PASS. +- [ ] **Step 3: Port R8, R9, M7, M8** with the same message texts. +- [ ] **Step 4: Port Table C** into `report_shape.py` (add the hop split). `test_report_shape.py` byte-matches `report-shapes.json`. +- [ ] **Step 5: Row types and reads.** A defaulted measure and the spine key have no `| None` in the Pydantic row model (`entity_model.py`, `_field_line`); the same measure without `@default` keeps it. The `ObjectManager` reads a `@spine` view with an empty row. +- [ ] **Step 6: Lanes.** `uv run pytest -q` scoped to conformance, the query scenarios, `tests/integration/test_api_contract_report.py` and the inert test — PASS. +- [ ] **Step 7: Commit (local).** `git commit -m "feat(python): @spine and measure @default"`. + +--- + +### Task 13: The no-churn proof + +**Files:** none modified. This task produces evidence and stops the branch if any of it is missing. + +- [ ] **Step 1: Appended, never edited.** For each shared artifact, compare the branch to `origin/main` structurally: + +```bash +# SQL: no line of an existing statement removed or changed. +git diff origin/main -- fixtures/persistence-conformance/canonical/schema.postgres.sql \ + fixtures/api-contract-conformance/report/schema.postgres.sql | grep -c '^-[^-]' # must print 0 +# JSON: the first N entries are deep-equal to origin/main's. +bun -e 'const {execSync}=require("child_process");const p="fixtures/persistence-conformance/report-shapes.json"; +const a=JSON.parse(execSync(`git show origin/main:${p}`)).reports,b=require("./"+p).reports; +if(JSON.stringify(b.slice(0,a.length))!==JSON.stringify(a))throw new Error("an existing shape changed")' +``` + +The same deep-equal check for the existing children of `Week`, `Program` and the six reports in `meta.fitness.json`, and for `Invoice`, its four reports, `invoices` and the three existing `reports` entries in the `report/` corpus. + +- [ ] **Step 2: No existing expectation edited.** `git diff origin/main --stat` over `fixtures/**/expected*`, `fixtures/**/scenarios/**`, `fixtures/**/queries/**`, every committed generated tree (`server/csharp/MetaObjects.IntegrationTests/Generated/`, the Kotlin `tables/`) and every golden test: only new files, the `AppDbContext.g.cs` additions, and count literals (six → nine, thirteen → sixteen, 364 → 374). +- [ ] **Step 3: Inert.** The five inert tests pass: a sourceless `@spine` report and a defaulted measure on an entity generate nothing in any port. +- [ ] **Step 4: Codegen-compile gate.** It generates from `meta.fitness.json`, which now carries three more served reports: run each port's gate and confirm the new row types compile. +- [ ] **Step 5:** Record the four results in the PR description. No commit. + +--- + +### Task 14: Docs, skills, changelog, counts + +**Files:** +- Modify: `docs/features/reporting.md` — the registered table (`@spine`, `@default`); the example; two new sections, "Rows from a dimension's entity" and "A default for an empty measure", holding Tables D, E and G in prose; "The columns you get" (the two never-null rows); the rule tables (R8, R9, M7, M8, the integer check); "What differs by engine" (SQLite's `0.0`); "Known limits" (the spec §9 list); "What the corpus gates" (nine persistence scenarios, sixteen REST) +- Modify: `docs/ports/{typescript,csharp,java,kotlin,python}.md` (each port's Reports section; `kotlin.md` states the nullable rule and must gain the two exceptions), `docs/features/api-contract.md` (Reports), `docs/recipes/mysql.md` (Reports: a `@spine` body is valid MySQL as emitted) +- Modify: `agent-context/skills/metaobjects-authoring/references/reporting.md` ("the columns you get", "nulls and zeros", "known limits") and `SKILL.md` (the reporting section); the five `agent-context/skills/metaobjects-codegen/references/*.md` only where they state a report column's nullability +- Regenerate: `fixtures/agent-context-conformance/**` with `bun server/typescript/packages/sdk/scripts/regen-agent-context-conformance.ts` +- Modify: `CHANGELOG.md` `[Unreleased]` — one new entry; `docs/CONFORMANCE.md` (persistence 39 → 42, api-contract 78 → 81 with report 13 → 16); `fixtures/persistence-conformance/README.md` +- Check, and edit only what this change made wrong: `spec/roadmap.md` (the FR-044 rows), `.claude/rules/cross-language-porting.md` (it says "the 27 `reporting` conformance fixtures"), the project's own requirements ledger + +- [ ] **Step 1:** Write the docs. The authoring skill must teach the two questions an agent gets wrong: *do I want the rows that have no facts?* (then `@spine`, and every dimension goes through it) and *is zero true here, or is null?* (then `@default`, and never on a count). +- [ ] **Step 2:** The CHANGELOG entry states: the two attributes; `metamodelVersion` stays 1.1; nothing changes for a model that uses neither; the four decisions of "What merging this plan decides"; and that an adopter with owned generators needs no resync for this (no generator changed). +- [ ] **Step 3: Run.** `bun run site:payload && bun scripts/build-site-payload.ts --check && bun test scripts/site`, `cd server/typescript && bun test packages/sdk/test/agent-context-conformance.test.ts`. +- [ ] **Step 4: Commit (local).** `git commit -m "docs(reporting): @spine and measure @default"`. + +--- + +### Task 15: Full CI, review, gate + +- [ ] **Step 1:** Rebase on `origin/main`. Another change is editing the TypeScript report generators and the spec's worked example; resolve by keeping both, and re-run Tasks 6 and 8's regenerations if a canonical model moved under them. +- [ ] **Step 2:** `scripts/ci-local.sh` (full, no flags), once. Expected green in every lane, including `gates` (metamodel-version, site payload, leak scan, publish-set parity). +- [ ] **Step 3:** A fresh reviewer over `git diff origin/main..HEAD`, briefed with this plan's Review Focus and nothing else. Fix what it finds. +- [ ] **Step 4:** Hand the branch to the validation gate. Do not push to `main`. + +--- + +## No-churn and back-compat proof + +| Claim | What proves it | +|---|---| +| A model that declares neither attribute generates the same files | No generator is edited in any port. The `without/` model of `fixtures/codegen-noop/reporting/` is compared file by file in all five inert tests | +| A sourceless `@spine` report and a defaulted measure are inert | The `with/` model gains both and still equals `without/` in every port's inert test | +| The six existing report views keep their SQL | The lowering's branch without `spineDepth` is the present code; every golden in `report-ddl-emit.test.ts` and `extract-report-spec.test.ts` passes unedited; Task 13 Step 1 finds no removed line in either `schema.postgres.sql` | +| The six existing shapes keep their bytes | Table C changes `required` only under `@spine` or `@default`; `report-shapes-artifact.test.ts` passes after Task 4 before anything is regenerated; Task 13's deep-equal check | +| Existing loader behaviour is unchanged | R8, R9, M7 and M8 fire only when one of the two attributes is present; every existing conformance fixture and every D2 message stays byte-identical | +| The canonical format is unchanged | Two attributes serialise as a string and an integer, forms every port already prints | +| `metamodelVersion` stays `1.1` | `check-metamodel-version.mjs` passes without `--set`; `--explain` classifies ADDITIVE; 1.1 has not been released | +| Existing REST scenarios are unchanged | The thirteen scenario files are not edited; `Invoice` and its reports are not edited; no runner changes its assertions | +| No runtime changes | No `ObjectManager`, OMDB, EF Core or Exposed read path is edited; the three new persistence scenarios pass on the existing read code in five ports | +| **Behaviour change** (unreleased vocabulary only) | A report that declares `@spine`, or lists a measure with `@default`, lowers to different SQL and a tighter row type. Nothing released carries either attribute | + +## Unverified items + +Each is the first step of the task that touches it. + +| Item | Task | +|---|---| +| That a fractional number on an `attr.int` is `ERR_BAD_ATTR_VALUE` in each port (the existing fixture covers a string, not a fraction) | 3, 9, 10, 12 | +| The present row-scope phrases in `report-describe.ts`, and the docs goldens that pin them | 7 | +| What `docs-site/test/coverage.test.ts` needs for a new attribute to count as rendered | 7 | +| The served segment for `ProductRevenue` | 8 | +| How each lane's harness loads the base tables of `report/seed.json`, and whether the Java, Kotlin and Python lanes name the reports one by one | 8, 9 to 12 | +| The exact generated spelling of a required read-model field in each port (Table F) | 9 to 12 | +| A one-to-one hop whose reference is held by the far entity, reversed in a spine chain: not executed on an engine, only reasoned from the shared `ON` predicate | 5 | +| Whether `integration-tests-kotlin`'s reference tables are checked structurally or byte for byte | 11 | +| What `scripts/site/counts.ts` counts | 3 | +| Whether any entry of the project's own requirements ledger describes report rows or measure nulls | 14 | + +## Open questions for the captain + +None blocks the build. The four choices under "What merging this plan decides" are the ones that would be costly to reverse after 1.1.0; each has a recommendation and the reasons above, and merging this plan rules on them. Stated as questions: + +1. **The grain under `@spine`.** One row per distinct dimension tuple among the spine entity's rows (recommended: no exception to what a report row is), or one row per spine row whatever is listed (rows a caller cannot tell apart)? +2. **A ratio's operand keeps its `@default` inside the ratio** (recommended: a measure reads the same wherever it is used, and both exporter targets behave this way), or the operand reads null there? +3. **`@default` is an integer** (recommended: every case found is zero, and widening later is additive), or a number typed per measure? +4. **The names `@spine` and `@default`.** Renaming either before 1.1.0 is a search and replace across this plan's change; after it, a deprecation. +5. **What this does not cover for the reference adopter.** Its day-by-day chart joins events to the schedule on three natural-key columns. With `@spine` the chart is expressible once the event carries a declared reference to the scheduled day; joining on the natural key stays out, as ruled. That is a data model change on the adopter's side, not something this plan can remove. diff --git a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md index 81c63777c..fe4936731 100644 --- a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md +++ b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md @@ -7,6 +7,12 @@ who need a full semantic layer get one through exporters to established tools. O the maintainer accepted all six open decisions as recommended (§8) and gave the explicit ADR-0023 agreement for the new vocabulary listed in §3.1, with the justifications written there. Ready for an implementation plan. +**Amended 2026-10-09:** two additions to the 1.1 vocabulary, requested by the maintainer after +a reference adopter's report pages were measured against Plans 1 to 3: a report's rows can come +from a dimension's entity (`@spine`, R8), and a measure can declare its value when it is null +(`@default`, R9). The register entries are in §3.2. Both are additive and `metamodelVersion` +stays `1.1`. Plan: +`docs/superpowers/plans/2026-10-09-fr-044-zero-rows-and-measure-defaults.md`. **Target:** metamodel `1.1` (additive vocabulary is a MINOR — `docs/compatibility-policy.md`, `scripts/check-metamodel-version.mjs`). Nothing here is a PATCH. **Relates to:** [ADR-0037](../../../spec/decisions/ADR-0037-metamodel-vocabulary-expansion-decision-framework.md) @@ -138,6 +144,24 @@ port's provider and in `fixtures/registry-conformance/expected-registry.json`. scope is the existing `@filter` (which carries R4's relative-date values), so no new attribute is needed for it. +### 3.2 ADR-0023 register amendment (2026-10-09) + +The maintainer asked for these two capabilities on 2026-10-09, for the 1.1 release, so that +vocabulary a first adopter needs does not force a second metamodel move straight after 1.1. +The names, the placement and the rules are this amendment's proposal (R8 and R9 below, and +the plan named in the header). Merging that plan is the ADR-0023 agreement to them. Nothing +else is added: `measure.derived` stays absent. + +| New name | Kind | Why it cannot be computed from existing metadata | +|---|---|---| +| `@spine` | attr, `string`, on `object.report` | Whether a report shows a dimension tuple that has no fact rows is the report author's choice, and the same dimensions serve both kinds of report: a sales table that lists only products with sales, and a catalogue table that lists every product with zero beside the ones that never sold. Nothing in the model says which a given report wants. The value is a to-one path because an entity can be reached from `@from` by more than one reference, so the entity's name alone would not say which rows are meant. | +| `@default` | attr, `int`, on `measure.aggregate` and `measure.ratio` | Null and zero mean different things ("no purchases" against "not applicable"), and which one a measure reads when it has nothing to aggregate is the author's statement about that measure. A `count` already derives its own zero, so the attribute is refused there. | + +`@default` reuses the name a field already has, under ADR-0037's "same concept, same +attribute name": on a field it is the value used when none is supplied, on a measure the +value used when there is none to aggregate. Unlike a field's, which follows the field's +type, a measure's is registered as an integer (see R9 for why). + ## 4. Requirements The examples use the reference adopter's model: `Purchase`, `Program` and `WorkoutEvent`. @@ -328,6 +352,115 @@ A FR-043 library, `stability: preview`, opt-in as `"libraries": ["reporting"]`: Composes only R1–R5 vocabulary; adds none. +### R8 — `@spine`: a report's rows from a dimension's entity (added 2026-10-09) + +Part of the 1.1 change set, with R1 to R5. + +```yaml +# on Purchase +- dimension.attribute: { name: programId, "@of": "Program.id", "@via": "Purchase.program" } +- dimension.attribute: { name: programTitle, "@of": "Program.title", "@via": "Purchase.program" } + +- object.report: + name: ProgramSales + "@from": Purchase + "@spine": "Purchase.program" + "@dimensions": [programId, programTitle] + "@measures": [purchases, buyers, revenue] + "@segment": active +``` + +**The gap.** A report has one row per dimension tuple that has fact rows. A reference +adopter's pages need the rows that have none: a published program with no purchases still +belongs in the per-program table, and a scheduled day nobody completed is an empty bar in a +chart whose whole point is the empty bars. R7's calendar spine covers the time case only. + +**Proposed shape.** One optional attribute on `object.report`. Without it nothing changes. +With it, the report's dimension tuples come from the rows of the entity at the end of the +path (the *spine entity*) instead of from the fact rows: one row per distinct dimension +tuple among **that entity's** rows, whether or not any row of `@from` refers to it. + +- `@spine` is a to-one path with `@via`'s grammar, `Owner.hop[.hop…]`. `Owner` is `@from` + (or an entity it extends) and every hop is to-one, exactly as for a dimension's `@via`. + The lowering needs a declared `identity.reference` on every hop, as it does for a dimension. +- **Every listed dimension is reached through the spine:** its `@via` begins with the + spine's hops. So a dimension is a column of the spine entity, or of an entity to-one from + it. A dimension read from the fact row (no `@via`, or a `@via` that leaves `@from` through + another reference) is refused, because it has no value in a row that has no facts. A + report with `@spine` lists at least one dimension. +- **A time dimension follows the same rule.** It is legal when its column belongs to the + spine entity or beyond (the month a program was published), and refused over a fact column + (the month of a purchase). Zero-filled time buckets remain R7's calendar spine. +- **`@segment` and `@filter` on the report scope fact rows, never spine rows.** A spine row + whose facts are all filtered out keeps its row. Both still name fields of `@from`. +- **A row with no facts** reads `0` for a `count`, null for `sum`, `avg`, `min` and `max` + (unless the measure declares R9's `@default`), and a ratio is computed from its operands as + they read. +- **A fact row whose reference is null, or matches no spine row, is in no row of the + report.** Without `@spine` such rows form a null group; with it there is no spine row to + hold them. +- The grain is unchanged: one row per distinct dimension tuple. To get exactly one row per + row of the spine entity, list a dimension over its identity (`programId` above). +- One spine per report. + +**ADR-0037 walk.** (0) Not derivable: the same dimensions serve a report that hides the empty +tuples and one that shows them, so the choice is the report's. (1) Not physical-only: it +changes the row set. (2) It configures an existing type and needs a reference (the path), so +it is an **attribute**; it is not a structural variant with its own generated shape, so not a +`@kind`, and it owns no behaviour of its own, so not a subtype. It lives on the report, not +on the dimension, because one dimension is listed by both kinds of report. + +**Admission.** RDB: the spine entity's table is the `FROM` and the fact table is +`LEFT JOIN`ed, with the report's row scope in the join condition. MongoDB: `$lookup` from +the spine collection, then `$group`. In memory: iterate the spine rows. Search: refused (no +join). + +### R9 — `@default`: a measure's value when it is null (added 2026-10-09) + +Part of the 1.1 change set, with R1 to R5. + +```yaml +- measure.aggregate: { name: revenue, "@agg": sum, "@of": "Purchase.amountCents", + "@segment": active, "@default": 0 } +- measure.ratio: { name: revenuePerBuyer, "@numerator": revenue, + "@denominator": buyers, "@default": 0 } +``` + +**The gap.** A `sum` of nothing and a ratio over zero are null by contract (R2), so every +caller defaults in the client where a hand-written view would have written +`COALESCE(…, 0)`. + +**Proposed shape.** One optional integer attribute on `measure.aggregate` and +`measure.ratio`: the value the measure reads when it would otherwise be null. That is the +case when nothing matched (an empty table, a row with no facts under R8, a measure `@filter` +or `@segment` that matched none of a group's rows), when every matched value is null, and +for a ratio whose denominator is zero or null. + +- Legal on a `sum` or `avg`, on a `min` or `max` over a numeric field, and on a ratio. +- **Refused on a `count`**: a count is never null (it is `0`), so a default could never + apply. +- **Refused on a `min` or `max` over a field that is not numeric** (a string, an enum, a + date): a default is a number. +- A measure with a `@default` is never null, so its derived report field is not nullable in + any port. +- A ratio's operand carries its own `@default` into the ratio: each operand is its full + expression, as it already is for conditions. + +**Why an integer.** Every case found is zero. An integer is a valid value of every numeric +type a measure can have (`long`, `currency` minor units, `decimal`, `double`), so the value +needs no rule per measure type. dbt MetricFlow's `fill_nulls_with` takes an integer, so the +§5 mapping stays lossless. A fractional default would need a spelling that five canonical +serializers and three SQL dialects agree on, for a case nobody has. Widening it later is +additive. + +**ADR-0037 walk.** (0) Not derivable: null and zero mean different things, and which one a +measure reads is the author's statement. (1) Not physical-only: it changes the value and the +nullability on the wire. (2) It configures an existing type: an **attribute**, under the +name fields already use for the same concept. + +**Admission.** RDB `COALESCE`; MongoDB `$ifNull`; Search the aggregation's `missing` value or +adapter code; in memory trivial. + ## 5. Mapping contract (core → Cube → MetricFlow) | MetaObjects | Cube | dbt MetricFlow | @@ -345,6 +478,8 @@ Composes only R1–R5 vocabulary; adds none. | `segment` | segment | metric `filter` / saved query filter | | relative filter `{ now: "-P7D" }` | query `dateRange` "last 7 days" | `{{ TimeDimension(...) }} >= dateadd(...)` | | `object.report` | a pre-aggregation (rollup) | a saved query | +| measure `@default: n` (R9) | a `number` measure `COALESCE({measure}, n)` | `fill_nulls_with: n` on the metric | +| report `@spine` (R8) | the spine entity's cube joins the fact cube `one_to_many`, and the rollup is rooted on it | none: `join_to_timespine` covers time only. The exporter must refuse a `@spine` report, never drop the attribute silently (§3 obligation 3) | ## 6. Dependencies and order @@ -354,7 +489,8 @@ Composes only R1–R5 vocabulary; adds none. 2. **R1, R2 (aggregate + ratio), R3, R4, R5** — one metamodel `1.1` change set: register in five ports, registry-conformance, loader fixtures, TS lowering for Postgres, SQLite/D1 and MySQL, persistence-conformance (read each report view in every port), api-contract - `report/` sub-corpus (list, filter, sort, paging, 405 on writes) in every port. + `report/` sub-corpus (list, filter, sort, paging, 405 on writes) in every port. **R8 and + R9** (added 2026-10-09) join this change set and are gated the same way. 3. **R2 `measure.derived`** — once FR-037 R5's arithmetic lands. 4. **R6** — Cube exporter. MetricFlow on first adopter demand (D5). 5. **R7** — the library. @@ -377,6 +513,13 @@ Composes only R1–R5 vocabulary; adds none. result equals the report view result on the conformance data. (MetricFlow: golden fixtures and `dbt parse`, when it is built.) - No-churn: the existing corpora produce byte-identical output. +- R8 and R9: loader error fixtures for a `@spine` over a to-many hop, a listed dimension not + reached through the spine, a `@spine` report with no dimensions, a `@default` on a `count`, + on a non-numeric `min`/`max`, and one that is not an integer. Value tests on Postgres and + SQLite with a spine row that has no facts: its row is present, a `count` is `0`, a `sum` + and a ratio are null without a `@default` and the declared value with one, and the view has + exactly as many rows as the spine entity has distinct dimension tuples. Every port reads + those rows and types a defaulted measure as not nullable. - The reference adopter's admin analytics are rebuilt from declarations, and its hand-written report code shrinks accordingly (measured and recorded in the release notes). @@ -403,3 +546,17 @@ The maintainer accepted every recommendation on 2026-10-03. - **Percentile/median, approximate distinct, conversion/funnel metrics, rolling windows, period-over-period**: advanced features present in only one or two surveyed tools. Available through the exported semantic layer. + +Parked with R8 and R9 (2026-10-09). Each is refused at load today, so admitting it later is +additive: + +- **Two spines in one report** (every program against every customer): a cross join whose + size is the product of both tables. Re-entry: an adopter case a single spine with onward + dimensions cannot serve. +- **A row scope on the spine entity** (only published programs). A report `@filter` names + `@from`'s fields. Until then, list the column as a dimension and filter it on the request. +- **A fact-row dimension beside a spine** (every program, split by purchase month). The row + for a program with no purchases would carry a null month. Zero-filled time buckets are R7. +- **A dimension over the reference column itself** (`Purchase.programId`) in a `@spine` + report. Declare the dimension over the spine entity's identity instead. +- **A fractional or non-numeric `@default`.**