Skip to content

Commit 8e5affd

Browse files
dmealingclaude
andcommitted
fix(loader): an unpairable M:N junction is a load error in every port
@through has always been documented as a junction declaring two identity.reference children, ONE PER FK SIDE, and docs/features/relationships.md already said a reference naming anything else fails with ERR_INVALID_RELATIONSHIP. The loader only ever counted the references. It never checked what they point at. So a junction whose two references name some other pair of entities loaded perfectly clean and then behaved differently in every port: TypeScript and C# warned and emitted no traversal route -- the endpoint simply 404s, with nothing in the build output a CI gate would fail on -- while Java, Kotlin and Python threw and failed the build. One input, two contracts, and the quiet arm was the dangerous one. Owner ruling 2026-09-20: the MODEL is what is wrong, so every port now rejects it at LOAD, which is the one answer all five already agreed was legitimate for a broken model. The loader runs the REAL FK derivation and converts its failure rather than re-implementing the pairing. The defect class being closed is "two parts of the pipeline disagree", so a second opinion would have been the wrong shape -- and proved it immediately: the first draft of the dedup helper resolved the junction by a different rule than the derivation and emitted rule (d)'s finding twice. Scope mirrors codegen's own iteration exactly -- every concrete, non-projection object crossed with its effective relationships, NOT deduped by declaration. Pairing is a property of the NAVIGATING entity, so an inherited M:N can pair from one subtype and not another; checking the declaration alone would both miss that and reject models codegen accepts. The C# test pins it from the other side, with two independently-unpairable corners in one TPH hierarchy reported separately. It also found a shipped bug it now fails loudly instead of silently. TypeScript and C# resolved @through through a bare-name lookup that a package-qualified name could never match, then fell back to the first entity with that short name -- so with two same-short-named junctions in different packages (the shape xpkg-m2n-collision has covered all along) the derivation paired against the wrong junction and gave up, emitting no route for a valid model. Java was already FQN-exact and unaffected. All four now resolve through the one ADR-0041/0042 matcher the loader itself uses, which also closes the deferred bare-collision gap (#174): a bare ref binds in the REFERRER's package rather than whichever same-named entity loaded first, which was load-order dependence, not a naming rule. A junction reference's @references resolves in the junction's package and the relationship's own @objectref in the declaring entity's, because under extends across packages those differ. PREVIOUSLY-LOADING METADATA STOPS LOADING. This ships as a PATCH under the three-part correction bar in docs/compatibility-policy.md: the form contradicted the documented contract, produced no correct outcome for anyone in any port, and the load error names the exact repair. metamodelVersion stays 1.0 -- this lives in loader validation and has no registry-manifest footprint, which is precisely the class that gate cannot see, so the CHANGELOG answers it instead. Verified in all five ports: TS metadata 2732 / codegen-ts 1702 / runtime-ts 459 / docs-site 46 plus build and typecheck; Java 1624 and a green full reactor; C# 1978 across four dlls; Python 2227 including 135 integration; Java and Kotlin integration lanes 126 and 147. The adopter estate -- a TPH subtype declaring an M:N, a directed self-join and four library packages -- loads with zero errors, with a control proving the rule is live on that same path. Two test-model corrections ride along, in C# and TS: "@isAbstract": true is not registered vocabulary and does nothing (the reserved key is the bare `abstract`), so bases whose comments called them abstract were loading as concrete. The doc example in features/relationships.md taught the same wrong key and is fixed; the shipped iam/ai libraries were already correct. The remaining occurrences in the other ports' test models are left alone deliberately -- flipping a base to genuinely abstract can change what a test exercises, and that is a separate pass. The test-file rename in 96c5989 was swept in by an earlier `git mv`; its content lands here, where it belongs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NrWUxA6euwvnFcwKxcK6A
1 parent 96c5989 commit 8e5affd

25 files changed

Lines changed: 1113 additions & 355 deletions

File tree

