Skip to content

fix: two identity.reference nodes onto one entity made every association join the first FK (#368) - #371

Merged
dmealing merged 31 commits into
mainfrom
fix/368-assoc-ref-disambiguation
Sep 14, 2026
Merged

dmealing merged 31 commits into
mainfrom
fix/368-assoc-ref-disambiguation

Conversation

@dmealing

Copy link
Copy Markdown
Member

Summary

When an entity declares two identity.reference nodes onto the same target entity
(e.g. Match.homeTeamRef and Match.awayTeamRef, both -> Team), a
@cardinality: one relationship.association names only its target via
@objectRef — never which reference it means. Every resolver that had to answer
"which FK does this relationship navigate?" took the first matching reference and
never noticed there was a second. The second relationship's generated code silently
joined on the first relationship's FK column.

Why this was invisible: the emitted code compiles, the DDL is correct (each FK
column is emitted with its own real foreign key — only the ORM navigation picks the
wrong one), both FK columns are type-identical integer references to the same target
PK so nothing in the type system can tell them apart, and meta verify was clean
(2 gate(s) ran, all clean, zero anti-patterns). The only symptom was wrong rows
coming back from a relational query that navigated the second association.

Resolution

An ambiguous @cardinality: one reference set now resolves by an explicit ladder,
identical across all four ports (ADR-0029 Amendment 1):

  1. exactly one candidate identity.reference onto the target → that one (the common
    case, unchanged behavior);
  2. a declared @sourceRefField → the candidate whose FK field it names (this check
    short-circuits: a value naming no candidate's FK field is a load error at any
    candidate count, it never falls through to name-pairing);
  3. exactly one candidate whose name/FK field pairs with the relationship's own name
    (e.g. awayTeam pairs with awayTeamRef / awayTeamId) → that one;
  4. otherwise → ERR_INVALID_RELATIONSHIP at load, naming the relationship and every
    candidate reference.

@sourceRefField is now legal on a @cardinality: one relationship — previously
it was M:N-only and declaring it on a to-one relationship was itself a load error.

Every surface fixed

  • The TypeScript codegen relations() block (codegen-ts/src/relation-resolver.ts)
    — no longer takes refs.find(...) on target alone; resolves through the shared
    ladder and skips (rather than guesses) when the loader should already have refused
    the model.
  • The runtime relation traversal (runtime-ts/src/relation-resolver.ts) — the
    forward and inverse relation-descriptor lookups both resolve through the same
    ladder instead of a bare "first reference to this target" scan.
  • The projection join lookup (codegen-ts/src/projection/extract-view-spec.ts) —
    a @via join hop now prefers the specific reference/relationship the hop already
    named over re-deriving one from the target alone, so an explicit hop resolves
    cleanly even when the ladder alone could not disambiguate; a hop that is still
    genuinely ambiguous fails with a message pointing at a real fix rather than a
    dead-end attribute (round 2). origin.first's own correlation also refuses rather
    than silently taking the first match — but only when ONE HOLDER declares two or
    more references onto the other side; a mutual 1:1 (one reference per holder,
    pointing opposite ways) is legal and keeps working (see Limitations below).
  • The docs link graph (docs-site/src/link-graph.ts) — the belongs-to edge used
    to dedupe against the FK edge it supersedes now resolves through the same ladder.
  • The referential-actions correlation (TypeScript migrate-ts, the C# port, and
    the JVM tree's Kotlin Exposed table generator — Python does not implement this
    correlation and is unaffected) — this had the same defect one level over: every FK past the first silently inherited the
    first relationship's @onDelete/@onUpdate instead of its own, so a model mixing
    restrict and cascade across two references to the same target emitted the wrong
    referential action on whichever FK wasn't examined first. Now correlated by
    inverting the same ladder (a relationship r belongs to reference ref iff
    resolving r's ladder returns ref itself); a genuinely ambiguous correlation
    contributes nothing rather than an arbitrary action. The Kotlin generator
    (KotlinExposedTableGenerator) had both halves of the defect — a first-match-on-
    target child-side tier and a first-match reverse tier — and is fixed the same way
    here: RelationshipReferences.resolveRelationshipReference is already on its
    classpath, so the inversion is the identical rule, not a parallel one.
  • The Python ladder's @references head-parse (relationship_references.py) —
    Python compared the WHOLE @references value, so the normative dotted
    Entity.field form never matched a target: a valid dotted model was refused and an
    ambiguous one loaded clean, in both directions against TypeScript. It now takes the
    segment before the first . (searched after the last ::), exactly as the other
    three ports' targetEntity accessor does.
  • Loader validation reaches all four ports (TypeScript, Python, C#, Java) — the
    ambiguity check and the @sourceRefField-on-cardinality:one legality change are
    both implemented identically in every loader, backed by four new shared
    conformance fixtures
    under fixtures/conformance/:
    relationship-one-two-refs-name-pairing (resolves via name pairing),
    relationship-one-two-refs-sourcerefield (resolves via the declared attribute),
    relationship-one-two-refs-dotted-references (the same shape with @references in
    the dotted Entity.field form — the corpus's only use of it, and the reason a
    Python-only head-parse divergence stayed green), and
    error-relationship-one-refs-ambiguous (refused with ERR_INVALID_RELATIONSHIP
    naming both candidates). Corpus 328 -> 329.

Registry / release consequence

expected-registry.json changed — the @sourceRefField attribute description and
the type-level rules prose (on association, aggregation, composition, and the
abstract base subtype) were corrected to describe the new dual meaning; no
attribute, subtype, or type was added, removed, or retyped. Per docs/RELEASING.md,
any change to expected-registry.json forces all four registries (npm / PyPI / NuGet
/ Maven) to publish together at the next release, regardless of which product files
changed — recording that here so it isn't a surprise at release time.
metamodelVersion stays 1.0 — this is a description/prose correction, not a
vocabulary change.

Adopters with the affected shapes will see FK referential-action diffs on their next
meta migrate.
The correlation fix changes which ON DELETE / ON UPDATE a second
FK to the same target resolves to, so a model that was silently emitting the first
relationship's action now emits its own — a real, intended schema diff rather than
drift. The same applies to a Kotlin Exposed table's ReferenceOption arguments.

Java's relationship validation passes now collect all findings instead of
throwing on the first violation: validateRelationshipsM2M was converted from
void + throw to returning a List<MetaDataException>, and the new rule-(e) pass
(validateOneSideReferenceResolution) collects by construction. A Java loader run
now reports every broken relationship where it previously reported only the first —
user-visible output, not an internal refactor.

Documented limitations (not fixed here)

  • Composite references can't be disambiguated — and this does not refuse. The
    ladder's @sourceRefField match and name-pairing both key off a candidate's
    first FK field only. Rule (e) accepts a @sourceRefField that matches any
    candidate's first column and the ladder's .find() then returns the first
    candidate, so two composite references sharing a first column load clean and
    resolve to whichever is declared first.
  • The loader gate covers only @cardinality: one. A many-cardinality
    relationship, or a bare identity.reference pair with no relationship wrapper at
    all, still reaches codegen unvalidated.
  • The load-time gate needs at least one candidate reference on the holder. Rule
    (e)'s candidates.length <= 1 skip skips zero as well as one, so a
    @cardinality: one relationship whose holder declares no identity.reference at
    the target is not covered. An inverted shape — the relationship on one entity, both
    FKs on the far side — loads clean, and codegen then silently drops the relation.
  • origin.first projections on an ambiguous target remain unusable.
    origin.first's own @via is not consulted by the correlation that resolves its
    base↔child join (a separate, larger wiring gap, out of scope here); when ONE HOLDER
    declares more than one reference onto the other side, the projection now fails
    loudly at generation time instead of silently picking one. A mutual 1:1 (one
    reference per holder, pointing opposite ways) is not that ambiguity and keeps
    working.

Closes #368.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC

dmealing and others added 30 commits September 13, 2026 14:27
Two identity.reference nodes onto the same entity made every
`@cardinality: one` relationship resolve to the first one's FK.

The plan resolves it with no new vocabulary: @sourceRefField is already
registered on every relationship.* subtype and currently fails to load on
a 1:N, so giving it meaning there is additive under the compatibility
policy's correction bar. Resolution ladder is unique-candidate ->
@sourceRefField -> name pairing -> ERR_INVALID_RELATIONSHIP at load,
per ADR-0029 §5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
Add resolveRelationshipReference() so a @Cardinality: one relationship
can disambiguate between multiple identity.reference candidates onto
the same target entity (e.g. Match.homeTeamRef vs Match.awayTeamRef
both -> Team), instead of silently taking the first match and joining
on the wrong FK column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
Widen validateRelationships rule (d): @sourceRefField is no longer
rejected on a @Cardinality: "one" relationship. It now legally names
which of several identity.reference nodes onto the same target the
relationship navigates, disambiguating cases like Match.homeTeam /
Match.awayTeam both referencing Team. The M:N junction reading still
requires @Cardinality: "many" + @through, so @sourceRefField on any
other non-M:N relationship keeps erroring.

Purely additive: @sourceRefField is already registered on every
relationship.* subtype, and this cardinality:one input previously had
no valid meaning at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
Rule (e): a `@cardinality: one` relationship must resolve to exactly one
identity.reference. Two references onto the same target are
indistinguishable from `@objectRef` alone, so codegen was silently
emitting the first candidate's FK column -- a wrong join that compiled,
typechecked, produced correct DDL, and passed `meta verify`. Add
validateOneSideReferenceResolution, registered right after the M:N
slim-vocabulary pass in the loader's validation sequence, so an
unresolvable case now fails to load with the candidates named
(ADR-0029 SS5) instead of surfacing only as wrong rows at runtime.

Two pre-existing Task-1 tests asserted a clean load for fixtures that
are themselves the ambiguous case this rule now catches; updated their
expectations to the new (correct) ERR_INVALID_RELATIONSHIP behavior
while leaving the underlying ladder-function assertions untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…rceRefField at any candidate count (#368)

Four review findings addressed:

1. A declared @sourceRefField naming no candidate was silently ignored
   whenever the entity had exactly one candidate, because the guard read
   candidate count before the declared field and resolveRelationshipReference's
   ladder step 1 returns the lone candidate unconditionally. Moved the
   @sourceRefField check ahead of the count guard and made it independent
   of the ladder, so a mismatch now errors at any candidate count (0, 1,
   or many). Task 1's ladder is untouched -- this is a validation-pass
   fix only.
2. Strengthened the declared-variant test to assert the message names the
   failing relationship and quotes the bad field, and that the valid
   sibling relationship produced no error of its own.
3. Restored exact-error assertions (toEqual) on the two Task-1 tests
   touched in the initial pass, which had regressed to toContain.
4. Composite reference candidates now render their full field tuple in
   error messages instead of just fields[0], so two composite references
   sharing a first column print distinguishably. Matching still keys on
   fields[0] alone -- a documented limitation, not fixed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…#368)

buildRelationMap picked the FIRST identity.reference whose target matched a
@Cardinality: one relationship's target — so when an entity declared two
references onto the same target (e.g. Match.homeTeamRef/awayTeamRef -> Team),
every such relationship's Drizzle one() block joined on the first reference's
FK column. It compiled, typechecked (both FKs share a type), the DDL was
correct, and `meta verify` was clean; the only symptom was wrong rows.

Resolve through the shared ladder from Task 1
(resolveRelationshipReference: unique candidate -> @sourceRefField ->
name pairing) instead of a bare .find(). The loader (Task 3) now refuses to
load a model it cannot resolve, so a miss here means an unloadable model
reached codegen — skip rather than guess.

New relation-resolver-two-refs.test.ts proves both the implicit name-pairing
case (the bug report) and an explicit @sourceRefField case reach codegen
correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…ion (#368)

resolveRelationDescriptor's findReferenceFkField walked the holder's
identity.reference children and returned the FIRST whose @references matched
the target -- the same defect Task 4 fixed in codegen, but at REST runtime.
When an entity declared two references onto the same target (e.g.
Match.homeTeamRef/awayTeamRef -> Team), every relation traversal onto that
target read the same FK column, so a generated REST route's lazy load or
include returned rows joined on the wrong column. It compiled, typechecked,
and the DDL was correct; the only symptom was wrong rows served over the API.

Resolve through the shared ladder from Task 1 (resolveRelationshipReference:
unique candidate -> @sourceRefField -> name pairing) instead of a bare walk.
The one-side and many-side call sites are not symmetric: many-side resolves
the FK on the OTHER entity, using that entity's own relationship name and
@sourceRefField, not sourceEntity's.

New relation-resolver-two-refs.test.ts proves both directions: one-side
(homeTeam -> homeTeamId, awayTeam -> awayTeamId) and many-side (the inverse
traversal resolves the FK of the matched relationship, not whichever
identity.reference happens to be declared first).

Also fixes test/_meta-build.ts: identity nodes are now dispatched to their
concrete subtype class (MetaPrimaryIdentity/MetaSecondaryIdentity/
MetaReferenceIdentity), mirroring the loader's own IDENTITY_CLASS_MAP,
instead of always constructing the base MetaIdentity regardless of subtype.
resolveRelationshipReference reads subtype-only getters (targetEntity,
referencesRaw) that a bare MetaIdentity doesn't have, so hand-built test
fixtures using identity.reference nodes stopped resolving any candidate
until this was fixed -- a real gap in the test helper, not the production
defect this task targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…t own-only (#368)

Scoping hole found ahead of the Java/C#/Python ports: validateOneSideReferenceResolution
walked obj.ownChildren() for relationships while referenceCandidatesFor()
reads the resolving/effective reference set, so the two disagreed under
extends. If entity A declares a @Cardinality:one relationship plus one
identity.reference, and B extends A adding a second identity.reference
onto the same target, validating B never examined the inherited
relationship at all -- it loaded clean while codegen/runtime, which
resolve against B's effective children, silently dropped the relation.

Switched the outer loop to obj.relationships() (own + inherited via
extends). Documented why rule (e) is resolving-scoped where rule (d)
(the M:N slim-vocabulary pass) is correctly own-scoped: rule (d)
validates attrs that travel with the relationship's own declaration;
rule (e) validates whether a given entity's reference set resolves the
relationship uniquely, a property of the effective entity. When both a
parent and a child are genuinely ambiguous, both now report -- two
broken entities, not duplicate reporting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
… the first (#368)

findReferenceBetween walked a then b and returned the first identity.reference
match, silently guessing when an entity declares two references onto the same
target (e.g. Match.homeTeamRef/awayTeamRef -> Team). Add findReferencesBetween
(every match) alongside it; findReferenceBetween now delegates to its first
entry, keeping its exact signature and documented first-match contract for
external consumers.

Both projection codegen call sites (a @via hop join and an origin.first
correlation) now refuse ambiguity instead of taking [0], throwing an error
that names the two entities, the candidate references, and points the author
at @via. Full codegen-ts suite (1638) still passes unmodified — no existing
projection fixture relies on the old silent-first behaviour.

docs-site's link-graph.ts also took the first reference when de-duping a
belongs-to relationship's raw FK edge, which actually caused the opposite bug
from a plain filter/loop rewrite: with two same-target relationships it drew a
spurious duplicate fk edge, and with one relationship plus an unrelated bare
reference to the same target it would have swallowed that reference's only
edge entirely. Resolved instead via resolveRelationshipReference (the same
disambiguation ladder relation-resolver already uses for @sourceRefField),
verified against both cases with a throwaway script before landing.

metadata suite 2694 -> 2697 (new ambiguity tests). No pre-existing test
modified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…t, with dedup by declaration (#368)

Maintainer ruling: validateRelationships (the pre-existing M:N
slim-vocabulary pass) iterated obj.ownChildren() for relationships, an
ADR-0039 own*() violation outside the sanctioned cases -- and about to be
mirrored into Java/C#/Python by Task 7. Switched to the resolving
obj.relationships() accessor, same as rule (e)'s fix round 2.

Unlike rule (e), rule (d)'s checks read only the relationship's own
declaration, so an inherited unmodified relationship visited once per
inheriting entity is pure duplicate noise, not independently-broken
entities. The obvious dedupe key (rel.source + code/message) doesn't
work: every message interpolates the CURRENTLY-ITERATING entity's name,
which differs per inheriting entity even for an identical declaration.
Fixed by reading every piece of validation context -- entity name,
@through's ADR-0042 package resolution, rule (a)'s self-join comparison
-- from rel.parent (the entity that actually DECLARES the relationship,
stable across every effective view that reaches it) instead of the
loop's obj. That makes each check's result a pure function of the
relationship node, so a plain Set<MetaData> keyed on rel's own object
identity, checked once, is sufficient and correct -- an override is a
genuinely different object and is never skipped.

This also fixes two latent bugs the naive loop-switch would have
introduced: @through resolving against the wrong (visiting, not
declaring) entity's package, and rule (a) misfiring on every subclass of
an entity with a legitimate self-join relationship. Neither is covered
by an existing fixture (0 count drop), flagged for Task 7 to carry
forward rather than re-introduce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
 ambiguity check (fix round 1)

Review found the ambiguity throw added in the prior commit was blind to the
resolved @via hop's own identity: resolveHop already finds the exact named
relationship or identity.reference, but the call site kept only targetName/
cardinality and re-derived candidates from the target entity alone — so
@via: "Match.homeTeamRef", naming the exact reference, still hit
"ambiguous: homeTeamRef, awayTeamRef" even though the author already
disambiguated it. Both the relationship-hop and reference-hop cases discarded
the hop identically.

Add resolveHopReference(holder, hop, hopName, target): a reference hop IS the
answer (no search needed); a relationship hop resolves through
resolveRelationshipReference (the same @sourceRefField/name-pairing ladder
relation-resolver.ts already uses), falling back to findReferencesBetween only
when even that ladder cannot choose. buildJoinTree's @via call site now routes
through it. buildSelectSpec's origin.first correlation is a genuinely
different mechanism — its own @via attribute is never read anywhere in this
file — so it keeps the throw but with a corrected, honest remedy instead of
the same now-proven-wrong "declare @via" advice.

Added regression + ambiguity coverage: codegen-ts/test/projection/
reference-ambiguity.test.ts (3 tests: exact-reference @via now resolves; a
cardinality:many relationship hop whose candidates live on the target side
still throws correctly; an origin.first correlation with two references
throws). docs-site/test/link-graph.test.ts gets a Match/Team fixture
(test/fixture/input/repro368/, kept out of acme/ to avoid golden.test.ts's
full-site snapshot) asserting exactly 2 edges render, not 3.

metadata 2699 (unrelated work landed since the prior commit; unchanged here),
codegen-ts 1638 -> 1641, docs-site 45 -> 46. No pre-existing test modified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
Ports the TypeScript resolution ladder and its two loader-validation
rules to Python only (Java/C# are separate tasks):

- New relationship_references.py: the resolve-relationship-reference.ts
  ladder verbatim (unique candidate -> @sourceRefField -> unique
  name-pairing -> none), same PAIRING_SUFFIXES order, candidate-side-only
  suffix stripping.
- validation_passes.py rule (d): @sourceRefField is now legal on
  @Cardinality: "one" (previously rejected on any non-M:N relationship);
  both rule (d) and the new rule (e) iterate the effective (resolving)
  relationship set instead of own-declared only, per the ADR-0039 ruling
  that own*() outside the emit-declared-here case is a bug. Rule (d)
  dedupes on the relationship node's own identity so an inherited,
  unmodified relationship reports once, not once per inheriting entity;
  rule (e) does not dedupe, since its candidate set is a property of the
  effective entity. Fixed two latent "obj vs. declaring entity" bugs
  (ADR-0042 package resolution for a bare @through, and rule (a)'s
  self-join check) that the switch to resolving iteration would
  otherwise have exposed.
- New rule (e) (_validate_one_side_reference_resolution): a
  @Cardinality: one relationship must resolve to exactly one
  identity.reference; ambiguity is a load error naming every candidate.

21 new tests (loader-integration + direct ladder unit tests). Full
Python suite: 2138 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
Ports the TypeScript resolution ladder and its two loader-validation
rules to C# (Java is a separate task):

- New RelationshipReferences.cs: the resolve-relationship-reference.ts
  ladder verbatim (unique candidate -> @sourceRefField -> unique
  name-pairing -> none), same PAIRING_SUFFIXES order, candidate-side-only
  suffix stripping.
- ValidationPasses.cs rule (d): @sourceRefField is now legal on
  @Cardinality: "one" (previously rejected on any non-M:N relationship);
  both rule (d) and the new rule (e) iterate the effective (resolving)
  relationship set instead of own-declared only, per the ADR-0039 ruling
  that own*() outside the emit-declared-here case is a bug. Rule (d)
  dedupes on the relationship node's own identity (explicit
  ReferenceEqualityComparer) so an inherited, unmodified relationship
  reports once, not once per inheriting entity; rule (e) does not dedupe,
  since its candidate set is a property of the effective entity. Fixed
  two latent "obj vs. declaring entity" bugs (ADR-0042 package resolution
  for a bare @through, and rule (a)'s self-join check) that the switch to
  resolving iteration would otherwise have exposed — both now read
  context from rel.Parent, matching the TS/Python ports.
- New rule (e) (ValidateOneSideReferenceResolution): a @Cardinality: one
  relationship must resolve to exactly one identity.reference; ambiguity
  is a load error naming every candidate.

23 new tests (loader-integration + direct ladder unit tests), including
two regressions for the latent obj-vs-declaring-entity bugs neither TS
nor Python fixture-covered. Full C# suite: 1892 passed, 1 pre-existing
skip, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
Ports the TypeScript resolution ladder and its two loader-validation
rules to Java (the last of the five ports -- TypeScript, Python and
C# are done):

- New RelationshipReferences.java: the resolve-relationship-reference.ts
  ladder verbatim (unique candidate -> @sourceRefField -> unique
  name-pairing -> none), same PAIRING_SUFFIXES order, candidate-side-only
  suffix stripping, toLowerCase(Locale.ROOT) throughout.
- ValidationPhase.java rule (d): @sourceRefField is now legal on
  @Cardinality: "one" (previously rejected on any non-M:N relationship);
  both rule (d) and the new rule (e) iterate the effective (resolving)
  relationship set via getRelationships() instead of the own-only
  boolean overload, per the ADR-0039 ruling that own*() outside the
  emit-declared-here case is a bug. Rule (d) dedupes on the relationship
  node's own identity (IdentityHashMap-backed set) so an inherited,
  unmodified relationship reports once, not once per inheriting entity;
  rule (e) does not dedupe, since its candidate set is a property of the
  effective entity. Fixed two latent "obj vs. declaring entity" bugs
  (ADR-0042 package resolution for a bare @through, and rule (a)'s
  self-join check) that the switch to resolving iteration would
  otherwise have exposed -- both fixed by resolving the declaring
  entity from rel.getParent() instead of the visiting object.
- New rule (e) (validateOneSideReferenceResolution): a @Cardinality:
  one relationship must resolve to exactly one identity.reference;
  ambiguity is a load error naming every candidate.
- Both passes changed from eager-throw-on-first-violation to
  collect-every-finding, matching TS/Python/C#'s list-collection
  semantics (needed for the dedupe and cross-relationship-sibling
  tests to be meaningful) -- wired into run() the same way the
  registry-derived RegisteredValidation pass already is.

25 new tests (loader-integration + direct ladder unit tests), including
two order-dependent regressions for the latent bugs and a
cross-relationship state-leakage test not present in the Python port.
Full Java metadata module suite: 1607 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…le collect-all tests

Addresses five review findings on the Java 1:N reference resolution
parity port:

- Cross-port divergence: MetaRelationship.getCardinality() defaults to
  "one" when absent, but TS/C#/Python all compare the raw attribute at
  rule (d)'s @sourceRefField exemption and rule (e)'s cardinality gate.
  Added rawCardinality(rel) (mirroring the existing hopCardinality(...)
  raw-read idiom elsewhere in this file) and used it at both decision
  points, so an absent @Cardinality is neither "many" nor "one" on Java
  either. Fixed symmetricOnCardinalityOneStillErrors, which relied on
  the defaulting accessor instead of declaring @Cardinality: "one".
  Added two new tests, both mutation-verified against the pre-fix
  (defaulting) behavior.
- Collect-all evidence: run()'s own dedupe(collected) collapses
  byte-identical exceptions by code+envelope regardless of
  checkedRels, so the existing dedupe tests would pass even with it
  deleted. Added Issue368RuleDDedupeUnitTest (com.metaobjects.loader
  package, to reach the package-private validateRelationshipsM2M
  directly and assert on its raw returned list size, bypassing run()'s
  dedupe) and twoIndependentRelationshipViolationsBothSurface (two
  unrelated relationships' errors must both survive one load). Both
  mutation-verified: removing checkedRels breaks the former (4 vs 1);
  simulating eager-throw-and-stop breaks the latter (1 vs 2).
- Wrapped both new run() loops in the existing pass(...) helper so an
  exception escaping list-construction folds into `collected` instead
  of aborting the remaining passes.
- Pinned the root.objects() iteration-order invariant the two latent-
  bug regression tests depend on with an explicit assertion.
- De-duplicated refFkField/stripPackage (byte-for-byte copies in
  RelationshipReferences.java and M2MFields.java, both already in the
  same package) into a new package-private ReferenceFkUtil.

mvn -pl metadata test: 1611 passed, 0 failed. Full Java reactor (mvn
test at server/java/, all 14 modules): BUILD SUCCESS, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
C# and Java already had regression tests proving the two "obj vs. declaring
entity" order-dependent bugs (ADR-0042 bare-@through package resolution and
rule (a)'s self-join check) fail against the visiting entity instead of the
relationship's declaring entity when an inheriting entity is visited first.
TypeScript and Python had the fix but no fixture that actually exercised
visit order, so their tests passed either way. Ports both regressions into
both, pinning the visit-order invariant explicitly (Java's lead) so a future
iteration-order change fails loudly instead of passing for the wrong reason.
Verified each new test genuinely fails by temporarily reverting the
declaring-entity fix, observing the predicted misfire, then restoring it.

Also backfills the cross-relationship state-leakage (sibling-isolation) test
into Python and C#, mirroring the existing Java/TS coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…n matched target alone

An entity may declare more than one relationship onto the same target
(Match.homeTeam / awayTeam, both -> Team). resolveReferentialActions /
ReferentialActions.Resolve correlated a relationship to the identity.reference
supplying its @onDelete/@onUpdate by matching the target entity alone, so
every FK past the first silently inherited the FIRST relationship's actions
(awayTeamRef got homeTeam's restrict instead of its own cascade) -- wrong
schema, not just wrong reads.

Tier 2 (sibling relationship) now applies the INVERSE of the existing
relationship->reference ladder (resolveRelationshipReference /
RelationshipReferences.ResolveRelationshipReference): a relationship belongs
to a reference iff the ladder, applied to it, resolves back to that same
reference. Tier 3 (reverse relationship on the target/parent entity) can't
use the ladder the same way -- its candidate relationships and the reference
live on different objects -- so it now fails closed (no action) when more
than one non-@through relationship on the parent points back at the child,
mirroring the sibling-reference ambiguity guard one level up instead of
taking the first match.

Both ports fail closed identically when the correlation is genuinely
ambiguous: no action, never a guess. Python and Java implement no such
correlation and are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…ield doc fix (#368)

Adds three shared conformance fixtures exercising the association->reference
disambiguation ladder for a `@cardinality: one` relationship: an ambiguous
case with two same-target identity.reference nodes and no disambiguator
(ERR_INVALID_RELATIONSHIP), the same shape resolved via explicit
@sourceRefField, and the issue's own name-pairing repro (homeTeamRef/
homeTeam, awayTeamRef/awayTeam). All four ports pick up and pass them
automatically via directory discovery.

Also corrects @sourceRefField's registered description, which described it
as junction/self-join-only. Since this issue it also selects which of
several identity.reference nodes a @Cardinality:one relationship navigates.
Updated everywhere it's declared (spec/metamodel, all four ports' schemas/
embedded copies) plus every derived artifact (expected-registry.json,
metamodel-docs, the site reference HTML) so nothing byte-matched against the
old text goes stale. metamodelVersion stays 1.0; the registry diff is
description-only on the four existing sourceRefField entries — no attribute
or type/subtype added or removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
Records that the ambiguity rule in ADR-0029 §5 (a second path is a load
error naming the candidates) now also governs 1:N FK selection between a
`@cardinality: one` relationship and multiple `identity.reference`
candidates onto the same target: unique candidate -> declared
`@sourceRefField` -> name-pairing -> ERR_INVALID_RELATIONSHIP.

- spec/decisions/ADR-0029-*.md: Amendment 1 states the ladder normatively,
  the candidate-side-only suffix-stripping rule and why, why it still
  meets the "trivially portable" bar, that no vocabulary was added, and
  which piece of the change (the new load refusal) is licensed by
  docs/compatibility-policy.md's correction bar.
- AGENTS.md (CLAUDE.md symlink target): extends the @sourceRefField
  sentence in the relationship-subtypes bullet to cover its 1:N meaning.
- CHANGELOG.md: Unreleased/Fixed entry covering the wrong-column join
  across codegen, runtime, projection joins and the docs link graph, the
  referential-actions sibling-FK defect (TS + C#), @sourceRefField now
  legal on @Cardinality: one, and the expected-registry.json description
  correction that forces a four-registry publish at the next release.
- docs/features/relationships.md: new "When one entity has two references
  to the same target" section documenting the ladder for authors, its
  documented limitations (composite references, the @Cardinality: one-only
  gate, origin.first, @via vs @sourceRefField), and the new conformance
  fixtures.

No code, test or fixture file touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…meaning

The type-level `rules` narrative for relationship.{base,association,
aggregation,composition} still described @sourceRefField as disambiguating
only a DIRECTED M:N self-join, contradicting the attribute table on the same
generated page (already updated for #368 to cover the 1:N identity.reference
selector case too). Extended the shared clause to name both meanings,
byte-identical across spec/metamodel/relationship.json and its six generated/
mirrored copies (python, csharp, ts-embedded, expected-registry.json,
metamodel-docs, site-reference).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…mended a dead-end attribute

The projection join-hop ambiguity error in buildJoinTree (extract-view-spec.ts)
told the author to "Declare @sourceRefField on the relationship" -- but the
only relationship hop that can ever reach this throw is @Cardinality "many"
and non-M:N, and validateRelationships rule (d) (validation-passes.ts) rejects
@sourceRefField on exactly that shape. Rule (e) already rejects, at load time,
any @Cardinality "one" relationship this same resolution ladder can't resolve,
so a "one" relationship never survives to reach codegen unresolved. The advice
could never be followed.

Replaced it with an honest message: explain why @sourceRefField can't help,
and state the remedies that ARE legal -- remove the extra identity.reference,
or restructure the model. Checked the sibling origin.first ambiguity message
(buildSelectSpec) -- it never recommended @sourceRefField (there's no
relationship node in that shape to attach it to), so it needed no change; a
test now locks that in too.

Message and test changes only -- no resolution/validation behavior changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…Cardinality claim was false in a reachable shape

Rule (e)'s zero-candidate gap (validation-passes.ts:2226, `candidates.length
<= 1` skips 0 too) lets a @Cardinality "one" relationship whose FK is
entirely on the far side reach the same join-hop ambiguity throw as the
@Cardinality "many" case -- and the previous message's "this relationship's
@Cardinality is not 'one'" claim is false for that shape (it genuinely is
"one" there). Not fixing rule (e) itself -- it's implemented in four
language ports, and a TypeScript-only change would create exactly the
cross-port divergence this branch exists to avoid; the gap stays parked.

Replaced the message with a claim computed from data already at hand
(which side of the join actually holds the ambiguous candidates), split
into the two branches where each statement is provably true:
- No candidate belongs to the hop's own entity: state that @sourceRefField
  only ever consults the hop's own identity.reference children and none
  exist here -- @Cardinality is never asserted, since it's unknowable in
  this branch (could be "one" via the gap, or "many").
- A candidate does belong to the hop's own entity: state the @Cardinality
  reason, which is now provably safe -- rule (e) uses the identical
  own-side ladder for @Cardinality "one" relationships, so reaching codegen
  with an own-side candidate proves @Cardinality is not "one".

Added a regression test for the coordinator-identified gap shape (Owner/Pet,
@Cardinality "one", Owner holds no reference of its own) plus one for the
other branch (Team holding its own duplicate references, @Cardinality
"many") so both branches stay exercised and neither can silently regress
into an unguaranteed claim again.

Message and test changes only -- no resolution/validation behavior changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
The metamodel conformance corpus grew from 325 to 328 fixtures with three
new association-reference disambiguation scenarios. Update all occurrences
in the documentation to reflect the new count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
reference_candidates_for compared the WHOLE @references value, so a dotted
"Team.id" never matched the target entity "Team". TypeScript, C# and Java all
take the segment before the first "." (MetaReferenceIdentity.targetEntity /
ReferenceIdentity.getTargetEntity), and the dotted form is normative —
spec/metamodel/identity.json documents "Program.id".

The divergence ran in BOTH directions, confirmed by running both loaders on
identical models: Python refused a valid dotted model (zero candidates made a
declared @sourceRefField look unsatisfiable — "names no identity.reference
targeting Team. Candidates: .") and Python failed to refuse a genuinely
ambiguous dotted model that TypeScript correctly rejects (rule (e)'s
`len(candidates) <= 1` skips zero as well as one).

Python has no MetaReferenceIdentity subclass to hang the accessor on, so the
head-parse lives in relationship_references as reference_target_entity(). The
dot is searched after the last "::" so a package separator can never be
mistaken for the field separator.

derive_m2m_fields._ref_target_entity has the same blind spot (the new module
copied it) and is DELIBERATELY left alone — repairing it would change M:N
derivation behaviour, which is outside this fix. It now carries a comment
naming the gap and the one-line repair.

Nothing in the 328-fixture corpus used the dotted form, which is why this
divergence stayed green: fixtures/conformance/relationship-one-two-refs-dotted-references
is the sibling of relationship-one-two-refs-sourcerefield with dotted
@references, and all four ports run it (corpus 328 -> 329; counts updated in
docs/CONFORMANCE.md, AGENTS.md and site-payload.json). Verified it FAILS on the
pre-fix Python loader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
KotlinExposedTableGenerator still had the defect migrate-ts/referential-actions.ts
and the C# ReferentialActions port were fixed for, so this branch left the ports
DISAGREEING where they had previously been consistently wrong.

Tier 2 matched a sibling relationship on the TARGET ALONE
(`firstOrNull { resolveObjectByShortOrFqn(...) === target }`), so with
Match.homeTeamRef (@onDelete: restrict) and Match.awayTeamRef (@onDelete: cascade)
both FKs were emitted with the FIRST relationship's action. Tier 3 took
`firstOrNull` on the reverse relationship where TS/C# now fail closed.

The port is a direct inversion, not a parallel rule: Kotlin is on the JVM and
RelationshipReferences.resolveRelationshipReference is already on the classpath, so
tier 2 now asks whether the ladder applied to `rel` resolves back to `ref` ITSELF,
and tier 3 returns `singleOrNull()` over the reverse candidates.

KotlinExposedTableTwoRefsTest covers both tiers and was verified to FAIL on the
pre-fix generator (both tests red, with the wrong ReferenceOption in the emitted
MatchTable). Full codegen-kotlin suite: 377 tests, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
… 1:1

extract-view-spec's origin.first correlation threw whenever
findReferencesBetween(base, childEntity).length > 1. That walk is BIDIRECTIONAL
(find-reference.ts walks [[a,b],[b,a]]), so a mutual 1:1 —
Customer.primaryAddressRef -> Address PLUS Address.customerRef -> Customer —
returned two entries and hard-failed codegen.

That is not #368 ambiguity: the two references point in OPPOSITE directions, the
surrounding code explicitly supports both (`referenceHolder: "source" | "target"`),
and findReferenceBetween's own contract calls mutual 1:1 "rare, but legal".

The refusal is now grouped by HOLDER and fires only when a SINGLE holder declares
two or more references onto the other side — the shape the first-match genuinely
cannot choose within. The message names that holder. A mutual 1:1 keeps the
documented first-match behaviour.

Tests: the new mutual-1:1 case asserts extractViewSpec does NOT throw and still
resolves source-held on primary_address_id (verified to FAIL pre-fix with exactly
the reported error); the existing same-holder ambiguity test still asserts the
throw, and now also pins the holder name in the message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…r call sites

Both existing tests resolve by NAME PAIRING (ladder step 3), so dropping the 4th
argument from either findReferenceFkField call in relation-resolver.ts left them
green — nothing proved @sourceRefField was read at runtime at all.

The new model names its relationships so they pair with NOTHING ("winner"/"loser"
against alphaRef/betaRef) and crosses the declared FKs against declaration order,
so only ladder step 2 can resolve them. Verified: dropping the 4th argument at
either call site turns both new tests red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…t rule (e)'s scope note

Two small cross-port corrections, identical in TypeScript, Python, C# and Java.

M7 — rule (d) rejected @sourceRefField with "but is not a M:N relationship.", which
reads as "this attribute is never allowed here" now that @Cardinality: "one" is a
legal home for it (the whole point of this branch). The message now names both:
"...but is neither a M:N relationship (requires @through with @Cardinality: "many")
nor a @Cardinality: "one" relationship." Wording is byte-identical across the four
ports.

M6 — rule (e)'s header comment still justified itself against a rule (d) that was
"own-scoped" ("so own-scoping there is correct"). Rule (d) stopped being own-scoped
in this same branch: it walks the effective relationship set and dedupes by node
identity, reporting against the declaring entity. The comment now says what actually
differs — the SUBJECT of each pass, not which relationships it can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…e gap, state the composite fallback

Three corrections to the public record.

I2 — CHANGELOG and PR-BODY both said "Python and Java do not implement this
correlation and are unaffected". That was FALSE for the Java tree's Kotlin Exposed
generator, which had the same first-match-on-target defect (fixed in the preceding
commit). Both now name TypeScript migrate-ts, the C# port and the Kotlin generator,
and say only Python is unaffected.

I3 — the ledger ruled that rule (e)'s ZERO-candidate gap goes in the limitation
list and it never landed. Added: the gate covers a @Cardinality: one relationship
only when its holder declares at least one identity.reference at the target, because
`candidates.length <= 1` skips zero as well as one. The user-visible consequence is
stated plainly — an inverted shape (relationship on one entity, both FKs on the far
side) loads clean and codegen then SILENTLY DROPS the relation.

M5 — the composite-reference limitation was understated: it does not refuse. Rule
(e) accepts a @sourceRefField matching any candidate's first column and the ladder's
.find() returns the FIRST candidate, so such a model loads clean and resolves to
whichever composite reference is declared first.

Also recorded in CHANGELOG, both user-visible: Java's relationship validation moved
from eager-throw-on-first-violation to collect-all-findings (a change to Java loader
OUTPUT), and adopters with the affected shapes will see FK referential-action diffs
on their next `meta migrate`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
…nships.md

The new fixture gates the dotted `Entity.field` form of `@references` across all
four ports; the feature doc's "Verified by" block is where that mapping lives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
The list gained the rule-(e) zero-candidate gap in e2b32f8 but the
count word was not updated with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
@dmealing
dmealing merged commit 61470b8 into main Sep 14, 2026
1 check passed
@dmealing
dmealing deleted the fix/368-assoc-ref-disambiguation branch September 14, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

codegen-ts: two identity.reference nodes onto the same entity make every relationship.association join the FIRST one's FK column

1 participant