Skip to content

feat(reporting): lower view-backed object.report to SQL views and read it in every port (FR-044 Plan 2) - #399

Merged
dmealing merged 32 commits into
mainfrom
fm/fr044-plan2
Oct 4, 2026
Merged

dmealing merged 32 commits into
mainfrom
fm/fr044-plan2

Conversation

@dmealing

@dmealing dmealing commented Oct 4, 2026

Copy link
Copy Markdown
Member

Intent

Execute FR-044 Plan 2 (report view lowering), as written in docs/superpowers/plans/2026-10-03-fr-044-plan-2-report-view-lowering.md (merged in PR #398), using subagent-driven development, then gate it and merge.
Plan 2 makes reports real after Plan 1 (PR #397) made them loadable but inert: TypeScript view lowering for object.report and relative-date filter values on Postgres, SQLite/D1 and MySQL; shared persistence-conformance read scenarios green in every port (TypeScript, C#, Java, Kotlin, Python); the reporting section of the metaobjects-authoring skill, docs and changelog. Tasks 1-16 in the plan's order.
The plan's seven open questions are answered, each as the plan already assumes:

  1. A report lowers only when it declares source.rdb with @kind: view; a sourceless report stays inert.
  2. MySQL SQL ships through buildReportViews plus the documented recipe, tested against MySQL 8.4; no CLI surface.
  3. UTC only for v1: instants bucket in UTC and now is the UTC clock; no time-zone vocabulary.
  4. A dimension @via join follows the settled projection rule unchanged (a required belongs-to FK is INNER).
  5. C# and Kotlin generate a report's typed row and table object in this plan; TypeScript, Java and Python generators keep skipping reports until Plan 3.
  6. Report views appear on the meta docs agent schema page; model and API pages for reports wait for Plan 3.
  7. count counts rows whose @of column is not null; a sum of nothing is null; ratio and average precision are engine-native.
    Standing decisions: no query-time engine (compiled SQL views only); all measures from the report's own @from entity and @via to-one only; relative-date values legal only in segment, measure.aggregate and object.report filters; measure.derived excluded; nothing outside spec section 3.1; Plans 3-5 are later work.

What Changed

  • TypeScript lowers reports to SQL views. An object.report with a read-only source.rdb @kind: view is now lowered by meta migrate on Postgres, SQLite and D1 (extractReportSpec + emitReportViewDdl; a changed view is dropped and re-created; refusals by name for a TPH-subtype @from, a @via hop with no identity.reference, an empty in list, and a tableless @from). buildReportViews(root, { dialect: "mysql" }) emits MySQL SQL with a new recipe in docs/recipes/mysql.md. Time-grain and relative-date filter SQL (time-sql.ts) is UTC-only, weeks start Monday; a sourceless report still generates nothing.
  • Every port reads the view. The TypeScript and Python ObjectManager and Java OMDB (list and count with filter/sort/limit on derived fields; by-id and writes refused; Java OQL builds rows from a report result class) read view-backed reports; C# generates a keyless EF Core row type plus DbContext mapping and Kotlin an Exposed table object, both refusing reports they cannot lower (e.g. derived fields over field.object, Kotlin hard-keyword or colliding member names). meta docs lists the view on the agent schema page. Also fixed: Java OMDB resolves a projection's physical name from @view before legacy @table, and Kotlin reserves Exposed Table member names.
  • Shared conformance and docs. Six persistence scenarios (fixtures/persistence-conformance/queries/report-*.yaml) plus the report-shapes.json artifact hold all five ports to the same columns and semantics (count of non-null @of is zero over nothing, null sum, null ratio over zero); the metaobjects-authoring skill gains references/reporting.md, and the reporting feature doc, port docs, and CHANGELOG describe the lowering and its remaining limits (no routes, typed clients, filter allowlists, api-docs, measure.derived, or query-time grouping — Plan 3+).

Risk Assessment

✅ Low: No substantiated source findings: every risky surface (dialect-specific SQL, cross-port shape parity, read/write refusals, fail-closed refusals naming the report) is either verified against a real engine here or gated by executable behavior tests (six shared scenarios on real Postgres/SQLite/MySQL, byte-matched shape artifact, convergence tests), and the near-miss wrong-SQL paths I constructed are each unreachable behind a loader rule.

Testing

Baseline scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains was green before this run. I then drove the change live on all five ports: the six shared persistence-conformance report scenarios read through each port's real runtime against a containerized Postgres (TypeScript ObjectManager 6/6; C# xUnit theories 33/33; Java QueryScenarioTests 33/33; Kotlin Exposed QueryScenarioConformanceTest 33/33; Python runner 6/6 report-filtered), and the TS lowering was exercised against all three engines end-to-end (report-views-pg 14, report-views-sqlite 11, report-views-mysql 13, all converging to an empty second diff and returning the scenario rows). A hand-driven CLI pass lowered the canonical reports through meta docs --agent (all six report views on the schema page) and reproduced the adversarial refusal — a view-backed report over a sourceless @from fails with 'report WidgetTotals: @from Widget has no table...', while the lowerable control report in the same model renders; transcripts saved as artifacts alongside a SQLite transcript showing emitted view SQL and actual rows. Port report unit/read suites (C# 74, Java ReportReadTest 22 + shape tests, Kotlin 30, Python 40) and the TS inert corpus (31) all passed. The complete regression suite scripts/ci-local.sh --strict-toolchains was launched and was still running its final integration lane at phase close; its completion notification will land in this session. Worktree left clean; no source files modified.

  • Live validation: ✅ go - 11 of 12 scenarios driven live against the product
Scenario Result Live Evidence
A view-backed report lowers to SQL, converges under migrate (emit, apply, re-diff empty), and returns the expected rows on Postgres — including non-UTC session bucketing and a keyword-named measure ✅ pass live bun test test/report-views-pg.test.ts — 14 pass against a real Testcontainers Postgres
The same lowering converges byte-stably on SQLite and reads the expected rows ✅ pass live bun test test/report-views-sqlite.test.ts — 11 pass; plus live demo transcript (artifact sqlite-report-views-rows.txt) showing emitted SQL, actual rows and empty second diff
MySQL 8.4 ships the report views through buildReportViews and returns the documented values (longShare 0.7500/0.0000, ratio 0.6667) ✅ pass live bun test test/report-views-mysql.test.ts — 13 pass against a real Testcontainers MySQL 8.4
The TypeScript ObjectManager reads the six shared report scenarios (totals, totals-empty, grouped measures, time grains, hour/date, relative date) on a real Postgres ✅ pass live bun test test/query.test.ts -t report — 6 pass
The C# runtime (EF Core over AppDbContext) reads every shared query scenario including the six report scenarios on a real Postgres ✅ pass live dotnet test MetaObjects.IntegrationTests --filter FullyQualifiedName~QueryScenarioTests — 33/33 pass
The Java runtime reads every shared query scenario including the six report scenarios on a real Postgres, and OMDB reads a report over a real engine executing the lowered view ✅ pass live mvn -f integration-tests/pom.xml test -Dtest=QueryScenarioTests — 33/33 pass; mvn -pl omdb test -Dtest=ReportReadTest — 22 pass (Derby)
The Kotlin runtime (Exposed tables incl. the generated report tables) reads every shared query scenario including the six report scenarios on a real Postgres ✅ pass live mvn -f integration-tests-kotlin/pom.xml test -Dtest=QueryScenarioConformanceTest — 33/33 pass
The Python ObjectManager reads the six shared report scenarios on a real Postgres ✅ pass live pytest tests/integration/test_query_scenarios.py -k report — 6/6 pass
A sourceless report stays inert: no view, no migrate statement, no runtime read — while a view-backed report in a comparable model lowers and appears ✅ pass live bun test test/unit/reporting-inert.test.ts — 31 pass; CLI control drive: GadgetTotals (view source) renders v_gadget_totals, WidgetTotals (sourceless @from) refuses
Adversarial: a report that cannot be lowered (view source over a @from with no writable table) is refused with an error naming the report and the entity, and the schema page is skipped rather than sta… ✅ pass live live CLI drive transcript (artifact cli-refusal-sourceless-from.txt): 'report WidgetTotals: @from Widget has no table (it is abstract or declares no writable source.rdb)...'
Report views appear on the meta docs agent schema page, and a model with no report keeps its pre-report page byte for byte ✅ pass live live CLI drive on the canonical fixture produced agent/schema.md listing all six report views (artifacts agent-schema-page-with-report-views.md, agent-schema-views-excerpt.md); the no-report byte-iden…
Complete regression: the repository's run-everything command passes on this change ⏸️ untested no Wall clock, not a missing tool or credential: the full suite (all five port conformance lanes, Java reactor, 5-port docker integration matrix) had not finished when the phase was forced to close. It i…
Evidence: meta docs --agent schema page with all six report views (live CLI drive)
<!-- @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT. -->

# Schema

The physical shape of the `postgres` database this model generates. Read it before writing a query, a migration, or anything that names a table or a column.

- The **DDL is not repeated here.** The migration files are the DDL, they are generated, and they are what runs — this page describes the schema they produce.
- Change the schema by changing the **metadata** and running `meta migrate`. Never hand-apply SQL to a live database: it drifts from the migration history and collides at the next migrate.
- `Field` and `Declared` are the METADATA names. Edit those; the column name follows.

> **Nothing in this model declares a `description`.** The tables and columns below are named but not explained. Adding `description` to an entity or a field is the single highest-value change to this page: it is what tells a reader *which* of two plausible columns to use, and it is the same text `meta migrate` emits as a `COMMENT ON`, so it lands in the database too.

## Tables

\### `all_types`

Declared by `fitness::AllTypes`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `id` | `id` | `field.uuid` | `UUID` | no | generated uuid | PK |
| `s_val` | `sVal` | `field.string` | `VARCHAR(200)` | no |  |  |
| `i_val` | `iVal` | `field.int` | `INTEGER` | no |  |  |
| `l_val` | `lVal` | `field.long` | `BIGINT` | no |  |  |
| `d_val` | `dVal` | `field.double` | `DOUBLE PRECISION` | no |  |  |
| `f_val` | `fVal` | `field.float` | `REAL` | no |  |  |
| `dec_val` | `decVal` | `field.decimal` | `NUMERIC(18,6)` | no |  |  |
| `b_val` | `bVal` | `field.boolean` | `BOOLEAN` | no |  |  |
| `date_val` | `dateVal` | `field.date` | `DATE` | no |  |  |
| `time_val` | `timeVal` | `field.time` | `TIME` | no |  |  |
| `ts_val` | `tsVal` | `field.timestamp` | `TIMESTAMP` | no |  |  |
| `ts_tz_val` | `tsTzVal` | `field.timestamp` | `TIMESTAMPTZ` | no |  |  |
| `money_val` | `moneyVal` | `field.currency` | `BIGINT` | no |  |  |
| `enum_val` | `enumVal` | `field.enum` | `TEXT` | no |  |  |
| `int_enum_val` | `intEnumVal` | `field.enum` | `INTEGER` | yes |  |  |
| `uuid_val` | `uuidVal` | `field.uuid` | `UUID` | no |  |  |
| `uri_val` | `uriVal` | `field.uri` | `TEXT` | no |  |  |
| `inet_val` | `inetVal` | `field.inet` | `INET` | no |  |  |
| `inet6_val` | `inet6Val` | `field.inet` | `INET` | no |  |  |
| `settings` | `settings` | `field.object` | `JSONB` | yes |  |  |
| `labels` | `labels` | `field.object` | `JSONB` | yes |  |  |

**Checks**

- `all_types_enum_val_chk` — `"enum_val" IN ('LOW', 'MEDIUM', 'HIGH')`
- `all_types_int_enum_val_chk` — `"int_enum_val" IN (0, 5, 9)`

\### `assets`

Declared by `fitness::Asset`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `id` | `id` | `field.uuid` | `UUID` | no | generated uuid | PK |
| `owner_id` | `ownerId` | `field.uuid` | `UUID` | no |  |  |
| `external_id` | `externalId` | `field.string` | `UUID` | yes |  |  |
| `payload` | `payload` | `field.string` | `JSONB` | yes |  |  |
| `recorded_at` | `recordedAt` | `field.timestamp` | `TIMESTAMPTZ` | no |  |  |
| `observed_at` | `observedAt` | `field.timestamp` | `TIMESTAMP` | no |  |  |
| `as_of_date` | `asOfDate` | `field.date` | `DATE` | no |  |  |
| `at_time` | `atTime` | `field.time` | `TIME` | no |  |  |

\### `auths`

Declared by `fitness::Auth`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `id` | `id` | `field.long` | `BIGINT` | no | auto-increment | PK |
| `type` | `type` | `field.enum` | `TEXT` | yes |  |  |
| `reference` | `reference` | `field.string` | `VARCHAR(80)` | no |  |  |
| `quantity` | `quantity` | `field.int` | `INTEGER` | yes |  |  |
| `copay_amount` | `copayAmount` | `field.decimal` | `NUMERIC(10,2)` | yes |  |  |
| `approver` | `approver` | `field.string` | `VARCHAR(80)` | yes |  |  |
| `urgency` | `urgency` | `field.string` | `VARCHAR(16)` | yes |  |  |

**Checks**

- `auths_type_chk` — `"type" IN ('Bridge', 'Copay', 'PriorAuth', 'Referral')`

\### `follows`

Declared by `fitness::Follow`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `follower_id` | `followerId` | `field.long` | `BIGINT` | no |  | PK (composite) · FK → `people` |
| `followee_id` | `followeeId` | `field.long` | `BIGINT` | no |  | PK (composite) · FK → `people` |

**Foreign keys**

- `follows_follower_id_fk` · `follower_id` → `people`(`id`)
- `follows_followee_id_fk` · `followee_id` → `people`(`id`)

\### `friendships`

Declared by `fitness::Friendship`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `person_a_id` | `personAId` | `field.long` | `BIGINT` | no |  | PK (composite) · FK → `people` |
| `person_b_id` | `personBId` | `field.long` | `BIGINT` | no |  | PK (composite) · FK → `people` |

**Foreign keys**

- `friendships_person_a_id_fk` · `person_a_id` → `people`(`id`)
- `friendships_person_b_id_fk` · `person_b_id` → `people`(`id`)

\### `measurements`

Declared by `fitness::Measurement`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `id` | `id` | `field.long` | `BIGINT` | no | auto-increment | PK |
| `temp_c` | `tempC` | `field.float` | `REAL` | no |  |  |
| `mass_kg` | `massKg` | `field.double` | `DOUBLE PRECISION` | no |  |  |
| `precise_kg` | `preciseKg` | `field.decimal` | `NUMERIC(9,4)` | no |  |  |

\### `nodes`

Declared by `fitness::Node`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `id` | `id` | `field.long` | `BIGINT` | no | auto-increment | PK |
| `label` | `label` | `field.string` | `VARCHAR(80)` | no |  |  |
| `parent_id` | `parentId` | `field.long` | `BIGINT` | yes |  | FK → `nodes` |

**Foreign keys**

- `nodes_parent_id_fk` · `parent_id` → `nodes`(`id`)

\### `people`

Declared by `fitness::Person`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `id` | `id` | `field.long` | `BIGINT` | no | auto-increment | PK |
| `name` | `name` | `field.string` | `VARCHAR(80)` | no |  |  |

\### `post_referrals`

Declared by `fitness::PostReferral`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `post_id` | `postId` | `field.long` | `BIGINT` | no |  | PK (composite) · FK → `posts` |
| `referral_id` | `referralId` | `field.long` | `BIGINT` | no |  | PK (composite) · FK → `auths` |

**Foreign keys**

- `post_referrals_post_id_fk` · `post_id` → `posts`(`id`)
- `post_referrals_referral_id_fk` · `referral_id` → `auths`(`id`)

\### `post_tags`

Declared by `fitness::PostTag`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `post_id` | `postId` | `field.long` | `BIGINT` | no |  | PK (composite) · FK → `posts` |
| `tag_id` | `tagId` | `field.long` | `BIGINT` | no |  | PK (composite) · FK → `tags` |

**Foreign keys**

- `post_tags_post_id_fk` · `post_id` → `posts`(`id`)
- `post_tags_tag_id_fk` · `tag_id` → `tags`(`id`)

\### `posts`

Declared by `fitness::Post`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `id` | `id` | `field.long` | `BIGINT` | no | auto-increment | PK |
| `title` | `title` | `field.string` | `VARCHAR(200)` | no |  |  |

\### `programs`

Declared by `fitness::Program`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `id` | `id` | `field.long` | `BIGINT` | no | auto-increment | PK |
| `title` | `title` | `field.string` | `VARCHAR(200)` | no |  | unique |
| `price_cents` | `priceCents` | `field.currency` | `BIGINT` | no |  |  |
| `status` | `status` | `field.enum` | `TEXT` | no |  | indexed (composite) |
| `created_ts` | `createdAt` | `field.timestamp` | `TIMESTAMP` | no |  |  |

**Indexes**

- `byTitle` · unique · on `title`
- `idx_programs_title_status` · index · on `title`, `status`

**Checks**

- `programs_status_chk` — `"status" IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')`

\### `tags`

Declared by `fitness::Tag`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `id` | `id` | `field.long` | `BIGINT` | no | auto-increment | PK |
| `name` | `name` | `field.string` | `VARCHAR(80)` | no |  |  |

\### `weeks`

Declared by `fitness::Week`.

| Column | Field | Declared | SQL type | Null | Default | Key |
|---|---|---|---|---|---|---|
| `id` | `id` | `field.long` | `BIGINT` | no | auto-increment | PK |
| `program_id` | `programId` | `field.long` | `BIGINT` | no |  | FK → `programs` |
| `label` | `label` | `field.string` | `VARCHAR(80)` | yes |  |  |
| `duration_minutes` | `durationMinutes` | `field.int` | `INTEGER` | no |  |  |

**Foreign keys**

- `weeks_program_id_fk` · `program_id` → `programs`(`id`) · on delete `cascade` · on update `cascade`

## Views

A view is generated from its projection's `origin.*` children or its report's dimensions and measures — it is derived, never hand-written. Editing the view SQL directly is drift the tool cannot see.

\### `v_asset_activity`

Declared by `fitness::AssetActivity`.

\### `v_fitness_totals`

Declared by `fitness::FitnessTotals`.

\### `v_program`

Declared by `fitness::ProgramView`.

| Column | Lineage |
|---|---|
| `id` | passthrough from `Program.id` |
| `title` | passthrough from `Program.title` |
| `status` | passthrough from `Program.status` |

\### `v_program_minutes`

Declared by `fitness::ProgramMinutes`.

\### `v_program_stat`

Declared by `fitness::ProgramStat`.

| Column | Lineage |
|---|---|
| `program_id` | passthrough from `Program.id` |
| `week_count` | `count` of `Week.id` via `Program.weeks` |
| `total_minutes` | `sum` of `Week.durationMinutes` via `Program.weeks` |
| `avg_minutes` | `avg` of `Week.durationMinutes` via `Program.weeks` |
| `min_minutes` | `min` of `Week.durationMinutes` via `Program.weeks` |
| `max_minutes` | `max` of `Week.durationMinutes` via `Program.weeks` |

\### `v_programs_by_month`

Declared by `fitness::ProgramsByMonth`.

\### `v_programs_by_week`

Declared by `fitness::ProgramsByWeek`.

\### `v_recent_programs`

Declared by `fitness::RecentPrograms`.

## Relationships

- `fitness::Program.weeks` · `composition` · one-to-many → `Week`
- `fitness::Post.tags` · `association` · many-to-many → `Tag` · through `PostTag`
- `fitness::Post.referrals` · `association` · many-to-many → `ReferralAuth` · through `PostReferral`
- `fitness::Person.following` · `association` · many-to-many → `Person` · through `Follow`
- `fitness::Person.friends` · `association` · many-to-many → `Person` · through `Friendship`
- `fitness::ReferralAuth.posts` · `association` · many-to-many → `Post` · through `PostReferral`

## Enums

Members are the values the wire and the generated types use. Whether the DATABASE also refuses a value outside the set is that table's **Checks** above — an `@isArray` enum carries none, and neither does a view column.

| Field | Members | Storage |
|---|---|---|
| `fitness::Program.status` | `DRAFT`, `PUBLISHED`, `ARCHIVED` | string-backed |
| `fitness::Auth.type` | `Bridge`, `Copay`, `PriorAuth`, `Referral` | string-backed |
| `fitness::BridgeAuth.type` | `Bridge`, `Copay`, `PriorAuth`, `Referral` | string-backed |
| `fitness::CopayAuth.type` | `Bridge`, `Copay`, `PriorAuth`, `Referral` | string-backed |
| `fitness::PriorAuthAuth.type` | `Bridge`, `Copay`, `PriorAuth`, `Referral` | string-backed |
| `fitness::ReferralAuth.type` | `Bridge`, `Copay`, `PriorAuth`, `Referral` | string-backed |
| `fitness::AllTypes.enumVal` | `LOW`, `MEDIUM`, `HIGH` | string-backed |
| `fitness::AllTypes.intEnumVal` | `DRAFT`, `PUBLISHED`, `ARCHIVED` | int-backed (DRAFT=0, PUBLISHED=5, ARCHIVED=9) |
Evidence: Views section excerpt: v_asset_activity, v_fitness_totals, v_program_minutes, v_programs_by_month, v_programs_by_week, v_recent_programs

**Foreign keys**

- `weeks_program_id_fk` · `program_id` → `programs`(`id`) · on delete `cascade` · on update `cascade`

## Views

A view is generated from its projection's `origin.*` children or its report's dimensions and measures — it is derived, never hand-written. Editing the view SQL directly is drift the tool cannot see.

\### `v_asset_activity`

Declared by `fitness::AssetActivity`.

\### `v_fitness_totals`

Declared by `fitness::FitnessTotals`.

\### `v_program`

Declared by `fitness::ProgramView`.

| Column | Lineage |
|---|---|
| `id` | passthrough from `Program.id` |
| `title` | passthrough from `Program.title` |
| `status` | passthrough from `Program.status` |

\### `v_program_minutes`

Declared by `fitness::ProgramMinutes`.

\### `v_program_stat`

Declared by `fitness::ProgramStat`.

| Column | Lineage |
|---|---|
| `program_id` | passthrough from `Program.id` |
| `week_count` | `count` of `Week.id` via `Program.weeks` |
| `total_minutes` | `sum` of `Week.durationMinutes` via `Program.weeks` |
| `avg_minutes` | `avg` of `Week.durationMinutes` via `Program.weeks` |
| `min_minutes` | `min` of `Week.durationMinutes` via `Program.weeks` |
| `max_minutes` | `max` of `Week.durationMinutes` via `Program.weeks` |

\### `v_programs_by_month`

Declared by `fitness::ProgramsByMonth`.

\### `v_programs_by_week`

Declared by `fitness::ProgramsByWeek`.

\### `v_recent_programs`

Declared by `fitness::RecentPrograms`.

## Relationships
Evidence: CLI refusal transcript: view-backed report over a sourceless @from (error names report and entity; page skipped, not silently wrong)
meta docs — wrote model surface skipped; no api pages; agent/schema.md → /tmp/nm-fr044-adv/docs-reject2
meta: docs: agent/schema.md skipped — the expected schema could not be built (report 'WidgetTotals': @from 'Widget' has no table (it is abstract or declares no writable source.rdb), so no view can be derived. Give 'Widget' a source, or remove the report's source.). Run 'meta migrate' for the full diagnosis.
meta docs — wrote model surface skipped; no api pages → /tmp/nm-fr044-adv/docs-reject3
Evidence: SQLite live run: emitted CREATE VIEW SQL for four reports plus rows read back (weeks 5, totalMinutes 285, longShare 0.6; ProgramMinutes 240/0.75) and empty second diff
=== LOWERED REPORT VIEWS (sqlite, literal naming) ===

-- v_program_minutes
 SELECT w."programId" AS "program", p."title" AS "programTitle", COUNT(w."id") AS "weeks", COUNT(CASE WHEN w."durationMinutes" >= 60 THEN w."id" END) AS "longWeeks", COUNT(DISTINCT w."label") AS "labels", COUNT(DISTINCT CASE WHEN w."programId" IS NOT NULL AND w."durationMinutes" IS NOT NULL THEN json_array(w."programId", w."durationMinutes") END) AS "slots", SUM(w."durationMinutes") AS "totalMinutes", AVG(w."durationMinutes") AS "avgMinutes", MIN(w."durationMinutes") AS "minMinutes", MAX(w."durationMinutes") AS "maxMinutes", CAST(COUNT(CASE WHEN w."durationMinutes" >= 60 THEN w."id" END) AS REAL) / NULLIF(COUNT(w."id"), 0) AS "longShare" FROM "weeks" w INNER JOIN "programs" p ON p."id" = w."programId" GROUP BY w."programId", p."title"

-- v_fitness_totals
 SELECT COUNT(w."id") AS "weeks", SUM(w."durationMinutes") AS "totalMinutes", CAST(COUNT(CASE WHEN w."durationMinutes" >= 60 THEN w."id" END) AS REAL) / NULLIF(COUNT(w."id"), 0) AS "longShare" FROM "weeks" w

-- v_programs_by_month
 SELECT date(p."created_ts", 'start of month') AS "createdAtMonth", p."status" AS "status", COUNT(p."id") AS "programs", SUM(CASE WHEN p."status" = 'PUBLISHED' THEN p."priceCents" END) AS "listValue" FROM "programs" p GROUP BY date(p."created_ts", 'start of month'), p."status"

-- v_recent_programs
 SELECT COUNT(p."id") AS "programs" FROM "programs" p WHERE p."created_ts" >= strftime('%Y-%m-%dT%H:%M:%f', 'now', '-30 days')

=== ROWS READ BACK (SELECT * FROM each report view) ===

-- v_fitness_totals (no dimensions: one row for the whole table)
[
 {
  "weeks": 5,
  "totalMinutes": 285,
  "longShare": 0.6
 }
]

-- v_program_minutes (dimensions + every measure kind)
[
 {
  "program": 1,
  "programTitle": "Foundations",
  "weeks": 4,
  "longWeeks": 3,
  "labels": 2,
  "slots": 3,
  "totalMinutes": 240,
  "avgMinutes": 60,
  "minMinutes": 30,
  "maxMinutes": 90,
  "longShare": 0.75
 },
 {
  "program": 2,
  "programTitle": "Strength",
  "weeks": 1,
  "longWeeks": 0,
  "labels": 1,
  "slots": 1,
  "totalMinutes": 45,
  "avgMinutes": 45,
  "minMinutes": 45,
  "maxMinutes": 45,
  "longShare": 0
 }
]

-- convergence: a second diff is empty
changes: 2, blocked: 2
- Outcome: ⚠️ 1 warning across 1 run (36m37s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

⚠️ **Test** - 1 warning
  • ⚠️ The complete regression suite (scripts/ci-local.sh --strict-toolchains) was launched and was still executing its final lane (integration-tests, 5-port + docker: fresh postgres:16 + mysql:8.4 containers active) when this phase closed. Baseline --only ts-fast --only ts-unit passed, and every port's full query-scenario suite was driven directly and green (C# 33/33, Java 33/33, Kotlin 33/33, Python report 6/6, TS 6/6 + engine tests), so the missing outcome is the remaining breadth (other 27 TS scenarios, full Python suite, codegen-compile gates, hygiene lanes). The run's exit notification will arrive in this session; its log (tail -120) is in task output bjibylid0.
  • Live validation: ✅ go - 11 of 12 scenarios driven live against the product
Scenario Result Live Evidence
A view-backed report lowers to SQL, converges under migrate (emit, apply, re-diff empty), and returns the expected rows on Postgres — including non-UTC session bucketing and a keyword-named measure ✅ pass live bun test test/report-views-pg.test.ts — 14 pass against a real Testcontainers Postgres
The same lowering converges byte-stably on SQLite and reads the expected rows ✅ pass live bun test test/report-views-sqlite.test.ts — 11 pass; plus live demo transcript (artifact sqlite-report-views-rows.txt) showing emitted SQL, actual rows and empty second diff
MySQL 8.4 ships the report views through buildReportViews and returns the documented values (longShare 0.7500/0.0000, ratio 0.6667) ✅ pass live bun test test/report-views-mysql.test.ts — 13 pass against a real Testcontainers MySQL 8.4
The TypeScript ObjectManager reads the six shared report scenarios (totals, totals-empty, grouped measures, time grains, hour/date, relative date) on a real Postgres ✅ pass live bun test test/query.test.ts -t report — 6 pass
The C# runtime (EF Core over AppDbContext) reads every shared query scenario including the six report scenarios on a real Postgres ✅ pass live dotnet test MetaObjects.IntegrationTests --filter FullyQualifiedName~QueryScenarioTests — 33/33 pass
The Java runtime reads every shared query scenario including the six report scenarios on a real Postgres, and OMDB reads a report over a real engine executing the lowered view ✅ pass live mvn -f integration-tests/pom.xml test -Dtest=QueryScenarioTests — 33/33 pass; mvn -pl omdb test -Dtest=ReportReadTest — 22 pass (Derby)
The Kotlin runtime (Exposed tables incl. the generated report tables) reads every shared query scenario including the six report scenarios on a real Postgres ✅ pass live mvn -f integration-tests-kotlin/pom.xml test -Dtest=QueryScenarioConformanceTest — 33/33 pass
The Python ObjectManager reads the six shared report scenarios on a real Postgres ✅ pass live pytest tests/integration/test_query_scenarios.py -k report — 6/6 pass
A sourceless report stays inert: no view, no migrate statement, no runtime read — while a view-backed report in a comparable model lowers and appears ✅ pass live bun test test/unit/reporting-inert.test.ts — 31 pass; CLI control drive: GadgetTotals (view source) renders v_gadget_totals, WidgetTotals (sourceless @from) refuses
Adversarial: a report that cannot be lowered (view source over a @from with no writable table) is refused with an error naming the report and the entity, and the schema page is skipped rather than sta… ✅ pass live live CLI drive transcript (artifact cli-refusal-sourceless-from.txt): 'report WidgetTotals: @from Widget has no table (it is abstract or declares no writable source.rdb)...'
Report views appear on the meta docs agent schema page, and a model with no report keeps its pre-report page byte for byte ✅ pass live live CLI drive on the canonical fixture produced agent/schema.md listing all six report views (artifacts agent-schema-page-with-report-views.md, agent-schema-views-excerpt.md); the no-report byte-iden…
Complete regression: the repository's run-everything command passes on this change ⏸️ untested no Wall clock, not a missing tool or credential: the full suite (all five port conformance lanes, Java reactor, 5-port docker integration matrix) had not finished when the phase was forced to close. It i…
  • 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, green)
  • cd server/typescript/packages/integration-tests && bun test test/report-views-pg.test.ts (14 pass, real Postgres via Testcontainers)
  • cd server/typescript/packages/integration-tests && bun test test/report-views-sqlite.test.ts (11 pass)
  • cd server/typescript/packages/integration-tests && bun test test/report-views-mysql.test.ts (13 pass, real MySQL 8.4 via Testcontainers)
  • cd server/typescript/packages/integration-tests && bun test test/query.test.ts -t report (6/6 report scenarios through ObjectManager on real Postgres)
  • bun test test/report-shapes-artifact.test.ts (3 pass, cross-port shape byte contract)
  • bun test test/regen-reports-divergence.test.ts (6 pass)
  • cd server/typescript/packages/cli && bun test test/unit/reporting-inert.test.ts (31 pass, sourceless reports generate nothing)
  • dotnet test MetaObjects.IntegrationTests --filter FullyQualifiedName~QueryScenarioTests (33/33 on real Postgres, six report theories included)
  • dotnet test server/csharp --filter ReportShape|ReportingInert|ReportRow (13 + 61 pass)
  • uv run --extra integration pytest tests/integration/test_query_scenarios.py -k report (6/6 on real Postgres); pytest tests/test_report_shape.py tests/test_report_read_model.py tests/runtime/test_object_manager_report.py (40 pass)
  • mvn -f integration-tests/pom.xml test -Dtest=QueryScenarioTests (33/33 on real Postgres); mvn -pl omdb -am test -Dtest=ReportReadTest (22 pass, embedded Derby executing the lowered view); mvn -pl metadata test -Dtest=ReportShapeTest,ReportReadModelTest
  • mvn -f integration-tests-kotlin/pom.xml test -Dtest=QueryScenarioConformanceTest (33/33 on real Postgres); mvn -pl codegen-kotlin test -Dtest=KotlinReportTableGeneratorTest,KotlinReservedTableMembersTest (30 pass)
  • meta docs --agent against a consumer project over the canonical fixture (report views on agent/schema.md) and against an adversarial sourceless-@from model (named refusal)
  • ad-hoc SQLite demo lowering the canonical reports through buildProjectionViews + migrate-ts and reading rows back (evidence artifact)
  • scripts/ci-local.sh --strict-toolchains (full regression — launched, still in its final integration lane at phase close)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…ews; canonical reports and shape artifact (FR-044)
… on Postgres and SQLite (FR-044)

Postgres: convergence x3, the Task 10 values read straight off the six canonical
views, UTC buckets under a New York session zone, empty-group row, INNER vs LEFT OUTER
join, relative window, and a changed report taking the drop-and-create path.
SQLite: convergence (verbatim text), the same values, week boundary, quarter and year
grains, relative window, tuple distinct count via json_array, and the hour bucket's
literal pinned to the .000Z spelling the TS adapters store. No emitter change.
…ipe (FR-044)

A MySQL 8.4 value test creates the six canonical report views under the default
sql_mode (ONLY_FULL_GROUP_BY asserted, not assumed) and pins the Task 10 rows, the
2/3 ratio at 0.6667 (Review Focus 5), the bigint type of a SIGNED-cast sum, and the
Table D grain and Table E relative-date values. No emitter change was needed.

docs/recipes/mysql.md gains a Reports section (meta migrate still does not own a
MySQL schema); the skill reference and the regenerated agent-context goldens follow.
…e recipe's declaration in the test (FR-044)

buildReportViews skips a report whose source.rdb is @Unmanaged, so the recipe's
@Unmanaged: true declaration returned no SQL. The recipe now declares @kind: view only
(meta migrate never targets MySQL, so nothing manages the view either way) and says that an
unmanaged source is skipped. The MySQL test now reads the recipe's own fenced declaration,
runs the recipe's loadDirectory + buildReportViews shape, and asserts one view, and none
once @Unmanaged is added. Tables and views are created in beforeAll after dropping stale
ones, so tests run alone (-t) and rerun against a persistent server.
Six shared read scenarios over the view-backed reports in the canonical model:
grouped measures, totals, totals over an empty table, time grains, hour and
week buckets, and a relative-date filter. List and count only; no get, no write.
The TypeScript runner discovers them from queries/ with no list to update and
passes all 33 query scenarios. Persistence corpus count 33 -> 39 (27 -> 33 query)
in docs/CONFORMANCE.md; README gains a Report scenarios subsection. Other ports'
persistence lanes are red on these until their own tasks.
… no report view; correct reporting doc details (FR-044)
KotlinExposedTableGenerator emits the read-only Exposed table of an
object.report that declares a source.rdb @kind: view, with one column per
derived field (contract Table B) taken from the JVM ReportShape. A report
with no view, or over a kind the lowering skips, still generates nothing,
and every other Kotlin generator still skips reports.

A report table binds by literal (no names artifact is emitted for a
report), types an enum column by the enum of the entity the dimension
reads, and reads a derived decimal with no declared precision at 38,18 so
Exposed does not round a ratio to four places. A derived field named after
a Kotlin keyword, or two that land on one column property, is a generation
error naming the report and the item.

Also reserves schemaName as an Exposed Table member in safeColumnProperty:
a column property of that name did not compile.

Six hand-written reference tables put the Kotlin persistence lane on the
six shared report scenarios.
…what cannot be lowered (FR-044)

The lowering and the read shape disagreed with validateReporting about what a
loadable model means. Each case loaded clean and then failed, or was silently
wrong, at migrate or at read.

- @Of and @via resolve in the package of the entity that DECLARES the dimension
  or measure, not the @from entity's. A member inherited from a base in another
  package now resolves, and a same-named entity in the report's package can no
  longer capture the reference and mistype the column.
- Without @via the field is read from @from, and a @via walk starts at @from,
  as the loader's does.
- A dotted @measures item (Sale.total, loader rule R3) names the measure by its
  last segment. One canonical report now uses the dotted form; schema.postgres.sql
  and report-shapes.json are byte-identical.
- A report is classified (skip / @SQL / derive) by the same source its view is
  named by and the runtime reads: primary, else first. A replica declared first
  no longer decides it. The report-shapes generator uses the same selector.
- A derived report @from a TPH subtype is refused: the subtype shares its base's
  table, so the view aggregated every subtype's rows. An @SQL or @Unmanaged
  report is the author's body and is not refused.
- A @via hop with no identity.reference behind it says which hop and what it
  needs. An empty in list, a time dimension without a grain and a grain outside
  the closed set are refused by name instead of reaching the DDL.
…they do not own (FR-044)

- MySQL: drop only the views and tables this file creates, by name, instead of
  every view in the database (a shared test database lost unrelated views).
- Postgres: changing a report runs a plain migrate with no dropView allowance,
  so a broken drop-and-create pairing would fail the test.
- The NULL-component tuple count is read through a lowered view over nullable
  columns on Postgres and SQLite, not through hand-written SQL.
- A decimal sum and a double / float sum are created and read on Postgres, with
  the view's column types asserted.
- Every inline model asserts the loader returned no errors.
- The SQLite inet residue is narrowed to the two inet columns.
…nown limits (FR-044)

- The documented @via example could not be lowered: its relationship had no
  identity.reference behind it. The example now declares one, and the feature doc,
  authoring skill rule 2 and the skill reference say a hop needs it.
- Known limits: a derived report from a TPH subtype is refused (declare it from
  the base with a filter on the discriminator); an abstract view-backed report
  and non-view source kinds get no C# row or Kotlin table; a dimension over a
  field.object is not supported across ports.
- Which source decides when a report declares several (primary, else first), the
  dotted @measures form, and that count counts non-null @Of.
- CHANGELOG [Unreleased] and the agent-context goldens follow.
…ort over a field.object (FR-044)

The report shape resolved a bare @Of in the @from entity's package. The loader
resolves it in the package of the entity that DECLARES the dimension or measure,
so a member inherited from a base in another package either failed to resolve or
was typed from a same-named decoy. The shape now follows the loader: declaring
package, the named entity must be @from or an ancestor, and without @via the
field is read from @from. A dotted @measures item (Sale.total) names the measure
by its last segment. A time dimension item with no grain, or one outside the
closed set, does not resolve.

OMDB: a derived field over a field.object is refused by name when the read model
is built (it was a NullPointerException on read). OQL with a report result class
builds rows from the read model. getObjectRef leaves an object with no metadata
to the base method. A projection whose view is named by @view now has a read
mapping: the view name is the source's physical name, one rule for projections
and reports.
…eport over a field.object (FR-044)

The enum class of a report column is taken from ReportShape.ofEntity, so the
generator restates nothing about packages or @via. gen fails, naming the report
and the dimension or measure, when a derived field reads a field.object. Tests
cover a member inherited across packages (with and without a same-named decoy),
a dotted @measures item, and an abstract view-backed report (generates nothing).
…eport over a field.object (FR-044)

ReportShapes resolves @Of in the declaring entity's package, checks the named
entity is @from or an ancestor, and reads the field from @from when there is no
@via. A dotted @measures item names the measure by its last segment. A report
row with a dimension over a field.object silently lost that property; gen now
refuses it by name. An abstract view-backed report generates nothing (tested).
…able B rows and carry rules (FR-044)

report_shape resolves @Of in the declaring entity's package, checks the named
entity is @from or an ancestor, and reads the field from @from when there is no
@via. A dotted @measures item names the measure by its last segment. The shapes
test helper names the view by the read model's rule (primary, else first). New
direct tests for sum/avg/min/max by @Of subtype and for what a derived field
carries from its type source. ADR-0039 comments on the own-only reads.
Java OMDB (on read), Kotlin gen and C# gen now refuse a report whose column is
typed by a field.object, naming the report and the item; the TypeScript and
Python runtimes read it as parsed JSON. The Known limits entry and the skill
reference said the other ports were not gated for it.
…s a dot; document the redeclared-field limit (FR-044)

The @via walk was handed the @from entity's resolution key as its head, and the
walk splits on every dot, so a report in a package such as com.acme resolved no
hop and was refused with a false 'no foreign key' message. The head is now the
entity's short name, resolved in its own package, which is still that entity
when another package has one of the same name.

Known limits gains the quiet form of the @via rule: when the reached subtype
redeclares the field and @Of names the base, the view reads the base's column.
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