fix: two identity.reference nodes onto one entity made every association join the first FK (#368) - #371
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When an entity declares two
identity.referencenodes onto the same target entity(e.g.
Match.homeTeamRefandMatch.awayTeamRef, both-> Team), a@cardinality: onerelationship.associationnames 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
integerreferences to the same targetPK so nothing in the type system can tell them apart, and
meta verifywas clean(
2 gate(s) ran, all clean, zero anti-patterns). The only symptom was wrong rowscoming back from a relational query that navigated the second association.
Resolution
An ambiguous
@cardinality: onereference set now resolves by an explicit ladder,identical across all four ports (ADR-0029 Amendment 1):
identity.referenceonto the target → that one (the commoncase, unchanged behavior);
@sourceRefField→ the candidate whose FK field it names (this checkshort-circuits: a value naming no candidate's FK field is a load error at any
candidate count, it never falls through to name-pairing);
(e.g.
awayTeampairs withawayTeamRef/awayTeamId) → that one;ERR_INVALID_RELATIONSHIPat load, naming the relationship and everycandidate reference.
@sourceRefFieldis now legal on a@cardinality: onerelationship — previouslyit was M:N-only and declaring it on a to-one relationship was itself a load error.
Every surface fixed
relations()block (codegen-ts/src/relation-resolver.ts)— no longer takes
refs.find(...)on target alone; resolves through the sharedladder and skips (rather than guesses) when the loader should already have refused
the model.
runtime-ts/src/relation-resolver.ts) — theforward and inverse relation-descriptor lookups both resolve through the same
ladder instead of a bare "first reference to this target" scan.
codegen-ts/src/projection/extract-view-spec.ts) —a
@viajoin hop now prefers the specific reference/relationship the hop alreadynamed 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 ratherthan 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).
docs-site/src/link-graph.ts) — the belongs-to edge usedto dedupe against the FK edge it supersedes now resolves through the same ladder.
migrate-ts, the C# port, andthe 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/@onUpdateinstead of its own, so a model mixingrestrictandcascadeacross two references to the same target emitted the wrongreferential action on whichever FK wasn't examined first. Now correlated by
inverting the same ladder (a relationship
rbelongs to referencerefiffresolving
r's ladder returnsrefitself); a genuinely ambiguous correlationcontributes 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.resolveRelationshipReferenceis already on itsclasspath, so the inversion is the identical rule, not a parallel one.
@referenceshead-parse (relationship_references.py) —Python compared the WHOLE
@referencesvalue, so the normative dottedEntity.fieldform never matched a target: a valid dotted model was refused and anambiguous one loaded clean, in both directions against TypeScript. It now takes the
segment before the first
.(searched after the last::), exactly as the otherthree ports'
targetEntityaccessor does.ambiguity check and the
@sourceRefField-on-cardinality:onelegality change areboth 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@referencesinthe dotted
Entity.fieldform — the corpus's only use of it, and the reason aPython-only head-parse divergence stayed green), and
error-relationship-one-refs-ambiguous(refused withERR_INVALID_RELATIONSHIPnaming both candidates). Corpus 328 -> 329.
Registry / release consequence
expected-registry.jsonchanged — the@sourceRefFieldattribute description andthe type-level
rulesprose (onassociation,aggregation,composition, and theabstract
basesubtype) were corrected to describe the new dual meaning; noattribute, subtype, or type was added, removed, or retyped. Per
docs/RELEASING.md,any change to
expected-registry.jsonforces 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.
metamodelVersionstays1.0— this is a description/prose correction, not avocabulary change.
Adopters with the affected shapes will see FK referential-action diffs on their next
meta migrate. The correlation fix changes whichON DELETE/ON UPDATEa secondFK 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
ReferenceOptionarguments.Java's relationship validation passes now collect all findings instead of
throwing on the first violation:
validateRelationshipsM2Mwas converted fromvoid+throwto returning aList<MetaDataException>, and the new rule-(e) pass(
validateOneSideReferenceResolution) collects by construction. A Java loader runnow reports every broken relationship where it previously reported only the first —
user-visible output, not an internal refactor.
Documented limitations (not fixed here)
ladder's
@sourceRefFieldmatch and name-pairing both key off a candidate'sfirst FK field only. Rule (e) accepts a
@sourceRefFieldthat matches anycandidate's first column and the ladder's
.find()then returns the firstcandidate, so two composite references sharing a first column load clean and
resolve to whichever is declared first.
@cardinality: one. Amany-cardinalityrelationship, or a bare
identity.referencepair with no relationship wrapper atall, still reaches codegen unvalidated.
(e)'s
candidates.length <= 1skip skips zero as well as one, so a@cardinality: onerelationship whose holder declares noidentity.referenceatthe 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.firstprojections on an ambiguous target remain unusable.origin.first's own@viais not consulted by the correlation that resolves itsbase↔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