You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 8e5affd
Browse filesBrowse the repository at this point in the historyBrowse files
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
Copy file name to clipboardExpand all lines: .claude/rules/cross-language-porting.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -20,7 +20,7 @@ Preserve the following contracts exactly across all language ports:
20
20
- 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.)
21
21
- 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`.
22
22
- 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/`.
24
24
- 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).
25
25
- Layout subtypes: `dataGrid`
26
26
- 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.)
Copy file name to clipboardExpand all lines: AGENTS.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -70,7 +70,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
70
70
-**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.
71
71
72
72
**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.
- 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".)
76
76
- 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.
Copy file name to clipboardExpand all lines: docs/features/relationships.md
+13-2Lines changed: 13 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -333,15 +333,25 @@ entity and the entity you are navigating from as the relationship's *subject*: t
333
333
source-side junction reference may name either, and `@objectRef` naming either makes
334
334
the relationship a self-join. Nothing else counts — in particular an entity lying
335
335
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
337
337
`ERR_INVALID_RELATIONSHIP`.
338
338
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
+
339
349
```yaml
340
350
# PostBase (abstract) declares the M:N; Post extends it. The junction may reference
341
351
# EITHER PostBase or Post — both derive postId/tagId for Post.tags.
342
352
- object.entity:
343
353
name: PostBase
344
-
isAbstract: true
354
+
abstract: true
345
355
children:
346
356
- relationship.association:
347
357
name: tags
@@ -545,6 +555,7 @@ The following conformance fixtures gate this feature's behavior across ports:
545
555
-[`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)
546
556
-[`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)
547
557
-[`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)
548
559
549
560
Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these
550
561
via their respective conformance runners. See [`docs/CONFORMANCE.md`](../CONFORMANCE.md)
0 commit comments