diff --git a/fixtures/conformance/xpkg-m2n-collision/expected-effective.json b/fixtures/conformance/xpkg-m2n-collision/expected-effective.json new file mode 100644 index 000000000..7f37fb36d --- /dev/null +++ b/fixtures/conformance/xpkg-m2n-collision/expected-effective.json @@ -0,0 +1,161 @@ +{ + "metadata.root": { + "package": "xpkg::partner", + "children": [ + { + "object.entity": { + "name": "Account", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "region" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "AccountLink", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "aId" + } + }, + { + "field.long": { + "name": "bId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "aRef", + "@fields": [ + "aId" + ], + "@references": "xpkg::partner::Account" + } + }, + { + "identity.reference": { + "name": "bRef", + "@fields": [ + "bId" + ], + "@references": "xpkg::partner::Account" + } + } + ] + } + }, + { + "object.entity": { + "name": "Account", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "name" + } + }, + { + "relationship.association": { + "name": "partners", + "@cardinality": "many", + "@objectRef": "xpkg::partner::Account", + "@through": "xpkg::store::AccountLink" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "AccountLink", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "ownerId" + } + }, + { + "field.long": { + "name": "partnerId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "ownerRef", + "@fields": [ + "ownerId" + ], + "@references": "xpkg::store::Account" + } + }, + { + "identity.reference": { + "name": "partnerRef", + "@fields": [ + "partnerId" + ], + "@references": "xpkg::partner::Account" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-m2n-collision/expected.json b/fixtures/conformance/xpkg-m2n-collision/expected.json new file mode 100644 index 000000000..7f37fb36d --- /dev/null +++ b/fixtures/conformance/xpkg-m2n-collision/expected.json @@ -0,0 +1,161 @@ +{ + "metadata.root": { + "package": "xpkg::partner", + "children": [ + { + "object.entity": { + "name": "Account", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "region" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "AccountLink", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "aId" + } + }, + { + "field.long": { + "name": "bId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "aRef", + "@fields": [ + "aId" + ], + "@references": "xpkg::partner::Account" + } + }, + { + "identity.reference": { + "name": "bRef", + "@fields": [ + "bId" + ], + "@references": "xpkg::partner::Account" + } + } + ] + } + }, + { + "object.entity": { + "name": "Account", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "name" + } + }, + { + "relationship.association": { + "name": "partners", + "@cardinality": "many", + "@objectRef": "xpkg::partner::Account", + "@through": "xpkg::store::AccountLink" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "AccountLink", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "ownerId" + } + }, + { + "field.long": { + "name": "partnerId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "ownerRef", + "@fields": [ + "ownerId" + ], + "@references": "xpkg::store::Account" + } + }, + { + "identity.reference": { + "name": "partnerRef", + "@fields": [ + "partnerId" + ], + "@references": "xpkg::partner::Account" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-m2n-collision/input/meta.partner.json b/fixtures/conformance/xpkg-m2n-collision/input/meta.partner.json new file mode 100644 index 000000000..84f81ae14 --- /dev/null +++ b/fixtures/conformance/xpkg-m2n-collision/input/meta.partner.json @@ -0,0 +1,30 @@ +{ + "metadata.root": { + "package": "xpkg::partner", + "children": [ + { + "object.entity": { + "name": "Account", + "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "region" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] + } + }, + { + "object.entity": { + "name": "AccountLink", + "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "aId" } }, + { "field.long": { "name": "bId" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "identity.reference": { "name": "aRef", "@fields": ["aId"], "@references": "xpkg::partner::Account" } }, + { "identity.reference": { "name": "bRef", "@fields": ["bId"], "@references": "xpkg::partner::Account" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-m2n-collision/input/meta.store.json b/fixtures/conformance/xpkg-m2n-collision/input/meta.store.json new file mode 100644 index 000000000..2465caee2 --- /dev/null +++ b/fixtures/conformance/xpkg-m2n-collision/input/meta.store.json @@ -0,0 +1,38 @@ +{ + "metadata.root": { + "package": "xpkg::store", + "children": [ + { + "object.entity": { + "name": "Account", + "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name" } }, + { + "relationship.association": { + "name": "partners", + "@cardinality": "many", + "@objectRef": "xpkg::partner::Account", + "@through": "xpkg::store::AccountLink" + } + }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] + } + }, + { + "object.entity": { + "name": "AccountLink", + "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "ownerId" } }, + { "field.long": { "name": "partnerId" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "identity.reference": { "name": "ownerRef", "@fields": ["ownerId"], "@references": "xpkg::store::Account" } }, + { "identity.reference": { "name": "partnerRef", "@fields": ["partnerId"], "@references": "xpkg::partner::Account" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-m2n-collision/providers.json b/fixtures/conformance/xpkg-m2n-collision/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/xpkg-m2n-collision/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/server/java/metadata/src/main/java/com/metaobjects/relationship/M2MFields.java b/server/java/metadata/src/main/java/com/metaobjects/relationship/M2MFields.java index 716bad9e6..a28c4519c 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/relationship/M2MFields.java +++ b/server/java/metadata/src/main/java/com/metaobjects/relationship/M2MFields.java @@ -109,6 +109,7 @@ public static M2MFields derive(MetaRelationship rel, MetaObject source, MetaRoot "relationship \"" + source.getShortName() + "." + rel.getShortName() + "\" is missing @objectRef (the M:N target)"); } + MetaObject target = findObject(root, targetName); List refs = referenceIdentities(junction); if (refs.size() != 2) { @@ -118,12 +119,22 @@ public static M2MFields derive(MetaRelationship rel, MetaObject source, MetaRoot + " identity.reference children (found " + refs.size() + ")"); } - boolean isSelfJoin = stripPackage(targetName).equals(source.getShortName()); + // ADR-0041: classify the self-join by RESOLVED object identity (FQN-exact), + // never a stripped bare tail. A cross-package hetero M:N whose target shares + // a bare name with the source (or an unrelated entity) must NOT be mis-read + // as a self-join, and an FQN @objectRef must bind the correct package. Falls + // back to a bare-name compare only when @objectRef is unresolvable (defensive + // — loader validation normally guarantees resolution before derive runs). + boolean isSelfJoin = (target != null) + ? target.getName().equals(source.getName()) + : stripPackage(targetName).equals(source.getShortName()); if (!isSelfJoin) { - // Hetero: match each reference by the entity it resolves to. - MetaIdentity sourceRef = findRefToEntity(refs, source.getShortName()); - MetaIdentity targetRef = findRefToEntity(refs, stripPackage(targetName)); + // Hetero: match each reference by the ENTITY OBJECT its @references + // resolves to (FQN-exact), so a same-bare-name cross-package reference + // binds the correct package rather than the first bare-tail match. + MetaIdentity sourceRef = findRefToObject(root, refs, source); + MetaIdentity targetRef = findRefToObject(root, refs, target); String sourceField = sourceRef != null ? refFkField(sourceRef) : null; String targetField = targetRef != null ? refFkField(targetRef) : null; if (sourceField == null || targetField == null) { @@ -212,33 +223,55 @@ private static String refFkField(MetaIdentity ref) { return fields.isEmpty() ? null : fields.get(0); } - /** The bare entity name a reference's {@code @references} resolves to, or {@code null}. */ - private static String refTargetEntity(MetaIdentity ref) { + /** + * The entity object a reference's {@code @references} resolves to (FQN-exact), + * or {@code null}. ADR-0041: resolve to an actual object and compare identity — + * a bare-tail string compare mis-binds a same-named cross-package target. + */ + private static MetaObject refTargetObject(MetaRoot root, MetaIdentity ref) { // ADR-0039: @references is an inheritable effective identity attr — RESOLVE // (default includeParentData=true); own-only would miss an inherited target. if (!ref.hasMetaAttr(MetaIdentity.ATTR_REFERENCES)) return null; String v = ref.getMetaAttr(MetaIdentity.ATTR_REFERENCES).getValueAsString(); if (v == null || v.isEmpty()) return null; - // @references may carry a dotted Entity.field form — the entity is the head segment. + // @references may carry a dotted Entity.field form — the entity is the head + // segment (packages use "::", never ".", so the first "." splits it off). int dot = v.indexOf('.'); String entity = dot >= 0 ? v.substring(0, dot) : v; - return stripPackage(entity); + return findObject(root, entity); } - private static MetaIdentity findRefToEntity(List refs, String entityShortName) { + /** + * The reference whose {@code @references} resolves to {@code entity} (compared by + * FQN identity), or {@code null}. ADR-0041: identity compare, never a stripped + * bare tail — two junction references to same-bare-name entities in different + * packages must be distinguished by their full package-qualified name. + */ + private static MetaIdentity findRefToObject(MetaRoot root, List refs, MetaObject entity) { + if (entity == null) return null; for (MetaIdentity ref : refs) { - String t = refTargetEntity(ref); - if (t != null && t.equals(entityShortName)) return ref; + MetaObject t = refTargetObject(root, ref); + if (t != null && t.getName().equals(entity.getName())) return ref; } return null; } private static MetaObject findObject(MetaRoot root, String name) { - String bare = stripPackage(name); + if (name == null) return null; + // ADR-0041: a FULLY-QUALIFIED ref (contains "::") resolves EXACTLY on the + // object's package-qualified name — never a bare-tail fallback (the closed + // bug: an FQN @objectRef / @through binding a same-named object in the WRONG + // package). A bare ref matches the object's short name (first match wins; the + // same-package preference for a bare collision is the deferred follow-up, + // issue #174, mirroring SpringM2mSupport.findEntity and the TS residual note). + boolean fqn = name.contains(MetaData.PKG_SEPARATOR); // ADR-0039: root-level scan — root is never extended, so own children is correct. for (MetaData child : root.getChildren(MetaData.class, false)) { - if (child instanceof MetaObject && bare.equals(child.getShortName())) { - return (MetaObject) child; + if (child instanceof MetaObject) { + MetaObject mo = (MetaObject) child; + if (fqn ? name.equals(mo.getName()) : stripPackage(name).equals(mo.getShortName())) { + return mo; + } } } return null; diff --git a/server/java/metadata/src/test/java/com/metaobjects/relationship/M2MSlimVocabularyTest.java b/server/java/metadata/src/test/java/com/metaobjects/relationship/M2MSlimVocabularyTest.java index 493b76127..ff42f134b 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/relationship/M2MSlimVocabularyTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/relationship/M2MSlimVocabularyTest.java @@ -338,4 +338,131 @@ public void junctionWithInheritedReferencesDerivesFkDirection() { assertEquals("postId", f.getSourceField()); assertEquals("tagId", f.getTargetField()); } + + // --- 5. ADR-0041 — FQN references resolve EXACTLY, never a bare-tail fallback ------- + // + // These gate the M:N derivation-path FQN-discard bug (the shared cross-port + // xpkg-m2n-collision conformance fixture LOADS + round-trips the same model, but + // the canonical serializer only preserves the declared @objectRef/@through strings — + // it does NOT surface M2MFields.derive's result — so the collision cannot be + // distinguished at the conformance layer; these unit tests ARE the derive gate). + + private MetaDataLoader loadTwo(String a, String aId, String b, String bId) { + MetaDataLoader loader = newTestLoader(); + loader.load(List.of(new InMemoryStringSource(a, aId), new InMemoryStringSource(b, bId))); + return loader; + } + + /** Root object whose FULL package-qualified name matches (never a bare-tail match). */ + private static MetaObject objExact(MetaDataLoader loader, String fqn) { + for (MetaObject mo : loader.getRoot().getChildren(MetaObject.class, false)) { + if (fqn.equals(mo.getName())) return mo; + } + throw new IllegalStateException("no object " + fqn); + } + + private static MetaRelationship relOf(MetaObject obj, String relName) { + for (MetaRelationship r : obj.getRelationships()) { + if (relName.equals(r.getShortName())) return r; + } + throw new IllegalStateException("no relationship " + relName + " on " + obj.getName()); + } + + /** Decoy package: an unrelated {@code Account} + {@code AccountLink} sharing the bare + * names used by the real M:N in {@link #STORE_PKG}. Loaded FIRST so a bare-tail match + * would bind THIS package. */ + private static final String PARTNER_PKG = + "{ \"metadata.root\": { \"package\": \"xpkg::partner\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Account\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"AccountLink\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"aId\" } }," + + " { \"field.long\": { \"name\": \"bId\" } }," + + " { \"identity.primary\": { \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"aRef\", \"@fields\": \"aId\", \"@references\": \"xpkg::partner::Account\" } }," + + " { \"identity.reference\": { \"name\": \"bRef\", \"@fields\": \"bId\", \"@references\": \"xpkg::partner::Account\" } } ] } }" + + "] } }"; + + /** Real M:N: store::Account --partners--> partner::Account (a DIFFERENT entity that shares + * the bare name "Account") through store::AccountLink (whose bare name also collides). */ + private static final String STORE_PKG = + "{ \"metadata.root\": { \"package\": \"xpkg::store\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Account\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"relationship.association\": { \"name\": \"partners\", \"@cardinality\": \"many\"," + + " \"@objectRef\": \"xpkg::partner::Account\", \"@through\": \"xpkg::store::AccountLink\" } }," + + " { \"identity.primary\": { \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"AccountLink\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"ownerId\" } }," + + " { \"field.long\": { \"name\": \"partnerId\" } }," + + " { \"identity.primary\": { \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"ownerRef\", \"@fields\": \"ownerId\", \"@references\": \"xpkg::store::Account\" } }," + + " { \"identity.reference\": { \"name\": \"partnerRef\", \"@fields\": \"partnerId\", \"@references\": \"xpkg::partner::Account\" } } ] } }" + + "] } }"; + + @Test + public void deriveCrossPackageHeteroBindsCorrectPackage() { + // ADR-0041: same-bare-name entities/junctions in different packages. Under the + // pre-fix bare-tail resolution, findObject binds the WRONG junction + // (partner::AccountLink, loaded first) AND stripPackage("...::Account")=="Account" + // mis-reads the cross-package hetero M:N as a self-join → throws "ambiguous". The + // FQN-exact fix binds store::AccountLink and derives ownerId/partnerId. + MetaDataLoader loader = loadTwo(PARTNER_PKG, "partner.json", STORE_PKG, "store.json"); + MetaObject storeAccount = objExact(loader, "xpkg::store::Account"); + M2MFields f = M2MFields.derive(relOf(storeAccount, "partners"), storeAccount, loader.getRoot()); + assertEquals("ownerId", f.getSourceField()); + assertEquals("partnerId", f.getTargetField()); + } + + /** Decoy package whose junction bare name "PostTag" collides but carries DIFFERENT + * FK field names — so binding it (vs the real blog::PostTag) is observable. */ + private static final String ARCHIVE_PKG = + "{ \"metadata.root\": { \"package\": \"xpkg::archive\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Post\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }, { \"identity.primary\": { \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Tag\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }, { \"identity.primary\": { \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"PostTag\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"archivedPostId\" } }," + + " { \"field.long\": { \"name\": \"archivedTagId\" } }," + + " { \"identity.primary\": { \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"pRef\", \"@fields\": \"archivedPostId\", \"@references\": \"xpkg::archive::Post\" } }," + + " { \"identity.reference\": { \"name\": \"tRef\", \"@fields\": \"archivedTagId\", \"@references\": \"xpkg::archive::Tag\" } } ] } }" + + "] } }"; + + /** Real M:N: blog::Post --tags--> blog::Tag through blog::PostTag, FQN @through. */ + private static final String BLOG_PKG = + "{ \"metadata.root\": { \"package\": \"xpkg::blog\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Post\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"relationship.association\": { \"name\": \"tags\", \"@cardinality\": \"many\"," + + " \"@objectRef\": \"xpkg::blog::Tag\", \"@through\": \"xpkg::blog::PostTag\" } }," + + " { \"identity.primary\": { \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Tag\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }, { \"identity.primary\": { \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"PostTag\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"postId\" } }," + + " { \"field.long\": { \"name\": \"tagId\" } }," + + " { \"identity.primary\": { \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"postRef\", \"@fields\": \"postId\", \"@references\": \"xpkg::blog::Post\" } }," + + " { \"identity.reference\": { \"name\": \"tagRef\", \"@fields\": \"tagId\", \"@references\": \"xpkg::blog::Tag\" } } ] } }" + + "] } }"; + + @Test + public void deriveCrossPackageJunctionCollisionBindsCorrectPackage() { + // ADR-0041: the @through junction bare name "PostTag" collides with an unrelated + // xpkg::archive::PostTag (loaded FIRST, DIFFERENT FK field names). A bare-tail match + // binds the decoy and derives archivedPostId/archivedTagId; FQN-exact resolution + // binds xpkg::blog::PostTag and derives postId/tagId. + MetaDataLoader loader = loadTwo(ARCHIVE_PKG, "archive.json", BLOG_PKG, "blog.json"); + MetaObject blogPost = objExact(loader, "xpkg::blog::Post"); + M2MFields f = M2MFields.derive(relOf(blogPost, "tags"), blogPost, loader.getRoot()); + assertEquals("postId", f.getSourceField()); + assertEquals("tagId", f.getTargetField()); + } } diff --git a/server/java/omdb/src/main/java/com/metaobjects/manager/db/M2MResolver.java b/server/java/omdb/src/main/java/com/metaobjects/manager/db/M2MResolver.java index 92ec0e117..5fbe01c03 100644 --- a/server/java/omdb/src/main/java/com/metaobjects/manager/db/M2MResolver.java +++ b/server/java/omdb/src/main/java/com/metaobjects/manager/db/M2MResolver.java @@ -160,11 +160,17 @@ private static boolean keyEquals(Object a, Object b) { } private static MetaObject mustGetEntity(MetaRoot root, String name) { - String bare = stripPackage(name); + // ADR-0041: a FULLY-QUALIFIED ref (contains "::") resolves EXACTLY on the + // object's package-qualified name — never a bare-tail fallback (the closed + // bug: an FQN @objectRef / @through binding a same-named entity in the WRONG + // package). A bare ref matches the entity's short name. + boolean fqn = name != null && name.contains(com.metaobjects.MetaData.PKG_SEPARATOR); // ADR-0039: root-level entity scan — root is never extended, so own children // is correct (entities are declared directly under root, not inherited into it). for (var child : root.getChildren(MetaObject.class, false)) { - if (bare.equals(child.getShortName())) return child; + if (fqn ? name.equals(child.getName()) : stripPackage(name).equals(child.getShortName())) { + return child; + } } throw new IllegalStateException("Entity '" + name + "' not found in model root"); } diff --git a/spec/decisions/ADR-0041-cross-package-reference-resolution.md b/spec/decisions/ADR-0041-cross-package-reference-resolution.md index 0f21ac8fd..636890e5f 100644 --- a/spec/decisions/ADR-0041-cross-package-reference-resolution.md +++ b/spec/decisions/ADR-0041-cross-package-reference-resolution.md @@ -33,7 +33,7 @@ The "referrer's package" is the package of the node carrying the reference. This ## Consequences - **New error code `ERR_AMBIGUOUS_REF`**, registered cross-port (added to each port's error-code set and `fixtures/conformance/ERROR-CODES.json`). -- **Resolvers become referrer-package-aware.** Each port routes object-reference resolution through one shared helper (`resolveObjectRef(root, ref, referrerPkg)` in TS) so relationship/reference/origin/extends/template resolution behave identically; the referrer's package is threaded to each call site. Java's bare-tail-before-FQN ordering and the codegen bare-name-first-match helpers (Java `SpringM2mSupport`/`SpringPayloadGenerator`, Kotlin `KotlinGenUtil`) are corrected to this contract. +- **Resolvers become referrer-package-aware.** Each port routes object-reference resolution through one shared helper (`resolveObjectRef(root, ref, referrerPkg)` in TS) so relationship/reference/origin/extends/template resolution behave identically; the referrer's package is threaded to each call site. Java's bare-tail-before-FQN ordering (`SymbolTable.nameMatches`, `ValidationPhase.nameMatches`) and the Java codegen/runtime helpers that shared it (`SpringM2mSupport.findEntity`, `KotlinRenderHelperGenerator`, and the M:N FK-derivation SSOT `M2MFields` + runtime `M2MResolver`) are corrected to resolve FQNs exactly; `SpringPayloadGenerator`/`KotlinGenUtil` were already FQN-safe and unchanged. The remaining *bare*-collision same-package preference in codegen/runtime is a documented follow-up (#174). - **Conformance-gated.** New `fixtures/conformance/` fixtures cover every ref-bearing attribute cross-package (unique-name working cases) plus the collision contract: FQN-exact-across-collision (via `extends`, observable in effective serialization), bare same-package preference, and bare-ambiguous → `ERR_AMBIGUOUS_REF`. All five ports must serialize/err byte-identically. - **Breaking only for models that today rely on a bare reference silently binding to an arbitrarily-chosen same-named entity in another package** — previously undefined behavior, now an explicit `ERR_AMBIGUOUS_REF` that is fixed by qualifying the reference. No change for unique names or explicit FQNs.