From ed08232a57a5a64fbbcd4276b215ccefd0b0cdbc Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 14:42:38 -0400 Subject: [PATCH] =?UTF-8?q?docs(fr-044):=20Plan=203=20=E2=80=94=20generate?= =?UTF-8?q?d=20read=20routes,=20typed=20rows=20and=20docs=20pages=20for=20?= =?UTF-8?q?reports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The implementation plan after Plan 2 (report view lowering and reads in every port). It covers a generated list route for a view-backed object.report in five ports, the api-contract report/ sub-corpus, the TypeScript, Java and Python generators no longer skipping reports, and model and API pages for reports in meta docs. A report is served as a keyless read-only projection is served today: each port hands the read model it already has to its existing read-only generators. The contract is stated as tables the ports copy: which reports are served, the REST surface, what may be filtered and sorted, the wire encoding of each derived subtype, what each port generates, the corpus (model, seed, twelve scenarios in full) and the docs pages. The corpus model was loaded with the real loader and its three views and every query behind an expected row were run on Postgres 16. Two throwaway spikes fed the read model through every TypeScript and Python generator, and what they emitted and what broke is what the tasks are written from. HTTP bodies were not executed: no port serves a report yet. Cited paths and names were read in the tree; what could not be confirmed is marked UNVERIFIED and listed. Seven questions for the maintainer are at the end. Found while verifying, and folded into the tasks: TypeScript and Python mount item routes for a keyless projection against the documented contract, and TypeScript types a decimal in a view read schema as a number while the value is a string. --- ...-10-04-fr-044-plan-3-report-read-routes.md | 1161 +++++++++++++++++ 1 file changed, 1161 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-04-fr-044-plan-3-report-read-routes.md diff --git a/docs/superpowers/plans/2026-10-04-fr-044-plan-3-report-read-routes.md b/docs/superpowers/plans/2026-10-04-fr-044-plan-3-report-read-routes.md new file mode 100644 index 000000000..75ceb6882 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-fr-044-plan-3-report-read-routes.md @@ -0,0 +1,1161 @@ +# FR-044 Plan 3 — Generated read routes, typed rows and docs pages for reports + +> **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:** Serve every view-backed `object.report` over the cross-port REST contract in all five ports (a list route with filter, sort and paging on the derived fields, and a `405` on write), gate it with a new api-contract `report/` sub-corpus, stop the TypeScript, Java and Python generators from skipping reports, and give reports model and API pages in `meta docs`. + +**Architecture:** A report is served exactly as a keyless read-only projection is served today. Each port already has a detached, projection-shaped read model of a report (Plan 2); this plan hands that read model to the port's existing read-only generators instead of teaching each generator what a report is. Two things are added to make that work: every derived field is marked filterable on the read model, and the two ports whose read-only surface cannot be keyless yet (TypeScript and Python) learn to be. No SQL changes: the lowering Plan 2 landed is not edited. + +**Tech Stack:** TypeScript (Bun, Drizzle, Fastify, Hono, TanStack Query), C# (.NET, EF Core, ASP.NET Minimal API), Java (Maven, Spring MVC), Kotlin (KotlinPoet, Exposed, Spring MVC), Python (pytest, Pydantic, FastAPI). Postgres 16 for the full-stack lanes. + +**Spec:** `docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md` (R5 "Read-only everywhere", §3 obligation 4, §7 acceptance "Every port's generated route lists a report with `?filter` and `?sort` on derived fields, and answers writes `405`"). Plan 2: `docs/superpowers/plans/2026-10-03-fr-044-plan-2-report-view-lowering.md`. What Plans 1 and 2 shipped: `docs/features/reporting.md`. The REST contract: `docs/features/api-contract.md`. + +**This is Plan 3 of 5.** Not in this plan: Plan 4 (Cube exporter), Plan 5 (`reporting` library). Also out of scope: #395, #222, #8, #393. + +## How this plan was verified + +Every path, function, test file and helper cited below was read in the tree at `e2456aa23` (the Plan 2 merge), unless it is marked **UNVERIFIED**. Three things were executed, not only read: + +- **Two throwaway spikes, reverted.** The TypeScript runner (`codegen-ts/src/runner.ts:413`) and the Python runner (`codegen/runner.py:106`) were patched to swap each view-backed report for its read model, and every registered generator was run over `fixtures/codegen-noop/reporting/` and `fixtures/persistence-conformance/canonical/meta.fitness.json`. What came out, and what broke, is what Tasks 3 and 9 are written from. The TypeScript codegen-compile gate was run against the patch; its only report failures were the six `findById` functions (see the table below). +- **The corpus model.** The `report/meta.json` in Table F was loaded with the real TypeScript loader (zero errors), its shapes came from `reportShape`, its route segments from `pluralize(toSnakeCase(name))`, and its schema from `generateCanonicalSchemaSql`. +- **The SQL.** The three views and every query behind a scenario's expected rows were run on PostgreSQL 16.15 and returned the rows shown in Table F. + +**Not executed:** the HTTP responses. No port serves a report yet, so the expected bodies in Table F are the SQL rows above written in the wire encodings of Table D. The TypeScript lane (Task 4) is the first thing that runs them. + +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. + +What the code does that the documents do not say, found while verifying. Each one changes a task below: + +| The documents say | What the code does | +|---|---| +| "A keyless projection mounts no `/{id}` route" in all five ports (`docs/features/api-contract.md`, and the `typescript.md` and `python.md` references of the `metaobjects-codegen` skill) | True in C#, Java and Kotlin only. TypeScript's `mountReadOnlyCrudRoutes` (`runtime-ts/src/drizzle-fastify/mount-read-only.ts`, and the Hono twin) mounts `GET :id` and three item refusals unconditionally; when the view has no `id` column the `GET` builds its query with no `WHERE` and so answers the view's first row (read in the code, not executed). Python's `_render_readonly_router` (`router_generator.py:1456`) always emits the item routes and a `find_by_id(id: int)` on the seam. | +| TypeScript generates a read-only finder pair for a projection | `renderProjectionQueriesFile` (`codegen-ts/src/templates/queries-file.ts:154`) always emits `findById` reading `.id`. For an object with no `id` field that does not compile. | +| `field.decimal` is a `string` in TypeScript (`docs/features/field-types.md`; `column-mapper.ts:737`) | The read schema of a view says `z.number()` for a decimal (`codegen-ts/src/templates/field-meta.ts:166`), while Drizzle's `numeric` column reads a string. Every `avg` and every ratio of a report is a decimal, so a report makes this visible on its first row. | +| A filter allowlist is "the object's `@filterable` fields" | True in all five ports. A report declares no fields, so its derived fields carry no `@filterable`, and the spike's allowlists came out empty in both TypeScript and Python. | +| A sort allowlist follows `@sortable` | TypeScript only (`filter-shared.ts`). C#, Java, Kotlin and Python allow a sort on every orderable scalar field regardless of `@sortable`. | +| The api-contract corpus pins the wire format | It never asserts a `field.date` or a `field.decimal` literally. The Java runner's `structuralEquals` compares numbers by `longValue()`, so it could not tell `0.4` from `0`. | + +## Global Constraints + +- **No new vocabulary.** Nothing is registered. `metamodelVersion` stays `1.1`; `fixtures/registry-conformance/expected-registry.json` is not touched. `@filterable` is an existing registered attribute of `field.*`; this plan sets it on detached read-model fields only, never on a `dimension`, `measure` or `object.report` node. Anything outside spec §3.1 needs a new agreement. +- **No query-time engine.** A route lists the compiled view. No request parameter picks dimensions, measures or a grain. +- **A report is served only when it declares `source.rdb` with `@kind: view`.** A sourceless report stays inert in every generator and mounts nothing. +- **Every measure belongs to `@from`; dimension `@via` is to-one only; joins follow the projection rule unchanged.** This plan does not edit the lowering: `extract-report-spec.ts`, `report-ddl-emit.ts`, `time-sql.ts` and `build-projection-views.ts` keep their bytes. +- **UTC only.** No time-zone vocabulary, no time-zone request parameter. +- **Numeric precision is engine-native.** No scenario asserts the spelling of a decimal. +- **`measure.derived` does not exist.** +- **View SQL is produced by TypeScript only** (ADR-0015). No other port emits SQL for a report, in product code or in a test harness: the C# lane executes a committed TypeScript-produced artifact, and the Java, Kotlin and Python lanes seed rows behind their seam. +- **A report has no identity.** No get-by-id route, no write route, no `findById` on any generated seam. +- **The route segment rule is unchanged:** the object name, `snake_case`d, then pluralized (`docs/features/api-contract.md`). No report-specific spelling. +- **Default physical table-name pluralization is frozen** (1.0.13). This plan derives no physical name. +- **Routes, DTOs, allowlists, hooks and controllers are reference helpers** (ADR-0034 Amendment 3). TypeScript's ejectable copies under `codegen-ts/src/reference/` change in the same commit as the package generators; `reference-byte-identical.test.ts` holds them together. +- **No-churn:** a model with no `object.report` produces byte-identical output in every generator and every docs surface, with the two exceptions open questions 4 and 5 name (a keyless projection in TypeScript and Python; a decimal in a TypeScript view read schema). +- ADR-0039: read effective properties with resolving accessors. Any `own*()` call carries a comment naming its sanctioned case. Python `attr()` is OWN. +- 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. +- **Release hold continues:** `main` carries `metamodelVersion 1.1`, so no 1.0.x PATCH is cut from it. +- Do not push until every port is green. The corpus lands in Task 1 and each port's lane does not exist until that port's task. + +## Review Focus + +1. **A report name that is already plural, or reads "X by Y".** `InvoiceStatusTotals` is served at `/invoice_status_totals` and `InvoicesByMonth` at `/invoices_by_months`. A person will guess `/invoices_by_month`; the rule is the documented one, the same in five ports, and the corpus pins both spellings. (Table F; `list.yaml`, `list-time-grain.yaml`.) +2. **A measure that is null.** A `sum` whose rows all fall outside its segment is JSON `null`, and the key is present. A port that omits a null key fails `equals`. (`list.yaml`: `paidCents: null`.) +3. **A decimal column.** An `avg` or a ratio must serialise, must be typed as the port's decimal type (a `string` in TypeScript), and must filter with the numeric operators. (Task 3 test `a decimal derived field is a string in the read schema`; `filter-on-measure.yaml` requests `r3` and `r4`.) +4. **A request for one row, or a write.** `GET /invoice_status_totals/1` is `404` and never a row; `POST /invoice_status_totals` is `405` with the envelope. (`no-item-route.yaml`, `write-verbs-405.yaml`; Task 2 test `itemRoutes: false mounts no /:id route of any verb`.) +5. **A filter or sort on a field of the `@from` entity that the report does not expose.** `?filter[reference][eq]=…` is `400` naming the field, because the allowlist is the report's derived fields, not `Invoice`'s. (`filter-invalid-field.yaml`, `sort-invalid.yaml`.) + +--- + +## Contract tables (what the ports copy) + +### Table A — which reports are served + +One rule in five ports. It is the rule C# and Kotlin already use to decide whether a report gets a row class or a table object. + +| The report | Served? | +|---|---| +| declares no `source.*` | No. Nothing is generated and nothing is mounted, as in Plans 1 and 2. | +| is abstract | No. | +| its read source has `@kind: view` (with or without `@sql`, with or without `@unmanaged`) | **Yes.** The view is assumed to exist, as the runtime reads of Plan 2 assume. | +| its read source has `@kind: materializedView`, `storedProc` or `tableFunction` | No. The lowering skips those kinds, so no relation with the Table B columns is promised. | + +"Read source" is Plan 2's rule: the report's own read-only source with `@role: primary`, else its first own read-only source. + +| Port | The predicate | Status | +|---|---|---| +| TypeScript | `servedReport(obj)` in `codegen-ts/src/source-detect.ts`, built on `reportReadSource` from `@metaobjectsdev/metadata` | new (Task 3) | +| C# | `ReportRows.IsViewBacked(obj)` in `MetaObjects.Codegen/ReportRows.cs` | exists | +| Java, Kotlin | `RestSurfaceGate.isServedReport(obj)` in `codegen-base/.../generator/util/RestSurfaceGate.java`, built on `ReportShape.readSource` | new (Task 7). `KotlinExposedTableGenerator.emitReport` has the same three checks inline and switches to it. | +| Python | `is_served_report(obj)` in `codegen/instance_artifacts.py`, built on `report_read_source` | new (Task 9) | + +### Table B — the REST surface of a served report + +`` is `pluralize(snake_case(report name))`, the rule every object uses. + +| Request | Answer | +|---|---| +| `GET //` | `200`, a JSON array of rows, one per distinct dimension tuple. `?filter[...]`, `?sort=`, `limit`, `offset` apply exactly as on any list route. `withCount=1` answers `{ "rows": [...], "total": N }`, where `N` is the number of groups after filtering. | +| `POST //` | `405 {"error": "method_not_allowed"}`. `message` is free prose (each port says "report" where it says "projection" today). | +| any verb on `///{id}` | Not mounted. The framework's own `404`; its body is outside the contract. | +| a filter or sort error | The four field-naming envelopes of `docs/features/api-contract.md`, unchanged. | + +This is the existing keyless-projection contract ("a keyless projection … refuses only the collection verb"). The default page size stays per port, as documented: TypeScript and C# return every row when `limit` is omitted, Java, Kotlin and Python the first 50. + +### Table C — what a caller may filter and sort on + +| Derived field | Filter | Sort | +|---|---|---| +| any field whose subtype has a filter band (`opsForField` returns operators): string, enum, uuid, int, long, double, float, decimal, currency, date, time, timestamp, boolean | Yes, with exactly the operators its subtype has on any other object | Yes, default direction `asc` | +| a field typed `object` or `map` | No | No | + +Mechanism, the same in every port: the read model sets `@filterable: true` on each derived field that has a filter band. The port's existing allowlist generator then needs no report branch. Nothing sets `@sortable` or `@sortableDefaultOrder`: TypeScript treats a filterable field as sortable, and the other four ports sort on every orderable scalar already. + +A report author cannot narrow this set. There is no node to put `@filterable` on, and registering it on a dimension or measure would be new vocabulary (open question 1). + +### Table D — wire encoding of a derived field + +The existing Tier 1 encodings (`docs/features/api-contract.md`), applied to Plan 2's Table B subtypes. Nothing is new here except the last column. + +| Derived subtype | Comes from | JSON | Asserted by the corpus | +|---|---|---|---| +| `string`, `enum`, `uuid` | an attribute dimension; `min`/`max` | string | yes (`status`) | +| `long`, `int` | `count`; `sum` of int or long; an attribute dimension; `min`/`max` | number | yes (`invoices`, `totalCents`) | +| `currency` | `sum`, `min`, `max` of a currency | integer minor units | no (same encoding as `long`) | +| `date` | a time dimension at `day`, `week`, `month`, `quarter`, `year` | `YYYY-MM-DD`, the first day of the bucket | **yes, for the first time in this corpus** (`issuedOnMonth`) | +| `timestamp` | a time dimension at `hour` | an instant with `Z`, or naive without | no. The corpus has never asserted a timestamp literal, and that does not change here. | +| `decimal` | `avg`; a ratio; `sum` of a decimal | **the port's own decimal spelling.** Not pinned. | presence and filtering only (`paidShare`) | +| `double` | `sum` or `avg` of double or float | number | no | +| a null value | any nullable measure or dimension | `null`, key present | yes (`paidCents`) | + +### Table E — what each port generates for a served report + +| Port | Before this plan | This plan adds | +|---|---|---| +| TypeScript | nothing | `.ts` (Drizzle view binding, Zod read schema, row type, descriptor with `$path`, filter and sort allowlists, filter type), `.queries.ts` (`list…` only), `.routes.ts` and `.routes.hono.ts`, `.names.ts`, the barrel export, `.hooks.ts` (the list hook only) with `.meta.ts` | +| C# | `.g.cs` (keyless row), the `DbContext` mapping | `Routes.g.cs`, `FilterAllowlist.g.cs` | +| Java | nothing | `Dto`, `Repository` (`list` and `count`), `FilterAllowlist`, `Controller` | +| Kotlin | `Table` (Exposed) | the `` data class, `FilterAllowlist`, `Controller` | +| Python | nothing | `.py` (Pydantic row), `_filter_allowlist.py`, `_router.py`, `_names.py` | + +Nothing generates a form, a create or update schema, a repository write method, a `findById`, or a detail hook for a report. A names artifact appears in TypeScript and Python because their read model flows through the names generator; C#, Java and Kotlin keep binding the view and columns by literal (open question 7). + +Two refusals carry over from Plan 2 and now also stop the route tier: Java, Kotlin and C# refuse `gen` for a served report with a derived field over a `field.object`; C# refuses a derived field whose Pascal name equals the report's class name; Kotlin refuses a derived field named after a hard keyword or colliding on a column property. TypeScript and Python serve a report over a `field.object` and return the parsed JSON. + +### Table F — the `report/` sub-corpus + +`fixtures/api-contract-conformance/report/`. Generated lane only, on all five ports, for the reason `projection/` gives: what is under test is whether a port's generator emits the routes. + +**Model** (`meta.json`): one fact entity and four reports, three served and one sourceless. + +```json +{ + "metadata.root": { + "package": "acme::sales", + "children": [ + { "object.entity": { + "name": "Invoice", + "children": [ + { "source.rdb": { "@table": "invoices" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "reference", "@required": true, "@maxLength": 40 } }, + { "field.string": { "name": "status", "@required": true, "@maxLength": 20 } }, + { "field.long": { "name": "amountCents", "@required": true } }, + { "field.date": { "name": "issuedOn", "@required": true } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } }, + { "segment.filter": { "name": "paid", "@filter": { "status": "PAID" } } }, + { "dimension.attribute": { "name": "status", "@of": "Invoice.status" } }, + { "dimension.time": { "name": "issuedOn", "@of": "Invoice.issuedOn", "@grains": ["day", "month"] } }, + { "measure.aggregate": { "name": "invoices", "@agg": "count", "@of": "Invoice.id" } }, + { "measure.aggregate": { "name": "paidInvoices", "@agg": "count", "@of": "Invoice.id", "@segment": "paid" } }, + { "measure.aggregate": { "name": "totalCents", "@agg": "sum", "@of": "Invoice.amountCents" } }, + { "measure.aggregate": { "name": "paidCents", "@agg": "sum", "@of": "Invoice.amountCents", "@segment": "paid" } }, + { "measure.ratio": { "name": "paidShare", "@numerator": "paidInvoices", "@denominator": "invoices" } } + ] + }}, + { "object.report": { + "name": "InvoiceStatusTotals", + "@from": "Invoice", + "@dimensions": ["status"], + "@measures": ["invoices", "totalCents", "paidCents"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_invoice_status_totals" } } ] + }}, + { "object.report": { + "name": "InvoicesByMonth", + "@from": "Invoice", + "@dimensions": ["issuedOn:month"], + "@measures": ["invoices", "totalCents"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_invoices_by_month" } } ] + }}, + { "object.report": { + "name": "InvoiceTotals", + "@from": "Invoice", + "@measures": ["invoices", "totalCents", "paidShare"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_invoice_totals" } } ] + }}, + { "object.report": { + "name": "InvoiceDays", + "@from": "Invoice", + "@dimensions": ["issuedOn:day"], + "@measures": ["invoices"] + }} + ] + } +} +``` + +**What the loader and `reportShape` return for it** (executed): + +| Report | Route | View | Derived fields (name : subtype : required) | +|---|---|---|---| +| `InvoiceStatusTotals` | `/api/invoice_status_totals` | `v_invoice_status_totals` | `status:string:yes`, `invoices:long:yes`, `totalCents:long:no`, `paidCents:long:no` | +| `InvoicesByMonth` | `/api/invoices_by_months` | `v_invoices_by_month` | `issuedOnMonth:date:yes`, `invoices:long:yes`, `totalCents:long:no` | +| `InvoiceTotals` | `/api/invoice_totals` | `v_invoice_totals` | `invoices:long:yes`, `totalCents:long:no`, `paidShare:decimal:no` | +| `InvoiceDays` | none (sourceless) | none | `issuedOnDay:date:yes`, `invoices:long:yes` | + +**Seed** (`seed.json`). `invoices` is the base table, used by the full-stack lanes (TypeScript, C#). `reports` is what the three views return for those rows, used by the seam lanes (Java, Kotlin, Python), whose in-memory repository or H2 table stands in for the view. A TypeScript test holds the two halves together (Task 4). + +```json +{ + "invoices": [ + { "id": 1, "reference": "INV-1001", "status": "PAID", "amountCents": 125000, "issuedOn": "2026-04-30" }, + { "id": 2, "reference": "INV-1002", "status": "OPEN", "amountCents": 40000, "issuedOn": "2026-05-01" }, + { "id": 3, "reference": "INV-1003", "status": "OPEN", "amountCents": 90500, "issuedOn": "2026-05-17" }, + { "id": 4, "reference": "INV-1004", "status": "VOID", "amountCents": 0, "issuedOn": "2026-05-31" }, + { "id": 5, "reference": "INV-1005", "status": "PAID", "amountCents": 60000, "issuedOn": "2026-06-01" } + ], + "reports": { + "InvoiceStatusTotals": [ + { "status": "OPEN", "invoices": 2, "totalCents": 130500, "paidCents": null }, + { "status": "PAID", "invoices": 2, "totalCents": 185000, "paidCents": 185000 }, + { "status": "VOID", "invoices": 1, "totalCents": 0, "paidCents": null } + ], + "InvoicesByMonth": [ + { "issuedOnMonth": "2026-04-01", "invoices": 1, "totalCents": 125000 }, + { "issuedOnMonth": "2026-05-01", "invoices": 3, "totalCents": 130500 }, + { "issuedOnMonth": "2026-06-01", "invoices": 1, "totalCents": 60000 } + ], + "InvoiceTotals": [ + { "invoices": 5, "totalCents": 315500, "paidShare": "0.4" } + ] + } +} +``` + +`paidShare` is a string in the seed so a seam lane can build its own decimal type from it without a float in between. Postgres returned `0.40000000000000000000` for it. + +**The views TypeScript produces for this model** (executed; `literal` naming, which is what the schema artifact carries): + +```sql +CREATE VIEW "v_invoice_status_totals" AS + SELECT + i."status" AS "status", + COUNT(i."id") AS "invoices", + CAST(SUM(i."amountCents") AS BIGINT) AS "totalCents", + CAST(SUM(i."amountCents") FILTER (WHERE i."status" = 'PAID') AS BIGINT) AS "paidCents" + FROM "invoices" i + GROUP BY i."status"; + +CREATE VIEW "v_invoices_by_month" AS + SELECT + CAST(date_trunc('month', CAST(i."issuedOn" AS TIMESTAMP)) AS DATE) AS "issuedOnMonth", + COUNT(i."id") AS "invoices", + CAST(SUM(i."amountCents") AS BIGINT) AS "totalCents" + FROM "invoices" i + GROUP BY CAST(date_trunc('month', CAST(i."issuedOn" AS TIMESTAMP)) AS DATE); + +CREATE VIEW "v_invoice_totals" AS + SELECT + COUNT(i."id") AS "invoices", + CAST(SUM(i."amountCents") AS BIGINT) AS "totalCents", + CAST(COUNT(i."id") FILTER (WHERE i."status" = 'PAID') AS NUMERIC) / NULLIF(COUNT(i."id"), 0) AS "paidShare" + FROM "invoices" i; +``` + +Postgres reports the view columns as `character varying`, `bigint`, `date` and `numeric`, which is Plan 2's Table B. + +**Scenarios** (12). Every expected row below is a row the SQL returned. Task 1 gives each file in full. + +| File | Requests | Expect | +|---|---|---| +| `list.yaml` | `GET /api/invoice_status_totals?sort=status:asc` | `200`, `equals` the three `InvoiceStatusTotals` seed rows in that order | +| `list-time-grain.yaml` | `GET /api/invoices_by_months?sort=issuedOnMonth:asc` | `200`, `equals` the three `InvoicesByMonth` seed rows | +| `list-totals.yaml` | `GET /api/invoice_totals`; then `?withCount=1` | `200` `length: 1`; then `envelope` with `rowsLength: 1`, `total: 1` | +| `filter-on-dimension.yaml` | `?filter[status][eq]=OPEN` | `equals` the `OPEN` row | +| `filter-on-measure.yaml` | `r1` `?filter[invoices][gte]=2&sort=status:asc`; `r2` `?filter[paidCents][isNull]=true&sort=status:asc`; `r3` `/api/invoice_totals?filter[paidShare][gt]=0`; `r4` `…[gt]=0.5` | `r1` `equals` `OPEN`, `PAID`; `r2` `equals` `OPEN`, `VOID`; `r3` `length: 1`; `r4` `length: 0` | +| `filter-invalid-field.yaml` | `?filter[reference][eq]=INV-1001` | `400`, `error: invalid_filter_field`, `field: reference` | +| `filter-invalid-op.yaml` | `?filter[invoices][like]=2` | `400`, `error: invalid_filter_op`, `field: invoices` | +| `sort-desc-on-measure.yaml` | `?sort=totalCents:desc` | `equals` `PAID`, `OPEN`, `VOID` | +| `sort-invalid.yaml` | `?sort=reference:asc` | `400`, `error: invalid_sort`, `field: reference` | +| `pagination.yaml` | `r1` `?sort=status:asc&limit=1&offset=1`; `r2` `?sort=status:asc&limit=2&withCount=1` | `r1` `equals` the `PAID` row; `r2` `envelope`, `rowsLength: 2`, `total: 3` | +| `write-verbs-405.yaml` | `POST /api/invoice_status_totals` with a body | `405`, `error: method_not_allowed` | +| `no-item-route.yaml` | `GET`, `PATCH`, `PUT`, `DELETE` on `/api/invoice_status_totals/1` | `404` each, no body assertion | + +Requests without a path prefix are on `/api/invoice_status_totals`. The scenarios use only assertion keys every runner already has (`equals`, `length`, `envelope`, `error` with `field`, and a status with no `body`); all five runners return early when `expect.body` is absent. No runner changes. + +The api-contract corpus goes from 61 scenarios to 73 (`+ 12 report`). + +### Table G — what `meta docs` shows + +| Surface | A served report | A sourceless report | The `@from` entity | +|---|---|---|---| +| Model page (`--model`, `docsFile`) | A page: kind `report`, its `@from` (linked), its view, its row scope (`@segment`, `@filter`), and a column table from `reportShape`: name, type, nullable, role, and a definition written from the dimension or measure ("`Invoice.issuedOn` truncated to month, UTC"; "sum of `Invoice.amountCents` where segment `paid`"; "`paidInvoices` / `invoices`, null when the denominator is 0") | The same page, with "Not served: declares no view source" in place of the view | A "Reporting" section: its dimensions, measures and segments, and the reports that name it | +| API page (`--api`, `apiDocsFile`, and each port's api-docs builder) | One unit: the row model, `GET ` and nothing else, the list query function, the list hook | No unit | unchanged | +| Agent pages (`--agent`) | `agent/ui.md` lists the list hook and says there is no detail hook and no form; `agent/schema.md` is unchanged (it already lists the view) | nothing | unchanged | +| Site (`--site`, `docs-site`) | A report page and an entry in the object index; the reporting nodes count as rendered in the coverage audit | A report page, marked not served | The same "Reporting" section | + +### Table H — fixtures and gates + +| Gate | Path | Ports | +|---|---|---| +| Corpus | `fixtures/api-contract-conformance/report/` (`meta.json`, `seed.json`, `scenarios/*.yaml`, `README.md`) | all (shared input) | +| Schema artifact | `fixtures/api-contract-conformance/report/schema.postgres.sql` (new, TypeScript-produced, committed, drift-checked) | TS produces; C# executes | +| Corpus self-check | `server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts` (no Docker) | TS | +| Generated lane | TS `test/api-contract-report.test.ts`; C# `Api/ApiContractReportConformanceTest.cs`; Java `api/ReportGeneratedApiContractConformanceTest.java`; Kotlin `api/report/ReportGeneratedApiContractConformanceTest.kt`; Python `tests/integration/test_api_contract_report.py` | all five | +| Codegen-compile | each port's existing gate over `meta.fitness.json`, which carries six view-backed reports. Once TypeScript, Java and Python stop skipping them, their row tier is compiled with no new fixture. | all five | +| Inert corpus | `fixtures/codegen-noop/reporting/` and the five tests its README lists, re-stated | all five | +| Keyless mount | `runtime-ts/test/drizzle-fastify/mount-read-only.test.ts`, and a Hono twin | TS | +| Counts | `docs/CONFORMANCE.md` (61 → 73), checked by `scripts/site/counts.test.ts` | docs | + +--- + +## File structure + +**Shared, new:** `fixtures/api-contract-conformance/report/` (16 files: `README.md`, `meta.json`, `seed.json`, `schema.postgres.sql` and the twelve scenarios of Table F). + +**TypeScript, new** (paths under `server/typescript/packages/`): + +| File | Responsibility | +|---|---| +| `integration-tests/src/api-contract-report-schema.ts` | `REPORT_API_SCHEMA_HEADER` and `generateReportApiSchemaSql(root)`: the one definition of the artifact's bytes | +| `integration-tests/src/gen-api-contract-report-schema.ts` | the script that writes `report/schema.postgres.sql` | +| `integration-tests/src/api-contract-report-generated-server.ts` | boots the emitted report routes on Fastify against Postgres | +| `integration-tests/test/api-contract-report-corpus.test.ts` | corpus self-check and schema-artifact drift test | +| `integration-tests/test/api-contract-report.test.ts` | the generated lane | +| `runtime-ts/test/hono/mount-read-only.test.ts` | keyless Hono mount | + +**TypeScript, modified:** `metadata/src/core/reporting/report-read-model.ts`; `codegen-ts/src/source-detect.ts`, `runner.ts`, `api-surface.ts`; `codegen-ts/src/templates/routes-file.ts`, `routes-file-hono.ts`, `queries-file.ts`, `field-meta.ts`; `codegen-ts/src/reference/routes.ts`, `routes-hono.ts`, `queries.ts`; `codegen-ts/src/generators/docs-file.ts`, `docs-data-builder.ts`, `api-model.ts`, `agent-ui-page.ts`; `codegen-ts-tanstack/src/templates/hooks-file.ts`; `runtime-ts/src/drizzle-fastify/mount-read-only.ts`, `runtime-ts/src/hono/mount-read-only.ts`; `docs-site/src/coverage.ts`, `link-graph.ts`; `integration-tests/src/paths.ts`, `canonical-schema.ts`, `package.json`; `cli/test/unit/reporting-inert.test.ts`. + +**Other ports:** listed in Tasks 6 to 9. + +**Docs and skills:** listed in Task 10. + +## Task order and parallelism + +| Task | Depends on | Can run in parallel with | +|---|---|---| +| 1 corpus, schema artifact, self-check | none | none | +| 2 TS keyless read-only mounts | none | 1 | +| 3 TS generators serve a report | 2 | 6, 7, 9 | +| 4 TS generated lane | 1, 3 | 6, 7, 9 | +| 5 TS `meta docs` | 3 | 4, 6 to 9 | +| 6 C# | 1 | 3, 4, 5, 7, 9 | +| 7 Java | 1 | 3, 4, 5, 6, 9 | +| 8 Kotlin | 7 (shares `RestSurfaceGate`) | 3, 4, 5, 6, 9 | +| 9 Python | 1 | 3 to 8 | +| 10 docs, skills, changelog, counts, ledger | 3 | 4 to 9 | +| 11 full CI, review, push | all | none | + +Task 1 is the only shared prerequisite. After it there are four independent tracks: TypeScript (2, 3, 4, 5), C# (6), JVM (7, 8) and Python (9). + +--- + +### Task 1: The `report/` sub-corpus + +**Files:** +- Create: `fixtures/api-contract-conformance/report/meta.json`, `seed.json`, `README.md`, `scenarios/` (the twelve files of Table F), `schema.postgres.sql` +- Create: `server/typescript/packages/integration-tests/src/api-contract-report-schema.ts`, `src/gen-api-contract-report-schema.ts` +- Modify: `server/typescript/packages/integration-tests/src/paths.ts` (after line 42), `src/canonical-schema.ts` (`generateCanonicalSchemaSql`, line 66), `package.json` (script `gen:report-api-schema`) +- Test: `server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts` + +**Interfaces:** +- Consumes: `loadMetadataFile` (`src/load-metadata.ts`), `loadScenarios` (`src/api-contract-scenario.ts`), `generateCanonicalSchemaSql` (`src/canonical-schema.ts`), `reportShape`, `reportReadSource`, `pluralize`, `toSnakeCase`, `OBJECT_SUBTYPE_REPORT` from `@metaobjectsdev/metadata`. +- Produces: `API_CONTRACT_REPORT_DIR`, `API_CONTRACT_REPORT_SCENARIOS_DIR`, `API_CONTRACT_REPORT_SCHEMA_SQL_PATH` in `paths.ts`; in `api-contract-report-schema.ts`: + +```ts +export const REPORT_API_SCHEMA_HEADER: string; +/** The bytes of report/schema.postgres.sql for a loaded report/meta.json. */ +export function generateReportApiSchemaSql(root: MetaRoot): Promise; +``` + +- [ ] **Step 1: Write `meta.json` and `seed.json`** exactly as in Table F. + +- [ ] **Step 2: Write the twelve scenarios.** + +`scenarios/list.yaml`: + +```yaml +name: report-list +description: > + GET /api/invoice_status_totals — a view-backed object.report serves a list route. + One row per distinct dimension tuple, one key per derived field. paidCents is a + sum scoped by the `paid` segment: it is null for a group with no paid row, and the + key is present. Ordered by the dimension so the assertion is deterministic. +requests: + - id: r1 + method: GET + path: /api/invoice_status_totals?sort=status:asc + expect: + status: 200 + body: + equals: + - { status: "OPEN", invoices: 2, totalCents: 130500, paidCents: null } + - { status: "PAID", invoices: 2, totalCents: 185000, paidCents: 185000 } + - { status: "VOID", invoices: 1, totalCents: 0, paidCents: null } +``` + +`scenarios/filter-on-measure.yaml`: + +```yaml +name: report-filter-on-measure +description: > + The FR-009 filter grammar applies to a report's derived fields, measures included. + r1 filters on a count, r2 on a null sum, r3 and r4 on a ratio. A ratio is a decimal: + its spelling is the port's own and is not asserted, so r3 and r4 assert only how many + rows match (the ratio is 2/5). +requests: + - id: r1 + method: GET + path: /api/invoice_status_totals?filter[invoices][gte]=2&sort=status:asc + expect: + status: 200 + body: + equals: + - { status: "OPEN", invoices: 2, totalCents: 130500, paidCents: null } + - { status: "PAID", invoices: 2, totalCents: 185000, paidCents: 185000 } + - id: r2 + method: GET + path: /api/invoice_status_totals?filter[paidCents][isNull]=true&sort=status:asc + expect: + status: 200 + body: + equals: + - { status: "OPEN", invoices: 2, totalCents: 130500, paidCents: null } + - { status: "VOID", invoices: 1, totalCents: 0, paidCents: null } + - id: r3 + method: GET + path: /api/invoice_totals?filter[paidShare][gt]=0 + expect: + status: 200 + body: + length: 1 + - id: r4 + method: GET + path: /api/invoice_totals?filter[paidShare][gt]=0.5 + expect: + status: 200 + body: + length: 0 +``` + +`scenarios/no-item-route.yaml`: + +```yaml +name: report-no-item-route +description: > + A report has no identity, so no /{id} route of any verb is mounted. The status is + the contract; the body is the framework's own 404 and is not asserted. In particular + a GET must never answer a row. +requests: + - { id: get-item, method: GET, path: /api/invoice_status_totals/1, expect: { status: 404 } } + - { id: patch-item, method: PATCH, path: /api/invoice_status_totals/1, body: { invoices: 9 }, expect: { status: 404 } } + - { id: put-item, method: PUT, path: /api/invoice_status_totals/1, body: { invoices: 9 }, expect: { status: 404 } } + - { id: delete-item, method: DELETE, path: /api/invoice_status_totals/1, expect: { status: 404 } } +``` + +`scenarios/list-time-grain.yaml`: + +```yaml +name: report-list-time-grain +description: > + GET /api/invoices_by_months — a time dimension at a grain is a derived field named + and typed date: the first day of the bucket, spelled YYYY-MM-DD. + The segment is the report NAME snake_cased and pluralized, like every other object. +requests: + - id: r1 + method: GET + path: /api/invoices_by_months?sort=issuedOnMonth:asc + expect: + status: 200 + body: + equals: + - { issuedOnMonth: "2026-04-01", invoices: 1, totalCents: 125000 } + - { issuedOnMonth: "2026-05-01", invoices: 3, totalCents: 130500 } + - { issuedOnMonth: "2026-06-01", invoices: 1, totalCents: 60000 } +``` + +`scenarios/list-totals.yaml`: + +```yaml +name: report-list-totals +description: > + GET /api/invoice_totals — a report with no dimensions is exactly one row. It carries + a ratio, whose spelling is not asserted, so the row is counted rather than compared. + withCount=1 wraps the same list in the { rows, total } envelope. +requests: + - id: r1 + method: GET + path: /api/invoice_totals + expect: + status: 200 + body: + length: 1 + - id: r2 + method: GET + path: /api/invoice_totals?withCount=1 + expect: + status: 200 + body: + envelope: true + rowsLength: 1 + total: 1 +``` + +`scenarios/filter-on-dimension.yaml`: + +```yaml +name: report-filter-on-dimension +description: > + ?filter[status][eq]=OPEN — a dimension is a filterable derived field. +requests: + - id: r1 + method: GET + path: /api/invoice_status_totals?filter[status][eq]=OPEN + expect: + status: 200 + body: + equals: + - { status: "OPEN", invoices: 2, totalCents: 130500, paidCents: null } +``` + +`scenarios/filter-invalid-field.yaml`: + +```yaml +name: report-filter-invalid-field +description: > + `reference` is a field of Invoice, the report's @from entity, and not a derived field + of the report. The allowlist comes from the report's own derived fields, so it is + rejected with the envelope that names it. +requests: + - id: r1 + method: GET + path: /api/invoice_status_totals?filter[reference][eq]=INV-1001 + expect: + status: 400 + body: + error: "invalid_filter_field" + field: "reference" +``` + +`scenarios/filter-invalid-op.yaml`: + +```yaml +name: report-filter-invalid-op +description: > + A count is a long, so it takes the numeric operators and not `like`. The operator set + of a derived field is the one its derived subtype has on any other object. +requests: + - id: r1 + method: GET + path: /api/invoice_status_totals?filter[invoices][like]=2 + expect: + status: 400 + body: + error: "invalid_filter_op" + field: "invoices" +``` + +`scenarios/sort-desc-on-measure.yaml`: + +```yaml +name: report-sort-desc-on-measure +description: > + ?sort=totalCents:desc — a measure is a sortable derived field. +requests: + - id: r1 + method: GET + path: /api/invoice_status_totals?sort=totalCents:desc + expect: + status: 200 + body: + equals: + - { status: "PAID", invoices: 2, totalCents: 185000, paidCents: 185000 } + - { status: "OPEN", invoices: 2, totalCents: 130500, paidCents: null } + - { status: "VOID", invoices: 1, totalCents: 0, paidCents: null } +``` + +`scenarios/sort-invalid.yaml`: + +```yaml +name: report-sort-invalid +description: > + A sort on a field the report does not derive is rejected, naming the field. +requests: + - id: r1 + method: GET + path: /api/invoice_status_totals?sort=reference:asc + expect: + status: 400 + body: + error: "invalid_sort" + field: "reference" +``` + +`scenarios/pagination.yaml`: + +```yaml +name: report-pagination +description: > + limit and offset page the groups, and withCount=1 reports how many groups there are, + not how many invoices. +requests: + - id: r1 + method: GET + path: /api/invoice_status_totals?sort=status:asc&limit=1&offset=1 + expect: + status: 200 + body: + equals: + - { status: "PAID", invoices: 2, totalCents: 185000, paidCents: 185000 } + - id: r2 + method: GET + path: /api/invoice_status_totals?sort=status:asc&limit=2&withCount=1 + expect: + status: 200 + body: + envelope: true + rowsLength: 2 + total: 3 +``` + +`scenarios/write-verbs-405.yaml`: + +```yaml +name: report-write-verbs-405 +description: > + A report is read-only. POST on the collection answers 405 with the cross-port envelope, + not a 404 and not the framework's own body. There is no item address to refuse a + PATCH, PUT or DELETE on (see report-no-item-route). `message` is free prose and is + not asserted. +requests: + - id: post-collection + method: POST + path: /api/invoice_status_totals + body: { status: "OPEN", invoices: 1, totalCents: 1, paidCents: 1 } + expect: + status: 405 + body: + error: "method_not_allowed" +``` + +- [ ] **Step 3: Write `README.md`**, following `projection/README.md`: what the sub-corpus gates, Table B as "The contract", why it runs the generated lane only, why `seed.json` has two halves and which lane uses which, that a decimal's spelling is not asserted, that `InvoiceDays` is sourceless on purpose, and a wiring table with one row per port. + +- [ ] **Step 4: Add the path constants** to `paths.ts`, after `API_CONTRACT_PROJECTION_SCENARIOS_DIR`: + +```ts +// FR-044 view-backed report subcorpus (GENERATED lane only, every port). +export const API_CONTRACT_REPORT_DIR = resolve(API_CONTRACT_DIR, "report"); +export const API_CONTRACT_REPORT_SCENARIOS_DIR = resolve(API_CONTRACT_REPORT_DIR, "scenarios"); +export const API_CONTRACT_REPORT_SCHEMA_SQL_PATH = resolve(API_CONTRACT_REPORT_DIR, "schema.postgres.sql"); +``` + +- [ ] **Step 5: Write the failing self-check** in `api-contract-report-corpus.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + OBJECT_SUBTYPE_REPORT, pluralize, reportReadSource, reportShape, toSnakeCase, +} from "@metaobjectsdev/metadata"; +import { loadMetadataFile } from "../src/load-metadata.ts"; +import { loadScenarios } from "../src/api-contract-scenario.ts"; +import { generateReportApiSchemaSql } from "../src/api-contract-report-schema.ts"; +import { + API_CONTRACT_REPORT_DIR, API_CONTRACT_REPORT_SCENARIOS_DIR, API_CONTRACT_REPORT_SCHEMA_SQL_PATH, +} from "../src/paths.ts"; + +const seed = JSON.parse(readFileSync(join(API_CONTRACT_REPORT_DIR, "seed.json"), "utf8")) as { + invoices: Array>; + reports: Record>>; +}; + +describe("api-contract report corpus", () => { + test("the model loads and three of its four reports are view-backed", async () => { + const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json")); + const reports = root.objects().filter((o) => o.subType === OBJECT_SUBTYPE_REPORT); + expect(reports.map((r) => r.name)).toEqual( + ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceDays"]); + expect(reports.filter((r) => reportReadSource(r) !== undefined).map((r) => r.name)).toEqual( + ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals"]); + }); + + test("the route segments are the ones the scenarios call", async () => { + const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json")); + const segment = (name: string): string => pluralize(toSnakeCase(name)); + expect(["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals"].map(segment)).toEqual( + ["invoice_status_totals", "invoices_by_months", "invoice_totals"]); + expect(root.objects().length).toBe(5); + }); + + test("each seeded report row has exactly the report's derived fields", async () => { + const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json")); + for (const [name, rows] of Object.entries(seed.reports)) { + const report = root.objects().find((o) => o.name === name); + if (report === undefined) throw new Error(`seed.json names a report the model lacks: ${name}`); + const fields = reportShape(report, root).fields.map((f) => f.name); + for (const row of rows) expect(Object.keys(row)).toEqual(fields); + } + expect(Object.keys(seed.reports)).toEqual(["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals"]); + }); + + test("every scenario parses, and there are twelve", () => { + const scenarios = loadScenarios(API_CONTRACT_REPORT_SCENARIOS_DIR); + expect(scenarios.length).toBe(12); + for (const s of scenarios) expect(s.requests.length).toBeGreaterThan(0); + }); + + test("schema.postgres.sql is what TypeScript produces from meta.json", async () => { + const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json")); + const expected = await generateReportApiSchemaSql(root); + const committed = readFileSync(API_CONTRACT_REPORT_SCHEMA_SQL_PATH, "utf8"); + if (committed !== expected) { + throw new Error("report/schema.postgres.sql is stale. Run `bun run gen:report-api-schema` in integration-tests."); + } + }); +}); +``` + +- [ ] **Step 6: Run it and see it fail.** `cd server/typescript/packages/integration-tests && bun test test/api-contract-report-corpus.test.ts`. Expected: FAIL (the module `api-contract-report-schema.ts` does not exist). + +- [ ] **Step 7: Give `generateCanonicalSchemaSql` an optional header.** It hard-codes a header that names the persistence corpus (seen when it was run on this model). Add a second parameter `opts?: { header?: string }`; when `header` is absent the output is byte-identical to today, which the existing `schema-artifact.test.ts` proves. Then write `api-contract-report-schema.ts`: + +```ts +import type { MetaRoot } from "@metaobjectsdev/metadata"; +import { generateCanonicalSchemaSql } from "./canonical-schema.ts"; + +export const REPORT_API_SCHEMA_HEADER = [ + "-- @generated by @metaobjectsdev/integration-tests — DO NOT EDIT.", + "-- api-contract report sub-corpus schema (Postgres, literal column naming).", + "-- Produced by TypeScript from report/meta.json; the C# generated lane executes it.", + "-- Regenerate: `bun run gen:report-api-schema` in this package.", +].join("\n"); + +export function generateReportApiSchemaSql(root: MetaRoot): Promise { + return generateCanonicalSchemaSql(root, { header: REPORT_API_SCHEMA_HEADER }); +} +``` + +- [ ] **Step 8: Write `gen-api-contract-report-schema.ts`** on the pattern of `gen-canonical-schema.ts`: load `report/meta.json` with `loadMetadataFile`, call `generateReportApiSchemaSql(root)`, write `API_CONTRACT_REPORT_SCHEMA_SQL_PATH`, and report a replacement through `describeRegenReplacement`. Add `"gen:report-api-schema": "bun run src/gen-api-contract-report-schema.ts"` to `package.json`. Run it, then compare the `CREATE TABLE "invoices"` statement and the three `CREATE VIEW` bodies in the written file with Table F by eye. + +- [ ] **Step 9: Run.** `bun test test/api-contract-report-corpus.test.ts test/schema-artifact.test.ts`. Expected: PASS, with `canonical/schema.postgres.sql` unchanged (`git status` shows only new files and the three modified sources). + +- [ ] **Step 10: Commit (local).** `git commit -m "test(api-contract): report sub-corpus, its schema artifact and a self-check (FR-044)"`. Stage the files by name. + +--- + +### Task 2: TypeScript read-only mounts can be keyless + +**Files:** +- Modify: `server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-read-only.ts`, `server/typescript/packages/runtime-ts/src/hono/mount-read-only.ts` +- Test: `server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts` (add cases), `server/typescript/packages/runtime-ts/test/hono/mount-read-only.test.ts` (new) + +**Interfaces:** +- Produces, on both `MountReadOnlyOptions` interfaces: + +```ts + /** + * False for an object with no single-column primary identity: an `object.report`, or + * a keyless projection. Mounts the list route and the collection POST refusal only, + * and no `/:id` route of any verb. Default true, which is today's behaviour. + */ + readonly itemRoutes?: boolean; + /** The noun in the 405 message, which is free prose. Default "projection". */ + readonly resource?: "projection" | "report"; +``` + +- [ ] **Step 1: Read the existing test file.** Its two `describe` blocks build a Drizzle view and a Fastify instance inside each test; there is no shared helper. Lift that setup into a local `mountView(opts: Partial)` that returns the ready Fastify instance with the view mounted at `/totals`, and leave the existing tests as they are. Hono has no read-only mount test file today (`test/hono/` holds `mount-crud.test.ts` and `route-errors.test.ts`). + +- [ ] **Step 2: Write the failing tests.** In the Fastify file: + +```ts +test("itemRoutes: false mounts no /:id route of any verb", async () => { + const app = await mountView({ itemRoutes: false, resource: "report" }); + expect((await app.inject({ method: "GET", url: "/totals" })).statusCode).toBe(200); + for (const method of ["GET", "PATCH", "PUT", "DELETE"] as const) { + expect((await app.inject({ method, url: "/totals/1" })).statusCode).toBe(404); + } + const post = await app.inject({ method: "POST", url: "/totals", payload: {} }); + expect(post.statusCode).toBe(405); + expect(post.json()).toMatchObject({ error: "method_not_allowed" }); + expect(String(post.json().message)).toContain("report"); +}); + +test("the default still mounts GET :id and the three item refusals", async () => { + const app = await mountView({}); + expect((await app.inject({ method: "PATCH", url: "/totals/1", payload: {} })).statusCode).toBe(405); +}); +``` + +The Hono file holds the same two tests against `app.request(...)`. + +- [ ] **Step 3: Run and see them fail.** `cd server/typescript && bun test packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts packages/runtime-ts/test/hono/mount-read-only.test.ts`. Expected: FAIL (`GET /totals/1` answers 200 or 400, not 404). + +- [ ] **Step 4: Implement.** In both mounts, wrap the `GET :id` registration and the three item refusals in `if (opts.itemRoutes !== false) { … }`, and build the 405 message from `opts.resource ?? "projection"`. The list route and the collection `POST` refusal are untouched. + +- [ ] **Step 5: Run.** The same command, plus `bun test packages/runtime-ts/test`. Expected: PASS, every existing test included. + +- [ ] **Step 6: Commit (local).** `git commit -m "feat(runtime-ts): a read-only mount can be keyless (FR-044)"`. + +--- + +### Task 3: TypeScript generators serve a report + +**Files:** +- Modify: `server/typescript/packages/metadata/src/core/reporting/report-read-model.ts` (`derivedField`) +- Modify: `server/typescript/packages/codegen-ts/src/source-detect.ts` (after `isReport`, line 96), `runner.ts` (line 413), `api-surface.ts` (line 47) +- Modify: `codegen-ts/src/templates/routes-file.ts` (projection branch, line 76), `routes-file-hono.ts` (line 78), `queries-file.ts` (`renderProjectionQueriesFile`, line 154), `field-meta.ts` (line 163), `projection-decl.ts` (comment wording only) +- Modify: `codegen-ts/src/reference/routes.ts`, `routes-hono.ts`, `queries.ts` (the ejectable copies) +- Modify: `server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts` (`renderReadOnlyHooksFile`, reached from line 58) +- Modify: `server/typescript/packages/cli/test/unit/reporting-inert.test.ts` +- Test: `metadata/test/report-read-model.test.ts`, `codegen-ts/test/projection/routes-file.test.ts`, `codegen-ts/test/projection/queries-file.test.ts`, `codegen-ts/test/codegen-compile-conformance.test.ts` (existing), `codegen-ts/test/reference-byte-identical.test.ts` (existing), `codegen-ts/test/routes-hono-parity.test.ts` (existing) + +**Interfaces:** +- Consumes: `itemRoutes`, `resource` (Task 2); `reportReadModel`, `reportReadSource`, `opsForField` (`metadata/src/core/query/query-constants.ts:106`), `FIELD_ATTR_FILTERABLE`, `SOURCE_KIND_VIEW` (`metadata/src/persistence/source/source-constants.ts:78`) and the `MetaSource.effectiveKind` getter from `@metaobjectsdev/metadata`. +- Produces: + +```ts +// codegen-ts/src/source-detect.ts +/** Table A: a non-abstract object.report whose read source is @kind: view. */ +export function servedReport(obj: MetaObject): boolean; + +// codegen-ts/src/api-surface.ts +/** True iff the object has a single-column primary identity, so its REST surface has + * /:id routes. Mirrors the JVM RestSurfaceGate.hasItemRoute. */ +export function hasItemRoute(entity: MetaObject): boolean; +``` + +**What the spike showed.** With the runner swapping a view-backed report for its read model, the existing generators already emit a correct Drizzle view binding, Zod read schema, descriptor (`$path: "/store_totals"`), names artifact, barrel export, Fastify and Hono route files and a hooks file. Five things were wrong, and they are this task: both allowlists were empty; `.queries.ts` had a `find…ById` that does not compile; the route files mounted item routes; the hooks file had a detail hook; a decimal was typed `number`. A report with an enum dimension emitted an inline `z.enum([...])` and compiled. + +- [ ] **Step 1: Write the failing tests.** + +In `metadata/test/report-read-model.test.ts`, with the file's own `load`, `model` and `fieldsOf` helpers (lines 28 to 39): + +```ts +test("every derived field with a filter band is filterable; @sortable is never set", async () => { + const root = await load(); + const fields = fieldsOf(model(root, "ProgramMinutes")); + expect(fields.length).toBe(11); + for (const f of fields) { + expect(f.attr(FIELD_ATTR_FILTERABLE)).toBe(true); + expect(f.attr(FIELD_ATTR_SORTABLE)).toBeUndefined(); + } +}); +``` + +In `codegen-ts/test/projection/routes-file.test.ts` and `queries-file.test.ts`, using the files' existing render helpers over `fixtures/codegen-noop/reporting/with/meta.shop.json`: + +```ts +test("a served report mounts a keyless read-only surface", ...); + // rendered StoreTotals.routes.ts contains `itemRoutes: false` and `resource: "report"`, + // and its doc comment says "report", not "projection" + +test("a projection with a single-column identity is unchanged", ...); + // the existing projection golden is byte-identical: no `itemRoutes` key at all + +test("a served report gets a list query and no by-id query", ...); + // StoreTotals.queries.ts exports listStoreTotals and no findStoreTotalsById + +test("a decimal derived field is a string in the read schema", ...); + // ProgramMinutes.ts (from meta.fitness.json): avgMinutes: z.string().nullable(), + // longShare: z.string().nullable(); totalMinutes stays z.number().int().nullable() + +test("a report and an entity that share a route segment are a generation error", ...); + // a model with entity Invoice and view-backed report Invoices throws the existing + // collection-name collision error, naming both +``` + +- [ ] **Step 2: Run and see them fail.** + +- [ ] **Step 3: Mark derived fields filterable.** In `derivedField`, after the type-shaping attrs are copied (the band depends on `@intValueMap`, which is a carried attr): + +```ts + // Table C (Plan 3): a report author has no node to put @filterable on, so every + // derived field that has a filter band is filterable. Set on this detached model + // only; no vocabulary is added and the declared tree is not touched. + if (opsForField(field).length > 0) field.setAttr(FIELD_ATTR_FILTERABLE, true); +``` + +- [ ] **Step 4: Swap at the runner.** Add `servedReport` to `source-detect.ts`, then replace line 413 of `runner.ts`: + +```ts + // FR-044: a served report (Table A) is generated from its read model, which the + // projection generators emit as a keyless read-only object. Every other report + // generates nothing. + const generatable = filtered.flatMap((o) => { + if (!isReport(o)) return [o]; + return servedReport(o) ? [reportReadModel(o, root)] : []; + }); +``` + +Reword the warning below it: every selected object is a report with no view source, which generates nothing. + +- [ ] **Step 5: Answer `servesReadApi` and `hasItemRoute`.** `servesReadApi` returns `servedReport(entity)` for a report (declared node or read model) and its existing answer otherwise. Add `hasItemRoute`. + +- [ ] **Step 6: Make the read-only templates keyless-aware.** In `routes-file.ts` and `routes-file-hono.ts`, when `!hasItemRoute(entity)` add `itemRoutes: false,` to the mount options, and when `isReport(entity)` add `resource: "report",` and say "report" in the doc comment ("Exposes GET list only. POST returns 405."). In `renderProjectionQueriesFile`, emit the by-id function only when `hasItemRoute(obj)`, and write "report (read-only)" instead of "projection (read-only)" in the file header for a report. In `renderReadOnlyHooksFile`, emit the detail hook and the `details`/`detail` keys only when `hasItemRoute(entity)`. Apply the same edits to `reference/routes.ts`, `reference/routes-hono.ts` and `reference/queries.ts`. **UNVERIFIED:** how `test-generators/src/` and `codegen-ts-tanstack/src/reference/hooks.ts` stay in step with these; `reference-byte-identical.test.ts` and `reference-templates.test.ts` say, so read them first. + +This step also corrects a keyless **projection**, which today gets item routes and a by-id query it cannot serve (open question 4). + +- [ ] **Step 7: Type a decimal as a string in a view read schema.** In `field-meta.ts`, move `FIELD_SUBTYPE_DECIMAL` out of the `z.number()` arm into a `z.string()` arm, with a comment that Drizzle's `numeric` reads a string and `field.decimal` is a `string` in TypeScript. This changes the read schema of an existing **projection** with a decimal field (open question 5). + +- [ ] **Step 8: Re-state the inert test.** In `cli/test/unit/reporting-inert.test.ts`: the codegen `describe` becomes "a sourceless report is inert; a served report emits exactly its read-only files". For each catalog generator, the files added by the `with` model are exactly the Table E TypeScript list for `StoreTotals`, and nothing is added for `ProgramEngagement` or `DailyRevenue`. Every file the `without` model emits is byte-identical in the `with` run, the barrel excepted. Update the header comment and `fixtures/codegen-noop/reporting/README.md`. + +- [ ] **Step 9: Run.** + +```bash +cd server/typescript +bun test packages/metadata/test/report-read-model.test.ts packages/codegen-ts/test packages/codegen-ts-tanstack packages/cli/test/unit/reporting-inert.test.ts +bun test packages/runtime-ts/test # the persistence read path ignores @filterable +``` + +Expected: PASS. The codegen-compile gate now compiles the six fitness reports' entity, names and queries files. Build the workspace first (`bun run --filter '*' build` at the repo root): on an unbuilt tree that gate fails on unresolved `@metaobjectsdev/*` type declarations, which is not a report defect. + +- [ ] **Step 10: Commit (local).** `git commit -m "feat(codegen-ts): generate the read-only surface of a view-backed report (FR-044)"`. + +--- + +### Task 4: TypeScript generated lane + +**Files:** +- Create: `server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts`, `test/api-contract-report.test.ts` + +Follow `src/api-contract-projection-generated-server.ts` and `test/api-contract-projection.test.ts`; read both first. + +- [ ] **Step 1: Write the server module.** It differs from the projection one in three places. It runs `runGen` with `[entityFile(), routesFile()]` over `report/meta.json` and throws if a routes file was emitted for `InvoiceDays`. It creates `invoices` by hand in the emitted snake_case spelling (`amount_cents`, `issued_on`), then creates each view from `buildReportViews(root, { dialect: "postgres", columnNamingStrategy: DEFAULT_COLUMN_NAMING_STRATEGY })` as `CREATE VIEW "" AS `, so this lane runs the real lowering under the real route. It registers all three emitted registrars (`invoiceStatusTotalsRoutes`, `invoicesByMonthRoutes`, `invoiceTotalsRoutes`). + +- [ ] **Step 2: Write the lane test** with the projection test's scenario loop over `API_CONTRACT_REPORT_SCENARIOS_DIR`, and one extra test that holds the seed together: + +```ts +test("the seeded report rows are what the views return", async () => { + // start, applySeed(seed.invoices), then for each [name, path] of the three served reports: + // GET path (no query) + // compare with seed.reports[name] ignoring row order; compare paidShare with + // Number(a) === Number(b), every other key by strict equality +}); +``` + +- [ ] **Step 3: Run.** `cd server/typescript/packages/integration-tests && bun test test/api-contract-report.test.ts` (needs Docker). Expected: 13 tests PASS. A failure on a wire shape is a codegen or mount defect; fix it there, and change an expected value only if Table F itself is wrong (then fix both and say so in the commit). + +- [ ] **Step 4: Commit (local).** `git commit -m "test(integration): report api-contract lane, TypeScript generated routes (FR-044)"`. + +--- + +### Task 5: Reports in `meta docs` + +**Files:** +- Modify: `codegen-ts/src/generators/docs-file.ts` (line 118), `docs-data-builder.ts`, `api-model.ts` (line 303 and `restSymbols`), `agent-ui-page.ts` +- Modify: `docs-site/src/coverage.ts` (the `isReportingVocabulary` predicate and `deferred`), `docs-site/src/link-graph.ts` (line 41), and the site's object page template +- Modify: `cli/test/unit/reporting-inert.test.ts` (the `meta docs` `describe`) + +Required content is Table G. **UNVERIFIED:** the shape `buildEntityDocData` returns and which template renders a model page; the docs-site page templates; which golden tests pin model and API pages (`codegen-ts/test/golden/` holds `api-docs-accuracy.test.ts`); whether `GET /_meta` lists a report (this plan does not change it). + +- [ ] **Step 1: Read first.** `docs-data-builder.ts` (`buildEntityDocData`), the model page template it feeds, `api-model.ts` `buildEntityUnit` and `restSymbols` (line 617), `agent-ui-page.ts`, `docs-site/src/coverage.ts` and `link-graph.ts`. +- [ ] **Step 2: Write failing tests** over `fixtures/codegen-noop/reporting/with/meta.shop.json`: + - the model surface has a page for each of `StoreTotals`, `ProgramEngagement` and `DailyRevenue`; `StoreTotals`'s page names `v_store_totals` and lists `purchases`, `buyers`, `revenue` with their Table B types; the two sourceless pages say they are not served; `Purchase`'s page has a Reporting section naming its dimensions, measures, segments and reports; + - the API surface has one unit for `StoreTotals` whose only REST symbol is `GET /store_totals`, and no unit for the other two; + - `agent/ui.md` names `useStoreTotalsList` and no `useStoreTotals(id)`; + - the site's coverage report has an empty `deferred` and no "not rendered" warning for a reporting kind; + - a model with no report renders every surface byte-identically to before (the `without` model against a snapshot taken before this task). +- [ ] **Step 3: Implement.** Remove the two `isReport` skips and the `link-graph.ts` skip. Build a report's model page from `reportShape` (which resolves for a sourceless report too), not from `reportReadModel`. In `restSymbols`, emit the `/:id` symbol only when `hasItemRoute(obj)`, which also corrects the documented endpoints of a keyless projection. In `buildApiModel`, skip a report that is not `servedReport`, and build a served report's unit from `reportReadModel(obj, root)`, since the declared node has no fields for `modelFieldShapes` to read. Delete `isReportingVocabulary`, `deferred` and the branch in `walk`, as the comment at `coverage.ts:16` asks. +- [ ] **Step 4: Re-state the docs `describe`** of `reporting-inert.test.ts` as "differs by exactly" the additions of Step 2. +- [ ] **Step 5: Run.** `cd server/typescript && bun test packages/codegen-ts/test packages/docs-site packages/cli/test/unit/reporting-inert.test.ts`. Expected: PASS. +- [ ] **Step 6: Commit (local).** `git commit -m "feat(docs): model and API pages for reports in meta docs (FR-044)"`. + +--- + +### Task 6: C# port + +C# already generates a served report's keyless row and `DbContext` mapping from `ReportRows.RowModel`. This task adds the route file and the filter allowlist. + +**Files:** +- Modify: `server/csharp/MetaObjects.Codegen/ReportRows.cs` (`DerivedField`), `Generators/RoutesGenerator.cs` (`Filter` line 50, `AppliesTo` line 63, `Generate`, `GenerateStandardRoutes`, `AppendReject`), `Generators/FilterAllowlistGenerator.cs` (`AppliesTo` line 59, and its `Generate`), `ApiDocs/CSharpApiModelBuilder.cs` (line 52) +- Create: `server/csharp/MetaObjects.IntegrationTests/Api/ApiContractReportConformanceTest.cs`, `ReportGeneratedServerFactory.cs`, `ReportFixture.cs` +- Modify: `MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs` (after line 52: `ReportDir`, `ReportScenariosDir`, `ReportSeedFile`, `ReportMetaJson`, `ReportSchemaSql`) +- Modify: `server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs`, `ReportRowCodegenTests.cs` + +- [ ] **Step 1: Read first.** `GenerateStandardRoutes` from line 100: `hasItem` is already false for an object with no single-column key, so the list route and `AppendProjectionRejects(..., hasItem: false)` already produce Table B. It also calls `ctx.Warn("… has no single-column primary key — emitting collection GET only.")`, which must not fire for a report. `RoutesGenerator.Generate` iterates `ctx.Entities`, which holds no report. **UNVERIFIED:** how `FilterAllowlistGenerator` iterates (through `PerEntityGenerator`), and what `CSharpApiModelBuilder` needs to document a row model. +- [ ] **Step 2: Failing unit tests** in `ReportRowCodegenTests.cs`: for the `with` model, `RoutesGenerator` emits `StoreTotalsRoutes.g.cs` with exactly one `MapGet` and one `MapPost` and no `{id}`; `FilterAllowlistGenerator` emits `StoreTotalsFilterAllowlist.g.cs` naming `purchases`, `buyers` and `revenue`; neither emits for `ProgramEngagement` or `DailyRevenue`; no warning is raised for `StoreTotals`. `dotnet test server/csharp --filter ReportRow`: FAIL. +- [ ] **Step 3: Implement.** Set `@filterable: true` in `ReportRows.DerivedField` for a field with a filter band (Table C). Change both `AppliesTo` predicates from `!entity.IsReport() && …` to "a report applies iff `ReportRows.IsViewBacked`", so the harness and the api-docs builder, which pass declared nodes, get the right answer. Have both generators iterate `ReportRows.WithReportRows(ctx)` filtered by `AppliesTo`, as `EntityGenerator` (line 50) does. Skip the keyless warning for a report and say "report" in its 405 message. In `CSharpApiModelBuilder`, document a served report (row model, `GET` list) and nothing for an unserved one. +- [ ] **Step 4: Write the lane** on the pattern of `ProjectionGeneratedServerFactory.cs` and `ProjectionFixture.cs`. `ReportFixture.ProvisionSchemaAsync` executes `ApiContractCorpusPaths.ReportSchemaSql` verbatim (literal column naming, which is what this lane generates with) and holds no SQL of its own. `ApplySeedAsync` inserts `seed.json`'s `invoices` only. The factory asserts routes for exactly `Invoice` and the three served reports. +- [ ] **Step 5: Update `ReportingInertTests.cs`:** `StoreTotals` now emits its row, its `DbContext` mapping, its routes file and its allowlist, and nothing in the names tier. +- [ ] **Step 6: Run.** `dotnet test server/csharp`, then the integration lane through `scripts/integration-test.sh` (read it for the C# invocation). Expected: green, including the twelve report scenarios, the codegen-compile gate and `IntegrationFixtureDriftTests`. +- [ ] **Step 7: Commit (local).** `git commit -m "feat(csharp): read-only routes and filter allowlist for a view-backed report (FR-044)"`. + +--- + +### Task 7: Java port + +**Files:** +- Modify: `server/java/codegen-base/src/main/java/com/metaobjects/generator/util/RestSurfaceGate.java` +- Modify: `server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java` (the derived-field builder) +- Modify: `server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java` (loop at line 130, `emitReadOnly`), `SpringDtoGenerator.java` (lines 119 and 172), `SpringRepositoryGenerator.java` (line 91), `SpringFilterAllowlistGenerator.java` (line 80), and `apidocs/JavaApiModelBuilder.java` (line 90) +- Create: `server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java`, `ReportCorpus.java`, `generated/GeneratedReportControllerHarness.java`, `generated/InMemoryReportRepositorySource.java` +- Modify: `server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java`, `SpringProjectionRestSurfaceTest.java`; `server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java` + +**Interfaces:** +- Produces, in `RestSurfaceGate`: + +```java +/** Table A: a non-abstract object.report whose read source is @kind: view. */ +public static boolean isServedReport(MetaObject obj); + +/** + * The object a REST-surface generator emits for {@code obj}: the report's read model + * for a served report, {@code null} for any other report, {@code obj} itself otherwise. + */ +public static MetaObject restShapeOf(MetaObject obj); +``` + +`isReadOnly(obj)` additionally returns true for a served report or its read model. `hasItemRoute` needs no change: a read model has no identity. + +- [ ] **Step 1: Read first.** All four generators loop over `loader.getMetaObjects()` and gate on `RestSurfaceGate` or on the subtype (`SpringDtoGenerator` admits `entity` and `projection` only). `SpringRepositoryGenerator.emitReadOnly` (line 223) already omits `findById` when `hasItemRoute` is false. **UNVERIFIED:** how `SpringDtoGenerator` names the Java type of an enum field, and whether a derived enum field (an attribute dimension over a `field.enum`) should reuse the `@of` entity's enum type or get one in the report's package; decide from the code and state the choice in the commit. +- [ ] **Step 2: Failing unit tests.** In `ReportReadModelTest`: every derived field is filterable. In `SpringProjectionRestSurfaceTest` (or a new `SpringReportRestSurfaceTest` beside it): for the `with` model the four generators emit `StoreTotalsDto`, `StoreTotalsRepository` (with `list` and `count` and no `findById`), `StoreTotalsFilterAllowlist` and `StoreTotalsController` (one `@GetMapping`, one `@PostMapping`, no `/{id}` mapping), and nothing for the two sourceless reports. `mvn -q -pl metadata,codegen-base,codegen-spring -am test -Dtest='ReportReadModelTest,SpringReportRestSurfaceTest'`: FAIL. Use `MAVEN_ARGS` for a repository override, never `MAVEN_OPTS`. +- [ ] **Step 3: Implement.** Mark derived fields filterable in `ReportReadModel`. Add the two gate methods. In each of the four loops, replace `entity` with `RestSurfaceGate.restShapeOf(entity)` and `continue` on `null`; admit `SUBTYPE_REPORT` in `SpringDtoGenerator`'s subtype test when the object is a read model. The emitted names come from the report's short name, which the read model keeps. Say "report" in the controller's javadoc and its 405 message. The Java refusal of a derived field over a `field.object` (already raised by `ReportReadModel`) now also stops these generators; the test asserts the message names the report. +- [ ] **Step 4: Write the lane** on the pattern of `ProjectionGeneratedApiContractConformanceTest.java` and `GeneratedProjectionControllerHarness.java`. The harness generates and compiles the whole model, fails with a clear message if no controller was emitted for a served report or if one was emitted for `InvoiceDays`, and mounts the three report controllers on one `TomcatHost` (**UNVERIFIED:** whether `TomcatHost.start` accepts several controllers; if not, extend it rather than starting three hosts). `ReportCorpus.seedRows(String report)` reads `seed.json`'s `reports` and the harness converts each row with `mapper.convertValue(row, dtoClass)` (a `LocalDate` field needs the `ObjectMapper` to carry `JavaTimeModule`; **UNVERIFIED** whether `TomcatHost`'s mapper does). `InMemoryReportRepositorySource` is one generic in-memory source instantiated per report, applying the controller's predicates, sort and paging to the seeded rows, with `gte`, `gt`, `eq`, `isNull` and a numeric comparison that is exact for a `BigDecimal`. +- [ ] **Step 5: Update `ReportingInertTest.java`:** sourceless reports stay inert; `StoreTotals` emits exactly its four files. +- [ ] **Step 6: Run.** `mvn -q -pl metadata,codegen-base,codegen-spring -am test`, then `mvn -f server/java/integration-tests/pom.xml test -Dtest=ReportGeneratedApiContractConformanceTest`, then the codegen-compile gate (`codegen-spring/.../CodegenCompileConformanceTest.java`), which now compiles six report DTOs. Expected: green. +- [ ] **Step 7: Commit (local).** `git commit -m "feat(java): read-only Spring surface for a view-backed report (FR-044)"`. + +--- + +### Task 8: Kotlin port + +Kotlin already emits `Table`. The read-only controller reads that object by name (`KotlinNaming.tableObjectName(shortName)`, in `emitReadOnly`), so the controller needs no new binding. + +**Files:** +- Modify: `server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt` (loop at line 102, `EMITTED_SUBTYPES` at line 634), `KotlinFilterAllowlistGenerator.kt` (line 66), `KotlinSpringControllerGenerator.kt` (loop at line 115, `emitReadOnly`), `KotlinExposedTableGenerator.kt` (`emitReport`, line 377), `apidocs/KotlinApiModelBuilder.kt` (the subtype filter at line 72) +- Create: `server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt`, `api/report/generated/GeneratedReportControllerHarness.kt` +- Modify: `server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt`, `KotlinSpringControllerGeneratorTest.kt` + +- [ ] **Step 1: Read first.** `emitReadOnly` (it builds `sortFields` and `scalarFields` from `entity.metaFields`, so it must be handed the read model, not the declared report) and `emitReport`'s `ReportTablePlan` (an enum derived field uses the `@of` entity's enum class through `reportEnumClass`, and an unsized decimal reads at `REPORT_DECIMAL_PRECISION`/`REPORT_DECIMAL_SCALE`). **UNVERIFIED:** whether `KotlinEntityGenerator.emit` can emit a data class for a read model as it stands, and how it would name an enum property's type; the data class must use the same enum class the table does. +- [ ] **Step 2: Failing unit tests:** for the `with` model, `StoreTotals` gets a data class, `StoreTotalsFilterAllowlist` and `StoreTotalsController` (a list handler and a POST refusal, no `/{id}`); the sourceless reports get nothing; the generated sources compile together with `StoreTotalsTable` (kotlin-compile-testing, as `KotlinSpringControllerGeneratorTest` does). +- [ ] **Step 3: Implement.** In the three loops, map each object through `RestSurfaceGate.restShapeOf` (Task 7). `KotlinEntityGenerator` skips any subtype outside `EMITTED_SUBTYPES` (entity, value, projection); admit a report only when the object is a read model. `KotlinApiModelBuilder` has the same three-subtype filter; admit a served report there and build its unit from the read model. `emitReport` switches to `RestSurfaceGate.isServedReport`. Refusals from `emitReport` (hard keyword, column collision, `field.object`) already stop `gen` before a controller is written. +- [ ] **Step 4: Write the lane** on the pattern of `api/projection/`. Per scenario: a fresh in-memory H2 in PostgreSQL mode, `SchemaUtils.create(...)` on the three generated report table objects (H2 creates them as plain tables that stand in for the views; no view SQL is written in Kotlin), then insert `seed.json`'s `reports` rows. +- [ ] **Step 5: Update `ReportingInertTest.kt`:** `StoreTotals` emits its table, data class, allowlist and controller. +- [ ] **Step 6: Run.** The Kotlin unit suite, the lane, `codegen-kotlin/.../CodegenCompileConformanceTest.kt`, and the Exposed 1.x check (`codegen-kotlin-exposed1x-check`). Expected: green. +- [ ] **Step 7: Commit (local).** `git commit -m "feat(kotlin): read-only Spring controller for a view-backed report (FR-044)"`. + +--- + +### Task 9: Python port + +**Files:** +- Modify: `server/python/src/metaobjects/meta/core/reporting/report_read_model.py`, `codegen/instance_artifacts.py`, `codegen/runner.py` (line 106), `codegen/generators/router_generator.py` (`_render_readonly_router` line 1456, `_emit_readonly_reject_handlers` line 1421), `apidocs/builder.py` (line 124) +- Create: `server/python/tests/integration/test_api_contract_report.py`, `tests/integration/generated_report_app.py` +- Modify: `server/python/tests/test_reporting_inert.py`, `tests/test_report_read_model.py` + +**What the spike showed.** With the runner swap, `entity`, `filter-allowlist`, `names` and `routes` each emit one file for `StoreTotals` and nothing else changes. The model is a correct Pydantic class (`revenue: int | None = None`). Three things were wrong: the allowlist was empty; the router had `GET`, `PATCH`, `PUT` and `DELETE` on `/{store_totals_id}`; and the repository `Protocol` had `find_by_id(self, id: int)`. + +- [ ] **Step 1: Failing tests.** In `test_report_read_model.py`: every derived field is filterable (read with the resolving accessor; Python `attr()` is OWN). A new `tests/codegen/test_report_router.py`: the rendered `store_totals_router.py` has exactly `@router.get("")` and `@router.post("")`, no path with `{`, and a `StoreTotalsRepository` with `list` and `count` only; `store_totals_filter_allowlist.py` names `purchases`, `buyers`, `revenue`; a projection with a single-column identity renders byte-identically to before. `cd server/python && uv run pytest -q tests/test_report_read_model.py tests/codegen/test_report_router.py`: FAIL. +- [ ] **Step 2: Implement.** Mark derived fields filterable in `report_read_model.py`. Add `is_served_report` and `has_item_route(entity)` (true iff a primary identity with exactly one field) to `instance_artifacts.py`. Replace the drop at `runner.py:106` with the swap, and reword the warning: + +```python + # FR-044: a served report (Table A) is generated from its read model, which the + # read-only generators emit as a keyless object. Every other report generates nothing. + objs = [ + report_read_model(o, metadata) if o.sub_type == OBJECT_SUBTYPE_REPORT else o + for o in objs + if o.sub_type != OBJECT_SUBTYPE_REPORT or is_served_report(o) + ] +``` + +In `_render_readonly_router`, emit the `get` handler, the three item refusals and `find_by_id` only when `has_item_route(entity)`, and say "report" for a report. This also corrects a keyless projection (open question 4). In `apidocs/builder.py`, document a served report and skip an unserved one. +- [ ] **Step 3: Write the lane** on the pattern of `test_api_contract_projection.py` and `generated_projection_app.py`: one app with the three generated routers, each behind an in-memory repository seeded from `seed.json`'s `reports` (`paidShare` as `Decimal("0.4")`, `issuedOnMonth` as a `date`). +- [ ] **Step 4: Update `test_reporting_inert.py`:** the `with` model adds exactly four files, all for `StoreTotals`. +- [ ] **Step 5: Run.** `uv run pytest -q`, the lane, and `tests/codegen/test_codegen_compile_conformance.py` (which now imports six report models). Expected: green. +- [ ] **Step 6: Commit (local).** `git commit -m "feat(python): read-only FastAPI router for a view-backed report (FR-044)"`. + +--- + +### Task 10: Docs, skills, changelog, counts, ledger + +**Files:** +- Modify: `docs/features/reporting.md` ("What does not exist yet", "What the runtime does with it", the last sentence of that section, "Known limits", "What the corpus gates"), `docs/features/api-contract.md` (a "Reports" section after "Read-only projections"; a `date` and a `decimal` note under "Type encodings"), `docs/CONFORMANCE.md` (line 37, the heading at line 258, line 260, the total at line 401), `fixtures/api-contract-conformance/README.md` (the layout and the sub-corpus list) +- Modify: `agent-context/skills/metaobjects-authoring/SKILL.md` (line 750) and `references/reporting.md`; the five `metaobjects-codegen` references (`typescript.md`, `csharp.md`, `java.md`, `kotlin.md`, `python.md`: a "Reports" paragraph after "Projections"); `agent-context/skills/metaobjects-runtime-ui/SKILL.md` (the list hook) +- Regenerate: `fixtures/agent-context-conformance/*/expected/` +- Modify: `docs/ports/typescript.md`, `csharp.md`, `java.md`, `kotlin.md`, `python.md`; `README.md` (line 157, "no routes yet"); `CHANGELOG.md` `[Unreleased]`; `.claude/rules/cross-language-porting.md` and `AGENTS.md` wherever they say a report has no routes +- Modify: `metaobjects/meta.requirements.yaml`, then regenerate `fixtures/requirement-harness/*` + +- [ ] **Step 1: `docs/features/reporting.md`.** Replace the "no REST route" statements with Tables A to E as prose: which reports are served, the surface, what may be filtered and sorted, the wire encodings with the decimal caveat, and what each port generates. Keep "no query-time choice", "no `measure.derived`" and "UTC only". Add to "Known limits": a report cannot narrow its filterable fields; the route segment follows the object rule (`InvoicesByMonth` is `/invoices_by_months`); a decimal's spelling differs by port. +- [ ] **Step 2: `docs/features/api-contract.md`.** The "Reports" section states Table B and points at the sub-corpus. Correct "Read-only projections" where it implies every port honoured the keyless rule before this change. +- [ ] **Step 3: Skills.** Each codegen reference gains the Table E row for its port. Then: + +```bash +cd server/typescript/packages/sdk +bun scripts/regen-agent-context-conformance.ts +bun test test/agent-context-conformance.test.ts test/agent-context-capability-grounding.test.ts +cd ../../../.. && bun scripts/check-doc-examples.ts +``` + +Expected: PASS. +- [ ] **Step 4: Counts.** Update the four places in `docs/CONFORMANCE.md` (61 → 73, "+ 12 report") and run `bun test scripts/site/counts.test.ts`. **UNVERIFIED:** whether that test counts api-contract scenarios or only the metamodel corpus; read it first. +- [ ] **Step 5: The project's own requirements ledger.** `metaobjects/meta.requirements.yaml` has `objectReport` (near line 525) and the `reporting` branch (line 1153), all `status: planned`; its description says "how it is served is the API surface". Read those entries and `fixtures/requirement-harness/README.md`. Move to a non-`planned` status only what this plan makes true, with an `@implementedBy` that resolves, then run `bun scripts/generate-requirement-harness.ts` and the five harness tests. **UNVERIFIED:** which entries those are; if none is about serving, change nothing and say so in the commit. +- [ ] **Step 6: CHANGELOG `[Unreleased]`:** a view-backed report is served by a generated list route in every port; TypeScript, Java and Python now generate a report's row type; a report has model and API pages in `meta docs`; and, under their own bullets, the two corrections that reach beyond reports (keyless projections in TypeScript and Python; a decimal in a TypeScript view read schema), each with the model shape it affects. +- [ ] **Step 7: Commit (local).** `git commit -m "docs(reporting): report routes, the report api-contract corpus, and the per-port generators (FR-044)"`. + +--- + +### Task 11: Full CI, review, push + +- [ ] **Step 1:** `scripts/ci-local.sh` (full, no flags). Expected: every lane green, including `gates` (metamodel-version unchanged at 1.1, counts, leak scan, doc examples) and each port's codegen-compile gate. +- [ ] **Step 2:** `scripts/integration-test.sh` for the api-contract and persistence lanes in all five ports. The persistence lanes prove the read models still read with `@filterable` set. +- [ ] **Step 3:** `node scripts/check-metamodel-version.mjs` (no `--set`). Expected: passes with no vocabulary change reported. +- [ ] **Step 4:** Independent review of the whole change by a fresh reviewer over `git diff origin/main..HEAD`; fix findings. +- [ ] **Step 5:** Push and open the pull request. Comment on the FR-044 issue with the commit range and "Plan 3 of 5 done". + +--- + +## No-churn and back-compat proof + +| Claim | What proves it | +|---|---| +| A model with no report generates the same files | Each runner's swap maps a non-report to itself; the `without` model of `fixtures/codegen-noop/reporting/` is compared file by file in all five inert tests | +| A sourceless report is still inert | Table A; the two sourceless reports of the `with` model emit nothing in any port's inert test; `InvoiceDays` gets no route in any lane harness | +| No vocabulary change | `expected-registry.json` untouched; `check-metamodel-version.mjs` passes without `--set`; no loader pass is added, so the metamodel conformance fixtures are untouched | +| The lowering is unchanged | None of `extract-report-spec.ts`, `report-ddl-emit.ts`, `time-sql.ts`, `build-projection-views.ts` is edited; `canonical/schema.postgres.sql` and `report-shapes.json` are byte-identical (their drift tests) | +| Persistence reads are unchanged | The read models gain `@filterable` only; no runtime reads it. The six report scenarios of `persistence-conformance` stay green in five ports | +| A projection with a single-column identity is unchanged | Task 3, 7 and 9 tests "a projection with a single-column identity is unchanged"; the `projection/` sub-corpus stays 7 of 7 in five ports | +| The canonical format is unchanged | The read models stay detached and are never serialised | +| Existing api-contract scenarios are unchanged | No runner changes; the new sub-corpus uses existing assertion keys only | +| C# and Kotlin output from Plan 2 is unchanged | The row class, the `DbContext` mapping and the Exposed table keep their bytes; `IntegrationFixtureDriftTests` and `KotlinCodegenMatchesReferenceTest` | +| **Behaviour change 1** (unreleased vocabulary) | A view-backed report now generates code and mounts a route. Nothing released carries `object.report` | +| **Behaviour change 2** (released behaviour, open question 4) | A keyless read-only projection in TypeScript and Python loses its `/{id}` routes, its by-id query and its detail hook. They were never able to serve a row by key. The CHANGELOG names it | +| **Behaviour change 3** (released output, open question 5) | In TypeScript, a decimal field of a view read schema is `z.string()`, not `z.number()`. The runtime value was always a string. The CHANGELOG names it | + +## Unverified items + +Each is the first step of the task that touches it. + +| Item | Task | +|---|---| +| The HTTP bodies of Table F (derived from executed SQL and Table D; no port serves a report yet) | 4 | +| How `test-generators/src/` and `codegen-ts-tanstack/src/reference/` stay in step with the package templates | 3 | +| The render helpers in `codegen-ts/test/projection/routes-file.test.ts` and `queries-file.test.ts` that the new cases reuse | 3 | +| What `buildEntityDocData` returns, the model page template, the docs-site page templates, and the golden tests that pin them | 5 | +| Whether `GET /_meta` lists a report (not changed by this plan) | 5 | +| How `FilterAllowlistGenerator` iterates its objects; what `CSharpApiModelBuilder` needs for a row model | 6 | +| How `SpringDtoGenerator` types an enum field, and the right enum type for a derived enum field | 7 | +| Whether `TomcatHost.start` takes several controllers and whether its `ObjectMapper` serialises `LocalDate` as `YYYY-MM-DD` | 7 | +| Whether `KotlinEntityGenerator.emit` accepts a read model, and its enum type naming | 8 | +| That a `LocalDate`, a C# `DateOnly`, a Python `date` and a Kotlin date each reach the wire as `YYYY-MM-DD` (the corpus has never asserted a date) | 4, 6, 7, 8, 9 | +| What `scripts/site/counts.test.ts` counts | 10 | +| Which entries of the project's own requirements ledger this plan makes true | 10 | + +## Open questions for the captain + +1. **What may a caller filter and sort on?** A report declares no fields, so there is nowhere to write `@filterable`. The plan makes every derived field filterable and sortable (Table C), which is what the spec's "the standard `?filter`, `?sort`, paging over the derived fields" says. Letting an author narrow the set would mean registering `@filterable` on dimensions and measures, which is outside §3.1. Confirm "all derived fields". +2. **The route segment.** The plan keeps the one rule: `InvoicesByMonth` is served at `/invoices_by_months`, `DailyRevenue` at `/daily_revenues`, `StoreTotals` at `/store_totals`. A report-specific rule (no pluralization) would be a second rule in five ports and in the naming corpus. Confirm the existing rule. +3. **A request for `/{id}`.** The plan mounts nothing there, as for a keyless projection, so the answer is the framework's own `404` and only the status is asserted. The alternative is an explicit `404 {"error": "not_found"}` in five ports, which advertises an address a report does not have. Confirm "not mounted". +4. **Keyless projections in TypeScript and Python.** They mount item routes today, against the documented contract, and in TypeScript `GET /x/1` on a view with no `id` column answers the view's first row. The plan fixes both ports with the same switch a report needs, without adding a scenario to `projection/` (unit tests only). It is a behaviour change for an adopter with a keyless projection. Fix it here, or leave projections alone and give reports their own switch? +5. **Decimals.** Two parts. (a) The corpus does not assert a decimal's spelling, because no port agrees on one and precision is engine-native; it asserts that a ratio is present and filters. (b) TypeScript types a decimal in a view read schema as `number` while the value is a string. The plan corrects the type for reports and projections alike, which changes generated output for a projection with a decimal field. Confirm both, or restrict (b) to reports. +6. **The typed client.** The plan emits the TanStack list hook for a served report in Plan 3, because `servesReadApi` is the one gate the hook generators ask and the route now exists. The spec's R7 puts "a reference dashboard generator (endpoint + React hook per report)" in the library (Plan 5). Is the plain list hook wanted now, or should the UI tier stay off for reports until Plan 5? +7. **Two smaller asymmetries, both left as they fall.** (a) A sourceless report gets a model page in `meta docs` marked "not served", and no API page; that departs from "inert everywhere" for documentation only, as every other sourceless object already has a model page. (b) TypeScript and Python emit a names artifact for a served report as a by-product of the read model; C#, Java and Kotlin keep binding by literal, as Plan 2 left them. Say if either should be made uniform.