Skip to content

Commit bdeb476

Browse files
dmealingclaude
andcommitted
fix(loader): reject a projection that inherits its source through extends
All four loaders now emit ERR_PROJECTION_INHERITED_SOURCE for a CONCRETE object.projection that inherits a source.* via extends instead of declaring its own. No new vocabulary, no codegen change, no registry change. The shape produced a broken artifact, differently in every port, because two source predicates disagree by design: "which source am I bound to" resolves through the super chain (an entity legitimately inherits its table — TPH / BaseEntity, and making that resolving fixed a real JVM bug), while "what KIND of source am I" is own-only (projection-ness is a property of the declaring object). In TypeScript hasAnyRdbSource selected the object for route generation while isProjection returned false, so routes-file fell through to the writable branch and mounted full mountCrudRoutes POST/PATCH/DELETE over a read-only view — against a Drizzle binding entity-file never emitted. Java and Kotlin skipped on their subtype gate, Python on the resolved source's kind, C# emitted nothing. Five ports, four behaviors, no working output anywhere. The fix guards the shape rather than flipping either predicate. Both readings are right for what they were designed for; only their intersection is incoherent, and it is incoherent by construction — extends only ADDS members, so a child projection's extra fields have no provider in the parent's view, and both objects would claim one physical view while declaring different exposures (the declared field set IS the exposure, fail-closed). Prior art splits the same way. Shared-storage inheritance inherits binding AND writability together (Hibernate @immutable is inherited by entity subclasses; EF Core keyless ToView types; SQLAlchemy single-table). Shape-reuse inheritance does not inherit the binding at all — JPA @MappedSuperclass has no table of its own, and Django documents inheriting db_table from an abstract base as a trap for exactly this reason. A projection is the second kind. Systems that expose views also derive writability structurally per object rather than splitting it from the binding (jOOQ TableRecord vs UpdatableRecord; Prisma disables mutations on views). Enforced at the CONCRETE level (mirrors #236): an abstract projection base may carry shared shape, and a source on one is inert until a concrete child extends it. Skipped when the super is not a legal projection, so a projection extending an entity reports one error at its root cause instead of two. Gated by a shared error-projection-inherited-source fixture run by all five ports (TS 516, Java 516, Python 495, C# 842 conformance; full suites green). Nothing in the repo relies on the shape — a corpus scan found it only inside an existing error fixture — and no port generated working output for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3msoGxjRMwx94PhKSLDuE
1 parent efe12d4 commit bdeb476

19 files changed

