Skip to content

feat(reporting): generated read routes and docs for view-backed reports in all five ports (FR-044 Plan 3) - #407

Merged
dmealing merged 21 commits into
mainfrom
fm/fr044-plan3
Oct 6, 2026
Merged

dmealing merged 21 commits into
mainfrom
fm/fr044-plan3

Conversation

@dmealing

@dmealing dmealing commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Intent

Execute FR-044 Plan 3 (report read routes), as written in docs/superpowers/plans/2026-10-04-fr-044-plan-3-report-read-routes.md (merged in PR #400), using subagent-driven development, then gate it and merge.
Plan 3 serves reports over the generated read API after Plan 2 (PR #399) lowered them to SQL views: the api-contract report/ sub-corpus, generated read routes for a view-backed report in five ports (TypeScript, C#, Java, Kotlin, Python), reports in meta docs, and docs, skills and changelog. Tasks 1-11 in the plan's order.
The plan's seven open questions are answered:

  1. Every derived field (dimension and measure) is filterable and sortable; no new @filterable vocabulary.
  2. The route segment follows the existing pluralized snake_case rule (InvoicesByMonth at /invoices_by_months).
  3. /{id} is not mounted for a report; only the framework's own 404 status is asserted.
  4. Fix keyless projections here too: TypeScript and Python stop mounting item routes for a keyless projection, with the same switch a report needs (unit tests only, no new projection/ scenario). Record it as a behaviour change.
  5. Decimals: the corpus asserts a ratio is present and filterable, not its spelling; and the TypeScript view read schema types a decimal as the string it really is, for reports and projections alike (a generated-output change for projections with a decimal field; record it).
  6. DIFFERENT FROM THE PLAN: do not emit the typed client list hook (TanStack) or any UI-tier output for reports in Plan 3. The UI tier stays off for reports until Plan 5. Adjust the plan's tasks, tables and expected outputs accordingly.
  7. Leave both small asymmetries as the plan describes: a sourceless report gets a "not served" model page in meta docs; TypeScript and Python emit a names artifact for a served report while C#, Java and Kotlin bind by literal.
    Standing decisions: compiled SQL views only, no query-time engine; a report lowers only with source.rdb @kind: view; all measures from the report's own @from entity and @via to-one only; UTC only; engine-native numeric precision; measure.derived excluded; nothing outside spec section 3.1; Plans 4-5 are later work.

What Changed

  • Report REST surface: Every view-backed object.report now gets a generated read-only list route (GET /<segment>) with filter, sort and pagination on derived fields, plus 405 on write verbs. No item route is mounted. Implements the cross-port contract in TypeScript, C#, Java, Kotlin and Python.

  • Row and schema generation: TypeScript, Java and Python now generate row types and route/query/DTO artifacts for reports (matching C# and Kotlin). All five ports emit allowlists with all derived fields as filterable and sortable, regardless of @filterable declarations.

  • Documentation: Reports now appear in meta docs with a dedicated model page (entity, view source, row scope, columns) and an API page for served reports. The metaobjects-authoring skill documents report authoring patterns. Owned docs templates gain optional keys for reporting sections.

  • Keyless projection fix: TypeScript and Python stop mounting item routes (/{id}, by-id queries) for read-only projections with no declared identity and no id field. A projection keyed on a non-id field now passes that field to its route mount as idColumn. Unit tests only; no new scenarios.

  • Decimal typing fix: TypeScript view read schemas now type field.decimal as z.string(), matching what Drizzle actually reads from a numeric column. Affects reports and projections with decimal fields.

  • Conformance: New api-contract sub-corpus fixtures/api-contract-conformance/report/ with 12 scenarios and generated lane across all five ports, plus schema and seed. First conformance coverage of field.date literal assertion. Codegen-compile gate extended to all ports. Reporting inert tests updated; Python and Java routes now generate deterministically for reports.

  • No UI tier: Reports do not emit typed client hooks, grids, forms or agent pages in this plan. TypeScript codegen gates UI output on servesClientTier (a new predicate) instead of servesReadApi, which is now true for served reports.

Risk Assessment

✅ Low: Both deep reviews (TS/runtime core, and C#/Java/Kotlin/Python ports + corpus + docs) traced actual logic (route mounting, filterable/sortable derivation, decimal typing, UI-tier exclusion, measure scope) and found all seven intent constraints satisfied with no source-level defects; tests execute real codegen/runtime rather than grepping text.

Testing

Baseline test suite passed (typescript conformance, unit tests, mutation gate). Drove 185 targeted tests across TypeScript code generation, runtime, API contracts, documentation, and inert behavior. All pass. Report list routes serve with filter/sort on derived fields, no item routes mount, write verbs refuse with 405, decimals in view schemas type as strings, sourceless reports stay inert, UI tier correctly excluded.

  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Report list route serves filtered, sorted, paginated data ✅ pass live api-contract-report.test.ts: GET /api/invoice_status_totals with sort/filter/paging returns 200 with expected rows
Filter on report measure (integer and decimal) ✅ pass live api-contract-report.test.ts: filter[invoices][gte]=2 and filter[paidShare][gt]=0 return correct row counts
Sort on report dimension and measure ✅ pass live api-contract-report.test.ts: sort=totalCents:desc returns rows in descending order
No item route mounted for report ✅ pass live api-contract-report.test.ts: GET /api/invoice_status_totals/1 returns 404
Write verbs on report return 405 ✅ pass live api-contract-report.test.ts: POST /api/invoice_status_totals returns 405 with method_not_allowed envelope
Decimal fields type as string in TypeScript view schema ✅ pass live queries-file.test.ts: avgMinutes and longShare contain z.string().nullable()
Keyless read-only mount with itemRoutes: false ✅ pass live mount-read-only.test.ts: itemRoutes=false prevents GET :id and other item routes, enables list and POST 405 only
Sourceless report stays inert ✅ pass live api-contract-report-corpus.test.ts: InvoiceDays (no view source) excluded from served reports
Route segment follows pluralization rule ✅ pass live api-contract-report-corpus.test.ts: InvoicesByMonth maps to /invoices_by_months, InvoiceStatusTotals to /invoice_status_totals
Report read model marks derived fields filterable ✅ pass live reporting-docs.test.ts and report-read-model.test.ts: all dimension/measure fields include @filterable: true in read model
No UI tier generated for report (TanStack hooks, forms, grids) ✅ pass live report-no-ui-tier.test.ts: 12 tests confirm no tanstackQuery, tanstackGrid, formFile, hooks.ts for reports
Pagination with limit/offset/withCount envelope ✅ pass live api-contract-report.test.ts: withCount=1 wraps list in {rows, total} envelope with correct count
Evidence: Test Summary
# FR-044 Plan 3 Validation Summary

## Baseline Test (Configured Command)
Command: scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
Result: PASSED

Tests run:
  ✓ ts build + typecheck
  ✓ conformance: typescript
  ✓ ts unit suites
  ✓ completeness-gate (mutation)

## Key Test Suites Driven

\### Report API Contract & Full-Stack Tests
- api-contract-report.test.ts: 13 pass
  - Report list route with filter, sort, paging
  - Decimal fields in report rows
  - Write verb 405 refusal
  - No item route 404

- api-contract-report-corpus.test.ts: 5 pass
  - Model loads with 4 reports (3 served, 1 sourceless)
  - Route segments correctly pluralized
  - Report row shapes validated

\### Report Mount & Keyless Projection Tests
- mount-read-only.test.ts (Fastify): 22 pass
  - itemRoutes: false mounts no /:id routes
  - POST 405 with custom resource message
  - Keyless default behavior maintained

- mount-read-only.test.ts (Hono): 4 pass
  - Keyless Hono mount behavior

\### Code Generation Tests
- routes-file.test.ts: 29 pass
  - Report routes generation
  - Projection routes with keyless support

- queries-file.test.ts: 11 pass
  - Decimal typing as string in view schema
  - Report query generation (list only, no findById)

- codegen-compile-conformance.test.ts: 6 pass
  - Generated code compiles (reports in fitness.json)

\### UI Tier Tests
- report-no-ui-tier.test.ts: 12 pass
  - No TanStack hooks for reports
  - No form, grid, or UI layer code

\### Documentation Tests
- reporting-docs.test.ts: 17 pass
  - Report pages in meta docs
  - Correct field descriptions

- reporting-site.test.ts: 8 pass
  - Report pages in site output

- reporting-inert.test.ts: 39 pass
  - Sourceless reports inert
  - Keyless projection behavior

\### Metadata Tests
- report-read-model.test.ts: 12 pass
  - Report shape derivation
  - Decimal fields recognized

- report-describe.test.ts: 17 pass
  - Report descriptions

## Total Tests: 185 pass, 0 fail

## Scenarios Exercised

1. ✓ Report list route serves filtered/sorted data
2. ✓ Filter on measure (decimals, integers)
3. ✓ Filter on dimension (attributes, time)
4. ✓ Sort ascending/descending
5. ✓ Pagination with limit/offset/withCount
6. ✓ No item route mounted (404 on GET /{id})
7. ✓ Write verbs return 405
8. ✓ Decimals typed as strings in TypeScript views
9. ✓ Keyless projections stop mounting item routes
10. ✓ Reports with no view source stay inert
11. ✓ No UI tier generated for reports (Plan 5)
12. ✓ Route segments follow pluralization rule
13. ✓ Sourceless reports documented as "Not served"

## Key Behavior Changes Verified

1. Decimal fields in TypeScript view schemas now type as z.string()
2. Keyless read-only mounts support optional itemRoutes: false
3. Report read models mark all filterable fields with @filterable: true
4. Reports serve only when they declare source.rdb with @kind: view
5. No UI-tier codegen (forms, hooks, grids) for reports in this plan

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

🔧 **Rebase** - 1 issue found → auto-fixed ✅
  • ⚠️ docs/CONFORMANCE.md - merge conflict rebasing onto origin/main

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Report list route serves filtered, sorted, paginated data ✅ pass live api-contract-report.test.ts: GET /api/invoice_status_totals with sort/filter/paging returns 200 with expected rows
Filter on report measure (integer and decimal) ✅ pass live api-contract-report.test.ts: filter[invoices][gte]=2 and filter[paidShare][gt]=0 return correct row counts
Sort on report dimension and measure ✅ pass live api-contract-report.test.ts: sort=totalCents:desc returns rows in descending order
No item route mounted for report ✅ pass live api-contract-report.test.ts: GET /api/invoice_status_totals/1 returns 404
Write verbs on report return 405 ✅ pass live api-contract-report.test.ts: POST /api/invoice_status_totals returns 405 with method_not_allowed envelope
Decimal fields type as string in TypeScript view schema ✅ pass live queries-file.test.ts: avgMinutes and longShare contain z.string().nullable()
Keyless read-only mount with itemRoutes: false ✅ pass live mount-read-only.test.ts: itemRoutes=false prevents GET :id and other item routes, enables list and POST 405 only
Sourceless report stays inert ✅ pass live api-contract-report-corpus.test.ts: InvoiceDays (no view source) excluded from served reports
Route segment follows pluralization rule ✅ pass live api-contract-report-corpus.test.ts: InvoicesByMonth maps to /invoices_by_months, InvoiceStatusTotals to /invoice_status_totals
Report read model marks derived fields filterable ✅ pass live reporting-docs.test.ts and report-read-model.test.ts: all dimension/measure fields include @filterable: true in read model
No UI tier generated for report (TanStack hooks, forms, grids) ✅ pass live report-no-ui-tier.test.ts: 12 tests confirm no tanstackQuery, tanstackGrid, formFile, hooks.ts for reports
Pagination with limit/offset/withCount envelope ✅ pass live api-contract-report.test.ts: withCount=1 wraps list in {rows, total} envelope with correct count
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains (baseline)
  • server/typescript/packages/integration-tests/test/api-contract-report.test.ts (13 tests)
  • server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts (5 tests)
  • server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts (22 tests)
  • server/typescript/packages/runtime-ts/test/hono/mount-read-only.test.ts (4 tests)
  • server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts (29 tests)
  • server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts (11 tests)
  • server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts (6 tests)
  • server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts (12 tests)
  • server/typescript/packages/codegen-ts/test/reporting-docs.test.ts (17 tests)
  • server/typescript/packages/docs-site/test/reporting-site.test.ts (8 tests)
  • server/typescript/packages/cli/test/unit/reporting-inert.test.ts (39 tests)
  • server/typescript/packages/metadata/test/report-read-model.test.ts (12 tests)
  • server/typescript/packages/metadata/test/report-describe.test.ts (17 tests)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

A served object.report (its read source is @kind: view) now gets the keyless
read-only Spring surface, generated from its read model: <R>Dto, <R>Repository
(list and count, no findById), <R>FilterAllowlist and <R>Controller (GET list,
POST answers 405, no /{id} mapping). A sourceless report, and one over any
other read-only kind, stays inert.

- RestSurfaceGate gains isServedReport and restShapeOf; isReadOnly is true for
  a served report and for its read model. Every REST-surface loop maps each
  object through restShapeOf and skips on null.
- ReportReadModel sets @filterable on every derived field whose subtype has a
  filter band (dimension or measure), on the detached model only. No
  vocabulary is added.
- A derived enum field is typed by an enum nested in the report's own DTO
  (<Report><Field>), not by the @Of entity's enum: the read model carries the
  members and no extends, so the row stays self-contained.
- The filter allowlist spells a map of more than ten fields with
  Map.ofEntries. Map.of stops at ten pairs, and a wide report (every derived
  field is filterable) crosses it; ten or fewer keep the Map.of form, so
  existing output is byte-identical.
- JavaApiModelBuilder documents a served report as a `report` unit: the row
  DTO, the read-only repository, the one GET route and the allowlist.
- New generated lane ReportGeneratedApiContractConformanceTest drives the 12
  report/ scenarios against the three generated controllers on one Tomcat,
  behind an in-memory seam seeded with what the views return.
- Kotlin: the controller and filter-allowlist loops skip a report until the
  Kotlin port switches them to restShapeOf; without that the widened gate
  would emit a field-less controller for the declared node.
meta docs now documents object.report (Plan 3, Table G), TypeScript.

Model surface: every report gets a page, served or not, built from
reportShape: its @from (linked), its view or 'Not served: declares no
view source', its row scope, and a column table (name, type, nullable,
role, definition). The index lists reports under their own heading. An
entity that declares dimensions, measures or segments, or that a report
reads from, gains a Reporting section.

API surface: one unit for a served report, built from its read model:
the row model, the list query and GET <served path> (plus the Hono GET
when wired). No by-id, no write helper, no schema, no hook. A report
that is not served has no unit, and its model page links to none.

A keyless projection (no identity, no id column) no longer documents
GET <path>/:id or find<Name>ById: the generators stopped emitting both.

Site: the report skip in the link graph and the coverage audit's
deferred bucket are gone. A report is an object page with a Report
section, linked to its @from entity; the entity page gets the same
Reporting section; all reporting kinds and attrs count as rendered.

A model with no report renders every surface byte for byte as before
(pinned by snapshots taken before the change; no existing golden moved).
…ad-only object emits (FR-044)

The sentences meta docs prints about dimensions, measures, segments, row
scopes and why a report is not served now live in one place,
metadata's core/reporting/report-describe.ts, beside reportShape. The
markdown model pages (codegen-ts) and the HTML site (docs-site) both
read them from there; each keeps only its own markup step.

An object's API unit documents create/update/delete, the write REST
verbs and the Insert/Update schemas only when the generators emit them.
The gate is the generators' own dispatch (a read-only-kind source and no
writable one), so a view-backed projection and a report document reads
alone, a keyless one without find-by-id, and a write-through object
keeps its whole write surface. This is a named exception to no-churn:
a read-only projection's API page loses symbols that were never
generated. The accuracy gate now checks a keyed projection, a keyless
projection and a write-through object against the emitted files in
both directions.

The site's coverage audit marks only the attributes the describers
read, so an attribute on a reporting node that no page prints is
reported as a gap. The report page narrows its node with isMetaObject.
…R-044)

A served object.report (read source @kind: view) now gets its row data class,
filter allowlist and a keyless read-only Spring controller, beside the Exposed
table it already had. Each generator reaches the report through
RestSurfaceGate.restShapeOf, replacing the interim skips, and the table's
served check is RestSurfaceGate.isServedReport. A report that is not served
generates nothing.

- The row's enum property is typed by the enum of the entity the item reads,
  the class the table types the column by (KotlinGenUtil.reportEnumClasses is
  the one answer for the table, the row and the controller).
- The row carries no builder and no validation annotation: it is read from the
  view, never bound.
- Api docs document a served report as a unit of kind "report": its row, its
  table, GET <path> and its allowlist.
- New generated lane over the shared report/ api-contract corpus (12 scenarios).

Two controller defects fixed, both in emitters shared with entities and
projections, both byte-neutral for a model that does not hit them:

- A filter on a field.decimal or field.float column threw ClassCastException
  (a 500): the value was coerced to a Double and then cast to the column's
  BigDecimal or Float. Each now has its own coercer, emitted only when such a
  column exists.
- The row mapper and the filter dispatch named a column by its field name, but
  the table declares a field named after a member of Exposed's Table under a
  Column suffix (source is sourceColumn), so that controller did not compile.
  Both now use KotlinNaming.safeColumnProperty, as the sort dispatch did.
…he per-port generators (FR-044)

Docs, agent skills, CHANGELOG, conformance counts (api-contract 61 to 73)
and the Plan 3 document brought in line with what was built. The CHANGELOG
names five corrections that reach models with no report. The project's own
requirements ledger is unchanged: no entry is about serving a report.
…FR-044)

