From cd1aaca19dbb50444b084033e726720ec625edab Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 06:21:04 -0500 Subject: [PATCH 1/6] docs(agent-context): route report wording to object.report before @sql The meta init agent guide gains one rule: a grouped total, dashboard number or read-only report view is an object.report over dimension.* / measure.*, with a hand-written @sql view body as the last resort. The metaobjects-authoring routing table sends report, dashboard, totals, GROUP BY and per-month wording to declaring-reports.md first, and the @sql sections of read-views-and-projections.md and sources.md point an aggregating body at it and say what @sql is for. --- CHANGELOG.md | 7 +++++++ agent-context/skills/metaobjects-authoring/SKILL.md | 4 ++-- .../references/read-views-and-projections.md | 7 ++++--- .../skills/metaobjects-authoring/references/sources.md | 4 +++- agent-context/templates/always-on.md.mustache | 3 +++ .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 4 ++-- .../references/read-views-and-projections.md | 7 ++++--- .../skills/metaobjects-authoring/references/sources.md | 4 +++- .../csharp/expected/.metaobjects/AGENTS.md | 3 +++ .../csharp/expected/.metaobjects/CLAUDE.md | 3 +++ .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 4 ++-- .../references/read-views-and-projections.md | 7 ++++--- .../skills/metaobjects-authoring/references/sources.md | 4 +++- .../expected/.metaobjects/AGENTS.md | 3 +++ .../expected/.metaobjects/CLAUDE.md | 3 +++ .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 4 ++-- .../references/read-views-and-projections.md | 7 ++++--- .../skills/metaobjects-authoring/references/sources.md | 4 +++- .../java-react/expected/.metaobjects/AGENTS.md | 3 +++ .../java-react/expected/.metaobjects/CLAUDE.md | 3 +++ .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 4 ++-- .../references/read-views-and-projections.md | 7 ++++--- .../skills/metaobjects-authoring/references/sources.md | 4 +++- .../python/expected/.metaobjects/AGENTS.md | 3 +++ .../python/expected/.metaobjects/CLAUDE.md | 3 +++ .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 4 ++-- .../references/read-views-and-projections.md | 7 ++++--- .../skills/metaobjects-authoring/references/sources.md | 4 +++- .../ts-react-tanstack/expected/.metaobjects/AGENTS.md | 3 +++ .../ts-react-tanstack/expected/.metaobjects/CLAUDE.md | 3 +++ 30 files changed, 94 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc0a6175a..56c39122b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ here.** ## [Unreleased] +### Changed + +- **Agent context routes report work to the reporting vocabulary.** The `meta init` agent guide + and the `metaobjects-authoring` skill now send grouped totals, dashboard numbers and read-only + report views to `object.report` (`references/declaring-reports.md`) before the `@sql` escape, + which they describe as the last resort for bodies the vocabulary cannot express. + ## [1.3.0] — 2026-11-11 _npm `1.3.0` (full lockstep across all 14 `@metaobjectsdev/*` publish candidates)._ diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index de11bce2c..459023b77 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -31,10 +31,10 @@ This file covers what almost every model needs. The topics below live in | `references/identities-and-indexes.md` | declaring a primary, secondary or reference identity, an FK, or a non-unique `index.lookup` | | `references/sources.md` | the `source.rdb` shape and `@kind`; a store MetaObjects does not manage; a derived or computed column (reach for an entity read-view first) | | `references/abstracts-and-overlay.md` | a shared `abstract` base, `extends`, or a layered `overlay` file | -| `references/declaring-reports.md` | declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | +| `references/declaring-reports.md` | **open first** for a report, dashboard, totals, `GROUP BY`, "per client / per month" or a read-only summary view: declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | | `references/model-first-rationale.md` | someone argues that hand-writing a layer is cheaper this once | | `references/relationships.md` | a many-to-many link or self-join, or a "rows that reference this one" query | -| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | +| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view (a grouped or aggregate view is `declaring-reports.md` first) | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | | `references/reporting.md` | what a declared report gives you: what columns a report gets, time grains, null rules, rows with no facts (`@spine`), defaults (`@default`), engine differences | diff --git a/agent-context/skills/metaobjects-authoring/references/read-views-and-projections.md b/agent-context/skills/metaobjects-authoring/references/read-views-and-projections.md index 06d2ccf2e..99422ce11 100644 --- a/agent-context/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/agent-context/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -1,6 +1,6 @@ # Projections, `origin.*`, and managed views -> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. +> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. A grouped total or report (per client, per month) is an `object.report`: `declaring-reports.md`. ## Contents - Read-view or projection? @@ -125,8 +125,9 @@ non-aggregate-derived** projection fields: naming an undeclared field fails the **The `CREATE VIEW` body is generated from those `origin.*` children by the Node `meta migrate` — never hand-author view SQL for a shape origins can express** (an unmodeled -view is *unmanaged*, so `meta verify --db` can't even catch the drift). For a genuinely -irreducible body (recursive CTE, window function, set operation) that origins can't express, +view is *unmanaged*, so `meta verify --db` can't even catch the drift). **An `@sql` body that +`GROUP BY`s: check `declaring-reports.md` first** (`object.report`, `@spine`, `@default`). +For a genuinely irreducible body (recursive CTE, window function, set operation) that origins can't express, carry it in the `source.rdb` **`@sql`** escape (#208, ADR-0043) — a hand-written body the tool registers, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`) — rather than a hand-edited migration file where it goes diff --git a/agent-context/skills/metaobjects-authoring/references/sources.md b/agent-context/skills/metaobjects-authoring/references/sources.md index 5f7f94c03..bff437f41 100644 --- a/agent-context/skills/metaobjects-authoring/references/sources.md +++ b/agent-context/skills/metaobjects-authoring/references/sources.md @@ -94,4 +94,6 @@ column because the filter tier runs against the view. **When you need an independent exposure** — a subset, renamed columns, a row-filtered view, a versioned or external shape — that is an `object.projection`. It, the `origin.*` vocabulary, the structured `@filter` / `@expr` shapes and the `@sql` / `@unmanaged` escapes -are in `references/read-views-and-projections.md`. +are in `references/read-views-and-projections.md`. A grouped or aggregate view (totals per client +or per month) is an `object.report` (`references/declaring-reports.md`); `@sql` is for what neither +can express (a recursive CTE, a window function, a set operation). diff --git a/agent-context/templates/always-on.md.mustache b/agent-context/templates/always-on.md.mustache index cc0003da6..cc1aaaf1b 100644 --- a/agent-context/templates/always-on.md.mustache +++ b/agent-context/templates/always-on.md.mustache @@ -50,6 +50,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/SKILL.md index de11bce2c..459023b77 100644 --- a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -31,10 +31,10 @@ This file covers what almost every model needs. The topics below live in | `references/identities-and-indexes.md` | declaring a primary, secondary or reference identity, an FK, or a non-unique `index.lookup` | | `references/sources.md` | the `source.rdb` shape and `@kind`; a store MetaObjects does not manage; a derived or computed column (reach for an entity read-view first) | | `references/abstracts-and-overlay.md` | a shared `abstract` base, `extends`, or a layered `overlay` file | -| `references/declaring-reports.md` | declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | +| `references/declaring-reports.md` | **open first** for a report, dashboard, totals, `GROUP BY`, "per client / per month" or a read-only summary view: declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | | `references/model-first-rationale.md` | someone argues that hand-writing a layer is cheaper this once | | `references/relationships.md` | a many-to-many link or self-join, or a "rows that reference this one" query | -| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | +| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view (a grouped or aggregate view is `declaring-reports.md` first) | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | | `references/reporting.md` | what a declared report gives you: what columns a report gets, time grains, null rules, rows with no facts (`@spine`), defaults (`@default`), engine differences | diff --git a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index 06d2ccf2e..99422ce11 100644 --- a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -1,6 +1,6 @@ # Projections, `origin.*`, and managed views -> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. +> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. A grouped total or report (per client, per month) is an `object.report`: `declaring-reports.md`. ## Contents - Read-view or projection? @@ -125,8 +125,9 @@ non-aggregate-derived** projection fields: naming an undeclared field fails the **The `CREATE VIEW` body is generated from those `origin.*` children by the Node `meta migrate` — never hand-author view SQL for a shape origins can express** (an unmodeled -view is *unmanaged*, so `meta verify --db` can't even catch the drift). For a genuinely -irreducible body (recursive CTE, window function, set operation) that origins can't express, +view is *unmanaged*, so `meta verify --db` can't even catch the drift). **An `@sql` body that +`GROUP BY`s: check `declaring-reports.md` first** (`object.report`, `@spine`, `@default`). +For a genuinely irreducible body (recursive CTE, window function, set operation) that origins can't express, carry it in the `source.rdb` **`@sql`** escape (#208, ADR-0043) — a hand-written body the tool registers, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`) — rather than a hand-edited migration file where it goes diff --git a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/sources.md b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/sources.md index 5f7f94c03..bff437f41 100644 --- a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/sources.md +++ b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/sources.md @@ -94,4 +94,6 @@ column because the filter tier runs against the view. **When you need an independent exposure** — a subset, renamed columns, a row-filtered view, a versioned or external shape — that is an `object.projection`. It, the `origin.*` vocabulary, the structured `@filter` / `@expr` shapes and the `@sql` / `@unmanaged` escapes -are in `references/read-views-and-projections.md`. +are in `references/read-views-and-projections.md`. A grouped or aggregate view (totals per client +or per month) is an `object.report` (`references/declaring-reports.md`); `@sql` is for what neither +can express (a recursive CTE, a window function, a set operation). diff --git a/fixtures/agent-context-conformance/csharp/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/csharp/expected/.metaobjects/AGENTS.md index cc76b4b87..a3f4014b0 100644 --- a/fixtures/agent-context-conformance/csharp/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/csharp/expected/.metaobjects/AGENTS.md @@ -68,6 +68,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/csharp/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/csharp/expected/.metaobjects/CLAUDE.md index cc76b4b87..a3f4014b0 100644 --- a/fixtures/agent-context-conformance/csharp/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/csharp/expected/.metaobjects/CLAUDE.md @@ -68,6 +68,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index de11bce2c..459023b77 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -31,10 +31,10 @@ This file covers what almost every model needs. The topics below live in | `references/identities-and-indexes.md` | declaring a primary, secondary or reference identity, an FK, or a non-unique `index.lookup` | | `references/sources.md` | the `source.rdb` shape and `@kind`; a store MetaObjects does not manage; a derived or computed column (reach for an entity read-view first) | | `references/abstracts-and-overlay.md` | a shared `abstract` base, `extends`, or a layered `overlay` file | -| `references/declaring-reports.md` | declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | +| `references/declaring-reports.md` | **open first** for a report, dashboard, totals, `GROUP BY`, "per client / per month" or a read-only summary view: declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | | `references/model-first-rationale.md` | someone argues that hand-writing a layer is cheaper this once | | `references/relationships.md` | a many-to-many link or self-join, or a "rows that reference this one" query | -| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | +| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view (a grouped or aggregate view is `declaring-reports.md` first) | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | | `references/reporting.md` | what a declared report gives you: what columns a report gets, time grains, null rules, rows with no facts (`@spine`), defaults (`@default`), engine differences | diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index 06d2ccf2e..99422ce11 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -1,6 +1,6 @@ # Projections, `origin.*`, and managed views -> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. +> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. A grouped total or report (per client, per month) is an `object.report`: `declaring-reports.md`. ## Contents - Read-view or projection? @@ -125,8 +125,9 @@ non-aggregate-derived** projection fields: naming an undeclared field fails the **The `CREATE VIEW` body is generated from those `origin.*` children by the Node `meta migrate` — never hand-author view SQL for a shape origins can express** (an unmodeled -view is *unmanaged*, so `meta verify --db` can't even catch the drift). For a genuinely -irreducible body (recursive CTE, window function, set operation) that origins can't express, +view is *unmanaged*, so `meta verify --db` can't even catch the drift). **An `@sql` body that +`GROUP BY`s: check `declaring-reports.md` first** (`object.report`, `@spine`, `@default`). +For a genuinely irreducible body (recursive CTE, window function, set operation) that origins can't express, carry it in the `source.rdb` **`@sql`** escape (#208, ADR-0043) — a hand-written body the tool registers, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`) — rather than a hand-edited migration file where it goes diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md index 5f7f94c03..bff437f41 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md @@ -94,4 +94,6 @@ column because the filter tier runs against the view. **When you need an independent exposure** — a subset, renamed columns, a row-filtered view, a versioned or external shape — that is an `object.projection`. It, the `origin.*` vocabulary, the structured `@filter` / `@expr` shapes and the `@sql` / `@unmanaged` escapes -are in `references/read-views-and-projections.md`. +are in `references/read-views-and-projections.md`. A grouped or aggregate view (totals per client +or per month) is an `object.report` (`references/declaring-reports.md`); `@sql` is for what neither +can express (a recursive CTE, a window function, a set operation). diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md index b700a9d00..6c4200f7c 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md @@ -87,6 +87,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md index b700a9d00..6c4200f7c 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -87,6 +87,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md index de11bce2c..459023b77 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -31,10 +31,10 @@ This file covers what almost every model needs. The topics below live in | `references/identities-and-indexes.md` | declaring a primary, secondary or reference identity, an FK, or a non-unique `index.lookup` | | `references/sources.md` | the `source.rdb` shape and `@kind`; a store MetaObjects does not manage; a derived or computed column (reach for an entity read-view first) | | `references/abstracts-and-overlay.md` | a shared `abstract` base, `extends`, or a layered `overlay` file | -| `references/declaring-reports.md` | declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | +| `references/declaring-reports.md` | **open first** for a report, dashboard, totals, `GROUP BY`, "per client / per month" or a read-only summary view: declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | | `references/model-first-rationale.md` | someone argues that hand-writing a layer is cheaper this once | | `references/relationships.md` | a many-to-many link or self-join, or a "rows that reference this one" query | -| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | +| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view (a grouped or aggregate view is `declaring-reports.md` first) | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | | `references/reporting.md` | what a declared report gives you: what columns a report gets, time grains, null rules, rows with no facts (`@spine`), defaults (`@default`), engine differences | diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index 06d2ccf2e..99422ce11 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -1,6 +1,6 @@ # Projections, `origin.*`, and managed views -> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. +> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. A grouped total or report (per client, per month) is an `object.report`: `declaring-reports.md`. ## Contents - Read-view or projection? @@ -125,8 +125,9 @@ non-aggregate-derived** projection fields: naming an undeclared field fails the **The `CREATE VIEW` body is generated from those `origin.*` children by the Node `meta migrate` — never hand-author view SQL for a shape origins can express** (an unmodeled -view is *unmanaged*, so `meta verify --db` can't even catch the drift). For a genuinely -irreducible body (recursive CTE, window function, set operation) that origins can't express, +view is *unmanaged*, so `meta verify --db` can't even catch the drift). **An `@sql` body that +`GROUP BY`s: check `declaring-reports.md` first** (`object.report`, `@spine`, `@default`). +For a genuinely irreducible body (recursive CTE, window function, set operation) that origins can't express, carry it in the `source.rdb` **`@sql`** escape (#208, ADR-0043) — a hand-written body the tool registers, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`) — rather than a hand-edited migration file where it goes diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/sources.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/sources.md index 5f7f94c03..bff437f41 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/sources.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/sources.md @@ -94,4 +94,6 @@ column because the filter tier runs against the view. **When you need an independent exposure** — a subset, renamed columns, a row-filtered view, a versioned or external shape — that is an `object.projection`. It, the `origin.*` vocabulary, the structured `@filter` / `@expr` shapes and the `@sql` / `@unmanaged` escapes -are in `references/read-views-and-projections.md`. +are in `references/read-views-and-projections.md`. A grouped or aggregate view (totals per client +or per month) is an `object.report` (`references/declaring-reports.md`); `@sql` is for what neither +can express (a recursive CTE, a window function, a set operation). diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md index 26abb81c1..074f87fdd 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md @@ -73,6 +73,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md index 26abb81c1..074f87fdd 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md @@ -73,6 +73,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md index de11bce2c..459023b77 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -31,10 +31,10 @@ This file covers what almost every model needs. The topics below live in | `references/identities-and-indexes.md` | declaring a primary, secondary or reference identity, an FK, or a non-unique `index.lookup` | | `references/sources.md` | the `source.rdb` shape and `@kind`; a store MetaObjects does not manage; a derived or computed column (reach for an entity read-view first) | | `references/abstracts-and-overlay.md` | a shared `abstract` base, `extends`, or a layered `overlay` file | -| `references/declaring-reports.md` | declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | +| `references/declaring-reports.md` | **open first** for a report, dashboard, totals, `GROUP BY`, "per client / per month" or a read-only summary view: declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | | `references/model-first-rationale.md` | someone argues that hand-writing a layer is cheaper this once | | `references/relationships.md` | a many-to-many link or self-join, or a "rows that reference this one" query | -| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | +| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view (a grouped or aggregate view is `declaring-reports.md` first) | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | | `references/reporting.md` | what a declared report gives you: what columns a report gets, time grains, null rules, rows with no facts (`@spine`), defaults (`@default`), engine differences | diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index 06d2ccf2e..99422ce11 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -1,6 +1,6 @@ # Projections, `origin.*`, and managed views -> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. +> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. A grouped total or report (per client, per month) is an `object.report`: `declaring-reports.md`. ## Contents - Read-view or projection? @@ -125,8 +125,9 @@ non-aggregate-derived** projection fields: naming an undeclared field fails the **The `CREATE VIEW` body is generated from those `origin.*` children by the Node `meta migrate` — never hand-author view SQL for a shape origins can express** (an unmodeled -view is *unmanaged*, so `meta verify --db` can't even catch the drift). For a genuinely -irreducible body (recursive CTE, window function, set operation) that origins can't express, +view is *unmanaged*, so `meta verify --db` can't even catch the drift). **An `@sql` body that +`GROUP BY`s: check `declaring-reports.md` first** (`object.report`, `@spine`, `@default`). +For a genuinely irreducible body (recursive CTE, window function, set operation) that origins can't express, carry it in the `source.rdb` **`@sql`** escape (#208, ADR-0043) — a hand-written body the tool registers, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`) — rather than a hand-edited migration file where it goes diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/sources.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/sources.md index 5f7f94c03..bff437f41 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/sources.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/sources.md @@ -94,4 +94,6 @@ column because the filter tier runs against the view. **When you need an independent exposure** — a subset, renamed columns, a row-filtered view, a versioned or external shape — that is an `object.projection`. It, the `origin.*` vocabulary, the structured `@filter` / `@expr` shapes and the `@sql` / `@unmanaged` escapes -are in `references/read-views-and-projections.md`. +are in `references/read-views-and-projections.md`. A grouped or aggregate view (totals per client +or per month) is an `object.report` (`references/declaring-reports.md`); `@sql` is for what neither +can express (a recursive CTE, a window function, a set operation). diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md index 8f925a267..4ac33cdba 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md @@ -69,6 +69,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md index 8f925a267..4ac33cdba 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md @@ -69,6 +69,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index de11bce2c..459023b77 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -31,10 +31,10 @@ This file covers what almost every model needs. The topics below live in | `references/identities-and-indexes.md` | declaring a primary, secondary or reference identity, an FK, or a non-unique `index.lookup` | | `references/sources.md` | the `source.rdb` shape and `@kind`; a store MetaObjects does not manage; a derived or computed column (reach for an entity read-view first) | | `references/abstracts-and-overlay.md` | a shared `abstract` base, `extends`, or a layered `overlay` file | -| `references/declaring-reports.md` | declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | +| `references/declaring-reports.md` | **open first** for a report, dashboard, totals, `GROUP BY`, "per client / per month" or a read-only summary view: declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | | `references/model-first-rationale.md` | someone argues that hand-writing a layer is cheaper this once | | `references/relationships.md` | a many-to-many link or self-join, or a "rows that reference this one" query | -| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | +| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view (a grouped or aggregate view is `declaring-reports.md` first) | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | | `references/reporting.md` | what a declared report gives you: what columns a report gets, time grains, null rules, rows with no facts (`@spine`), defaults (`@default`), engine differences | diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index 06d2ccf2e..99422ce11 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -1,6 +1,6 @@ # Projections, `origin.*`, and managed views -> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. +> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. A grouped total or report (per client, per month) is an `object.report`: `declaring-reports.md`. ## Contents - Read-view or projection? @@ -125,8 +125,9 @@ non-aggregate-derived** projection fields: naming an undeclared field fails the **The `CREATE VIEW` body is generated from those `origin.*` children by the Node `meta migrate` — never hand-author view SQL for a shape origins can express** (an unmodeled -view is *unmanaged*, so `meta verify --db` can't even catch the drift). For a genuinely -irreducible body (recursive CTE, window function, set operation) that origins can't express, +view is *unmanaged*, so `meta verify --db` can't even catch the drift). **An `@sql` body that +`GROUP BY`s: check `declaring-reports.md` first** (`object.report`, `@spine`, `@default`). +For a genuinely irreducible body (recursive CTE, window function, set operation) that origins can't express, carry it in the `source.rdb` **`@sql`** escape (#208, ADR-0043) — a hand-written body the tool registers, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`) — rather than a hand-edited migration file where it goes diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md index 5f7f94c03..bff437f41 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md @@ -94,4 +94,6 @@ column because the filter tier runs against the view. **When you need an independent exposure** — a subset, renamed columns, a row-filtered view, a versioned or external shape — that is an `object.projection`. It, the `origin.*` vocabulary, the structured `@filter` / `@expr` shapes and the `@sql` / `@unmanaged` escapes -are in `references/read-views-and-projections.md`. +are in `references/read-views-and-projections.md`. A grouped or aggregate view (totals per client +or per month) is an `object.report` (`references/declaring-reports.md`); `@sql` is for what neither +can express (a recursive CTE, a window function, a set operation). diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md index f71ff6288..32ec03894 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md @@ -86,6 +86,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md index f71ff6288..32ec03894 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -86,6 +86,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are From 51b9785ffbad4a4f282882b7b7a7bd69f0faf0fe Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 06:51:45 -0500 Subject: [PATCH 2/6] fix(codegen-ts): emit view columns fed by a reverse identity.reference @via hop An origin @via hop that walks an identity.reference backwards (#560) loaded, but the projection view emitter resolved hops only through relationships and forward references and silently dropped the aggregate and first columns from CREATE VIEW. It now falls back to the same reverse-hop lookup the loader uses and joins from the reference holder. Gated by a Postgres test that migrates, inserts and reads the view. --- .../src/projection/extract-view-spec.ts | 34 +++++-- .../test/origin-reverse-hop-view-pg.test.ts | 99 +++++++++++++++++++ 2 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 server/typescript/packages/integration-tests/test/origin-reverse-hop-view-pg.test.ts diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts index 8fd32e963..639c83b7e 100644 --- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts @@ -54,6 +54,7 @@ import { FIELD_ATTR_COLUMN, OBJECT_PROJECTION_ATTR_FILTER, findReferencesBetween, + findReverseReferenceHops, resolveRelationshipReference, resolveObjectRef, refTarget, @@ -353,11 +354,16 @@ function findReferenceHop(obj: MetaData, name: string): MetaData | undefined { } /** Resolve a `@via` hop (relationship OR reference) to its target + cardinality. - * A reference hop is a to-one forward FK; its target is `@references`. */ + * A reference hop is a to-one forward FK; its target is `@references`. A hop naming + * neither may walk an identity.reference BACKWARDS (#560): `Project.timeEntries` is the + * reference TimeEntry holds onto Project, named by TimeEntry's collection name — a to-many + * hop whose target is the holder (`reverse`). It resolves only when exactly one reference + * answers the name; the loader refuses an ambiguous one (ERR_AMBIGUOUS_PATH). */ function resolveHop( obj: MetaData, name: string, -): { hop: MetaData; target: MetaObject | undefined; cardinality: "one" | "many" } | undefined { + root: MetaRoot, +): { hop: MetaData; target: MetaObject | undefined; cardinality: "one" | "many"; reverse?: true } | undefined { // ADR-0059 R3/R7 — a hop's target is the object its reference attribute binds to, resolved // in the package of the node that declares that attribute, never the walked entity's. const rel = findRelationship(obj, name); @@ -377,7 +383,11 @@ function resolveHop( const target = refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES); return { hop: ref, target: isMetaObject(target) ? target : undefined, cardinality: "one" }; } - return undefined; + if (!isMetaObject(obj)) return undefined; + const reverse = findReverseReferenceHops(obj, name, root.objects()); + if (reverse.length !== 1) return undefined; + const { holder, referenceIdentity } = reverse[0]!; + return { hop: referenceIdentity, target: holder, cardinality: "many", reverse: true }; } /** @@ -404,7 +414,10 @@ function resolveHopReference( hop: MetaData, hopName: string, target: MetaObject, + reverse = false, ): ReferenceLookup | ReferenceLookup[] { + // #560 — a reverse hop walks the reference `target` holds onto `holder`. + if (reverse) return { holder: target, other: holder, referenceIdentity: hop as unknown as MetaReferenceIdentity }; if (hop.type === TYPE_IDENTITY && hop.subType === IDENTITY_SUBTYPE_REFERENCE) { return { holder, other: target, referenceIdentity: hop as unknown as MetaReferenceIdentity }; } @@ -424,11 +437,11 @@ function resolveHopReference( * reads it for the reference's full `@fields` list, where a view join reads the first. */ export function hopReferenceIdentity(holder: MetaObject, hopName: string, root: MetaRoot): MetaReferenceIdentity | undefined { - const resolved = resolveHop(holder, hopName); + const resolved = resolveHop(holder, hopName, root); if (!resolved) return undefined; const target = resolved.target; if (!target) return undefined; - const ref = resolveHopReference(holder, resolved.hop, hopName, target); + const ref = resolveHopReference(holder, resolved.hop, hopName, target, resolved.reverse); if (Array.isArray(ref)) return ref.length === 1 ? ref[0]!.referenceIdentity : undefined; return ref.referenceIdentity; } @@ -705,7 +718,7 @@ function viaTerminalEntity(via: string, root: MetaRoot, referrerPkg: string): Me let currentObj = resolveEntityRef(root, rawEntity, referrerPkg); if (!currentObj) return undefined; for (const relName of segments.slice(1)) { - const resolved = resolveHop(currentObj, relName); + const resolved = resolveHop(currentObj, relName, root); if (!resolved) return undefined; const target = resolved.target; if (!target) return undefined; @@ -858,9 +871,9 @@ export function walkViaPath(via: string, root: MetaRoot, referrerPkg: string, ct // FR-024: a hop may name a relationship OR a reference-only FK // (identity.reference — a to-one forward-FK edge). ADR-0039: resolving — // a traversed relationship/reference may inherit its target via extends. - const resolved = resolveHop(currentObj, relName); + const resolved = resolveHop(currentObj, relName, root); if (!resolved) break; - const { hop, target, cardinality } = resolved; + const { hop, target, cardinality, reverse } = resolved; if (!target) break; // #368: two identity.reference declarations onto the same target are legal @@ -869,7 +882,7 @@ export function walkViaPath(via: string, root: MetaRoot, referrerPkg: string, ct // one from the target alone, so an explicit `@via: "Match.homeTeamRef"` (or a // relationship disambiguated by @sourceRefField/name-pairing) resolves cleanly. // Only a relationship hop that even the ladder cannot choose reaches the throw. - const resolvedRef = resolveHopReference(currentObj as MetaObject, hop, relName, target); + const resolvedRef = resolveHopReference(currentObj as MetaObject, hop, relName, target, reverse); let ref: ReferenceLookup | undefined; if (Array.isArray(resolvedRef)) { if (resolvedRef.length > 1) { @@ -919,8 +932,9 @@ export function walkViaPath(via: string, root: MetaRoot, referrerPkg: string, ct const resolvedPkField = ref.referenceIdentity.resolvedTargetPkField(root) ?? "id"; + // A reverse hop's reference is always on the far side, even on a self-reference. const referenceHolder: "source" | "target" = - ref.holder.name === currentObj.name ? "source" : "target"; + !reverse && ref.holder.name === currentObj.name ? "source" : "target"; // FK lives on the holder; PK on the entity it references. Resolve both to // physical columns now so the ON clause is naming-strategy correct. diff --git a/server/typescript/packages/integration-tests/test/origin-reverse-hop-view-pg.test.ts b/server/typescript/packages/integration-tests/test/origin-reverse-hop-view-pg.test.ts new file mode 100644 index 000000000..ddc738b7f --- /dev/null +++ b/server/typescript/packages/integration-tests/test/origin-reverse-hop-view-pg.test.ts @@ -0,0 +1,99 @@ +/** + * Postgres: a projection view whose origin `@via` walks an identity.reference BACKWARDS (#560). + * + * `Project.timeEntries` names no relationship: it walks TimeEntry's `project` reference from the + * entity it targets back to the entity holding it. The loader accepted that hop, but the view + * emitter resolved hops only through relationships and forward references, so it silently left + * the aggregate and first columns out of CREATE VIEW. This test migrates an empty database, puts + * rows in, and reads the view: a missing column, or a join pointed the wrong way, fails it. + */ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { buildExpectedSchema, collectUnmanagedNames, diff, emit, introspectPostgres, splitSqlStatements } from "@metaobjectsdev/migrate-ts"; +import { buildProjectionViews } from "@metaobjectsdev/codegen-ts"; +import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { Kysely, PostgresDialect, sql } from "kysely"; +import { Pool } from "pg"; +import { startPostgres, type RunningPg } from "../src/postgres-container.ts"; + +const META = { + "metadata.root": { + package: "acme::studio", + children: [ + { "object.entity": { name: "Project", children: [ + { "source.rdb": { "@table": "projects" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "name" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + ] } }, + { "object.entity": { name: "TimeEntry", children: [ + { "source.rdb": { "@table": "time_entries" } }, + { "field.long": { name: "id" } }, + { "field.long": { name: "projectId" } }, + { "field.decimal": { name: "hours", "@precision": 6, "@scale": 2 } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + { "identity.reference": { name: "project", "@fields": "projectId", "@references": "Project" } }, + ] } }, + { "object.projection": { name: "ProjectHours", children: [ + { "source.rdb": { "@kind": "view", "@view": "v_project_hours" } }, + { "field.long": { name: "id", extends: "Project.id" } }, + { "field.string": { name: "name", extends: "Project.name" } }, + { "field.decimal": { name: "totalHours", "@precision": 10, "@scale": 2, children: [ + { "origin.aggregate": { "@agg": "sum", "@of": "TimeEntry.hours", "@via": "Project.timeEntries" } } ] } }, + { "field.int": { name: "entryCount", children: [ + { "origin.aggregate": { "@agg": "count", "@of": "TimeEntry.id", "@via": "Project.timeEntries" } } ] } }, + { "field.decimal": { name: "latestHours", "@precision": 6, "@scale": 2, children: [ + { "origin.first": { "@of": "TimeEntry.hours", "@via": "Project.timeEntries", "@orderBy": ["id:desc"] } } ] } }, + { "identity.primary": { name: "id", extends: "Project.id" } }, + ] } }, + ], + }, +}; + +let pg: RunningPg; +let k: Kysely>; +let root: MetaRoot; + +beforeAll(async () => { + pg = await startPostgres(); + k = new Kysely>({ + dialect: new PostgresDialect({ pool: new Pool({ connectionString: pg.connectionUri, max: 1 }) }), + }); + const loaded = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(META))]); + expect(loaded.errors).toEqual([]); + root = loaded.root; +}, 120_000); + +afterAll(async () => { + await k.destroy(); + await pg.stop(); +}, 60_000); + +describe("origin @via over a reverse identity.reference hop (postgres)", () => { + test("the view carries the aggregate and first columns, joined from the holder", async () => { + const views = buildProjectionViews(root, { dialect: "postgres" }); + const result = await diff({ + expected: buildExpectedSchema(root, { views }), + actual: await introspectPostgres(k), + dialect: "postgres", + unmanagedNames: collectUnmanagedNames(root), + }); + expect(result.blocked).toEqual([]); + for (const stmt of splitSqlStatements(emit(result.changes, { dialect: "postgres" }).up)) { + await sql.raw(stmt).execute(k); + } + + await sql.raw(`INSERT INTO projects (id, name) VALUES (1, 'Alpha'), (2, 'Idle')`).execute(k); + await sql.raw(`INSERT INTO time_entries (id, project_id, hours) VALUES (10, 1, 2.50), (11, 1, 4.00)`).execute(k); + + const rows = await sql>`SELECT * FROM v_project_hours ORDER BY id`.execute(k); + // Columns follow the default snake_case naming; pg returns numeric and bigint as strings. + const num = (v: unknown) => (v === null ? null : Number(v)); + expect(rows.rows.map((r) => ({ + id: Number(r.id), name: r.name, totalHours: num(r.total_hours), + entryCount: num(r.entry_count), latestHours: num(r.latest_hours), + }))).toEqual([ + { id: 1, name: "Alpha", totalHours: 6.5, entryCount: 2, latestHours: 4 }, + { id: 2, name: "Idle", totalHours: null, entryCount: 0, latestHours: null }, + ]); + }, 60_000); +}); From 51fc96c473340d61bc61d3213450221b55a585d4 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 06:51:45 -0500 Subject: [PATCH 3/6] fix(metadata): refuse origin.aggregate sum/avg/min/max over a value-object field @of naming a field.object (flattened or jsonb) loaded and emitted SUM(), a column that does not exist. All four loaders (Kotlin through Java) now refuse it with ERR_INVALID_ORIGIN: a value object is several columns or a document, and summing a Money-shaped value would add amounts in different currencies. count is unaffected. Gated by the new error-origin-aggregate-value-object conformance fixture. --- AGENTS.md | 2 +- CHANGELOG.md | 14 +++++ docs/CONFORMANCE.md | 6 +- docs/features/source-kinds.md | 6 ++ .../expected-errors.json | 17 ++++++ .../input/meta.billing.json | 58 +++++++++++++++++++ .../providers.json | 1 + .../MetaObjects/Loader/ValidationPasses.cs | 14 +++++ .../Persistence/Origin/OriginConstants.cs | 5 ++ .../metaobjects/loader/ValidationPhase.java | 17 ++++++ .../com/metaobjects/origin/MetaOrigin.java | 5 ++ .../metaobjects/loader/validation_passes.py | 18 ++++++ .../persistence/origin/origin_constants.py | 3 + .../metadata/src/loader/validation-passes.ts | 16 +++++ .../persistence/origin/origin-constants.ts | 3 + 15 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 fixtures/conformance/error-origin-aggregate-value-object/expected-errors.json create mode 100644 fixtures/conformance/error-origin-aggregate-value-object/input/meta.billing.json create mode 100644 fixtures/conformance/error-origin-aggregate-value-object/providers.json diff --git a/AGENTS.md b/AGENTS.md index 7b73c724f..3d772c969 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres. **Cross-port conformance corpora** (every port runs the shared corpus): -- Metamodel: `fixtures/conformance/` (458 fixtures; 38 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. +- Metamodel: `fixtures/conformance/` (459 fixtures; 38 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. - Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical. - Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015; the FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".) - API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived. diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c39122b..5e2d310b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ here.** ## [Unreleased] +### Fixed + +- **A projection view keeps the columns a reverse `@via` hop feeds.** An origin `@via` that walks + an `identity.reference` backwards (`Project.timeEntries`, #560) loaded, but `meta migrate` left + its `origin.aggregate` / `origin.first` columns out of `CREATE VIEW` without a word. The view + emitter now resolves the hop the way the loader does and joins from the holder of the + reference. Gated by a Postgres test that creates the view and reads it. +- **`origin.aggregate` `sum`/`avg`/`min`/`max` over a value-object field is refused at load.** + `@of: Invoice.total` on a flattened Money field loaded and emitted `SUM(i.total)`, a column that + does not exist. Every port now refuses it with `ERR_INVALID_ORIGIN` and says to aggregate a + scalar field; gated by the `error-origin-aggregate-value-object` conformance fixture. Refusing, + rather than summing the flattened amount column, keeps a sum from adding amounts in different + currencies. `count` is unaffected. + ### Changed - **Agent context routes report work to the reporting vocabulary.** The `meta init` agent guide diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 4fac40083..24b413c16 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | Corpus | Fixtures | TS | Java | Kotlin | C# | Python | |---|---|---|---|---|---|---| -| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 458 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 459 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | | [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 | | [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 45 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | @@ -354,7 +354,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (458) +### `fixtures/conformance/` — metamodel loader + canonical serializer (459) | Fixture prefix | Feature doc | |---|---| @@ -716,7 +716,7 @@ JSON and verify payload feeds it unchanged. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 458 + yaml 16 + verify 45 +The fixtures in the nine corpora mapped above (metamodel 459 + yaml 16 + verify 45 + render 18 + persistence 62 + api-contract 123 + source-resolution 25 + scope 10 + dependency 47) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance`, the `cube-model` corpus and `explorer-conformance`, whose case lists live in their own READMEs. None are orphaned today. The remaining diff --git a/docs/features/source-kinds.md b/docs/features/source-kinds.md index 6840c71a1..3fe738009 100644 --- a/docs/features/source-kinds.md +++ b/docs/features/source-kinds.md @@ -182,6 +182,12 @@ max over only the active rows — the filtered-scalar shape a plain aggregate ca ]}} ``` +**`sum` / `avg` / `min` / `max` need a scalar `@of`.** A value-object field (`field.object`, +flattened or jsonb) is several columns or a document, so there is no one column to reduce, and a +Money-shaped sum would add amounts in different currencies. The loader refuses it in every port +(`ERR_INVALID_ORIGIN`); model the amount as its own scalar field and group by its currency. +`count` still counts rows. + **More `origin.aggregate` reductions (#195).** Beyond the scalar reduces, `@agg` also takes `any` / `all` (predicate quantifiers over a `@filter` — `@of` forbidden; empty set → `any=false`, `all=true`) and `collect` (an array rollup of `@of` into an `isArray` field, diff --git a/fixtures/conformance/error-origin-aggregate-value-object/expected-errors.json b/fixtures/conformance/error-origin-aggregate-value-object/expected-errors.json new file mode 100644 index 000000000..3290a6cb6 --- /dev/null +++ b/fixtures/conformance/error-origin-aggregate-value-object/expected-errors.json @@ -0,0 +1,17 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_ORIGIN", + "source": { + "format": "resolved", + "files": [ + "meta.billing.json" + ], + "jsonPath": "$['metadata.root'].children[3]['object.projection'].children[2]['field.decimal'].children[0]['origin.aggregate']", + "referrer": "ProjectInvoicing::invoicedTotal", + "target": "Invoice.total" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-origin-aggregate-value-object/input/meta.billing.json b/fixtures/conformance/error-origin-aggregate-value-object/input/meta.billing.json new file mode 100644 index 000000000..4f1377801 --- /dev/null +++ b/fixtures/conformance/error-origin-aggregate-value-object/input/meta.billing.json @@ -0,0 +1,58 @@ +{ + "metadata.root": { + "package": "acme::billing", + "children": [ + { + "object.value": { + "name": "Money", + "children": [ + { "field.decimal": { "name": "amount", "@precision": 12, "@scale": 2 } }, + { "field.string": { "name": "currency", "@maxLength": 3 } } + ] + } + }, + { + "object.entity": { + "name": "Project", + "children": [ + { "source.rdb": { "@table": "projects" } }, + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "relationship.association": { "name": "invoices", "@objectRef": "Invoice", "@cardinality": "many" } } + ] + } + }, + { + "object.entity": { + "name": "Invoice", + "children": [ + { "source.rdb": { "@table": "invoices" } }, + { "field.long": { "name": "id" } }, + { "field.long": { "name": "projectId" } }, + { "field.object": { "name": "total", "@objectRef": "Money", "@storage": "flattened" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "project", "@fields": "projectId", "@references": "Project" } } + ] + } + }, + { + "object.projection": { + "name": "ProjectInvoicing", + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_project_invoicing" } }, + { "field.long": { "name": "id", "extends": "Project.id" } }, + { + "field.decimal": { + "name": "invoicedTotal", + "children": [ + { "origin.aggregate": { "@agg": "sum", "@of": "Invoice.total", "@via": "Project.invoices" } } + ] + } + }, + { "identity.primary": { "name": "id", "extends": "Project.id" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-origin-aggregate-value-object/providers.json b/fixtures/conformance/error-origin-aggregate-value-object/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-origin-aggregate-value-object/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 04612bb0c..c9fe25c3d 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -703,6 +703,20 @@ public static IReadOnlyList ValidateOriginPaths(MetaData root) // NOTE (FR-024 B6): NO extends/origin agreement on aggregates. var ofTarget = ValidateFromPath(of, root, obj, field.Name, errors, "origin.aggregate.@of", src); + // A sum/avg/min/max reduces ONE column. A value-object field is several columns + // (flattened) or a document (jsonb), so there is no column to reduce, and summing a + // Money-shaped value would add amounts in different currencies. + // ADR-0039: resolving — @objectRef may be inherited via extends. + if (ofTarget is ResolvedFromTarget valueTarget + && agg is not null && Array.IndexOf(COLUMN_VALUE_AGGREGATES, agg) >= 0 + && (valueTarget.Field.SubType == FIELD_SUBTYPE_OBJECT + || valueTarget.Field.Attr(FIELD_ATTR_OBJECT_REF) is not null)) + errors.Add(new MetaError( + $"origin.aggregate @agg:{agg} on {obj.Name}.{field.Name}: @of \"{of}\" is a value-object " + + $"field (field.{valueTarget.Field.SubType}), which has no single column to aggregate. " + + "Aggregate a scalar field instead: model the amount as its own field (and group by its currency).", + ErrorCode.ERR_INVALID_ORIGIN, + Envelope: ResolvedSource.From(src, $"{obj.Fqn()}::{field.Name}", of))); // #195 — collect preserves the element type: the array field's own // subType must equal the @of column's subType (the #185 doctrine). if (isCollect && ofTarget is ResolvedFromTarget collectTarget diff --git a/server/csharp/MetaObjects/Persistence/Origin/OriginConstants.cs b/server/csharp/MetaObjects/Persistence/Origin/OriginConstants.cs index 9d57eb3d7..42bb13f82 100644 --- a/server/csharp/MetaObjects/Persistence/Origin/OriginConstants.cs +++ b/server/csharp/MetaObjects/Persistence/Origin/OriginConstants.cs @@ -105,6 +105,11 @@ public static class OriginConstants public const string AGG_ALL = "all"; public const string AGG_COLLECT = "collect"; + /// The @agg values that reduce the VALUES of the @of column (count only counts + /// non-null rows). Mirrors the TS COLUMN_VALUE_AGGREGATES. + public static readonly string[] COLUMN_VALUE_AGGREGATES = + [AGGREGATE_FN_SUM, AGGREGATE_FN_AVG, AGGREGATE_FN_MIN, AGGREGATE_FN_MAX]; + /// The full @agg attribute vocabulary (matches origin.json allowedValues). public static readonly string[] ORIGIN_AGG_VALUES = [AGGREGATE_FN_COUNT, AGGREGATE_FN_SUM, AGGREGATE_FN_AVG, AGGREGATE_FN_MIN, AGGREGATE_FN_MAX, diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index b26e8eeed..905b37ffe 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -3114,6 +3114,23 @@ private static void validateOriginNode(MetaRoot root, MetaObject obj, } OriginTarget ofTarget = validateFromOrOfPath(of, root, obj, field.getName(), "origin.aggregate.@of", src); + // A sum/avg/min/max reduces ONE column. A value-object field is several columns + // (flattened) or a document (jsonb), so there is no column to reduce, and summing a + // Money-shaped value would add amounts in different currencies. + // ADR-0039: resolving (hasMetaAttr defaults to includeParentData) — @objectRef may be + // inherited via extends. + if (MetaOrigin.COLUMN_VALUE_AGGREGATES.contains(agg) + && (ObjectField.SUBTYPE_OBJECT.equals(ofTarget.field.getSubType()) + || ofTarget.field.hasMetaAttr(ObjectField.ATTR_OBJECTREF))) { + throw new MetaDataException( + ErrorMessageConstants.ERR_INVALID_ORIGIN + + ": origin.aggregate @agg:" + agg + " on " + obj.getName() + "." + field.getName() + + ": @of \"" + of + "\" is a value-object field (field." + ofTarget.field.getSubType() + + "), which has no single column to aggregate. Aggregate a scalar field instead:" + + " model the amount as its own field (and group by its currency).", + ErrorCode.ERR_INVALID_ORIGIN, + ResolvedSource.from(src, obj.getShortName() + "::" + field.getName(), of)); + } // #195 — collect preserves the element type: the array field's own subType // must equal the @of column's subType (the #185 doctrine on the element). if (isCollect && !field.getSubType().equals(ofTarget.field.getSubType())) { diff --git a/server/java/metadata/src/main/java/com/metaobjects/origin/MetaOrigin.java b/server/java/metadata/src/main/java/com/metaobjects/origin/MetaOrigin.java index c0d9a5771..ab238a77e 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/origin/MetaOrigin.java +++ b/server/java/metadata/src/main/java/com/metaobjects/origin/MetaOrigin.java @@ -124,6 +124,11 @@ public abstract class MetaOrigin extends MetaData { public static final String AGG_ALL = "all"; public static final String AGG_COLLECT = "collect"; + /** The {@code @agg} values that reduce the VALUES of the {@code @of} column (count only counts + * non-null rows). Mirrors the TS COLUMN_VALUE_AGGREGATES. */ + public static final java.util.List COLUMN_VALUE_AGGREGATES = + java.util.List.of(AGG_SUM, AGG_AVG, AGG_MIN, AGG_MAX); + /** * Closed set of valid {@code @agg} values. Used by * {@link com.metaobjects.loader.ValidationPhase} for enum-membership checks. diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index d3c566cb9..54280e7b8 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -105,6 +105,7 @@ AGG_ANY, AGG_COLLECT, ASSEMBLY_ORIGIN_SUBTYPES, + COLUMN_VALUE_AGGREGATES, ORIGIN_ATTR_AGG, ORIGIN_ATTR_CONVERT, ORIGIN_ATTR_DISTINCT, @@ -2500,6 +2501,23 @@ def _validate_origin_paths( of_target = _validate_entity_field_ref( of_ref, ORIGIN_ATTR_OF, ctx, root, host_pkg, errors, origin, referrer ) + # A sum/avg/min/max reduces ONE column. A value-object field is several + # columns (flattened) or a document (jsonb), so there is no column to reduce, + # and summing a Money-shaped value would add amounts in different currencies. + # Resolving read: @objectRef may be inherited via extends (ADR-0039). + if ( + of_target is not None + and agg in COLUMN_VALUE_AGGREGATES + and (of_target[1].sub_type == FIELD_SUBTYPE_OBJECT + or of_target[1].get_meta_attr(FIELD_ATTR_OBJECT_REF) is not None) + ): + errors.append(MetaError( + f"origin.aggregate @agg:{agg} on {obj.name}.{node.name}: @of \"{of_ref}\" is a " + f"value-object field (field.{of_target[1].sub_type}), which has no single column " + f"to aggregate. Aggregate a scalar field instead: model the amount as its own " + f"field (and group by its currency).", + ErrorCode.ERR_INVALID_ORIGIN, + envelope=resolved_source(origin.source, referrer, of_ref))) # #195 — collect preserves the element type: the array field's own subType # must equal the @of column's subType (the #185 doctrine on the element). if is_collect and of_target is not None and node.sub_type != of_target[1].sub_type: diff --git a/server/python/src/metaobjects/meta/persistence/origin/origin_constants.py b/server/python/src/metaobjects/meta/persistence/origin/origin_constants.py index 6097df59c..04d95b23f 100644 --- a/server/python/src/metaobjects/meta/persistence/origin/origin_constants.py +++ b/server/python/src/metaobjects/meta/persistence/origin/origin_constants.py @@ -51,6 +51,9 @@ AGG_ANY = "any" AGG_ALL = "all" AGG_COLLECT = "collect" +# The @agg values that reduce the VALUES of the @of column (count only counts +# non-null rows). Mirrors the TS COLUMN_VALUE_AGGREGATES. +COLUMN_VALUE_AGGREGATES = ("sum", "avg", "min", "max") ORIGIN_ATTR_OF = "of" # @filter (attr.filter object): an optional PORTABLE structured predicate scoping the # aggregated rows (same shape as a preset filter). On @agg:any|all it is the QUANTIFIED diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts index fe976c190..ac5b819ef 100644 --- a/server/typescript/packages/metadata/src/loader/validation-passes.ts +++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts @@ -132,6 +132,7 @@ import { AGG_ALL, AGG_COLLECT, ASSEMBLY_ORIGIN_SUBTYPES, + COLUMN_VALUE_AGGREGATES, } from "../persistence/origin/origin-constants.js"; import { inferExprType, @@ -1543,6 +1544,21 @@ export function validateOriginPaths(root: MetaData): ParseError[] { // an aggregate computes something new (count/sum/…); spec §4 defines // agreement for passthrough only. const ofTarget = _validateFromPath(of_, root, obj, field.name, src, errors, "origin.aggregate.@of"); + // A sum/avg/min/max reduces ONE column. A value-object field is several columns + // (flattened) or a document (jsonb), so there is no column to reduce, and summing + // a Money-shaped value across rows would add amounts in different currencies. + if ( + ofTarget !== undefined && + (COLUMN_VALUE_AGGREGATES as readonly unknown[]).includes(agg) && + // ADR-0039: resolving — @objectRef may be inherited via extends. + (ofTarget.field.subType === FIELD_SUBTYPE_OBJECT || ofTarget.field.attr(FIELD_ATTR_OBJECT_REF) !== undefined) + ) { + errors.push(new ParseError( + `origin.aggregate @agg:${String(agg)} on ${obj.name}.${field.name}: @of "${of_}" is a value-object ` + + `field (field.${ofTarget.field.subType}), which has no single column to aggregate. Aggregate a ` + + `scalar field instead: model the amount as its own field (and group by its currency).`, + { code: "ERR_INVALID_ORIGIN", source: resolvedSource(src, `${obj.fqn()}::${field.name}`, of_) })); + } // #195 — collect preserves the element type: the array field's own subType // must equal the @of column's subType (the #185 doctrine on the element). if (isCollect && ofTarget !== undefined && field.subType !== ofTarget.field.subType) { diff --git a/server/typescript/packages/metadata/src/persistence/origin/origin-constants.ts b/server/typescript/packages/metadata/src/persistence/origin/origin-constants.ts index f2a5ee3aa..47f994b0d 100644 --- a/server/typescript/packages/metadata/src/persistence/origin/origin-constants.ts +++ b/server/typescript/packages/metadata/src/persistence/origin/origin-constants.ts @@ -91,6 +91,9 @@ export const ORIGIN_FIRST_ATTR_FILTER = "filter"; export const AGGREGATE_FUNCTIONS = ["count", "sum", "avg", "min", "max"] as const; export type AggregateFunction = (typeof AGGREGATE_FUNCTIONS)[number]; +/** The @agg values that reduce the VALUES of the @of column (count only counts non-null rows). */ +export const COLUMN_VALUE_AGGREGATES: readonly AggregateFunction[] = ["sum", "avg", "min", "max"]; + // #195 — the predicate-quantifier + array-rollup @agg values. export const AGG_ANY = "any"; export const AGG_ALL = "all"; From 14da8cd54bd2bc1fbd57f9ef28c83c3317f5170e Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 06:51:45 -0500 Subject: [PATCH 4/6] chore: declare non-production paths for no-mistakes triage Fixtures, the metamodel spec and its generated per-port copies, the generated reference site and the showcase payload. --- .nm-triage-nonprod | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .nm-triage-nonprod diff --git a/.nm-triage-nonprod b/.nm-triage-nonprod new file mode 100644 index 000000000..540941647 --- /dev/null +++ b/.nm-triage-nonprod @@ -0,0 +1,11 @@ +# Paths that no-mistakes triage counts as non-production (one glob per line). +# Conformance corpora and other test fixtures: data, gated by every port's runner. +fixtures/** +# The metamodel spec and the copies generated from it into each port. +spec/metamodel/*.json +server/csharp/MetaObjects/SpecMetamodel/*.json +server/python/src/metaobjects/spec_metamodel/*.json +server/typescript/packages/metadata/src/**/*.embedded.ts +# The generated reference site and showcase payload. +site-reference/** +examples/showcase/site-payload.json From fe55b6dfd5726958836da3e8159ce62a769ccef1 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 08:34:31 -0500 Subject: [PATCH 5/6] fix(java): guard a missing @agg before the value-object aggregate check List.of(...).contains(null) throws, so an origin.aggregate with no @agg over a resolvable @of crashed the loader instead of reporting the missing attribute. --- .../src/main/java/com/metaobjects/loader/ValidationPhase.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index 905b37ffe..cd3f2fd32 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -3119,7 +3119,7 @@ private static void validateOriginNode(MetaRoot root, MetaObject obj, // Money-shaped value would add amounts in different currencies. // ADR-0039: resolving (hasMetaAttr defaults to includeParentData) — @objectRef may be // inherited via extends. - if (MetaOrigin.COLUMN_VALUE_AGGREGATES.contains(agg) + if (agg != null && MetaOrigin.COLUMN_VALUE_AGGREGATES.contains(agg) && (ObjectField.SUBTYPE_OBJECT.equals(ofTarget.field.getSubType()) || ofTarget.field.hasMetaAttr(ObjectField.ATTR_OBJECTREF))) { throw new MetaDataException( From d9cf177ce09b7c0017bddcec4fc6300eed37c54d Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 10:27:39 -0500 Subject: [PATCH 6/6] no-mistakes(document): regenerate showcase payload fixture count --- examples/showcase/site-payload.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 78478a680..c12759239 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.3" }, "counts": { - "fixtures": 458, + "fixtures": 459, "corpora": 38, "baseTypes": 17 },