‎.claude/rules/cross-language-porting.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Preserve the following contracts exactly across all language ports:
2020
- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `<Name>Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.)
2121
- Source subtypes: `rdb` (paradigm; ADR-0007). The pre-v2 `dbTable`/`dbView` subtypes are RETIRED — `source.rdb` + `@kind: table|view|materializedView|storedProc|tableFunction` is the form, with read-only-ness derived from `@kind`. Multi-source via `@role` (exactly one `primary` per object). Source physical name = `@table` (NOT `@name`); field physical name = `@column` (renamed from `@dbColumn`). Referential actions on relationships: `@onDelete` / `@onUpdate`.
2222
- Origin subtypes: `passthrough`, `aggregate`, `collection`, `computed`, `first` (concrete; `base` is the abstract root). `passthrough` is legal on an `object.value`-hosted field (FR-015 parameter lineage); the four assembly origins (`aggregate`/`computed`/`collection`/`first`) live on `object.projection` only — a value-hosted assembly origin is `ERR_SUBTYPE_RULE_VIOLATION` (#210).
23-
- Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side); on a `@cardinality: one` relationship it instead names which of several `identity.reference` nodes onto the same target this relationship navigates, short-circuiting the unique-candidate/`@sourceRefField`/name-pairing ladder (#368, [ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md) Amendment 1) — an unresolvable 1:N reference set is `ERR_INVALID_RELATIONSHIP` at load. `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N → `ERR_INVALID_RELATIONSHIP`.
23+
- Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side); on a `@cardinality: one` relationship it instead names which of several `identity.reference` nodes onto the same target this relationship navigates, short-circuiting the unique-candidate/`@sourceRefField`/name-pairing ladder (#368, [ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md) Amendment 1) — an unresolvable 1:N reference set is `ERR_INVALID_RELATIONSHIP` at load. `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N / **junction-unpairable** → `ERR_INVALID_RELATIONSHIP`. **Unpairable means the junction declares its two references but neither resolves to the navigating entity, or neither to the `@objectRef` target** — declaring two references is NOT enough, and the loader now checks WHAT they point at (owner ruling 2026-09-20). It does so by running the real FK derivation and converting its failure, never a parallel re-implementation, so the loader and the derivation cannot drift; scope mirrors codegen's own iteration exactly — every CONCRETE, non-projection object crossed with its EFFECTIVE relationships, NOT deduped by declaration, because pairing is a property of the navigating entity and an inherited M:N can pair from one subtype and not another. Before the ruling this loaded clean and then diverged: TS and C# warned and emitted no traversal route (a silent 404), while Java, Kotlin and Python failed the build. Gated by `fixtures/conformance/error-relationship-m2m-junction-unpairable/`.
2424
- Index subtypes: `index.lookup` (non-unique retrieval index; uniqueness is encoded in the **type**: `identity.secondary` = unique alternate key, `index.lookup` = non-unique; `@unique` is REMOVED from `identity.secondary` — `ERR_UNKNOWN_ATTR` on any legacy `@unique`). RDB-physical escapes `@using`/`@expr`/`@where`/`@orders` are registered by the db provider on **both** `identity.secondary` and `index.lookup`. `index.fulltext` / `index.vector` / `index.spatial` are reserved on the subtype axis — documented, NOT registered (YAGNI + 1.0 vocab freeze). See [ADR-0040](spec/decisions/ADR-0040-index-type-and-secondary-key-purity.md).
2525
- Layout subtypes: `dataGrid`
2626
- API subtypes: `api.base` / `api.operational` (request/response surface; subtype axis = interaction model, NEVER protocol — protocol lives in `binding.*` per operation: `rest` now, `messaging`/`grpc` reserved). Children: `operation.query` (outputRef → `object.projection`) / `operation.command` (inputRef → `object.value`, may also outputRef). Derived CRUD (FR-008/009) stays the zero-config default; declared `api` extends it. Org-tier modeling (application/service/network/deployment) stays OUT of core — provider SPI, FQN references. See [ADR-0030](spec/decisions/ADR-0030-declared-api-surface-and-org-tier-boundary.md). (FR-024 declared-API — planned; not yet in `expected-registry.json`; the remaining third of FR-024 after the projection/value taxonomy + validation parity.)

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
7070
- **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.
7171

7272
**Cross-port conformance corpora** (every port runs the shared corpus):
73-
- Metamodel: `fixtures/conformance/` (329 fixtures; 22 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
73+
- Metamodel: `fixtures/conformance/` (330 fixtures; 22 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
7474
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
7575
- 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".)
7676
- 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. 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.

‎CHANGELOG.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,49 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
1515

1616
### Changed
1717

18+
- **BREAKING (metadata): a M:N junction that cannot be PAIRED is now a load error in every
19+
port — previously-loading metadata stops loading.** `@through` has always been documented
20+
as a junction declaring "two `identity.reference` children, **one per FK side**", but the
21+
loader only ever counted them; it never checked what they point at. A junction whose two
22+
references name some other pair of entities therefore loaded perfectly clean, and then
23+
behaved differently in every port: TypeScript and C# warned and emitted no traversal route
24+
at all — the endpoint simply 404s, with nothing in the build output a CI gate would fail
25+
on — while Java, Kotlin and Python threw and failed the build. One input, two contracts,
26+
and the quiet arm was the dangerous one. Owner ruling 2026-09-20: the MODEL is what is
27+
wrong, so every port now rejects it at LOAD with `ERR_INVALID_RELATIONSHIP`.
28+
29+
**This is a correction, not a redesign, and it ships as a PATCH under the three-part test
30+
in `docs/compatibility-policy.md`:** the form contradicted the documented contract; it
31+
produced no correct outcome for anyone, in any port (a traversal that 404s is not a
32+
working outcome); and the load error names the exact repair — which entity, which
33+
relationship, which junction, and which reference is missing. `metamodelVersion` stays
34+
`1.0`: this lives in loader validation and has no registry-manifest footprint, which is
35+
precisely the class the version gate cannot see. **If your model has such a junction it
36+
will stop loading, and the fix is to point one of its `identity.reference` children at
37+
the navigating entity.**
38+
39+
The loader runs the REAL FK derivation and converts its failure, rather than
40+
re-implementing the pairing — the defect class being closed here is "two parts of the
41+
pipeline disagree", so a second opinion would have been the wrong shape. Scope mirrors
42+
codegen's own iteration exactly: every concrete, non-projection object crossed with its
43+
effective relationships, NOT deduped by declaration, because pairing is a property of the
44+
navigating entity and an inherited M:N can pair from one subtype and not another. Gated
45+
cross-port by `fixtures/conformance/error-relationship-m2m-junction-unpairable/`.
46+
47+
- **A cross-package M:N bound the WRONG junction in TypeScript and C#.** Found by the rule
48+
above, which made it fail loudly instead of silently. Both ports resolved `@through`
49+
through a bare-name lookup that a package-qualified name could never match, then fell
50+
back to the first entity with that short name — so with two same-short-named junctions in
51+
different packages (the shape `xpkg-m2n-collision` has covered all along) the derivation
52+
paired against the wrong one and gave up, emitting no traversal route for a perfectly
53+
valid model. Java was already FQN-exact here and unaffected. All four now resolve through
54+
the one ADR-0041/0042 matcher the loader itself uses, which also closes the deferred
55+
bare-name collision gap (#174): a bare `@objectRef`/`@through` now binds in the
56+
**referrer's** package instead of whichever same-named entity happened to load first —
57+
load-order dependence, not a naming rule. A junction reference's `@references` resolves in
58+
the junction's package and the relationship's own `@objectRef` in the declaring entity's,
59+
because under `extends` across packages those differ.
60+
1861
- **BREAKING (generated routes): the REST collection URL is now the entity name
1962
`snake_case`d then pluralized, in every port.** One rule replaces five. The ports
2063
agreed only on single regular words — `Author` → `authors` under every old rule, and

‎docs/CONFORMANCE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
2626

2727
| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
2828
|---|---|---|---|---|---|---|
29-
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 329 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
29+
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 330 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
3030
| [`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 |
3131
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
3232
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |

‎docs/features/relationships.md‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,15 +333,25 @@ entity and the entity you are navigating from as the relationship's *subject*: t
333333
source-side junction reference may name either, and `@objectRef` naming either makes
334334
the relationship a self-join. Nothing else counts — in particular an entity lying
335335
strictly *between* the declaring base and the navigating entity in a deeper hierarchy
336-
is **not** accepted, and a junction reference naming one fails derivation with
336+
is **not** accepted, and a junction reference naming one fails with
337337
`ERR_INVALID_RELATIONSHIP`.
338338

339+
**Since 2026-09-20 that failure is at LOAD, in every port.** Declaring two
340+
`identity.reference` children is not enough — the loader now checks what they point
341+
at, by running the same FK derivation and reporting its failure. Before, this was
342+
only ever caught downstream, and not consistently: TypeScript and C# warned and
343+
emitted no traversal route (so `GET /posts/1/tags` simply 404'd, with nothing in the
344+
build output to fail on), while Java, Kotlin and Python failed the build. The check
345+
runs for every concrete entity that can navigate the relationship, so an inherited
346+
M:N that pairs from one subtype and not another is reported against the subtype that
347+
cannot pair — which is the one whose endpoint would have gone missing.
348+
339349
```yaml
340350
# PostBase (abstract) declares the M:N; Post extends it. The junction may reference
341351
# EITHER PostBase or Post — both derive postId/tagId for Post.tags.
342352
- object.entity:
343353
name: PostBase
344-
isAbstract: true
354+
abstract: true
345355
children:
346356
- relationship.association:
347357
name: tags
@@ -545,6 +555,7 @@ The following conformance fixtures gate this feature's behavior across ports:
545555
- [`fixtures/conformance/relationship-one-two-refs-name-pairing/`](../../fixtures/conformance/relationship-one-two-refs-name-pairing/) — the same shape resolved by name-pairing alone (ladder stage 3)
546556
- [`fixtures/conformance/error-relationship-one-refs-ambiguous/`](../../fixtures/conformance/error-relationship-one-refs-ambiguous/) — neither `@sourceRefField` nor a pairing name given: `ERR_INVALID_RELATIONSHIP` at load (#368, ADR-0029 Amendment 1)
547557
- [`fixtures/conformance/relationship-one-two-refs-dotted-references/`](../../fixtures/conformance/relationship-one-two-refs-dotted-references/) — the same two-reference shape with `@references` in the dotted `Entity.field` form: the entity half is the segment before the first `.`, so the ladder resolves identically to the bare form
558+
- [`fixtures/conformance/error-relationship-m2m-junction-unpairable/`](../../fixtures/conformance/error-relationship-m2m-junction-unpairable/) — a junction declaring its two `identity.reference` children but neither resolving to the navigating entity: `ERR_INVALID_RELATIONSHIP` at load in every port (owner ruling 2026-09-20)
548559

549560
Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these
550561
via their respective conformance runners. See [`docs/CONFORMANCE.md`](../CONFORMANCE.md)

‎examples/showcase/site-payload.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"metamodel": "1.0"
88
},
99
"counts": {
10-
"fixtures": 329,
10+
"fixtures": 330,
1111
"corpora": 22,
1212
"baseTypes": 14
1313
},
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_INVALID_RELATIONSHIP",
5+
"source": {
6+
"format": "json",
7+
"files": [
8+
"meta.commerce.json"
9+
],
10+
"jsonPath": "$['metadata.root'].children[0]['object.entity'].children[1]['relationship.association']"
11+
}
12+
}
13+
],
14+
"warnings": []
15+
}

0 commit comments

Comments
 (0)