Lines changed: 260 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,54 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Fixed — a projection may no longer inherit a source through `extends` (`ERR_PROJECTION_INHERITED_SOURCE`)
11+
12+
All four loaders now reject a **concrete** `object.projection` that inherits a
13+
`source.*` through `extends` instead of declaring its own. Cross-port loader change,
14+
no new vocabulary, no codegen change.
15+
16+
The shape produced a broken artifact and did so differently in every port, because
17+
two source predicates disagree **by design**: "which source am I bound to" resolves
18+
through the super chain (an entity legitimately inherits its table — TPH/BaseEntity),
19+
while "what KIND of source am I" is own-only (projection-ness is a property of the
20+
declaring object). In TypeScript that meant `hasAnyRdbSource` selected the object for
21+
route generation while `isProjection` returned false, so it fell through to the
22+
writable branch and mounted full `mountCrudRoutes` POST/PATCH/DELETE **over a
23+
read-only view** — against a Drizzle binding the entity generator never emitted.
24+
Java and Kotlin skipped it on their subtype gate, Python on the resolved source's
25+
kind, and C# emitted nothing at all. Five ports, four behaviors, no working output.
26+
27+
The fix guards the shape rather than flipping either predicate. Both readings are
28+
correct for what they were designed for; only their intersection is incoherent, and
29+
it is incoherent by construction: `extends` only ADDS members, so a child projection's
30+
extra fields have no provider in the parent's view, and both objects would claim one
31+
physical view while declaring different exposures (the declared field set IS the
32+
exposure, fail-closed — ADR-0028).
33+
34+
Prior art splits the same way and validates the split. Shared-storage inheritance
35+
inherits binding **and** writability together (Hibernate `@Immutable` "may be applied
36+
only to the root entity, and is inherited by entity subclasses"; EF Core keyless
37+
`ToView` types; SQLAlchemy single-table). Shape-reuse inheritance does not inherit the
38+
binding at all — JPA `@MappedSuperclass` "has no separate table defined for it", and
39+
Django documents inheriting `db_table` from an abstract base as a trap: "all the child
40+
classes … would use the same database table, which is almost certainly not what you
41+
want". A projection is the second kind. Systems that expose views also derive
42+
writability structurally per object rather than splitting it from the binding (jOOQ
43+
emits `TableRecord` rather than `UpdatableRecord`; Prisma disables mutations on views
44+
outright).
45+
46+
Enforced at the **concrete** level (mirrors #236): an abstract projection base may
47+
carry shared shape, and a source on one stays inert until a concrete child extends it.
48+
The sanctioned pattern is unchanged and already in the corpus — abstract sourceless
49+
base, concrete projection declaring its own view. Skipped when the super is not a legal
50+
projection, so a projection extending an entity still reports one error at its root
51+
cause rather than two.
52+
53+
Gated by a shared `error-projection-inherited-source` conformance fixture run by all
54+
five ports. No fixture, example or adopter model in the repo relies on the shape, and
55+
no port generated working output for it.
56+
57+
1058
### Added — sourceless-projection conformance fixture (#271)
1159

1260
`projection-sourceless` pins, across all five ports, that an `object.projection`

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ _Last refreshed 2026-08-05._
3232
- **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.
3333

3434
**Cross-port conformance corpora** (every port runs the shared corpus):
35-
- Metamodel: `fixtures/conformance/` (261 fixtures; 19 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
35+
- Metamodel: `fixtures/conformance/` (262 fixtures; 19 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
3636
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
3737
- 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 **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".)
3838
- 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.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ metaobjects/
166166
├── CLAUDE.md # project instructions for Claude
167167
├── spec/ # canonical metamodel docs, ADRs, roadmap
168168
├── fixtures/ # 19 cross-language conformance corpora — the oracle
169-
│ ├── conformance/ # metamodel (loader + serializer + navigation), 261 fixtures
169+
│ ├── conformance/ # metamodel (loader + serializer + navigation), 262 fixtures
170170
│ ├── yaml-conformance/ # YAML authoring desugar
171171
│ ├── render-conformance/ # FR-004 byte-identical render oracle
172172
│ ├── verify-conformance/ # FR-004 template-drift gate

docs/CONFORMANCE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l`.
2525

2626
| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
2727
|---|---|---|---|---|---|---|
28-
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 261 ||| inherits via `metadata-ktx` |||
28+
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 262 ||| inherits via `metadata-ktx` |||
2929
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 15 | 15 / 15 | 14 / 15 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 14 / 15 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 15 / 15 |
3030
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 ||| inherits via Java |||
3131
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 ||||||
@@ -69,7 +69,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.
6969

7070
## Fixture-to-doc mapping
7171

72-
### `fixtures/conformance/` — metamodel loader + canonical serializer (261)
72+
### `fixtures/conformance/` — metamodel loader + canonical serializer (262)
7373

7474
| Fixture prefix | Feature doc |
7575
|---|---|

docs/llms/llms-full.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Because the render is conformance-gated, the determinism guarantee holds in ever
9999
| C# | NuGet `0.20.14` (.NET tool) | Loader + canonical serializer + EF Core + ASP.NET codegen + render/verify. `dotnet meta` tool. |
100100
| Python | PyPI `0.20.14` | Loader + serializer + render + verify + codegen + `ObjectManager` runtime. Fully green across all corpora. |
101101

102-
Conformance fixtures live at [`fixtures/`](https://github.com/metaobjectsdev/metaobjects/tree/main/fixtures). Every port runs the shared corpus byte-identically: metamodel (`conformance/`, 261 fixtures), render, persistence (Testcontainers Postgres, with an `op: roundtrip` gate so every `field.*` subtype write+read round-trips on every port), api-contract (41 scenarios — 26 core plus TPH / M:N / jsonb / write-through — two lanes: a reference server AND each port's generated API booted over HTTP), registry (byte-matched metamodel-vocabulary manifest, live + green in all five ports), and yaml/verify.
102+
Conformance fixtures live at [`fixtures/`](https://github.com/metaobjectsdev/metaobjects/tree/main/fixtures). Every port runs the shared corpus byte-identically: metamodel (`conformance/`, 262 fixtures), render, persistence (Testcontainers Postgres, with an `op: roundtrip` gate so every `field.*` subtype write+read round-trips on every port), api-contract (41 scenarios — 26 core plus TPH / M:N / jsonb / write-through — two lanes: a reference server AND each port's generated API booted over HTTP), registry (byte-matched metamodel-vocabulary manifest, live + green in all five ports), and yaml/verify.
103103

104104
---
105105

docs/llms/llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ This `llms.txt` is the short index; the deep, version-matched how-to is the scaf
3131
## Spec and standard
3232

3333
- [Specification (canonical, target-agnostic)](https://github.com/metaobjectsdev/metaobjects/tree/main/spec): the normative metadata schema and semantics that every implementation must conform to.
34-
- [Conformance fixtures](https://github.com/metaobjectsdev/metaobjects/tree/main/fixtures): cross-port corpora — `conformance/` (metamodel, 261 fixtures), `render-conformance/`, `persistence-conformance/` (against Testcontainers Postgres, including an `op: roundtrip` gate so every `field.*` subtype write+read round-trips on every port), `api-contract-conformance/` (41 scenarios — 26 core REST contract + filter operators, plus TPH / M:N / jsonb / write-through — run in two lanes: a reference server AND each port's generated API booted over HTTP), `registry-conformance/` (byte-matched metamodel-vocabulary manifest, live + green in all five ports), and `yaml-conformance/` / `verify-conformance/`. Every port runs the shared corpus byte-identically.
34+
- [Conformance fixtures](https://github.com/metaobjectsdev/metaobjects/tree/main/fixtures): cross-port corpora — `conformance/` (metamodel, 262 fixtures), `render-conformance/`, `persistence-conformance/` (against Testcontainers Postgres, including an `op: roundtrip` gate so every `field.*` subtype write+read round-trips on every port), `api-contract-conformance/` (41 scenarios — 26 core REST contract + filter operators, plus TPH / M:N / jsonb / write-through — run in two lanes: a reference server AND each port's generated API booted over HTTP), `registry-conformance/` (byte-matched metamodel-vocabulary manifest, live + green in all five ports), and `yaml-conformance/` / `verify-conformance/`. Every port runs the shared corpus byte-identically.
3535
- [Roadmap](https://github.com/metaobjectsdev/metaobjects/blob/main/spec/roadmap.md): current + planned work across all implementations.
3636

3737
## The four pillars

fixtures/conformance/ERROR-CODES.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"ERR_PROJECTION_IDENTITY_NOT_EXTENDED": "FR-024: an identity.* on an object.projection lacks extends — a projection identity is a pass-through of an entity identity.",
1515
"ERR_IDENTITY_KEY_MISMATCH": "FR-024: identity key correspondence broken — an extended-identity field has no local pass-through field extending it, or an explicit @fields disagrees with the computed pass-through key.",
1616
"ERR_PROJECTION_SOURCE_WRITABLE": "FR-024 (ADR-0028): a source.* on an object.projection has a writable @kind (table, or @kind omitted which defaults to table) — a projection is a derived read-only representation; its sources must be read-only kinds (view, materializedView, storedProc, tableFunction).",
17+
"ERR_PROJECTION_INHERITED_SOURCE": "FR-024 (ADR-0028): a concrete object.projection inherits a source.* through extends instead of declaring its own. A projection's extends is shape lineage, not a shared-storage hierarchy: extends only ADDS members, so the child's extra fields have no provider in the parent's view, and two objects would claim one physical view with different declared exposures. Declare the source on the concrete projection; an abstract projection base carries shape only.",
1718
"ERR_INVALID_SUBTYPE_CHILD": "A child node type/subType is not permitted under its parent.",
1819
"ERR_CHILD_NOT_ALLOWED": "FR-033: a structural child (field/identity/source/validator/… — not an attr) is placed under a parent whose registered childRules do not admit it (the structural analogue of ERR_UNKNOWN_ATTR). Strict-load only; a no-op under wildcard childRules. The detail names the parent, the child (type.subType 'name'), and which placement was rejected.",
1920
"ERR_UNKNOWN_ATTR": "An attribute name is not declared on the node's type.",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"errors": [
3+
{
4+
"code": "ERR_PROJECTION_INHERITED_SOURCE",
5+
"source": {
6+
"format": "json",
7+
"files": [
8+
"meta.demo.json"
9+
],
10+
"jsonPath": "$['metadata.root'].children[2]['object.projection']"
11+
}
12+
}
13+
],
14+
"warnings": []
15+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"metadata.root": {
3+
"package": "demo",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Customer",
8+
"children": [
9+
{ "source.rdb": { "@table": "customers" } },
10+
{ "field.uuid": { "name": "id" } },
11+
{ "field.string": { "name": "name" } },
12+
{ "identity.primary": { "name": "id", "@fields": ["id"] } }
13+
]
14+
}
15+
},
16+
{
17+
"object.projection": {
18+
"name": "CustomersV1",
19+
"children": [
20+
{ "source.rdb": { "@kind": "view", "@view": "v_customers_v1" } },
21+
{ "field.uuid": { "name": "customerId", "extends": "demo::Customer.id" } },
22+
{ "field.string": { "name": "name", "extends": "demo::Customer.name" } },
23+
{ "identity.primary": { "name": "id", "extends": "demo::Customer.id" } }
24+
]
25+
}
26+
},
27+
{
28+
"object.projection": {
29+
"name": "CustomersV1Extra",
30+
"extends": "demo::CustomersV1",
31+
"children": [
32+
{ "field.string": { "name": "label" } }
33+
]
34+
}
35+
}
36+
]
37+
}
38+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
["metaobjects-core-types", "metaobjects-db"]

0 commit comments

Comments
 (0)