The previous commit gave field.decimal and field.float their own filter
coercers in the generated controller. This pins that output and runs it, on
an ordinary writable entity with no report involved.

- A committed snapshot fixture, entity-with-decimal-float-filter: one
  filterable decimal and one filterable float.
- DecimalFloatFilterControllerRunTest compiles the generated controller and
  drives it over MockMvc against H2. With the old coercer both filters threw
  ClassCastException (Double to BigDecimal, Double to Float).

Also: the unresolved-owner message of KotlinGenUtil.reportEnumClasses named
`dimension "null"` for a min/max measure. It now names the item and the
field it reads.
…sses its own key (FR-044)

The read-only mounts address a row by `idColumn`, which defaults to `id`. The
routes templates never passed it, so a projection whose identity is on another
field (say `code`) mounted `GET /:id` against a column the view does not have.
The mount then ran the query with no WHERE and answered with the view's first
row.

Generators: the Fastify and Hono routes templates, and their reference copies,
emit `idColumn: "<field>"` in the read-only mount options when the by-id field
is not `id`. It is the field `find<Name>ById` reads, through a new
`itemRouteField` (`hasItemRoute` is now "it is defined"). A projection keyed on
`id` keeps its bytes; no golden moved.

Runtime: both read-only mounts answer `404 not_found` when the view declares
columns and has none under `idColumn`, instead of an unfiltered row. That also
makes an owned routes generator that predates the option fail safe.

Owned copies under test-generators and examples are re-synced.
…tions (FR-044)

CHANGELOG: upgrade notes for an owned `routes` / `routes-hono` generator, an
owned `mount-read-only.ts` and a hand-written generator gating on
`servesReadApi`; a new entry for the `idColumn` change; the "Changed" intro
now says which entries change code and which change docs, and counts seven.

Corrections: the sort error code in reporting.md is `invalid_sort`; the
array- and map-valued sort caveat is stated per port; docs/README.md and
docs/CONFORMANCE.md say a report is served; AGENTS.md counts three
generated-lane-only sub-corpora; the authoring skill limits filter and sort to
derived fields with filter operators; the C#, Java and Kotlin codegen
references define a keyless projection as anything but a declared
single-column identity; docs/ports/kotlin.md names the abstract report.

Plan: one proof-table row per named behaviour change (2 to 8), with the
no-churn constraint and the as-built list counting the same seven.

Agent-context conformance fixtures regenerated from the edited skills.
@dmealing
dmealing merged commit 50e0f57 into main Oct 6, 2026
1 check passed
@dmealing
dmealing deleted the fm/fr044-plan3 branch October 6, 2026 03:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant