From 48dc28e6aadb67dd1e2322da8006d1e9e814e3a0 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 14:27:22 -0400
Subject: [PATCH 01/31] docs(plan): association->reference disambiguation for
#368
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
...68-association-reference-disambiguation.md | 1337 +++++++++++++++++
1 file changed, 1337 insertions(+)
create mode 100644 docs/superpowers/plans/2026-09-13-issue-368-association-reference-disambiguation.md
diff --git a/docs/superpowers/plans/2026-09-13-issue-368-association-reference-disambiguation.md b/docs/superpowers/plans/2026-09-13-issue-368-association-reference-disambiguation.md
new file mode 100644
index 000000000..c5f182ccf
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-13-issue-368-association-reference-disambiguation.md
@@ -0,0 +1,1337 @@
+# Issue #368 — Association→Reference Disambiguation Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** When an entity declares two or more `identity.reference` nodes targeting the same entity, each `relationship.*` with `@cardinality: one` must resolve to its *own* reference — explicitly via `@sourceRefField`, implicitly by name pairing, or fail to load naming the candidates — instead of silently taking the first match.
+
+**Architecture:** One shared resolution helper in `@metaobjectsdev/metadata` implements a three-stage ladder (unique-candidate → `@sourceRefField` → name pairing). A new loader validation rule makes an unresolvable model a **load error** in all four ports, so `meta verify` catches it (ADR-0029 §5: "introducing a second path later is a load error naming the candidates"). The TypeScript consumers that currently do their own first-match lookup are re-pointed at the helper.
+
+**Tech Stack:** TypeScript (Bun test runner), Java (JUnit/Maven), C# (xUnit/dotnet), Python (pytest); cross-port fixtures in `fixtures/conformance/`.
+
+**Spec:** GitHub issue [#368](https://github.com/metaobjectsdev/metaobjects/issues/368) plus the design rulings recorded in "Design decisions" below.
+
+---
+
+## Global Constraints
+
+- **No new metamodel vocabulary.** `@sourceRefField` is already registered on all four `relationship.*` subtypes in `fixtures/registry-conformance/expected-registry.json`. The attr *set* must not change, so `metamodelVersion` stays `"1.0"` (currently frozen). Task 9 asserts this.
+- **ADR-0023 (strict provenance):** no invented attributes. Anything the loader accepts must already come from a registered provider.
+- **ADR-0029 §5:** ambiguity is a **load error naming the candidates**, not a codegen-time guess and not silent inference beyond the specified rule.
+- **ADR-0037 §65:** no same-name-different-meaning attrs. `@via` is reserved to `origin.*` and must NOT be used here.
+- **ADR-0039 (own-accessor discipline):** use *resolving* accessors (`attr()`, `children()`, `referenceIdentities()`) everywhere. The one exception is validation iterating `ownChildren()` to validate the declaring entity — matching the existing rule-(d) loop.
+- **Public repo hygiene:** no private project names, no absolute home paths in any committed file, including fixtures and commit messages.
+- **TDD:** every task writes a failing test first and runs it to confirm the failure before implementing.
+- **Error code:** `ERR_INVALID_RELATIONSHIP` (already defined at `server/typescript/packages/metadata/src/errors.ts:120`).
+
+---
+
+## Design decisions
+
+These were settled before planning; executors must not re-litigate them.
+
+**1. Why not derive it?** The FK *is* derived whenever exactly one `identity.reference` targets the relationship's `@objectRef` — that is today's behaviour and it stays. When two references target the same entity, `homeTeam` and `awayTeam` are *identical declarations* (`{@objectRef: "Team", @cardinality: "one"}`); the only distinguishing signal is the node's own name. Declaration-order pairing was rejected: it is the `views()[0]` failure of #356, and `meta fmt` (#304) would silently re-pair every join on a format run.
+
+**2. Why `@sourceRefField` and not a new attr?** It is already registered on `relationship.association`/`aggregation`/`composition`/`base`, and on a `@cardinality: one` relationship it is currently a hard error ("sets @sourceRefField but is not a M:N relationship", `validation-passes.ts:2011`). Giving it meaning there is **strictly additive** — input that never had a valid meaning gains one — which is the `docs/compatibility-policy.md` correction bar, not a breaking change.
+
+**3. The name-pairing rule (normative).** It is cross-port conformance surface, so it is specified exactly:
+
+> Let `A` be the relationship's name. For each candidate reference `R` with first FK field `F`, build the **pairing key set**:
+> `{ lower(R.name), strip(lower(R.name)), lower(F), strip(lower(F)) }`
+> where `strip(s)` removes **one** trailing suffix from the ordered list `["reference", "ref", "id", "key"]` (first match wins; returns `s` unchanged if none match, and never returns the empty string — if stripping would empty the string, `s` is returned unchanged).
+> `R` **name-matches** `A` iff `lower(A)` is in that set.
+> The rule resolves **only** when exactly one candidate name-matches. Zero or two-or-more → error.
+
+Suffixes are stripped from the *candidate* side only, never from `A`. This is deliberate: stripping `A` would let `valid` → `val` falsely pair with a `valRef`. Failing closed is correct — the rule may only ever *select*, never guess.
+
+**4. Where the error surfaces.** Load/validation time in all four ports, so `meta verify` reports it. The issue's core complaint was that `meta verify` was clean while the output was wrong.
+
+**5. Ports.** *Validation* is cross-port (TS/Java/C#/Python — Kotlin uses the Java metadata layer). *Resolution* is TypeScript-only, because only TS emits a forward `one()` navigation from the association node; Java/C#/Python enumerate references individually for their ADR-0038 finders. Task 8 verifies that claim rather than assuming it.
+
+---
+
+## File Structure
+
+**Created:**
+- `server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts` — the resolution ladder + candidate enumeration. Single responsibility: given an entity and a 1:N relationship, say which `identity.reference` it navigates through.
+- `server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts` — unit tests for the ladder and the pairing rule.
+- `fixtures/conformance/relationship-one-two-refs-sourcerefield/` — positive fixture, explicit disambiguation.
+- `fixtures/conformance/relationship-one-two-refs-name-pairing/` — positive fixture, implicit pairing.
+- `fixtures/conformance/error-relationship-one-refs-ambiguous/` — error envelope.
+
+**Modified:**
+- `server/typescript/packages/metadata/src/index.ts:117` — export the new helper.
+- `server/typescript/packages/metadata/src/loader/validation-passes.ts:1994-2024` — widen rule (d); add rule (e).
+- `server/typescript/packages/codegen-ts/src/relation-resolver.ts:95-101` — use the helper.
+- `server/typescript/packages/runtime-ts/src/relation-resolver.ts:32-50` — use the helper.
+- `server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts:760,1093` — ambiguity-aware lookup.
+- `server/typescript/packages/metadata/src/core/relationship/find-reference.ts` — add plural `findReferencesBetween`.
+- `server/typescript/packages/docs-site/src/link-graph.ts:105` — render all edges, not the first.
+- `server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java:1741-1765`
+- `server/csharp/MetaObjects/Loader/ValidationPasses.cs:3141-3160`
+- `server/python/src/metaobjects/loader/validation_passes.py:2650-2670`
+- `CHANGELOG.md`, `spec/decisions/ADR-0029-…md` (amendment note)
+
+---
+
+## Task 1: The resolution helper
+
+**Files:**
+- Create: `server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts`
+- Test: `server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts`
+- Modify: `server/typescript/packages/metadata/src/index.ts` (add exports after line 118)
+
+**Interfaces:**
+- Consumes: `MetaObject.referenceIdentities()`, `MetaReferenceIdentity.{name, fields, targetEntity}`, `stripPackage` from `../../naming.js`.
+- Produces:
+ - `referenceCandidatesFor(holder: MetaObject, targetEntity: string): MetaReferenceIdentity[]`
+ - `resolveRelationshipReference(holder: MetaObject, relationshipName: string, targetEntity: string, sourceRefField?: string): MetaReferenceIdentity | undefined`
+ - `referencePairingKeys(ref: MetaReferenceIdentity): Set`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts
+import { describe, expect, test } from "bun:test";
+import { MetaDataLoader } from "../src/loader/loader.js";
+import {
+ referenceCandidatesFor,
+ resolveRelationshipReference,
+} from "../src/core/relationship/resolve-relationship-reference.js";
+import type { MetaObject } from "../src/core/object/meta-object.js";
+
+const MATCH_MODEL = {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ {
+ "object.entity": {
+ name: "Team",
+ children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ],
+ },
+ },
+ {
+ "object.entity": {
+ name: "Match",
+ children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId" } },
+ { "field.int": { name: "awayTeamId" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ ],
+ },
+ },
+ ],
+ },
+};
+
+function loadMatch(): MetaObject {
+ const root = new MetaDataLoader().loadFromObject(MATCH_MODEL, "meta.repro.json");
+ return root.findObject("Match")! as MetaObject;
+}
+
+describe("resolveRelationshipReference", () => {
+ test("enumerates every candidate reference for the target", () => {
+ const candidates = referenceCandidatesFor(loadMatch(), "Team");
+ expect(candidates.map((r) => r.name)).toEqual(["homeTeamRef", "awayTeamRef"]);
+ });
+
+ test("name pairing resolves each association to its own reference", () => {
+ const match = loadMatch();
+ expect(resolveRelationshipReference(match, "homeTeam", "Team")?.name).toBe("homeTeamRef");
+ expect(resolveRelationshipReference(match, "awayTeam", "Team")?.name).toBe("awayTeamRef");
+ });
+
+ test("@sourceRefField wins over name pairing", () => {
+ const match = loadMatch();
+ expect(
+ resolveRelationshipReference(match, "homeTeam", "Team", "awayTeamId")?.name,
+ ).toBe("awayTeamRef");
+ });
+
+ test("a single candidate resolves regardless of name", () => {
+ const root = new MetaDataLoader().loadFromObject(
+ {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "winnerFk" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "anythingAtAll", "@fields": ["winnerFk"], "@references": "Team" } },
+ { "relationship.association": { name: "champion", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ ],
+ },
+ },
+ "meta.repro.json",
+ );
+ const match = root.findObject("Match")! as MetaObject;
+ expect(resolveRelationshipReference(match, "champion", "Team")?.name).toBe("anythingAtAll");
+ });
+
+ test("unpairable names return undefined rather than guessing", () => {
+ const root = new MetaDataLoader().loadFromObject(
+ {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "alphaFk" } },
+ { "field.int": { name: "betaFk" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } },
+ { "identity.reference": { name: "betaRef", "@fields": ["betaFk"], "@references": "Team" } },
+ { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ ],
+ },
+ },
+ "meta.repro.json",
+ );
+ const match = root.findObject("Match")! as MetaObject;
+ expect(resolveRelationshipReference(match, "winner", "Team")).toBeUndefined();
+ });
+
+ test("suffix stripping never applies to the relationship name", () => {
+ // "valid" must NOT be stripped to "val" and pair with valRef.
+ const root = new MetaDataLoader().loadFromObject(
+ {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "valFk" } },
+ { "field.int": { name: "otherFk" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "valRef", "@fields": ["valFk"], "@references": "Team" } },
+ { "identity.reference": { name: "otherRef", "@fields": ["otherFk"], "@references": "Team" } },
+ { "relationship.association": { name: "valid", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ ],
+ },
+ },
+ "meta.repro.json",
+ );
+ const match = root.findObject("Match")! as MetaObject;
+ expect(resolveRelationshipReference(match, "valid", "Team")).toBeUndefined();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd server/typescript/packages/metadata && bun test test/resolve-relationship-reference.test.ts`
+Expected: FAIL — `Cannot find module '../src/core/relationship/resolve-relationship-reference.js'`
+
+> If `loadFromObject` is not the loader's in-memory entry point, find the one the neighbouring tests in `server/typescript/packages/metadata/test/` use (grep for `new MetaDataLoader()`), and use that in every test in this plan. Do not invent a loader API.
+
+- [ ] **Step 3: Write the implementation**
+
+```ts
+// server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts
+// Association -> identity.reference resolution (issue #368).
+//
+// An entity may declare more than one identity.reference onto the SAME target
+// entity (Match.homeTeamRef and Match.awayTeamRef both -> Team). A
+// `@cardinality: one` relationship names only its target, so when two
+// references match, the target alone cannot say which FK the navigation uses.
+// Taking the first match emits a join on the wrong column that typechecks, has
+// correct DDL and passes verify — so the ladder below resolves it explicitly or
+// not at all. ADR-0029 §5: ambiguity is a load error naming the candidates.
+
+import type { MetaObject } from "../object/meta-object.js";
+import type { MetaReferenceIdentity } from "../identity/meta-identity.js";
+import { stripPackage } from "../../naming.js";
+
+/**
+ * Trailing suffixes stripped from a CANDIDATE's name/FK field when building its
+ * pairing keys. Ordered — first match wins, so "reference" is tested before
+ * "ref". Never applied to the relationship name (see referencePairingKeys).
+ */
+const PAIRING_SUFFIXES = ["reference", "ref", "id", "key"] as const;
+
+function stripOneSuffix(value: string): string {
+ for (const suffix of PAIRING_SUFFIXES) {
+ if (value.length > suffix.length && value.endsWith(suffix)) {
+ return value.slice(0, value.length - suffix.length);
+ }
+ }
+ return value;
+}
+
+/** The FK field a reference is anchored on (first field; composite FKs pair on their first column). */
+function refFkField(ref: MetaReferenceIdentity): string | undefined {
+ return ref.fields.length > 0 ? ref.fields[0] : undefined;
+}
+
+/**
+ * The set of lowercased names a candidate reference answers to: its own name
+ * and its FK field, each with and without one stripped suffix.
+ */
+export function referencePairingKeys(ref: MetaReferenceIdentity): Set {
+ const keys = new Set();
+ const add = (value: string | undefined): void => {
+ if (!value) return;
+ const lower = value.toLowerCase();
+ keys.add(lower);
+ keys.add(stripOneSuffix(lower));
+ };
+ add(ref.name);
+ add(refFkField(ref));
+ return keys;
+}
+
+/**
+ * Every identity.reference on `holder` whose @references targets `targetEntity`.
+ * Package-insensitive on both sides: @references and @objectRef may each be bare
+ * or fully qualified.
+ */
+export function referenceCandidatesFor(
+ holder: MetaObject,
+ targetEntity: string,
+): MetaReferenceIdentity[] {
+ const target = stripPackage(targetEntity);
+ // ADR-0039: resolving — referenceIdentities() honors references inherited via extends.
+ return holder
+ .referenceIdentities()
+ .filter((ref) => stripPackage(ref.targetEntity ?? "") === target)
+ .filter((ref) => refFkField(ref) !== undefined);
+}
+
+/**
+ * Which identity.reference does this `@cardinality: one` relationship navigate
+ * through? The ladder, in order:
+ *
+ * 1. exactly one candidate -> that one (the common case; unchanged behaviour)
+ * 2. `@sourceRefField` declared -> the candidate whose FK field it names
+ * 3. exactly one candidate name-pairs -> that one
+ * 4. otherwise -> undefined (caller reports the ambiguity)
+ *
+ * Returns undefined for "no candidate" and "cannot choose" alike; callers that
+ * need to tell them apart use referenceCandidatesFor().
+ */
+export function resolveRelationshipReference(
+ holder: MetaObject,
+ relationshipName: string,
+ targetEntity: string,
+ sourceRefField?: string,
+): MetaReferenceIdentity | undefined {
+ const candidates = referenceCandidatesFor(holder, targetEntity);
+ if (candidates.length === 0) return undefined;
+ if (candidates.length === 1) return candidates[0];
+
+ if (sourceRefField !== undefined && sourceRefField !== "") {
+ return candidates.find((ref) => refFkField(ref) === sourceRefField);
+ }
+
+ const wanted = relationshipName.toLowerCase();
+ const paired = candidates.filter((ref) => referencePairingKeys(ref).has(wanted));
+ return paired.length === 1 ? paired[0] : undefined;
+}
+```
+
+- [ ] **Step 4: Export from the package barrel**
+
+In `server/typescript/packages/metadata/src/index.ts`, immediately after line 118 (`export type { ReferenceLookup } …`):
+
+```ts
+export {
+ referenceCandidatesFor,
+ referencePairingKeys,
+ resolveRelationshipReference,
+} from "./core/relationship/resolve-relationship-reference.js";
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `cd server/typescript/packages/metadata && bun test test/resolve-relationship-reference.test.ts`
+Expected: PASS — 6 tests
+
+- [ ] **Step 6: Typecheck**
+
+Run: `cd server/typescript/packages/metadata && bun run build && bun run typecheck`
+Expected: exit 0
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts \
+ server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts \
+ server/typescript/packages/metadata/src/index.ts
+git commit -m "feat(metadata): association->reference resolution ladder (#368)"
+```
+
+---
+
+## Task 2: Widen rule (d) — `@sourceRefField` legal on `@cardinality: one`
+
+**Files:**
+- Modify: `server/typescript/packages/metadata/src/loader/validation-passes.ts:2010-2016`
+- Test: `server/typescript/packages/metadata/test/relationship-m2m-validation.test.ts` (append; if that file does not exist, grep `test/` for the file asserting "is not a M:N relationship" and append there)
+
+**Interfaces:**
+- Consumes: `RELATIONSHIP_ATTR_SOURCE_REF_FIELD`, `CARDINALITY_ONE` (already imported in this file).
+- Produces: no new exports. Behavioural change only: `@sourceRefField` + `@cardinality: one` loads clean; `@sourceRefField` with any other non-M:N cardinality still errors.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+test("@sourceRefField is legal on a cardinality:one relationship (#368)", () => {
+ const result = loadExpectingErrors({
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId" } },
+ { "field.int": { name: "awayTeamId" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } },
+ ] } },
+ ],
+ },
+ });
+ expect(result.errors).toEqual([]);
+});
+
+test("@sourceRefField on a non-M:N, non-one relationship still errors", () => {
+ const result = loadExpectingErrors({
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "relationship.association": { name: "teams", "@objectRef": "Team", "@cardinality": "many", "@sourceRefField": "whatever" } },
+ ] } },
+ ],
+ },
+ });
+ expect(result.errors.map((e) => e.code)).toContain("ERR_INVALID_RELATIONSHIP");
+});
+```
+
+> Match the surrounding file's loader helper (grep it for `loadExpectingErrors` or equivalent) rather than introducing a new one.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd server/typescript/packages/metadata && bun test -t "sourceRefField is legal"`
+Expected: FAIL — one `ERR_INVALID_RELATIONSHIP` ("sets @sourceRefField but is not a M:N relationship") per association
+
+- [ ] **Step 3: Implement**
+
+In the `if (!isM2M) { … }` block, replace the `if (hasSourceRefField) { … }` clause with:
+
+```ts
+ // #368: @sourceRefField also disambiguates a `@cardinality: one`
+ // relationship when the entity holds more than one identity.reference
+ // onto the same target. Only the M:N *junction* reading is rejected
+ // here; rule (e) below checks that it names a real local reference.
+ if (hasSourceRefField && cardinality !== CARDINALITY_ONE) {
+ errors.push(
+ new ParseError(
+ `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.`,
+ { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
+ ),
+ );
+ }
+```
+
+Then change the `continue;` that closes the `!isM2M` block so a `cardinality: one` relationship falls through to rule (e) in Task 3 rather than skipping validation. The simplest shape that preserves existing behaviour: keep `continue;` for now and add rule (e) as its own loop in Task 3.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd server/typescript/packages/metadata && bun test test/relationship-m2m-validation.test.ts`
+Expected: PASS, including every pre-existing test in the file
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/typescript/packages/metadata/src/loader/validation-passes.ts \
+ server/typescript/packages/metadata/test/relationship-m2m-validation.test.ts
+git commit -m "feat(loader): @sourceRefField is legal on cardinality:one (#368)"
+```
+
+---
+
+## Task 3: Rule (e) — unresolvable 1:N reference is a load error (TypeScript)
+
+**Files:**
+- Modify: `server/typescript/packages/metadata/src/loader/validation-passes.ts` (new pass, placed immediately after the M:N slim-vocabulary pass)
+- Test: same file as Task 2
+
+**Interfaces:**
+- Consumes: `referenceCandidatesFor`, `resolveRelationshipReference` from Task 1.
+- Produces: `ERR_INVALID_RELATIONSHIP` whose message names the entity, the relationship, and every candidate as `name(fkField)`.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+test("two references to one target with an unpairable relationship name is a load error (#368)", () => {
+ const result = loadExpectingErrors({
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "alphaFk" } },
+ { "field.int": { name: "betaFk" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } },
+ { "identity.reference": { name: "betaRef", "@fields": ["betaFk"], "@references": "Team" } },
+ { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ ],
+ },
+ });
+ expect(result.errors.map((e) => e.code)).toContain("ERR_INVALID_RELATIONSHIP");
+ const message = result.errors.map((e) => e.message).join("\n");
+ expect(message).toContain("Match.winner");
+ expect(message).toContain("alphaRef(alphaFk)");
+ expect(message).toContain("betaRef(betaFk)");
+});
+
+test("the issue #368 repro loads clean via name pairing", () => {
+ const result = loadExpectingErrors({
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId" } },
+ { "field.int": { name: "awayTeamId" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ ],
+ },
+ });
+ expect(result.errors).toEqual([]);
+});
+
+test("@sourceRefField naming no local reference is a load error", () => {
+ const result = loadExpectingErrors({
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId" } },
+ { "field.int": { name: "awayTeamId" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "nonesuch" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } },
+ ] } },
+ ],
+ },
+ });
+ expect(result.errors.map((e) => e.code)).toContain("ERR_INVALID_RELATIONSHIP");
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd server/typescript/packages/metadata && bun test -t "#368"`
+Expected: FAIL — `result.errors` is empty for the ambiguous cases (that is the bug)
+
+- [ ] **Step 3: Implement rule (e)**
+
+Add this pass alongside the M:N pass in `validation-passes.ts`, and register it wherever the M:N pass is registered (grep for the function name of the pass containing rule (d) and mirror its registration):
+
+```ts
+// Rule (e) — #368: a `@cardinality: one` relationship must resolve to exactly one
+// identity.reference. Two references onto the same target are indistinguishable
+// from the relationship's @objectRef alone, so the resolver would silently emit
+// the first one's FK column. ADR-0029 §5: a second path is a load error naming
+// the candidates.
+function validateOneSideReferenceResolution(root: MetaRoot): ParseError[] {
+ const errors: ParseError[] = [];
+ for (const obj of root.objects()) {
+ // ADR-0039: own — a relationship is validated on the entity that DECLARES it.
+ for (const rel of obj.ownChildren().filter((c) => c.type === TYPE_RELATIONSHIP)) {
+ // ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends.
+ if (rel.attr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_ONE) continue;
+ const objectRef = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF);
+ if (typeof objectRef !== "string" || objectRef === "") continue;
+
+ const candidates = referenceCandidatesFor(obj as MetaObject, objectRef);
+ if (candidates.length <= 1) continue;
+
+ const sourceRefField = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD);
+ const declared = typeof sourceRefField === "string" && sourceRefField !== ""
+ ? sourceRefField
+ : undefined;
+ const resolved = resolveRelationshipReference(
+ obj as MetaObject, rel.name, objectRef, declared,
+ );
+ if (resolved) continue;
+
+ const listed = candidates
+ .map((c) => `${c.name}(${c.fields[0]})`)
+ .join(", ");
+ errors.push(
+ new ParseError(
+ declared !== undefined
+ ? `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} ` +
+ `"${declared}", which names no identity.reference targeting "${objectRef}". ` +
+ `Candidates: ${listed}.`
+ : `relationship "${obj.name}.${rel.name}" is ambiguous: "${obj.name}" declares ` +
+ `${candidates.length} identity.reference nodes targeting "${objectRef}" and the ` +
+ `relationship name does not pair with exactly one. Candidates: ${listed}. ` +
+ `Set @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} to the FK field this relationship navigates.`,
+ { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
+ ),
+ );
+ }
+ }
+ return errors;
+}
+```
+
+Add the imports at the top of `validation-passes.ts`:
+
+```ts
+import {
+ referenceCandidatesFor,
+ resolveRelationshipReference,
+} from "../core/relationship/resolve-relationship-reference.js";
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd server/typescript/packages/metadata && bun test`
+Expected: PASS — the whole metadata suite, not just the new tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/typescript/packages/metadata/src/loader/validation-passes.ts \
+ server/typescript/packages/metadata/test/relationship-m2m-validation.test.ts
+git commit -m "feat(loader): ambiguous 1:N reference is a load error (#368)"
+```
+
+---
+
+## Task 4: codegen-ts relation resolver — the reported defect
+
+**Files:**
+- Modify: `server/typescript/packages/codegen-ts/src/relation-resolver.ts:95-101`
+- Test: `server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts` (create)
+
+**Interfaces:**
+- Consumes: `resolveRelationshipReference` from Task 1 (via `@metaobjectsdev/metadata`).
+- Produces: `RelationEntry.fkField` now the relationship's *own* FK.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts
+import { describe, expect, test } from "bun:test";
+import { MetaDataLoader } from "@metaobjectsdev/metadata";
+import { buildRelationMap } from "../src/relation-resolver.js";
+
+describe("buildRelationMap with two references onto one target (#368)", () => {
+ test("each association joins on its own FK column", () => {
+ const root = new MetaDataLoader().loadFromObject(
+ {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "source.rdb": { "@table": "team" } },
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "source.rdb": { "@table": "match" } },
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId", "@column": "home_team_id" } },
+ { "field.int": { name: "awayTeamId", "@column": "away_team_id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ ],
+ },
+ },
+ "meta.repro.json",
+ );
+
+ const relations = buildRelationMap(root).get("Match")!;
+ const byName = Object.fromEntries(relations.map((r) => [r.name, r.fkField]));
+ expect(byName.homeTeam).toBe("homeTeamId");
+ expect(byName.awayTeam).toBe("awayTeamId"); // was "homeTeamId" — the #368 defect
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd server/typescript/packages/codegen-ts && bun test test/relation-resolver-two-refs.test.ts`
+Expected: FAIL — `expect(byName.awayTeam).toBe("awayTeamId")` receives `"homeTeamId"`
+
+- [ ] **Step 3: Implement**
+
+Replace lines 95-101 of `relation-resolver.ts`:
+
+```ts
+ // #368: an entity may hold more than one identity.reference onto the same
+ // target, so the target alone does not identify the FK. Resolve through the
+ // shared ladder (unique candidate -> @sourceRefField -> name pairing); the
+ // loader has already refused anything it cannot resolve, so a miss here
+ // means an unloadable model reached codegen — skip rather than guess.
+ // ADR-0039: resolving — @sourceRefField may be inherited via extends.
+ const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
+ const matching = resolveRelationshipReference(
+ obj, child.name, targetEntity, declaredRefField,
+ );
+ if (!matching) continue;
+
+ const fkField = matching.fields[0];
+ if (!fkField) continue;
+```
+
+Add to the existing import block from `@metaobjectsdev/metadata`:
+
+```ts
+ RELATIONSHIP_ATTR_SOURCE_REF_FIELD,
+ resolveRelationshipReference,
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd server/typescript/packages/codegen-ts && bun test`
+Expected: PASS — full codegen-ts suite
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/typescript/packages/codegen-ts/src/relation-resolver.ts \
+ server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts
+git commit -m "fix(codegen-ts): relations() joined every association on the first FK (#368)"
+```
+
+---
+
+## Task 5: runtime-ts relation resolver
+
+**Files:**
+- Modify: `server/typescript/packages/runtime-ts/src/relation-resolver.ts:32-50,80`
+- Test: `server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts` (create)
+
+**Interfaces:**
+- Consumes: `resolveRelationshipReference` from Task 1.
+- Produces: `RelationDescriptor.sourceField` now the relationship's own FK.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts
+import { describe, expect, test } from "bun:test";
+import { MetaDataLoader } from "@metaobjectsdev/metadata";
+import { resolveRelationDescriptor } from "../src/relation-resolver.js";
+
+const MODEL = {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "source.rdb": { "@table": "team" } },
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "source.rdb": { "@table": "match" } },
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId" } },
+ { "field.int": { name: "awayTeamId" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ ],
+ },
+};
+
+describe("resolveRelationDescriptor with two references onto one target (#368)", () => {
+ test("each relation reads its own FK field", () => {
+ const root = new MetaDataLoader().loadFromObject(MODEL, "meta.repro.json");
+ const match = root.findObject("Match")!;
+ expect(resolveRelationDescriptor(match, "homeTeam", root).sourceField).toBe("homeTeamId");
+ expect(resolveRelationDescriptor(match, "awayTeam", root).sourceField).toBe("awayTeamId");
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd server/typescript/packages/runtime-ts && bun test test/relation-resolver-two-refs.test.ts`
+Expected: FAIL — `awayTeam` resolves `sourceField` to `"homeTeamId"`
+
+- [ ] **Step 3: Implement**
+
+Replace the body of `findReferenceFkField` (lines 32-50) so it delegates, and pass the relationship through. Change its signature and both call sites:
+
+```ts
+/**
+ * The FK field a `@cardinality: one` relationship navigates through.
+ * #368: an entity may declare several identity.reference nodes onto the same
+ * target, so the target alone is not enough — resolve through the shared ladder.
+ */
+function findReferenceFkField(
+ holder: MetaData,
+ targetName: string,
+ relationshipName: string,
+ sourceRefField?: string,
+): string | undefined {
+ const ref = resolveRelationshipReference(
+ holder as unknown as MetaObject, relationshipName, targetName, sourceRefField,
+ );
+ return ref?.fields[0];
+}
+```
+
+At the one-side call site (line 80):
+
+```ts
+ // ADR-0039: resolving — @sourceRefField may be inherited via extends.
+ const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
+ const fkField = findReferenceFkField(sourceEntity, targetEntityName, child.name, declaredRefField);
+```
+
+At the many-side call site (line 117), the FK lives on the *other* entity and belongs to that entity's relationship `child`:
+
+```ts
+ const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
+ const fkField = findReferenceFkField(other, sourceEntity.name, child.name, declaredRefField);
+```
+
+Add `MetaObject`, `RELATIONSHIP_ATTR_SOURCE_REF_FIELD` and `resolveRelationshipReference` to the `@metaobjectsdev/metadata` import block.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd server/typescript/packages/runtime-ts && bun test`
+Expected: PASS — full runtime-ts suite
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/typescript/packages/runtime-ts/src/relation-resolver.ts \
+ server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts
+git commit -m "fix(runtime-ts): relation traversal read the first FK for every relation (#368)"
+```
+
+---
+
+## Task 6: `findReferenceBetween` ambiguity + docs link graph
+
+**Files:**
+- Modify: `server/typescript/packages/metadata/src/core/relationship/find-reference.ts`
+- Modify: `server/typescript/packages/metadata/src/index.ts` (export the plural)
+- Modify: `server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts:760,1093`
+- Modify: `server/typescript/packages/docs-site/src/link-graph.ts:105`
+- Test: `server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts` (create)
+
+**Interfaces:**
+- Produces: `findReferencesBetween(a: MetaObject, b: MetaObject): ReferenceLookup[]` — every match, `a` walked first. `findReferenceBetween` keeps its exact signature and first-match behaviour for compatibility.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts
+import { describe, expect, test } from "bun:test";
+import { MetaDataLoader } from "../src/loader/loader.js";
+import { findReferenceBetween, findReferencesBetween } from "../src/core/relationship/find-reference.js";
+import type { MetaObject } from "../src/core/object/meta-object.js";
+
+describe("findReferencesBetween (#368)", () => {
+ test("returns every reference, not just the first", () => {
+ const root = new MetaDataLoader().loadFromObject(
+ {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId" } },
+ { "field.int": { name: "awayTeamId" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ ] } },
+ ],
+ },
+ },
+ "meta.repro.json",
+ );
+ const match = root.findObject("Match")! as MetaObject;
+ const team = root.findObject("Team")! as MetaObject;
+
+ const all = findReferencesBetween(match, team);
+ expect(all.map((r) => r.referenceIdentity.name)).toEqual(["homeTeamRef", "awayTeamRef"]);
+
+ // Back-compat: the singular still answers with the first.
+ expect(findReferenceBetween(match, team)?.referenceIdentity.name).toBe("homeTeamRef");
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd server/typescript/packages/metadata && bun test test/find-reference-ambiguity.test.ts`
+Expected: FAIL — `findReferencesBetween` is not exported
+
+- [ ] **Step 3: Implement**
+
+In `find-reference.ts`, add above `findReferenceBetween`:
+
+```ts
+/**
+ * Every identity.reference on `a` or `b` targeting the other side, `a` walked
+ * first. #368: two references onto the same entity are legal, so callers that
+ * must not guess enumerate here and report ambiguity themselves.
+ */
+export function findReferencesBetween(
+ a: MetaObject,
+ b: MetaObject,
+): ReferenceLookup[] {
+ const found: ReferenceLookup[] = [];
+ for (const [holder, other] of [[a, b], [b, a]] as const) {
+ for (const ref of holder.referenceIdentities()) {
+ if (stripPackage(ref.targetEntity) === other.name) {
+ found.push({ holder, other, referenceIdentity: ref });
+ }
+ }
+ }
+ return found;
+}
+```
+
+and rewrite `findReferenceBetween` to delegate, keeping its documented first-match contract:
+
+```ts
+export function findReferenceBetween(
+ a: MetaObject,
+ b: MetaObject,
+): ReferenceLookup | undefined {
+ return findReferencesBetween(a, b)[0];
+}
+```
+
+Export `findReferencesBetween` from `src/index.ts` beside the existing `findReferenceBetween` export (line 117).
+
+At `extract-view-spec.ts:760` and `:1093`, replace each `findReferenceBetween(...)` call with the plural form and refuse ambiguity instead of silently taking `[0]`:
+
+```ts
+ const refs = findReferencesBetween(currentObj as MetaObject, target);
+ if (refs.length > 1) {
+ throw new Error(
+ `projection join from "${(currentObj as MetaObject).name}" to "${target.name}" is ambiguous: ` +
+ `${refs.map((r) => r.referenceIdentity.name).join(", ")}. ` +
+ `Declare the hop explicitly with @via.`,
+ );
+ }
+ const ref = refs[0];
+```
+
+At `link-graph.ts:105`, replace the `.find(...)` with a filter so the docs graph draws every edge:
+
+```ts
+ const matches = obj.referenceIdentities().filter((r) => stripPackage(r.targetEntity ?? "") === target);
+```
+
+then emit one edge per match (adapt the surrounding loop; the existing single-`match` body becomes the loop body).
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd server/typescript && bun run --filter '*' build && bun test`
+Expected: PASS across packages
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/typescript/packages/metadata/src/core/relationship/find-reference.ts \
+ server/typescript/packages/metadata/src/index.ts \
+ server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts \
+ server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts \
+ server/typescript/packages/docs-site/src/link-graph.ts
+git commit -m "fix(metadata,codegen-ts,docs): enumerate references instead of taking the first (#368)"
+```
+
+---
+
+## Task 7: Cross-port validation parity (Java, C#, Python)
+
+Each port gets the *same two changes* as Tasks 2+3: widen the `@sourceRefField` rejection to spare `@cardinality: one`, and add rule (e). Port the ladder from Task 1 verbatim — same suffix list, same order, same "candidate side only" stripping.
+
+**Files:**
+- Modify: `server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java:1741-1765`
+- Create: `server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java`
+- Modify: `server/csharp/MetaObjects/Loader/ValidationPasses.cs:3141-3160`
+- Create: `server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs`
+- Modify: `server/python/src/metaobjects/loader/validation_passes.py:2650-2670`
+- Create: `server/python/src/metaobjects/meta/core/relationship/relationship_references.py`
+- Test: one test per port, mirroring Task 3's three cases (ambiguous → error; repro → clean; bad `@sourceRefField` → error). Place them beside each port's existing M:N validation tests — Java `server/java/metadata/src/test/java/com/metaobjects/relationship/M2MSlimVocabularyTest.java` is the model.
+
+**Interfaces:**
+- Produces, in each port, the equivalents of `referenceCandidatesFor` and `resolveRelationshipReference` with identical semantics. Message text must match the TS wording so the conformance error envelopes agree on `code` (envelopes assert `code` + `source`, not message text — but keep them aligned anyway).
+
+- [ ] **Step 1: Write the failing test in each port**
+
+Mirror Task 3's three test cases. Use each port's existing loader-error test helper; do not introduce a new one.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+```bash
+cd server/java && mvn -q -pl metadata test -Dtest=M2MSlimVocabularyTest
+cd server/csharp && dotnet test --filter FullyQualifiedName~Relationship
+cd server/python && python -m pytest tests/ -k "relationship and (ambiguous or sourceref)" -v
+```
+Expected: FAIL — no error is raised for the ambiguous model
+
+- [ ] **Step 3: Implement the ladder + rule (e) in each port**
+
+Port `resolve-relationship-reference.ts` (Task 1, Step 3) to each language, keeping `PAIRING_SUFFIXES = ["reference", "ref", "id", "key"]` in that order and stripping only on the candidate side. Then apply the Task 2 widening and add the Task 3 pass.
+
+Python note (ADR-0039 naming inversion): Python's `attr()` is OWN — use `get_meta_attr()` for resolving reads, matching the surrounding code in `validation_passes.py`.
+
+- [ ] **Step 4: Run the per-port suites to verify they pass**
+
+```bash
+cd server/java && mvn -q -pl metadata test
+cd server/csharp && dotnet test
+cd server/python && python -m pytest tests/ -q
+```
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/java server/csharp server/python
+git commit -m "feat(java,csharp,python): 1:N reference resolution parity (#368)"
+```
+
+---
+
+## Task 8: Verify no other port resolves forward 1:N by target alone
+
+**Files:** none modified unless the audit finds a defect.
+
+- [ ] **Step 1: Audit each port's forward-relation emit**
+
+```bash
+cd
+grep -rn "referenceIdentities\|reference_identities\|ReferenceIdentities" \
+ --include=*.java --include=*.cs --include=*.py server/ | grep -v test
+```
+
+For every hit that resolves a FK from a *relationship* (as opposed to enumerating references for ADR-0038 finders), check whether it filters by target only and takes the first. `server/python/src/metaobjects/codegen/generators/router_generator.py` (`reverse_fks_for`) and `server/java/.../M2MFields.java` were already reviewed during planning and are **correct** — they enumerate each reference individually.
+
+- [ ] **Step 2: Record the finding**
+
+If every port is clean, add one line to the PR description saying so. If a port is not clean, fix it with the same ladder and a test, in its own commit.
+
+- [ ] **Step 3: Commit (only if a fix was needed)**
+
+```bash
+git commit -m "fix(): forward 1:N reference resolution (#368)"
+```
+
+---
+
+## Task 9: Conformance fixtures + registry invariant
+
+**Files:**
+- Create: `fixtures/conformance/relationship-one-two-refs-sourcerefield/{input/meta.sport.json,expected.json}`
+- Create: `fixtures/conformance/relationship-one-two-refs-name-pairing/{input/meta.sport.json,expected.json}`
+- Create: `fixtures/conformance/error-relationship-one-refs-ambiguous/{input/meta.sport.json,expected-errors.json}`
+
+**Interfaces:**
+- Consumes: the fixture format at `fixtures/conformance/error-relationship-symmetric-and-sourceref/` (error) and `fixtures/conformance/relationship-m2m-hetero/` (positive).
+
+- [ ] **Step 1: Write the error fixture**
+
+`fixtures/conformance/error-relationship-one-refs-ambiguous/input/meta.sport.json`:
+
+```json
+{
+ "metadata.root": {
+ "package": "acme::sport",
+ "children": [
+ {
+ "object.entity": {
+ "name": "Team",
+ "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]
+ }
+ },
+ {
+ "object.entity": {
+ "name": "Match",
+ "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "alphaFk" } },
+ { "field.long": { "name": "betaFk" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "alphaRef", "@fields": "alphaFk", "@references": "acme::sport::Team" } },
+ { "identity.reference": { "name": "betaRef", "@fields": "betaFk", "@references": "acme::sport::Team" } },
+ { "relationship.association": { "name": "winner", "@cardinality": "one", "@objectRef": "acme::sport::Team" } }
+ ]
+ }
+ }
+ ]
+ }
+}
+```
+
+`expected-errors.json`:
+
+```json
+{
+ "errors": [
+ {
+ "code": "ERR_INVALID_RELATIONSHIP",
+ "source": {
+ "format": "json",
+ "files": ["meta.sport.json"],
+ "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[6]['relationship.association']"
+ }
+ }
+ ],
+ "warnings": []
+}
+```
+
+- [ ] **Step 2: Write the two positive fixtures**
+
+Same two entities. For `relationship-one-two-refs-sourcerefield`, `Match` declares both references plus two associations carrying `"@sourceRefField": "alphaFk"` and `"@sourceRefField": "betaFk"`. For `relationship-one-two-refs-name-pairing`, use the issue's `homeTeamId`/`awayTeamId` + `homeTeamRef`/`awayTeamRef` + `homeTeam`/`awayTeam` shape with no `@sourceRefField`. Generate each `expected.json` the way the other positive fixtures do — run the TS conformance runner in update mode if one exists (`grep -rn "update\|--write" server/typescript/packages/metadata/test/conformance*`), otherwise hand-write it to match the canonical serializer output of a neighbouring fixture.
+
+- [ ] **Step 3: Run the conformance corpus in every port**
+
+```bash
+cd server/typescript && bun test -t conformance
+cd server/java && mvn -q -pl metadata test -Dtest=*Conformance*
+cd server/csharp && dotnet test --filter FullyQualifiedName~Conformance
+cd server/python && python -m pytest tests/conformance -q
+```
+Expected: PASS in all four
+
+- [ ] **Step 4: Assert the registry did NOT change**
+
+```bash
+git diff --exit-code fixtures/registry-conformance/expected-registry.json
+```
+Expected: exit 0, no output. **If this prints a diff, stop** — the change has grown a vocabulary cost it was designed to avoid, and that needs a ruling before going further.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add fixtures/conformance/relationship-one-two-refs-sourcerefield \
+ fixtures/conformance/relationship-one-two-refs-name-pairing \
+ fixtures/conformance/error-relationship-one-refs-ambiguous
+git commit -m "test(conformance): 1:N reference disambiguation fixtures (#368)"
+```
+
+---
+
+## Task 10: Documentation
+
+**Files:**
+- Modify: `CHANGELOG.md`
+- Modify: `spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md` (amendment)
+- Modify: `CLAUDE.md:526` (the relationship-subtypes bullet)
+- Modify: `docs/features/` — whichever page documents relationships (grep for `@sourceRefField`)
+
+- [ ] **Step 1: Add the ADR-0029 amendment**
+
+Append to ADR-0029:
+
+```markdown
+## Amendment 1 (2026-09-13) — the ambiguity rule extends to 1:N FK selection
+
+§5's contract ("a second path is a load error naming the candidates") was stated
+for `@via` on `origin.*`. Issue #368 found the same ambiguity one level down: two
+`identity.reference` nodes onto the same target make a `@cardinality: one`
+relationship's FK unresolvable from `@objectRef` alone, and the TypeScript
+resolvers silently took the first.
+
+The rule now also covers 1:N FK selection, resolved by this ladder:
+
+1. exactly one candidate reference → that one;
+2. `@sourceRefField` declared → the candidate whose FK field it names;
+3. exactly one candidate name-pairs with the relationship name → that one;
+4. otherwise → `ERR_INVALID_RELATIONSHIP` naming every candidate.
+
+Stage 3 is a **local string rule**, not a graph walk: a candidate's pairing keys
+are its own name and its FK field, each lowercased with and without one trailing
+suffix from `["reference", "ref", "id", "key"]`. Suffixes are stripped from the
+candidate side only. This keeps §5's "trivially portable" bar — it is pure string
+comparison over one entity's own children, not the multi-hop path inference §5
+declined to attempt.
+
+No new vocabulary: `@sourceRefField` was already registered on every
+`relationship.*` subtype, and on `@cardinality: one` it previously failed to
+load, so giving it meaning is additive under the `docs/compatibility-policy.md`
+correction bar. `metamodelVersion` is unchanged.
+```
+
+- [ ] **Step 2: Update `CLAUDE.md:526`**
+
+Extend the `@sourceRefField` sentence in the relationship-subtypes bullet:
+
+```
+`@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side); on a `@cardinality: one` relationship it names which of several `identity.reference` nodes onto the same target this relationship navigates (#368, ADR-0029 Amendment 1) — an unresolvable 1:N reference is `ERR_INVALID_RELATIONSHIP` at load.
+```
+
+- [ ] **Step 3: Add the CHANGELOG entry**
+
+Under the unreleased heading, in the style of the surrounding entries:
+
+```markdown
+### Fixed
+- **Two `identity.reference` nodes onto the same entity no longer make every
+ `@cardinality: one` relationship join the first one's FK column** (#368). The
+ TypeScript codegen `relations()` block, the runtime relation traversal, the
+ projection join lookup and the docs link graph each took the first
+ target-matching reference, so a second association emitted a wrong-column join
+ that compiled, typechecked, produced correct DDL and passed `meta verify` —
+ surfacing only as wrong rows. Resolution is now explicit: unique candidate →
+ `@sourceRefField` → name pairing → `ERR_INVALID_RELATIONSHIP` at load, in all
+ four ports. `@sourceRefField` is now legal on `@cardinality: one` (it
+ previously failed to load there). No vocabulary change; `metamodelVersion`
+ unchanged.
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add CHANGELOG.md CLAUDE.md spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md docs/features
+git commit -m "docs: ADR-0029 Amendment 1 + changelog for #368"
+```
+
+---
+
+## Task 11: End-to-end verification against the issue's repro
+
+- [ ] **Step 1: Build the whole TS workspace**
+
+```bash
+cd server/typescript && bun run --filter '*' build && bun run --filter '*' typecheck
+```
+Expected: exit 0 (this is the same gate `.githooks/pre-push` runs)
+
+- [ ] **Step 2: Reproduce the issue exactly**
+
+Create a scratch project outside the repo, paste the `metaobjects/meta.json` and `metaobjects.config.ts` from issue #368 verbatim, then:
+
+```bash
+meta eject entity && meta gen
+```
+
+Expected in `src/generated/Match.ts`:
+
+```ts
+export const matchesRelations = relations(matches, ({ one }) => ({
+ homeTeam: one(teams, { fields: [matches.homeTeamId], references: [teams.id] }),
+ awayTeam: one(teams, { fields: [matches.awayTeamId], references: [teams.id] }),
+}));
+```
+
+- [ ] **Step 3: Confirm the ambiguous model is now refused**
+
+Rename `awayTeamRef`→`betaRef` and `awayTeamId`→`betaFk` in the scratch model so nothing pairs, then run `meta verify`.
+Expected: `ERR_INVALID_RELATIONSHIP` naming `Match.awayTeam` and both candidates.
+
+- [ ] **Step 4: Run the local CI gate**
+
+```bash
+cd && scripts/ci-local.sh --quick
+```
+Expected: PASS (this is what CLAUDE.md asks for before opening a PR)
+
+- [ ] **Step 5: Open the PR**
+
+```bash
+npx -y gh-axi pr create \
+ --title "fix: two identity.reference nodes onto one entity made every association join the first FK (#368)" \
+ --body-file \
+ --repo=metaobjectsdev/metaobjects
+```
+
+The body must state: the four surfaces fixed, the resolution ladder, that `expected-registry.json` and `metamodelVersion` are unchanged, and the Task 8 audit result.
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Issue "Proposed fix" 1 (declarable) → Tasks 2, 3, 7 via `@sourceRefField`
+- Issue "Proposed fix" 2 (name pairing) → Task 1 stage 3, fixtured in Task 9
+- Issue "Proposed fix" 3 (fail loudly) → Task 3 rule (e), cross-port in Task 7
+- Issue "Root cause" (`relation-resolver.ts:95-101`) → Task 4
+- Issue "anti-pattern check for existing estates" → covered by rule (e) firing at load, so `meta verify` reports it without regenerating (Task 11 Step 3 proves it)
+- Extra surfaces beyond the issue → Tasks 5, 6, 8
+
+**Open decision for the maintainer:** `@sourceRefField`'s registered *description* still reads "names the source-side FK field on the junction". Updating it to cover the 1:N meaning changes `expected-registry.json`, which per `CLAUDE.md` forces all four registries to publish at the next release even though the attr set — and so `metamodelVersion` — is unchanged. This plan **leaves the description alone** and documents the widened meaning in ADR-0029 Amendment 1, `CLAUDE.md` and the feature docs instead. Task 9 Step 4 enforces that choice. Flip it only on an explicit ruling.
+
+**Type consistency:** `resolveRelationshipReference(holder, relationshipName, targetEntity, sourceRefField?)` and `referenceCandidatesFor(holder, targetEntity)` are used with those exact signatures in Tasks 3, 4, 5 and 7. `findReferencesBetween(a, b): ReferenceLookup[]` is used with that signature in Task 6 only.
From 5ebb69c13b5e83adb81925b16f5e4f8ee00be059 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 14:35:44 -0400
Subject: [PATCH 02/31] feat(metadata): association->reference resolution
ladder (#368)
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../resolve-relationship-reference.ts | 99 +++++++++++
.../typescript/packages/metadata/src/index.ts | 5 +
.../resolve-relationship-reference.test.ts | 160 ++++++++++++++++++
3 files changed, 264 insertions(+)
create mode 100644 server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts
create mode 100644 server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts
diff --git a/server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts b/server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts
new file mode 100644
index 000000000..bbf227de3
--- /dev/null
+++ b/server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts
@@ -0,0 +1,99 @@
+// Association -> identity.reference resolution (issue #368).
+//
+// An entity may declare more than one identity.reference onto the SAME target
+// entity (Match.homeTeamRef and Match.awayTeamRef both -> Team). A
+// `@cardinality: one` relationship names only its target, so when two
+// references match, the target alone cannot say which FK the navigation uses.
+// Taking the first match emits a join on the wrong column that typechecks, has
+// correct DDL and passes verify — so the ladder below resolves it explicitly or
+// not at all. ADR-0029 §5: ambiguity is a load error naming the candidates.
+
+import type { MetaObject } from "../object/meta-object.js";
+import type { MetaReferenceIdentity } from "../identity/meta-identity.js";
+import { stripPackage } from "../../naming.js";
+
+/**
+ * Trailing suffixes stripped from a CANDIDATE's name/FK field when building its
+ * pairing keys. Ordered — first match wins, so "reference" is tested before
+ * "ref". Never applied to the relationship name (see referencePairingKeys).
+ */
+const PAIRING_SUFFIXES = ["reference", "ref", "id", "key"] as const;
+
+function stripOneSuffix(value: string): string {
+ for (const suffix of PAIRING_SUFFIXES) {
+ if (value.length > suffix.length && value.endsWith(suffix)) {
+ return value.slice(0, value.length - suffix.length);
+ }
+ }
+ return value;
+}
+
+/** The FK field a reference is anchored on (first field; composite FKs pair on their first column). */
+function refFkField(ref: MetaReferenceIdentity): string | undefined {
+ return ref.fields.length > 0 ? ref.fields[0] : undefined;
+}
+
+/**
+ * The set of lowercased names a candidate reference answers to: its own name
+ * and its FK field, each with and without one stripped suffix.
+ */
+export function referencePairingKeys(ref: MetaReferenceIdentity): Set {
+ const keys = new Set();
+ const add = (value: string | undefined): void => {
+ if (!value) return;
+ const lower = value.toLowerCase();
+ keys.add(lower);
+ keys.add(stripOneSuffix(lower));
+ };
+ add(ref.name);
+ add(refFkField(ref));
+ return keys;
+}
+
+/**
+ * Every identity.reference on `holder` whose @references targets `targetEntity`.
+ * Package-insensitive on both sides: @references and @objectRef may each be bare
+ * or fully qualified.
+ */
+export function referenceCandidatesFor(
+ holder: MetaObject,
+ targetEntity: string,
+): MetaReferenceIdentity[] {
+ const target = stripPackage(targetEntity);
+ // ADR-0039: resolving — referenceIdentities() honors references inherited via extends.
+ return holder
+ .referenceIdentities()
+ .filter((ref) => stripPackage(ref.targetEntity ?? "") === target)
+ .filter((ref) => refFkField(ref) !== undefined);
+}
+
+/**
+ * Which identity.reference does this `@cardinality: one` relationship navigate
+ * through? The ladder, in order:
+ *
+ * 1. exactly one candidate -> that one (the common case; unchanged behaviour)
+ * 2. `@sourceRefField` declared -> the candidate whose FK field it names
+ * 3. exactly one candidate name-pairs -> that one
+ * 4. otherwise -> undefined (caller reports the ambiguity)
+ *
+ * Returns undefined for "no candidate" and "cannot choose" alike; callers that
+ * need to tell them apart use referenceCandidatesFor().
+ */
+export function resolveRelationshipReference(
+ holder: MetaObject,
+ relationshipName: string,
+ targetEntity: string,
+ sourceRefField?: string,
+): MetaReferenceIdentity | undefined {
+ const candidates = referenceCandidatesFor(holder, targetEntity);
+ if (candidates.length === 0) return undefined;
+ if (candidates.length === 1) return candidates[0];
+
+ if (sourceRefField !== undefined && sourceRefField !== "") {
+ return candidates.find((ref) => refFkField(ref) === sourceRefField);
+ }
+
+ const wanted = relationshipName.toLowerCase();
+ const paired = candidates.filter((ref) => referencePairingKeys(ref).has(wanted));
+ return paired.length === 1 ? paired[0] : undefined;
+}
diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts
index 2822af835..f6255f5ad 100644
--- a/server/typescript/packages/metadata/src/index.ts
+++ b/server/typescript/packages/metadata/src/index.ts
@@ -116,6 +116,11 @@ export { MetaRelationship } from "./core/relationship/meta-relationship.js";
// Cross-entity reference lookup
export { findReferenceBetween } from "./core/relationship/find-reference.js";
export type { ReferenceLookup } from "./core/relationship/find-reference.js";
+export {
+ referenceCandidatesFor,
+ referencePairingKeys,
+ resolveRelationshipReference,
+} from "./core/relationship/resolve-relationship-reference.js";
// FR-017 — M:N junction FK derivation (hetero / directed-self-join / symmetric)
export { deriveM2MFields, M2MDerivationError } from "./core/relationship/derive-m2m-fields.js";
export type { M2MFields } from "./core/relationship/derive-m2m-fields.js";
diff --git a/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts b/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts
new file mode 100644
index 000000000..780da15b4
--- /dev/null
+++ b/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, test } from "bun:test";
+import { MetaDataLoader } from "../src/loader/meta-data-loader.js";
+import { InMemoryStringSource } from "../src/loader/meta-data-source.js";
+import {
+ referenceCandidatesFor,
+ resolveRelationshipReference,
+} from "../src/core/relationship/resolve-relationship-reference.js";
+import type { MetaObject } from "../src/core/object/meta-object.js";
+
+const MATCH_MODEL = {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ {
+ "object.entity": {
+ name: "Team",
+ children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ],
+ },
+ },
+ {
+ "object.entity": {
+ name: "Match",
+ children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId" } },
+ { "field.int": { name: "awayTeamId" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ ],
+ },
+ },
+ ],
+ },
+};
+
+async function loadMatch(): Promise {
+ const { root, errors } = await new MetaDataLoader().load([
+ new InMemoryStringSource(JSON.stringify(MATCH_MODEL), { id: "meta.repro.json" }),
+ ]);
+ expect(errors).toEqual([]);
+ return root.findObject("Match")!;
+}
+
+describe("resolveRelationshipReference", () => {
+ test("enumerates every candidate reference for the target", async () => {
+ const candidates = referenceCandidatesFor(await loadMatch(), "Team");
+ expect(candidates.map((r) => r.name)).toEqual(["homeTeamRef", "awayTeamRef"]);
+ });
+
+ test("name pairing resolves each association to its own reference", async () => {
+ const match = await loadMatch();
+ expect(resolveRelationshipReference(match, "homeTeam", "Team")?.name).toBe("homeTeamRef");
+ expect(resolveRelationshipReference(match, "awayTeam", "Team")?.name).toBe("awayTeamRef");
+ });
+
+ test("@sourceRefField wins over name pairing", async () => {
+ const match = await loadMatch();
+ expect(
+ resolveRelationshipReference(match, "homeTeam", "Team", "awayTeamId")?.name,
+ ).toBe("awayTeamRef");
+ });
+
+ test("a single candidate resolves regardless of name", async () => {
+ const { root, errors } = await new MetaDataLoader().load([
+ new InMemoryStringSource(
+ JSON.stringify({
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "winnerFk" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "anythingAtAll", "@fields": ["winnerFk"], "@references": "Team" } },
+ { "relationship.association": { name: "champion", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ ],
+ },
+ }),
+ { id: "meta.repro.json" },
+ ),
+ ]);
+ expect(errors).toEqual([]);
+ const match = root.findObject("Match")!;
+ expect(resolveRelationshipReference(match, "champion", "Team")?.name).toBe("anythingAtAll");
+ });
+
+ test("unpairable names return undefined rather than guessing", async () => {
+ const { root, errors } = await new MetaDataLoader().load([
+ new InMemoryStringSource(
+ JSON.stringify({
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "alphaFk" } },
+ { "field.int": { name: "betaFk" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } },
+ { "identity.reference": { name: "betaRef", "@fields": ["betaFk"], "@references": "Team" } },
+ { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ ],
+ },
+ }),
+ { id: "meta.repro.json" },
+ ),
+ ]);
+ expect(errors).toEqual([]);
+ const match = root.findObject("Match")!;
+ expect(resolveRelationshipReference(match, "winner", "Team")).toBeUndefined();
+ });
+
+ test("suffix stripping never applies to the relationship name", async () => {
+ // "valid" must NOT be stripped to "val" and pair with valRef.
+ const { root, errors } = await new MetaDataLoader().load([
+ new InMemoryStringSource(
+ JSON.stringify({
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "valFk" } },
+ { "field.int": { name: "otherFk" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "valRef", "@fields": ["valFk"], "@references": "Team" } },
+ { "identity.reference": { name: "otherRef", "@fields": ["otherFk"], "@references": "Team" } },
+ { "relationship.association": { name: "valid", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ ],
+ },
+ }),
+ { id: "meta.repro.json" },
+ ),
+ ]);
+ expect(errors).toEqual([]);
+ const match = root.findObject("Match")!;
+ expect(resolveRelationshipReference(match, "valid", "Team")).toBeUndefined();
+ });
+});
From 8167abb0af985f1207b092addd23f1124e0b0085 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 14:44:40 -0400
Subject: [PATCH 03/31] feat(loader): @sourceRefField is legal on
cardinality:one (#368)
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../metadata/src/loader/validation-passes.ts | 13 +++++--
.../metadata/test/relationship-m2m.test.ts | 35 +++++++++++++++++++
2 files changed, 45 insertions(+), 3 deletions(-)
diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts
index a473a6aa0..cadee2306 100644
--- a/server/typescript/packages/metadata/src/loader/validation-passes.ts
+++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts
@@ -1938,8 +1938,11 @@ export function validateDataGridFilterValues(root: MetaData): ParseError[] {
// (c) When @through is present: the named entity must exist and declare exactly
// two identity.reference children; @sourceRefField (if present) must match
// one of those references' FK fields → ERR_INVALID_RELATIONSHIP.
-// (d) @through / @sourceRefField / @symmetric are invalid on a non-M:N
-// relationship (@cardinality != "many", or no @through) → ERR_INVALID_RELATIONSHIP.
+// (d) @through / @symmetric are invalid on a non-M:N relationship
+// (@cardinality != "many", or no @through) → ERR_INVALID_RELATIONSHIP.
+// @sourceRefField is also invalid there, EXCEPT on @cardinality: "one"
+// (#368: it then names which of several identity.reference nodes onto
+// the same target the relationship navigates).
//
// Own-relationships only: a relationship is validated on the entity that declares
// it (matching the own-attrs policy of the other passes).
@@ -2005,7 +2008,11 @@ export function validateRelationships(root: MetaData): ParseError[] {
),
);
}
- if (hasSourceRefField) {
+ // #368: @sourceRefField also disambiguates a `@cardinality: one`
+ // relationship when the entity holds more than one identity.reference
+ // onto the same target. Only the M:N *junction* reading is rejected
+ // here; rule (e) below checks that it names a real local reference.
+ if (hasSourceRefField && cardinality !== CARDINALITY_ONE) {
errors.push(
new ParseError(
`relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.`,
diff --git a/server/typescript/packages/metadata/test/relationship-m2m.test.ts b/server/typescript/packages/metadata/test/relationship-m2m.test.ts
index b20651a80..844b8ff69 100644
--- a/server/typescript/packages/metadata/test/relationship-m2m.test.ts
+++ b/server/typescript/packages/metadata/test/relationship-m2m.test.ts
@@ -325,6 +325,41 @@ describe("FR-017 M:N validation rules", () => {
expect(codesOf(errors)).toContain("ERR_INVALID_RELATIONSHIP");
});
+ // Rule (d) exception (#368): @sourceRefField also disambiguates a
+ // @cardinality:one relationship — which of several identity.reference nodes
+ // onto the same target it navigates (see resolve-relationship-reference.test.ts).
+ // Only the M:N *junction* reading of @sourceRefField still requires @cardinality:many.
+ test("sourceRefField on a cardinality:one relationship loads cleanly (#368)", async () => {
+ const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "homeTeamId" } },
+ { "field.long": { name: "awayTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } } ] } },
+ ] } });
+ expect(errors).toHaveLength(0);
+ });
+
+ test("sourceRefField on a @cardinality:many relationship without @through still errors", async () => {
+ const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "relationship.association": { name: "teams", "@objectRef": "Team", "@cardinality": "many", "@sourceRefField": "whatever" } } ] } },
+ ] } });
+ expect(codesOf(errors)).toContain("ERR_INVALID_RELATIONSHIP");
+ });
+
test("valid hetero M:N produces no relationship errors", async () => {
const { errors } = await loadDoc({ "metadata.root": { package: "acme", children: [
{ "object.entity": { name: "Post", children: [
From 23b7db6115a27e44bcf99aac8d1318e48c8edb0c Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 14:56:15 -0400
Subject: [PATCH 04/31] feat(loader): ambiguous 1:N reference is a load error
(#368)
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../metadata/src/loader/meta-data-loader.ts | 8 ++-
.../metadata/src/loader/validation-passes.ts | 59 +++++++++++++++-
.../metadata/test/relationship-m2m.test.ts | 67 +++++++++++++++++++
.../resolve-relationship-reference.test.ts | 11 ++-
4 files changed, 141 insertions(+), 4 deletions(-)
diff --git a/server/typescript/packages/metadata/src/loader/meta-data-loader.ts b/server/typescript/packages/metadata/src/loader/meta-data-loader.ts
index 07c14dd9e..a7ab1dafe 100644
--- a/server/typescript/packages/metadata/src/loader/meta-data-loader.ts
+++ b/server/typescript/packages/metadata/src/loader/meta-data-loader.ts
@@ -25,7 +25,7 @@ import { ParseError } from "../errors.js";
import type { LoaderWarning } from "../source.js";
import { codeSource, resolvedSource } from "../source.js";
import { parseJson } from "../parser-json.js";
-import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateIndexLookupFields, validateProjectionFilter, validateRetiredRequirementLinks } from "./validation-passes.js";
+import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateOneSideReferenceResolution, validateIndexLookupFields, validateProjectionFilter, validateRetiredRequirementLinks } from "./validation-passes.js";
import { runRegisteredValidation } from "./validation-registry.js";
import { validateSourceRoles } from "../persistence/source/validate-source-roles.js";
import { validateSourceEscapes } from "../persistence/source/validate-source-escapes.js";
@@ -652,6 +652,12 @@ export class MetaDataLoader {
// are invalid on a 1:N relationship.
errors.push(...validateRelationships(root));
+ // Rule (e) — #368: a `@cardinality: one` relationship must resolve to
+ // exactly one identity.reference when its declaring entity holds more
+ // than one onto the same target; an unresolvable case is a load error
+ // naming the candidates (ADR-0029 §5).
+ errors.push(...validateOneSideReferenceResolution(root));
+
// index.lookup @fields resolution — each index.lookup must name ≥1 field,
// and every field must exist in the entity's effective (resolved) field set
// (ADR-0039: resolving accessor, so inherited fields via extends are visible).
diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts
index cadee2306..d46c05157 100644
--- a/server/typescript/packages/metadata/src/loader/validation-passes.ts
+++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts
@@ -129,6 +129,11 @@ import {
CARDINALITY_ONE,
CARDINALITY_MANY,
} from "../core/relationship/relationship-constants.js";
+import {
+ referenceCandidatesFor,
+ resolveRelationshipReference,
+} from "../core/relationship/resolve-relationship-reference.js";
+import type { MetaRoot } from "../shared/meta-root.js";
import { stripPackage } from "../naming.js";
import {
FILTER_COMPOSE_OR,
@@ -2011,7 +2016,8 @@ export function validateRelationships(root: MetaData): ParseError[] {
// #368: @sourceRefField also disambiguates a `@cardinality: one`
// relationship when the entity holds more than one identity.reference
// onto the same target. Only the M:N *junction* reading is rejected
- // here; rule (e) below checks that it names a real local reference.
+ // here; rule (e) — validateOneSideReferenceResolution, below in this
+ // file — checks that it names a real local reference.
if (hasSourceRefField && cardinality !== CARDINALITY_ONE) {
errors.push(
new ParseError(
@@ -2113,6 +2119,57 @@ export function validateRelationships(root: MetaData): ParseError[] {
return errors;
}
+// ---------------------------------------------------------------------------
+// Rule (e) — #368: a `@cardinality: one` relationship must resolve to exactly
+// one identity.reference. Two references onto the same target are
+// indistinguishable from the relationship's @objectRef alone, so the resolver
+// would silently emit the first one's FK column. ADR-0029 §5: a second path
+// is a load error naming the candidates.
+//
+// Registered alongside validateRelationships (the M:N slim-vocabulary pass,
+// above) — same deferred-resolution timing (after all files load + extends
+// resolution), same own-relationships-only scope.
+// ---------------------------------------------------------------------------
+
+export function validateOneSideReferenceResolution(root: MetaRoot): ParseError[] {
+ const errors: ParseError[] = [];
+ for (const obj of root.objects()) {
+ // ADR-0039: own — a relationship is validated on the entity that DECLARES it.
+ for (const rel of obj.ownChildren().filter((c) => c.type === TYPE_RELATIONSHIP)) {
+ // ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends.
+ if (rel.attr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_ONE) continue;
+ const objectRef = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF);
+ if (typeof objectRef !== "string" || objectRef === "") continue;
+
+ const candidates = referenceCandidatesFor(obj, objectRef);
+ if (candidates.length <= 1) continue;
+
+ const sourceRefField = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD);
+ const declared = typeof sourceRefField === "string" && sourceRefField !== ""
+ ? sourceRefField
+ : undefined;
+ const resolved = resolveRelationshipReference(obj, rel.name, objectRef, declared);
+ if (resolved) continue;
+
+ const listed = candidates.map((c) => `${c.name}(${c.fields[0]})`).join(", ");
+ errors.push(
+ new ParseError(
+ declared !== undefined
+ ? `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} ` +
+ `"${declared}", which names no identity.reference targeting "${objectRef}". ` +
+ `Candidates: ${listed}.`
+ : `relationship "${obj.name}.${rel.name}" is ambiguous: "${obj.name}" declares ` +
+ `${candidates.length} identity.reference nodes targeting "${objectRef}" and the ` +
+ `relationship name does not pair with exactly one. Candidates: ${listed}. ` +
+ `Set @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} to the FK field this relationship navigates.`,
+ { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
+ ),
+ );
+ }
+ }
+ return errors;
+}
+
// NOTE: identity.reference @references resolution moved to the validation registry
// (defaultValidationRegistry → a declarative reference descriptor with dottedFieldPath).
diff --git a/server/typescript/packages/metadata/test/relationship-m2m.test.ts b/server/typescript/packages/metadata/test/relationship-m2m.test.ts
index 844b8ff69..32fc9f052 100644
--- a/server/typescript/packages/metadata/test/relationship-m2m.test.ts
+++ b/server/typescript/packages/metadata/test/relationship-m2m.test.ts
@@ -379,3 +379,70 @@ describe("FR-017 M:N validation rules", () => {
expect(codesOf(errors)).not.toContain("ERR_BAD_ATTR_VALUE");
});
});
+
+// ---------------------------------------------------------------------------
+// Rule (e) (#368): a `@cardinality: one` relationship must resolve to exactly
+// one identity.reference. Two references onto the same target are
+// indistinguishable from @objectRef alone — the resolver used to silently
+// emit the first one's FK column, so ambiguity is now a load error naming
+// the candidates instead.
+// ---------------------------------------------------------------------------
+
+describe("FR-017 Rule (e) — #368 ambiguous 1:N reference resolution", () => {
+ test("two references to one target with an unpairable relationship name is a load error (#368)", async () => {
+ const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "alphaFk" } },
+ { "field.long": { name: "betaFk" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { name: "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } },
+ { "identity.reference": { name: "betaRef", "@fields": ["betaFk"], "@references": "Team" } },
+ { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one" } } ] } },
+ ] } });
+ expect(codesOf(errors)).toContain("ERR_INVALID_RELATIONSHIP");
+ const message = errors.map((e) => e.message).join("\n");
+ expect(message).toContain("Match.winner");
+ expect(message).toContain("alphaRef(alphaFk)");
+ expect(message).toContain("betaRef(betaFk)");
+ });
+
+ test("the issue #368 repro loads clean via name pairing", async () => {
+ const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "homeTeamId" } },
+ { "field.long": { name: "awayTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } } ] } },
+ ] } });
+ expect(errors).toHaveLength(0);
+ });
+
+ test("sourceRefField naming no local reference is a load error (#368)", async () => {
+ const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "homeTeamId" } },
+ { "field.long": { name: "awayTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "nonesuch" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } } ] } },
+ ] } });
+ expect(codesOf(errors)).toContain("ERR_INVALID_RELATIONSHIP");
+ });
+});
diff --git a/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts b/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts
index 780da15b4..9eb7929ec 100644
--- a/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts
+++ b/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts
@@ -121,7 +121,11 @@ describe("resolveRelationshipReference", () => {
{ id: "meta.repro.json" },
),
]);
- expect(errors).toEqual([]);
+ // #368 rule (e) (validation-passes.ts) now flags this fixture as a load
+ // error — it's the exact ambiguity the ladder returning undefined exists
+ // to surface. The ladder function itself is unchanged; assert its return
+ // value directly against the merged root rather than requiring a clean load.
+ expect(errors.map((e) => (e as { code?: string }).code)).toContain("ERR_INVALID_RELATIONSHIP");
const match = root.findObject("Match")!;
expect(resolveRelationshipReference(match, "winner", "Team")).toBeUndefined();
});
@@ -153,7 +157,10 @@ describe("resolveRelationshipReference", () => {
{ id: "meta.repro.json" },
),
]);
- expect(errors).toEqual([]);
+ // #368 rule (e) (validation-passes.ts) now flags this fixture as a load
+ // error too — see the comment on "unpairable names return undefined
+ // rather than guessing" above.
+ expect(errors.map((e) => (e as { code?: string }).code)).toContain("ERR_INVALID_RELATIONSHIP");
const match = root.findObject("Match")!;
expect(resolveRelationshipReference(match, "valid", "Team")).toBeUndefined();
});
From 763f7a300f3ef66baf7aa8a6d01e89a9f6e9654b Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 15:08:01 -0400
Subject: [PATCH 05/31] fix(loader): rule (e) fix round 1 -- catch
declared-but-unmatched sourceRefField 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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../metadata/src/loader/validation-passes.ts | 54 ++++++++++++---
.../metadata/test/relationship-m2m.test.ts | 69 +++++++++++++++++++
.../resolve-relationship-reference.test.ts | 4 +-
3 files changed, 114 insertions(+), 13 deletions(-)
diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts
index d46c05157..8b390a261 100644
--- a/server/typescript/packages/metadata/src/loader/validation-passes.ts
+++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts
@@ -2142,26 +2142,44 @@ export function validateOneSideReferenceResolution(root: MetaRoot): ParseError[]
if (typeof objectRef !== "string" || objectRef === "") continue;
const candidates = referenceCandidatesFor(obj, objectRef);
- if (candidates.length <= 1) continue;
const sourceRefField = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD);
const declared = typeof sourceRefField === "string" && sourceRefField !== ""
? sourceRefField
: undefined;
- const resolved = resolveRelationshipReference(obj, rel.name, objectRef, declared);
+
+ if (declared !== undefined) {
+ // A declared @sourceRefField short-circuits the ladder at ANY
+ // candidate count — checked independently of resolveRelationshipReference,
+ // whose step 1 ("exactly one candidate -> that one") would otherwise
+ // silently return the lone candidate even when it disagrees with the
+ // declared field. The author named a specific FK; it must exist,
+ // whether there are zero, one, or many candidates.
+ const matchesDeclared = candidates.some((c) => c.fields[0] === declared);
+ if (matchesDeclared) continue;
+ errors.push(
+ new ParseError(
+ `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} ` +
+ `"${declared}", which names no identity.reference targeting "${objectRef}". ` +
+ `Candidates: ${formatReferenceCandidates(candidates)}.`,
+ { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
+ ),
+ );
+ continue;
+ }
+
+ // No @sourceRefField declared: ambiguity only exists with 2+ candidates —
+ // resolveRelationshipReference's name-pairing step (ladder step 3) decides.
+ if (candidates.length <= 1) continue;
+ const resolved = resolveRelationshipReference(obj, rel.name, objectRef);
if (resolved) continue;
- const listed = candidates.map((c) => `${c.name}(${c.fields[0]})`).join(", ");
errors.push(
new ParseError(
- declared !== undefined
- ? `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} ` +
- `"${declared}", which names no identity.reference targeting "${objectRef}". ` +
- `Candidates: ${listed}.`
- : `relationship "${obj.name}.${rel.name}" is ambiguous: "${obj.name}" declares ` +
- `${candidates.length} identity.reference nodes targeting "${objectRef}" and the ` +
- `relationship name does not pair with exactly one. Candidates: ${listed}. ` +
- `Set @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} to the FK field this relationship navigates.`,
+ `relationship "${obj.name}.${rel.name}" is ambiguous: "${obj.name}" declares ` +
+ `${candidates.length} identity.reference nodes targeting "${objectRef}" and the ` +
+ `relationship name does not pair with exactly one. Candidates: ${formatReferenceCandidates(candidates)}. ` +
+ `Set @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} to the FK field this relationship navigates.`,
{ code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
),
);
@@ -2170,6 +2188,20 @@ export function validateOneSideReferenceResolution(root: MetaRoot): ParseError[]
return errors;
}
+/**
+ * Render a candidate reference as `name(fkField)`, or `name(fieldA, fieldB)`
+ * for a composite reference — so two composite references sharing a first
+ * column (e.g. both starting `tenantId`) still print distinguishably.
+ *
+ * NOTE: this is display only. Matching (both here and in
+ * resolveRelationshipReference) still keys on `fields[0]` alone — a
+ * composite reference cannot actually be disambiguated by @sourceRefField.
+ * That's a documented limitation, not fixed by this rendering change.
+ */
+function formatReferenceCandidates(candidates: readonly MetaReferenceIdentity[]): string {
+ return candidates.map((c) => `${c.name}(${c.fields.join(", ")})`).join(", ");
+}
+
// NOTE: identity.reference @references resolution moved to the validation registry
// (defaultValidationRegistry → a declarative reference descriptor with dottedFieldPath).
diff --git a/server/typescript/packages/metadata/test/relationship-m2m.test.ts b/server/typescript/packages/metadata/test/relationship-m2m.test.ts
index 32fc9f052..d57adb716 100644
--- a/server/typescript/packages/metadata/test/relationship-m2m.test.ts
+++ b/server/typescript/packages/metadata/test/relationship-m2m.test.ts
@@ -444,5 +444,74 @@ describe("FR-017 Rule (e) — #368 ambiguous 1:N reference resolution", () => {
{ "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } } ] } },
] } });
expect(codesOf(errors)).toContain("ERR_INVALID_RELATIONSHIP");
+ const message = errors.map((e) => e.message).join("\n");
+ expect(message).toContain("Match.homeTeam");
+ expect(message).toContain('"nonesuch"');
+ // awayTeam's @sourceRefField correctly names awayTeamRef's FK field — no error for it.
+ expect(message).not.toContain("Match.awayTeam");
+ });
+
+ // Fix round 1: a declared @sourceRefField naming nothing must error even
+ // with exactly one candidate — resolveRelationshipReference's ladder step 1
+ // ("exactly one candidate -> that one") would otherwise silently return
+ // that lone candidate regardless of whether it matches the declared field,
+ // emitting a join on the wrong column with no error at all.
+ test("sourceRefField naming nothing with a single candidate is a load error (#368)", async () => {
+ const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "homeTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } } ] } },
+ ] } });
+ expect(codesOf(errors)).toEqual(["ERR_INVALID_RELATIONSHIP"]);
+ const message = errors.map((e) => e.message).join("\n");
+ expect(message).toContain("Match.awayTeam");
+ expect(message).toContain('"awayTeamId"');
+ });
+
+ test("sourceRefField correctly naming the single candidate loads clean (#368)", async () => {
+ const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "homeTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId" } } ] } },
+ ] } });
+ expect(errors).toHaveLength(0);
+ });
+
+ // Fix round 1: two composite references sharing a first column must still
+ // print distinguishably in the candidate list (previously rendered as
+ // `fields[0]` only, so both showed as e.g. "aRef(tenantId), bRef(tenantId)").
+ // Matching still keys on fields[0] alone (documented limitation) — this is
+ // a message-rendering fix only.
+ test("composite reference candidates render their full field tuple (#368)", async () => {
+ const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "tenantId" } },
+ { "field.long": { name: "homeTeamId" } },
+ { "field.long": { name: "awayTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { name: "aRef", "@fields": ["tenantId", "homeTeamId"], "@references": "Team" } },
+ { "identity.reference": { name: "bRef", "@fields": ["tenantId", "awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one" } } ] } },
+ ] } });
+ expect(codesOf(errors)).toContain("ERR_INVALID_RELATIONSHIP");
+ const message = errors.map((e) => e.message).join("\n");
+ expect(message).toContain("aRef(tenantId, homeTeamId)");
+ expect(message).toContain("bRef(tenantId, awayTeamId)");
});
});
diff --git a/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts b/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts
index 9eb7929ec..d6f634e9d 100644
--- a/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts
+++ b/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts
@@ -125,7 +125,7 @@ describe("resolveRelationshipReference", () => {
// error — it's the exact ambiguity the ladder returning undefined exists
// to surface. The ladder function itself is unchanged; assert its return
// value directly against the merged root rather than requiring a clean load.
- expect(errors.map((e) => (e as { code?: string }).code)).toContain("ERR_INVALID_RELATIONSHIP");
+ expect(errors.map((e) => (e as { code?: string }).code)).toEqual(["ERR_INVALID_RELATIONSHIP"]);
const match = root.findObject("Match")!;
expect(resolveRelationshipReference(match, "winner", "Team")).toBeUndefined();
});
@@ -160,7 +160,7 @@ describe("resolveRelationshipReference", () => {
// #368 rule (e) (validation-passes.ts) now flags this fixture as a load
// error too — see the comment on "unpairable names return undefined
// rather than guessing" above.
- expect(errors.map((e) => (e as { code?: string }).code)).toContain("ERR_INVALID_RELATIONSHIP");
+ expect(errors.map((e) => (e as { code?: string }).code)).toEqual(["ERR_INVALID_RELATIONSHIP"]);
const match = root.findObject("Match")!;
expect(resolveRelationshipReference(match, "valid", "Team")).toBeUndefined();
});
From df9ceb2c7a60c4ffba028a68e2e5b3dd6bf8f9ad Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 15:18:47 -0400
Subject: [PATCH 06/31] fix(codegen-ts): relations() joined every association
on the first FK (#368)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../codegen-ts/src/relation-resolver.ts | 22 +++--
.../test/relation-resolver-two-refs.test.ts | 80 +++++++++++++++++++
2 files changed, 94 insertions(+), 8 deletions(-)
create mode 100644 server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts
diff --git a/server/typescript/packages/codegen-ts/src/relation-resolver.ts b/server/typescript/packages/codegen-ts/src/relation-resolver.ts
index 19682c4d7..fc7db0084 100644
--- a/server/typescript/packages/codegen-ts/src/relation-resolver.ts
+++ b/server/typescript/packages/codegen-ts/src/relation-resolver.ts
@@ -10,9 +10,11 @@ import {
RELATIONSHIP_ATTR_CARDINALITY,
RELATIONSHIP_ATTR_OBJECT_REF,
RELATIONSHIP_ATTR_THROUGH,
+ RELATIONSHIP_ATTR_SOURCE_REF_FIELD,
CARDINALITY_ONE,
CARDINALITY_MANY,
deriveM2MFields,
+ resolveRelationshipReference,
stripPackage,
} from "@metaobjectsdev/metadata";
import { variableNameFromEntity } from "./naming.js";
@@ -89,16 +91,20 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
if (!targetEntityRaw) continue;
const targetEntity = stripPackage(targetEntityRaw);
- // Find an identity.reference on `obj` whose @references targets this relationship's target.
- // Compare against package-stripped names since both relationship @objectRef and
- // identity.reference @references may carry package-qualified entity names.
- const refs = obj.referenceIdentities();
- const matching = refs.find((r) => stripPackage(r.targetEntity ?? "") === targetEntity);
+ // #368: an entity may hold more than one identity.reference onto the same
+ // target, so the target alone does not identify the FK. Resolve through the
+ // shared ladder (unique candidate -> @sourceRefField -> name pairing); the
+ // loader has already refused anything it cannot resolve, so a miss here
+ // means an unloadable model reached codegen — skip rather than guess.
+ // ADR-0039: resolving — @sourceRefField may be inherited via extends.
+ const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
+ const matching = resolveRelationshipReference(
+ obj, child.name, targetEntity, declaredRefField,
+ );
if (!matching) continue;
- const fkFields = matching.fields;
- if (fkFields.length === 0) continue;
- const fkField = fkFields[0]!;
+ const fkField = matching.fields[0];
+ if (!fkField) continue;
ensure(obj.name).push({
name: child.name,
diff --git a/server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts b/server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts
new file mode 100644
index 000000000..19f533078
--- /dev/null
+++ b/server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts
@@ -0,0 +1,80 @@
+// #368 — an entity may declare two identity.reference nodes onto the same
+// target entity (Match -> Team twice, via homeTeamRef/awayTeamRef). A
+// `@cardinality: one` relationship names only its target, so
+// `refs.find(r => target matches)` picked the FIRST identity.reference for
+// every such relationship — every association joined on the same FK column.
+// It compiles, typechecks, and the DDL is correct; the only symptom is wrong
+// rows. This proves buildRelationMap resolves each association to its OWN
+// reference: the implicit name-pairing case (the bug report) and an explicit
+// `@sourceRefField` case (names that do not pair, so only the declared field
+// can disambiguate).
+
+import { describe, expect, test } from "bun:test";
+import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata";
+import { buildRelationMap } from "../src/relation-resolver.js";
+
+const MODEL = {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId" } },
+ { "field.int": { name: "awayTeamId" } },
+ { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ // Names that do NOT pair with either reference's name/FK field, so the
+ // relationship can only be resolved via an explicit @sourceRefField —
+ // proving that declaration reaches codegen and is not just tested at
+ // the metadata layer (Task 1).
+ { "object.entity": { name: "Player", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
+ ] } },
+ { "object.entity": { name: "Game", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "p1Id" } },
+ { "field.int": { name: "p2Id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
+ { "identity.reference": { name: "p1Ref", "@fields": ["p1Id"], "@references": "Player" } },
+ { "identity.reference": { name: "p2Ref", "@fields": ["p2Id"], "@references": "Player" } },
+ { "relationship.association": { name: "winner", "@objectRef": "Player", "@cardinality": "one", "@sourceRefField": "p1Id" } },
+ { "relationship.association": { name: "loser", "@objectRef": "Player", "@cardinality": "one", "@sourceRefField": "p2Id" } },
+ ] } },
+ ],
+ },
+};
+
+async function loadRoot(): Promise {
+ const { root, errors } = await new MetaDataLoader().load([
+ new InMemoryStringSource(JSON.stringify(MODEL), { id: "meta.repro.json" }),
+ ]);
+ expect(errors).toEqual([]);
+ return root;
+}
+
+describe("buildRelationMap with two references onto one target (#368)", () => {
+ test("each association joins on its own FK column (name pairing)", async () => {
+ const root = await loadRoot();
+ const relations = buildRelationMap(root).get("Match")!;
+ const byName = Object.fromEntries(relations.map((r) => [r.name, r.fkField]));
+ expect(byName.homeTeam).toBe("homeTeamId");
+ expect(byName.awayTeam).toBe("awayTeamId"); // was "homeTeamId" — the #368 defect
+ });
+
+ test("an explicit @sourceRefField also resolves to its own FK column", async () => {
+ const root = await loadRoot();
+ const relations = buildRelationMap(root).get("Game")!;
+ const byName = Object.fromEntries(relations.map((r) => [r.name, r.fkField]));
+ expect(byName.winner).toBe("p1Id");
+ expect(byName.loser).toBe("p2Id");
+ });
+});
From 6b3d2e402244c60465355beca09f80a883e08d97 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 15:29:31 -0400
Subject: [PATCH 07/31] fix(runtime-ts): relation traversal read the first FK
for every relation (#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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../runtime-ts/src/relation-resolver.ts | 53 ++++-----
.../packages/runtime-ts/test/_meta-build.ts | 23 ++++
.../test/relation-resolver-two-refs.test.ts | 107 ++++++++++++++++++
3 files changed, 155 insertions(+), 28 deletions(-)
create mode 100644 server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts
diff --git a/server/typescript/packages/runtime-ts/src/relation-resolver.ts b/server/typescript/packages/runtime-ts/src/relation-resolver.ts
index 61ae7def0..8c5a7f5c4 100644
--- a/server/typescript/packages/runtime-ts/src/relation-resolver.ts
+++ b/server/typescript/packages/runtime-ts/src/relation-resolver.ts
@@ -1,12 +1,11 @@
-import type { ColumnNamingStrategy, MetaData } from "@metaobjectsdev/metadata";
+import type { ColumnNamingStrategy, MetaData, MetaObject } from "@metaobjectsdev/metadata";
import {
- TYPE_OBJECT, TYPE_RELATIONSHIP, TYPE_IDENTITY,
- IDENTITY_SUBTYPE_REFERENCE,
- IDENTITY_ATTR_FIELDS,
- IDENTITY_REFERENCE_ATTR_REFERENCES,
+ TYPE_OBJECT, TYPE_RELATIONSHIP,
RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF,
+ RELATIONSHIP_ATTR_SOURCE_REF_FIELD,
CARDINALITY_ONE, CARDINALITY_MANY,
DEFAULT_COLUMN_NAMING_STRATEGY,
+ resolveRelationshipReference,
} from "@metaobjectsdev/metadata";
import { MetadataError } from "./errors.js";
import {
@@ -26,28 +25,20 @@ export interface RelationDescriptor {
}
/**
- * Find an identity.reference declared on `holder` whose @references targets `targetName`.
- * Returns the FK field name (first field on the identity) or undefined.
+ * The FK field a `@cardinality: one` relationship navigates through.
+ * #368: an entity may declare several identity.reference nodes onto the same
+ * target, so the target alone is not enough — resolve through the shared ladder.
*/
-function findReferenceFkField(holder: MetaData, targetName: string): string | undefined {
- // ADR-0039: effective children — an identity.reference may be inherited via extends.
- for (const child of holder.children()) {
- if (child.type !== TYPE_IDENTITY) continue;
- if (child.subType !== IDENTITY_SUBTYPE_REFERENCE) continue;
- // ADR-0039: effective attrs — @references/@fields may be inherited.
- const ref = child.attr(IDENTITY_REFERENCE_ATTR_REFERENCES);
- if (typeof ref !== "string") continue;
- const dotIdx = ref.indexOf(".");
- const entityName = dotIdx === -1 ? ref : ref.slice(0, dotIdx);
- if (entityName !== targetName) continue;
- const fields = child.attr(IDENTITY_ATTR_FIELDS);
- if (Array.isArray(fields) && fields.length > 0) return String(fields[0]);
- if (typeof fields === "string") {
- const first = fields.split(",")[0]?.trim();
- if (first) return first;
- }
- }
- return undefined;
+function findReferenceFkField(
+ holder: MetaData,
+ targetName: string,
+ relationshipName: string,
+ sourceRefField?: string,
+): string | undefined {
+ const ref = resolveRelationshipReference(
+ holder as unknown as MetaObject, relationshipName, targetName, sourceRefField,
+ );
+ return ref?.fields[0];
}
/**
@@ -77,7 +68,9 @@ export function resolveRelationDescriptor(
{ entity: sourceEntity.name },
);
}
- const fkField = findReferenceFkField(sourceEntity, targetEntityName);
+ // ADR-0039: resolving — @sourceRefField may be inherited via extends.
+ const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
+ const fkField = findReferenceFkField(sourceEntity, targetEntityName, child.name, declaredRefField);
if (!fkField) {
throw new MetadataError(
`Relationship '${relationName}' on '${sourceEntity.name}' has no identity.reference targeting '${targetEntityName}'`,
@@ -114,7 +107,11 @@ export function resolveRelationDescriptor(
if (targetEntityName !== sourceEntity.name) continue;
const inverseName = inversePluralName(other.name);
if (inverseName !== relationName) continue;
- const fkField = findReferenceFkField(other, sourceEntity.name);
+ // ADR-0039: resolving — @sourceRefField may be inherited via extends. This
+ // FK lives on `other` and belongs to `other`'s own relationship `child` —
+ // not to anything declared on sourceEntity (see #368 module comment).
+ const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
+ const fkField = findReferenceFkField(other, sourceEntity.name, child.name, declaredRefField);
if (!fkField) {
throw new MetadataError(
`Inverse relationship for '${relationName}' on '${sourceEntity.name}': entity '${other.name}' has no identity.reference targeting '${sourceEntity.name}'`,
diff --git a/server/typescript/packages/runtime-ts/test/_meta-build.ts b/server/typescript/packages/runtime-ts/test/_meta-build.ts
index 6ac16b538..015475dea 100644
--- a/server/typescript/packages/runtime-ts/test/_meta-build.ts
+++ b/server/typescript/packages/runtime-ts/test/_meta-build.ts
@@ -19,6 +19,9 @@ import {
TYPE_LAYOUT,
TYPE_SOURCE,
TYPE_ORIGIN,
+ IDENTITY_SUBTYPE_PRIMARY,
+ IDENTITY_SUBTYPE_SECONDARY,
+ IDENTITY_SUBTYPE_REFERENCE,
MetaRoot,
MetaObject,
MetaField,
@@ -26,6 +29,9 @@ import {
MetaValidator,
MetaView,
MetaIdentity,
+ MetaPrimaryIdentity,
+ MetaSecondaryIdentity,
+ MetaReferenceIdentity,
MetaRelationship,
MetaLayout,
MetaSource,
@@ -48,11 +54,28 @@ const CTORS: Record = {
[TYPE_ORIGIN]: MetaOrigin,
};
+// identity.reference nodes must be actual MetaReferenceIdentity instances —
+// the loader's IDENTITY_CLASS_MAP (core-types.ts) dispatches on subtype the
+// same way, since resolveRelationshipReference() (#368) reads its
+// subtype-only getters (targetEntity, referencesRaw). A plain MetaIdentity
+// built for subtype "reference" answers `undefined` to those, so identity
+// subtypes get their own dispatch here too rather than falling through to
+// the generic TYPE_IDENTITY entry above.
+const IDENTITY_CTORS: Record = {
+ [IDENTITY_SUBTYPE_PRIMARY]: MetaPrimaryIdentity,
+ [IDENTITY_SUBTYPE_SECONDARY]: MetaSecondaryIdentity,
+ [IDENTITY_SUBTYPE_REFERENCE]: MetaReferenceIdentity,
+};
+
/**
* Build a concrete metadata node from a TypeId + name. Drop-in replacement for
* the removed `new MetaData(typeId, name)` constructor.
*/
export function meta(typeId: TypeId, name = ""): MetaData {
+ if (typeId.type === TYPE_IDENTITY) {
+ const IdentityCtor = IDENTITY_CTORS[typeId.subType] ?? MetaIdentity;
+ return new IdentityCtor(typeId, name);
+ }
const Ctor = CTORS[typeId.type];
if (Ctor === undefined) {
throw new Error(`meta(): no concrete class for type "${typeId.type}"`);
diff --git a/server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts b/server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts
new file mode 100644
index 000000000..8f05745c3
--- /dev/null
+++ b/server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts
@@ -0,0 +1,107 @@
+// #368 — an entity may declare more than one identity.reference onto the SAME
+// target entity (Match -> Team twice, via homeTeamRef/awayTeamRef). REST
+// runtime's resolveRelationDescriptor (relation-resolver.ts) had the same
+// defect Task 4 fixed in codegen: findReferenceFkField(holder, targetName)
+// walked the holder's identity.reference children and returned the FIRST
+// whose @references matched the target, so every relation traversal onto that
+// target read the SAME FK column — one relation's lazy load / include would
+// silently return the wrong rows via the generated REST API.
+//
+// Two cases:
+// - one-side: a relationship declared ON the entity holding both FKs
+// (Match.homeTeam / Match.awayTeam) must each read its own FK.
+// - many-side: the inverse traversal (Team -> Match) resolves the FK that
+// lives on the OTHER entity (Match); that lookup must also disambiguate by
+// the matched relationship, not just take the first identity.reference.
+
+import { describe, expect, test } from "bun:test";
+import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata";
+import { resolveRelationDescriptor } from "../src/relation-resolver.js";
+
+// One-side model: mirrors Task 4's codegen-ts repro exactly (proven to load
+// clean — both relationships name-pair with their own identity.reference).
+const ONE_SIDE_MODEL = {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId" } },
+ { "field.int": { name: "awayTeamId" } },
+ { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ ],
+ },
+};
+
+// Many-side model: same shape, but the identity.reference children are
+// declared in the OPPOSITE order from the relationship children — awayTeamRef
+// appears before homeTeamRef, while the homeTeam relationship (the first
+// cardinality:one relationship on Match targeting Team) still appears first.
+// Name-pairing is order-independent, so this loads exactly as cleanly as
+// ONE_SIDE_MODEL. It exists to separate "first identity.reference
+// structurally" (the #368 defect) from "the identity.reference that actually
+// name-pairs with the matched relationship" (the fix) — with the same
+// declaration order as ONE_SIDE_MODEL the two happen to coincide and the bug
+// would go unnoticed on the many-side path.
+const MANY_SIDE_MODEL = {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId" } },
+ { "field.int": { name: "awayTeamId" } },
+ { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ ] } },
+ ],
+ },
+};
+
+async function load(model: unknown): Promise {
+ const { root, errors } = await new MetaDataLoader().load([
+ new InMemoryStringSource(JSON.stringify(model), { id: "meta.repro.json" }),
+ ]);
+ expect(errors).toEqual([]);
+ return root;
+}
+
+describe("resolveRelationDescriptor with two references onto one target (#368)", () => {
+ test("one-side: each relation reads its own FK field", async () => {
+ const root = await load(ONE_SIDE_MODEL);
+ const match = root.findObject("Match")!;
+ expect(resolveRelationDescriptor(match, "homeTeam", root).sourceField).toBe("homeTeamId");
+ expect(resolveRelationDescriptor(match, "awayTeam", root).sourceField).toBe("awayTeamId"); // was "homeTeamId"
+ });
+
+ test("many-side: the inverse traversal resolves the FK of the matched relationship, not the first reference", async () => {
+ const root = await load(MANY_SIDE_MODEL);
+ const team = root.findObject("Team")!;
+ // Team's only reachable inverse name is "matches" (inversePluralName is
+ // keyed on the OTHER entity's name, not per-relationship), which resolves
+ // to Match's first cardinality:one relationship targeting Team — homeTeam.
+ // Structurally, awayTeamRef is declared before homeTeamRef in Match's
+ // children, so the pre-#368 "first identity.reference" lookup would
+ // return awayTeamId here even though the matched relationship is homeTeam.
+ const desc = resolveRelationDescriptor(team, "matches", root);
+ expect(desc.cardinality).toBe("many");
+ expect(desc.targetEntityName).toBe("Match");
+ expect(desc.targetField).toBe("homeTeamId"); // was "awayTeamId"
+ });
+});
From 710074c1522a371a5f98791c4ded47cb1409157b Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 15:42:25 -0400
Subject: [PATCH 08/31] fix(loader): rule (e) must iterate the effective
relationship set, not 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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../metadata/src/loader/validation-passes.ts | 24 ++++++++--
.../metadata/test/relationship-m2m.test.ts | 47 +++++++++++++++++++
2 files changed, 68 insertions(+), 3 deletions(-)
diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts
index 8b390a261..8d538347b 100644
--- a/server/typescript/packages/metadata/src/loader/validation-passes.ts
+++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts
@@ -2128,14 +2128,32 @@ export function validateRelationships(root: MetaData): ParseError[] {
//
// Registered alongside validateRelationships (the M:N slim-vocabulary pass,
// above) — same deferred-resolution timing (after all files load + extends
-// resolution), same own-relationships-only scope.
+// resolution).
+//
+// Scope differs deliberately from rule (d): rule (d) validates attrs that
+// travel with the relationship's OWN declaration (@through/@symmetric/
+// @sourceRefField), so own-scoping there is correct — those attrs don't
+// change meaning depending on who inherits the relationship. Rule (e)
+// instead validates whether THIS entity's reference set resolves the
+// relationship uniquely, which is a property of the EFFECTIVE entity, not of
+// wherever the relationship happens to be declared. A child entity that
+// extends a clean parent and adds a second identity.reference onto the same
+// target makes an INHERITED relationship ambiguous on the child even though
+// the parent (and the relationship's own declaration) are untouched — own-
+// scoping this pass would leave that case unchecked, and codegen/runtime
+// (which resolve against the effective entity) would silently drop the
+// relation (#368 fix round 2). If a parent and a child are both genuinely
+// ambiguous, both are reported — two entities are broken, not one error
+// duplicated.
// ---------------------------------------------------------------------------
export function validateOneSideReferenceResolution(root: MetaRoot): ParseError[] {
const errors: ParseError[] = [];
for (const obj of root.objects()) {
- // ADR-0039: own — a relationship is validated on the entity that DECLARES it.
- for (const rel of obj.ownChildren().filter((c) => c.type === TYPE_RELATIONSHIP)) {
+ // ADR-0039: resolving — see the scope note above: rule (e) checks THIS
+ // entity's effective reference set against every relationship it can see,
+ // including one only inherited via extends.
+ for (const rel of obj.relationships()) {
// ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends.
if (rel.attr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_ONE) continue;
const objectRef = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF);
diff --git a/server/typescript/packages/metadata/test/relationship-m2m.test.ts b/server/typescript/packages/metadata/test/relationship-m2m.test.ts
index d57adb716..a716082e7 100644
--- a/server/typescript/packages/metadata/test/relationship-m2m.test.ts
+++ b/server/typescript/packages/metadata/test/relationship-m2m.test.ts
@@ -514,4 +514,51 @@ describe("FR-017 Rule (e) — #368 ambiguous 1:N reference resolution", () => {
expect(message).toContain("aRef(tenantId, homeTeamId)");
expect(message).toContain("bRef(tenantId, awayTeamId)");
});
+
+ // Fix round 2: rule (e) must iterate the EFFECTIVE relationship set
+ // (obj.relationships(), own + inherited via extends), not ownChildren().
+ // A entity declares the relationship + a single reference (clean on its
+ // own); B extends A and adds a SECOND reference onto the same target. The
+ // relationship is only inherited on B, so an own-scoped pass would never
+ // examine it there and this would load clean while codegen/runtime, which
+ // resolve against B's effective children, silently drop the relation.
+ test("an inherited relationship becomes ambiguous when a child entity adds a second reference (#368)", async () => {
+ const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "A", children: [
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "homeTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one" } } ] } },
+ { "object.entity": { name: "B", "extends": "A", children: [
+ { "field.long": { name: "awayTeamId" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } } ] } },
+ ] } });
+ expect(codesOf(errors)).toEqual(["ERR_INVALID_RELATIONSHIP"]);
+ const message = errors.map((e) => e.message).join("\n");
+ expect(message).toContain("B.winner");
+ expect(message).toContain("homeTeamRef(homeTeamId)");
+ expect(message).toContain("awayTeamRef(awayTeamId)");
+ });
+
+ test("a child entity's added reference that name-pairs with the inherited relationship loads clean (#368)", async () => {
+ const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "A", children: [
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "homeTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } } ] } },
+ { "object.entity": { name: "B", "extends": "A", children: [
+ { "field.long": { name: "awayTeamId" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } } ] } },
+ ] } });
+ expect(errors).toHaveLength(0);
+ });
});
From c09f4dc59bfa0a8f56124e1e0c53bc11d9df7a4a Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 15:56:17 -0400
Subject: [PATCH 09/31] fix(metadata,codegen-ts,docs): enumerate references
instead of taking the first (#368)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../src/projection/extract-view-spec.ts | 27 ++++-
.../packages/docs-site/src/link-graph.ts | 16 ++-
.../src/core/relationship/find-reference.ts | 45 +++++--
.../typescript/packages/metadata/src/index.ts | 2 +-
.../test/find-reference-ambiguity.test.ts | 113 ++++++++++++++++++
5 files changed, 184 insertions(+), 19 deletions(-)
create mode 100644 server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts
diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
index fb6c8b5e6..131b259d2 100644
--- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
+++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
@@ -47,7 +47,7 @@ import {
IDENTITY_REFERENCE_ATTR_REFERENCES,
FIELD_ATTR_COLUMN,
OBJECT_PROJECTION_ATTR_FILTER,
- findReferenceBetween,
+ findReferencesBetween,
resolveObjectRef,
type AggregateFunction,
} from "@metaobjectsdev/metadata";
@@ -757,7 +757,18 @@ function buildJoinTree(
const target = resolveEntityRef(root, targetName, packageOf(currentObj));
if (!target) break;
- const ref = findReferenceBetween(currentObj as MetaObject, target);
+ // #368: two identity.reference declarations onto the same target are
+ // legal (e.g. Match.homeTeamRef/awayTeamRef -> Team), so the hop
+ // cannot silently take the first — refuse and name the remedy.
+ const refs = findReferencesBetween(currentObj as MetaObject, target);
+ if (refs.length > 1) {
+ throw new Error(
+ `projection join from "${(currentObj as MetaObject).name}" to "${target.name}" is ambiguous: ` +
+ `${refs.map((r) => r.referenceIdentity.name).join(", ")}. ` +
+ `Declare the hop explicitly with @via.`,
+ );
+ }
+ const ref = refs[0];
if (!ref) break;
const fkField = ref.referenceIdentity.fields[0];
@@ -1090,7 +1101,17 @@ function buildSelectSpec(
// The base↔child correlation FK — resolved exactly as buildJoinTree resolves a
// single hop (the identity.reference is the FK-direction SSOT). Single-hop @via;
// a multi-hop @via on origin.first is not lowered here (rare, and validated away).
- const ref = findReferenceBetween(base, childEntity);
+ // #368: two identity.reference declarations onto the same target are legal, so
+ // this correlation cannot silently take the first — refuse and name the remedy.
+ const refs = findReferencesBetween(base, childEntity);
+ if (refs.length > 1) {
+ throw new Error(
+ `origin.first correlation from "${base.name}" to "${childEntity.name}" is ambiguous: ` +
+ `${refs.map((r) => r.referenceIdentity.name).join(", ")}. ` +
+ `Declare the hop explicitly with @via.`,
+ );
+ }
+ const ref = refs[0];
if (!ref) continue;
const fkField = ref.referenceIdentity.fields[0];
if (!fkField) continue;
diff --git a/server/typescript/packages/docs-site/src/link-graph.ts b/server/typescript/packages/docs-site/src/link-graph.ts
index f4630137e..fcfaacb02 100644
--- a/server/typescript/packages/docs-site/src/link-graph.ts
+++ b/server/typescript/packages/docs-site/src/link-graph.ts
@@ -1,5 +1,5 @@
import type { MetaData, MetaObject, MetaRelationship } from "@metaobjectsdev/metadata";
-import { deriveM2MFields, stripPackage } from "@metaobjectsdev/metadata";
+import { deriveM2MFields, resolveRelationshipReference, stripPackage } from "@metaobjectsdev/metadata";
import { type LoadedModel, treeOf } from "./load.js";
export interface DocNode { kind: "object" | "prompt" | "output"; name: string; pkg: string; pkgPath: string; href: string; node: MetaData; tree: string; }
@@ -99,10 +99,18 @@ export class LinkGraph {
addM2mEdge(fqn, to, rel, obj, dn.pkg, onDelete, subtype); // Task 3 helper
continue;
}
- // belongs-to (1:N, one) — find the matching identity.reference to dedupe (mirrors
- // relation-resolver: first reference whose target matches, package-stripped).
+ // belongs-to (1:N, one) — resolve the identity.reference this relationship
+ // actually navigates, so the FK loop below can dedupe the edge it supersedes.
+ // #368: an entity may declare more than one reference onto the same target
+ // (e.g. Match.homeTeamRef/awayTeamRef -> Team), so a bare "target matches"
+ // `.find()` picked the same candidate for every same-target relationship —
+ // resolveRelationshipReference is the SSOT ladder (unique candidate,
+ // @sourceRefField, or unique name-pairing) that relation-resolver itself now
+ // uses; ambiguous cases resolve to undefined and simply leave every candidate
+ // reference to draw its own "fk" edge, which is the safe (extra-info, not
+ // missing-edge) outcome for a docs graph.
const target = stripPackage(objectRef);
- const match = obj.referenceIdentities().find((r) => stripPackage(r.targetEntity ?? "") === target);
+ const match = resolveRelationshipReference(obj, rel.name, target, rel.sourceRefField);
const fkField = match?.fields?.[0];
if (fkField) coveredFk.add(`${to}::${fkField}`);
addRef({ from: fqn, to, via: rel.name, kind: "relationship", cardinality, onDelete, subtype });
diff --git a/server/typescript/packages/metadata/src/core/relationship/find-reference.ts b/server/typescript/packages/metadata/src/core/relationship/find-reference.ts
index 1d9ce1c12..c85eb3efd 100644
--- a/server/typescript/packages/metadata/src/core/relationship/find-reference.ts
+++ b/server/typescript/packages/metadata/src/core/relationship/find-reference.ts
@@ -20,25 +20,48 @@ export interface ReferenceLookup {
}
/**
- * Find an identity.reference on either `a` or `b` whose @references targets the
- * other side. Returns undefined if neither side declares one.
+ * Every identity.reference on `a` or `b` whose @references targets the other
+ * side, `a` walked first. Comparison is package-insensitive: `@references` may
+ * be a bare entity name ("User") or a fully-qualified one ("pkg::User"); both
+ * match `other.name`.
*
- * Comparison is package-insensitive: `@references` may be a bare entity name
- * ("User") or a fully-qualified one ("pkg::User"); both match `other.name`.
- *
- * If both sides declare references targeting each other (rare, but legal for
- * mutual 1:1), returns the first found, walking `a` first.
+ * #368: an entity may legally declare more than one identity.reference onto
+ * the same target (e.g. Match.homeTeamRef and Match.awayTeamRef both -> Team).
+ * Callers that must not silently guess which one is meant enumerate here and
+ * report the ambiguity themselves; see also `findReferenceBetween`, which
+ * keeps the historical first-match contract for callers that legitimately
+ * expect at most one reference between the pair.
*/
-export function findReferenceBetween(
+export function findReferencesBetween(
a: MetaObject,
b: MetaObject,
-): ReferenceLookup | undefined {
+): ReferenceLookup[] {
+ const found: ReferenceLookup[] = [];
for (const [holder, other] of [[a, b], [b, a]] as const) {
for (const ref of holder.referenceIdentities()) {
if (stripPackage(ref.targetEntity) === other.name) {
- return { holder, other, referenceIdentity: ref };
+ found.push({ holder, other, referenceIdentity: ref });
}
}
}
- return undefined;
+ return found;
+}
+
+/**
+ * Find an identity.reference on either `a` or `b` whose @references targets the
+ * other side. Returns undefined if neither side declares one.
+ *
+ * Delegates to `findReferencesBetween` and returns its first entry — kept for
+ * backward compatibility (this is exported public API) and for the mutual-1:1
+ * case where both sides declare references targeting each other. #368: two
+ * references onto the SAME target are also legal and indistinguishable from
+ * here — a caller that must not guess which one is meant should call
+ * `findReferencesBetween` directly and handle ambiguity explicitly rather than
+ * relying on this function's first-match behaviour.
+ */
+export function findReferenceBetween(
+ a: MetaObject,
+ b: MetaObject,
+): ReferenceLookup | undefined {
+ return findReferencesBetween(a, b)[0];
}
diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts
index f6255f5ad..ba674d705 100644
--- a/server/typescript/packages/metadata/src/index.ts
+++ b/server/typescript/packages/metadata/src/index.ts
@@ -114,7 +114,7 @@ export type { IdentityPassthroughResolution } from "./core/identity/validate-ide
// Relationship
export { MetaRelationship } from "./core/relationship/meta-relationship.js";
// Cross-entity reference lookup
-export { findReferenceBetween } from "./core/relationship/find-reference.js";
+export { findReferenceBetween, findReferencesBetween } from "./core/relationship/find-reference.js";
export type { ReferenceLookup } from "./core/relationship/find-reference.js";
export {
referenceCandidatesFor,
diff --git a/server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts b/server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts
new file mode 100644
index 000000000..fbafa1ce5
--- /dev/null
+++ b/server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, test } from "bun:test";
+import { MetaDataLoader } from "../src/loader/meta-data-loader.js";
+import { InMemoryStringSource } from "../src/loader/meta-data-source.js";
+import { findReferenceBetween, findReferencesBetween } from "../src/core/relationship/find-reference.js";
+import type { MetaObject } from "../src/core/object/meta-object.js";
+
+// #368 — Match declares two identity.reference children onto the same target
+// (Team), with no relationship at all. That's legal (task 3's loader rule only
+// gates a `@cardinality: one` relationship, not a bare reference pair), so it
+// is exactly the model findReferencesBetween exists to enumerate honestly.
+const MODEL = {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ {
+ "object.entity": {
+ name: "Team",
+ children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ],
+ },
+ },
+ {
+ "object.entity": {
+ name: "Match",
+ children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId" } },
+ { "field.int": { name: "awayTeamId" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ ],
+ },
+ },
+ ],
+ },
+};
+
+describe("findReferencesBetween (#368)", () => {
+ test("returns every reference, not just the first", async () => {
+ const { root, errors } = await new MetaDataLoader().load([
+ new InMemoryStringSource(JSON.stringify(MODEL), { id: "meta.repro.json" }),
+ ]);
+ expect(errors).toEqual([]);
+ const match = root.findObject("Match")! as MetaObject;
+ const team = root.findObject("Team")! as MetaObject;
+
+ const all = findReferencesBetween(match, team);
+ expect(all.map((r) => r.referenceIdentity.name)).toEqual(["homeTeamRef", "awayTeamRef"]);
+
+ // Back-compat: the singular still answers with the first — this is the
+ // documented contract public consumers (outside this repo) still get.
+ expect(findReferenceBetween(match, team)?.referenceIdentity.name).toBe("homeTeamRef");
+ });
+
+ test("singular delegates to the plural's first entry even when there is only one match", async () => {
+ const SINGLE = {
+ "metadata.root": {
+ package: "repro2",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "teamId" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "teamRef", "@fields": ["teamId"], "@references": "Team" } },
+ ] } },
+ ],
+ },
+ };
+ const { root, errors } = await new MetaDataLoader().load([
+ new InMemoryStringSource(JSON.stringify(SINGLE), { id: "meta.repro2.json" }),
+ ]);
+ expect(errors).toEqual([]);
+ const match = root.findObject("Match")! as MetaObject;
+ const team = root.findObject("Team")! as MetaObject;
+
+ expect(findReferencesBetween(match, team).map((r) => r.referenceIdentity.name)).toEqual(["teamRef"]);
+ expect(findReferenceBetween(match, team)?.referenceIdentity.name).toBe("teamRef");
+ });
+
+ test("returns empty/undefined when neither side references the other", async () => {
+ const NONE = {
+ "metadata.root": {
+ package: "repro3",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Stadium", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ ],
+ },
+ };
+ const { root, errors } = await new MetaDataLoader().load([
+ new InMemoryStringSource(JSON.stringify(NONE), { id: "meta.repro3.json" }),
+ ]);
+ expect(errors).toEqual([]);
+ const team = root.findObject("Team")! as MetaObject;
+ const stadium = root.findObject("Stadium")! as MetaObject;
+
+ expect(findReferencesBetween(team, stadium)).toEqual([]);
+ expect(findReferenceBetween(team, stadium)).toBeUndefined();
+ });
+});
From d49c3d921cda06030e123cde9a80953835880af0 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 16:19:16 -0400
Subject: [PATCH 10/31] fix(loader): rule (d) must also iterate the effective
relationship set, 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 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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../metadata/src/loader/validation-passes.ts | 78 ++++++++++++++-----
.../metadata/test/relationship-m2m.test.ts | 38 +++++++++
2 files changed, 96 insertions(+), 20 deletions(-)
diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts
index 8d538347b..0d91f0f4e 100644
--- a/server/typescript/packages/metadata/src/loader/validation-passes.ts
+++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts
@@ -1949,8 +1949,23 @@ export function validateDataGridFilterValues(root: MetaData): ParseError[] {
// (#368: it then names which of several identity.reference nodes onto
// the same target the relationship navigates).
//
-// Own-relationships only: a relationship is validated on the entity that declares
-// it (matching the own-attrs policy of the other passes).
+// ADR-0039: resolving, not own-only (#368 fix round 3) — every rule above
+// validates a property of the relationship's OWN declaration (its attrs,
+// plus for rule (c) the @through target resolved against the root), so a
+// relationship inherited via extends must be validated wherever it's
+// visible, or a child entity that only SEES the relationship through
+// inheritance could carry a violation no pass ever examines. That makes an
+// inherited, UNMODIFIED relationship visited once per inheriting entity —
+// checked once below (keyed on the relationship node's own identity), not
+// once per entity, using the DECLARING entity (rel.parent) rather than
+// whichever entity's effective view got there first for every piece of
+// context a rule reads (message text, @through's package per ADR-0042, rule
+// (a)'s self-join comparison). That keeps each check's result independent of
+// which entity triggered it, which is what makes "checked once" both
+// sufficient and correct. An override replaces the node in place
+// (MetaData._effectiveChildren), so it is never the same object as what it
+// overrides and is never skipped against it — a genuinely different
+// declaration is always independently checked.
// ---------------------------------------------------------------------------
// The junction's reference view: the validator and the runtime/codegen FK
@@ -1978,14 +1993,36 @@ function _countJunctionReferences(junction: MetaData): number {
export function validateRelationships(root: MetaData): ParseError[] {
const errors: ParseError[] = [];
+ // #368 fix round 3 — the outer loop below is now resolving (obj.relationships()),
+ // so a relationship inherited unmodified by N entities is reached N times. Its
+ // own attrs never change based on who inherits it, so re-validating it more
+ // than once would report the identical finding N times — pure noise. Checked
+ // is keyed on the relationship NODE's own object identity: MetaData._effectiveChildren
+ // reuses the super's child object in place for an unmodified inherited child,
+ // so the same physical declaration IS the same object everywhere it's visible,
+ // while an override replaces it with a genuinely different object (correctly
+ // NOT skipped — a distinct declaration is a distinct finding).
+ const checked = new Set();
// ADR-0039: root has no super; children()==ownChildren() but resolving is the default.
for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) {
- // ADR-0042 — a bare @through resolves in the declaring entity's package.
- const referrerPkg = obj.package ?? obj.fileDefaultPackage ?? "";
- // ADR-0039: own — a relationship is validated on the entity that DECLARES it
- // (the M:N slim-vocabulary rules apply to own-declared relationships; its
- // inheritable attrs are read resolving below).
- for (const rel of obj.ownChildren().filter((c) => c.type === TYPE_RELATIONSHIP)) {
+ // ADR-0039: resolving — see the header comment above: rule (d) (like rule
+ // (e)) must see a relationship inherited via extends, not just this
+ // entity's own declarations. `checked` absorbs the resulting revisits.
+ for (const rel of (obj as MetaObject).relationships()) {
+ if (checked.has(rel)) continue;
+ checked.add(rel);
+
+ // Every rule below validates a property of the relationship's OWN
+ // declaration (its attrs, plus for rule (c) the @through target), so
+ // context — the entity name in messages, the package a bare @through
+ // resolves in (ADR-0042), and rule (a)'s self-join comparison — is
+ // always the entity that DECLARES `rel` (rel.parent), never `obj` (the
+ // entity whose effective view happened to reach it first). This keeps
+ // the check's result independent of iteration order/inheritance depth,
+ // which is what makes checking each node exactly once correct.
+ const declaringEntity = rel.parent ?? obj;
+ const referrerPkg = declaringEntity.package ?? declaringEntity.fileDefaultPackage ?? "";
+
// ADR-0039: resolving — a relationship may inherit its M:N attrs via extends.
const through = rel.attr(RELATIONSHIP_ATTR_THROUGH);
const sourceRefField = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD);
@@ -2007,7 +2044,7 @@ export function validateRelationships(root: MetaData): ParseError[] {
if (hasThrough) {
errors.push(
new ParseError(
- `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_THROUGH} but is not a M:N ` +
+ `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_THROUGH} but is not a M:N ` +
`relationship (requires @${RELATIONSHIP_ATTR_CARDINALITY}: "${CARDINALITY_MANY}").`,
{ code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
),
@@ -2021,7 +2058,7 @@ export function validateRelationships(root: MetaData): ParseError[] {
if (hasSourceRefField && cardinality !== CARDINALITY_ONE) {
errors.push(
new ParseError(
- `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.`,
+ `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.`,
{ code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
),
);
@@ -2029,7 +2066,7 @@ export function validateRelationships(root: MetaData): ParseError[] {
if (symmetric) {
errors.push(
new ParseError(
- `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.`,
+ `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.`,
{ code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
),
);
@@ -2041,7 +2078,7 @@ export function validateRelationships(root: MetaData): ParseError[] {
if (symmetric && hasSourceRefField) {
errors.push(
new ParseError(
- `relationship "${obj.name}.${rel.name}" sets both @${RELATIONSHIP_ATTR_SYMMETRIC} and ` +
+ `relationship "${declaringEntity.name}.${rel.name}" sets both @${RELATIONSHIP_ATTR_SYMMETRIC} and ` +
`@${RELATIONSHIP_ATTR_SOURCE_REF_FIELD}; they are mutually exclusive.`,
{ code: "ERR_BAD_ATTR_VALUE", source: rel.source },
),
@@ -2053,12 +2090,13 @@ export function validateRelationships(root: MetaData): ParseError[] {
// in this package is self, but an FQN "other::Widget" (a different same-short-
// name entity) is NOT (comparing stripped short names would misclassify it).
const isSelfJoin =
- typeof objectRef === "string" && resolveObjectRef(root, objectRef, referrerPkg).node === obj;
+ typeof objectRef === "string" &&
+ resolveObjectRef(root, objectRef, referrerPkg).node === declaringEntity;
if (symmetric && !isSelfJoin) {
errors.push(
new ParseError(
- `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SYMMETRIC} but @${RELATIONSHIP_ATTR_OBJECT_REF} ` +
- `"${String(objectRef)}" is not the declaring entity "${obj.name}"; @${RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.`,
+ `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SYMMETRIC} but @${RELATIONSHIP_ATTR_OBJECT_REF} ` +
+ `"${String(objectRef)}" is not the declaring entity "${declaringEntity.name}"; @${RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.`,
{ code: "ERR_BAD_ATTR_VALUE", source: rel.source },
),
);
@@ -2069,8 +2107,8 @@ export function validateRelationships(root: MetaData): ParseError[] {
if (!junction) {
errors.push(
new ParseError(
- `relationship "${obj.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" does not resolve to an entity.${didYouMeanHint(root, String(through))}`,
- { code: "ERR_INVALID_RELATIONSHIP", source: resolvedSource(rel.source, `${obj.fqn()}::${rel.name}`, String(through)) },
+ `relationship "${declaringEntity.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" does not resolve to an entity.${didYouMeanHint(root, String(through))}`,
+ { code: "ERR_INVALID_RELATIONSHIP", source: resolvedSource(rel.source, `${declaringEntity.fqn()}::${rel.name}`, String(through)) },
),
);
continue;
@@ -2082,7 +2120,7 @@ export function validateRelationships(root: MetaData): ParseError[] {
if (junction.subType !== OBJECT_SUBTYPE_ENTITY) {
errors.push(
new ParseError(
- `relationship "${obj.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" resolves to ` +
+ `relationship "${declaringEntity.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" resolves to ` +
`${junction.type}.${junction.subType}, not an entity — a junction is a persisted join table ` +
`and must be object.entity.`,
{ code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
@@ -2094,7 +2132,7 @@ export function validateRelationships(root: MetaData): ParseError[] {
if (refCount !== 2) {
errors.push(
new ParseError(
- `relationship "${obj.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" must declare exactly two ` +
+ `relationship "${declaringEntity.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" must declare exactly two ` +
`identity.reference children (one per FK side); found ${refCount}.`,
{ code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
),
@@ -2107,7 +2145,7 @@ export function validateRelationships(root: MetaData): ParseError[] {
if (!fkFields.includes(sourceRefField as string)) {
errors.push(
new ParseError(
- `relationship "${obj.name}.${rel.name}" @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} "${sourceRefField}" does not match ` +
+ `relationship "${declaringEntity.name}.${rel.name}" @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} "${sourceRefField}" does not match ` +
`any identity.reference FK field on junction "${through}". Available: ${fkFields.join(", ") || "(none)"}.`,
{ code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
),
diff --git a/server/typescript/packages/metadata/test/relationship-m2m.test.ts b/server/typescript/packages/metadata/test/relationship-m2m.test.ts
index a716082e7..6b8f7acc1 100644
--- a/server/typescript/packages/metadata/test/relationship-m2m.test.ts
+++ b/server/typescript/packages/metadata/test/relationship-m2m.test.ts
@@ -561,4 +561,42 @@ describe("FR-017 Rule (e) — #368 ambiguous 1:N reference resolution", () => {
] } });
expect(errors).toHaveLength(0);
});
+
+ // Fix round 3: validateRelationships (rule (d), the M:N slim-vocabulary
+ // pass) also switched to the resolving relationship set for ADR-0039
+ // compliance. Unlike rule (e), rule (d)'s checks read only the
+ // relationship's own attrs, so an inherited, UNMODIFIED relationship must
+ // be reported exactly once no matter how many entities inherit it — this
+ // is the dedup guard, not a "different entity, different finding" case.
+ test("an inherited rule-(d) violation is reported once, not once per inheriting entity (#368)", async () => {
+ const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [
+ { "object.entity": { name: "Program", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "A", children: [
+ { "field.long": { name: "id" } },
+ { "relationship.composition": { name: "program", "@objectRef": "Program",
+ "@cardinality": "one", "@through": "X" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "B", "extends": "A" } },
+ ] } });
+ expect(codesOf(errors)).toEqual(["ERR_INVALID_RELATIONSHIP"]);
+ });
+
+ test("an inherited rule-(d) violation stays a single error across several inheriting children (#368)", async () => {
+ const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [
+ { "object.entity": { name: "Program", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "A", children: [
+ { "field.long": { name: "id" } },
+ { "relationship.composition": { name: "program", "@objectRef": "Program",
+ "@cardinality": "one", "@through": "X" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "B", "extends": "A" } },
+ { "object.entity": { name: "C", "extends": "A" } },
+ { "object.entity": { name: "D", "extends": "A" } },
+ ] } });
+ expect(codesOf(errors)).toEqual(["ERR_INVALID_RELATIONSHIP"]);
+ });
});
From b1afdd9ef29b5127f2df6284e60336ec9b4e54b6 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 16:33:07 -0400
Subject: [PATCH 11/31] fix(codegen-ts): stop discarding the @via hop's own
name before the #368 ambiguity check (fix round 1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../src/projection/extract-view-spec.ts | 87 +++++++--
.../projection/reference-ambiguity.test.ts | 182 ++++++++++++++++++
.../fixture/input/repro368/meta.repro368.yaml | 25 +++
.../docs-site/test/link-graph.test.ts | 18 ++
4 files changed, 297 insertions(+), 15 deletions(-)
create mode 100644 server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts
create mode 100644 server/typescript/packages/docs-site/test/fixture/input/repro368/meta.repro368.yaml
diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
index 131b259d2..c0bd13bc1 100644
--- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
+++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
@@ -42,16 +42,19 @@ import {
SORT_ORDER_DESC,
RELATIONSHIP_ATTR_OBJECT_REF,
RELATIONSHIP_ATTR_CARDINALITY,
+ RELATIONSHIP_ATTR_SOURCE_REF_FIELD,
CARDINALITY_ONE,
IDENTITY_SUBTYPE_REFERENCE,
IDENTITY_REFERENCE_ATTR_REFERENCES,
FIELD_ATTR_COLUMN,
OBJECT_PROJECTION_ATTR_FILTER,
findReferencesBetween,
+ resolveRelationshipReference,
resolveObjectRef,
type AggregateFunction,
+ type ReferenceLookup,
} from "@metaobjectsdev/metadata";
-import { type MetaData, type MetaField, type MetaRoot, type MetaSource, MetaObject } from "@metaobjectsdev/metadata";
+import { type MetaData, type MetaField, type MetaReferenceIdentity, type MetaRoot, type MetaSource, MetaObject } from "@metaobjectsdev/metadata";
import { intValueMapOf } from "../enum-meta.js";
import {
columnNameFromField,
@@ -330,6 +333,43 @@ function resolveHop(
return undefined;
}
+/**
+ * #368: which identity.reference does a resolved `@via` hop actually mean?
+ *
+ * `resolveHop` already found the EXACT node the hop segment named — a
+ * reference hop names the reference itself (nothing to disambiguate: two
+ * references onto the same target can coexist, but the hop picked one of them
+ * by name), and a relationship hop names a relationship whose backing
+ * reference `resolveRelationshipReference` resolves via the same SSOT ladder
+ * (unique candidate -> @sourceRefField -> unique name-pairing) that
+ * relation-resolver.ts already uses for the identical question elsewhere.
+ * Discarding `hop` and re-deriving purely from `holder`/`target` — as this
+ * file did before — throws away that specificity and reintroduces the exact
+ * ambiguity a named hop exists to resolve.
+ *
+ * Returns a single `ReferenceLookup` once resolved unambiguously, or the full
+ * candidate list when even the ladder cannot choose (a relationship hop whose
+ * name pairs with none of its candidates and no `@sourceRefField`) — the
+ * caller reports that list as a genuine ambiguity.
+ */
+function resolveHopReference(
+ holder: MetaObject,
+ hop: MetaData,
+ hopName: string,
+ target: MetaObject,
+): ReferenceLookup | ReferenceLookup[] {
+ if (hop.type === TYPE_IDENTITY && hop.subType === IDENTITY_SUBTYPE_REFERENCE) {
+ return { holder, other: target, referenceIdentity: hop as unknown as MetaReferenceIdentity };
+ }
+ // ADR-0039: resolving — @sourceRefField may be inherited via extends.
+ const sourceRefField = hop.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
+ const matching = resolveRelationshipReference(holder, hopName, target.name, sourceRefField);
+ if (matching) {
+ return { holder, other: target, referenceIdentity: matching };
+ }
+ return findReferencesBetween(holder, target);
+}
+
function viewName(projection: MetaObject, ctx: ExtractContext): string {
// The read-only source carries the physical view name. FR-016: physicalName
// implements the four-step rule (kind-matching alias → legacy @table →
@@ -750,25 +790,33 @@ function buildJoinTree(
// a traversed relationship/reference may inherit its target via extends.
const resolved = resolveHop(currentObj, relName);
if (!resolved) break;
- const { targetName, cardinality } = resolved;
+ const { hop, targetName, cardinality } = resolved;
// @objectRef/@references may be package-qualified ("pkg::Entity"); resolve it
// package-aware relative to the hop's source entity (the loader qualifies a
// same-package ref even when authored bare), so the join binds the exact target.
const target = resolveEntityRef(root, targetName, packageOf(currentObj));
if (!target) break;
- // #368: two identity.reference declarations onto the same target are
- // legal (e.g. Match.homeTeamRef/awayTeamRef -> Team), so the hop
- // cannot silently take the first — refuse and name the remedy.
- const refs = findReferencesBetween(currentObj as MetaObject, target);
- if (refs.length > 1) {
- throw new Error(
- `projection join from "${(currentObj as MetaObject).name}" to "${target.name}" is ambiguous: ` +
- `${refs.map((r) => r.referenceIdentity.name).join(", ")}. ` +
- `Declare the hop explicitly with @via.`,
- );
+ // #368: two identity.reference declarations onto the same target are legal
+ // (e.g. Match.homeTeamRef/awayTeamRef -> Team) — resolveHopReference prefers
+ // the SPECIFIC reference/relationship the hop already named over re-deriving
+ // one from the target alone, so an explicit `@via: "Match.homeTeamRef"` (or a
+ // relationship disambiguated by @sourceRefField/name-pairing) resolves cleanly.
+ // Only a relationship hop that even the ladder cannot choose reaches the throw.
+ const resolvedRef = resolveHopReference(currentObj as MetaObject, hop, relName, target);
+ let ref: ReferenceLookup | undefined;
+ if (Array.isArray(resolvedRef)) {
+ if (resolvedRef.length > 1) {
+ throw new Error(
+ `projection join hop "${relName}" from "${(currentObj as MetaObject).name}" to "${target.name}" is ambiguous: ` +
+ `${resolvedRef.map((r) => r.referenceIdentity.name).join(", ")}. ` +
+ `Declare @sourceRefField on the relationship, or name the identity.reference directly in @via.`,
+ );
+ }
+ ref = resolvedRef[0];
+ } else {
+ ref = resolvedRef;
}
- const ref = refs[0];
if (!ref) break;
const fkField = ref.referenceIdentity.fields[0];
@@ -1102,13 +1150,22 @@ function buildSelectSpec(
// single hop (the identity.reference is the FK-direction SSOT). Single-hop @via;
// a multi-hop @via on origin.first is not lowered here (rare, and validated away).
// #368: two identity.reference declarations onto the same target are legal, so
- // this correlation cannot silently take the first — refuse and name the remedy.
+ // this correlation cannot silently take the first — refuse rather than guess.
+ // Unlike buildJoinTree's @via hop (a named relationship/reference this file can
+ // resolve via resolveHopReference), origin.first's OWN @via (ORIGIN_FIRST_ATTR_VIA)
+ // is never read anywhere in this file — buildJoinTree explicitly skips it
+ // (ORIGIN_SUBTYPE_FIRST falls through to `continue` there) and this branch derives
+ // childEntity from @of alone, so there is no hop name here to prefer. Fixing that is
+ // a separate, larger change (wiring @via/single-hop-unique inference into this
+ // branch to match _validateViaPath/_inferViaSingleHop) — out of scope for #368's
+ // silent-first-match fix; the message below reflects the real, narrower remedy.
const refs = findReferencesBetween(base, childEntity);
if (refs.length > 1) {
throw new Error(
`origin.first correlation from "${base.name}" to "${childEntity.name}" is ambiguous: ` +
`${refs.map((r) => r.referenceIdentity.name).join(", ")}. ` +
- `Declare the hop explicitly with @via.`,
+ `origin.first's own @via is not consulted for this correlation — reduce to a ` +
+ `single identity.reference between these two entities.`,
);
}
const ref = refs[0];
diff --git a/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts b/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts
new file mode 100644
index 000000000..196974b47
--- /dev/null
+++ b/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts
@@ -0,0 +1,182 @@
+import { describe, test, expect } from "bun:test";
+import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
+import { extractViewSpec } from "../../src/projection/extract-view-spec.js";
+
+// #368 — an entity may legally declare more than one identity.reference onto
+// the same target (e.g. Match.homeTeamRef/awayTeamRef both -> Team). The
+// loader's ambiguity gate (validateOneSideReferenceResolution) only fires for
+// a `@cardinality: one` relationship node — a bare identity.reference pair or
+// an origin.first correlation reaches codegen unvalidated, so extract-view-spec
+// itself must refuse to silently guess. These tests are the regression
+// coverage: a fix-round-1 review found the first cut of the throw was blind to
+// an explicit @via hop's own name (it re-derived candidates by target entity
+// alone even when the hop already named an exact reference or a disambiguated
+// relationship), which made the throw's own suggested remedy impossible to
+// satisfy. Test 1 is that regression test.
+async function load(children: unknown[]) {
+ const json = JSON.stringify({ "metadata.root": { package: "test", children } });
+ const result = await new MetaDataLoader().load([new InMemoryStringSource(json)]);
+ if (result.errors.length > 0) {
+ throw new Error(`Loader errors:\n${result.errors.map((e) => e.message).join("\n")}`);
+ }
+ return result.root;
+}
+
+const TEAM = {
+ "object.entity": {
+ name: "Team",
+ children: [
+ { "source.rdb": { "@table": "teams" } },
+ { "field.int": { name: "id" } },
+ { "field.string": { name: "name" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ // Lets the loader's single-hop-@via inference succeed for origin.first
+ // below (exactly one relationship Team -> Match) — it does not affect
+ // the reference-count ambiguity check, which reads identity.reference
+ // declarations directly and ignores relationships entirely.
+ { "relationship.association": { name: "matches", "@objectRef": "Match", "@cardinality": "many" } },
+ ],
+ },
+};
+
+function matchEntity(extraChildren: unknown[] = []) {
+ return {
+ "object.entity": {
+ name: "Match",
+ children: [
+ { "source.rdb": { "@table": "matches" } },
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "homeTeamId" } },
+ { "field.int": { name: "awayTeamId" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": "homeTeamId", "@references": "Team" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": "awayTeamId", "@references": "Team" } },
+ ...extraChildren,
+ ],
+ },
+ };
+}
+
+describe("extractViewSpec — reference ambiguity (#368)", () => {
+ test("an explicit @via naming one of two references to the same target resolves to THAT one, not the first", async () => {
+ // Regression test for fix-round-1 finding 1: before the fix, this threw
+ // "ambiguous: homeTeamRef, awayTeamRef" even though the author already
+ // disambiguated by naming the exact reference in @via.
+ const root = await load([
+ TEAM,
+ matchEntity(),
+ {
+ "object.projection": {
+ name: "MatchView",
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_match" } },
+ { "field.int": { name: "id", extends: "Match.id" } },
+ { "identity.primary": { name: "id", extends: "Match.id" } },
+ {
+ "field.string": {
+ name: "away_team_name",
+ children: [
+ {
+ "origin.passthrough": {
+ "@from": "Team.name",
+ // Names the exact reference, not the ambiguous target alone.
+ "@via": "Match.awayTeamRef",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ]);
+
+ const projection = root.objects().find((o) => o.name === "MatchView")!;
+ const spec = extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" });
+
+ expect(spec.joinTree.joins.length).toBe(1);
+ const join = spec.joinTree.joins[0]!;
+ expect(join.relationship).toBe("awayTeamRef");
+ expect(join.targetEntity).toBe("test::Team");
+ // The disambiguating proof: the FK column matches the NAMED reference
+ // (away_team_id), never the declaration-order-first one (home_team_id).
+ expect(join.fkColumn).toBe("away_team_id");
+
+ const awayTeamName = spec.selectSpec.columns.find((c) => c.fieldName === "away_team_name");
+ expect(awayTeamName).toBeDefined();
+ });
+
+ test("a cardinality:many relationship hop whose candidates live on the target side throws naming both", async () => {
+ // A `@cardinality: "one"` relationship with an unresolvable candidate set
+ // is rejected by the LOADER itself (Task 3's validateOneSideReferenceResolution
+ // rule (e)) before codegen ever runs — so it cannot be used to exercise this
+ // throw. `@cardinality: "many"` is NOT gated by that rule (finding 2), so
+ // Team.matches loads fine even though Match holds two references back to
+ // Team. resolveRelationshipReference correctly reports zero candidates on
+ // the holder (Team) side — the FK physically lives on Match — and
+ // resolveHopReference falls back to findReferencesBetween, which walks
+ // both sides and finds both.
+ const root = await load([
+ TEAM,
+ matchEntity(),
+ {
+ "object.projection": {
+ name: "TeamSummary",
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_team_summary" } },
+ { "field.int": { name: "id", extends: "Team.id" } },
+ { "identity.primary": { name: "id", extends: "Team.id" } },
+ {
+ "field.int": {
+ name: "matchCount",
+ children: [
+ { "origin.aggregate": { "@agg": "count", "@of": "Match.id", "@via": "Team.matches" } },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ]);
+
+ const projection = root.objects().find((o) => o.name === "TeamSummary")!;
+ expect(() => extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" })).toThrow(
+ /projection join hop "matches".*is ambiguous:.*homeTeamRef.*awayTeamRef/s,
+ );
+ });
+
+ test("origin.first correlation with two references and no relationship throws naming both", async () => {
+ const root = await load([
+ TEAM,
+ matchEntity(),
+ {
+ "object.projection": {
+ name: "TeamSummary",
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_team_summary" } },
+ { "field.int": { name: "id", extends: "Team.id" } },
+ { "identity.primary": { name: "id", extends: "Team.id" } },
+ {
+ "field.int": {
+ name: "lastMatchId",
+ children: [
+ {
+ "origin.first": {
+ "@of": "Match.id",
+ "@orderBy": ["id:desc"],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ]);
+
+ const projection = root.objects().find((o) => o.name === "TeamSummary")!;
+ expect(() => extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" })).toThrow(
+ /origin\.first correlation from "Team" to "Match" is ambiguous:.*homeTeamRef.*awayTeamRef/s,
+ );
+ });
+});
diff --git a/server/typescript/packages/docs-site/test/fixture/input/repro368/meta.repro368.yaml b/server/typescript/packages/docs-site/test/fixture/input/repro368/meta.repro368.yaml
new file mode 100644
index 000000000..c3f1d9230
--- /dev/null
+++ b/server/typescript/packages/docs-site/test/fixture/input/repro368/meta.repro368.yaml
@@ -0,0 +1,25 @@
+# #368 fixture — an entity with TWO identity.reference declarations onto the
+# SAME target, one backed by a named relationship and one bare. Used only by
+# link-graph.test.ts's own-fixture ambiguity test; deliberately kept out of
+# the acme/ fixture tree so it never has to be reconciled against golden.test.ts's
+# full-site snapshot.
+metadata:
+ package: repro368
+ children:
+ - object.entity:
+ name: Team
+ children:
+ - field.int: { name: id }
+ - field.string: { name: name }
+ - identity.primary: { name: id, "@fields": id }
+ - object.entity:
+ name: Match
+ children:
+ - field.int: { name: id }
+ - field.int: { name: homeTeamId }
+ - field.int: { name: awayTeamId }
+ - identity.primary: { name: id, "@fields": id }
+ - identity.reference: { name: homeTeamRef, fields: homeTeamId, references: Team }
+ - identity.reference: { name: awayTeamRef, fields: awayTeamId, references: Team }
+ - relationship.association: { name: homeTeam, "@objectRef": Team, cardinality: one }
+ - relationship.association: { name: awayTeam, "@objectRef": Team, cardinality: one }
diff --git a/server/typescript/packages/docs-site/test/link-graph.test.ts b/server/typescript/packages/docs-site/test/link-graph.test.ts
index 46a0d1877..1672ef12d 100644
--- a/server/typescript/packages/docs-site/test/link-graph.test.ts
+++ b/server/typescript/packages/docs-site/test/link-graph.test.ts
@@ -84,3 +84,21 @@ test("symmetric self-join (@symmetric) is flagged symmetric", async () => {
expect(e!.through).toBe("acme::shop::CustomerFriend");
expect(e!.symmetric).toBe(true);
});
+
+// #368 — Match declares TWO identity.reference children onto the SAME target
+// (Team), each backed by its own named `@cardinality: "one"` relationship
+// (homeTeam/awayTeam). Before fix-round-1, the belongs-to dedup pass used
+// `.find()` to guess which reference a relationship navigates, so BOTH
+// relationships resolved to the same (first) candidate — the second reference
+// (awayTeamId) was never marked covered and rendered an extra, redundant "fk"
+// edge alongside its own "relationship" edge: 3 edges where 2 were correct.
+// Kept in its own fixture dir (not acme/) so it never has to be reconciled
+// against golden.test.ts's full-site snapshot.
+test("two references to the same target render exactly one edge per relationship, no redundant fk edge (#368)", async () => {
+ const model = await loadModel([join(FIX, "repro368")]);
+ const g = new LinkGraph(model);
+ const toTeam = g.refsFrom("repro368::Match").filter((r) => r.to === "repro368::Team");
+ expect(toTeam.map((r) => r.via).sort()).toEqual(["awayTeam", "homeTeam"]);
+ expect(toTeam.every((r) => r.kind === "relationship")).toBe(true);
+ expect(toTeam.length).toBe(2);
+});
From 7fab449a2f844e154161502e62b27f50da14c346 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 17:08:14 -0400
Subject: [PATCH 12/31] fix(loader,python): 1:N reference resolution parity
(#368)
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../metaobjects/loader/validation_passes.py | 201 +++++++--
.../relationship/relationship_references.py | 132 ++++++
.../unit/test_relationship_m2m_validation.py | 391 ++++++++++++++++++
.../unit/test_relationship_references.py | 139 +++++++
4 files changed, 840 insertions(+), 23 deletions(-)
create mode 100644 server/python/src/metaobjects/meta/core/relationship/relationship_references.py
create mode 100644 server/python/tests/unit/test_relationship_m2m_validation.py
create mode 100644 server/python/tests/unit/test_relationship_references.py
diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py
index e070a0bf8..e465878e7 100644
--- a/server/python/src/metaobjects/loader/validation_passes.py
+++ b/server/python/src/metaobjects/loader/validation_passes.py
@@ -136,6 +136,11 @@
)
from ..source import resolved_source
from ..naming_refs import did_you_mean_hint, resolve_object_ref
+from ..meta.core.relationship.relationship_references import (
+ reference_candidates_for,
+ reference_fields,
+ resolve_relationship_reference,
+)
# A subtype-specific template attr is valid ONLY on the subtype(s) it is registered
# for. The metamodel registers these per-subtype (see the core_types template block),
@@ -193,6 +198,10 @@ def run_validations(
# FR-024 B6 — an entity's origin-bearing field needs a read-capable source.
_validate_derived_field_providability(root, errors)
_validate_relationships(root, errors)
+ # Rule (e) (#368) — registered alongside _validate_relationships (the M:N
+ # slim-vocabulary pass, rule (d)): same deferred-resolution timing (after
+ # all files load + extends resolution).
+ _validate_one_side_reference_resolution(root, errors)
# Phase 2 — validation DERIVED FROM THE TYPE REGISTRY: each node's TypeDefinition
# carries its reference descriptors (relationship @objectRef, identity.reference
# @references for core; a downstream provider's type carries its own) + validator,
@@ -2614,17 +2623,44 @@ def _count_junction_references(junction: MetaData) -> int:
def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
- # ADR-0039: a relationship is validated on the entity that DECLARES it — the
- # M:N slim-vocabulary rules apply to own-declared relationships (obj.own_children()),
- # but each relationship's @through/@sourceRefField/@symmetric/@cardinality/@objectRef
- # is read RESOLVING (get_meta_attr), since those attrs may be inherited via extends.
- # Mirrors the TS validateRelationships (root.children() + obj.ownChildren() +
- # `rel.attr` which resolves — validation-passes.ts:1313-1324). (The junction's
- # identity.reference fields are also read resolving — see _count_junction_references.)
+ # #368 — the inner loop below iterates the EFFECTIVE relationship set
+ # (obj.children(), resolving), so a relationship inherited unmodified by N
+ # entities is reached N times. Its own attrs never change based on who
+ # inherits it, so re-validating it more than once would report the
+ # identical finding N times — pure noise. `checked` is keyed on the
+ # relationship NODE's own object identity: MetaData.children()'s
+ # effective-children computation reuses the super's child object in place
+ # for an unmodified inherited child (see MetaData._effective_children_inner),
+ # so the same physical declaration IS the same object everywhere it's
+ # visible, while an override replaces it with a genuinely different object
+ # (correctly NOT deduped — a distinct declaration is a distinct finding).
+ checked: set[MetaData] = set()
+ # ADR-0039: root has no super; children()==own_children(), but resolving
+ # is still the correct default (mirrors the TS root.children()).
for obj in (c for c in root.children() if c.type == TYPE_OBJECT):
- # ADR-0042 — a bare @through / @objectRef resolves in the declaring entity's package.
- referrer_pkg = obj.package or obj.file_default_package or ""
- for rel in (c for c in obj.own_children() if c.type == TYPE_RELATIONSHIP):
+ # ADR-0039: resolving — rule (d) (like rule (e)) must see a
+ # relationship inherited via extends, not just this entity's own
+ # declarations. `checked` absorbs the resulting revisits.
+ for rel in (c for c in obj.children() if c.type == TYPE_RELATIONSHIP):
+ if rel in checked:
+ continue
+ checked.add(rel)
+
+ # Every rule below validates a property of the relationship's OWN
+ # declaration (its attrs, plus for rule (c) the @through target), so
+ # context — the entity name in messages, the package a bare
+ # @through resolves in (ADR-0042), and rule (a)'s self-join
+ # comparison — is always the entity that DECLARES `rel`
+ # (rel.parent), never `obj` (the entity whose effective view
+ # happened to reach it first). This keeps the check's result
+ # independent of iteration order/inheritance depth, which is what
+ # makes checking each node exactly once correct.
+ declaring_entity = rel.parent if rel.parent is not None else obj
+ referrer_pkg = (
+ declaring_entity.package or declaring_entity.file_default_package or ""
+ )
+
+ # ADR-0039: resolving — a relationship may inherit its M:N attrs via extends.
through = rel.get_meta_attr(RELATIONSHIP_ATTR_THROUGH)
source_ref_field = rel.get_meta_attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD)
symmetric = rel.get_meta_attr(RELATIONSHIP_ATTR_SYMMETRIC) is True
@@ -2637,6 +2673,7 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
)
is_many = cardinality == CARDINALITY_MANY
is_m2m = has_through and is_many
+ is_cardinality_one = cardinality == CARDINALITY_ONE
# NOTE: @objectRef existence resolution moved to the validation registry
# (a declarative ReferenceDescriptor on relationship.* TypeDefinitions,
@@ -2646,22 +2683,28 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
if not is_m2m:
if has_through:
errors.append(MetaError(
- f'relationship "{obj.name}.{rel.name}" sets '
+ f'relationship "{declaring_entity.name}.{rel.name}" sets '
f'@{RELATIONSHIP_ATTR_THROUGH} but is not a M:N relationship '
f'(requires @{RELATIONSHIP_ATTR_CARDINALITY}: "{CARDINALITY_MANY}").',
ErrorCode.ERR_INVALID_RELATIONSHIP,
envelope=rel.source,
))
- if has_source_ref_field:
+ # #368: @sourceRefField also disambiguates a `@cardinality: one`
+ # relationship when the entity holds more than one
+ # identity.reference onto the same target. Only the M:N
+ # *junction* reading is rejected here; rule (e) —
+ # _validate_one_side_reference_resolution, below in this file —
+ # checks that it names a real local reference.
+ if has_source_ref_field and not is_cardinality_one:
errors.append(MetaError(
- f'relationship "{obj.name}.{rel.name}" sets '
+ f'relationship "{declaring_entity.name}.{rel.name}" sets '
f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.',
ErrorCode.ERR_INVALID_RELATIONSHIP,
envelope=rel.source,
))
if symmetric:
errors.append(MetaError(
- f'relationship "{obj.name}.{rel.name}" sets '
+ f'relationship "{declaring_entity.name}.{rel.name}" sets '
f'@{RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.',
ErrorCode.ERR_INVALID_RELATIONSHIP,
envelope=rel.source,
@@ -2671,7 +2714,7 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
# Rule (b): @symmetric and @sourceRefField are mutually exclusive.
if symmetric and has_source_ref_field:
errors.append(MetaError(
- f'relationship "{obj.name}.{rel.name}" sets both '
+ f'relationship "{declaring_entity.name}.{rel.name}" sets both '
f'@{RELATIONSHIP_ATTR_SYMMETRIC} and '
f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD}; they are mutually exclusive.',
ErrorCode.ERR_BAD_ATTR_VALUE,
@@ -2684,13 +2727,13 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
# name entity) is NOT (comparing stripped short names would misclassify it).
is_self_join = (
isinstance(object_ref, str)
- and resolve_object_ref(root, object_ref, referrer_pkg) is obj
+ and resolve_object_ref(root, object_ref, referrer_pkg) is declaring_entity
)
if symmetric and not is_self_join:
errors.append(MetaError(
- f'relationship "{obj.name}.{rel.name}" sets '
+ f'relationship "{declaring_entity.name}.{rel.name}" sets '
f'@{RELATIONSHIP_ATTR_SYMMETRIC} but @{RELATIONSHIP_ATTR_OBJECT_REF} '
- f'"{object_ref}" is not the declaring entity "{obj.name}"; '
+ f'"{object_ref}" is not the declaring entity "{declaring_entity.name}"; '
f'@{RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.',
ErrorCode.ERR_BAD_ATTR_VALUE,
envelope=rel.source,
@@ -2704,12 +2747,12 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
junction = resolve_object_ref(root, str(through), referrer_pkg)
if junction is None:
errors.append(MetaError(
- f'relationship "{obj.name}.{rel.name}" '
+ f'relationship "{declaring_entity.name}.{rel.name}" '
f'@{RELATIONSHIP_ATTR_THROUGH} "{through}" does not resolve to an '
f"entity.{did_you_mean_hint(root, str(through))}",
ErrorCode.ERR_INVALID_RELATIONSHIP,
envelope=resolved_source(
- rel.source, f"{obj.fqn()}::{rel.name}", str(through)
+ rel.source, f"{declaring_entity.fqn()}::{rel.name}", str(through)
),
))
continue
@@ -2719,7 +2762,7 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
# assert it here. (A value/projection has no table to join through.)
if junction.sub_type != OBJECT_SUBTYPE_ENTITY:
errors.append(MetaError(
- f'relationship "{obj.name}.{rel.name}" '
+ f'relationship "{declaring_entity.name}.{rel.name}" '
f'@{RELATIONSHIP_ATTR_THROUGH} "{through}" resolves to '
f"{junction.type}.{junction.sub_type}, not an entity — a junction is a "
f"persisted join table and must be object.entity.",
@@ -2730,7 +2773,7 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
ref_count = _count_junction_references(junction)
if ref_count != 2:
errors.append(MetaError(
- f'relationship "{obj.name}.{rel.name}" '
+ f'relationship "{declaring_entity.name}.{rel.name}" '
f'@{RELATIONSHIP_ATTR_THROUGH} "{through}" must declare exactly two '
f'identity.reference children (one per FK side); found {ref_count}.',
ErrorCode.ERR_INVALID_RELATIONSHIP,
@@ -2744,7 +2787,7 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
if source_ref_field not in fk_fields:
available = ", ".join(fk_fields) or "(none)"
errors.append(MetaError(
- f'relationship "{obj.name}.{rel.name}" '
+ f'relationship "{declaring_entity.name}.{rel.name}" '
f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} "{source_ref_field}" '
f'does not match any identity.reference FK field on junction '
f'"{through}". Available: {available}.',
@@ -2753,6 +2796,118 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
))
+# ---------------------------------------------------------------------------
+# Rule (e) — #368: a `@cardinality: one` relationship must resolve to exactly
+# one identity.reference. Two references onto the same target are
+# indistinguishable from the relationship's @objectRef alone, so the resolver
+# would silently emit the first one's FK column. ADR-0029 Section 5: ambiguity
+# is a load error naming the candidates.
+#
+# Registered alongside _validate_relationships (the M:N slim-vocabulary pass,
+# above) — same deferred-resolution timing (after all files load + extends
+# resolution).
+#
+# Scope differs deliberately from rule (d): rule (d) validates attrs that
+# travel with the relationship's OWN declaration (@through/@symmetric/
+# @sourceRefField), so own-scoping there is correct — those attrs don't
+# change meaning depending on who inherits the relationship. Rule (e)
+# instead validates whether THIS entity's reference set resolves the
+# relationship uniquely, which is a property of the EFFECTIVE entity, not of
+# wherever the relationship happens to be declared. A child entity that
+# extends a clean parent and adds a second identity.reference onto the same
+# target makes an INHERITED relationship ambiguous on the child even though
+# the parent (and the relationship's own declaration) are untouched — own-
+# scoping this pass would leave that case unchecked, and codegen/runtime
+# (which resolve against the effective entity) would silently drop the
+# relation. If a parent and a child are both genuinely ambiguous, both are
+# reported — two entities are broken, not one error duplicated. (No dedupe
+# here, unlike rule (d): rule (e)'s candidate set genuinely differs per
+# entity.)
+# ---------------------------------------------------------------------------
+
+
+def _validate_one_side_reference_resolution(
+ root: MetaData, errors: list[MetaError]
+) -> None:
+ # ADR-0039: root has no super; children()==own_children().
+ for obj in (c for c in root.children() if c.type == TYPE_OBJECT):
+ # ADR-0039: resolving — see the scope note above: rule (e) checks THIS
+ # entity's effective reference set against every relationship it can
+ # see, including one only inherited via extends.
+ for rel in (c for c in obj.children() if c.type == TYPE_RELATIONSHIP):
+ # ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends.
+ if rel.get_meta_attr(RELATIONSHIP_ATTR_CARDINALITY) != CARDINALITY_ONE:
+ continue
+ object_ref = rel.get_meta_attr(RELATIONSHIP_ATTR_OBJECT_REF)
+ if not isinstance(object_ref, str) or object_ref == "":
+ continue
+
+ candidates = reference_candidates_for(obj, object_ref)
+
+ source_ref_field = rel.get_meta_attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD)
+ declared = (
+ source_ref_field
+ if isinstance(source_ref_field, str) and source_ref_field != ""
+ else None
+ )
+
+ if declared is not None:
+ # A declared @sourceRefField short-circuits the ladder at ANY
+ # candidate count — checked independently of
+ # resolve_relationship_reference, whose step 1 ("exactly one
+ # candidate -> that one") would otherwise silently return the
+ # lone candidate even when it disagrees with the declared
+ # field. The author named a specific FK; it must exist,
+ # whether there are zero, one, or many candidates.
+ matches_declared = any(
+ reference_fields(c)[:1] == [declared] for c in candidates
+ )
+ if matches_declared:
+ continue
+ errors.append(MetaError(
+ f'relationship "{obj.name}.{rel.name}" sets '
+ f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} "{declared}", which names '
+ f'no identity.reference targeting "{object_ref}". '
+ f'Candidates: {_format_reference_candidates(candidates)}.',
+ ErrorCode.ERR_INVALID_RELATIONSHIP,
+ envelope=rel.source,
+ ))
+ continue
+
+ # No @sourceRefField declared: ambiguity only exists with 2+
+ # candidates — resolve_relationship_reference's name-pairing step
+ # (ladder step 3) decides.
+ if len(candidates) <= 1:
+ continue
+ resolved = resolve_relationship_reference(obj, rel.name, object_ref)
+ if resolved is not None:
+ continue
+
+ errors.append(MetaError(
+ f'relationship "{obj.name}.{rel.name}" is ambiguous: "{obj.name}" declares '
+ f'{len(candidates)} identity.reference nodes targeting "{object_ref}" and '
+ f'the relationship name does not pair with exactly one. '
+ f'Candidates: {_format_reference_candidates(candidates)}. '
+ f'Set @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} to the FK field this '
+ f'relationship navigates.',
+ ErrorCode.ERR_INVALID_RELATIONSHIP,
+ envelope=rel.source,
+ ))
+
+
+def _format_reference_candidates(candidates: list[MetaData]) -> str:
+ """Render a candidate reference as ``name(fkField)``, or
+ ``name(fieldA, fieldB)`` for a composite reference — so two composite
+ references sharing a first column still print distinguishably.
+
+ NOTE: this is display only. Matching (both here and in
+ resolve_relationship_reference) still keys on the first field alone — a
+ composite reference cannot actually be disambiguated by @sourceRefField.
+ That's a documented limitation, not fixed by this rendering.
+ """
+ return ", ".join(f"{c.name}({', '.join(reference_fields(c))})" for c in candidates)
+
+
# NOTE: identity.reference @references resolution moved to the validation registry
# (a declarative ReferenceDescriptor with dotted_field_path on the identity.reference
# TypeDefinition, resolved by registered_validation).
diff --git a/server/python/src/metaobjects/meta/core/relationship/relationship_references.py b/server/python/src/metaobjects/meta/core/relationship/relationship_references.py
new file mode 100644
index 000000000..cfaf4e03f
--- /dev/null
+++ b/server/python/src/metaobjects/meta/core/relationship/relationship_references.py
@@ -0,0 +1,132 @@
+"""Association -> identity.reference resolution (issue #368).
+
+An entity may declare more than one identity.reference onto the SAME target
+entity (Match.homeTeamRef and Match.awayTeamRef both -> Team). A
+``@cardinality: one`` relationship names only its target, so when two
+references match, the target alone cannot say which FK the navigation uses.
+Taking the first match emits a join on the wrong column that typechecks, has
+correct DDL and passes verify -- so the ladder below resolves it explicitly or
+not at all. ADR-0029 Section 5: ambiguity is a load error naming the
+candidates.
+
+Python port of the TypeScript reference implementation
+(``metadata/src/core/relationship/resolve-relationship-reference.ts``) --
+that file is the authoritative spec; this module mirrors it exactly (same
+suffix list, same order, same "candidate side only" stripping).
+"""
+from __future__ import annotations
+
+from ...meta_data import MetaData
+from ....naming import strip_package
+from ....shared.base_types import TYPE_IDENTITY
+from ..identity.identity_constants import (
+ IDENTITY_ATTR_FIELDS,
+ IDENTITY_REFERENCE_ATTR_REFERENCES,
+ IDENTITY_SUBTYPE_REFERENCE,
+)
+
+# Trailing suffixes stripped from a CANDIDATE's name/FK field when building its
+# pairing keys. Ordered -- first match wins, so "reference" is tested before
+# "ref". Never applied to the relationship name (see reference_pairing_keys).
+PAIRING_SUFFIXES: tuple[str, ...] = ("reference", "ref", "id", "key")
+
+
+def _strip_one_suffix(value: str) -> str:
+ for suffix in PAIRING_SUFFIXES:
+ if len(value) > len(suffix) and value.endswith(suffix):
+ return value[: len(value) - len(suffix)]
+ return value
+
+
+def reference_fields(ref: MetaData) -> list[str]:
+ """The full @fields tuple of an identity.reference, in declared order.
+
+ @fields may be authored as a JSON array (the normal identity.reference
+ shape) or as a single bare string (as identity.primary sometimes is) --
+ both are normalized here so callers never branch on authoring shape.
+ """
+ fields = ref.get_meta_attr(IDENTITY_ATTR_FIELDS) # ADR-0039: resolving.
+ if isinstance(fields, (list, tuple)):
+ return [f for f in fields if isinstance(f, str)]
+ if isinstance(fields, str) and fields:
+ return [f.strip() for f in fields.split(",") if f.strip()]
+ return []
+
+
+def _first_fk_field(ref: MetaData) -> str | None:
+ """The FK field a reference is anchored on (first field; composite FKs
+ pair on their first column)."""
+ fields = reference_fields(ref)
+ return fields[0] if fields else None
+
+
+def reference_pairing_keys(ref: MetaData) -> set[str]:
+ """The set of lowercased names a candidate reference answers to: its own
+ name and its FK field, each with and without one stripped suffix."""
+ keys: set[str] = set()
+
+ def _add(value: str | None) -> None:
+ if not value:
+ return
+ lower = value.lower()
+ keys.add(lower)
+ keys.add(_strip_one_suffix(lower))
+
+ _add(ref.name)
+ _add(_first_fk_field(ref))
+ return keys
+
+
+def reference_candidates_for(holder: MetaData, target_entity: str) -> list[MetaData]:
+ """Every identity.reference on ``holder`` whose @references targets
+ ``target_entity``. Package-insensitive on both sides: @references and
+ @objectRef may each be bare or fully qualified.
+ """
+ target = strip_package(target_entity)
+ candidates: list[MetaData] = []
+ # ADR-0039: resolving -- children() honors references inherited via extends.
+ for child in holder.children():
+ if child.type != TYPE_IDENTITY or child.sub_type != IDENTITY_SUBTYPE_REFERENCE:
+ continue
+ references = child.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES)
+ if not isinstance(references, str) or not references:
+ continue
+ if strip_package(references) != target:
+ continue
+ if _first_fk_field(child) is None:
+ continue
+ candidates.append(child)
+ return candidates
+
+
+def resolve_relationship_reference(
+ holder: MetaData,
+ relationship_name: str,
+ target_entity: str,
+ source_ref_field: str | None = None,
+) -> MetaData | None:
+ """Which identity.reference does this ``@cardinality: one`` relationship
+ navigate through? The ladder, in order:
+
+ 1. exactly one candidate -> that one (the common case; unchanged behaviour)
+ 2. ``@sourceRefField`` declared -> the candidate whose FK field it names
+ 3. exactly one candidate name-pairs -> that one
+ 4. otherwise -> None (caller reports the ambiguity)
+
+ Returns None for "no candidate" and "cannot choose" alike; callers that
+ need to tell them apart use :func:`reference_candidates_for`.
+ """
+ candidates = reference_candidates_for(holder, target_entity)
+ if len(candidates) == 0:
+ return None
+ if len(candidates) == 1:
+ return candidates[0]
+
+ if source_ref_field is not None and source_ref_field != "":
+ return next(
+ (c for c in candidates if _first_fk_field(c) == source_ref_field), None
+ )
+
+ wanted = relationship_name.lower()
+ paired = [c for c in candidates if wanted in reference_pairing_keys(c)]
+ return paired[0] if len(paired) == 1 else None
diff --git a/server/python/tests/unit/test_relationship_m2m_validation.py b/server/python/tests/unit/test_relationship_m2m_validation.py
new file mode 100644
index 000000000..c7230cb05
--- /dev/null
+++ b/server/python/tests/unit/test_relationship_m2m_validation.py
@@ -0,0 +1,391 @@
+"""Loader validation for relationship.* M:N slim vocabulary + #368 1:N
+reference-disambiguation rules.
+
+Python port of the TS reference suite
+(``metadata/test/relationship-m2m.test.ts``) covering the #368 additions:
+
+ (B) ``@sourceRefField`` becomes legal on ``@cardinality: "one"`` (previously
+ rejected on any non-M:N relationship).
+ (C) Rule (e) -- a `@cardinality: one` relationship must resolve to exactly
+ one identity.reference candidate; ambiguity is a load error.
+ (D) Both rule (d) (the M:N slim-vocabulary pass) and rule (e) iterate the
+ EFFECTIVE relationship set (own + inherited via extends), not just
+ own-declared relationships -- with rule (d) deduping on the
+ relationship node's identity (an inherited, unmodified relationship
+ must not be reported once per inheriting entity).
+
+See ``server/typescript/packages/metadata/src/core/relationship/
+resolve-relationship-reference.ts`` and ``.../src/loader/validation-passes.ts``
+(``validateRelationships`` / ``validateOneSideReferenceResolution``) for the
+authoritative spec these tests mirror.
+"""
+from __future__ import annotations
+
+import json
+
+from metaobjects import InMemoryStringSource, MetaDataLoader
+
+
+def _load(doc: dict) -> tuple[list[str], list[str]]:
+ """Load *doc* and return (error codes, error messages)."""
+ result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))])
+ codes = [e.code.value for e in result.errors]
+ messages = [e.message for e in result.errors]
+ return codes, messages
+
+
+# ---------------------------------------------------------------------------
+# (B) @sourceRefField becomes legal on @cardinality: "one"
+# ---------------------------------------------------------------------------
+
+
+def test_source_ref_field_on_cardinality_one_loads_clean() -> None:
+ """The issue #368 repro: two 1:N relationships, each disambiguated by
+ @sourceRefField, load with no errors."""
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "homeTeamId"}},
+ {"field.long": {"name": "awayTeamId"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}},
+ {"identity.reference": {"name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team"}},
+ {"relationship.association": {"name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId"}},
+ {"relationship.association": {"name": "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId"}},
+ ]}},
+ ]}}
+ codes, _ = _load(doc)
+ assert codes == []
+
+
+def test_source_ref_field_on_many_without_through_still_errors() -> None:
+ """@sourceRefField on @cardinality: many with no @through is still not
+ M:N -- the widening only spares @cardinality: one."""
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"relationship.association": {"name": "teams", "@objectRef": "Team", "@cardinality": "many", "@sourceRefField": "whatever"}},
+ ]}},
+ ]}}
+ codes, _ = _load(doc)
+ assert "ERR_INVALID_RELATIONSHIP" in codes
+
+
+def test_through_on_cardinality_one_still_errors() -> None:
+ """@through still requires @cardinality: many -- only @sourceRefField
+ was widened."""
+ doc = {"metadata.root": {"package": "acme", "children": [
+ {"object.entity": {"name": "Week", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"relationship.composition": {"name": "program", "@objectRef": "Program", "@cardinality": "one", "@through": "X"}},
+ ]}},
+ {"object.entity": {"name": "Program", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ ]}}
+ codes, _ = _load(doc)
+ assert "ERR_INVALID_RELATIONSHIP" in codes
+
+
+def test_symmetric_on_cardinality_one_still_errors() -> None:
+ """@symmetric still requires M:N -- only @sourceRefField was widened."""
+ doc = {"metadata.root": {"package": "acme", "children": [
+ {"object.entity": {"name": "Week", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"relationship.association": {"name": "program", "@objectRef": "Program", "@symmetric": True}},
+ ]}},
+ {"object.entity": {"name": "Program", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ ]}}
+ codes, _ = _load(doc)
+ assert "ERR_INVALID_RELATIONSHIP" in codes
+
+
+# ---------------------------------------------------------------------------
+# (A) The resolution ladder, exercised end-to-end through the loader.
+# ---------------------------------------------------------------------------
+
+
+def test_issue_repro_loads_clean_via_name_pairing() -> None:
+ """Two references onto the same target, no @sourceRefField -- resolved by
+ name-pairing (homeTeamRef <-> homeTeam, awayTeamRef <-> awayTeam)."""
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "homeTeamId"}},
+ {"field.long": {"name": "awayTeamId"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}},
+ {"relationship.association": {"name": "homeTeam", "@objectRef": "Team", "@cardinality": "one"}},
+ {"identity.reference": {"name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team"}},
+ {"relationship.association": {"name": "awayTeam", "@objectRef": "Team", "@cardinality": "one"}},
+ ]}},
+ ]}}
+ codes, _ = _load(doc)
+ assert codes == []
+
+
+def test_unpairable_names_error_naming_both_candidates() -> None:
+ """Two references whose names don't pair with the relationship name --
+ ambiguous, and the error names both candidates as name(fkField)."""
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "alphaFk"}},
+ {"field.long": {"name": "betaFk"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"identity.reference": {"name": "alphaRef", "@fields": ["alphaFk"], "@references": "Team"}},
+ {"identity.reference": {"name": "betaRef", "@fields": ["betaFk"], "@references": "Team"}},
+ {"relationship.association": {"name": "winner", "@objectRef": "Team", "@cardinality": "one"}},
+ ]}},
+ ]}}
+ codes, messages = _load(doc)
+ assert "ERR_INVALID_RELATIONSHIP" in codes
+ joined = "\n".join(messages)
+ assert "Match.winner" in joined
+ assert "alphaRef(alphaFk)" in joined
+ assert "betaRef(betaFk)" in joined
+
+
+def test_declared_but_unmatched_errors_at_single_candidate_count() -> None:
+ """A declared @sourceRefField naming nothing must error even with exactly
+ one candidate -- the ladder's step 1 ("exactly one candidate -> that
+ one") must not silently short-circuit past a bad declared value."""
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "homeTeamId"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}},
+ {"relationship.association": {"name": "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId"}},
+ ]}},
+ ]}}
+ codes, messages = _load(doc)
+ assert codes == ["ERR_INVALID_RELATIONSHIP"]
+ joined = "\n".join(messages)
+ assert "Match.awayTeam" in joined
+ assert '"awayTeamId"' in joined
+
+
+def test_declared_but_unmatched_errors_at_zero_candidate_count() -> None:
+ """A declared @sourceRefField naming nothing must also error with ZERO
+ candidates (no identity.reference targets the objectRef at all) -- the
+ declared value is read BEFORE any candidate-count guard."""
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"relationship.association": {"name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId"}},
+ ]}},
+ ]}}
+ codes, messages = _load(doc)
+ assert codes == ["ERR_INVALID_RELATIONSHIP"]
+ joined = "\n".join(messages)
+ assert "Match.homeTeam" in joined
+ assert '"homeTeamId"' in joined
+
+
+def test_source_ref_field_correctly_naming_single_candidate_loads_clean() -> None:
+ """Regression: a correctly-declared @sourceRefField over a single
+ candidate stays clean."""
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "homeTeamId"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}},
+ {"relationship.association": {"name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId"}},
+ ]}},
+ ]}}
+ codes, _ = _load(doc)
+ assert codes == []
+
+
+def test_composite_reference_candidates_render_full_field_tuple() -> None:
+ """Two composite references sharing a first column must still print
+ distinguishably in the candidate list (fields[0] alone would collide)."""
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "tenantId"}},
+ {"field.long": {"name": "homeTeamId"}},
+ {"field.long": {"name": "awayTeamId"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"identity.reference": {"name": "aRef", "@fields": ["tenantId", "homeTeamId"], "@references": "Team"}},
+ {"identity.reference": {"name": "bRef", "@fields": ["tenantId", "awayTeamId"], "@references": "Team"}},
+ {"relationship.association": {"name": "winner", "@objectRef": "Team", "@cardinality": "one"}},
+ ]}},
+ ]}}
+ codes, messages = _load(doc)
+ assert "ERR_INVALID_RELATIONSHIP" in codes
+ joined = "\n".join(messages)
+ assert "aRef(tenantId, homeTeamId)" in joined
+ assert "bRef(tenantId, awayTeamId)" in joined
+
+
+# ---------------------------------------------------------------------------
+# (D) Rule (e) must iterate the EFFECTIVE relationship set.
+# ---------------------------------------------------------------------------
+
+
+def test_inherited_relationship_ambiguity_errors() -> None:
+ """A extends cleanly; B extends A and adds a second reference onto the
+ same target -- the inherited relationship becomes ambiguous on B even
+ though A (and the relationship's own declaration) are untouched."""
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "A", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "homeTeamId"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}},
+ {"relationship.association": {"name": "winner", "@objectRef": "Team", "@cardinality": "one"}},
+ ]}},
+ {"object.entity": {"name": "B", "extends": "A", "children": [
+ {"field.long": {"name": "awayTeamId"}},
+ {"identity.reference": {"name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team"}},
+ ]}},
+ ]}}
+ codes, messages = _load(doc)
+ assert codes == ["ERR_INVALID_RELATIONSHIP"]
+ joined = "\n".join(messages)
+ assert "B.winner" in joined
+ assert "homeTeamRef(homeTeamId)" in joined
+ assert "awayTeamRef(awayTeamId)" in joined
+
+
+def test_inherited_relationship_resolved_by_child_added_reference_loads_clean() -> None:
+ """A child entity's added reference that name-pairs with the inherited
+ relationship resolves cleanly."""
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "A", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "homeTeamId"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}},
+ {"relationship.association": {"name": "awayTeam", "@objectRef": "Team", "@cardinality": "one"}},
+ ]}},
+ {"object.entity": {"name": "B", "extends": "A", "children": [
+ {"field.long": {"name": "awayTeamId"}},
+ {"identity.reference": {"name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team"}},
+ ]}},
+ ]}}
+ codes, _ = _load(doc)
+ assert codes == []
+
+
+# ---------------------------------------------------------------------------
+# (D) Rule (d) dedupe -- own attrs never change per inheriting entity, so an
+# inherited unmodified relationship must be reported exactly once.
+# ---------------------------------------------------------------------------
+
+
+def test_rule_d_dedupe_single_error_for_one_inheriting_child() -> None:
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Program", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "A", "children": [
+ {"field.long": {"name": "id"}},
+ {"relationship.composition": {"name": "program", "@objectRef": "Program", "@cardinality": "one", "@through": "X"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "B", "extends": "A"}},
+ ]}}
+ codes, _ = _load(doc)
+ assert codes == ["ERR_INVALID_RELATIONSHIP"]
+
+
+def test_rule_d_dedupe_single_error_across_several_inheriting_children() -> None:
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Program", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "A", "children": [
+ {"field.long": {"name": "id"}},
+ {"relationship.composition": {"name": "program", "@objectRef": "Program", "@cardinality": "one", "@through": "X"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "B", "extends": "A"}},
+ {"object.entity": {"name": "C", "extends": "A"}},
+ {"object.entity": {"name": "D", "extends": "A"}},
+ ]}}
+ codes, _ = _load(doc)
+ assert codes == ["ERR_INVALID_RELATIONSHIP"]
+
+
+# ---------------------------------------------------------------------------
+# Regression: valid M:N still loads clean.
+# ---------------------------------------------------------------------------
+
+
+def test_valid_hetero_m2m_produces_no_relationship_errors() -> None:
+ doc = {"metadata.root": {"package": "acme", "children": [
+ {"object.entity": {"name": "Post", "children": [
+ {"field.long": {"name": "id"}},
+ {"relationship.association": {"name": "tags", "@cardinality": "many", "@objectRef": "Tag", "@through": "PostTag"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "Tag", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "PostTag", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "postId"}},
+ {"field.long": {"name": "tagId"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"identity.reference": {"name": "p", "@fields": ["postId"], "@references": "Post"}},
+ {"identity.reference": {"name": "t", "@fields": ["tagId"], "@references": "Tag"}},
+ ]}},
+ ]}}
+ codes, _ = _load(doc)
+ assert "ERR_INVALID_RELATIONSHIP" not in codes
+ assert "ERR_BAD_ATTR_VALUE" not in codes
diff --git a/server/python/tests/unit/test_relationship_references.py b/server/python/tests/unit/test_relationship_references.py
new file mode 100644
index 000000000..a6eaee159
--- /dev/null
+++ b/server/python/tests/unit/test_relationship_references.py
@@ -0,0 +1,139 @@
+"""Direct unit tests for the #368 resolution ladder
+(``metaobjects.meta.core.relationship.relationship_references``).
+
+Python port of the TS reference suite
+(``metadata/test/resolve-relationship-reference.test.ts``) -- exercises
+``reference_candidates_for`` / ``resolve_relationship_reference`` directly
+against a loaded model, rather than only through the loader's error output
+(see ``test_relationship_m2m_validation.py`` for the loader-integration
+tests covering rule (d) / rule (e)).
+"""
+from __future__ import annotations
+
+import json
+
+from metaobjects import InMemoryStringSource, MetaDataLoader
+from metaobjects.meta.core.relationship.relationship_references import (
+ reference_candidates_for,
+ resolve_relationship_reference,
+)
+from metaobjects.meta.meta_data import MetaData
+from metaobjects.shared.base_types import TYPE_OBJECT
+
+_MATCH_MODEL = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": ["id"]}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "homeTeamId"}},
+ {"field.long": {"name": "awayTeamId"}},
+ {"identity.primary": {"name": "id", "@fields": ["id"]}},
+ {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}},
+ {"relationship.association": {"name": "homeTeam", "@objectRef": "Team", "@cardinality": "one"}},
+ {"identity.reference": {"name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team"}},
+ {"relationship.association": {"name": "awayTeam", "@objectRef": "Team", "@cardinality": "one"}},
+ ]}},
+]}}
+
+
+def _find_object(root: MetaData, name: str) -> MetaData:
+ return next(c for c in root.children() if c.type == TYPE_OBJECT and c.name == name)
+
+
+def _load_object(doc: dict, name: str) -> MetaData:
+ result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))])
+ assert result.errors == [], [e.message for e in result.errors]
+ return _find_object(result.root, name)
+
+
+def _load_match() -> MetaData:
+ return _load_object(_MATCH_MODEL, "Match")
+
+
+def test_enumerates_every_candidate_reference_for_the_target() -> None:
+ candidates = reference_candidates_for(_load_match(), "Team")
+ assert [c.name for c in candidates] == ["homeTeamRef", "awayTeamRef"]
+
+
+def test_name_pairing_resolves_each_association_to_its_own_reference() -> None:
+ match = _load_match()
+ home = resolve_relationship_reference(match, "homeTeam", "Team")
+ away = resolve_relationship_reference(match, "awayTeam", "Team")
+ assert home is not None and home.name == "homeTeamRef"
+ assert away is not None and away.name == "awayTeamRef"
+
+
+def test_source_ref_field_wins_over_name_pairing() -> None:
+ match = _load_match()
+ resolved = resolve_relationship_reference(match, "homeTeam", "Team", "awayTeamId")
+ assert resolved is not None and resolved.name == "awayTeamRef"
+
+
+def test_a_single_candidate_resolves_regardless_of_name() -> None:
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": ["id"]}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "winnerFk"}},
+ {"identity.primary": {"name": "id", "@fields": ["id"]}},
+ {"identity.reference": {"name": "anythingAtAll", "@fields": ["winnerFk"], "@references": "Team"}},
+ {"relationship.association": {"name": "champion", "@objectRef": "Team", "@cardinality": "one"}},
+ ]}},
+ ]}}
+ match = _load_object(doc, "Match")
+ resolved = resolve_relationship_reference(match, "champion", "Team")
+ assert resolved is not None and resolved.name == "anythingAtAll"
+
+
+def test_unpairable_names_return_none_rather_than_guessing() -> None:
+ # #368 rule (e) flags this fixture as a load error -- it's the exact
+ # ambiguity the ladder returning None exists to surface. Load with the
+ # merge/validation pipeline still run (errors non-empty is expected
+ # here), then assert the ladder's return value directly.
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": ["id"]}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "alphaFk"}},
+ {"field.long": {"name": "betaFk"}},
+ {"identity.primary": {"name": "id", "@fields": ["id"]}},
+ {"identity.reference": {"name": "alphaRef", "@fields": ["alphaFk"], "@references": "Team"}},
+ {"identity.reference": {"name": "betaRef", "@fields": ["betaFk"], "@references": "Team"}},
+ {"relationship.association": {"name": "winner", "@objectRef": "Team", "@cardinality": "one"}},
+ ]}},
+ ]}}
+ result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))])
+ assert [e.code.value for e in result.errors] == ["ERR_INVALID_RELATIONSHIP"]
+ match = _find_object(result.root, "Match")
+ assert resolve_relationship_reference(match, "winner", "Team") is None
+
+
+def test_suffix_stripping_never_applies_to_the_relationship_name() -> None:
+ # "valid" must NOT be stripped to "val" and pair with valRef.
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": ["id"]}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "valFk"}},
+ {"field.long": {"name": "otherFk"}},
+ {"identity.primary": {"name": "id", "@fields": ["id"]}},
+ {"identity.reference": {"name": "valRef", "@fields": ["valFk"], "@references": "Team"}},
+ {"identity.reference": {"name": "otherRef", "@fields": ["otherFk"], "@references": "Team"}},
+ {"relationship.association": {"name": "valid", "@objectRef": "Team", "@cardinality": "one"}},
+ ]}},
+ ]}}
+ result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))])
+ assert [e.code.value for e in result.errors] == ["ERR_INVALID_RELATIONSHIP"]
+ match = _find_object(result.root, "Match")
+ assert resolve_relationship_reference(match, "valid", "Team") is None
From 28d9223980c62b279657189478071432a97f43c0 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 17:27:07 -0400
Subject: [PATCH 13/31] fix(loader,csharp): 1:N reference resolution parity
(#368)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
...ssue368RelationshipReferenceLadderTests.cs | 154 +++++
...368RelationshipReferenceValidationTests.cs | 537 ++++++++++++++++++
.../Relationship/RelationshipReferences.cs | 127 +++++
.../MetaObjects/Loader/MetaDataLoader.cs | 13 +-
.../MetaObjects/Loader/ValidationPasses.cs | 208 ++++++-
5 files changed, 1012 insertions(+), 27 deletions(-)
create mode 100644 server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceLadderTests.cs
create mode 100644 server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceValidationTests.cs
create mode 100644 server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs
diff --git a/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceLadderTests.cs b/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceLadderTests.cs
new file mode 100644
index 000000000..8bffebb5e
--- /dev/null
+++ b/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceLadderTests.cs
@@ -0,0 +1,154 @@
+// Issue #368 — direct unit tests for the reference-resolution ladder
+// (MetaObjects.Core.Relationship.RelationshipReferences).
+//
+// C# port of the TS reference suite
+// (server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts)
+// and its Python port (server/python/tests/unit/test_relationship_references.py) —
+// exercises ReferenceCandidatesFor / ResolveRelationshipReference directly against
+// a loaded model, rather than only through the loader's error output (see
+// Issue368RelationshipReferenceValidationTests.cs for the loader-integration tests
+// covering rule (d) / rule (e)).
+
+using MetaObjects.Core.Relationship;
+using MetaObjects.Loader;
+using MetaObjects.Meta;
+using Xunit;
+
+namespace MetaObjects.Conformance.Tests;
+
+public class Issue368RelationshipReferenceLadderTests
+{
+ private static LoadResult LoadInline(string json) =>
+ new MetaDataLoader().Load([new InMemoryStringSource(json, id: "inline.json")]);
+
+ private const string MatchModel = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": ["id"] } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "homeTeamId" } },
+ { "field.long": { "name": "awayTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": ["id"] } },
+ { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.reference": { "name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { "name": "awayTeam", "@objectRef": "Team", "@cardinality": "one" } }
+ ]}}
+ ]}}
+ """;
+
+ private static MetaObject LoadMatch()
+ {
+ var result = LoadInline(MatchModel);
+ Assert.Empty(result.Errors);
+ return result.Root.FindObject("Match")!;
+ }
+
+ [Fact]
+ public void Enumerates_every_candidate_reference_for_the_target()
+ {
+ var candidates = RelationshipReferences.ReferenceCandidatesFor(LoadMatch(), "Team");
+ Assert.Equal(["homeTeamRef", "awayTeamRef"], candidates.Select(c => c.Name));
+ }
+
+ [Fact]
+ public void Name_pairing_resolves_each_association_to_its_own_reference()
+ {
+ var match = LoadMatch();
+ Assert.Equal("homeTeamRef", RelationshipReferences.ResolveRelationshipReference(match, "homeTeam", "Team")?.Name);
+ Assert.Equal("awayTeamRef", RelationshipReferences.ResolveRelationshipReference(match, "awayTeam", "Team")?.Name);
+ }
+
+ [Fact]
+ public void Source_ref_field_wins_over_name_pairing()
+ {
+ var match = LoadMatch();
+ Assert.Equal(
+ "awayTeamRef",
+ RelationshipReferences.ResolveRelationshipReference(match, "homeTeam", "Team", "awayTeamId")?.Name);
+ }
+
+ [Fact]
+ public void A_single_candidate_resolves_regardless_of_name()
+ {
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": ["id"] } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "winnerFk" } },
+ { "identity.primary": { "name": "id", "@fields": ["id"] } },
+ { "identity.reference": { "name": "anythingAtAll", "@fields": ["winnerFk"], "@references": "Team" } },
+ { "relationship.association": { "name": "champion", "@objectRef": "Team", "@cardinality": "one" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInline(doc);
+ Assert.Empty(result.Errors);
+ var match = result.Root.FindObject("Match")!;
+ Assert.Equal("anythingAtAll", RelationshipReferences.ResolveRelationshipReference(match, "champion", "Team")?.Name);
+ }
+
+ [Fact]
+ public void Unpairable_names_return_null_rather_than_guessing()
+ {
+ // #368 rule (e) flags this fixture as a load error -- it's the exact
+ // ambiguity the ladder returning null exists to surface. Load with the
+ // merge/validation pipeline still run (errors non-empty is expected here),
+ // then assert the ladder's return value directly.
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": ["id"] } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "alphaFk" } },
+ { "field.long": { "name": "betaFk" } },
+ { "identity.primary": { "name": "id", "@fields": ["id"] } },
+ { "identity.reference": { "name": "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } },
+ { "identity.reference": { "name": "betaRef", "@fields": ["betaFk"], "@references": "Team" } },
+ { "relationship.association": { "name": "winner", "@objectRef": "Team", "@cardinality": "one" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInline(doc);
+ Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code));
+ var match = result.Root.FindObject("Match")!;
+ Assert.Null(RelationshipReferences.ResolveRelationshipReference(match, "winner", "Team"));
+ }
+
+ [Fact]
+ public void Suffix_stripping_never_applies_to_the_relationship_name()
+ {
+ // "valid" must NOT be stripped to "val" and pair with valRef.
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": ["id"] } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "valFk" } },
+ { "field.long": { "name": "otherFk" } },
+ { "identity.primary": { "name": "id", "@fields": ["id"] } },
+ { "identity.reference": { "name": "valRef", "@fields": ["valFk"], "@references": "Team" } },
+ { "identity.reference": { "name": "otherRef", "@fields": ["otherFk"], "@references": "Team" } },
+ { "relationship.association": { "name": "valid", "@objectRef": "Team", "@cardinality": "one" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInline(doc);
+ Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code));
+ var match = result.Root.FindObject("Match")!;
+ Assert.Null(RelationshipReferences.ResolveRelationshipReference(match, "valid", "Team"));
+ }
+}
diff --git a/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceValidationTests.cs b/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceValidationTests.cs
new file mode 100644
index 000000000..cd8b834ad
--- /dev/null
+++ b/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceValidationTests.cs
@@ -0,0 +1,537 @@
+// Issue #368 — loader validation for relationship.* M:N slim vocabulary + the
+// 1:N reference-disambiguation rules (the resolution ladder in
+// MetaObjects.Core.Relationship.RelationshipReferences).
+//
+// C# port of the TS reference suite (relationship-m2m.test.ts) and its Python
+// port (test_relationship_m2m_validation.py), covering the #368 additions:
+//
+// (B) @sourceRefField becomes legal on @cardinality: "one" (previously
+// rejected on any non-M:N relationship).
+// (C) Rule (e) -- a @cardinality: one relationship must resolve to exactly
+// one identity.reference candidate; ambiguity is a load error.
+// (D) Both rule (d) (the M:N slim-vocabulary pass) and rule (e) iterate the
+// EFFECTIVE relationship set (own + inherited via extends), not just
+// own-declared relationships -- with rule (d) deduping on the
+// relationship node's identity (an inherited, unmodified relationship
+// must not be reported once per inheriting entity).
+//
+// Also regression-covers two latent "obj vs. declaring entity" bugs the TS/
+// Python authors flagged when they switched rule (d) to resolving iteration
+// (see d49c3d921 / 7fab449a2 commit messages) -- neither had a fixture in
+// either port, so they are new here: ADR-0042 package resolution for a bare
+// @through must use the DECLARING entity's package, and rule (a)'s self-join
+// comparison must use the DECLARING entity, not whichever entity's effective
+// view reached the inherited relationship first.
+//
+// See server/typescript/packages/metadata/src/core/relationship/
+// resolve-relationship-reference.ts and .../src/loader/validation-passes.ts
+// (validateRelationships / validateOneSideReferenceResolution) for the
+// authoritative spec these tests mirror.
+
+using MetaObjects.Loader;
+using Xunit;
+
+namespace MetaObjects.Conformance.Tests;
+
+public class Issue368RelationshipReferenceValidationTests
+{
+ private static LoadResult LoadInline(string json) =>
+ new MetaDataLoader().Load([new InMemoryStringSource(json, id: "inline.json")]);
+
+ private static LoadResult LoadInlineMulti(params string[] jsons) =>
+ new MetaDataLoader().Load(
+ jsons.Select((json, i) => new InMemoryStringSource(json, id: $"inline-{i}.json"))
+ .Cast()
+ .ToArray());
+
+ // -------------------------------------------------------------------------
+ // (B) @sourceRefField becomes legal on @cardinality: "one"
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Source_ref_field_on_cardinality_one_loads_clean()
+ {
+ // The issue #368 repro: two 1:N relationships, each disambiguated by
+ // @sourceRefField, load with no errors.
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "homeTeamId" } },
+ { "field.long": { "name": "awayTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "identity.reference": { "name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId" } },
+ { "relationship.association": { "name": "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } }
+ ]}}
+ ]}}
+ """;
+ Assert.Empty(LoadInline(doc).Errors);
+ }
+
+ [Fact]
+ public void Source_ref_field_on_many_without_through_still_errors()
+ {
+ // @sourceRefField on @cardinality: many with no @through is still not
+ // M:N -- the widening only spares @cardinality: one.
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "relationship.association": { "name": "teams", "@objectRef": "Team", "@cardinality": "many", "@sourceRefField": "whatever" } }
+ ]}}
+ ]}}
+ """;
+ Assert.Contains(LoadInline(doc).Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP);
+ }
+
+ [Fact]
+ public void Through_on_cardinality_one_still_errors()
+ {
+ // @through still requires @cardinality: many -- only @sourceRefField was widened.
+ const string doc = """
+ { "metadata.root": { "package": "acme", "children": [
+ { "object.entity": { "name": "Week", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "relationship.composition": { "name": "program", "@objectRef": "Program", "@cardinality": "one", "@through": "X" } }
+ ]}},
+ { "object.entity": { "name": "Program", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}}
+ ]}}
+ """;
+ Assert.Contains(LoadInline(doc).Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP);
+ }
+
+ [Fact]
+ public void Symmetric_on_cardinality_one_still_errors()
+ {
+ // @symmetric still requires M:N -- only @sourceRefField was widened.
+ const string doc = """
+ { "metadata.root": { "package": "acme", "children": [
+ { "object.entity": { "name": "Week", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "relationship.association": { "name": "program", "@objectRef": "Program", "@symmetric": true } }
+ ]}},
+ { "object.entity": { "name": "Program", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}}
+ ]}}
+ """;
+ Assert.Contains(LoadInline(doc).Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP);
+ }
+
+ // -------------------------------------------------------------------------
+ // (A) The resolution ladder, exercised end-to-end through the loader.
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Issue_repro_loads_clean_via_name_pairing()
+ {
+ // Two references onto the same target, no @sourceRefField -- resolved by
+ // name-pairing (homeTeamRef <-> homeTeam, awayTeamRef <-> awayTeam).
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "homeTeamId" } },
+ { "field.long": { "name": "awayTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.reference": { "name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { "name": "awayTeam", "@objectRef": "Team", "@cardinality": "one" } }
+ ]}}
+ ]}}
+ """;
+ Assert.Empty(LoadInline(doc).Errors);
+ }
+
+ [Fact]
+ public void Unpairable_names_error_naming_both_candidates()
+ {
+ // Two references whose names don't pair with the relationship name --
+ // ambiguous, and the error names both candidates as name(fkField).
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "alphaFk" } },
+ { "field.long": { "name": "betaFk" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } },
+ { "identity.reference": { "name": "betaRef", "@fields": ["betaFk"], "@references": "Team" } },
+ { "relationship.association": { "name": "winner", "@objectRef": "Team", "@cardinality": "one" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInline(doc);
+ Assert.Contains(result.Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP);
+ string joined = string.Join("\n", result.Errors.Select(e => e.Message));
+ Assert.Contains("Match.winner", joined);
+ Assert.Contains("alphaRef(alphaFk)", joined);
+ Assert.Contains("betaRef(betaFk)", joined);
+ }
+
+ [Fact]
+ public void Declared_but_unmatched_errors_at_single_candidate_count()
+ {
+ // A declared @sourceRefField naming nothing must error even with exactly
+ // one candidate -- the ladder's step 1 ("exactly one candidate -> that
+ // one") must not silently short-circuit past a bad declared value.
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "homeTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { "name": "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInline(doc);
+ Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code));
+ string joined = string.Join("\n", result.Errors.Select(e => e.Message));
+ Assert.Contains("Match.awayTeam", joined);
+ Assert.Contains("\"awayTeamId\"", joined);
+ }
+
+ [Fact]
+ public void Declared_but_unmatched_errors_at_zero_candidate_count()
+ {
+ // A declared @sourceRefField naming nothing must also error with ZERO
+ // candidates (no identity.reference targets the objectRef at all) -- the
+ // declared value is read BEFORE any candidate-count guard.
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInline(doc);
+ Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code));
+ string joined = string.Join("\n", result.Errors.Select(e => e.Message));
+ Assert.Contains("Match.homeTeam", joined);
+ Assert.Contains("\"homeTeamId\"", joined);
+ }
+
+ [Fact]
+ public void Source_ref_field_correctly_naming_single_candidate_loads_clean()
+ {
+ // Regression: a correctly-declared @sourceRefField over a single
+ // candidate stays clean.
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "homeTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId" } }
+ ]}}
+ ]}}
+ """;
+ Assert.Empty(LoadInline(doc).Errors);
+ }
+
+ [Fact]
+ public void Composite_reference_candidates_render_full_field_tuple()
+ {
+ // Two composite references sharing a first column must still print
+ // distinguishably in the candidate list (fields[0] alone would collide).
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "tenantId" } },
+ { "field.long": { "name": "homeTeamId" } },
+ { "field.long": { "name": "awayTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "aRef", "@fields": ["tenantId", "homeTeamId"], "@references": "Team" } },
+ { "identity.reference": { "name": "bRef", "@fields": ["tenantId", "awayTeamId"], "@references": "Team" } },
+ { "relationship.association": { "name": "winner", "@objectRef": "Team", "@cardinality": "one" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInline(doc);
+ Assert.Contains(result.Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP);
+ string joined = string.Join("\n", result.Errors.Select(e => e.Message));
+ Assert.Contains("aRef(tenantId, homeTeamId)", joined);
+ Assert.Contains("bRef(tenantId, awayTeamId)", joined);
+ }
+
+ // -------------------------------------------------------------------------
+ // (D) Rule (e) must iterate the EFFECTIVE relationship set.
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Inherited_relationship_ambiguity_errors()
+ {
+ // A extends cleanly; B extends A and adds a second reference onto the
+ // same target -- the inherited relationship becomes ambiguous on B even
+ // though A (and the relationship's own declaration) are untouched.
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "A", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "homeTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { "name": "winner", "@objectRef": "Team", "@cardinality": "one" } }
+ ]}},
+ { "object.entity": { "name": "B", "extends": "A", "children": [
+ { "field.long": { "name": "awayTeamId" } },
+ { "identity.reference": { "name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInline(doc);
+ Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code));
+ string joined = string.Join("\n", result.Errors.Select(e => e.Message));
+ Assert.Contains("B.winner", joined);
+ Assert.Contains("homeTeamRef(homeTeamId)", joined);
+ Assert.Contains("awayTeamRef(awayTeamId)", joined);
+ }
+
+ [Fact]
+ public void Inherited_relationship_resolved_by_child_added_reference_loads_clean()
+ {
+ // A child entity's added reference that name-pairs with the inherited
+ // relationship resolves cleanly.
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "A", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "homeTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "relationship.association": { "name": "awayTeam", "@objectRef": "Team", "@cardinality": "one" } }
+ ]}},
+ { "object.entity": { "name": "B", "extends": "A", "children": [
+ { "field.long": { "name": "awayTeamId" } },
+ { "identity.reference": { "name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }
+ ]}}
+ ]}}
+ """;
+ Assert.Empty(LoadInline(doc).Errors);
+ }
+
+ // -------------------------------------------------------------------------
+ // (D) Rule (d) dedupe -- own attrs never change per inheriting entity, so an
+ // inherited unmodified relationship must be reported exactly once.
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Rule_d_dedupe_single_error_for_one_inheriting_child()
+ {
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Program", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "A", "children": [
+ { "field.long": { "name": "id" } },
+ { "relationship.composition": { "name": "program", "@objectRef": "Program", "@cardinality": "one", "@through": "X" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "B", "extends": "A" } }
+ ]}}
+ """;
+ Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], LoadInline(doc).Errors.Select(e => e.Code));
+ }
+
+ [Fact]
+ public void Rule_d_dedupe_single_error_across_several_inheriting_children()
+ {
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Program", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "A", "children": [
+ { "field.long": { "name": "id" } },
+ { "relationship.composition": { "name": "program", "@objectRef": "Program", "@cardinality": "one", "@through": "X" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "B", "extends": "A" } },
+ { "object.entity": { "name": "C", "extends": "A" } },
+ { "object.entity": { "name": "D", "extends": "A" } }
+ ]}}
+ """;
+ Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], LoadInline(doc).Errors.Select(e => e.Code));
+ }
+
+ // -------------------------------------------------------------------------
+ // Two latent "obj vs. declaring entity" bugs the TS/Python authors flagged
+ // (but did not cover with a fixture) when rule (d) switched to resolving
+ // iteration -- see d49c3d921 / 7fab449a2. Regression-covered here.
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Inherited_self_join_relationship_is_not_misflagged_as_non_self_join()
+ {
+ // Node extends NodeBase, which declares a @symmetric self-join
+ // relationship onto NodeBase itself (@objectRef: "NodeBase"). Node is
+ // declared BEFORE NodeBase (extends is resolved order-independently by
+ // a deferred pass, so this is legal) so that the outer validation loop
+ // visits `obj = Node` FIRST -- if rule (a)'s self-join comparison used
+ // the visiting `obj` instead of the relationship's DECLARING entity
+ // (NodeBase, via rel.Parent), it would wrongly conclude @objectRef
+ // "NodeBase" is not the (visiting) declaring entity "Node" and misfire
+ // ERR_BAD_ATTR_VALUE -- and dedupe would then lock in that wrong result
+ // when NodeBase's own turn came second.
+ const string doc = """
+ { "metadata.root": { "package": "acme", "children": [
+ { "object.entity": { "name": "Node", "extends": "NodeBase", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "NodeBase", "@isAbstract": true, "children": [
+ { "relationship.association": { "name": "peers", "@cardinality": "many", "@objectRef": "NodeBase",
+ "@through": "NodeLink", "@symmetric": true } }
+ ]}},
+ { "object.entity": { "name": "NodeLink", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "aId" } },
+ { "field.long": { "name": "bId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "a", "@fields": ["aId"], "@references": "NodeBase" } },
+ { "identity.reference": { "name": "b", "@fields": ["bId"], "@references": "NodeBase" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInline(doc);
+ Assert.DoesNotContain(result.Errors, e => e.Code == ErrorCode.ERR_BAD_ATTR_VALUE);
+ Assert.DoesNotContain(result.Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP);
+ }
+
+ [Fact]
+ public void Inherited_bare_through_resolves_in_the_declaring_entity_package_not_the_visiting_one()
+ {
+ // WeekBase (package "base") declares a M:N relationship with a BARE
+ // @through "Tag" -- ADR-0042 says a bare ref resolves in the DECLARING
+ // entity's package ("base::Tag"), never the package of whichever entity
+ // inherits and visits it. Week extends WeekBase from a DIFFERENT package
+ // ("acme") that also happens to declare its own unrelated "Tag" entity.
+ // The acme source is loaded FIRST so the outer validation loop visits
+ // `obj = Week` before `obj = WeekBase` -- if @through resolution used
+ // the visiting entity's package it would wrongly bind to "acme::Tag"
+ // (which has zero identity.reference children) instead of "base::Tag"
+ // (which correctly has two), and dedupe would lock in that wrong result
+ // before WeekBase's own (correct) turn ever came.
+ const string baseDoc = """
+ { "metadata.root": { "package": "base", "children": [
+ { "object.entity": { "name": "WeekBase", "@isAbstract": true, "children": [
+ { "relationship.association": { "name": "tags", "@cardinality": "many", "@objectRef": "Tag", "@through": "Tag" } }
+ ]}},
+ { "object.entity": { "name": "Tag", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "weekId" } },
+ { "field.long": { "name": "labelId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "w", "@fields": ["weekId"], "@references": "base::WeekBase" } },
+ { "identity.reference": { "name": "l", "@fields": ["labelId"], "@references": "base::Tag" } }
+ ]}}
+ ]}}
+ """;
+ const string acmeDoc = """
+ { "metadata.root": { "package": "acme", "children": [
+ { "object.entity": { "name": "Week", "extends": "base::WeekBase", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Tag", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInlineMulti(acmeDoc, baseDoc);
+ Assert.DoesNotContain(result.Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP);
+ }
+
+ // -------------------------------------------------------------------------
+ // Regression: valid M:N still loads clean.
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Valid_hetero_m2m_produces_no_relationship_errors()
+ {
+ const string doc = """
+ { "metadata.root": { "package": "acme", "children": [
+ { "object.entity": { "name": "Post", "children": [
+ { "field.long": { "name": "id" } },
+ { "relationship.association": { "name": "tags", "@cardinality": "many", "@objectRef": "Tag", "@through": "PostTag" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Tag", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "PostTag", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "postId" } },
+ { "field.long": { "name": "tagId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "p", "@fields": ["postId"], "@references": "Post" } },
+ { "identity.reference": { "name": "t", "@fields": ["tagId"], "@references": "Tag" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInline(doc);
+ Assert.DoesNotContain(result.Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP);
+ Assert.DoesNotContain(result.Errors, e => e.Code == ErrorCode.ERR_BAD_ATTR_VALUE);
+ }
+}
diff --git a/server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs b/server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs
new file mode 100644
index 000000000..595b31cff
--- /dev/null
+++ b/server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs
@@ -0,0 +1,127 @@
+// Association -> identity.reference resolution (issue #368).
+//
+// An entity may declare more than one identity.reference onto the SAME target
+// entity (Match.homeTeamRef and Match.awayTeamRef both -> Team). A
+// `@cardinality: one` relationship names only its target, so when two
+// references match, the target alone cannot say which FK the navigation uses.
+// Taking the first match emits a join on the wrong column that typechecks, has
+// correct DDL and passes verify — so the ladder below resolves it explicitly or
+// not at all. ADR-0029 §5: ambiguity is a load error naming the candidates.
+//
+// Ported 1:1 from
+// typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts
+// (see also the Python port, relationship_references.py) — that file is the
+// authoritative spec; this module mirrors it exactly (same suffix list, same
+// order, same "candidate side only" stripping).
+
+using MetaObjects.Meta;
+
+namespace MetaObjects.Core.Relationship;
+
+///
+/// Which identity.reference does a @cardinality: one relationship
+/// navigate through, when its target entity carries more than one
+/// identity.reference candidate onto the same target? See
+/// for the four-step ladder.
+///
+public static class RelationshipReferences
+{
+ ///
+ /// Trailing suffixes stripped from a CANDIDATE's name/FK field when building its
+ /// pairing keys. Ordered — first match wins, so "reference" is tested before
+ /// "ref". Never applied to the relationship name (see ReferencePairingKeys).
+ ///
+ private static readonly string[] PairingSuffixes = ["reference", "ref", "id", "key"];
+
+ private static string StripOneSuffix(string value)
+ {
+ foreach (var suffix in PairingSuffixes)
+ {
+ if (value.Length > suffix.Length && value.EndsWith(suffix, StringComparison.Ordinal))
+ {
+ return value[..^suffix.Length];
+ }
+ }
+ return value;
+ }
+
+ /// The FK field a reference is anchored on (first field; composite FKs pair on their first column).
+ private static string? RefFkField(MetaReferenceIdentity reference)
+ {
+ var fields = reference.Fields;
+ return fields.Count > 0 ? fields[0] : null;
+ }
+
+ /// Last ::-segment of a (possibly package-qualified, possibly null/empty) name.
+ private static string StripPackage(string? name)
+ {
+ if (string.IsNullOrEmpty(name)) return "";
+ int idx = name.LastIndexOf(PACKAGE_SEPARATOR, StringComparison.Ordinal);
+ return idx < 0 ? name : name[(idx + PACKAGE_SEPARATOR.Length)..];
+ }
+
+ ///
+ /// The set of lowercased names a candidate reference answers to: its own
+ /// name and its FK field, each with and without one stripped suffix.
+ /// Ordinal, culture-invariant lowercasing — behaviour must not vary by locale.
+ ///
+ public static HashSet ReferencePairingKeys(MetaReferenceIdentity reference)
+ {
+ var keys = new HashSet();
+ void Add(string? value)
+ {
+ if (string.IsNullOrEmpty(value)) return;
+ string lower = value.ToLowerInvariant();
+ keys.Add(lower);
+ keys.Add(StripOneSuffix(lower));
+ }
+ Add(reference.Name);
+ Add(RefFkField(reference));
+ return keys;
+ }
+
+ ///
+ /// Every identity.reference on whose @references targets
+ /// . Package-insensitive on both sides: @references
+ /// and @objectRef may each be bare or fully qualified.
+ ///
+ public static List ReferenceCandidatesFor(MetaObject holder, string targetEntity)
+ {
+ string target = StripPackage(targetEntity);
+ // ADR-0039: resolving — ReferenceIdentities() honors references inherited via extends.
+ return holder.ReferenceIdentities()
+ .Where(r => StripPackage(r.TargetEntity) == target)
+ .Where(r => RefFkField(r) is not null)
+ .ToList();
+ }
+
+ ///
+ /// Which identity.reference does this @cardinality: one relationship
+ /// navigate through? The ladder, in order:
+ ///
+ /// - exactly one candidate -> that one (the common case; unchanged behaviour)
+ /// - @sourceRefField declared -> the candidate whose FK field it names,
+ /// SHORT-CIRCUITING (does not fall through to name-pairing on a miss)
+ /// - exactly one candidate name-pairs -> that one
+ /// - otherwise -> null (caller reports the ambiguity)
+ ///
+ /// Returns null for "no candidate" and "cannot choose" alike; callers that need to
+ /// tell them apart use .
+ ///
+ public static MetaReferenceIdentity? ResolveRelationshipReference(
+ MetaObject holder, string relationshipName, string targetEntity, string? sourceRefField = null)
+ {
+ var candidates = ReferenceCandidatesFor(holder, targetEntity);
+ if (candidates.Count == 0) return null;
+ if (candidates.Count == 1) return candidates[0];
+
+ if (!string.IsNullOrEmpty(sourceRefField))
+ {
+ return candidates.FirstOrDefault(r => RefFkField(r) == sourceRefField);
+ }
+
+ string wanted = relationshipName.ToLowerInvariant();
+ var paired = candidates.Where(r => ReferencePairingKeys(r).Contains(wanted)).ToList();
+ return paired.Count == 1 ? paired[0] : null;
+ }
+}
diff --git a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs
index 8c3c0506d..196a1a410 100644
--- a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs
+++ b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs
@@ -548,9 +548,20 @@ public LoadResult Load(IReadOnlyList sources)
// Pass 14 (FR-017): M:N relationship slim-vocabulary validation —
// symmetric-self-join-only / symmetric⊕sourceRefField (ERR_BAD_ATTR_VALUE);
// junction-two-references / sourceRefField-match / M:N-attr-on-1:N
- // (ERR_INVALID_RELATIONSHIP). Deferred-resolution (own-relationships only).
+ // (ERR_INVALID_RELATIONSHIP). Deferred-resolution, iterating the
+ // EFFECTIVE relationship set (own + inherited via extends), deduped by
+ // the relationship node's own identity (ADR-0039 / #368).
errors.AddRange(ValidationPasses.ValidateRelationships(root));
+ // Rule (e) (#368) — registered alongside ValidateRelationships (the
+ // M:N slim-vocabulary pass, above): a @cardinality: one relationship
+ // must resolve to exactly one identity.reference candidate on the
+ // EFFECTIVE entity; ambiguity is a load error naming the candidates
+ // (ERR_INVALID_RELATIONSHIP). No dedupe — the candidate set is a
+ // property of the effective entity, so a parent and a child can both
+ // be genuinely, independently ambiguous.
+ errors.AddRange(ValidationPasses.ValidateOneSideReferenceResolution(root));
+
// index.lookup @fields resolution — every index.lookup must name ≥1 field,
// and each must exist in the entity's effective field set (ERR_INVALID_INDEX).
errors.AddRange(ValidationPasses.ValidateIndexLookupFields(root));
diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs
index dcfb535f9..38af137b8 100644
--- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs
+++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs
@@ -10,6 +10,7 @@
using System.Text.RegularExpressions;
using MetaObjects.Core.Attr;
+using MetaObjects.Core.Relationship;
using MetaObjects.Core.Requirement;
using MetaObjects.Meta;
using MetaObjects.Persistence.Source;
@@ -3059,10 +3060,7 @@ public static IReadOnlyList ValidateFieldMap(MetaData root)
// Pass 14 (FR-017): ValidateRelationships — M:N slim-vocabulary rules.
//
// Deferred-resolution validation (runs after all files load + extends:
- // resolution, like origin paths), enforcing the cross-port M:N contract.
- // Iterates OWN relationships (a relationship is validated on the entity that
- // declares it — a declaration-structure walk), but reads each relationship's
- // inheritable M:N attrs via the RESOLVING Attr accessor (ADR-0039; TS parity):
+ // resolution, like origin paths), enforcing the cross-port M:N contract:
//
// (a) @symmetric:true is valid only on a self-join (@objectRef == declaring
// entity). Otherwise ERR_BAD_ATTR_VALUE.
@@ -3071,8 +3069,29 @@ public static IReadOnlyList ValidateFieldMap(MetaData root)
// must exist and declare exactly two identity.reference children;
// @sourceRefField (if present) must match one of those references' FK
// fields -> ERR_INVALID_RELATIONSHIP.
- // (d) @through / @sourceRefField / @symmetric are invalid on a non-M:N
- // relationship (@cardinality != "many", or no @through) -> ERR_INVALID_RELATIONSHIP.
+ // (d) @through / @symmetric are invalid on a non-M:N relationship
+ // (@cardinality != "many", or no @through) -> ERR_INVALID_RELATIONSHIP.
+ // @sourceRefField is also invalid there, EXCEPT on @cardinality: "one"
+ // (#368: it then names which of several identity.reference nodes onto
+ // the same target the relationship navigates).
+ //
+ // ADR-0039: resolving, not own-only (#368) — every rule above validates a
+ // property of the relationship's OWN declaration (its attrs, plus for rule
+ // (c) the @through target resolved against the root), so a relationship
+ // inherited via extends must be validated wherever it's visible, or a child
+ // entity that only SEES the relationship through inheritance could carry a
+ // violation no pass ever examines. That makes an inherited, UNMODIFIED
+ // relationship visited once per inheriting entity — checked once below
+ // (keyed on the relationship node's own identity), not once per entity,
+ // using the DECLARING entity (rel.Parent) rather than whichever entity's
+ // effective view got there first for every piece of context a rule reads
+ // (message text, @through's package per ADR-0042, rule (a)'s self-join
+ // comparison). That keeps each check's result independent of which entity
+ // triggered it, which is what makes "checked once" both sufficient and
+ // correct. An override replaces the node in place (effective-children), so
+ // it is never the same object as what it overrides and is never skipped
+ // against it — a genuinely different declaration is always independently
+ // checked.
//
// Ported from validateRelationships in
// typescript/packages/metadata/src/loader/validation-passes.ts.
@@ -3108,15 +3127,45 @@ public static IReadOnlyList ValidateRelationships(MetaData root)
{
var errors = new List();
- foreach (var obj in root.OwnChildren().Where(c => c.Type == TYPE_OBJECT))
+ // #368 — the inner loop below iterates the EFFECTIVE relationship set
+ // (obj.Relationships(), resolving), so a relationship inherited unmodified
+ // by N entities is reached N times. Its own attrs never change based on
+ // who inherits it, so re-validating it more than once would report the
+ // identical finding N times — pure noise. `checkedRels` is keyed on the
+ // relationship NODE's own object identity: effective-children reuses the
+ // super's child object in place for an unmodified inherited child, so the
+ // same physical declaration IS the same object everywhere it's visible,
+ // while an override replaces it with a genuinely different object
+ // (correctly NOT deduped — a distinct declaration is a distinct finding).
+ // Explicit ReferenceEqualityComparer — MetaData carries no Equals/
+ // GetHashCode override today, but identity-keyed dedupe must not
+ // silently degrade to value equality if one is added later.
+ var checkedRels = new HashSet(ReferenceEqualityComparer.Instance);
+
+ // ADR-0039: root has no super; OwnChildren()==Children(), but resolving
+ // is still the correct default (mirrors the TS/Python root.children()).
+ foreach (var obj in root.OwnChildren().Where(c => c.Type == TYPE_OBJECT).Cast())
{
- // ADR-0042 — a bare @through / @objectRef resolves in the declaring entity's package.
- string referrerPkg = NamingRefs.EffectivePackage(obj);
- foreach (var rel in obj.OwnChildren().Where(c => c.Type == TYPE_RELATIONSHIP))
+ // ADR-0039: resolving — rule (d) (like rule (e)) must see a
+ // relationship inherited via extends, not just this entity's own
+ // declarations. `checkedRels` absorbs the resulting revisits.
+ foreach (var rel in obj.Relationships())
{
- // ADR-0039: resolving — a relationship may inherit its M:N attrs via extends
- // (TS validation-passes.ts:1320-1324). Iterated via OwnChildren above (a rel is
- // validated on the entity that declares it), but its attrs may still be inherited.
+ if (!checkedRels.Add(rel)) continue;
+
+ // Every rule below validates a property of the relationship's OWN
+ // declaration (its attrs, plus for rule (c) the @through target), so
+ // context — the entity name in messages, the package a bare @through
+ // resolves in (ADR-0042), and rule (a)'s self-join comparison — is
+ // always the entity that DECLARES `rel` (rel.Parent), never `obj`
+ // (the entity whose effective view happened to reach it first). This
+ // keeps the check's result independent of iteration order/
+ // inheritance depth, which is what makes checking each node exactly
+ // once correct.
+ var declaringEntity = rel.Parent ?? obj;
+ string referrerPkg = NamingRefs.EffectivePackage(declaringEntity);
+
+ // ADR-0039: resolving — a relationship may inherit its M:N attrs via extends.
var through = rel.Attr(RELATIONSHIP_ATTR_THROUGH);
var sourceRefField = rel.Attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD);
bool symmetric = rel.Attr(RELATIONSHIP_ATTR_SYMMETRIC) is true;
@@ -3127,6 +3176,7 @@ public static IReadOnlyList ValidateRelationships(MetaData root)
bool hasSourceRefField = sourceRefField is string srs && srs.Length > 0;
bool isMany = cardinality is string cs && cs == CARDINALITY_MANY;
bool isM2M = hasThrough && isMany;
+ bool isCardinalityOne = cardinality is string co && co == CARDINALITY_ONE;
// NOTE: @objectRef existence resolution moved to the validation registry
// (a declarative ReferenceDescriptor on relationship.* TypeDefinitions,
@@ -3138,22 +3188,28 @@ public static IReadOnlyList ValidateRelationships(MetaData root)
if (hasThrough)
{
errors.Add(new MetaError(
- $"relationship \"{obj.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_THROUGH} but is not a M:N " +
+ $"relationship \"{declaringEntity.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_THROUGH} but is not a M:N " +
$"relationship (requires @{RELATIONSHIP_ATTR_CARDINALITY}: \"{CARDINALITY_MANY}\").",
ErrorCode.ERR_INVALID_RELATIONSHIP,
Envelope: rel.Source));
}
- if (hasSourceRefField)
+ // #368: @sourceRefField also disambiguates a `@cardinality: one`
+ // relationship when the entity holds more than one
+ // identity.reference onto the same target. Only the M:N
+ // *junction* reading is rejected here; rule (e) —
+ // ValidateOneSideReferenceResolution, below in this file —
+ // checks that it names a real local reference.
+ if (hasSourceRefField && !isCardinalityOne)
{
errors.Add(new MetaError(
- $"relationship \"{obj.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.",
+ $"relationship \"{declaringEntity.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.",
ErrorCode.ERR_INVALID_RELATIONSHIP,
Envelope: rel.Source));
}
if (symmetric)
{
errors.Add(new MetaError(
- $"relationship \"{obj.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.",
+ $"relationship \"{declaringEntity.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.",
ErrorCode.ERR_INVALID_RELATIONSHIP,
Envelope: rel.Source));
}
@@ -3164,7 +3220,7 @@ public static IReadOnlyList ValidateRelationships(MetaData root)
if (symmetric && hasSourceRefField)
{
errors.Add(new MetaError(
- $"relationship \"{obj.Name}.{rel.Name}\" sets both @{RELATIONSHIP_ATTR_SYMMETRIC} and " +
+ $"relationship \"{declaringEntity.Name}.{rel.Name}\" sets both @{RELATIONSHIP_ATTR_SYMMETRIC} and " +
$"@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD}; they are mutually exclusive.",
ErrorCode.ERR_BAD_ATTR_VALUE,
Envelope: rel.Source));
@@ -3175,12 +3231,12 @@ public static IReadOnlyList ValidateRelationships(MetaData root)
// package is self, but an FQN "other::Widget" (a different same-short-name entity)
// is NOT (comparing stripped short names would misclassify it).
bool isSelfJoin = objectRef is string objRefStr &&
- ReferenceEquals(NamingRefs.ResolveObjectRef(root, objRefStr, referrerPkg), obj);
+ ReferenceEquals(NamingRefs.ResolveObjectRef(root, objRefStr, referrerPkg), declaringEntity);
if (symmetric && !isSelfJoin)
{
errors.Add(new MetaError(
- $"relationship \"{obj.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SYMMETRIC} but @{RELATIONSHIP_ATTR_OBJECT_REF} " +
- $"\"{objectRef}\" is not the declaring entity \"{obj.Name}\"; @{RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.",
+ $"relationship \"{declaringEntity.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SYMMETRIC} but @{RELATIONSHIP_ATTR_OBJECT_REF} " +
+ $"\"{objectRef}\" is not the declaring entity \"{declaringEntity.Name}\"; @{RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.",
ErrorCode.ERR_BAD_ATTR_VALUE,
Envelope: rel.Source));
}
@@ -3191,9 +3247,9 @@ public static IReadOnlyList ValidateRelationships(MetaData root)
if (junction is null)
{
errors.Add(new MetaError(
- $"relationship \"{obj.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_THROUGH} \"{through}\" does not resolve to an entity.",
+ $"relationship \"{declaringEntity.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_THROUGH} \"{through}\" does not resolve to an entity.",
ErrorCode.ERR_INVALID_RELATIONSHIP,
- Envelope: ResolvedSource.From(rel.Source, $"{obj.Fqn()}::{rel.Name}", (string)through!)));
+ Envelope: ResolvedSource.From(rel.Source, $"{declaringEntity.Fqn()}::{rel.Name}", (string)through!)));
continue;
}
// A junction is a physical join table — it MUST be an object.entity. ADR-0046
@@ -3203,7 +3259,7 @@ public static IReadOnlyList ValidateRelationships(MetaData root)
if (junction.SubType != OBJECT_SUBTYPE_ENTITY)
{
errors.Add(new MetaError(
- $"relationship \"{obj.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_THROUGH} \"{through}\" resolves to " +
+ $"relationship \"{declaringEntity.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_THROUGH} \"{through}\" resolves to " +
$"{junction.Type}.{junction.SubType}, not an entity — a junction is a persisted join table " +
"and must be object.entity.",
ErrorCode.ERR_INVALID_RELATIONSHIP,
@@ -3214,7 +3270,7 @@ public static IReadOnlyList ValidateRelationships(MetaData root)
if (refCount != 2)
{
errors.Add(new MetaError(
- $"relationship \"{obj.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_THROUGH} \"{through}\" must declare exactly two " +
+ $"relationship \"{declaringEntity.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_THROUGH} \"{through}\" must declare exactly two " +
$"identity.reference children (one per FK side); found {refCount}.",
ErrorCode.ERR_INVALID_RELATIONSHIP,
Envelope: rel.Source));
@@ -3227,7 +3283,7 @@ public static IReadOnlyList ValidateRelationships(MetaData root)
if (!fkFields.Contains((string)sourceRefField!, StringComparer.Ordinal))
{
errors.Add(new MetaError(
- $"relationship \"{obj.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} \"{sourceRefField}\" does not match " +
+ $"relationship \"{declaringEntity.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} \"{sourceRefField}\" does not match " +
$"any identity.reference FK field on junction \"{through}\". Available: {(fkFields.Count > 0 ? string.Join(", ", fkFields) : "(none)")}.",
ErrorCode.ERR_INVALID_RELATIONSHIP,
Envelope: rel.Source));
@@ -3239,6 +3295,106 @@ public static IReadOnlyList ValidateRelationships(MetaData root)
return errors.AsReadOnly();
}
+ // ---------------------------------------------------------------------------
+ // Rule (e) — #368: a `@cardinality: one` relationship must resolve to exactly
+ // one identity.reference. Two references onto the same target are
+ // indistinguishable from the relationship's @objectRef alone, so the resolver
+ // would silently emit the first one's FK column. ADR-0029 §5: ambiguity is a
+ // load error naming the candidates.
+ //
+ // Registered alongside ValidateRelationships (the M:N slim-vocabulary pass,
+ // above) — same deferred-resolution timing (after all files load + extends
+ // resolution).
+ //
+ // Scope differs deliberately from rule (d): rule (d) validates attrs that
+ // travel with the relationship's OWN declaration (@through/@symmetric/
+ // @sourceRefField), so own-scoping there is correct — those attrs don't
+ // change meaning depending on who inherits the relationship. Rule (e)
+ // instead validates whether THIS entity's reference set resolves the
+ // relationship uniquely, which is a property of the EFFECTIVE entity, not of
+ // wherever the relationship happens to be declared. A child entity that
+ // extends a clean parent and adds a second identity.reference onto the same
+ // target makes an INHERITED relationship ambiguous on the child even though
+ // the parent (and the relationship's own declaration) are untouched — own-
+ // scoping this pass would leave that case unchecked, and codegen/runtime
+ // (which resolve against the effective entity) would silently drop the
+ // relation. If a parent and a child are both genuinely ambiguous, both are
+ // reported — two entities are broken, not one error duplicated. (No dedupe
+ // here, unlike rule (d): rule (e)'s candidate set genuinely differs per entity.)
+ // ---------------------------------------------------------------------------
+
+ public static IReadOnlyList ValidateOneSideReferenceResolution(MetaRoot root)
+ {
+ var errors = new List();
+ foreach (var obj in root.Objects())
+ {
+ // ADR-0039: resolving — see the scope note above: rule (e) checks THIS
+ // entity's effective reference set against every relationship it can see,
+ // including one only inherited via extends.
+ foreach (var rel in obj.Relationships())
+ {
+ // ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends.
+ if (rel.Attr(RELATIONSHIP_ATTR_CARDINALITY) is not string cardinality || cardinality != CARDINALITY_ONE) continue;
+ if (rel.Attr(RELATIONSHIP_ATTR_OBJECT_REF) is not string objectRef || objectRef.Length == 0) continue;
+
+ var candidates = RelationshipReferences.ReferenceCandidatesFor(obj, objectRef);
+
+ var sourceRefField = rel.Attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD);
+ string? declared = sourceRefField is string srf && srf.Length > 0 ? srf : null;
+
+ if (declared is not null)
+ {
+ // A declared @sourceRefField short-circuits the ladder at ANY
+ // candidate count — checked independently of
+ // RelationshipReferences.ResolveRelationshipReference, whose step 1
+ // ("exactly one candidate -> that one") would otherwise silently
+ // return the lone candidate even when it disagrees with the
+ // declared field. The author named a specific FK; it must exist,
+ // whether there are zero, one, or many candidates.
+ bool matchesDeclared = candidates.Any(c => c.Fields.Count > 0 && c.Fields[0] == declared);
+ if (matchesDeclared) continue;
+ errors.Add(new MetaError(
+ $"relationship \"{obj.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} " +
+ $"\"{declared}\", which names no identity.reference targeting \"{objectRef}\". " +
+ $"Candidates: {FormatReferenceCandidates(candidates)}.",
+ ErrorCode.ERR_INVALID_RELATIONSHIP,
+ Envelope: rel.Source));
+ continue;
+ }
+
+ // No @sourceRefField declared: ambiguity only exists with 2+
+ // candidates — ResolveRelationshipReference's name-pairing step
+ // (ladder step 3) decides.
+ if (candidates.Count <= 1) continue;
+ var resolved = RelationshipReferences.ResolveRelationshipReference(obj, rel.Name, objectRef);
+ if (resolved is not null) continue;
+
+ errors.Add(new MetaError(
+ $"relationship \"{obj.Name}.{rel.Name}\" is ambiguous: \"{obj.Name}\" declares " +
+ $"{candidates.Count} identity.reference nodes targeting \"{objectRef}\" and the " +
+ "relationship name does not pair with exactly one. Candidates: " +
+ $"{FormatReferenceCandidates(candidates)}. Set @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} to the FK field this " +
+ "relationship navigates.",
+ ErrorCode.ERR_INVALID_RELATIONSHIP,
+ Envelope: rel.Source));
+ }
+ }
+ return errors.AsReadOnly();
+ }
+
+ ///
+ /// Render a candidate reference as name(fkField), or name(fieldA, fieldB)
+ /// for a composite reference — so two composite references sharing a first column still
+ /// print distinguishably.
+ ///
+ /// NOTE: this is display only. Matching (both here and in
+ /// RelationshipReferences.ResolveRelationshipReference) still keys on the first field
+ /// alone — a composite reference cannot actually be disambiguated by @sourceRefField.
+ /// That's a documented limitation, not fixed by this rendering.
+ ///
+ private static string FormatReferenceCandidates(IReadOnlyList candidates) =>
+ string.Join(", ", candidates.Select(c => $"{c.Name}({string.Join(", ", c.Fields)})"));
+
// NOTE: identity.reference @references resolution moved to the validation registry
// (a declarative ReferenceDescriptor with dottedFieldPath on the identity.reference
// TypeDefinition, resolved by RegisteredValidation).
From 441e4b4c12a635b5a121d682bad32640f8eceb32 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 17:51:17 -0400
Subject: [PATCH 14/31] fix(loader,java): 1:N reference resolution parity
(#368)
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../metaobjects/loader/ValidationPhase.java | 288 +++++++--
.../relationship/RelationshipReferences.java | 172 ++++++
...sue368RelationshipReferenceLadderTest.java | 174 ++++++
...68RelationshipReferenceValidationTest.java | 581 ++++++++++++++++++
4 files changed, 1154 insertions(+), 61 deletions(-)
create mode 100644 server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java
create mode 100644 server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceLadderTest.java
create mode 100644 server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java
diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
index e6dcdcb29..f066eb056 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
@@ -52,6 +52,7 @@
import com.metaobjects.origin.MetaOrigin;
import com.metaobjects.origin.PassthroughOrigin;
import com.metaobjects.relationship.MetaRelationship;
+import com.metaobjects.relationship.RelationshipReferences;
import com.metaobjects.requirement.MetaRequirement;
import com.metaobjects.attr.MetaAttribute;
import com.metaobjects.registry.ChildRequirement;
@@ -195,7 +196,21 @@ public static void run(MetaRoot root, MetaDataLoader loader) {
// Sibling of the source-role pass above; must run after it.
pass(collected, () -> validateSourceEscapes(root, loader));
pass(collected, () -> validateRelationshipReferentialActions(root));
- pass(collected, () -> validateRelationshipsM2M(root));
+ // Pass 14 (FR-017): M:N relationship slim-vocabulary validation — collects
+ // EVERY violation across the effective relationship set (own + inherited via
+ // extends), deduped by the relationship node's own identity (#368 / ADR-0039).
+ for (MetaDataException e : validateRelationshipsM2M(root)) {
+ collected.add(e);
+ }
+ // Rule (e) (#368) — registered alongside validateRelationshipsM2M above: a
+ // @cardinality: one relationship must resolve to exactly one identity.reference
+ // candidate on the EFFECTIVE entity; ambiguity is a load error naming the
+ // candidates (ERR_INVALID_RELATIONSHIP). No dedupe — the candidate set is a
+ // property of the effective entity, so a parent and a child can both be
+ // genuinely, independently ambiguous.
+ for (MetaDataException e : validateOneSideReferenceResolution(root)) {
+ collected.add(e);
+ }
// ADR-0042 — the cross-package ambiguity pass (ERR_AMBIGUOUS_REF) is RETIRED. A bare
// reference now resolves package-locally (referrer's package, else root-level) at every
// ref site (SymbolTable / resolveRootObject), so cross-package ambiguity is unreachable;
@@ -1670,44 +1685,76 @@ private static void validateRelationshipNode(MetaData node) {
// (c) When @through is present (M:N): the named entity must exist and declare
// exactly two identity.reference children; @sourceRefField (if present)
// must match one of those references' FK fields → ERR_INVALID_RELATIONSHIP.
- // (d) @through / @sourceRefField / @symmetric are invalid on a non-M:N
- // relationship (@cardinality != "many", or no @through) → ERR_INVALID_RELATIONSHIP.
+ // (d) @through / @symmetric are invalid on a non-M:N relationship
+ // (@cardinality != "many", or no @through) → ERR_INVALID_RELATIONSHIP.
+ // @sourceRefField is also invalid there, EXCEPT on @cardinality: "one"
+ // (#368: it then names which of several identity.reference nodes onto
+ // the same target the relationship navigates — rule (e), below, checks
+ // that it names a real local reference).
//
- // Own-relationships only: a relationship is validated on the entity that
- // declares it (matching the own-attrs policy of the other passes). Eager-throw
- // on the first violation, like the rest of this phase. The thrown source is the
- // relationship node's own JsonSource, so the cross-port envelope jsonPath points
- // at the relationship node — matching the shared error fixtures.
+ // ADR-0039: resolving, not own-only (#368) — every rule above validates a
+ // property of the relationship's OWN declaration (its attrs, plus for rule
+ // (c) the @through target resolved against the root), so a relationship
+ // inherited via extends must be validated wherever it's visible, or a child
+ // entity that only SEES the relationship through inheritance could carry a
+ // violation no pass ever examines. That makes an inherited, UNMODIFIED
+ // relationship visited once per inheriting entity — checked once below
+ // (keyed on the relationship node's own identity via an IdentityHashMap-
+ // backed set, not a plain HashSet — MetaData may override equals/hashCode
+ // and dedup here must be by reference), not once per entity, using the
+ // DECLARING entity (rel.getParent()) rather than whichever entity's
+ // effective view got there first for every piece of context a rule reads
+ // (message text, @through's package per ADR-0042, rule (a)'s self-join
+ // comparison). That keeps each check's result independent of which entity
+ // triggered it, which is what makes "checked once" both sufficient and
+ // correct. An override REPLACES the node in the overriding entity's own
+ // children (a genuinely different object), so it is never skipped against
+ // the node it overrides — a distinct declaration is always independently
+ // checked. This relies on Java's effective-children walk reusing the SAME
+ // physical child object for an unmodified inherited relationship (see
+ // MetaData.addParentChildren, which walks up to the super's own children
+ // collection rather than cloning) — verified against
+ // M2MSlimVocabularyTest#junctionWithInheritedReferencesLoadsCleanly's
+ // sibling mechanism and the new dedupe tests below.
+ //
+ // Collects EVERY violation (not eager-throw-and-stop) so a genuinely
+ // distinct defect on a sibling relationship is never masked by an earlier
+ // one — mirrors the TS/C#/Python ParseError[]/MetaError[] collection.
// =========================================================================
- static void validateRelationshipsM2M(MetaRoot root) {
- for (MetaData rootChild : root.getChildren(MetaData.class, false)) {
- walkRelationshipsM2M(root, rootChild);
- }
- }
-
- private static void walkRelationshipsM2M(MetaRoot root, MetaData node) {
- if (node instanceof MetaObject) {
- validateObjectRelationshipsM2M(root, (MetaObject) node);
- }
- for (MetaData child : node.getChildren(MetaData.class, false)) {
- walkRelationshipsM2M(root, child);
- }
- }
-
- private static void validateObjectRelationshipsM2M(MetaRoot root, MetaObject obj) {
- for (MetaData child : obj.getChildren(MetaData.class, false)) {
- if (!(child instanceof MetaRelationship)) continue;
- MetaRelationship rel = (MetaRelationship) child;
- validateRelationshipM2MNode(root, obj, rel);
+ static List validateRelationshipsM2M(MetaRoot root) {
+ List errors = new java.util.ArrayList<>();
+ java.util.Set checkedRels =
+ java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
+ // ADR-0039: root has no super; root.objects() is the complete top-level set.
+ for (MetaObject obj : root.objects()) {
+ // ADR-0039: resolving — rule (d) (like rule (e)) must see a relationship
+ // inherited via extends, not just this entity's own declarations.
+ // `checkedRels` absorbs the resulting revisits.
+ for (MetaRelationship rel : obj.getRelationships()) {
+ if (!checkedRels.add(rel)) continue;
+ validateRelationshipM2MNode(root, obj, rel, errors);
+ }
}
+ return errors;
}
private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj,
- MetaRelationship rel) {
+ MetaRelationship rel,
+ List errors) {
+ // Every rule below validates a property of the relationship's OWN
+ // declaration (its attrs, plus for rule (c) the @through target), so
+ // context — the entity name in messages, the package a bare @through
+ // resolves in (ADR-0042), and rule (a)'s self-join comparison — is
+ // always the entity that DECLARES `rel` (rel.getParent()), never `obj`
+ // (the entity whose effective view happened to reach it first). This
+ // keeps the check's result independent of iteration order/inheritance
+ // depth, which is what makes checking each node exactly once correct.
+ MetaData relParent = rel.getParent();
+ MetaObject declaringEntity = (relParent instanceof MetaObject) ? (MetaObject) relParent : obj;
// ADR-0042 — a bare @through / @objectRef self-join resolves in the declaring
// entity's package (an FQN resolves exactly).
- String referrerPkg = obj.getPackage() == null ? "" : obj.getPackage();
+ String referrerPkg = declaringEntity.getPackage() == null ? "" : declaringEntity.getPackage();
// ADR-0039: resolving — a relationship may inherit its M:N slim-vocabulary
// attrs (@through/@sourceRefField/@objectRef/@cardinality/@symmetric) via
// extends. Mirrors TS validateRelationships (validation-passes.ts:1320-1324),
@@ -1726,6 +1773,7 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj,
boolean hasSourceRefField = sourceRefField != null && !sourceRefField.isEmpty();
boolean isMany = MetaRelationship.CARDINALITY_MANY.equals(cardinality);
boolean isM2M = hasThrough && isMany;
+ boolean isCardinalityOne = MetaRelationship.CARDINALITY_ONE.equals(cardinality);
// NOTE: @objectRef existence resolution moved to the validation registry
// (RegisteredValidation.defaultRegistry → a declarative reference descriptor).
@@ -1734,42 +1782,47 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj,
// Rule (d): M:N-only attrs on a non-M:N relationship.
if (!isM2M) {
if (hasThrough) {
- throw new MetaDataException(
+ errors.add(new MetaDataException(
ErrorMessageConstants.ERR_INVALID_RELATIONSHIP
- + ": relationship \"" + obj.getShortName() + "." + rel.getShortName()
+ + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName()
+ "\" sets @" + MetaRelationship.ATTR_THROUGH
+ " but is not a M:N relationship (requires @"
+ MetaRelationship.ATTR_CARDINALITY + ": \""
+ MetaRelationship.CARDINALITY_MANY + "\").",
- ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource());
- }
- if (hasSourceRefField) {
- throw new MetaDataException(
+ ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()));
+ }
+ // #368: @sourceRefField also disambiguates a `@cardinality: one`
+ // relationship when the entity holds more than one identity.reference
+ // onto the same target. Only the M:N *junction* reading is rejected
+ // here; rule (e) — validateOneSideReferenceResolution, below — checks
+ // that it names a real local reference.
+ if (hasSourceRefField && !isCardinalityOne) {
+ errors.add(new MetaDataException(
ErrorMessageConstants.ERR_INVALID_RELATIONSHIP
- + ": relationship \"" + obj.getShortName() + "." + rel.getShortName()
+ + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName()
+ "\" sets @" + MetaRelationship.ATTR_SOURCE_REF_FIELD
+ " but is not a M:N relationship.",
- ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource());
+ ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()));
}
if (symmetric) {
- throw new MetaDataException(
+ errors.add(new MetaDataException(
ErrorMessageConstants.ERR_INVALID_RELATIONSHIP
- + ": relationship \"" + obj.getShortName() + "." + rel.getShortName()
+ + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName()
+ "\" sets @" + MetaRelationship.ATTR_SYMMETRIC
+ " but is not a M:N relationship.",
- ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource());
+ ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()));
}
return;
}
// Rule (b): @symmetric and @sourceRefField are mutually exclusive.
if (symmetric && hasSourceRefField) {
- throw new MetaDataException(
+ errors.add(new MetaDataException(
ErrorMessageConstants.ERR_BAD_ATTR_VALUE
- + ": relationship \"" + obj.getShortName() + "." + rel.getShortName()
+ + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName()
+ "\" sets both @" + MetaRelationship.ATTR_SYMMETRIC + " and @"
+ MetaRelationship.ATTR_SOURCE_REF_FIELD + "; they are mutually exclusive.",
- ErrorCode.ERR_BAD_ATTR_VALUE, rel.getSource());
+ ErrorCode.ERR_BAD_ATTR_VALUE, rel.getSource()));
}
// Rule (a): @symmetric is valid only on a self-join (@objectRef == declaring entity).
@@ -1777,16 +1830,16 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj,
// package is self, but an FQN "other::Widget" (a different same-short-name entity) is
// NOT (comparing stripped short names would misclassify it).
boolean isSelfJoin = objectRef != null
- && resolveRootObject(root, objectRef, referrerPkg) == obj;
+ && resolveRootObject(root, objectRef, referrerPkg) == declaringEntity;
if (symmetric && !isSelfJoin) {
- throw new MetaDataException(
+ errors.add(new MetaDataException(
ErrorMessageConstants.ERR_BAD_ATTR_VALUE
- + ": relationship \"" + obj.getShortName() + "." + rel.getShortName()
+ + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName()
+ "\" sets @" + MetaRelationship.ATTR_SYMMETRIC + " but @"
+ MetaRelationship.ATTR_OBJECT_REF + " \"" + objectRef
- + "\" is not the declaring entity \"" + obj.getShortName()
+ + "\" is not the declaring entity \"" + declaringEntity.getShortName()
+ "\"; @" + MetaRelationship.ATTR_SYMMETRIC + " is self-join-only.",
- ErrorCode.ERR_BAD_ATTR_VALUE, rel.getSource());
+ ErrorCode.ERR_BAD_ATTR_VALUE, rel.getSource()));
}
// Rule (c): @through must name an entity declaring exactly two identity.reference children.
@@ -1794,53 +1847,166 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj,
// cross-package @through no longer binds a junction in another package.
MetaObject junction = resolveRootObject(root, through, referrerPkg);
if (junction == null) {
- throw new MetaDataException(
+ errors.add(new MetaDataException(
ErrorMessageConstants.ERR_INVALID_RELATIONSHIP
- + ": relationship \"" + obj.getShortName() + "." + rel.getShortName()
+ + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName()
+ "\" @" + MetaRelationship.ATTR_THROUGH + " \"" + through
+ "\" does not resolve to an entity." + didYouMeanHint(root, through),
ErrorCode.ERR_INVALID_RELATIONSHIP,
- ResolvedSource.from(rel.getSource(), obj.getShortName() + "::" + rel.getShortName(), through));
+ ResolvedSource.from(rel.getSource(), declaringEntity.getShortName() + "::" + rel.getShortName(), through)));
+ return;
}
// A junction is a physical join table — it MUST be an object.entity. ADR-0046
// lets a value carry navigation-only references, so value-purity no longer
// implicitly guarantees a two-reference junction is an entity; assert it here.
// (A value/projection has no table to join through.)
if (!MetaObject.SUBTYPE_ENTITY.equals(junction.getSubType())) {
- throw new MetaDataException(
+ errors.add(new MetaDataException(
ErrorMessageConstants.ERR_INVALID_RELATIONSHIP
- + ": relationship \"" + obj.getShortName() + "." + rel.getShortName()
+ + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName()
+ "\" @" + MetaRelationship.ATTR_THROUGH + " \"" + through
+ "\" resolves to " + junction.getType() + "." + junction.getSubType()
+ ", not an entity — a junction is a persisted join table and must be object.entity.",
- ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource());
+ ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()));
+ return;
}
int refCount = countJunctionReferences(junction);
if (refCount != 2) {
- throw new MetaDataException(
+ errors.add(new MetaDataException(
ErrorMessageConstants.ERR_INVALID_RELATIONSHIP
- + ": relationship \"" + obj.getShortName() + "." + rel.getShortName()
+ + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName()
+ "\" @" + MetaRelationship.ATTR_THROUGH + " \"" + through
+ "\" must declare exactly two identity.reference children"
+ " (one per FK side); found " + refCount + ".",
- ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource());
+ ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()));
+ return;
}
// @sourceRefField (if present) must match one of the junction's reference FK fields.
if (hasSourceRefField) {
List fkFields = junctionReferenceFkFields(junction);
if (!fkFields.contains(sourceRefField)) {
- throw new MetaDataException(
+ errors.add(new MetaDataException(
ErrorMessageConstants.ERR_INVALID_RELATIONSHIP
- + ": relationship \"" + obj.getShortName() + "." + rel.getShortName()
+ + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName()
+ "\" @" + MetaRelationship.ATTR_SOURCE_REF_FIELD + " \"" + sourceRefField
+ "\" does not match any identity.reference FK field on junction \""
+ through + "\". Available: "
+ (fkFields.isEmpty() ? "(none)" : String.join(", ", fkFields)) + ".",
- ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource());
+ ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()));
}
}
}
+ // =========================================================================
+ // Rule (e) — #368: a `@cardinality: one` relationship must resolve to exactly
+ // one identity.reference. Two references onto the same target are
+ // indistinguishable from the relationship's @objectRef alone, so the resolver
+ // would silently emit the first one's FK column. ADR-0029 §5: ambiguity is a
+ // load error naming the candidates.
+ //
+ // Registered alongside validateRelationshipsM2M (the M:N slim-vocabulary pass,
+ // above) — same deferred-resolution timing (after all files load + extends
+ // resolution).
+ //
+ // Scope differs deliberately from rule (d): rule (d) validates attrs that
+ // travel with the relationship's OWN declaration (@through/@symmetric/
+ // @sourceRefField), so own-scoping there is correct — those attrs don't
+ // change meaning depending on who inherits the relationship. Rule (e)
+ // instead validates whether THIS entity's reference set resolves the
+ // relationship uniquely, which is a property of the EFFECTIVE entity, not of
+ // wherever the relationship happens to be declared. A child entity that
+ // extends a clean parent and adds a second identity.reference onto the same
+ // target makes an INHERITED relationship ambiguous on the child even though
+ // the parent (and the relationship's own declaration) are untouched — own-
+ // scoping this pass would leave that case unchecked, and codegen/runtime
+ // (which resolve against the effective entity) would silently drop the
+ // relation. If a parent and a child are both genuinely ambiguous, both are
+ // reported — two entities are broken, not one error duplicated. (No dedupe
+ // here, unlike rule (d): rule (e)'s candidate set genuinely differs per entity.)
+ // =========================================================================
+
+ static List validateOneSideReferenceResolution(MetaRoot root) {
+ List errors = new java.util.ArrayList<>();
+ for (MetaObject obj : root.objects()) {
+ // ADR-0039: resolving — see the scope note above: rule (e) checks THIS
+ // entity's effective reference set against every relationship it can
+ // see, including one only inherited via extends.
+ for (MetaRelationship rel : obj.getRelationships()) {
+ // ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends.
+ if (!MetaRelationship.CARDINALITY_ONE.equals(rel.getCardinality())) continue;
+ String objectRef = rel.getObjectRef();
+ if (objectRef == null || objectRef.isEmpty()) continue;
+
+ List candidates =
+ RelationshipReferences.referenceCandidatesFor(obj, objectRef);
+
+ String sourceRefField = rel.getSourceRefField();
+ String declared = (sourceRefField != null && !sourceRefField.isEmpty())
+ ? sourceRefField : null;
+
+ if (declared != null) {
+ // A declared @sourceRefField short-circuits the ladder at ANY
+ // candidate count — checked independently of
+ // RelationshipReferences.resolveRelationshipReference, whose
+ // step 1 ("exactly one candidate -> that one") would otherwise
+ // silently return the lone candidate even when it disagrees
+ // with the declared field. The author named a specific FK; it
+ // must exist, whether there are zero, one, or many candidates.
+ boolean matchesDeclared = candidates.stream().anyMatch(c -> {
+ List fields = c.getFields();
+ return !fields.isEmpty() && declared.equals(fields.get(0));
+ });
+ if (matchesDeclared) continue;
+ errors.add(new MetaDataException(
+ ErrorMessageConstants.ERR_INVALID_RELATIONSHIP
+ + ": relationship \"" + obj.getShortName() + "." + rel.getShortName()
+ + "\" sets @" + MetaRelationship.ATTR_SOURCE_REF_FIELD + " \"" + declared
+ + "\", which names no identity.reference targeting \"" + objectRef
+ + "\". Candidates: " + formatReferenceCandidates(candidates) + ".",
+ ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()));
+ continue;
+ }
+
+ // No @sourceRefField declared: ambiguity only exists with 2+
+ // candidates — resolveRelationshipReference's name-pairing step
+ // (ladder step 3) decides.
+ if (candidates.size() <= 1) continue;
+ ReferenceIdentity resolved = RelationshipReferences.resolveRelationshipReference(
+ obj, rel.getShortName(), objectRef);
+ if (resolved != null) continue;
+
+ errors.add(new MetaDataException(
+ ErrorMessageConstants.ERR_INVALID_RELATIONSHIP
+ + ": relationship \"" + obj.getShortName() + "." + rel.getShortName()
+ + "\" is ambiguous: \"" + obj.getShortName() + "\" declares " + candidates.size()
+ + " identity.reference nodes targeting \"" + objectRef + "\" and the relationship"
+ + " name does not pair with exactly one. Candidates: "
+ + formatReferenceCandidates(candidates) + ". Set @"
+ + MetaRelationship.ATTR_SOURCE_REF_FIELD + " to the FK field this relationship navigates.",
+ ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()));
+ }
+ }
+ return errors;
+ }
+
+ /** Render a candidate reference as {@code name(fkField)}, or {@code name(fieldA, fieldB)}
+ * for a composite reference — so two composite references sharing a first column still
+ * print distinguishably.
+ *
+ * NOTE: this is display only. Matching (both here and in
+ * {@link RelationshipReferences#resolveRelationshipReference}) still keys on the first
+ * field alone — a composite reference cannot actually be disambiguated by
+ * @sourceRefField. That's a documented limitation, not fixed by this rendering.
*/
+ private static String formatReferenceCandidates(List candidates) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < candidates.size(); i++) {
+ if (i > 0) sb.append(", ");
+ ReferenceIdentity c = candidates.get(i);
+ sb.append(c.getShortName()).append('(').append(String.join(", ", c.getFields())).append(')');
+ }
+ return sb.toString();
+ }
+
/** Count a junction's {@code identity.reference} children.
* ADR-0039: resolving — a junction may inherit an {@code identity.reference}
* via extends, so the FK direction/count must be judged on the EFFECTIVE view.
diff --git a/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java b/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java
new file mode 100644
index 000000000..29706a175
--- /dev/null
+++ b/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2003 Doug Mealing LLC dba Meta Objects
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.metaobjects.relationship;
+
+import com.metaobjects.MetaData;
+import com.metaobjects.identity.MetaIdentity;
+import com.metaobjects.identity.ReferenceIdentity;
+import com.metaobjects.object.MetaObject;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Association -> identity.reference resolution (issue #368).
+ *
+ * An entity may declare more than one identity.reference onto the SAME target
+ * entity (Match.homeTeamRef and Match.awayTeamRef both -> Team). A
+ * {@code @cardinality: one} relationship names only its target, so when two
+ * references match, the target alone cannot say which FK the navigation uses.
+ * Taking the first match emits a join on the wrong column that typechecks, has
+ * correct DDL and passes verify — so the ladder below resolves it explicitly or
+ * not at all. ADR-0029 §5: ambiguity is a load error naming the candidates.
+ *
+ * Ported 1:1 from
+ * {@code typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts}
+ * (see also the Python port, {@code relationship_references.py}, and the C# port,
+ * {@code MetaObjects.Core.Relationship.RelationshipReferences}) — that file is the
+ * authoritative spec; this class mirrors it exactly (same suffix list, same order,
+ * same "candidate side only" stripping).
+ */
+public final class RelationshipReferences {
+
+ private RelationshipReferences() {
+ // static utility class
+ }
+
+ /**
+ * Trailing suffixes stripped from a CANDIDATE's name/FK field when building its
+ * pairing keys. Ordered — first match wins, so "reference" is tested before
+ * "ref". Never applied to the relationship name (see {@link #referencePairingKeys}).
+ */
+ private static final List PAIRING_SUFFIXES = List.of("reference", "ref", "id", "key");
+
+ private static String stripOneSuffix(String value) {
+ for (String suffix : PAIRING_SUFFIXES) {
+ if (value.length() > suffix.length() && value.endsWith(suffix)) {
+ return value.substring(0, value.length() - suffix.length());
+ }
+ }
+ return value;
+ }
+
+ /** The FK field a reference is anchored on (first field; composite FKs pair on their
+ * first column), or {@code null} when the reference declares no {@code @fields}. */
+ private static String refFkField(ReferenceIdentity ref) {
+ List fields = ref.getFields();
+ return fields.isEmpty() ? null : fields.get(0);
+ }
+
+ /** Last {@code ::}-segment of a (possibly package-qualified, possibly null/empty) name. */
+ private static String stripPackage(String name) {
+ if (name == null || name.isEmpty()) return "";
+ int idx = name.lastIndexOf(MetaData.PKG_SEPARATOR);
+ return idx < 0 ? name : name.substring(idx + MetaData.PKG_SEPARATOR.length());
+ }
+
+ /**
+ * The set of lowercased names a candidate reference answers to: its own name
+ * (short name — a reference is never itself package-qualified) and its FK
+ * field, each with and without one stripped suffix.
+ *
+ * {@code toLowerCase(Locale.ROOT)} — never the no-arg {@code toLowerCase()} — so
+ * behaviour does not depend on the JVM's default locale (a Turkish-locale JVM lower-
+ * cases {@code "I"} to {@code "ı"}, not {@code "i"}).
+ */
+ public static Set referencePairingKeys(ReferenceIdentity ref) {
+ Set keys = new HashSet<>();
+ addPairingKey(keys, ref.getShortName());
+ addPairingKey(keys, refFkField(ref));
+ return keys;
+ }
+
+ private static void addPairingKey(Set keys, String value) {
+ if (value == null || value.isEmpty()) return;
+ String lower = value.toLowerCase(Locale.ROOT);
+ keys.add(lower);
+ keys.add(stripOneSuffix(lower));
+ }
+
+ /**
+ * Every {@code identity.reference} on {@code holder} whose {@code @references} targets
+ * {@code targetEntity}. Package-insensitive on both sides: {@code @references} and
+ * {@code @objectRef} may each be bare or fully qualified.
+ *
+ * ADR-0039: resolving — {@code holder.getIdentities()} (no-arg, includeParentData
+ * defaulting true) honors references inherited via extends.
+ */
+ public static List referenceCandidatesFor(MetaObject holder, String targetEntity) {
+ String target = stripPackage(targetEntity);
+ List out = new ArrayList<>();
+ for (MetaIdentity id : holder.getIdentities()) {
+ if (!(id instanceof ReferenceIdentity)) continue;
+ ReferenceIdentity ref = (ReferenceIdentity) id;
+ if (!target.equals(stripPackage(ref.getTargetEntity()))) continue;
+ if (refFkField(ref) == null) continue;
+ out.add(ref);
+ }
+ return out;
+ }
+
+ /**
+ * Which identity.reference does this {@code @cardinality: one} relationship navigate
+ * through? The ladder, in order:
+ *
+ * - exactly one candidate -> that one (the common case; unchanged behaviour)
+ * - {@code @sourceRefField} declared -> the candidate whose FK field it names,
+ * SHORT-CIRCUITING (does not fall through to name-pairing on a miss)
+ * - exactly one candidate name-pairs -> that one
+ * - otherwise -> {@code null} (caller reports the ambiguity)
+ *
+ * Returns {@code null} for "no candidate" and "cannot choose" alike; callers that need
+ * to tell them apart use {@link #referenceCandidatesFor}.
+ *
+ * @param holder the entity whose effective reference set is searched
+ * @param relationshipName the relationship's own (short) name, used for name-pairing
+ * @param targetEntity the relationship's {@code @objectRef}
+ * @param sourceRefField the relationship's declared {@code @sourceRefField}, or
+ * {@code null}/empty when absent
+ */
+ public static ReferenceIdentity resolveRelationshipReference(
+ MetaObject holder, String relationshipName, String targetEntity, String sourceRefField) {
+ List candidates = referenceCandidatesFor(holder, targetEntity);
+ if (candidates.isEmpty()) return null;
+ if (candidates.size() == 1) return candidates.get(0);
+
+ if (sourceRefField != null && !sourceRefField.isEmpty()) {
+ for (ReferenceIdentity ref : candidates) {
+ if (sourceRefField.equals(refFkField(ref))) return ref;
+ }
+ return null;
+ }
+
+ String wanted = relationshipName.toLowerCase(Locale.ROOT);
+ List paired = new ArrayList<>();
+ for (ReferenceIdentity ref : candidates) {
+ if (referencePairingKeys(ref).contains(wanted)) paired.add(ref);
+ }
+ return paired.size() == 1 ? paired.get(0) : null;
+ }
+
+ /** Overload for the common case with no declared {@code @sourceRefField}. */
+ public static ReferenceIdentity resolveRelationshipReference(
+ MetaObject holder, String relationshipName, String targetEntity) {
+ return resolveRelationshipReference(holder, relationshipName, targetEntity, null);
+ }
+}
diff --git a/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceLadderTest.java b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceLadderTest.java
new file mode 100644
index 000000000..4f2171143
--- /dev/null
+++ b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceLadderTest.java
@@ -0,0 +1,174 @@
+package com.metaobjects.relationship;
+
+import com.metaobjects.MetaData;
+import com.metaobjects.identity.ReferenceIdentity;
+import com.metaobjects.loader.InMemoryStringSource;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.object.MetaObject;
+import com.metaobjects.registry.SharedRegistryTestBase;
+import org.junit.Test;
+
+import java.util.List;
+
+import static org.junit.Assert.*;
+
+/**
+ * Issue #368 — direct unit tests for the reference-resolution ladder
+ * ({@link RelationshipReferences}).
+ *
+ * Java port of the TS reference suite
+ * ({@code server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts})
+ * and its Python/C# ports — exercises {@link RelationshipReferences#referenceCandidatesFor}
+ * / {@link RelationshipReferences#resolveRelationshipReference} directly against a loaded
+ * model, rather than only through the loader's error output (see
+ * {@link Issue368RelationshipReferenceValidationTest} for the loader-integration tests
+ * covering rule (d) / rule (e)).
+ */
+public class Issue368RelationshipReferenceLadderTest extends SharedRegistryTestBase {
+
+ private MetaDataLoader newTestLoader() {
+ return createTestLoader("Issue368RelationshipReferenceLadderTest", java.util.Collections.emptyList());
+ }
+
+ private MetaDataLoader loadClean(String json, String id) {
+ MetaDataLoader loader = newTestLoader();
+ loader.load(List.of(new InMemoryStringSource(json, id)));
+ return loader;
+ }
+
+ private static final String MATCH_MODEL =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"homeTeamId\" } },"
+ + " { \"field.long\": { \"name\": \"awayTeamId\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"homeTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } },"
+ + " { \"identity.reference\": { \"name\": \"awayTeamRef\", \"@fields\": \"awayTeamId\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"awayTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }"
+ + "] } }";
+
+ private MetaObject loadMatch() {
+ MetaDataLoader loader = loadClean(MATCH_MODEL, "match.json");
+ assertTrue(loader.getErrors().isEmpty());
+ return (MetaObject) loader.getRoot().getChildOfType("object", "repro::Match");
+ }
+
+ @Test
+ public void enumeratesEveryCandidateReferenceForTheTarget() {
+ List candidates = RelationshipReferences.referenceCandidatesFor(loadMatch(), "Team");
+ assertEquals(2, candidates.size());
+ assertEquals("homeTeamRef", candidates.get(0).getShortName());
+ assertEquals("awayTeamRef", candidates.get(1).getShortName());
+ }
+
+ @Test
+ public void namePairingResolvesEachAssociationToItsOwnReference() {
+ MetaObject match = loadMatch();
+ assertEquals("homeTeamRef",
+ RelationshipReferences.resolveRelationshipReference(match, "homeTeam", "Team").getShortName());
+ assertEquals("awayTeamRef",
+ RelationshipReferences.resolveRelationshipReference(match, "awayTeam", "Team").getShortName());
+ }
+
+ @Test
+ public void sourceRefFieldWinsOverNamePairing() {
+ MetaObject match = loadMatch();
+ assertEquals("awayTeamRef",
+ RelationshipReferences.resolveRelationshipReference(match, "homeTeam", "Team", "awayTeamId")
+ .getShortName());
+ }
+
+ @Test
+ public void sourceRefFieldMissShortCircuitsRatherThanFallingThroughToNamePairing() {
+ // #368 ladder step 2: a declared @sourceRefField that matches NOTHING must
+ // return null -- it must NOT fall through to step 3 (name-pairing), even
+ // though "homeTeam" would otherwise pair cleanly with "homeTeamRef".
+ MetaObject match = loadMatch();
+ assertNull(RelationshipReferences.resolveRelationshipReference(match, "homeTeam", "Team", "doesNotExist"));
+ }
+
+ @Test
+ public void aSingleCandidateResolvesRegardlessOfName() {
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"winnerFk\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"anythingAtAll\", \"@fields\": \"winnerFk\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"champion\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }"
+ + "] } }";
+ MetaDataLoader loader = loadClean(doc, "single-candidate.json");
+ assertTrue(loader.getErrors().isEmpty());
+ MetaObject match = (MetaObject) loader.getRoot().getChildOfType("object", "repro::Match");
+ assertEquals("anythingAtAll",
+ RelationshipReferences.resolveRelationshipReference(match, "champion", "Team").getShortName());
+ }
+
+ @Test
+ public void unpairableNamesReturnNullRatherThanGuessing() {
+ // #368 rule (e) flags this fixture as a load error -- it's the exact
+ // ambiguity the ladder returning null exists to surface. The loader throws;
+ // catch it and assert the ladder's return value directly against the
+ // already-loaded (partially-validated) root.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"alphaFk\" } },"
+ + " { \"field.long\": { \"name\": \"betaFk\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"alphaRef\", \"@fields\": \"alphaFk\", \"@references\": \"Team\" } },"
+ + " { \"identity.reference\": { \"name\": \"betaRef\", \"@fields\": \"betaFk\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"winner\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }"
+ + "] } }";
+ MetaDataLoader loader = newTestLoader();
+ try {
+ loader.load(List.of(new InMemoryStringSource(doc, "unpairable.json")));
+ fail("Expected the load to fail with an ambiguity error");
+ } catch (com.metaobjects.MetaDataException expected) {
+ // expected -- rule (e) rejects this model
+ }
+ MetaObject match = (MetaObject) loader.getRoot().getChildOfType("object", "repro::Match");
+ assertNull(RelationshipReferences.resolveRelationshipReference(match, "winner", "Team"));
+ }
+
+ @Test
+ public void suffixStrippingNeverAppliesToTheRelationshipName() {
+ // "valid" must NOT be stripped to "val" and pair with valRef.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"valFk\" } },"
+ + " { \"field.long\": { \"name\": \"otherFk\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"valRef\", \"@fields\": \"valFk\", \"@references\": \"Team\" } },"
+ + " { \"identity.reference\": { \"name\": \"otherRef\", \"@fields\": \"otherFk\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"valid\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }"
+ + "] } }";
+ MetaDataLoader loader = newTestLoader();
+ try {
+ loader.load(List.of(new InMemoryStringSource(doc, "valid-valref.json")));
+ fail("Expected the load to fail with an ambiguity error");
+ } catch (com.metaobjects.MetaDataException expected) {
+ // expected -- "valid" must not pair with "valRef"
+ }
+ MetaObject match = (MetaObject) loader.getRoot().getChildOfType("object", "repro::Match");
+ assertNull(RelationshipReferences.resolveRelationshipReference(match, "valid", "Team"));
+ }
+}
diff --git a/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java
new file mode 100644
index 000000000..aa348a568
--- /dev/null
+++ b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java
@@ -0,0 +1,581 @@
+package com.metaobjects.relationship;
+
+import com.metaobjects.ErrorCode;
+import com.metaobjects.MetaDataException;
+import com.metaobjects.loader.InMemoryStringSource;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.loader.MetaDataSource;
+import com.metaobjects.registry.SharedRegistryTestBase;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.*;
+
+/**
+ * Issue #368 — loader validation for relationship.* M:N slim vocabulary + the
+ * 1:N reference-disambiguation rules (the resolution ladder in
+ * {@link RelationshipReferences}).
+ *
+ * Java port of the TS reference suite ({@code relationship-m2m.test.ts}) and its
+ * Python/C# ports, covering the #368 additions:
+ *
+ * - (B) {@code @sourceRefField} becomes legal on {@code @cardinality: "one"}
+ * (previously rejected on any non-M:N relationship).
+ * - (C) Rule (e) — a {@code @cardinality: one} relationship must resolve to
+ * exactly one identity.reference candidate; ambiguity is a load error.
+ * - (D) Both rule (d) (the M:N slim-vocabulary pass) and rule (e) iterate the
+ * EFFECTIVE relationship set (own + inherited via extends), not just
+ * own-declared relationships — with rule (d) deduping on the relationship
+ * node's identity (an inherited, unmodified relationship must not be
+ * reported once per inheriting entity).
+ *
+ *
+ * Also regression-covers two latent "obj vs. declaring entity" bugs (present in
+ * TS, Python and C# before their own #368 fixes): ADR-0042 package resolution for a
+ * bare {@code @through} must use the DECLARING entity's package, and rule (a)'s
+ * self-join comparison must use the DECLARING entity, not whichever entity's
+ * effective view reached the inherited relationship first.
+ *
+ * See {@code server/typescript/packages/metadata/src/core/relationship/
+ * resolve-relationship-reference.ts} and {@code .../src/loader/validation-passes.ts}
+ * ({@code validateRelationships} / {@code validateOneSideReferenceResolution}) for
+ * the authoritative spec these tests mirror.
+ */
+public class Issue368RelationshipReferenceValidationTest extends SharedRegistryTestBase {
+
+ private MetaDataLoader newTestLoader() {
+ return createTestLoader("Issue368RelationshipReferenceValidationTest", java.util.Collections.emptyList());
+ }
+
+ /** Outcome of a load attempt: every error the loader saw, whether recorded via
+ * {@code getErrors()} (all-but-last) or thrown (the last one) — mirrors the C#
+ * {@code LoadResult.Errors} single collection so assertions read the same way. */
+ private static final class Outcome {
+ final MetaDataLoader loader;
+ final MetaDataException thrown;
+
+ Outcome(MetaDataLoader loader, MetaDataException thrown) {
+ this.loader = loader;
+ this.thrown = thrown;
+ }
+
+ List allErrors() {
+ List all = new ArrayList<>(loader.getErrors());
+ if (thrown != null) all.add(thrown);
+ return all;
+ }
+
+ String joinedMessages() {
+ StringBuilder sb = new StringBuilder();
+ for (MetaDataException e : allErrors()) {
+ if (sb.length() > 0) sb.append('\n');
+ sb.append(e.getMessage());
+ }
+ return sb.toString();
+ }
+ }
+
+ private Outcome attemptLoad(String... jsons) {
+ MetaDataLoader loader = newTestLoader();
+ List sources = new ArrayList<>();
+ for (int i = 0; i < jsons.length; i++) {
+ sources.add(new InMemoryStringSource(jsons[i], "inline-" + i + ".json"));
+ }
+ try {
+ loader.load(sources);
+ return new Outcome(loader, null);
+ } catch (MetaDataException e) {
+ return new Outcome(loader, e);
+ }
+ }
+
+ private void assertLoadsClean(String... jsons) {
+ Outcome outcome = attemptLoad(jsons);
+ if (outcome.thrown != null || !outcome.loader.getErrors().isEmpty()) {
+ fail("Expected a clean load; got: " + outcome.joinedMessages());
+ }
+ }
+
+ private void assertHasError(Outcome outcome, ErrorCode expected) {
+ boolean found = outcome.allErrors().stream()
+ .anyMatch(e -> e.getCode().map(c -> c == expected).orElse(false));
+ assertTrue("Expected an error with code " + expected + "; got: " + outcome.joinedMessages(), found);
+ }
+
+ private void assertNoErrorOfCode(Outcome outcome, ErrorCode code) {
+ boolean found = outcome.allErrors().stream()
+ .anyMatch(e -> e.getCode().map(c -> c == code).orElse(false));
+ assertFalse("Did not expect any " + code + "; got: " + outcome.joinedMessages(), found);
+ }
+
+ // -------------------------------------------------------------------------
+ // (B) @sourceRefField becomes legal on @cardinality: "one"
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void sourceRefFieldOnCardinalityOneLoadsClean() {
+ // The issue #368 repro: two 1:N relationships, each disambiguated by
+ // @sourceRefField, load with no errors.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"homeTeamId\" } },"
+ + " { \"field.long\": { \"name\": \"awayTeamId\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } },"
+ + " { \"identity.reference\": { \"name\": \"awayTeamRef\", \"@fields\": \"awayTeamId\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"homeTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\", \"@sourceRefField\": \"homeTeamId\" } },"
+ + " { \"relationship.association\": { \"name\": \"awayTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\", \"@sourceRefField\": \"awayTeamId\" } } ] } }"
+ + "] } }";
+ assertLoadsClean(doc);
+ }
+
+ @Test
+ public void sourceRefFieldOnManyWithoutThroughStillErrors() {
+ // @sourceRefField on @cardinality: many with no @through is still not
+ // M:N -- the widening only spares @cardinality: one.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"relationship.association\": { \"name\": \"teams\", \"@objectRef\": \"Team\", \"@cardinality\": \"many\", \"@sourceRefField\": \"whatever\" } } ] } }"
+ + "] } }";
+ assertHasError(attemptLoad(doc), ErrorCode.ERR_INVALID_RELATIONSHIP);
+ }
+
+ @Test
+ public void throughOnCardinalityOneStillErrors() {
+ // @through still requires @cardinality: many -- only @sourceRefField was widened.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"acme\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Week\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"relationship.composition\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@through\": \"X\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Program\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }"
+ + "] } }";
+ assertHasError(attemptLoad(doc), ErrorCode.ERR_INVALID_RELATIONSHIP);
+ }
+
+ @Test
+ public void symmetricOnCardinalityOneStillErrors() {
+ // @symmetric still requires M:N -- only @sourceRefField was widened.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"acme\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Week\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"relationship.association\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@symmetric\": true } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Program\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }"
+ + "] } }";
+ assertHasError(attemptLoad(doc), ErrorCode.ERR_INVALID_RELATIONSHIP);
+ }
+
+ // -------------------------------------------------------------------------
+ // (A) The resolution ladder, exercised end-to-end through the loader.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void issueReproLoadsCleanViaNamePairing() {
+ // Two references onto the same target, no @sourceRefField -- resolved by
+ // name-pairing (homeTeamRef <-> homeTeam, awayTeamRef <-> awayTeam).
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"homeTeamId\" } },"
+ + " { \"field.long\": { \"name\": \"awayTeamId\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"homeTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } },"
+ + " { \"identity.reference\": { \"name\": \"awayTeamRef\", \"@fields\": \"awayTeamId\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"awayTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }"
+ + "] } }";
+ assertLoadsClean(doc);
+ }
+
+ @Test
+ public void unpairableNamesErrorNamingBothCandidates() {
+ // Two references whose names don't pair with the relationship name --
+ // ambiguous, and the error names both candidates as name(fkField).
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"alphaFk\" } },"
+ + " { \"field.long\": { \"name\": \"betaFk\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"alphaRef\", \"@fields\": \"alphaFk\", \"@references\": \"Team\" } },"
+ + " { \"identity.reference\": { \"name\": \"betaRef\", \"@fields\": \"betaFk\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"winner\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(doc);
+ assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP);
+ String joined = outcome.joinedMessages();
+ assertTrue(joined, joined.contains("Match.winner"));
+ assertTrue(joined, joined.contains("alphaRef(alphaFk)"));
+ assertTrue(joined, joined.contains("betaRef(betaFk)"));
+ }
+
+ @Test
+ public void declaredButUnmatchedErrorsAtSingleCandidateCount() {
+ // A declared @sourceRefField naming nothing must error even with exactly
+ // one candidate -- the ladder's step 1 ("exactly one candidate -> that
+ // one") must not silently short-circuit past a bad declared value.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"homeTeamId\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"awayTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\", \"@sourceRefField\": \"awayTeamId\" } } ] } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(doc);
+ List all = outcome.allErrors();
+ assertEquals(outcome.joinedMessages(), 1, all.size());
+ assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP);
+ String joined = outcome.joinedMessages();
+ assertTrue(joined, joined.contains("Match.awayTeam"));
+ assertTrue(joined, joined.contains("\"awayTeamId\""));
+ }
+
+ @Test
+ public void declaredButUnmatchedErrorsAtZeroCandidateCount() {
+ // A declared @sourceRefField naming nothing must also error with ZERO
+ // candidates (no identity.reference targets the objectRef at all) -- the
+ // declared value is read BEFORE any candidate-count guard.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"relationship.association\": { \"name\": \"homeTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\", \"@sourceRefField\": \"homeTeamId\" } } ] } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(doc);
+ List all = outcome.allErrors();
+ assertEquals(outcome.joinedMessages(), 1, all.size());
+ assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP);
+ String joined = outcome.joinedMessages();
+ assertTrue(joined, joined.contains("Match.homeTeam"));
+ assertTrue(joined, joined.contains("\"homeTeamId\""));
+ }
+
+ @Test
+ public void sourceRefFieldCorrectlyNamingSingleCandidateLoadsClean() {
+ // Regression: a correctly-declared @sourceRefField over a single
+ // candidate stays clean.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"homeTeamId\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"homeTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\", \"@sourceRefField\": \"homeTeamId\" } } ] } }"
+ + "] } }";
+ assertLoadsClean(doc);
+ }
+
+ @Test
+ public void compositeReferenceCandidatesRenderFullFieldTuple() {
+ // Two composite references sharing a first column must still print
+ // distinguishably in the candidate list (fields[0] alone would collide).
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"tenantId\" } },"
+ + " { \"field.long\": { \"name\": \"homeTeamId\" } },"
+ + " { \"field.long\": { \"name\": \"awayTeamId\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"aRef\", \"@fields\": [\"tenantId\", \"homeTeamId\"], \"@references\": \"Team\" } },"
+ + " { \"identity.reference\": { \"name\": \"bRef\", \"@fields\": [\"tenantId\", \"awayTeamId\"], \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"winner\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(doc);
+ assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP);
+ String joined = outcome.joinedMessages();
+ assertTrue(joined, joined.contains("aRef(tenantId, homeTeamId)"));
+ assertTrue(joined, joined.contains("bRef(tenantId, awayTeamId)"));
+ }
+
+ // -------------------------------------------------------------------------
+ // (D) Rule (e) must iterate the EFFECTIVE relationship set.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void inheritedRelationshipAmbiguityErrors() {
+ // A extends cleanly; B extends A and adds a second reference onto the
+ // same target -- the inherited relationship becomes ambiguous on B even
+ // though A (and the relationship's own declaration) are untouched.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"A\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"homeTeamId\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"winner\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"B\", \"extends\": \"A\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"awayTeamId\" } },"
+ + " { \"identity.reference\": { \"name\": \"awayTeamRef\", \"@fields\": \"awayTeamId\", \"@references\": \"Team\" } } ] } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(doc);
+ List all = outcome.allErrors();
+ assertEquals(outcome.joinedMessages(), 1, all.size());
+ assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP);
+ String joined = outcome.joinedMessages();
+ assertTrue(joined, joined.contains("B.winner"));
+ assertTrue(joined, joined.contains("homeTeamRef(homeTeamId)"));
+ assertTrue(joined, joined.contains("awayTeamRef(awayTeamId)"));
+ }
+
+ @Test
+ public void inheritedRelationshipResolvedByChildAddedReferenceLoadsClean() {
+ // A child entity's added reference that name-pairs with the inherited
+ // relationship resolves cleanly.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"A\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"homeTeamId\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"awayTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"B\", \"extends\": \"A\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"awayTeamId\" } },"
+ + " { \"identity.reference\": { \"name\": \"awayTeamRef\", \"@fields\": \"awayTeamId\", \"@references\": \"Team\" } } ] } }"
+ + "] } }";
+ assertLoadsClean(doc);
+ }
+
+ // -------------------------------------------------------------------------
+ // (D) Rule (d) dedupe -- own attrs never change per inheriting entity, so an
+ // inherited unmodified relationship must be reported exactly once.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void ruleDDedupeSingleErrorForOneInheritingChild() {
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Program\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"A\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"relationship.composition\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@through\": \"X\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"B\", \"extends\": \"A\" } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(doc);
+ List all = outcome.allErrors();
+ assertEquals(outcome.joinedMessages(), 1, all.size());
+ assertEquals(ErrorCode.ERR_INVALID_RELATIONSHIP, all.get(0).getCode().orElse(null));
+ }
+
+ @Test
+ public void ruleDDedupeSingleErrorAcrossSeveralInheritingChildren() {
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Program\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"A\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"relationship.composition\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@through\": \"X\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"B\", \"extends\": \"A\" } },"
+ + " { \"object.entity\": { \"name\": \"C\", \"extends\": \"A\" } },"
+ + " { \"object.entity\": { \"name\": \"D\", \"extends\": \"A\" } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(doc);
+ List all = outcome.allErrors();
+ assertEquals(outcome.joinedMessages(), 1, all.size());
+ assertEquals(ErrorCode.ERR_INVALID_RELATIONSHIP, all.get(0).getCode().orElse(null));
+ }
+
+ // -------------------------------------------------------------------------
+ // Cross-relationship state-leakage regression: a declared-but-unmatched
+ // @sourceRefField at 2+ candidates on one relationship must not affect a
+ // SIBLING relationship on the same entity whose declared field DOES match.
+ // (Not present in the Python port; it is the case most likely to catch
+ // per-relationship state accidentally shared across a loop iteration.)
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void siblingRelationshipWithMatchingSourceRefFieldProducesNoErrorWhileTheBadOneDoes() {
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Venue\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"venueId\" } },"
+ + " { \"field.long\": { \"name\": \"alphaFk\" } },"
+ + " { \"field.long\": { \"name\": \"betaFk\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ // The GOOD sibling: a single, correctly-matched candidate onto Venue.
+ + " { \"identity.reference\": { \"name\": \"venueRef\", \"@fields\": \"venueId\", \"@references\": \"Venue\" } },"
+ + " { \"relationship.association\": { \"name\": \"venue\", \"@objectRef\": \"Venue\", \"@cardinality\": \"one\", \"@sourceRefField\": \"venueId\" } },"
+ // The BAD one: 2+ candidates onto Team, declared @sourceRefField matches neither.
+ + " { \"identity.reference\": { \"name\": \"alphaRef\", \"@fields\": \"alphaFk\", \"@references\": \"Team\" } },"
+ + " { \"identity.reference\": { \"name\": \"betaRef\", \"@fields\": \"betaFk\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"winner\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\", \"@sourceRefField\": \"doesNotExist\" } } ] } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(doc);
+ List all = outcome.allErrors();
+ assertEquals("Expected exactly one error (the bad 'winner' relationship only): "
+ + outcome.joinedMessages(), 1, all.size());
+ String joined = outcome.joinedMessages();
+ assertTrue(joined, joined.contains("Match.winner"));
+ assertTrue(joined, joined.contains("\"doesNotExist\""));
+ // The good sibling must never be named in any error -- no state leaked
+ // from evaluating "winner" into (or out of) evaluating "venue".
+ assertFalse(joined, joined.contains("Match.venue\""));
+ }
+
+ // -------------------------------------------------------------------------
+ // Two latent "obj vs. declaring entity" bugs -- present in TS, Python and C#
+ // before their own #368 fixes. Regression-covered here for Java.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void inheritedSelfJoinRelationshipIsNotMisflaggedAsNonSelfJoin() {
+ // Node extends NodeBase, which declares a @symmetric self-join
+ // relationship onto NodeBase itself (@objectRef: "NodeBase"). Node is
+ // declared BEFORE NodeBase (extends is resolved order-independently by
+ // a deferred pass, so this is legal) so that the outer validation loop
+ // visits `obj = Node` FIRST -- if rule (a)'s self-join comparison used
+ // the visiting `obj` instead of the relationship's DECLARING entity
+ // (NodeBase, via rel.getParent()), it would wrongly conclude @objectRef
+ // "NodeBase" is not the (visiting) declaring entity "Node" and misfire
+ // ERR_BAD_ATTR_VALUE.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"acme\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Node\", \"extends\": \"NodeBase\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"NodeBase\", \"@isAbstract\": true, \"children\": ["
+ + " { \"relationship.association\": { \"name\": \"peers\", \"@cardinality\": \"many\", \"@objectRef\": \"NodeBase\","
+ + " \"@through\": \"NodeLink\", \"@symmetric\": true } } ] } },"
+ + " { \"object.entity\": { \"name\": \"NodeLink\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"aId\" } },"
+ + " { \"field.long\": { \"name\": \"bId\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"a\", \"@fields\": \"aId\", \"@references\": \"NodeBase\" } },"
+ + " { \"identity.reference\": { \"name\": \"b\", \"@fields\": \"bId\", \"@references\": \"NodeBase\" } } ] } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(doc);
+ assertNoErrorOfCode(outcome, ErrorCode.ERR_BAD_ATTR_VALUE);
+ assertNoErrorOfCode(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP);
+ }
+
+ @Test
+ public void inheritedBareThroughResolvesInTheDeclaringEntityPackageNotTheVisitingOne() {
+ // WeekBase (package "base") declares a M:N relationship with a BARE
+ // @through "Tag" -- ADR-0042 says a bare ref resolves in the DECLARING
+ // entity's package ("base::Tag"), never the package of whichever entity
+ // inherits and visits it. Week extends WeekBase from a DIFFERENT package
+ // ("acme") that also happens to declare its own unrelated "Tag" entity.
+ // The acme source is loaded FIRST so the outer validation loop visits
+ // `obj = Week` before `obj = WeekBase` -- if @through resolution used the
+ // visiting entity's package it would wrongly bind to "acme::Tag" (which
+ // has zero identity.reference children) instead of "base::Tag" (which
+ // correctly has two).
+ String acmeDoc =
+ "{ \"metadata.root\": { \"package\": \"acme\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Week\", \"extends\": \"base::WeekBase\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Tag\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }"
+ + "] } }";
+ String baseDoc =
+ "{ \"metadata.root\": { \"package\": \"base\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"WeekBase\", \"@isAbstract\": true, \"children\": ["
+ + " { \"relationship.association\": { \"name\": \"tags\", \"@cardinality\": \"many\", \"@objectRef\": \"Tag\", \"@through\": \"Tag\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Tag\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"weekId\" } },"
+ + " { \"field.long\": { \"name\": \"labelId\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"w\", \"@fields\": \"weekId\", \"@references\": \"base::WeekBase\" } },"
+ + " { \"identity.reference\": { \"name\": \"l\", \"@fields\": \"labelId\", \"@references\": \"base::Tag\" } } ] } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(acmeDoc, baseDoc);
+ assertNoErrorOfCode(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP);
+ }
+
+ // -------------------------------------------------------------------------
+ // Regression: valid M:N still loads clean.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void validHeteroM2mProducesNoRelationshipErrors() {
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"acme\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Post\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"relationship.association\": { \"name\": \"tags\", \"@cardinality\": \"many\", \"@objectRef\": \"Tag\", \"@through\": \"PostTag\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Tag\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"PostTag\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"postId\" } },"
+ + " { \"field.long\": { \"name\": \"tagId\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"p\", \"@fields\": \"postId\", \"@references\": \"Post\" } },"
+ + " { \"identity.reference\": { \"name\": \"t\", \"@fields\": \"tagId\", \"@references\": \"Tag\" } } ] } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(doc);
+ assertNoErrorOfCode(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP);
+ assertNoErrorOfCode(outcome, ErrorCode.ERR_BAD_ATTR_VALUE);
+ }
+}
From 6f1ee5389d08ae9badf1acfd991b9c45e3d50585 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 18:24:38 -0400
Subject: [PATCH 15/31] fix(loader,java): #368 fix round 1 -- cardinality
raw-read, falsifiable 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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../metaobjects/loader/ValidationPhase.java | 51 +++++--
.../metaobjects/relationship/M2MFields.java | 12 +-
.../relationship/ReferenceFkUtil.java | 52 +++++++
.../relationship/RelationshipReferences.java | 27 ++--
.../loader/Issue368RuleDDedupeUnitTest.java | 98 +++++++++++++
...68RelationshipReferenceValidationTest.java | 129 +++++++++++++++++-
6 files changed, 333 insertions(+), 36 deletions(-)
create mode 100644 server/java/metadata/src/main/java/com/metaobjects/relationship/ReferenceFkUtil.java
create mode 100644 server/java/metadata/src/test/java/com/metaobjects/loader/Issue368RuleDDedupeUnitTest.java
diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
index f066eb056..145842c54 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
@@ -199,18 +199,27 @@ public static void run(MetaRoot root, MetaDataLoader loader) {
// Pass 14 (FR-017): M:N relationship slim-vocabulary validation — collects
// EVERY violation across the effective relationship set (own + inherited via
// extends), deduped by the relationship node's own identity (#368 / ADR-0039).
- for (MetaDataException e : validateRelationshipsM2M(root)) {
- collected.add(e);
- }
+ // Routed through pass(...) (not a bare loop) so a MetaDataException escaping
+ // the list-building call itself — as opposed to one already safely collected
+ // into the returned list — is still folded into `collected` rather than
+ // propagating straight out of run(), which would skip every remaining pass,
+ // dedupe(), and loader.addError().
+ pass(collected, () -> {
+ for (MetaDataException e : validateRelationshipsM2M(root)) {
+ collected.add(e);
+ }
+ });
// Rule (e) (#368) — registered alongside validateRelationshipsM2M above: a
// @cardinality: one relationship must resolve to exactly one identity.reference
// candidate on the EFFECTIVE entity; ambiguity is a load error naming the
// candidates (ERR_INVALID_RELATIONSHIP). No dedupe — the candidate set is a
// property of the effective entity, so a parent and a child can both be
// genuinely, independently ambiguous.
- for (MetaDataException e : validateOneSideReferenceResolution(root)) {
- collected.add(e);
- }
+ pass(collected, () -> {
+ for (MetaDataException e : validateOneSideReferenceResolution(root)) {
+ collected.add(e);
+ }
+ });
// ADR-0042 — the cross-package ambiguity pass (ERR_AMBIGUOUS_REF) is RETIRED. A bare
// reference now resolves package-locally (referrer's package, else root-level) at every
// ref site (SymbolTable / resolveRootObject), so cross-package ambiguity is unreachable;
@@ -1766,8 +1775,17 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj,
String sourceRefField = rel.getSourceRefField();
boolean symmetric = rel.isSymmetric();
String objectRef = rel.getObjectRef();
- // getCardinality() defaults to "one" when absent — matches the TS isMany check.
- String cardinality = rel.getCardinality();
+ // Cross-port divergence guard: MetaRelationship.getCardinality() DEFAULTS to
+ // "one" when the attr is absent, but TS (validation-passes.ts:2058/:2196),
+ // C# (ValidationPasses.cs:3179) and Python (validation_passes.py:2676) all
+ // compare the RAW attribute — an absent @cardinality is neither "many" nor
+ // "one" there. Using the defaulting getter here would spare @sourceRefField
+ // on a cardinality-less relationship (Java-only) and, in rule (e) below,
+ // wrongly apply the one-side-resolution check to it too. Read the raw own
+ // attr instead, mirroring the established hopCardinality(...) idiom elsewhere
+ // in this file (FR-024 B5/B6, a few hundred lines down) — defaulting is
+ // deliberately NOT reproduced here so all four ports treat "absent" alike.
+ String cardinality = rawCardinality(rel);
boolean hasThrough = through != null && !through.isEmpty();
boolean hasSourceRefField = sourceRefField != null && !sourceRefField.isEmpty();
@@ -1933,7 +1951,10 @@ static List validateOneSideReferenceResolution(MetaRoot root)
// see, including one only inherited via extends.
for (MetaRelationship rel : obj.getRelationships()) {
// ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends.
- if (!MetaRelationship.CARDINALITY_ONE.equals(rel.getCardinality())) continue;
+ // Raw read, not getCardinality() (see the cross-port divergence note in
+ // validateRelationshipM2MNode above) — an absent @cardinality must skip
+ // this pass entirely on Java, exactly as it does on TS/C#/Python.
+ if (!MetaRelationship.CARDINALITY_ONE.equals(rawCardinality(rel))) continue;
String objectRef = rel.getObjectRef();
if (objectRef == null || objectRef.isEmpty()) continue;
@@ -2007,6 +2028,18 @@ private static String formatReferenceCandidates(List candidat
return sb.toString();
}
+ /** The relationship's own (effective, resolving) {@code @cardinality}, or
+ * {@code null} when absent — deliberately NOT {@link MetaRelationship#getCardinality()},
+ * which defaults to {@code "one"}. TS/C#/Python all compare the raw attribute
+ * at the rule (d) sourceRefField-exemption check and the rule (e) gate, so an
+ * absent @cardinality must read as neither "many" nor "one" here too (matches
+ * the same raw-read idiom as {@code hopCardinality(...)} elsewhere in this file). */
+ private static String rawCardinality(MetaRelationship rel) {
+ return rel.hasMetaAttr(MetaRelationship.ATTR_CARDINALITY)
+ ? rel.getMetaAttr(MetaRelationship.ATTR_CARDINALITY).getValueAsString()
+ : null;
+ }
+
/** Count a junction's {@code identity.reference} children.
* ADR-0039: resolving — a junction may inherit an {@code identity.reference}
* via extends, so the FK direction/count must be judged on the EFFECTIVE view.
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 a28c4519c..16ef5aee0 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
@@ -217,10 +217,12 @@ private static List referenceIdentities(MetaObject junction) {
return out;
}
- /** First {@code @fields} entry of a reference (the physical FK column on the junction). */
+ // refFkField / stripPackage moved to the package-private ReferenceFkUtil (fix
+ // round 1, finding 5) — they were byte-for-byte duplicates of
+ // RelationshipReferences' own private copies, and both classes live in this
+ // same package.
private static String refFkField(MetaIdentity ref) {
- List fields = ref.getFields();
- return fields.isEmpty() ? null : fields.get(0);
+ return ReferenceFkUtil.refFkField(ref);
}
/**
@@ -278,8 +280,6 @@ private static MetaObject findObject(MetaRoot root, String name) {
}
private static String stripPackage(String name) {
- if (name == null) return null;
- int idx = name.lastIndexOf(MetaData.PKG_SEPARATOR);
- return (idx >= 0) ? name.substring(idx + MetaData.PKG_SEPARATOR.length()) : name;
+ return ReferenceFkUtil.stripPackage(name);
}
}
diff --git a/server/java/metadata/src/main/java/com/metaobjects/relationship/ReferenceFkUtil.java b/server/java/metadata/src/main/java/com/metaobjects/relationship/ReferenceFkUtil.java
new file mode 100644
index 000000000..6a7c66516
--- /dev/null
+++ b/server/java/metadata/src/main/java/com/metaobjects/relationship/ReferenceFkUtil.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2003 Doug Mealing LLC dba Meta Objects
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.metaobjects.relationship;
+
+import com.metaobjects.MetaData;
+import com.metaobjects.identity.MetaIdentity;
+
+import java.util.List;
+
+/**
+ * Package-private helpers shared by {@link M2MFields} and
+ * {@link RelationshipReferences} — both independently needed "the FK field an
+ * {@code identity.reference} is anchored on" and "the bare (package-stripped)
+ * tail of a dotted name" before this class existed, and had grown byte-for-byte
+ * duplicate private copies of each (fix round 1, finding 5). Kept here rather
+ * than made public: the other language ports keep this logic in separate
+ * modules (no cross-file sharing to mirror), so there is no cross-port reason
+ * to widen visibility beyond this package.
+ */
+final class ReferenceFkUtil {
+
+ private ReferenceFkUtil() {
+ // static utility class
+ }
+
+ /** The FK field an identity carries (first {@code @fields} entry; composite FKs
+ * key on their first column), or {@code null} when it declares no {@code @fields}. */
+ static String refFkField(MetaIdentity ref) {
+ List fields = ref.getFields();
+ return fields.isEmpty() ? null : fields.get(0);
+ }
+
+ /** Last {@code ::}-segment of a (possibly package-qualified, possibly null/empty) name. */
+ static String stripPackage(String name) {
+ if (name == null || name.isEmpty()) return "";
+ int idx = name.lastIndexOf(MetaData.PKG_SEPARATOR);
+ return idx < 0 ? name : name.substring(idx + MetaData.PKG_SEPARATOR.length());
+ }
+}
diff --git a/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java b/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java
index 29706a175..e097afcdf 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java
@@ -15,7 +15,6 @@
*/
package com.metaobjects.relationship;
-import com.metaobjects.MetaData;
import com.metaobjects.identity.MetaIdentity;
import com.metaobjects.identity.ReferenceIdentity;
import com.metaobjects.object.MetaObject;
@@ -66,19 +65,9 @@ private static String stripOneSuffix(String value) {
return value;
}
- /** The FK field a reference is anchored on (first field; composite FKs pair on their
- * first column), or {@code null} when the reference declares no {@code @fields}. */
- private static String refFkField(ReferenceIdentity ref) {
- List fields = ref.getFields();
- return fields.isEmpty() ? null : fields.get(0);
- }
-
- /** Last {@code ::}-segment of a (possibly package-qualified, possibly null/empty) name. */
- private static String stripPackage(String name) {
- if (name == null || name.isEmpty()) return "";
- int idx = name.lastIndexOf(MetaData.PKG_SEPARATOR);
- return idx < 0 ? name : name.substring(idx + MetaData.PKG_SEPARATOR.length());
- }
+ // refFkField / stripPackage moved to the package-private ReferenceFkUtil (fix
+ // round 1, finding 5) — they were byte-for-byte duplicates of M2MFields' own
+ // private copies, and both classes live in this same package.
/**
* The set of lowercased names a candidate reference answers to: its own name
@@ -92,7 +81,7 @@ private static String stripPackage(String name) {
public static Set referencePairingKeys(ReferenceIdentity ref) {
Set keys = new HashSet<>();
addPairingKey(keys, ref.getShortName());
- addPairingKey(keys, refFkField(ref));
+ addPairingKey(keys, ReferenceFkUtil.refFkField(ref));
return keys;
}
@@ -112,13 +101,13 @@ private static void addPairingKey(Set keys, String value) {
* defaulting true) honors references inherited via extends.
*/
public static List referenceCandidatesFor(MetaObject holder, String targetEntity) {
- String target = stripPackage(targetEntity);
+ String target = ReferenceFkUtil.stripPackage(targetEntity);
List out = new ArrayList<>();
for (MetaIdentity id : holder.getIdentities()) {
if (!(id instanceof ReferenceIdentity)) continue;
ReferenceIdentity ref = (ReferenceIdentity) id;
- if (!target.equals(stripPackage(ref.getTargetEntity()))) continue;
- if (refFkField(ref) == null) continue;
+ if (!target.equals(ReferenceFkUtil.stripPackage(ref.getTargetEntity()))) continue;
+ if (ReferenceFkUtil.refFkField(ref) == null) continue;
out.add(ref);
}
return out;
@@ -151,7 +140,7 @@ public static ReferenceIdentity resolveRelationshipReference(
if (sourceRefField != null && !sourceRefField.isEmpty()) {
for (ReferenceIdentity ref : candidates) {
- if (sourceRefField.equals(refFkField(ref))) return ref;
+ if (sourceRefField.equals(ReferenceFkUtil.refFkField(ref))) return ref;
}
return null;
}
diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/Issue368RuleDDedupeUnitTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/Issue368RuleDDedupeUnitTest.java
new file mode 100644
index 000000000..0f9c6be93
--- /dev/null
+++ b/server/java/metadata/src/test/java/com/metaobjects/loader/Issue368RuleDDedupeUnitTest.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2003 Doug Mealing LLC dba Meta Objects
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.metaobjects.loader;
+
+import com.metaobjects.MetaDataException;
+import com.metaobjects.MetaRoot;
+import com.metaobjects.registry.SharedRegistryTestBase;
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Issue #368 fix round 1, finding 2 — a dedupe test that can actually fail.
+ *
+ * {@link ValidationPhase#run(MetaRoot, MetaDataLoader)} already collapses findings
+ * by {@code code + envelope.toString()} ({@code ValidationPhase.dedupe}), and
+ * {@code JsonSource} is a record with value-based {@code toString()} — so four visits
+ * of the SAME physical relationship (one entity declaring it, three inheriting it
+ * unmodified) produce byte-identical exceptions (same code, same source instance,
+ * same message) regardless of whether {@code validateRelationshipsM2M}'s own
+ * {@code checkedRels} (an {@code IdentityHashMap}-backed dedupe set) does anything at
+ * all. A test that only calls {@code loader.load(...)} and checks the FINAL
+ * error count (as {@code Issue368RelationshipReferenceValidationTest}'s dedupe tests
+ * do) would pass identically with {@code checkedRels} deleted.
+ *
+ * This test lives in {@code com.metaobjects.loader} (not
+ * {@code com.metaobjects.relationship}, where the rest of the #368 test suite lives)
+ * specifically to reach the package-private {@link ValidationPhase#validateRelationshipsM2M}
+ * directly and assert on the size of the RAW list it returns — bypassing {@code run()}'s
+ * own dedupe entirely. Deleting {@code checkedRels} (making every relationship node
+ * always pass the identity check) would make this assert 4, not 1, so it is a genuine
+ * regression guard for that mechanism specifically.
+ */
+public class Issue368RuleDDedupeUnitTest extends SharedRegistryTestBase {
+
+ private MetaDataLoader newTestLoader() {
+ return createTestLoader("Issue368RuleDDedupeUnitTest", Collections.emptyList());
+ }
+
+ @Test
+ public void ruleDDedupeIsGenuineNotJustRunLevelEnvelopeCollapse() {
+ // A declares one broken M:N-only-attr-on-non-M:N relationship ("program",
+ // @through set but @cardinality: "one"). B, C and D each extend A WITHOUT
+ // overriding "program" -- so all four entities' effective relationship set
+ // (obj.getRelationships()) contains the literal SAME MetaRelationship object.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Program\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"A\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"relationship.composition\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@through\": \"X\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"B\", \"extends\": \"A\" } },"
+ + " { \"object.entity\": { \"name\": \"C\", \"extends\": \"A\" } },"
+ + " { \"object.entity\": { \"name\": \"D\", \"extends\": \"A\" } }"
+ + "] } }";
+
+ MetaDataLoader loader = newTestLoader();
+ MetaRoot root;
+ try {
+ loader.load(List.of(new InMemoryStringSource(doc, "dedupe-unit.json")));
+ root = loader.getRoot();
+ } catch (MetaDataException e) {
+ // Expected -- the model is deliberately invalid. The tree is already
+ // fully built (parse + extends resolution happen before validation), so
+ // getRoot() is still usable for a direct, fresh call below.
+ root = loader.getRoot();
+ }
+
+ // Call the pass directly -- a SECOND, INDEPENDENT invocation, not reading
+ // anything cached by the load() above -- and inspect its raw return value.
+ // run()'s dedupe(collected) is never involved here.
+ List raw = ValidationPhase.validateRelationshipsM2M(root);
+ assertEquals(
+ "checkedRels must dedupe A's single relationship across A + 3 inheriting "
+ + "children (B, C, D) to exactly one raw finding from "
+ + "validateRelationshipsM2M itself -- got: " + raw,
+ 1, raw.size());
+ }
+}
diff --git a/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java
index aa348a568..99369c8a7 100644
--- a/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java
+++ b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java
@@ -171,13 +171,18 @@ public void throughOnCardinalityOneStillErrors() {
@Test
public void symmetricOnCardinalityOneStillErrors() {
- // @symmetric still requires M:N -- only @sourceRefField was widened.
+ // @symmetric still requires M:N -- only @sourceRefField was widened. The
+ // fixture sets @cardinality: "one" EXPLICITLY (not just omits it) -- an
+ // omitted @cardinality is the diverging case covered separately by
+ // sourceRefFieldOnAbsentCardinalityStillErrors below; this test must not
+ // rely on MetaRelationship.getCardinality()'s "one" default to exercise
+ // the genuine @cardinality: "one" path.
String doc =
"{ \"metadata.root\": { \"package\": \"acme\", \"children\": ["
+ " { \"object.entity\": { \"name\": \"Week\", \"children\": ["
+ " { \"field.long\": { \"name\": \"id\" } },"
+ " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
- + " { \"relationship.association\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@symmetric\": true } } ] } },"
+ + " { \"relationship.association\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@symmetric\": true } } ] } },"
+ " { \"object.entity\": { \"name\": \"Program\", \"children\": ["
+ " { \"field.long\": { \"name\": \"id\" } },"
+ " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }"
@@ -185,6 +190,67 @@ public void symmetricOnCardinalityOneStillErrors() {
assertHasError(attemptLoad(doc), ErrorCode.ERR_INVALID_RELATIONSHIP);
}
+ @Test
+ public void sourceRefFieldOnAbsentCardinalityStillErrors() {
+ // Cross-port divergence guard (fix round 1): MetaRelationship.getCardinality()
+ // defaults to "one" when the attribute is absent, but TS/C#/Python all compare
+ // the RAW attribute -- an absent @cardinality is neither "many" nor "one" on
+ // any of the other three ports, so @sourceRefField must still be rejected by
+ // RULE (D) there. This relationship declares NO @cardinality at all.
+ //
+ // The @sourceRefField ("programId") deliberately NAMES A REAL, MATCHING
+ // candidate (programRef) so that rule (e) -- which also gates on cardinality
+ // and would otherwise independently flag/clear this fixture -- cannot mask
+ // whether rule (d) itself was fixed: under the (buggy) defaulting behavior,
+ // rule (d) wrongly exempts @sourceRefField (isCardinalityOne defaults true)
+ // AND rule (e) wrongly applies but finds the declared field matches its one
+ // candidate, so the WHOLE load comes back clean -- only the raw-read fix
+ // makes rule (d) reject this relationship at all.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"acme\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Week\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"programId\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"programRef\", \"@fields\": \"programId\", \"@references\": \"Program\" } },"
+ + " { \"relationship.association\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@sourceRefField\": \"programId\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Program\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(doc);
+ List all = outcome.allErrors();
+ assertEquals(outcome.joinedMessages(), 1, all.size());
+ assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP);
+ assertTrue(outcome.joinedMessages(), outcome.joinedMessages().contains("Week.program"));
+ assertTrue(outcome.joinedMessages(), outcome.joinedMessages()
+ .contains("sets @sourceRefField but is not a M:N relationship"));
+ }
+
+ @Test
+ public void ruleEDoesNotApplyToAbsentCardinality() {
+ // Cross-port divergence guard (fix round 1), rule (e) side: a relationship
+ // with NO @cardinality and 2+ same-target candidates must NOT be flagged as
+ // ambiguous by rule (e) on Java -- TS/C#/Python only apply rule (e) when the
+ // raw @cardinality attribute is exactly "one". With getCardinality()'s "one"
+ // default, this fixture would (wrongly, Java-only) fail to load.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"repro\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Team\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"Match\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"field.long\": { \"name\": \"alphaFk\" } },"
+ + " { \"field.long\": { \"name\": \"betaFk\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"identity.reference\": { \"name\": \"alphaRef\", \"@fields\": \"alphaFk\", \"@references\": \"Team\" } },"
+ + " { \"identity.reference\": { \"name\": \"betaRef\", \"@fields\": \"betaFk\", \"@references\": \"Team\" } },"
+ + " { \"relationship.association\": { \"name\": \"winner\", \"@objectRef\": \"Team\" } } ] } }"
+ + "] } }";
+ assertLoadsClean(doc);
+ }
+
// -------------------------------------------------------------------------
// (A) The resolution ladder, exercised end-to-end through the loader.
// -------------------------------------------------------------------------
@@ -433,6 +499,39 @@ public void ruleDDedupeSingleErrorAcrossSeveralInheritingChildren() {
assertEquals(ErrorCode.ERR_INVALID_RELATIONSHIP, all.get(0).getCode().orElse(null));
}
+ @Test
+ public void twoIndependentRelationshipViolationsBothSurface() {
+ // Fix round 1, finding 2: proves the collect-all change actually matters.
+ // Under the OLD eager-throw-on-first-violation style, only ONE of these two
+ // independent, unrelated relationship defects (different entities, different
+ // relationships, different JSON source locations) would ever be reported --
+ // the walk would abort at whichever one it reached first, silently leaving
+ // the other entity's defect unexamined. run()'s own code+envelope dedupe
+ // (ValidationPhase.dedupe) does NOT collapse these two: different source
+ // envelopes, different messages.
+ String doc =
+ "{ \"metadata.root\": { \"package\": \"acme\", \"children\": ["
+ + " { \"object.entity\": { \"name\": \"Program\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"WeekA\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"relationship.composition\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@through\": \"X\" } } ] } },"
+ + " { \"object.entity\": { \"name\": \"WeekB\", \"children\": ["
+ + " { \"field.long\": { \"name\": \"id\" } },"
+ + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } },"
+ + " { \"relationship.association\": { \"name\": \"sponsor\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@symmetric\": true } } ] } }"
+ + "] } }";
+ Outcome outcome = attemptLoad(doc);
+ List all = outcome.allErrors();
+ assertEquals("Expected BOTH independent relationship violations to surface "
+ + "from one load: " + outcome.joinedMessages(), 2, all.size());
+ String joined = outcome.joinedMessages();
+ assertTrue(joined, joined.contains("WeekA.program"));
+ assertTrue(joined, joined.contains("WeekB.sponsor"));
+ }
+
// -------------------------------------------------------------------------
// Cross-relationship state-leakage regression: a declared-but-unmatched
// @sourceRefField at 2+ candidates on one relationship must not affect a
@@ -510,6 +609,14 @@ public void inheritedSelfJoinRelationshipIsNotMisflaggedAsNonSelfJoin() {
+ " { \"identity.reference\": { \"name\": \"b\", \"@fields\": \"bId\", \"@references\": \"NodeBase\" } } ] } }"
+ "] } }";
Outcome outcome = attemptLoad(doc);
+ // Pin the iteration-order invariant this test's premise depends on: if
+ // root.objects() ever stopped iterating in declaration order (e.g. started
+ // sorting alphabetically), "Node" would no longer be visited before
+ // "NodeBase" and this test would keep passing for the wrong reason --
+ // silently no longer exercising the bug at all. Fail loudly instead.
+ assertEquals("root.objects() must iterate in declaration order for this "
+ + "test's premise (Node visited before NodeBase) to hold",
+ List.of("acme::Node", "acme::NodeBase", "acme::NodeLink"), objectVisitOrder(outcome));
assertNoErrorOfCode(outcome, ErrorCode.ERR_BAD_ATTR_VALUE);
assertNoErrorOfCode(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP);
}
@@ -548,9 +655,27 @@ public void inheritedBareThroughResolvesInTheDeclaringEntityPackageNotTheVisitin
+ " { \"identity.reference\": { \"name\": \"l\", \"@fields\": \"labelId\", \"@references\": \"base::Tag\" } } ] } }"
+ "] } }";
Outcome outcome = attemptLoad(acmeDoc, baseDoc);
+ // Pin the iteration-order invariant: the acme source must be fully visited
+ // (Week, then acme::Tag) before base's WeekBase/Tag, or this test's premise
+ // (obj = Week visited before obj = WeekBase) silently stops holding and the
+ // test would keep passing without ever exercising the bug.
+ assertEquals("root.objects() must iterate in source-then-declaration order "
+ + "for this test's premise (Week visited before WeekBase) to hold",
+ List.of("acme::Week", "acme::Tag", "base::WeekBase", "base::Tag"), objectVisitOrder(outcome));
assertNoErrorOfCode(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP);
}
+ /** The FQNs of {@code root.objects()} in iteration order, for pinning the
+ * declaration/source-order invariant the two order-dependence regression
+ * tests above rely on. */
+ private static List objectVisitOrder(Outcome outcome) {
+ List names = new ArrayList<>();
+ for (com.metaobjects.object.MetaObject obj : outcome.loader.getRoot().objects()) {
+ names.add(obj.getName());
+ }
+ return names;
+ }
+
// -------------------------------------------------------------------------
// Regression: valid M:N still loads clean.
// -------------------------------------------------------------------------
From 48544b7ab5493268c799714f21988af4b39f4921 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 18:53:31 -0400
Subject: [PATCH 16/31] test(#368): backfill order-dependence +
sibling-isolation regressions
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
...368RelationshipReferenceValidationTests.cs | 47 +++++
.../unit/test_relationship_m2m_validation.py | 166 ++++++++++++++++++
.../metadata/test/relationship-m2m.test.ts | 95 ++++++++++
3 files changed, 308 insertions(+)
diff --git a/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceValidationTests.cs b/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceValidationTests.cs
index cd8b834ad..d8c526926 100644
--- a/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceValidationTests.cs
+++ b/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceValidationTests.cs
@@ -302,6 +302,53 @@ public void Composite_reference_candidates_render_full_field_tuple()
Assert.Contains("bRef(tenantId, awayTeamId)", joined);
}
+ // -------------------------------------------------------------------------
+ // Cross-relationship state-leakage regression: a declared-but-unmatched
+ // @sourceRefField at 2+ candidates on one relationship must not affect a
+ // SIBLING relationship on the same entity whose declared field DOES match.
+ // Backfilled from the Java port (Issue368RelationshipReferenceValidationTest
+ // .siblingRelationshipWithMatchingSourceRefFieldProducesNoErrorWhileTheBadOneDoes) --
+ // the case most likely to catch per-relationship state accidentally shared
+ // across a loop iteration.
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Sibling_relationship_with_matching_source_ref_field_produces_no_error_while_the_bad_one_does()
+ {
+ const string doc = """
+ { "metadata.root": { "package": "repro", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Venue", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "venueId" } },
+ { "field.long": { "name": "alphaFk" } },
+ { "field.long": { "name": "betaFk" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "venueRef", "@fields": ["venueId"], "@references": "Venue" } },
+ { "relationship.association": { "name": "venue", "@objectRef": "Venue", "@cardinality": "one", "@sourceRefField": "venueId" } },
+ { "identity.reference": { "name": "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } },
+ { "identity.reference": { "name": "betaRef", "@fields": ["betaFk"], "@references": "Team" } },
+ { "relationship.association": { "name": "winner", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "doesNotExist" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInline(doc);
+ Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code));
+ string joined = string.Join("\n", result.Errors.Select(e => e.Message));
+ Assert.Contains("Match.winner", joined);
+ Assert.Contains("\"doesNotExist\"", joined);
+ // The good sibling must never be named in any error -- no state leaked
+ // from evaluating "winner" into (or out of) evaluating "venue".
+ Assert.DoesNotContain("Match.venue", joined);
+ }
+
// -------------------------------------------------------------------------
// (D) Rule (e) must iterate the EFFECTIVE relationship set.
// -------------------------------------------------------------------------
diff --git a/server/python/tests/unit/test_relationship_m2m_validation.py b/server/python/tests/unit/test_relationship_m2m_validation.py
index c7230cb05..1c7d1649e 100644
--- a/server/python/tests/unit/test_relationship_m2m_validation.py
+++ b/server/python/tests/unit/test_relationship_m2m_validation.py
@@ -24,6 +24,7 @@
import json
from metaobjects import InMemoryStringSource, MetaDataLoader
+from metaobjects.shared.base_types import TYPE_OBJECT
def _load(doc: dict) -> tuple[list[str], list[str]]:
@@ -34,6 +35,22 @@ def _load(doc: dict) -> tuple[list[str], list[str]]:
return codes, messages
+def _load_multi(*docs: dict) -> tuple[list[str], list[str], list[str]]:
+ """Load several docs as separate sources and return (error codes, error
+ messages, object visit order) -- the visit order is the resolution key
+ of each ``object.entity`` in ``root.children()`` iteration order, i.e.
+ the same order ``validate_relationships``'s outer loop sees them in."""
+ sources = [
+ InMemoryStringSource(json.dumps(doc), id=f"inline-{i}.json")
+ for i, doc in enumerate(docs)
+ ]
+ result = MetaDataLoader().load(sources)
+ codes = [e.code.value for e in result.errors]
+ messages = [e.message for e in result.errors]
+ visit_order = [c.resolution_key() for c in result.root.children() if c.type == TYPE_OBJECT]
+ return codes, messages, visit_order
+
+
# ---------------------------------------------------------------------------
# (B) @sourceRefField becomes legal on @cardinality: "one"
# ---------------------------------------------------------------------------
@@ -389,3 +406,152 @@ def test_valid_hetero_m2m_produces_no_relationship_errors() -> None:
codes, _ = _load(doc)
assert "ERR_INVALID_RELATIONSHIP" not in codes
assert "ERR_BAD_ATTR_VALUE" not in codes
+
+
+# ---------------------------------------------------------------------------
+# Cross-relationship state-leakage regression: a declared-but-unmatched
+# @sourceRefField at 2+ candidates on one relationship must not affect a
+# SIBLING relationship on the same entity whose declared field DOES match.
+# Backfilled from the Java port (Issue368RelationshipReferenceValidationTest
+# .siblingRelationshipWithMatchingSourceRefFieldProducesNoErrorWhileTheBadOneDoes) --
+# the case most likely to catch per-relationship state accidentally shared
+# across a loop iteration.
+# ---------------------------------------------------------------------------
+
+
+def test_sibling_relationship_with_matching_source_ref_field_produces_no_error_while_the_bad_one_does() -> None:
+ doc = {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "Venue", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "venueId"}},
+ {"field.long": {"name": "alphaFk"}},
+ {"field.long": {"name": "betaFk"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ # The GOOD sibling: a single, correctly-matched candidate onto Venue.
+ {"identity.reference": {"name": "venueRef", "@fields": ["venueId"], "@references": "Venue"}},
+ {"relationship.association": {"name": "venue", "@objectRef": "Venue", "@cardinality": "one", "@sourceRefField": "venueId"}},
+ # The BAD one: 2+ candidates onto Team, declared @sourceRefField matches neither.
+ {"identity.reference": {"name": "alphaRef", "@fields": ["alphaFk"], "@references": "Team"}},
+ {"identity.reference": {"name": "betaRef", "@fields": ["betaFk"], "@references": "Team"}},
+ {"relationship.association": {"name": "winner", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "doesNotExist"}},
+ ]}},
+ ]}}
+ codes, messages = _load(doc)
+ assert codes == ["ERR_INVALID_RELATIONSHIP"], f"Expected exactly one error (the bad 'winner' relationship only): {messages}"
+ joined = "\n".join(messages)
+ assert "Match.winner" in joined
+ assert '"doesNotExist"' in joined
+ # The good sibling must never be named in any error -- no state leaked
+ # from evaluating "winner" into (or out of) evaluating "venue".
+ assert "Match.venue" not in joined
+
+
+# ---------------------------------------------------------------------------
+# Two latent "obj vs. declaring entity" bugs, backfilled from the C#/Java
+# ports (see Issue368RelationshipReferenceValidationTests.cs and
+# Issue368RelationshipReferenceValidationTest.java). Both bugs are
+# ORDER-DEPENDENT: they only manifest when an inheriting entity is visited
+# by _validate_relationships's outer loop BEFORE its declaring base -- which
+# is why neither was caught by the tests above when the #368 fix landed here
+# (``declaring_entity = rel.parent if rel.parent is not None else obj`` in
+# validation_passes.py). Each fixture pins the visit-order invariant it
+# depends on via ``_load_multi``'s returned visit order, so a future change
+# to iteration order fails loudly instead of silently making the test pass
+# for the wrong reason.
+# ---------------------------------------------------------------------------
+
+
+def test_inherited_self_join_relationship_is_not_misflagged_as_non_self_join() -> None:
+ """Node extends NodeBase, which declares a @symmetric self-join
+ relationship onto NodeBase itself (@objectRef: "NodeBase"). Node is
+ declared BEFORE NodeBase (extends is resolved order-independently by a
+ deferred pass, so this is legal) so that the outer validation loop
+ visits `obj = Node` FIRST -- if rule (a)'s self-join comparison used the
+ visiting `obj` instead of the relationship's DECLARING entity (NodeBase,
+ via rel.parent), it would wrongly conclude @objectRef "NodeBase" is not
+ the (visiting) declaring entity "Node" and misfire ERR_BAD_ATTR_VALUE."""
+ doc = {"metadata.root": {"package": "acme", "children": [
+ {"object.entity": {"name": "Node", "extends": "NodeBase", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "NodeBase", "@isAbstract": True, "children": [
+ {"relationship.association": {"name": "peers", "@cardinality": "many", "@objectRef": "NodeBase",
+ "@through": "NodeLink", "@symmetric": True}},
+ ]}},
+ {"object.entity": {"name": "NodeLink", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "aId"}},
+ {"field.long": {"name": "bId"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"identity.reference": {"name": "a", "@fields": ["aId"], "@references": "NodeBase"}},
+ {"identity.reference": {"name": "b", "@fields": ["bId"], "@references": "NodeBase"}},
+ ]}},
+ ]}}
+ codes, messages, visit_order = _load_multi(doc)
+ # Pin the iteration-order invariant this test's premise depends on: if
+ # root.children() ever stopped iterating in declaration order (e.g.
+ # started sorting alphabetically), "Node" would no longer be visited
+ # before "NodeBase" and this test would keep passing for the wrong
+ # reason -- silently no longer exercising the bug at all. Fail loudly.
+ assert visit_order == ["acme::Node", "acme::NodeBase", "acme::NodeLink"], (
+ "root.children() must iterate in declaration order for this test's "
+ f"premise (Node visited before NodeBase) to hold; got {visit_order}"
+ )
+ assert "ERR_BAD_ATTR_VALUE" not in codes, messages
+ assert "ERR_INVALID_RELATIONSHIP" not in codes, messages
+
+
+def test_inherited_bare_through_resolves_in_the_declaring_entity_package_not_the_visiting_one() -> None:
+ """WeekBase (package "base") declares a M:N relationship with a BARE
+ @through "Tag" -- ADR-0042 says a bare ref resolves in the DECLARING
+ entity's package ("base::Tag"), never the package of whichever entity
+ inherits and visits it. Week extends WeekBase from a DIFFERENT package
+ ("acme") that also happens to declare its own unrelated "Tag" entity.
+ The acme source is loaded FIRST so the outer validation loop visits
+ `obj = Week` before `obj = WeekBase` -- if @through resolution used the
+ visiting entity's package it would wrongly bind to "acme::Tag" (which
+ has zero identity.reference children) instead of "base::Tag" (which
+ correctly has two)."""
+ acme_doc = {"metadata.root": {"package": "acme", "children": [
+ {"object.entity": {"name": "Week", "extends": "base::WeekBase", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ {"object.entity": {"name": "Tag", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ ]}},
+ ]}}
+ base_doc = {"metadata.root": {"package": "base", "children": [
+ {"object.entity": {"name": "WeekBase", "@isAbstract": True, "children": [
+ {"relationship.association": {"name": "tags", "@cardinality": "many", "@objectRef": "Tag", "@through": "Tag"}},
+ ]}},
+ {"object.entity": {"name": "Tag", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "weekId"}},
+ {"field.long": {"name": "labelId"}},
+ {"identity.primary": {"name": "id", "@fields": "id"}},
+ {"identity.reference": {"name": "w", "@fields": ["weekId"], "@references": "base::WeekBase"}},
+ {"identity.reference": {"name": "l", "@fields": ["labelId"], "@references": "base::Tag"}},
+ ]}},
+ ]}}
+ codes, messages, visit_order = _load_multi(acme_doc, base_doc)
+ # Pin the iteration-order invariant: the acme source must be fully
+ # visited (Week, then acme::Tag) before base's WeekBase/Tag, or this
+ # test's premise (obj = Week visited before obj = WeekBase) silently
+ # stops holding and the test would keep passing without ever exercising
+ # the bug.
+ assert visit_order == ["acme::Week", "acme::Tag", "base::WeekBase", "base::Tag"], (
+ "root.children() must iterate in source-then-declaration order for "
+ f"this test's premise (Week visited before WeekBase) to hold; got {visit_order}"
+ )
+ assert "ERR_INVALID_RELATIONSHIP" not in codes, messages
diff --git a/server/typescript/packages/metadata/test/relationship-m2m.test.ts b/server/typescript/packages/metadata/test/relationship-m2m.test.ts
index 6b8f7acc1..6d0a45765 100644
--- a/server/typescript/packages/metadata/test/relationship-m2m.test.ts
+++ b/server/typescript/packages/metadata/test/relationship-m2m.test.ts
@@ -600,3 +600,98 @@ describe("FR-017 Rule (e) — #368 ambiguous 1:N reference resolution", () => {
expect(codesOf(errors)).toEqual(["ERR_INVALID_RELATIONSHIP"]);
});
});
+
+// ---------------------------------------------------------------------------
+// Two latent "obj vs. declaring entity" bugs, backfilled from the C#/Java
+// ports (see Issue368RelationshipReferenceValidationTests.cs and
+// Issue368RelationshipReferenceValidationTest.java). Both bugs are
+// ORDER-DEPENDENT: they only manifest when an inheriting entity is visited
+// by validateRelationships's outer loop BEFORE its declaring base — which is
+// why neither was caught by the tests above when the #368 fix landed here
+// (`const declaringEntity = rel.parent ?? obj;` in validation-passes.ts).
+// Each fixture pins the visit-order invariant it depends on via
+// `objectVisitOrder`, so a future change to iteration order fails loudly
+// instead of silently making the test pass for the wrong reason.
+// ---------------------------------------------------------------------------
+
+describe("FR-017 #368 order-dependence regressions (declaring entity vs. visiting entity)", () => {
+ function objectVisitOrder(root: { children(): readonly { type: string; resolutionKey(): string }[] }): string[] {
+ return root.children().filter((c) => c.type === TYPE_OBJECT).map((o) => o.resolutionKey());
+ }
+
+ test("inherited self-join relationship is not misflagged as non-self-join", async () => {
+ // Node extends NodeBase, which declares a @symmetric self-join relationship
+ // onto NodeBase itself (@objectRef: "NodeBase"). Node is declared BEFORE
+ // NodeBase (extends is resolved order-independently by a deferred pass, so
+ // this is legal) so that the outer validation loop visits `obj = Node`
+ // FIRST — if rule (a)'s self-join comparison used the visiting `obj`
+ // instead of the relationship's DECLARING entity (NodeBase, via rel.parent),
+ // it would wrongly conclude @objectRef "NodeBase" is not the (visiting)
+ // declaring entity "Node" and misfire ERR_BAD_ATTR_VALUE.
+ const { root, errors } = await loadDoc({ "metadata.root": { package: "acme", children: [
+ { "object.entity": { name: "Node", "extends": "NodeBase", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "NodeBase", "@isAbstract": true, children: [
+ { "relationship.association": { name: "peers", "@cardinality": "many", "@objectRef": "NodeBase",
+ "@through": "NodeLink", "@symmetric": true } } ] } },
+ { "object.entity": { name: "NodeLink", children: [
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "aId" } },
+ { "field.long": { name: "bId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { name: "a", "@fields": ["aId"], "@references": "NodeBase" } },
+ { "identity.reference": { name: "b", "@fields": ["bId"], "@references": "NodeBase" } } ] } },
+ ] } });
+ // Pin the iteration-order invariant this test's premise depends on: if
+ // root.children() ever stopped iterating in declaration order (e.g. started
+ // sorting alphabetically), "Node" would no longer be visited before
+ // "NodeBase" and this test would keep passing for the wrong reason —
+ // silently no longer exercising the bug at all. Fail loudly instead.
+ expect(objectVisitOrder(root)).toEqual(["acme::Node", "acme::NodeBase", "acme::NodeLink"]);
+ expect(codesOf(errors)).not.toContain("ERR_BAD_ATTR_VALUE");
+ expect(codesOf(errors)).not.toContain("ERR_INVALID_RELATIONSHIP");
+ });
+
+ test("inherited bare @through resolves in the declaring entity's package, not the visiting one", async () => {
+ // WeekBase (package "base") declares a M:N relationship with a BARE
+ // @through "Tag" — ADR-0042 says a bare ref resolves in the DECLARING
+ // entity's package ("base::Tag"), never the package of whichever entity
+ // inherits and visits it. Week extends WeekBase from a DIFFERENT package
+ // ("acme") that also happens to declare its own unrelated "Tag" entity.
+ // The acme source is loaded FIRST so the outer validation loop visits
+ // `obj = Week` before `obj = WeekBase` — if @through resolution used
+ // the visiting entity's package it would wrongly bind to "acme::Tag"
+ // (which has zero identity.reference children) instead of "base::Tag"
+ // (which correctly has two).
+ const acmeDoc = { "metadata.root": { package: "acme", children: [
+ { "object.entity": { name: "Week", "extends": "base::WeekBase", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ { "object.entity": { name: "Tag", children: [
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } } ] } },
+ ] } };
+ const baseDoc = { "metadata.root": { package: "base", children: [
+ { "object.entity": { name: "WeekBase", "@isAbstract": true, children: [
+ { "relationship.association": { name: "tags", "@cardinality": "many", "@objectRef": "Tag", "@through": "Tag" } } ] } },
+ { "object.entity": { name: "Tag", children: [
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "weekId" } },
+ { "field.long": { name: "labelId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { name: "w", "@fields": ["weekId"], "@references": "base::WeekBase" } },
+ { "identity.reference": { name: "l", "@fields": ["labelId"], "@references": "base::Tag" } } ] } },
+ ] } };
+ const { root, errors } = await new MetaDataLoader().load([
+ new InMemoryStringSource(JSON.stringify(acmeDoc), { id: "acme.json" }),
+ new InMemoryStringSource(JSON.stringify(baseDoc), { id: "base.json" }),
+ ]);
+ // Pin the iteration-order invariant: the acme source must be fully visited
+ // (Week, then acme::Tag) before base's WeekBase/Tag, or this test's premise
+ // (obj = Week visited before obj = WeekBase) silently stops holding and the
+ // test would keep passing without ever exercising the bug.
+ expect(objectVisitOrder(root)).toEqual(["acme::Week", "acme::Tag", "base::WeekBase", "base::Tag"]);
+ expect(codesOf(errors)).not.toContain("ERR_INVALID_RELATIONSHIP");
+ });
+});
From 90393cad36ecf3f60fdc4b6243a6faba23490e0d Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 19:31:31 -0400
Subject: [PATCH 17/31] fix(migrate-ts,csharp): #368 round 2 --
referential-action correlation 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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../Issue368ReferentialActionsTests.cs | 137 ++++++++++++++++++
.../Persistence/Db/ReferentialActions.cs | 51 ++++++-
.../migrate-ts/src/referential-actions.ts | 46 +++++-
.../test/unit/referential-actions.test.ts | 131 +++++++++++++++++
4 files changed, 354 insertions(+), 11 deletions(-)
create mode 100644 server/csharp/MetaObjects.Conformance.Tests/Issue368ReferentialActionsTests.cs
diff --git a/server/csharp/MetaObjects.Conformance.Tests/Issue368ReferentialActionsTests.cs b/server/csharp/MetaObjects.Conformance.Tests/Issue368ReferentialActionsTests.cs
new file mode 100644
index 000000000..4feedd2ef
--- /dev/null
+++ b/server/csharp/MetaObjects.Conformance.Tests/Issue368ReferentialActionsTests.cs
@@ -0,0 +1,137 @@
+// Issue #368 round 2 — the same "matches on target alone" defect the ladder
+// (MetaObjects.Core.Relationship.RelationshipReferences) fixed for relationship
+// navigation also existed in MetaObjects.Persistence.Db.ReferentialActions:
+// correlating an identity.reference to the relationship.* that supplies its
+// @onDelete / @onUpdate matched on the TARGET ENTITY ALONE, so when an entity
+// declares more than one relationship to the same target (Match.homeTeam /
+// awayTeam, both -> Team), every FK past the first silently inherited the
+// FIRST relationship's referential actions.
+//
+// C# port of the TS suite
+// (server/typescript/packages/migrate-ts/test/unit/referential-actions.test.ts,
+// describe("#368 round 2: ...")) — that file is the authoritative spec; this
+// mirrors it directly against ReferentialActions.Resolve.
+
+using MetaObjects.Loader;
+using MetaObjects.Meta;
+using MetaObjects.Persistence.Db;
+using Xunit;
+
+namespace MetaObjects.Conformance.Tests;
+
+public class Issue368ReferentialActionsTests
+{
+ private static LoadResult LoadInline(string json) =>
+ new MetaDataLoader().Load([new InMemoryStringSource(json, id: "inline.json")]);
+
+ // Not an interpolated raw string: the model is dense in `}}`, which an
+ // interpolated raw string would read as an interpolation hole (see the
+ // comment on TwoEntityTemplate in Issue294ReferentialActionTests.cs).
+ private const string MatchTeamTemplate = """
+ { "metadata.root": { "package": "acme", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "homeTeamId" } },
+ { "field.long": { "name": "awayTeamId" } },
+ { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "identity.reference": { "name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ /*RELS*/,
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}}
+ ]}}
+ """;
+
+ private static string MatchTeamDoc(string rels) => MatchTeamTemplate.Replace("/*RELS*/", rels);
+
+ private static (MetaObject Match, MetaReferenceIdentity HomeTeamRef, MetaReferenceIdentity AwayTeamRef)
+ LoadMatchTeam(string rels)
+ {
+ var result = LoadInline(MatchTeamDoc(rels));
+ Assert.Empty(result.Errors);
+ var match = result.Root.FindObject("Match")!;
+ var homeTeamRef = match.ReferenceIdentities().Single(r => r.Name == "homeTeamRef");
+ var awayTeamRef = match.ReferenceIdentities().Single(r => r.Name == "awayTeamRef");
+ return (match, homeTeamRef, awayTeamRef);
+ }
+
+ [Fact]
+ public void REGRESSION_name_pairing_each_FK_gets_its_own_relationships_action()
+ {
+ var (match, homeTeamRef, awayTeamRef) = LoadMatchTeam("""
+ { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict" } },
+ { "relationship.composition": { "name": "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade" } }
+ """);
+ Assert.Equal(new ResolvedReferentialActions("restrict", "cascade"), ReferentialActions.Resolve(match, homeTeamRef));
+ Assert.Equal(new ResolvedReferentialActions("cascade", "cascade"), ReferentialActions.Resolve(match, awayTeamRef));
+ }
+
+ [Fact]
+ public void REGRESSION_source_ref_field_each_FK_gets_its_own_relationships_action_even_when_names_dont_pair()
+ {
+ // Relationship names deliberately don't name-pair with either reference —
+ // only @sourceRefField can route these correctly.
+ var (match, homeTeamRef, awayTeamRef) = LoadMatchTeam("""
+ { "relationship.association": { "name": "primary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict", "@sourceRefField": "homeTeamId" } },
+ { "relationship.composition": { "name": "secondary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade", "@sourceRefField": "awayTeamId" } }
+ """);
+ Assert.Equal(new ResolvedReferentialActions("restrict", "cascade"), ReferentialActions.Resolve(match, homeTeamRef));
+ Assert.Equal(new ResolvedReferentialActions("cascade", "cascade"), ReferentialActions.Resolve(match, awayTeamRef));
+ }
+
+ [Fact]
+ public void No_regression_a_single_relationship_to_a_target_still_resolves_regardless_of_its_name()
+ {
+ // Only one reference to Team exists here (winnerRef) — the relationship
+ // name "champion" pairs with neither reference's name, but the ladder's
+ // "exactly one candidate" tier means naming never mattered for this, by
+ // far the most common, shape.
+ const string doc = """
+ { "metadata.root": { "package": "acme", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}},
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "winnerId" } },
+ { "identity.reference": { "name": "winnerRef", "@fields": ["winnerId"], "@references": "Team" } },
+ { "relationship.composition": { "name": "champion", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]}}
+ ]}}
+ """;
+ var result = LoadInline(doc);
+ Assert.Empty(result.Errors);
+ var match = result.Root.FindObject("Match")!;
+ var reference = match.ReferenceIdentities().Single();
+ Assert.Equal(new ResolvedReferentialActions("cascade", "cascade"), ReferentialActions.Resolve(match, reference));
+ }
+
+ [Fact]
+ public void Unresolvable_correlation_emits_the_default_not_the_first_relationships_action()
+ {
+ // Neither relationship name pairs with either reference and no
+ // @sourceRefField disambiguates — the ladder can't choose. This is the
+ // SAME ambiguity rule (e) already refuses at load time (ADR-0029 §5),
+ // mirroring how Issue368RelationshipReferenceLadderTests asserts the
+ // load error directly rather than requiring a clean load. Resolve()
+ // still fails closed (no action on either FK) as defense in depth,
+ // rather than either one inheriting the first relationship's action.
+ var result = LoadInline(MatchTeamDoc("""
+ { "relationship.association": { "name": "primary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict" } },
+ { "relationship.composition": { "name": "secondary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade" } }
+ """));
+ Assert.Equal(
+ [ErrorCode.ERR_INVALID_RELATIONSHIP, ErrorCode.ERR_INVALID_RELATIONSHIP],
+ result.Errors.Select(e => e.Code));
+ var match = result.Root.FindObject("Match")!;
+ var homeTeamRef = match.ReferenceIdentities().Single(r => r.Name == "homeTeamRef");
+ var awayTeamRef = match.ReferenceIdentities().Single(r => r.Name == "awayTeamRef");
+ Assert.Equal(new ResolvedReferentialActions(null, null), ReferentialActions.Resolve(match, homeTeamRef));
+ Assert.Equal(new ResolvedReferentialActions(null, null), ReferentialActions.Resolve(match, awayTeamRef));
+ }
+}
diff --git a/server/csharp/MetaObjects/Persistence/Db/ReferentialActions.cs b/server/csharp/MetaObjects/Persistence/Db/ReferentialActions.cs
index b9ba23498..67d3383e8 100644
--- a/server/csharp/MetaObjects/Persistence/Db/ReferentialActions.cs
+++ b/server/csharp/MetaObjects/Persistence/Db/ReferentialActions.cs
@@ -7,6 +7,7 @@
// EF Core model can agree with that DDL instead of falling back to EF's own convention
// (#294). Any change to the precedence belongs in BOTH files.
+using MetaObjects.Core.Relationship;
using MetaObjects.Meta;
namespace MetaObjects.Persistence.Db;
@@ -50,7 +51,12 @@ public static class ReferentialActions
/// would add a clause that changes nothing (and, on the TS side, would dirty
/// introspection round-trips).
///
- /// If multiple relationships target the same entity (rare), the first is used.
+ /// When more than one relationship targets the same entity (Match.homeTeam /
+ /// awayTeam both -> Team), each is correlated to its OWN identity.reference via the
+ /// shared relationship<->reference ladder (#368 round 2) — never the first
+ /// match. When the correlation is genuinely ambiguous, it contributes nothing rather
+ /// than an arbitrary — possibly wrong — action (see the tier-2/tier-3 bodies below for
+ /// the exact rules).
///
public static ResolvedReferentialActions Resolve(MetaObject entity, MetaReferenceIdentity reference)
{
@@ -76,14 +82,30 @@ public static ResolvedReferentialActions Resolve(MetaObject entity, MetaReferenc
// direct FK. When the target does not resolve (dangling @references — normally
// a load error), fall back to an exact-string match so behavior on
// partially-valid trees is unchanged.
+ //
+ // #368 (round 2): `entity` may declare MORE THAN ONE relationship to this
+ // SAME target (Match.homeTeam / awayTeam both -> Team). Matching `r` on the
+ // target alone can't say which FK `r` supplies actions FOR — every FK past
+ // the first silently inherited the first relationship's actions. Resolve it
+ // with the INVERSE of the relationship->reference ladder
+ // (RelationshipReferences.ResolveRelationshipReference): `r` belongs to
+ // `reference` iff the ladder, applied to `r`, resolves back to `reference`
+ // itself — not merely "resolves to *some* reference on this target". `r` and
+ // `reference` are always declared on the same `entity`, the exact shape the
+ // ladder is built for, so this is a direct inversion, not a second parallel
+ // rule.
var rel = entity.Relationships().FirstOrDefault(r =>
{
if (r.Through is not null) return false;
var objectRef = r.ObjectRef;
if (objectRef is null) return false;
if (targetObj is null) return objectRef == target;
- return NamingRefs.RefMatchesObject(
- targetObj, objectRef, NamingRefs.EffectivePackage(r.Parent ?? entity));
+ if (!NamingRefs.RefMatchesObject(
+ targetObj, objectRef, NamingRefs.EffectivePackage(r.Parent ?? entity)))
+ return false;
+ return ReferenceEquals(
+ RelationshipReferences.ResolveRelationshipReference(entity, r.Name, objectRef, r.SourceRefField),
+ reference);
});
// (3) Failing that, the REVERSE relationship declared on the TARGET entity.
@@ -137,6 +159,20 @@ reverse.OnDelete is null
/// target, the reverse relationship cannot say WHICH FK carries the ownership edge,
/// so it contributes to none of them (arming every FK could cascade through an edge
/// the author never designated).
+ /// - When the TARGET entity declares more than one non-@through
+ /// relationship back at (rare — e.g. a "posts"
+ /// composition and a separate "latestPost" association both @objectRef-ing
+ /// Post), no candidate is preferred: this is the tier-2 ambiguity's mirror image
+ /// (multiple RELATIONSHIPS rather than multiple REFERENCES), and it cannot be
+ /// resolved by the relationship->reference ladder — that ladder picks among
+ /// references declared on the SAME object as the relationship, whereas here the
+ /// candidate relationships live on targetObj while
+ /// lives on , a different object, so the ladder has no
+ /// candidates to apply to. Previously this used the FIRST match (the same
+ /// silently-wrong-schema defect as tier 2); it now fails closed like the guard
+ /// above, once the ambiguity guard above has already established
+ /// is the entity's only candidate reference to this
+ /// target.
///
///
private static MetaRelationship? FindReverseRelationship(
@@ -160,14 +196,19 @@ reverse.OnDelete is null
// The reverse relationship's bare @objectRef resolves in ITS declaring owner's
// package (normally the target entity's own package).
- return targetObj.Relationships().FirstOrDefault(r =>
+ //
+ // Fail closed on ambiguity rather than taking the first match: if more than one
+ // non-@through relationship on targetObj resolves back to `entity`, none of them
+ // is preferred (see the class doc above).
+ var reverseCandidates = targetObj.Relationships().Where(r =>
{
if (r.Through is not null) return false; // M:N — junction path, not this FK
var objectRef = r.ObjectRef;
if (objectRef is null) return false;
return NamingRefs.RefMatchesObject(
entity, objectRef, NamingRefs.EffectivePackage(r.Parent ?? targetObj));
- });
+ }).ToList();
+ return reverseCandidates.Count == 1 ? reverseCandidates[0] : null;
}
///
diff --git a/server/typescript/packages/migrate-ts/src/referential-actions.ts b/server/typescript/packages/migrate-ts/src/referential-actions.ts
index 71d128d8d..95f63e8d1 100644
--- a/server/typescript/packages/migrate-ts/src/referential-actions.ts
+++ b/server/typescript/packages/migrate-ts/src/referential-actions.ts
@@ -7,6 +7,7 @@ import {
VALIDATOR_SUBTYPE_REQUIRED,
refMatchesObject,
resolveObjectRef,
+ resolveRelationshipReference,
type MetaObject,
type MetaRelationship,
type MetaReferenceIdentity,
@@ -76,7 +77,12 @@ export function isRequired(field: MetaData): boolean {
* omits actions when the DB value is "no-action", so the expected side does the same
* to keep round-trip diffs clean.
*
- * If multiple relationships target the same entity (rare), the first one is used.
+ * When more than one relationship targets the same entity (Match.homeTeam /
+ * awayTeam both -> Team), each is correlated to its OWN identity.reference via
+ * the shared relationship<->reference ladder (#368 round 2) — never the first
+ * match. When the correlation is genuinely ambiguous, it contributes nothing
+ * rather than an arbitrary — possibly wrong — action (see the tier-2/tier-3
+ * bodies below for the exact rules).
*
* The single `as FkAction` cast in normalize() is safe because REFERENTIAL_ACTIONS
* (metadata package) and FkAction (migrate-ts/src/types.ts) are the same four-value
@@ -116,6 +122,18 @@ export function resolveReferentialActions(
// When the target does not resolve (dangling @references — normally a
// load error), fall back to the legacy exact-string match so behavior on
// partially-valid trees is unchanged.
+ //
+ // #368 (round 2): `entity` may declare MORE THAN ONE identity.reference
+ // onto this same target (Match.homeTeamRef / awayTeamRef both -> Team).
+ // Matching `r` on the target alone can't say which of those references
+ // `r` supplies actions FOR — every FK past the first silently inherited
+ // the first relationship's actions. Resolve it with the INVERSE of the
+ // relationship->reference ladder (resolveRelationshipReference): `r`
+ // belongs to `ref` iff the ladder, applied to `r`, resolves back to
+ // `ref` itself — not merely "resolves to *some* reference on this
+ // target". `r` and `ref` are always declared on the same `entity`, the
+ // exact shape the ladder is built for, so this is a direct inversion,
+ // not a second parallel rule.
// (3) Failing that, correlate the REVERSE relationship declared on the
// TARGET entity (the documented parent-side authoring shape).
let rel = entity.relationships().find((r) => {
@@ -125,7 +143,8 @@ export function resolveReferentialActions(
if (targetObj === undefined) return objectRef === target;
const relOwner = r.parent ?? entity;
const relOwnerPkg = relOwner.package ?? relOwner.fileDefaultPackage ?? "";
- return refMatchesObject(targetObj, objectRef, relOwnerPkg);
+ if (!refMatchesObject(targetObj, objectRef, relOwnerPkg)) return false;
+ return resolveRelationshipReference(entity, r.name, objectRef, r.sourceRefField) === ref;
});
// When the tier-3 satisfiability guard fires, the reverse relationship's
// AUTHORED @onUpdate still applies (only the inferred contributions drop).
@@ -185,9 +204,19 @@ export function resolveReferentialActions(
* same target, the reverse relationship cannot say WHICH FK carries the
* ownership edge, so it contributes to none of them (arming every FK could
* cascade through an edge the author never designated).
- *
- * If multiple reverse relationships point back at the entity (rare), the first
- * one is used — mirroring the tier-2 sibling-relationship rule.
+ * - When the TARGET entity declares more than one non-@through relationship
+ * back at `entity` (rare — e.g. a "posts" composition and a separate
+ * "latestPost" association both @objectRef-ing Post), no candidate is
+ * preferred: this is the tier-2 ambiguity's mirror image (multiple
+ * RELATIONSHIPS rather than multiple REFERENCES), and it cannot be
+ * resolved by the relationship->reference ladder — that ladder picks among
+ * references declared on the SAME object as the relationship, whereas here
+ * the candidate relationships live on `targetObj` while `ref` lives on
+ * `entity`, a different object, so the ladder has no candidates to apply
+ * to. Previously this used the FIRST match (the same silently-wrong-schema
+ * defect as tier 2); it now fails closed like the guard above, once the
+ * ambiguity guard above has already established `ref` is the entity's only
+ * candidate reference to this target.
*/
function findReverseRelationship(
entity: MetaObject,
@@ -211,7 +240,11 @@ function findReverseRelationship(
// The reverse relationship's bare @objectRef resolves in ITS declaring
// owner's package (normally the target entity's own package).
- return targetObj.relationships().find((r) => {
+ //
+ // Fail closed on ambiguity rather than taking the first match: if more than
+ // one non-@through relationship on targetObj resolves back to `entity`,
+ // none of them is preferred (see the class doc above).
+ const reverseCandidates = targetObj.relationships().filter((r) => {
if (r.through !== undefined) return false; // M:N — junction path, not this FK
const objectRef = r.objectRef;
if (objectRef === undefined) return false;
@@ -219,6 +252,7 @@ function findReverseRelationship(
const relOwnerPkg = relOwner.package ?? relOwner.fileDefaultPackage ?? "";
return refMatchesObject(entity, objectRef, relOwnerPkg);
});
+ return reverseCandidates.length === 1 ? reverseCandidates[0] : undefined;
}
function normalize(a: string | undefined): FkAction | undefined {
diff --git a/server/typescript/packages/migrate-ts/test/unit/referential-actions.test.ts b/server/typescript/packages/migrate-ts/test/unit/referential-actions.test.ts
index d3dc851c4..994ce3c59 100644
--- a/server/typescript/packages/migrate-ts/test/unit/referential-actions.test.ts
+++ b/server/typescript/packages/migrate-ts/test/unit/referential-actions.test.ts
@@ -88,6 +88,137 @@ describe("resolveReferentialActions", () => {
});
});
+// ---------------------------------------------------------------------------
+// #368 round 2: an entity declaring MORE THAN ONE relationship to the SAME
+// target (Match.homeTeam / Match.awayTeam, both -> Team). Matching on the
+// target alone can't say which FK a relationship supplies actions for, so
+// resolveReferentialActions must correlate each relationship to its OWN
+// identity.reference via the same relationship<->reference ladder used
+// elsewhere in the repo (issue #368 round 1), not the first match.
+//
+// REGRESSION: against the pre-fix code, "each FK gets its own relationship's
+// action" FAILED — awayTeamRef resolved to { onDelete: "restrict" } (homeTeam's
+// action, matched first in declaration order) instead of its own "cascade".
+// ---------------------------------------------------------------------------
+
+function matchTeamDoc(rels: Record[]) {
+ return { "metadata.root": { package: "acme", children: [
+ { "object.entity": { name: "Team", children: [
+ { "source.rdb": {} },
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "source.rdb": {} },
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "homeTeamId" } },
+ { "field.long": { name: "awayTeamId" } },
+ { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } },
+ { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } },
+ ...rels,
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ ] } },
+ ] } };
+}
+
+async function loadMatchTeam(rels: Record[]) {
+ const { root, errors } = await loadDoc(matchTeamDoc(rels));
+ expect(errors).toHaveLength(0);
+ const match = root.objects().find((o) => o.name === "Match")!;
+ const homeTeamRef = match.referenceIdentities().find((r) => r.name === "homeTeamRef")!;
+ const awayTeamRef = match.referenceIdentities().find((r) => r.name === "awayTeamRef")!;
+ return { root, match, homeTeamRef, awayTeamRef };
+}
+
+describe("#368 round 2: two relationships to the same target correlate to their OWN reference", () => {
+ test("REGRESSION: name-pairing — each FK gets its own relationship's action, not the first relationship's", async () => {
+ const { match, homeTeamRef, awayTeamRef } = await loadMatchTeam([
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict" } },
+ { "relationship.composition": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade" } },
+ ]);
+ expect(resolveReferentialActions(match, homeTeamRef)).toEqual({ onDelete: "restrict", onUpdate: "cascade" });
+ expect(resolveReferentialActions(match, awayTeamRef)).toEqual({ onDelete: "cascade", onUpdate: "cascade" });
+ });
+
+ test("end-to-end: each FK's own ON DELETE lands in the emitted DDL (Postgres)", async () => {
+ const { root } = await loadMatchTeam([
+ { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict" } },
+ { "relationship.composition": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade" } },
+ ]);
+ const snapshot = buildExpectedSchema(root);
+ const { changes } = await diff(snapshot, EMPTY_SCHEMA);
+ const { up } = emit(changes, { dialect: "postgres" });
+ expect(up).toContain('ADD CONSTRAINT "matches_home_team_id_fk"');
+ expect(up).toMatch(/matches_home_team_id_fk[^;]*ON DELETE RESTRICT/);
+ expect(up).toContain('ADD CONSTRAINT "matches_away_team_id_fk"');
+ expect(up).toMatch(/matches_away_team_id_fk[^;]*ON DELETE CASCADE/);
+ });
+
+ test("REGRESSION: @sourceRefField — each FK gets its own relationship's action even when names don't pair", async () => {
+ // Relationship names deliberately don't name-pair with either reference —
+ // only @sourceRefField can route these correctly.
+ const { match, homeTeamRef, awayTeamRef } = await loadMatchTeam([
+ { "relationship.association": { name: "primary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict", "@sourceRefField": "homeTeamId" } },
+ { "relationship.composition": { name: "secondary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade", "@sourceRefField": "awayTeamId" } },
+ ]);
+ expect(resolveReferentialActions(match, homeTeamRef)).toEqual({ onDelete: "restrict", onUpdate: "cascade" });
+ expect(resolveReferentialActions(match, awayTeamRef)).toEqual({ onDelete: "cascade", onUpdate: "cascade" });
+ });
+
+ test("no regression: a single relationship to a target still resolves regardless of its name", async () => {
+ // Only one reference to Team exists here (homeTeamRef) — the relationship
+ // name "champion" pairs with neither reference's name, but the ladder's
+ // "exactly one candidate" tier means naming never mattered for this,
+ // by far the most common, shape.
+ const doc = { "metadata.root": { package: "acme", children: [
+ { "object.entity": { name: "Team", children: [
+ { "source.rdb": {} },
+ { "field.long": { name: "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "source.rdb": {} },
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "winnerId" } },
+ { "identity.reference": { name: "winnerRef", "@fields": ["winnerId"], "@references": "Team" } },
+ { "relationship.composition": { name: "champion", "@objectRef": "Team", "@cardinality": "one" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ ] } },
+ ] } };
+ const { root, errors } = await loadDoc(doc);
+ expect(errors).toHaveLength(0);
+ const match = root.objects().find((o) => o.name === "Match")!;
+ const ref = match.referenceIdentities()[0]!;
+ expect(resolveReferentialActions(match, ref)).toEqual({ onDelete: "cascade", onUpdate: "cascade" });
+ });
+
+ test("unresolvable correlation emits the default, not the first relationship's action", async () => {
+ // Neither relationship name pairs with either reference and no
+ // @sourceRefField disambiguates — the ladder can't choose. This is the
+ // SAME ambiguity rule (e) (validateOneSideReferenceResolution) already
+ // refuses at load time (ADR-0029 §5), so — mirroring how the ladder's own
+ // suite tests this (resolve-relationship-reference.test.ts,
+ // "unpairable names return undefined rather than guessing") — assert the
+ // load error directly instead of requiring a clean load, then assert
+ // resolveReferentialActions still fails closed (no action on either FK)
+ // as defense in depth, rather than either one inheriting the first
+ // relationship's action.
+ const { root, errors } = await loadDoc(matchTeamDoc([
+ { "relationship.association": { name: "primary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict" } },
+ { "relationship.composition": { name: "secondary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade" } },
+ ]));
+ expect(errors.map((e) => (e as { code?: string }).code)).toEqual([
+ "ERR_INVALID_RELATIONSHIP",
+ "ERR_INVALID_RELATIONSHIP",
+ ]);
+ const match = root.objects().find((o) => o.name === "Match")!;
+ const homeTeamRef = match.referenceIdentities().find((r) => r.name === "homeTeamRef")!;
+ const awayTeamRef = match.referenceIdentities().find((r) => r.name === "awayTeamRef")!;
+ expect(resolveReferentialActions(match, homeTeamRef)).toEqual({ onDelete: undefined, onUpdate: undefined });
+ expect(resolveReferentialActions(match, awayTeamRef)).toEqual({ onDelete: undefined, onUpdate: undefined });
+ });
+});
+
// ---------------------------------------------------------------------------
// Round-trip: buildExpectedSchema → diff (against empty) → emit
//
From 895e787df0538305e2278f99125b2118370c3d61 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 20:09:47 -0400
Subject: [PATCH 18/31] test(conformance): 1:N reference disambiguation
fixtures + sourceRefField doc fix (#368)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../expected-errors.json | 13 +++
.../input/meta.sport.json | 30 ++++++
.../expected.json | 89 ++++++++++++++++++
.../input/meta.sport.json | 31 +++++++
.../expected.json | 91 +++++++++++++++++++
.../input/meta.sport.json | 31 +++++++
.../expected/types/relationship.md | 8 +-
.../expected-registry.json | 8 +-
.../Core/Relationship/RelationshipSchema.cs | 2 +-
.../SpecMetamodel/relationship.json | 8 +-
.../relationship/MetaRelationship.java | 9 +-
.../spec_metamodel/relationship.json | 8 +-
.../relationship-definition.embedded.ts | 8 +-
site-reference/types/relationship.html | 8 +-
spec/metamodel/relationship.json | 8 +-
15 files changed, 320 insertions(+), 32 deletions(-)
create mode 100644 fixtures/conformance/error-relationship-one-refs-ambiguous/expected-errors.json
create mode 100644 fixtures/conformance/error-relationship-one-refs-ambiguous/input/meta.sport.json
create mode 100644 fixtures/conformance/relationship-one-two-refs-name-pairing/expected.json
create mode 100644 fixtures/conformance/relationship-one-two-refs-name-pairing/input/meta.sport.json
create mode 100644 fixtures/conformance/relationship-one-two-refs-sourcerefield/expected.json
create mode 100644 fixtures/conformance/relationship-one-two-refs-sourcerefield/input/meta.sport.json
diff --git a/fixtures/conformance/error-relationship-one-refs-ambiguous/expected-errors.json b/fixtures/conformance/error-relationship-one-refs-ambiguous/expected-errors.json
new file mode 100644
index 000000000..aaef4c2e3
--- /dev/null
+++ b/fixtures/conformance/error-relationship-one-refs-ambiguous/expected-errors.json
@@ -0,0 +1,13 @@
+{
+ "errors": [
+ {
+ "code": "ERR_INVALID_RELATIONSHIP",
+ "source": {
+ "format": "json",
+ "files": ["meta.sport.json"],
+ "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[6]['relationship.association']"
+ }
+ }
+ ],
+ "warnings": []
+}
diff --git a/fixtures/conformance/error-relationship-one-refs-ambiguous/input/meta.sport.json b/fixtures/conformance/error-relationship-one-refs-ambiguous/input/meta.sport.json
new file mode 100644
index 000000000..a2ce8f14b
--- /dev/null
+++ b/fixtures/conformance/error-relationship-one-refs-ambiguous/input/meta.sport.json
@@ -0,0 +1,30 @@
+{
+ "metadata.root": {
+ "package": "acme::sport",
+ "children": [
+ {
+ "object.entity": {
+ "name": "Team",
+ "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]
+ }
+ },
+ {
+ "object.entity": {
+ "name": "Match",
+ "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "alphaFk" } },
+ { "field.long": { "name": "betaFk" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "alphaRef", "@fields": "alphaFk", "@references": "acme::sport::Team" } },
+ { "identity.reference": { "name": "betaRef", "@fields": "betaFk", "@references": "acme::sport::Team" } },
+ { "relationship.association": { "name": "winner", "@cardinality": "one", "@objectRef": "acme::sport::Team" } }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/fixtures/conformance/relationship-one-two-refs-name-pairing/expected.json b/fixtures/conformance/relationship-one-two-refs-name-pairing/expected.json
new file mode 100644
index 000000000..278c4e410
--- /dev/null
+++ b/fixtures/conformance/relationship-one-two-refs-name-pairing/expected.json
@@ -0,0 +1,89 @@
+{
+ "metadata.root": {
+ "package": "acme::sport",
+ "children": [
+ {
+ "object.entity": {
+ "name": "Team",
+ "children": [
+ {
+ "field.long": {
+ "name": "id"
+ }
+ },
+ {
+ "identity.primary": {
+ "name": "id",
+ "@fields": [
+ "id"
+ ]
+ }
+ }
+ ]
+ }
+ },
+ {
+ "object.entity": {
+ "name": "Match",
+ "children": [
+ {
+ "field.long": {
+ "name": "id"
+ }
+ },
+ {
+ "field.long": {
+ "name": "homeTeamId"
+ }
+ },
+ {
+ "field.long": {
+ "name": "awayTeamId"
+ }
+ },
+ {
+ "identity.primary": {
+ "name": "id",
+ "@fields": [
+ "id"
+ ]
+ }
+ },
+ {
+ "identity.reference": {
+ "name": "homeTeamRef",
+ "@fields": [
+ "homeTeamId"
+ ],
+ "@references": "acme::sport::Team"
+ }
+ },
+ {
+ "identity.reference": {
+ "name": "awayTeamRef",
+ "@fields": [
+ "awayTeamId"
+ ],
+ "@references": "acme::sport::Team"
+ }
+ },
+ {
+ "relationship.association": {
+ "name": "homeTeam",
+ "@cardinality": "one",
+ "@objectRef": "acme::sport::Team"
+ }
+ },
+ {
+ "relationship.association": {
+ "name": "awayTeam",
+ "@cardinality": "one",
+ "@objectRef": "acme::sport::Team"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/fixtures/conformance/relationship-one-two-refs-name-pairing/input/meta.sport.json b/fixtures/conformance/relationship-one-two-refs-name-pairing/input/meta.sport.json
new file mode 100644
index 000000000..7f5921310
--- /dev/null
+++ b/fixtures/conformance/relationship-one-two-refs-name-pairing/input/meta.sport.json
@@ -0,0 +1,31 @@
+{
+ "metadata.root": {
+ "package": "acme::sport",
+ "children": [
+ {
+ "object.entity": {
+ "name": "Team",
+ "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]
+ }
+ },
+ {
+ "object.entity": {
+ "name": "Match",
+ "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "homeTeamId" } },
+ { "field.long": { "name": "awayTeamId" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "homeTeamRef", "@fields": "homeTeamId", "@references": "acme::sport::Team" } },
+ { "identity.reference": { "name": "awayTeamRef", "@fields": "awayTeamId", "@references": "acme::sport::Team" } },
+ { "relationship.association": { "name": "homeTeam", "@cardinality": "one", "@objectRef": "acme::sport::Team" } },
+ { "relationship.association": { "name": "awayTeam", "@cardinality": "one", "@objectRef": "acme::sport::Team" } }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/fixtures/conformance/relationship-one-two-refs-sourcerefield/expected.json b/fixtures/conformance/relationship-one-two-refs-sourcerefield/expected.json
new file mode 100644
index 000000000..d5ceff8af
--- /dev/null
+++ b/fixtures/conformance/relationship-one-two-refs-sourcerefield/expected.json
@@ -0,0 +1,91 @@
+{
+ "metadata.root": {
+ "package": "acme::sport",
+ "children": [
+ {
+ "object.entity": {
+ "name": "Team",
+ "children": [
+ {
+ "field.long": {
+ "name": "id"
+ }
+ },
+ {
+ "identity.primary": {
+ "name": "id",
+ "@fields": [
+ "id"
+ ]
+ }
+ }
+ ]
+ }
+ },
+ {
+ "object.entity": {
+ "name": "Match",
+ "children": [
+ {
+ "field.long": {
+ "name": "id"
+ }
+ },
+ {
+ "field.long": {
+ "name": "alphaFk"
+ }
+ },
+ {
+ "field.long": {
+ "name": "betaFk"
+ }
+ },
+ {
+ "identity.primary": {
+ "name": "id",
+ "@fields": [
+ "id"
+ ]
+ }
+ },
+ {
+ "identity.reference": {
+ "name": "alphaRef",
+ "@fields": [
+ "alphaFk"
+ ],
+ "@references": "acme::sport::Team"
+ }
+ },
+ {
+ "identity.reference": {
+ "name": "betaRef",
+ "@fields": [
+ "betaFk"
+ ],
+ "@references": "acme::sport::Team"
+ }
+ },
+ {
+ "relationship.association": {
+ "name": "winner",
+ "@cardinality": "one",
+ "@objectRef": "acme::sport::Team",
+ "@sourceRefField": "alphaFk"
+ }
+ },
+ {
+ "relationship.association": {
+ "name": "loser",
+ "@cardinality": "one",
+ "@objectRef": "acme::sport::Team",
+ "@sourceRefField": "betaFk"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/fixtures/conformance/relationship-one-two-refs-sourcerefield/input/meta.sport.json b/fixtures/conformance/relationship-one-two-refs-sourcerefield/input/meta.sport.json
new file mode 100644
index 000000000..21af0bdb0
--- /dev/null
+++ b/fixtures/conformance/relationship-one-two-refs-sourcerefield/input/meta.sport.json
@@ -0,0 +1,31 @@
+{
+ "metadata.root": {
+ "package": "acme::sport",
+ "children": [
+ {
+ "object.entity": {
+ "name": "Team",
+ "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]
+ }
+ },
+ {
+ "object.entity": {
+ "name": "Match",
+ "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "alphaFk" } },
+ { "field.long": { "name": "betaFk" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "alphaRef", "@fields": "alphaFk", "@references": "acme::sport::Team" } },
+ { "identity.reference": { "name": "betaRef", "@fields": "betaFk", "@references": "acme::sport::Team" } },
+ { "relationship.association": { "name": "winner", "@cardinality": "one", "@objectRef": "acme::sport::Team", "@sourceRefField": "alphaFk" } },
+ { "relationship.association": { "name": "loser", "@cardinality": "one", "@objectRef": "acme::sport::Team", "@sourceRefField": "betaFk" } }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/fixtures/metamodel-docs/expected/types/relationship.md b/fixtures/metamodel-docs/expected/types/relationship.md
index 9341957c6..1cb51af1f 100644
--- a/fixtures/metamodel-docs/expected/types/relationship.md
+++ b/fixtures/metamodel-docs/expected/types/relationship.md
@@ -28,7 +28,7 @@ A shared/independent containment — the parent groups the target but does not o
| `@objectRef` | string | no | | | metaobjects-core-types | Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car'). |
| `@onDelete` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict). |
| `@onUpdate` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on key update. Default cascade. |
-| `@sourceRefField` | string | no | | | metaobjects-core-types | Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric. |
+| `@sourceRefField` | string | no | | | metaobjects-core-types | Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely. |
| `@symmetric` | boolean | no | | | metaobjects-core-types | Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField. |
| `@through` | string | no | | | metaobjects-core-types | Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references. |
@@ -54,7 +54,7 @@ A plain reference to another entity — no ownership; the target has an independ
| `@objectRef` | string | no | | | metaobjects-core-types | Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car'). |
| `@onDelete` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict). |
| `@onUpdate` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on key update. Default cascade. |
-| `@sourceRefField` | string | no | | | metaobjects-core-types | Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric. |
+| `@sourceRefField` | string | no | | | metaobjects-core-types | Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely. |
| `@symmetric` | boolean | no | | | metaobjects-core-types | Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField. |
| `@through` | string | no | | | metaobjects-core-types | Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references. |
@@ -78,7 +78,7 @@ Abstract relationship base — shared shape for the concrete association/aggrega
| `@objectRef` | string | no | | | metaobjects-core-types | Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car'). |
| `@onDelete` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict). |
| `@onUpdate` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on key update. Default cascade. |
-| `@sourceRefField` | string | no | | | metaobjects-core-types | Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric. |
+| `@sourceRefField` | string | no | | | metaobjects-core-types | Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely. |
| `@symmetric` | boolean | no | | | metaobjects-core-types | Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField. |
| `@through` | string | no | | | metaobjects-core-types | Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references. |
@@ -104,7 +104,7 @@ An owned containment — the parent owns the target's lifecycle; deleting the pa
| `@objectRef` | string | no | | | metaobjects-core-types | Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car'). |
| `@onDelete` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict). |
| `@onUpdate` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on key update. Default cascade. |
-| `@sourceRefField` | string | no | | | metaobjects-core-types | Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric. |
+| `@sourceRefField` | string | no | | | metaobjects-core-types | Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely. |
| `@symmetric` | boolean | no | | | metaobjects-core-types | Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField. |
| `@through` | string | no | | | metaobjects-core-types | Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references. |
diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json
index 706701432..294eb5992 100644
--- a/fixtures/registry-conformance/expected-registry.json
+++ b/fixtures/registry-conformance/expected-registry.json
@@ -3565,7 +3565,7 @@
"valueType": "string",
"isArray": false,
"required": false,
- "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric."
+ "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."
},
{
"name": "symmetric",
@@ -3636,7 +3636,7 @@
"valueType": "string",
"isArray": false,
"required": false,
- "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric."
+ "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."
},
{
"name": "symmetric",
@@ -3706,7 +3706,7 @@
"valueType": "string",
"isArray": false,
"required": false,
- "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric."
+ "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."
},
{
"name": "symmetric",
@@ -3777,7 +3777,7 @@
"valueType": "string",
"isArray": false,
"required": false,
- "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric."
+ "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."
},
{
"name": "symmetric",
diff --git a/server/csharp/MetaObjects/Core/Relationship/RelationshipSchema.cs b/server/csharp/MetaObjects/Core/Relationship/RelationshipSchema.cs
index 909377f55..9b72d51ab 100644
--- a/server/csharp/MetaObjects/Core/Relationship/RelationshipSchema.cs
+++ b/server/csharp/MetaObjects/Core/Relationship/RelationshipSchema.cs
@@ -38,7 +38,7 @@ public static class RelationshipSchema
Name: RelationshipConstants.RELATIONSHIP_ATTR_SOURCE_REF_FIELD,
ValueType: AttrConstants.ATTR_SUBTYPE_STRING,
Required: false,
- Description: "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric."),
+ Description: "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."),
new AttrSchema(
Name: RelationshipConstants.RELATIONSHIP_ATTR_SYMMETRIC,
diff --git a/server/csharp/MetaObjects/SpecMetamodel/relationship.json b/server/csharp/MetaObjects/SpecMetamodel/relationship.json
index bc6e78d64..e4cf15031 100644
--- a/server/csharp/MetaObjects/SpecMetamodel/relationship.json
+++ b/server/csharp/MetaObjects/SpecMetamodel/relationship.json
@@ -10,7 +10,7 @@
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
{ "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." },
- { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." },
+ { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." },
{ "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." },
{ "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." },
{ "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
@@ -26,7 +26,7 @@
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
{ "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." },
- { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." },
+ { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." },
{ "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." },
{ "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." },
{ "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
@@ -42,7 +42,7 @@
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
{ "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." },
- { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." },
+ { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." },
{ "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." },
{ "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." },
{ "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
@@ -58,7 +58,7 @@
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
{ "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." },
- { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." },
+ { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." },
{ "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." },
{ "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." },
{ "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
diff --git a/server/java/metadata/src/main/java/com/metaobjects/relationship/MetaRelationship.java b/server/java/metadata/src/main/java/com/metaobjects/relationship/MetaRelationship.java
index dde80bc8d..8f8363173 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/relationship/MetaRelationship.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/relationship/MetaRelationship.java
@@ -50,9 +50,12 @@ public abstract class MetaRelationship extends MetaData {
* derived from those references (never restated). Renamed from the retired {@code @joinEntity}. */
public final static String ATTR_THROUGH = "through";
- /** Directed self-join disambiguator: names the source-side FK field on the junction
- * (the other reference is the target side). Required only for directed/ambiguous self-join
- * M:N. Mutually exclusive with {@code @symmetric}. */
+ /** Disambiguates which reference/FK field a relationship uses when more than one candidate
+ * exists: on a directed self-join M:N it names the junction's source-side reference
+ * (mutually exclusive with {@code @symmetric}), and on a {@code @cardinality:'one'}
+ * relationship it names which of several {@code identity.reference} nodes onto the same
+ * target the relationship navigates when name-pairing does not resolve it uniquely
+ * (issue #368). */
public final static String ATTR_SOURCE_REF_FIELD = "sourceRefField";
/** Undirected self-join flag (union-on-read). Valid only when {@code @objectRef} == the
diff --git a/server/python/src/metaobjects/spec_metamodel/relationship.json b/server/python/src/metaobjects/spec_metamodel/relationship.json
index bc6e78d64..e4cf15031 100644
--- a/server/python/src/metaobjects/spec_metamodel/relationship.json
+++ b/server/python/src/metaobjects/spec_metamodel/relationship.json
@@ -10,7 +10,7 @@
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
{ "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." },
- { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." },
+ { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." },
{ "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." },
{ "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." },
{ "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
@@ -26,7 +26,7 @@
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
{ "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." },
- { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." },
+ { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." },
{ "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." },
{ "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." },
{ "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
@@ -42,7 +42,7 @@
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
{ "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." },
- { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." },
+ { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." },
{ "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." },
{ "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." },
{ "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
@@ -58,7 +58,7 @@
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
{ "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." },
- { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." },
+ { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." },
{ "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." },
{ "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." },
{ "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
diff --git a/server/typescript/packages/metadata/src/core/relationship/relationship-definition.embedded.ts b/server/typescript/packages/metadata/src/core/relationship/relationship-definition.embedded.ts
index 8b95173ba..d23b04751 100644
--- a/server/typescript/packages/metadata/src/core/relationship/relationship-definition.embedded.ts
+++ b/server/typescript/packages/metadata/src/core/relationship/relationship-definition.embedded.ts
@@ -45,7 +45,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
"name": "sourceRefField",
"min": 0,
"max": 1,
- "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric."
+ "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."
},
{
"type": "attr",
@@ -122,7 +122,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
"name": "sourceRefField",
"min": 0,
"max": 1,
- "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric."
+ "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."
},
{
"type": "attr",
@@ -199,7 +199,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
"name": "sourceRefField",
"min": 0,
"max": 1,
- "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric."
+ "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."
},
{
"type": "attr",
@@ -276,7 +276,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
"name": "sourceRefField",
"min": 0,
"max": 1,
- "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric."
+ "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."
},
{
"type": "attr",
diff --git a/site-reference/types/relationship.html b/site-reference/types/relationship.html
index 2f1ca2305..1e4912e6f 100644
--- a/site-reference/types/relationship.html
+++ b/site-reference/types/relationship.html
@@ -111,7 +111,7 @@ relationship.aggregation
|
|
metaobjects-core-types |
-Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric. |
+Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely. |
@symmetric |
@@ -195,7 +195,7 @@ relationship.association
|
|
metaobjects-core-types |
-Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric. |
+Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely. |
@symmetric |
@@ -278,7 +278,7 @@ relationship.base
|
|
metaobjects-core-types |
-Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric. |
+Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely. |
@symmetric |
@@ -362,7 +362,7 @@ relationship.composition
|
|
metaobjects-core-types |
-Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric. |
+Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely. |
@symmetric |
diff --git a/spec/metamodel/relationship.json b/spec/metamodel/relationship.json
index bc6e78d64..e4cf15031 100644
--- a/spec/metamodel/relationship.json
+++ b/spec/metamodel/relationship.json
@@ -10,7 +10,7 @@
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
{ "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." },
- { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." },
+ { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." },
{ "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." },
{ "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." },
{ "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
@@ -26,7 +26,7 @@
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
{ "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." },
- { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." },
+ { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." },
{ "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." },
{ "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." },
{ "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
@@ -42,7 +42,7 @@
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
{ "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." },
- { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." },
+ { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." },
{ "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." },
{ "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." },
{ "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
@@ -58,7 +58,7 @@
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
{ "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." },
- { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." },
+ { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." },
{ "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." },
{ "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." },
{ "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
From 9db0ce5dfe85c5ebd74a831f51cab24cdec2ed15 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 20:20:58 -0400
Subject: [PATCH 19/31] docs: ADR-0029 Amendment 1 + changelog for #368
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
AGENTS.md | 2 +-
CHANGELOG.md | 42 +++++++++++
docs/features/relationships.md | 70 +++++++++++++++++++
...-entity-child-extends-and-via-inference.md | 67 ++++++++++++++++++
4 files changed, 180 insertions(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index 799e0b9e8..b83940f1c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -523,7 +523,7 @@ Preserve the following contracts exactly across all language ports:
- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.)
- Source subtypes: `rdb` (paradigm; ADR-0007). The pre-v2 `dbTable`/`dbView` subtypes are RETIRED — `source.rdb` + `@kind: table|view|materializedView|storedProc|tableFunction` is the form, with read-only-ness derived from `@kind`. Multi-source via `@role` (exactly one `primary` per object). Source physical name = `@table` (NOT `@name`); field physical name = `@column` (renamed from `@dbColumn`). Referential actions on relationships: `@onDelete` / `@onUpdate`.
- Origin subtypes: `passthrough`, `aggregate`, `collection`, `computed`, `first` (concrete; `base` is the abstract root). `passthrough` is legal on an `object.value`-hosted field (FR-015 parameter lineage); the four assembly origins (`aggregate`/`computed`/`collection`/`first`) live on `object.projection` only — a value-hosted assembly origin is `ERR_SUBTYPE_RULE_VIOLATION` (#210).
-- Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side). `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N → `ERR_INVALID_RELATIONSHIP`.
+- Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side); on a `@cardinality: one` relationship it instead names which of several `identity.reference` nodes onto the same target this relationship navigates, short-circuiting the unique-candidate/`@sourceRefField`/name-pairing ladder (#368, [ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md) Amendment 1) — an unresolvable 1:N reference set is `ERR_INVALID_RELATIONSHIP` at load. `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N → `ERR_INVALID_RELATIONSHIP`.
- Index subtypes: `index.lookup` (non-unique retrieval index; uniqueness is encoded in the **type**: `identity.secondary` = unique alternate key, `index.lookup` = non-unique; `@unique` is REMOVED from `identity.secondary` — `ERR_UNKNOWN_ATTR` on any legacy `@unique`). RDB-physical escapes `@using`/`@expr`/`@where`/`@orders` are registered by the db provider on **both** `identity.secondary` and `index.lookup`. `index.fulltext` / `index.vector` / `index.spatial` are reserved on the subtype axis — documented, NOT registered (YAGNI + 1.0 vocab freeze). See [ADR-0040](spec/decisions/ADR-0040-index-type-and-secondary-key-purity.md).
- Layout subtypes: `dataGrid`
- API subtypes: `api.base` / `api.operational` (request/response surface; subtype axis = interaction model, NEVER protocol — protocol lives in `binding.*` per operation: `rest` now, `messaging`/`grpc` reserved). Children: `operation.query` (outputRef → `object.projection`) / `operation.command` (inputRef → `object.value`, may also outputRef). Derived CRUD (FR-008/009) stays the zero-config default; declared `api` extends it. Org-tier modeling (application/service/network/deployment) stays OUT of core — provider SPI, FQN references. See [ADR-0030](spec/decisions/ADR-0030-declared-api-surface-and-org-tier-boundary.md). (FR-024 declared-API — planned; not yet in `expected-registry.json`; the remaining third of FR-024 after the projection/value taxonomy + validation parity.)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bcbca9520..fa3049c4a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -85,6 +85,47 @@ here.**
you."*, spelled once in `sidecarLine` (TypeScript) and `generated_header` (Python)
rather than copy-pasted to eleven emitters.
+- **Two `identity.reference` nodes onto the same entity no longer make every
+ `@cardinality: one` relationship join the first one's FK column ([#368]).** An entity
+ may legitimately declare more than one FK to the same target — `Match.homeTeamRef`
+ and `Match.awayTeamRef` both `-> Team` — but a relationship names only its target via
+ `@objectRef`, never which reference it means. Four call sites took the first matching
+ reference and never noticed the second: the TypeScript codegen `relations()` block
+ wired the relationship to one FK column regardless, the runtime relation traversal
+ resolved every such navigation through it, a projection's `@via` join hop picked it
+ even when the hop explicitly named the other reference, and the docs-site link graph
+ drew the wrong edge. All four produce a join that typechecks, emits correct DDL, and
+ passes `meta verify` — the only symptom is wrong rows. The referential-actions
+ correlation (TypeScript's `migrate-ts` and the C# port; Python and Java do not
+ implement this correlation and are untouched) 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 action on whichever FK wasn't
+ examined first.
+
+ Resolution is now explicit and identical across TypeScript, Python, C# and Java: an
+ ambiguous `@cardinality: one` reference set resolves by a ladder — the sole
+ candidate, else a declared `@sourceRefField` naming the candidate's FK field, else a
+ name-pairing match between the relationship's name and a candidate's name/FK field,
+ else `ERR_INVALID_RELATIONSHIP` at load, naming every candidate
+ ([ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md)
+ Amendment 1). `@sourceRefField` is now legal on a `@cardinality: one` relationship —
+ it previously failed to load there as an M:N-only attribute. Two limits are
+ documented rather than fixed here: the ladder matches a candidate's first FK field
+ only, so two composite references sharing a first column stay indistinguishable, and
+ the loader gate covers `@cardinality: one` relationships only — a `many`-cardinality
+ relationship, or a bare `identity.reference` pair with no relationship wrapper, still
+ reaches codegen unvalidated. See
+ [`docs/features/relationships.md`](docs/features/relationships.md).
+
+ No vocabulary was added, removed or retyped — the only change to
+ `expected-registry.json` corrects `@sourceRefField`'s own description, which no
+ longer claims the attribute is M:N-only. Per
+ [`docs/RELEASING.md`](docs/RELEASING.md), any change to that file forces all four
+ registries (npm / PyPI / NuGet / Maven) to publish together at the next release,
+ changed product files or not — that consequence is recorded here so it isn't a
+ surprise at release time. `metamodelVersion` stays `1.0`.
+
### Added
- **The auth seam is printed in the generated routes handler's JSDoc ([#367]).** Stock
@@ -105,6 +146,7 @@ here.**
inconsistency, not a policy. Read-only is not public.
[#367]: https://github.com/metaobjectsdev/metaobjects/issues/367
+[#368]: https://github.com/metaobjectsdev/metaobjects/issues/368
## [1.0.3] — 2026-09-12
diff --git a/docs/features/relationships.md b/docs/features/relationships.md
index cf8ccd309..d247baecc 100644
--- a/docs/features/relationships.md
+++ b/docs/features/relationships.md
@@ -163,6 +163,73 @@ is valid in a `passthrough` and rejected in an `aggregate`. Inverse navigation
FK has no inverse edge. Explicit `@via` resolves either kind; single-hop-unique
inference stays relationship-only.
+## When one entity has two references to the same target
+
+An entity may legitimately declare more than one `identity.reference` onto the same
+target — a `Match` entity with both `alphaRef` and `betaRef` pointing at `Team`. A
+`@cardinality: one` relationship names only the target, via `@objectRef`; it does not
+say which reference it means. With two candidates, the target alone is not enough to
+pick the FK ([#368](https://github.com/metaobjectsdev/metaobjects/issues/368)).
+
+Resolution follows a ladder, checked in order:
+
+1. **Exactly one candidate** `identity.reference` targeting the relationship's
+ `@objectRef` → that one. This is the common case (one reference per target) and
+ needs no extra authoring.
+2. **`@sourceRefField` declared** on the relationship → the candidate whose first FK
+ field it names. This is a short-circuit: a declared value that names no candidate's
+ FK field is a load error regardless of how many candidates exist — it never falls
+ through to name-pairing.
+3. **Exactly one candidate name-pairs with the relationship** → that one. A candidate
+ pairs when its own name or its FK field — lowercased, with one trailing suffix from
+ `reference` / `ref` / `id` / `key` optionally stripped — equals the relationship's
+ name (lowercased, never stripped). A relationship named `awayTeam` pairs with a
+ reference named `awayTeamRef` or with one whose FK field is `awayTeamId`, with no
+ extra authoring.
+4. **Otherwise** → `ERR_INVALID_RELATIONSHIP` at load, naming every candidate. Fix it
+ by declaring `@sourceRefField` with the FK field this relationship means, or by
+ naming the relationship so it pairs with exactly one candidate.
+
+```yaml
+# Match declares TWO references onto Team: alphaRef (alphaFk) and betaRef (betaFk).
+# "winner" pairs with neither name, so it must be disambiguated explicitly.
+- relationship.association:
+ name: winner
+ objectRef: Team
+ cardinality: one
+ sourceRefField: alphaFk # picks alphaRef; drop this and the load fails,
+ # naming alphaRef(alphaFk) and betaRef(betaFk)
+- relationship.association:
+ name: loser
+ objectRef: Team
+ cardinality: one
+ sourceRefField: betaFk
+```
+
+**Limitations, documented rather than fixed:**
+
+- The ladder matches a candidate's **first** FK field only, so two composite
+ references sharing a first column are indistinguishable from each other. The load
+ error still renders each candidate's full field tuple (`name(fieldA, fieldB)`) so the
+ ambiguity is visible even where `@sourceRefField` cannot resolve it.
+- The load-time gate covers `@cardinality: one` relationships only. A
+ `many`-cardinality relationship, and a bare `identity.reference` pair with no
+ relationship wrapper at all, reach codegen unvalidated — an ambiguous reference set
+ in either shape is not caught at load.
+- A projection's `@via` hop (above) resolves the identical ambiguity for the hop it
+ names, but `origin.first`'s own `@via` is never consulted for its base↔child
+ correlation — an ambiguous target there has no `@via`-based fix; the only escape is
+ removing the second reference.
+- `@via` and `@sourceRefField` are different mechanisms on different node types:
+ `@via` lives on `origin.*` and names a projection join hop; `@sourceRefField` lives
+ on `relationship.*` and names an FK field. A projection ambiguity error points you at
+ `@via`; a relationship ambiguity error points you at `@sourceRefField` — don't reach
+ for one to fix the other.
+
+See [ADR-0029](../../spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md)
+Amendment 1 for the full ladder specification, including why suffix-stripping applies
+to candidates only.
+
## What each port generates
### TypeScript
@@ -286,6 +353,9 @@ The following conformance fixtures gate this feature's behavior across ports:
- [`fixtures/conformance/source-rdb-referential-actions/`](../../fixtures/conformance/source-rdb-referential-actions/) — `@onDelete` / `@onUpdate` on relationships
- [`fixtures/conformance/identity-reference-referential-actions/`](../../fixtures/conformance/identity-reference-referential-actions/) — the parent-side `@cardinality: many` composition (subtype-default cascade) + `@onDelete` / `@onUpdate` declared directly on `identity.reference` (ADR-0047)
- [`fixtures/conformance/error-unknown-relationship-subtype/`](../../fixtures/conformance/error-unknown-relationship-subtype/) — unknown `relationship.` rejected
+- [`fixtures/conformance/relationship-one-two-refs-sourcerefield/`](../../fixtures/conformance/relationship-one-two-refs-sourcerefield/) — two `identity.reference` nodes onto the same target, disambiguated by `@sourceRefField` (ladder stage 2)
+- [`fixtures/conformance/relationship-one-two-refs-name-pairing/`](../../fixtures/conformance/relationship-one-two-refs-name-pairing/) — the same shape resolved by name-pairing alone (ladder stage 3)
+- [`fixtures/conformance/error-relationship-one-refs-ambiguous/`](../../fixtures/conformance/error-relationship-one-refs-ambiguous/) — neither `@sourceRefField` nor a pairing name given: `ERR_INVALID_RELATIONSHIP` at load (#368, ADR-0029 Amendment 1)
Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these
via their respective conformance runners. See [`docs/CONFORMANCE.md`](../CONFORMANCE.md)
diff --git a/spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md b/spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md
index dc9afbb05..fe0adb5b6 100644
--- a/spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md
+++ b/spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md
@@ -75,3 +75,70 @@ needed an omission rule that five loaders can implement byte-identically. An int
`fixtures/conformance/` fixture (positive + error envelope).
- "This parameter is a Case identifier" becomes computable (the extends target IS
the entity's identity field) for doc-gen, FR-022 emission, and MCP tool schemas.
+
+## Amendment 1 (2026-09-13) — the ambiguity rule extends to 1:N FK selection
+
+§5's contract — "a second path is a load error naming the candidates" — was written
+for `@via` on `origin.*`. Issue #368 found the identical ambiguity one level down: an
+entity may legitimately declare more than one `identity.reference` onto the same
+target (`Match.homeTeamRef` and `Match.awayTeamRef` both `-> Team`), and a
+`@cardinality: one` relationship names only that target via `@objectRef`, never which
+reference it means. Every port's resolver took the first matching reference and never
+noticed the second — the emitted join typechecks, produces correct DDL, and passes
+`meta verify`; the only symptom is wrong rows.
+
+The rule now also governs 1:N FK selection, resolved by this ladder:
+
+1. exactly one candidate `identity.reference` onto the target → that one;
+2. `@sourceRefField` declared on the relationship → the candidate whose **first** FK
+ field it names — this check short-circuits: a declared value that names no
+ candidate's FK field is a load error at ANY candidate count (zero, one, or many),
+ it never falls through to name-pairing;
+3. exactly one candidate name-pairs with the relationship's own name → that one;
+4. otherwise → `ERR_INVALID_RELATIONSHIP`, naming every candidate.
+
+**Stage 3, stated normatively (all four ports implement it identically):** a
+candidate's pairing keys are `{lower(name), strip(lower(name)), lower(fkField),
+strip(lower(fkField))}`, where `strip` removes at most one trailing suffix from the
+ordered list `["reference", "ref", "id", "key"]` — first match in that order wins, and
+a value no longer than the matched suffix is left unstripped, so `strip` never returns
+empty. The relationship's own name is matched **un-stripped**, lowercased only; exactly
+one candidate holding a matching key resolves, zero or several do not.
+
+Stripping is candidate-side only, deliberately. If the relationship's own name were
+stripped the same way, an unrelated relationship named `valid` would falsely pair with
+a candidate reference named `valRef`: `valid` ends in the stripped suffix `id`, so
+stripping it yields `val` — the same key `valRef` produces by stripping `ref`. Loosely
+matching the reference/FK-field side while matching the relationship side exactly is
+what lets the common case (a `posts` relationship pairing with a `postId` FK) resolve
+with no extra authoring, without also inventing false pairs between words that merely
+share an ordinary English or SQL-ish ending.
+
+This still meets §5's "trivially portable" bar: stage 3 is local string comparison over
+one entity's own children — the candidate set is always the `identity.reference` nodes
+declared on the SAME holder as the relationship being resolved — not the multi-hop path
+inference §5 declined to attempt. Every port runs the same four lowercase comparisons
+over the same bounded set.
+
+**No new vocabulary.** `@sourceRefField` was already registered on all four
+`relationship.*` subtypes; declaring it on a `@cardinality: one` relationship
+previously failed to load outright (`ERR_INVALID_RELATIONSHIP` — "sets
+@sourceRefField but is not a M:N relationship"). Giving it meaning there adds no
+attribute, subtype or type to the registry — the only change to
+`expected-registry.json` is a correction to the attribute's own description text,
+which no longer claims the attribute is M:N-only (see the CHANGELOG for the release
+consequence). `metamodelVersion` stays `1.0`.
+
+**The one piece of this change that needs `docs/compatibility-policy.md`'s correction
+bar is stage 4's new refusal, not the widening above.** Before this rule, an entity
+with two-or-more candidate references and no name-pairing match loaded successfully
+and silently resolved to the first declared reference — a form that never had a
+reliable meaning (nothing distinguished it from an author's actual intent; two
+different builds could resolve the same ambiguous model to two different FKs) and
+produced no correct outcome for anyone who hit it. It now stops loading in a PATCH.
+That is exactly the correction bar's three-part test: (1) never validly
+expressible — the ambiguity was always present, merely unreported; (2) no correct
+outcome for anyone — the silent first-match is a wrong-column join, not a form of
+correct output; (3) the repair is exactly named — the error lists every candidate and
+states the fix (`@sourceRefField`, or a relationship name that pairs with exactly
+one).
From 45667f228e97514f0164f105d20ad6a3ba923302 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 20:55:05 -0400
Subject: [PATCH 20/31] docs(relationship): extend @sourceRefField rules prose
for #368 dual 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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
fixtures/metamodel-docs/expected/types/relationship.md | 8 ++++----
fixtures/registry-conformance/expected-registry.json | 8 ++++----
server/csharp/MetaObjects/SpecMetamodel/relationship.json | 8 ++++----
.../src/metaobjects/spec_metamodel/relationship.json | 8 ++++----
.../core/relationship/relationship-definition.embedded.ts | 8 ++++----
site-reference/types/relationship.html | 8 ++++----
spec/metamodel/relationship.json | 8 ++++----
7 files changed, 28 insertions(+), 28 deletions(-)
diff --git a/fixtures/metamodel-docs/expected/types/relationship.md b/fixtures/metamodel-docs/expected/types/relationship.md
index 1cb51af1f..fb5ef64eb 100644
--- a/fixtures/metamodel-docs/expected/types/relationship.md
+++ b/fixtures/metamodel-docs/expected/types/relationship.md
@@ -16,7 +16,7 @@ A shared/independent containment — the parent groups the target but does not o
**Owning provider:** metaobjects-core-types
-**Rules:** M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).
+**Rules:** M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).
**When to use:** One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.
@@ -42,7 +42,7 @@ A plain reference to another entity — no ownership; the target has an independ
**Owning provider:** metaobjects-core-types
-**Rules:** M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).
+**Rules:** M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).
**When to use:** A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.
@@ -68,7 +68,7 @@ Abstract relationship base — shared shape for the concrete association/aggrega
**Owning provider:** metaobjects-core-types
-**Rules:** @cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).
+**Rules:** @cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).
**Attributes**
@@ -92,7 +92,7 @@ An owned containment — the parent owns the target's lifecycle; deleting the pa
**Owning provider:** metaobjects-core-types
-**Rules:** M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).
+**Rules:** M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).
**When to use:** You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.
diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json
index 294eb5992..8e7fbb2ec 100644
--- a/fixtures/registry-conformance/expected-registry.json
+++ b/fixtures/registry-conformance/expected-registry.json
@@ -3517,7 +3517,7 @@
"type": "relationship",
"subType": "aggregation",
"description": "A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).",
"whenToUse": "One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.",
"attrs": [
{
@@ -3588,7 +3588,7 @@
"type": "relationship",
"subType": "association",
"description": "A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).",
"whenToUse": "A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.",
"attrs": [
{
@@ -3659,7 +3659,7 @@
"type": "relationship",
"subType": "base",
"description": "Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A `relationship.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.",
- "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).",
+ "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).",
"attrs": [
{
"name": "cardinality",
@@ -3729,7 +3729,7 @@
"type": "relationship",
"subType": "composition",
"description": "An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).",
"whenToUse": "You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.",
"attrs": [
{
diff --git a/server/csharp/MetaObjects/SpecMetamodel/relationship.json b/server/csharp/MetaObjects/SpecMetamodel/relationship.json
index e4cf15031..0fd2d5b1a 100644
--- a/server/csharp/MetaObjects/SpecMetamodel/relationship.json
+++ b/server/csharp/MetaObjects/SpecMetamodel/relationship.json
@@ -5,7 +5,7 @@
"type": "relationship",
"subType": "base",
"description": "Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A `relationship.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.",
- "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).",
+ "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).",
"children": [
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
@@ -21,7 +21,7 @@
"subType": "association",
"description": "A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).",
"whenToUse": "A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).",
"children": [
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
@@ -37,7 +37,7 @@
"subType": "aggregation",
"description": "A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).",
"whenToUse": "One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).",
"children": [
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
@@ -53,7 +53,7 @@
"subType": "composition",
"description": "An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).",
"whenToUse": "You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).",
"children": [
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
diff --git a/server/python/src/metaobjects/spec_metamodel/relationship.json b/server/python/src/metaobjects/spec_metamodel/relationship.json
index e4cf15031..0fd2d5b1a 100644
--- a/server/python/src/metaobjects/spec_metamodel/relationship.json
+++ b/server/python/src/metaobjects/spec_metamodel/relationship.json
@@ -5,7 +5,7 @@
"type": "relationship",
"subType": "base",
"description": "Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A `relationship.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.",
- "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).",
+ "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).",
"children": [
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
@@ -21,7 +21,7 @@
"subType": "association",
"description": "A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).",
"whenToUse": "A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).",
"children": [
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
@@ -37,7 +37,7 @@
"subType": "aggregation",
"description": "A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).",
"whenToUse": "One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).",
"children": [
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
@@ -53,7 +53,7 @@
"subType": "composition",
"description": "An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).",
"whenToUse": "You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).",
"children": [
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
diff --git a/server/typescript/packages/metadata/src/core/relationship/relationship-definition.embedded.ts b/server/typescript/packages/metadata/src/core/relationship/relationship-definition.embedded.ts
index d23b04751..40ab7f362 100644
--- a/server/typescript/packages/metadata/src/core/relationship/relationship-definition.embedded.ts
+++ b/server/typescript/packages/metadata/src/core/relationship/relationship-definition.embedded.ts
@@ -13,7 +13,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
"type": "relationship",
"subType": "base",
"description": "Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A `relationship.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.",
- "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).",
+ "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).",
"children": [
{
"type": "attr",
@@ -90,7 +90,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
"subType": "association",
"description": "A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).",
"whenToUse": "A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).",
"children": [
{
"type": "attr",
@@ -167,7 +167,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
"subType": "aggregation",
"description": "A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).",
"whenToUse": "One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).",
"children": [
{
"type": "attr",
@@ -244,7 +244,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
"subType": "composition",
"description": "An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).",
"whenToUse": "You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).",
"children": [
{
"type": "attr",
diff --git a/site-reference/types/relationship.html b/site-reference/types/relationship.html
index 1e4912e6f..0617a1fdf 100644
--- a/site-reference/types/relationship.html
+++ b/site-reference/types/relationship.html
@@ -53,7 +53,7 @@
relationship.aggregation
A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).
Owning provider: metaobjects-core-types
-Rules: M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).
+Rules: M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).
When to use: One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.
Attributes
@@ -137,7 +137,7 @@ relationship.aggregation
relationship.association
A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).
Owning provider: metaobjects-core-types
-Rules: M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).
+Rules: M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).
When to use: A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.
Attributes
@@ -221,7 +221,7 @@ relationship.association
relationship.base
Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A relationship.base node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.
Owning provider: metaobjects-core-types
-Rules: @cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).
+Rules: @cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).
Attributes
@@ -304,7 +304,7 @@ relationship.base
relationship.composition
An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).
Owning provider: metaobjects-core-types
-Rules: M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).
+Rules: M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).
When to use: You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.
Attributes
diff --git a/spec/metamodel/relationship.json b/spec/metamodel/relationship.json
index e4cf15031..0fd2d5b1a 100644
--- a/spec/metamodel/relationship.json
+++ b/spec/metamodel/relationship.json
@@ -5,7 +5,7 @@
"type": "relationship",
"subType": "base",
"description": "Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A `relationship.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.",
- "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).",
+ "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).",
"children": [
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
@@ -21,7 +21,7 @@
"subType": "association",
"description": "A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).",
"whenToUse": "A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).",
"children": [
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
@@ -37,7 +37,7 @@
"subType": "aggregation",
"description": "A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).",
"whenToUse": "One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).",
"children": [
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
@@ -53,7 +53,7 @@
"subType": "composition",
"description": "An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).",
"whenToUse": "You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.",
- "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).",
+ "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).",
"children": [
{ "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." },
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." },
From 0aa3cb91d5fd895cde61a61b880cf0706c2831d0 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 21:06:25 -0400
Subject: [PATCH 21/31] fix(codegen-ts): #368 round 2 -- the join-hop ambiguity
message recommended 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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../src/projection/extract-view-spec.ts | 14 +++-
.../projection/reference-ambiguity.test.ts | 70 ++++++++++++++++++-
2 files changed, 80 insertions(+), 4 deletions(-)
diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
index c0bd13bc1..fe0160de3 100644
--- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
+++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
@@ -807,10 +807,22 @@ function buildJoinTree(
let ref: ReferenceLookup | undefined;
if (Array.isArray(resolvedRef)) {
if (resolvedRef.length > 1) {
+ // #368 round 2: @sourceRefField cannot fix this. It only disambiguates a
+ // @cardinality "one" relationship (validation-passes.ts rule (d) rejects it
+ // on any other non-M:N relationship) — and rule (e) already rejects, at
+ // load time, any @cardinality "one" relationship whose reference set this
+ // same ladder (resolveRelationshipReference) cannot resolve. So a relationship
+ // hop can only reach this throw with a @cardinality other than "one", for
+ // which declaring @sourceRefField is itself a load error. There genuinely is
+ // no attribute that resolves it — say so, rather than pointing at a dead end.
throw new Error(
`projection join hop "${relName}" from "${(currentObj as MetaObject).name}" to "${target.name}" is ambiguous: ` +
`${resolvedRef.map((r) => r.referenceIdentity.name).join(", ")}. ` +
- `Declare @sourceRefField on the relationship, or name the identity.reference directly in @via.`,
+ `@sourceRefField cannot resolve this: it only disambiguates a @cardinality "${CARDINALITY_ONE}" ` +
+ `relationship, and this relationship's @cardinality is not "${CARDINALITY_ONE}" (declaring ` +
+ `@sourceRefField on it is itself a load error). There is no attribute that disambiguates a hop ` +
+ `like this -- remove the extra identity.reference between these two entities, or restructure ` +
+ `the model so only one remains.`,
);
}
ref = resolvedRef[0];
diff --git a/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts b/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts
index 196974b47..3016604a7 100644
--- a/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts
+++ b/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts
@@ -145,6 +145,59 @@ describe("extractViewSpec — reference ambiguity (#368)", () => {
);
});
+ test("the ambiguity message never recommends @sourceRefField as a fix (it is a dead end for this hop)", async () => {
+ // #368 round 2: the only relationship hop that reaches this throw is
+ // @cardinality "many" and non-M:N (see the previous test's comment) —
+ // and validateRelationships rule (d) rejects @sourceRefField on exactly
+ // that shape (validation-passes.ts, "sets @sourceRefField but is not a
+ // M:N relationship"). So the message must not tell the author to declare
+ // an attribute the loader will refuse. It must instead say plainly that
+ // there is no attribute fix, and name the two remedies that ARE legal:
+ // remove the extra identity.reference, or restructure the model.
+ const root = await load([
+ TEAM,
+ matchEntity(),
+ {
+ "object.projection": {
+ name: "TeamSummary",
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_team_summary" } },
+ { "field.int": { name: "id", extends: "Team.id" } },
+ { "identity.primary": { name: "id", extends: "Team.id" } },
+ {
+ "field.int": {
+ name: "matchCount",
+ children: [
+ { "origin.aggregate": { "@agg": "count", "@of": "Match.id", "@via": "Team.matches" } },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ]);
+
+ const projection = root.objects().find((o) => o.name === "TeamSummary")!;
+ let message = "";
+ try {
+ extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" });
+ throw new Error("expected extractViewSpec to throw the ambiguity error");
+ } catch (err) {
+ message = (err as Error).message;
+ }
+
+ // The dead-end advice from before this fix must never come back.
+ expect(message).not.toContain("Declare @sourceRefField on the relationship");
+ // The message explains WHY @sourceRefField cannot help (illegal on a
+ // non-"one" @cardinality relationship) rather than silently omitting it.
+ expect(message).toContain('@sourceRefField cannot resolve this: it only disambiguates a @cardinality "one"');
+ // And states the two remedies that are actually legal.
+ expect(message).toContain(
+ "There is no attribute that disambiguates a hop like this -- remove the extra identity.reference " +
+ "between these two entities, or restructure the model so only one remains.",
+ );
+ });
+
test("origin.first correlation with two references and no relationship throws naming both", async () => {
const root = await load([
TEAM,
@@ -175,8 +228,19 @@ describe("extractViewSpec — reference ambiguity (#368)", () => {
]);
const projection = root.objects().find((o) => o.name === "TeamSummary")!;
- expect(() => extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" })).toThrow(
- /origin\.first correlation from "Team" to "Match" is ambiguous:.*homeTeamRef.*awayTeamRef/s,
- );
+ let message = "";
+ try {
+ extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" });
+ throw new Error("expected extractViewSpec to throw the ambiguity error");
+ } catch (err) {
+ message = (err as Error).message;
+ }
+
+ expect(message).toMatch(/origin\.first correlation from "Team" to "Match" is ambiguous:.*homeTeamRef.*awayTeamRef/s);
+ // #368 round 2 sibling check: origin.first has no relationship node to attach
+ // @sourceRefField to at all (the correlation is derived from @of alone), so this
+ // message must never suggest it — confirming it stays dead-end-free alongside the
+ // buildJoinTree fix above.
+ expect(message).not.toContain("@sourceRefField");
});
});
From f78cc951045af793cce10ad184f8edbdfdf33563 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 21:25:20 -0400
Subject: [PATCH 22/31] fix(codegen-ts): #368 round 2 fix-round-1 -- the
join-hop message's @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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../src/projection/extract-view-spec.ts | 45 ++--
.../projection/reference-ambiguity.test.ts | 216 +++++++++++++++++-
2 files changed, 236 insertions(+), 25 deletions(-)
diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
index fe0160de3..72cbd7992 100644
--- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
+++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
@@ -807,22 +807,39 @@ function buildJoinTree(
let ref: ReferenceLookup | undefined;
if (Array.isArray(resolvedRef)) {
if (resolvedRef.length > 1) {
- // #368 round 2: @sourceRefField cannot fix this. It only disambiguates a
- // @cardinality "one" relationship (validation-passes.ts rule (d) rejects it
- // on any other non-M:N relationship) — and rule (e) already rejects, at
- // load time, any @cardinality "one" relationship whose reference set this
- // same ladder (resolveRelationshipReference) cannot resolve. So a relationship
- // hop can only reach this throw with a @cardinality other than "one", for
- // which declaring @sourceRefField is itself a load error. There genuinely is
- // no attribute that resolves it — say so, rather than pointing at a dead end.
+ // #368 round 2: @sourceRefField cannot fix this, but WHY differs by shape, and
+ // asserting the wrong reason for a given shape is itself a bug (fix round 1 of
+ // this cleanup caught exactly that). resolveRelationshipReference's ladder reads
+ // ONLY the hop's own entity's candidates (referenceCandidatesFor(currentObj, ...));
+ // it never even looks at `target`'s references. So:
+ // - If `currentObj` itself holds one of the ambiguous candidates, resolution
+ // already tried @sourceRefField/name-pairing against it and failed — and that
+ // is only reachable at all when @cardinality isn't "one": a @cardinality "one"
+ // relationship with 2+ own-side candidates is rejected at LOAD by rule (e)
+ // (validateOneSideReferenceResolution) using this exact same ladder, so if we
+ // got this far with an own-side candidate, @cardinality is provably not "one",
+ // and @sourceRefField is provably illegal here (rule (d)).
+ // - If NONE of the candidates are `currentObj`'s own, @sourceRefField could not
+ // have mattered regardless of @cardinality — it only ever consults the hop's
+ // OWN identity.reference children, and it has none targeting `target`. This is
+ // rule (e)'s zero-candidate gap (validation-passes.ts:2226, `<= 1` skips 0 too):
+ // a @cardinality "one" relationship can reach here with the FK entirely on the
+ // far side, so @cardinality itself must NOT be asserted in this branch.
+ const holderName = (currentObj as MetaObject).name;
+ const holderOwnsACandidate = resolvedRef.some((r) => r.holder.name === holderName);
+ const whySourceRefFieldCannotHelp = holderOwnsACandidate
+ ? `it only disambiguates a @cardinality "${CARDINALITY_ONE}" relationship, and this ` +
+ `relationship's @cardinality is not "${CARDINALITY_ONE}" (declaring @sourceRefField on it ` +
+ `is itself a load error)`
+ : `it only consults "${holderName}"'s own identity.reference children, and "${holderName}" ` +
+ `declares none targeting "${target.name}" -- every candidate above belongs to the other side ` +
+ `of this join`;
throw new Error(
- `projection join hop "${relName}" from "${(currentObj as MetaObject).name}" to "${target.name}" is ambiguous: ` +
+ `projection join hop "${relName}" from "${holderName}" to "${target.name}" is ambiguous: ` +
`${resolvedRef.map((r) => r.referenceIdentity.name).join(", ")}. ` +
- `@sourceRefField cannot resolve this: it only disambiguates a @cardinality "${CARDINALITY_ONE}" ` +
- `relationship, and this relationship's @cardinality is not "${CARDINALITY_ONE}" (declaring ` +
- `@sourceRefField on it is itself a load error). There is no attribute that disambiguates a hop ` +
- `like this -- remove the extra identity.reference between these two entities, or restructure ` +
- `the model so only one remains.`,
+ `@sourceRefField cannot resolve this: ${whySourceRefFieldCannotHelp}. There is no attribute ` +
+ `that disambiguates a hop like this -- remove the extra identity.reference between these two ` +
+ `entities, or restructure the model so only one remains.`,
);
}
ref = resolvedRef[0];
diff --git a/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts b/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts
index 3016604a7..24f5e82f9 100644
--- a/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts
+++ b/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts
@@ -146,14 +146,16 @@ describe("extractViewSpec — reference ambiguity (#368)", () => {
});
test("the ambiguity message never recommends @sourceRefField as a fix (it is a dead end for this hop)", async () => {
- // #368 round 2: the only relationship hop that reaches this throw is
- // @cardinality "many" and non-M:N (see the previous test's comment) —
- // and validateRelationships rule (d) rejects @sourceRefField on exactly
- // that shape (validation-passes.ts, "sets @sourceRefField but is not a
- // M:N relationship"). So the message must not tell the author to declare
- // an attribute the loader will refuse. It must instead say plainly that
- // there is no attribute fix, and name the two remedies that ARE legal:
- // remove the extra identity.reference, or restructure the model.
+ // #368 round 2 (fix round 1): Team owns ZERO identity.reference children of
+ // its own here, so resolveRelationshipReference's ladder (which only ever
+ // consults the HOP'S OWN entity's candidates) had nothing to work with —
+ // @sourceRefField could not have mattered regardless of @cardinality, since
+ // it also only ever consults the same own-side candidate set. The message
+ // must say THAT (not assert a @cardinality value it can't guarantee is the
+ // reason in every shape reaching this throw — see the "reverse @cardinality
+ // one" test below for a shape where the @cardinality *is* "one"), and name
+ // the two remedies that ARE legal: remove the extra identity.reference, or
+ // restructure the model.
const root = await load([
TEAM,
matchEntity(),
@@ -188,9 +190,15 @@ describe("extractViewSpec — reference ambiguity (#368)", () => {
// The dead-end advice from before this fix must never come back.
expect(message).not.toContain("Declare @sourceRefField on the relationship");
- // The message explains WHY @sourceRefField cannot help (illegal on a
- // non-"one" @cardinality relationship) rather than silently omitting it.
- expect(message).toContain('@sourceRefField cannot resolve this: it only disambiguates a @cardinality "one"');
+ // Fix round 1: nor may the message claim a @cardinality value as the reason
+ // -- Team owns no candidates, so the real reason is scope (whose references
+ // @sourceRefField consults), independent of @cardinality.
+ expect(message).not.toContain("@cardinality");
+ expect(message).toContain(
+ "@sourceRefField cannot resolve this: it only consults \"Team\"'s own identity.reference " +
+ 'children, and "Team" declares none targeting "Match" -- every candidate above belongs ' +
+ "to the other side of this join",
+ );
// And states the two remedies that are actually legal.
expect(message).toContain(
"There is no attribute that disambiguates a hop like this -- remove the extra identity.reference " +
@@ -198,6 +206,192 @@ describe("extractViewSpec — reference ambiguity (#368)", () => {
);
});
+ test("fix round 1: a reverse @cardinality \"one\" relationship (rule (e)'s zero-candidate gap) gets the same honest, cardinality-free message", async () => {
+ // Rule (e) (validateOneSideReferenceResolution, validation-passes.ts:2226)
+ // only fires when the HOLDER declares 2+ candidates of its own
+ // (`if (candidates.length <= 1) continue;` -- zero is silent, not just one).
+ // So a @cardinality "one" relationship whose FK is entirely on the FAR side
+ // (the holder itself declares no identity.reference at all) loads clean,
+ // and reaches this exact codegen throw exactly like the @cardinality "many"
+ // case above -- via findReferencesBetween's bidirectional walk finding the
+ // far side's 2 candidates. This is a DOCUMENTED, parked gap (not fixed here
+ // -- rule (e) is implemented in four language ports and broadening it in
+ // TypeScript alone would create cross-port divergence); this test only
+ // pins that the MESSAGE stays honest about it: @cardinality really is
+ // "one" here, so the message must not claim otherwise, or claim @cardinality
+ // is the reason @sourceRefField can't help.
+ const root = await load([
+ {
+ "object.entity": {
+ name: "Owner",
+ children: [
+ { "source.rdb": { "@table": "owners" } },
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ // No identity.reference on Owner itself -- the FK lives on Pet.
+ {
+ "relationship.association": {
+ name: "primaryPet",
+ "@objectRef": "Pet",
+ "@cardinality": "one",
+ },
+ },
+ ],
+ },
+ },
+ {
+ "object.entity": {
+ name: "Pet",
+ children: [
+ { "source.rdb": { "@table": "pets" } },
+ { "field.int": { name: "id" } },
+ { "field.string": { name: "name" } },
+ { "field.int": { name: "primaryOwnerId" } },
+ { "field.int": { name: "backupOwnerId" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ {
+ "identity.reference": {
+ name: "primaryOwnerRef",
+ "@fields": "primaryOwnerId",
+ "@references": "Owner",
+ },
+ },
+ {
+ "identity.reference": {
+ name: "backupOwnerRef",
+ "@fields": "backupOwnerId",
+ "@references": "Owner",
+ },
+ },
+ ],
+ },
+ },
+ {
+ "object.projection": {
+ name: "OwnerSummary",
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_owner_summary" } },
+ { "field.int": { name: "id", extends: "Owner.id" } },
+ { "identity.primary": { name: "id", extends: "Owner.id" } },
+ {
+ "field.string": {
+ name: "primary_pet_name",
+ children: [
+ { "origin.passthrough": { "@from": "Pet.name", "@via": "Owner.primaryPet" } },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ]);
+
+ const projection = root.objects().find((o) => o.name === "OwnerSummary")!;
+ let message = "";
+ try {
+ extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" });
+ throw new Error("expected extractViewSpec to throw the ambiguity error");
+ } catch (err) {
+ message = (err as Error).message;
+ }
+
+ expect(message).toMatch(
+ /projection join hop "primaryPet" from "Owner" to "Pet" is ambiguous:.*primaryOwnerRef.*backupOwnerRef/s,
+ );
+ // The bug this test guards against: claiming @cardinality is not "one" when it IS "one".
+ expect(message).not.toContain("@cardinality");
+ expect(message).not.toContain("Declare @sourceRefField on the relationship");
+ expect(message).toContain(
+ '@sourceRefField cannot resolve this: it only consults "Owner"\'s own identity.reference ' +
+ 'children, and "Owner" declares none targeting "Pet" -- every candidate above belongs ' +
+ "to the other side of this join",
+ );
+ });
+
+ test("fix round 1: a @cardinality \"many\" hop whose OWN entity holds the ambiguous candidates still gets a true message (this one MAY name @cardinality)", async () => {
+ // The mirror case: here Team itself declares two references onto Match, so
+ // resolveRelationshipReference's ladder DID look at Team's own candidates
+ // and failed to narrow them (name-pairing doesn't match "matches" to either).
+ // A @cardinality "one" relationship could never reach this throw in this
+ // shape -- rule (e) uses the identical own-side candidate ladder and would
+ // reject it at load first -- so @cardinality is PROVABLY not "one" whenever
+ // the hop's own entity owns one of the ambiguous candidates, and the message
+ // may safely say so (unlike the two tests above, where the candidates are
+ // on the far side and @cardinality could be either value).
+ const root = await load([
+ {
+ "object.entity": {
+ name: "Team",
+ children: [
+ { "source.rdb": { "@table": "teams" } },
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "featuredMatchId" } },
+ { "field.int": { name: "backupMatchId" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ {
+ "identity.reference": {
+ name: "featuredMatchRef",
+ "@fields": "featuredMatchId",
+ "@references": "Match",
+ },
+ },
+ {
+ "identity.reference": {
+ name: "backupMatchRef",
+ "@fields": "backupMatchId",
+ "@references": "Match",
+ },
+ },
+ { "relationship.association": { name: "matches", "@objectRef": "Match", "@cardinality": "many" } },
+ ],
+ },
+ },
+ {
+ "object.entity": {
+ name: "Match",
+ children: [
+ { "source.rdb": { "@table": "matches" } },
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ ],
+ },
+ },
+ {
+ "object.projection": {
+ name: "TeamSummary",
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_team_summary" } },
+ { "field.int": { name: "id", extends: "Team.id" } },
+ { "identity.primary": { name: "id", extends: "Team.id" } },
+ {
+ "field.int": {
+ name: "matchCount",
+ children: [
+ { "origin.aggregate": { "@agg": "count", "@of": "Match.id", "@via": "Team.matches" } },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ]);
+
+ const projection = root.objects().find((o) => o.name === "TeamSummary")!;
+ let message = "";
+ try {
+ extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" });
+ throw new Error("expected extractViewSpec to throw the ambiguity error");
+ } catch (err) {
+ message = (err as Error).message;
+ }
+
+ expect(message).toMatch(
+ /projection join hop "matches" from "Team" to "Match" is ambiguous:.*featuredMatchRef.*backupMatchRef/s,
+ );
+ expect(message).toContain('@sourceRefField cannot resolve this: it only disambiguates a @cardinality "one"');
+ expect(message).toContain('this relationship\'s @cardinality is not "one"');
+ });
+
test("origin.first correlation with two references and no relationship throws naming both", async () => {
const root = await load([
TEAM,
From 308e50fd268c55a3adca8e4d0439f8f948e31a8c Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 21:37:27 -0400
Subject: [PATCH 23/31] docs: update conformance fixture count to 328
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
AGENTS.md | 2 +-
docs/CONFORMANCE.md | 6 +++---
examples/showcase/site-payload.json | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index b83940f1c..70bf340bd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,7 +68,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
- **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres.
**Cross-port conformance corpora** (every port runs the shared corpus):
-- Metamodel: `fixtures/conformance/` (325 fixtures; 22 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
+- Metamodel: `fixtures/conformance/` (328 fixtures; 22 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
- Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".)
- API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed.
diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md
index 48f32e008..f8ee6c557 100644
--- a/docs/CONFORMANCE.md
+++ b/docs/CONFORMANCE.md
@@ -25,7 +25,7 @@ regenerate with `ls -d fixtures//*/ | wc -l`.
| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
|---|---|---|---|---|---|---|
-| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 325 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
+| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 328 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 |
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |
@@ -119,7 +119,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.
## Fixture-to-doc mapping
-### `fixtures/conformance/` — metamodel loader + canonical serializer (325)
+### `fixtures/conformance/` — metamodel loader + canonical serializer (328)
| Fixture prefix | Feature doc |
|---|---|
@@ -271,7 +271,7 @@ Phase 1a is TypeScript + Python only; those three ports arrive in Phase 2.
## Orphaned fixtures (tested but not yet documented)
-The fixtures in the nine corpora mapped above (metamodel 325 + yaml 16 + verify 31
+The fixtures in the nine corpora mapped above (metamodel 328 + yaml 16 + verify 31
+ render 15 + persistence 33 + api-contract 41 + source-resolution 25 + scope 10 +
dependency 23) each map to a feature doc. None are orphaned today. The remaining
corpora in the totals table gate tooling contracts (registry manifests, provider
diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json
index 1b3ee0ff9..9465b4714 100644
--- a/examples/showcase/site-payload.json
+++ b/examples/showcase/site-payload.json
@@ -7,7 +7,7 @@
"metamodel": "1.0"
},
"counts": {
- "fixtures": 325,
+ "fixtures": 328,
"corpora": 22,
"baseTypes": 14
},
From 423959ed883fc94957314ee1fedde75871ea1ad7 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 22:06:20 -0400
Subject: [PATCH 24/31] =?UTF-8?q?fix(python):=20#368=20=E2=80=94=20the=20l?=
=?UTF-8?q?adder=20was=20blind=20to=20the=20dotted=20@references=20form?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
AGENTS.md | 2 +-
docs/CONFORMANCE.md | 6 +-
examples/showcase/site-payload.json | 2 +-
.../expected.json | 91 +++++++++++++++++++
.../input/meta.sport.json | 31 +++++++
.../core/relationship/derive_m2m_fields.py | 13 ++-
.../relationship/relationship_references.py | 33 ++++++-
.../unit/test_relationship_references.py | 64 +++++++++++++
8 files changed, 233 insertions(+), 9 deletions(-)
create mode 100644 fixtures/conformance/relationship-one-two-refs-dotted-references/expected.json
create mode 100644 fixtures/conformance/relationship-one-two-refs-dotted-references/input/meta.sport.json
diff --git a/AGENTS.md b/AGENTS.md
index 70bf340bd..e34e7ebbb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,7 +68,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
- **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres.
**Cross-port conformance corpora** (every port runs the shared corpus):
-- Metamodel: `fixtures/conformance/` (328 fixtures; 22 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
+- Metamodel: `fixtures/conformance/` (329 fixtures; 22 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
- Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".)
- API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed.
diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md
index f8ee6c557..de6416012 100644
--- a/docs/CONFORMANCE.md
+++ b/docs/CONFORMANCE.md
@@ -25,7 +25,7 @@ regenerate with `ls -d fixtures//*/ | wc -l`.
| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
|---|---|---|---|---|---|---|
-| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 328 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
+| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 329 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 |
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |
@@ -119,7 +119,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.
## Fixture-to-doc mapping
-### `fixtures/conformance/` — metamodel loader + canonical serializer (328)
+### `fixtures/conformance/` — metamodel loader + canonical serializer (329)
| Fixture prefix | Feature doc |
|---|---|
@@ -271,7 +271,7 @@ Phase 1a is TypeScript + Python only; those three ports arrive in Phase 2.
## Orphaned fixtures (tested but not yet documented)
-The fixtures in the nine corpora mapped above (metamodel 328 + yaml 16 + verify 31
+The fixtures in the nine corpora mapped above (metamodel 329 + yaml 16 + verify 31
+ render 15 + persistence 33 + api-contract 41 + source-resolution 25 + scope 10 +
dependency 23) each map to a feature doc. None are orphaned today. The remaining
corpora in the totals table gate tooling contracts (registry manifests, provider
diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json
index 9465b4714..cf6a44b48 100644
--- a/examples/showcase/site-payload.json
+++ b/examples/showcase/site-payload.json
@@ -7,7 +7,7 @@
"metamodel": "1.0"
},
"counts": {
- "fixtures": 328,
+ "fixtures": 329,
"corpora": 22,
"baseTypes": 14
},
diff --git a/fixtures/conformance/relationship-one-two-refs-dotted-references/expected.json b/fixtures/conformance/relationship-one-two-refs-dotted-references/expected.json
new file mode 100644
index 000000000..d6134c0fd
--- /dev/null
+++ b/fixtures/conformance/relationship-one-two-refs-dotted-references/expected.json
@@ -0,0 +1,91 @@
+{
+ "metadata.root": {
+ "package": "acme::sport",
+ "children": [
+ {
+ "object.entity": {
+ "name": "Team",
+ "children": [
+ {
+ "field.long": {
+ "name": "id"
+ }
+ },
+ {
+ "identity.primary": {
+ "name": "id",
+ "@fields": [
+ "id"
+ ]
+ }
+ }
+ ]
+ }
+ },
+ {
+ "object.entity": {
+ "name": "Match",
+ "children": [
+ {
+ "field.long": {
+ "name": "id"
+ }
+ },
+ {
+ "field.long": {
+ "name": "alphaFk"
+ }
+ },
+ {
+ "field.long": {
+ "name": "betaFk"
+ }
+ },
+ {
+ "identity.primary": {
+ "name": "id",
+ "@fields": [
+ "id"
+ ]
+ }
+ },
+ {
+ "identity.reference": {
+ "name": "alphaRef",
+ "@fields": [
+ "alphaFk"
+ ],
+ "@references": "acme::sport::Team.id"
+ }
+ },
+ {
+ "identity.reference": {
+ "name": "betaRef",
+ "@fields": [
+ "betaFk"
+ ],
+ "@references": "acme::sport::Team.id"
+ }
+ },
+ {
+ "relationship.association": {
+ "name": "winner",
+ "@cardinality": "one",
+ "@objectRef": "acme::sport::Team",
+ "@sourceRefField": "alphaFk"
+ }
+ },
+ {
+ "relationship.association": {
+ "name": "loser",
+ "@cardinality": "one",
+ "@objectRef": "acme::sport::Team",
+ "@sourceRefField": "betaFk"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/fixtures/conformance/relationship-one-two-refs-dotted-references/input/meta.sport.json b/fixtures/conformance/relationship-one-two-refs-dotted-references/input/meta.sport.json
new file mode 100644
index 000000000..c6c1bef6f
--- /dev/null
+++ b/fixtures/conformance/relationship-one-two-refs-dotted-references/input/meta.sport.json
@@ -0,0 +1,31 @@
+{
+ "metadata.root": {
+ "package": "acme::sport",
+ "children": [
+ {
+ "object.entity": {
+ "name": "Team",
+ "children": [
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } }
+ ]
+ }
+ },
+ {
+ "object.entity": {
+ "name": "Match",
+ "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "alphaFk" } },
+ { "field.long": { "name": "betaFk" } },
+ { "identity.primary": { "name": "id", "@fields": "id" } },
+ { "identity.reference": { "name": "alphaRef", "@fields": "alphaFk", "@references": "acme::sport::Team.id" } },
+ { "identity.reference": { "name": "betaRef", "@fields": "betaFk", "@references": "acme::sport::Team.id" } },
+ { "relationship.association": { "name": "winner", "@cardinality": "one", "@objectRef": "acme::sport::Team", "@sourceRefField": "alphaFk" } },
+ { "relationship.association": { "name": "loser", "@cardinality": "one", "@objectRef": "acme::sport::Team", "@sourceRefField": "betaFk" } }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/server/python/src/metaobjects/meta/core/relationship/derive_m2m_fields.py b/server/python/src/metaobjects/meta/core/relationship/derive_m2m_fields.py
index dc64c4194..c389a0856 100644
--- a/server/python/src/metaobjects/meta/core/relationship/derive_m2m_fields.py
+++ b/server/python/src/metaobjects/meta/core/relationship/derive_m2m_fields.py
@@ -78,7 +78,18 @@ def _ref_fk_field(ref: MetaData) -> str | None:
def _ref_target_entity(ref: MetaData) -> str | None:
- """The @references target-entity name of a reference (bare, package-stripped)."""
+ """The @references target-entity name of a reference (bare, package-stripped).
+
+ KNOWN GAP (pre-dates #368, deliberately NOT fixed here): this compares the
+ WHOLE @references value, so the dotted ``Entity.field`` form ("Team.id")
+ never matches a bare entity name — a junction whose references are authored
+ dotted derives no M:N fields on this port, where TS's derive-m2m-fields.ts
+ (which reads ``ref.targetEntity``) resolves them. The one-line repair is to
+ delegate to ``relationship_references.reference_target_entity``; it is left
+ alone because it would change M:N derivation behaviour, which is outside the
+ #368 fix. Tracked separately from the rule-(e) ladder, whose copy of this
+ blind spot IS fixed.
+ """
v = ref.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES) # ADR-0039: resolving (identity attr)
return _strip_package(v) if isinstance(v, str) and v else None
diff --git a/server/python/src/metaobjects/meta/core/relationship/relationship_references.py b/server/python/src/metaobjects/meta/core/relationship/relationship_references.py
index cfaf4e03f..ecc1f9cbe 100644
--- a/server/python/src/metaobjects/meta/core/relationship/relationship_references.py
+++ b/server/python/src/metaobjects/meta/core/relationship/relationship_references.py
@@ -19,6 +19,7 @@
from ...meta_data import MetaData
from ....naming import strip_package
from ....shared.base_types import TYPE_IDENTITY
+from ....shared.separators import PACKAGE_SEP
from ..identity.identity_constants import (
IDENTITY_ATTR_FIELDS,
IDENTITY_REFERENCE_ATTR_REFERENCES,
@@ -53,6 +54,31 @@ def reference_fields(ref: MetaData) -> list[str]:
return []
+def reference_target_entity(ref: MetaData) -> str | None:
+ """The TARGET-ENTITY half of an ``identity.reference``'s ``@references``.
+
+ ``@references`` is either a bare entity name (``Team`` / ``acme::sport::Team``,
+ meaning "the target's primary identity") or the dotted ``Entity.field`` /
+ ``Entity.fieldA,fieldB`` form (``Team.id``) naming explicit target fields.
+ Both forms name the same entity, so the entity half is the segment BEFORE the
+ first ``.`` — mirroring the other three ports' ``targetEntity`` accessor
+ (TS ``MetaReferenceIdentity.targetEntity``, C# ``MetaReferenceIdentity.TargetEntity``,
+ Java ``ReferenceIdentity.getTargetEntity()``), which is the authoritative shape.
+ Python has no MetaReferenceIdentity subclass to hang it on, so it lives here.
+
+ The dot is searched only AFTER the last package separator so a ``::``-qualified
+ name can never have a package segment mistaken for the field separator. Returns
+ None when the attr is absent or empty.
+ """
+ raw = ref.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES) # ADR-0039: resolving.
+ if not isinstance(raw, str) or not raw:
+ return None
+ sep = raw.rfind(PACKAGE_SEP)
+ start = sep + len(PACKAGE_SEP) if sep >= 0 else 0
+ dot = raw.find(".", start)
+ return raw if dot == -1 else raw[:dot]
+
+
def _first_fk_field(ref: MetaData) -> str | None:
"""The FK field a reference is anchored on (first field; composite FKs
pair on their first column)."""
@@ -80,7 +106,8 @@ def _add(value: str | None) -> None:
def reference_candidates_for(holder: MetaData, target_entity: str) -> list[MetaData]:
"""Every identity.reference on ``holder`` whose @references targets
``target_entity``. Package-insensitive on both sides: @references and
- @objectRef may each be bare or fully qualified.
+ @objectRef may each be bare or fully qualified, and @references may use the
+ dotted ``Entity.field`` form (see :func:`reference_target_entity`).
"""
target = strip_package(target_entity)
candidates: list[MetaData] = []
@@ -88,8 +115,8 @@ def reference_candidates_for(holder: MetaData, target_entity: str) -> list[MetaD
for child in holder.children():
if child.type != TYPE_IDENTITY or child.sub_type != IDENTITY_SUBTYPE_REFERENCE:
continue
- references = child.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES)
- if not isinstance(references, str) or not references:
+ references = reference_target_entity(child)
+ if references is None:
continue
if strip_package(references) != target:
continue
diff --git a/server/python/tests/unit/test_relationship_references.py b/server/python/tests/unit/test_relationship_references.py
index a6eaee159..826ad11fe 100644
--- a/server/python/tests/unit/test_relationship_references.py
+++ b/server/python/tests/unit/test_relationship_references.py
@@ -137,3 +137,67 @@ def test_suffix_stripping_never_applies_to_the_relationship_name() -> None:
assert [e.code.value for e in result.errors] == ["ERR_INVALID_RELATIONSHIP"]
match = _find_object(result.root, "Match")
assert resolve_relationship_reference(match, "valid", "Team") is None
+
+
+# ---------------------------------------------------------------------------
+# The dotted `@references` form — "Team.id" names the same target entity as a
+# bare "Team" (spec/metamodel/identity.json documents it). This port used to
+# compare the WHOLE attr value, so a dotted reference matched no target: a valid
+# dotted model was refused (zero candidates made a declared @sourceRefField look
+# unsatisfiable) and a genuinely ambiguous dotted model loaded clean. The other
+# three ports head-parse at the first "." and always agreed; only Python did not.
+# ---------------------------------------------------------------------------
+
+def _dotted_model(relationship: dict) -> dict:
+ return {"metadata.root": {"package": "repro", "children": [
+ {"object.entity": {"name": "Team", "children": [
+ {"field.long": {"name": "id"}},
+ {"identity.primary": {"name": "id", "@fields": ["id"]}},
+ ]}},
+ {"object.entity": {"name": "Match", "children": [
+ {"field.long": {"name": "id"}},
+ {"field.long": {"name": "alphaFk"}},
+ {"field.long": {"name": "betaFk"}},
+ {"identity.primary": {"name": "id", "@fields": ["id"]}},
+ {"identity.reference": {"name": "alphaRef", "@fields": ["alphaFk"],
+ "@references": "Team.id"}},
+ {"identity.reference": {"name": "betaRef", "@fields": ["betaFk"],
+ "@references": "repro::Team.id"}},
+ {"relationship.association": relationship},
+ ]}},
+ ]}}
+
+
+def test_dotted_references_are_enumerated_as_candidates() -> None:
+ doc = _dotted_model({"name": "winner", "@objectRef": "Team",
+ "@cardinality": "one", "@sourceRefField": "alphaFk"})
+ match = _load_object(doc, "Match")
+ # Both the bare-dotted "Team.id" and the FQN-dotted "repro::Team.id" forms
+ # resolve to the entity "Team" — the package separator is never mistaken
+ # for the field separator.
+ assert [c.name for c in reference_candidates_for(match, "Team")] == ["alphaRef", "betaRef"]
+ assert [c.name for c in reference_candidates_for(match, "repro::Team")] == ["alphaRef", "betaRef"]
+
+
+def test_dotted_references_resolve_through_source_ref_field() -> None:
+ doc = _dotted_model({"name": "winner", "@objectRef": "Team",
+ "@cardinality": "one", "@sourceRefField": "betaFk"})
+ # Loads clean: the declared FK names a real candidate. Before the head-parse
+ # this raised ERR_INVALID_RELATIONSHIP ("names no identity.reference
+ # targeting Team. Candidates: .") on a model TypeScript accepted.
+ match = _load_object(doc, "Match")
+ resolved = resolve_relationship_reference(match, "winner", "Team", "betaFk")
+ assert resolved is not None and resolved.name == "betaRef"
+
+
+def test_dotted_references_are_still_ambiguous_without_disambiguation() -> None:
+ # The other direction: "winner" pairs with neither candidate, so the load
+ # must FAIL. Before the head-parse there were zero candidates, rule (e)'s
+ # `len(candidates) <= 1` skipped, and the model loaded clean while
+ # TypeScript refused it.
+ doc = _dotted_model({"name": "winner", "@objectRef": "Team", "@cardinality": "one"})
+ result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))])
+ assert [e.code.value for e in result.errors] == ["ERR_INVALID_RELATIONSHIP"]
+ assert "alphaRef(alphaFk), betaRef(betaFk)" in result.errors[0].message
+ match = _find_object(result.root, "Match")
+ assert resolve_relationship_reference(match, "winner", "Team") is None
From bb4cac295ad3cdc56f3918abe174d3c7d13f049f Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 22:14:12 -0400
Subject: [PATCH 25/31] =?UTF-8?q?fix(codegen-kotlin):=20#368=20=E2=80=94?=
=?UTF-8?q?=20port=20the=20referential-action=20correlation=20fix?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../kotlin/KotlinExposedTableGenerator.kt | 45 ++++--
.../kotlin/KotlinExposedTableTwoRefsTest.kt | 129 ++++++++++++++++++
2 files changed, 166 insertions(+), 8 deletions(-)
create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableTwoRefsTest.kt
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
index 1d5223a5d..0fcaa50df 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
@@ -16,6 +16,7 @@ import com.metaobjects.loader.MetaDataLoader
import com.metaobjects.`object`.MetaObject
import com.metaobjects.relationship.CompositionRelationship
import com.metaobjects.relationship.MetaRelationship
+import com.metaobjects.relationship.RelationshipReferences
import com.metaobjects.source.MetaSource
import com.metaobjects.source.RdbSource
import com.squareup.kotlinpoet.ClassName
@@ -1576,9 +1577,13 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase
+ // reference ladder (not by target alone, which let a second FK inherit
+ // the first relationship's actions); and the reverse relationship
+ // contributes nothing when this entity holds more than one enforced
+ // reference to the same target, or when the target declares more than
+ // one relationship back at this entity.
val (resolvedOnDelete, resolvedOnUpdate) =
resolveDecorationActions(loader, entity, child, target)
val refSuffix = referentialActionSuffix(resolvedOnDelete, resolvedOnUpdate)
@@ -1609,9 +1614,24 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase Team). Matching `rel` on the
+ // target ALONE cannot say which of those references `rel` supplies actions FOR,
+ // so every FK past the first silently inherited the FIRST relationship's
+ // @onDelete / @onUpdate — homeTeamRef's `restrict` landing on awayTeamRef's
+ // `cascade` FK, in a table Exposed compiles and the database accepts. Resolve it
+ // with the INVERSE of the relationship->reference ladder: `rel` belongs to `ref`
+ // iff the ladder, applied to `rel`, resolves back to `ref` ITSELF — not merely
+ // "to some reference on this target". `rel` and `ref` are both declared on (or
+ // inherited into) `entity`, the exact shape the ladder is built for, so this is
+ // a direct inversion rather than a second parallel rule. Mirrors the TS migrate
+ // engine's resolveReferentialActions and the C# ReferentialActions port.
val childSide = entity.relationships.firstOrNull { rel ->
rel.through == null && rel.objectRef != null &&
- KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef) === target
+ KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef) === target &&
+ RelationshipReferences.resolveRelationshipReference(
+ entity, rel.shortName, rel.objectRef, rel.sourceRefField) === ref
}
var rel = childSide
// When the tier-3 satisfiability guard fires, the reverse relationship's
@@ -1651,10 +1671,11 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase
+ // #368: fail closed on ambiguity rather than taking the first match — when more
+ // than one non-@through relationship on [target] resolves back to [entity] (e.g.
+ // a "posts" composition and a separate "latestPost" association both pointing at
+ // Post), none of them is preferred. This is the tier-2 ambiguity's mirror image
+ // (multiple RELATIONSHIPS rather than multiple REFERENCES) and the ladder cannot
+ // resolve it: the ladder picks among references declared on the SAME object as
+ // the relationship, whereas here the candidates live on [target] while [ref]
+ // lives on [entity]. Mirrors the TS findReverseRelationship.
+ return target.relationships.filter { rel ->
rel.through == null && rel.objectRef != null &&
KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef) === entity
- }
+ }.singleOrNull()
}
/**
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableTwoRefsTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableTwoRefsTest.kt
new file mode 100644
index 000000000..d25d03f0c
--- /dev/null
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableTwoRefsTest.kt
@@ -0,0 +1,129 @@
+package com.metaobjects.generator.kotlin
+
+import com.metaobjects.metadata.ktx.loadString
+import java.nio.file.Files
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+/**
+ * #368 — an entity may legally declare more than one `identity.reference` onto the
+ * SAME target (`Match.homeTeamRef` and `Match.awayTeamRef`, both -> `Team`).
+ *
+ * `KotlinExposedTableGenerator`'s ADR-0047 referential-action correlation had the
+ * defect the TS `migrate-ts` engine and the C# `ReferentialActions` port were fixed
+ * for: tier 2 matched a sibling relationship on the TARGET ALONE, so every FK past
+ * the first silently inherited the FIRST relationship's `@onDelete` / `@onUpdate`,
+ * and tier 3 took the first reverse relationship instead of failing closed. The
+ * emitted Exposed table compiles and the database accepts the DDL — the only symptom
+ * is the wrong referential action on the wrong FK.
+ */
+class KotlinExposedTableTwoRefsTest {
+
+ /**
+ * Tier 2 (child-side correlation). `homeTeam` declares `@onDelete: restrict`,
+ * `awayTeam` declares `@onDelete: cascade`; each must land on ITS OWN FK column.
+ * Before the fix both columns carried RESTRICT — the first relationship's action.
+ *
+ * The relationships resolve by ladder step 3 (name pairing: `homeTeam` pairs with
+ * `homeTeamRef` / `homeTeamId`), so the model loads clean with no `@sourceRefField`.
+ */
+ @Test fun `each FK carries its own relationship's referential actions`() {
+ val model = """{
+ "metadata.root": { "package": "x", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "source.rdb": { "@table": "teams" } },
+ { "identity.primary": { "@fields": "id" } }
+ ] } },
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "homeTeamId" } },
+ { "field.long": { "name": "awayTeamId" } },
+ { "source.rdb": { "@table": "matches" } },
+ { "identity.primary": { "@fields": "id" } },
+ { "identity.reference": { "name": "homeTeamRef",
+ "@fields": "homeTeamId", "@references": "Team" } },
+ { "identity.reference": { "name": "awayTeamRef",
+ "@fields": "awayTeamId", "@references": "Team" } },
+ { "relationship.association": { "name": "homeTeam", "@objectRef": "Team",
+ "@cardinality": "one", "@onDelete": "restrict" } },
+ { "relationship.association": { "name": "awayTeam", "@objectRef": "Team",
+ "@cardinality": "one", "@onDelete": "cascade" } }
+ ] } }
+ ] }
+ }""".trimIndent()
+ val outDir = Files.createTempDirectory("ktbl-368-tier2-")
+ try {
+ val gen = KotlinExposedTableGenerator()
+ gen.setArgs(mapOf("outputDir" to outDir.toString()))
+ gen.execute(loadString("issue368-tier2", model))
+
+ val src = Files.readString(outDir.resolve("x/MatchTable.kt"))
+ assertTrue(
+ ("val homeTeamId = long(\"home_team_id\").references(TeamTable.id, " +
+ "onDelete = ReferenceOption.RESTRICT, onUpdate = ReferenceOption.CASCADE)") in src,
+ "expected homeTeamId to carry homeTeam's @onDelete: restrict; saw:\n$src",
+ )
+ assertTrue(
+ ("val awayTeamId = long(\"away_team_id\").references(TeamTable.id, " +
+ "onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)") in src,
+ "expected awayTeamId to carry awayTeam's OWN @onDelete: cascade, not " +
+ "homeTeam's restrict (the #368 defect); saw:\n$src",
+ )
+ } finally {
+ outDir.toFile().deleteRecursively()
+ }
+ }
+
+ /**
+ * Tier 3 (parent-side reverse correlation) fails closed. `Team` declares TWO
+ * non-`@through` relationships back at `Match` — a `cascade` composition and a
+ * `restrict` association — and `Match` declares no relationship of its own, so
+ * tier 2 contributes nothing and tier 3 must choose. It cannot: neither candidate
+ * is preferred, so the FK is emitted BARE rather than arbitrarily armed with the
+ * first one's CASCADE.
+ */
+ @Test fun `an ambiguous reverse relationship contributes no referential action`() {
+ val model = """{
+ "metadata.root": { "package": "x", "children": [
+ { "object.entity": { "name": "Team", "children": [
+ { "field.long": { "name": "id" } },
+ { "source.rdb": { "@table": "teams" } },
+ { "identity.primary": { "@fields": "id" } },
+ { "relationship.composition": { "name": "matches", "@objectRef": "Match",
+ "@cardinality": "many", "@onDelete": "cascade" } },
+ { "relationship.association": { "name": "playedMatches", "@objectRef": "Match",
+ "@cardinality": "many", "@onDelete": "restrict" } }
+ ] } },
+ { "object.entity": { "name": "Match", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.long": { "name": "teamId" } },
+ { "source.rdb": { "@table": "matches" } },
+ { "identity.primary": { "@fields": "id" } },
+ { "identity.reference": { "name": "teamRef",
+ "@fields": "teamId", "@references": "Team" } }
+ ] } }
+ ] }
+ }""".trimIndent()
+ val outDir = Files.createTempDirectory("ktbl-368-tier3-")
+ try {
+ val gen = KotlinExposedTableGenerator()
+ gen.setArgs(mapOf("outputDir" to outDir.toString()))
+ gen.execute(loadString("issue368-tier3", model))
+
+ val src = Files.readString(outDir.resolve("x/MatchTable.kt"))
+ assertTrue(
+ "val teamId = long(\"team_id\").references(TeamTable.id).nullable()" in src ||
+ "val teamId = long(\"team_id\").references(TeamTable.id)" in src,
+ "expected a bare .references(TeamTable.id) FK; saw:\n$src",
+ )
+ assertTrue(
+ "ReferenceOption" !in src,
+ "an ambiguous reverse relationship must contribute NO referential action " +
+ "rather than the first candidate's; saw:\n$src",
+ )
+ } finally {
+ outDir.toFile().deleteRecursively()
+ }
+ }
+}
From 3937057dfaf63dcf9b91ab12e81d757a59ceb6a2 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 22:14:30 -0400
Subject: [PATCH 26/31] =?UTF-8?q?fix(codegen-ts):=20#368=20=E2=80=94=20the?=
=?UTF-8?q?=20origin.first=20refusal=20broke=20a=20legal=20mutual=201:1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../src/projection/extract-view-spec.ts | 18 ++++-
.../projection/reference-ambiguity.test.ts | 72 +++++++++++++++++++
2 files changed, 88 insertions(+), 2 deletions(-)
diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
index 72cbd7992..73a1a8917 100644
--- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
+++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts
@@ -1189,10 +1189,24 @@ function buildSelectSpec(
// branch to match _validateViaPath/_inferViaSingleHop) — out of scope for #368's
// silent-first-match fix; the message below reflects the real, narrower remedy.
const refs = findReferencesBetween(base, childEntity);
- if (refs.length > 1) {
+ // findReferencesBetween walks BOTH directions ([base,child] then [child,base]),
+ // so ">1 entry" is not by itself #368 ambiguity: a mutual 1:1 —
+ // Customer.primaryAddressRef -> Address PLUS Address.customerRef -> Customer —
+ // returns two entries pointing in OPPOSITE directions. That shape is legal
+ // (findReferenceBetween's own contract calls it "rare, but legal", and the
+ // referenceHolder: "source" | "target" branch below exists to serve it), and
+ // refusing it broke codegen for a model #368 says nothing about. The real
+ // ambiguity is two references declared by the SAME holder onto the other side —
+ // that is what the first-match below cannot choose between.
+ const ambiguousHolder = [base, childEntity].find(
+ (holder) => refs.filter((r) => r.holder === holder).length > 1,
+ );
+ if (ambiguousHolder !== undefined) {
+ const ambiguous = refs.filter((r) => r.holder === ambiguousHolder);
throw new Error(
`origin.first correlation from "${base.name}" to "${childEntity.name}" is ambiguous: ` +
- `${refs.map((r) => r.referenceIdentity.name).join(", ")}. ` +
+ `"${ambiguousHolder.name}" declares ${ambiguous.length} identity.reference nodes ` +
+ `onto the other side (${ambiguous.map((r) => r.referenceIdentity.name).join(", ")}). ` +
`origin.first's own @via is not consulted for this correlation — reduce to a ` +
`single identity.reference between these two entities.`,
);
diff --git a/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts b/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts
index 24f5e82f9..1789ee1b8 100644
--- a/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts
+++ b/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts
@@ -431,10 +431,82 @@ describe("extractViewSpec — reference ambiguity (#368)", () => {
}
expect(message).toMatch(/origin\.first correlation from "Team" to "Match" is ambiguous:.*homeTeamRef.*awayTeamRef/s);
+ // The refusal names the HOLDER that declares both, because that is the shape it
+ // cannot choose within (see the mutual-1:1 test below for the shape it must not
+ // refuse).
+ expect(message).toContain('"Match" declares 2 identity.reference nodes');
// #368 round 2 sibling check: origin.first has no relationship node to attach
// @sourceRefField to at all (the correlation is derived from @of alone), so this
// message must never suggest it — confirming it stays dead-end-free alongside the
// buildJoinTree fix above.
expect(message).not.toContain("@sourceRefField");
});
+
+ test("a mutual 1:1 — one reference per holder, pointing opposite ways — is NOT ambiguous", async () => {
+ // findReferencesBetween walks BOTH directions ([a,b] then [b,a]), so a mutual 1:1
+ // returns TWO entries. A bare `refs.length > 1` refusal therefore hard-failed
+ // codegen on a shape #368 says nothing about: 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". Only a SINGLE holder declaring two-or-more is #368 ambiguity.
+ const root = await load([
+ {
+ "object.entity": {
+ name: "Customer",
+ children: [
+ { "source.rdb": { "@table": "customers" } },
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "primaryAddressId" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ // Customer -> Address
+ { "identity.reference": { name: "primaryAddressRef", "@fields": "primaryAddressId", "@references": "Address" } },
+ // Lets the loader's single-hop-@via inference succeed for origin.first.
+ { "relationship.association": { name: "addresses", "@objectRef": "Address", "@cardinality": "many" } },
+ ],
+ },
+ },
+ {
+ "object.entity": {
+ name: "Address",
+ children: [
+ { "source.rdb": { "@table": "addresses" } },
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "customerId" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ // Address -> Customer: the other half of the mutual 1:1.
+ { "identity.reference": { name: "customerRef", "@fields": "customerId", "@references": "Customer" } },
+ ],
+ },
+ },
+ {
+ "object.projection": {
+ name: "CustomerSummary",
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_customer_summary" } },
+ { "field.int": { name: "id", extends: "Customer.id" } },
+ { "identity.primary": { name: "id", extends: "Customer.id" } },
+ {
+ "field.int": {
+ name: "latestAddressId",
+ children: [
+ { "origin.first": { "@of": "Address.id", "@orderBy": ["id:desc"] } },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ]);
+
+ const projection = root.objects().find((o) => o.name === "CustomerSummary")!;
+ // The assertion is that this does NOT throw.
+ const spec = extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" });
+ const col = spec.selectSpec.columns.find((c) => c.fieldName === "latestAddressId");
+ expect(col).toBeDefined();
+ expect(col!.kind).toBe("first");
+ // Documented first-match behaviour is preserved: `a` is walked first, so the
+ // base-side reference wins and the correlation is source-held.
+ expect((col as { referenceHolder: string }).referenceHolder).toBe("source");
+ expect((col as { fkColumn: string }).fkColumn).toBe("primary_address_id");
+ });
});
From 6b47b8d0dce0c19f20231a4b43c82c2fb18b6a84 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 22:14:30 -0400
Subject: [PATCH 27/31] =?UTF-8?q?test(runtime-ts):=20#368=20=E2=80=94=20pi?=
=?UTF-8?q?n=20@sourceRefField=20at=20both=20relation-resolver=20call=20si?=
=?UTF-8?q?tes?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../test/relation-resolver-two-refs.test.ts | 57 +++++++++++++++++++
1 file changed, 57 insertions(+)
diff --git a/server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts b/server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts
index 8f05745c3..c78940367 100644
--- a/server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts
+++ b/server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts
@@ -74,6 +74,33 @@ const MANY_SIDE_MODEL = {
},
};
+// @sourceRefField model: neither relationship name pairs with either reference
+// ("winner"/"loser" vs alphaRef(alphaFk)/betaRef(betaFk)), so ladder step 3 cannot
+// resolve them and only the declared @sourceRefField (step 2) can. Declaration
+// order is deliberate: alphaRef comes first, so a lookup that ignored
+// @sourceRefField would return alphaFk for BOTH relations.
+const SOURCE_REF_FIELD_MODEL = {
+ "metadata.root": {
+ package: "repro",
+ children: [
+ { "object.entity": { name: "Team", children: [
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
+ ] } },
+ { "object.entity": { name: "Match", children: [
+ { "field.int": { name: "id" } },
+ { "field.int": { name: "alphaFk" } },
+ { "field.int": { name: "betaFk" } },
+ { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
+ { "identity.reference": { name: "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } },
+ { "identity.reference": { name: "betaRef", "@fields": ["betaFk"], "@references": "Team" } },
+ { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "betaFk" } },
+ { "relationship.association": { name: "loser", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "alphaFk" } },
+ ] } },
+ ],
+ },
+};
+
async function load(model: unknown): Promise {
const { root, errors } = await new MetaDataLoader().load([
new InMemoryStringSource(JSON.stringify(model), { id: "meta.repro.json" }),
@@ -104,4 +131,34 @@ describe("resolveRelationDescriptor with two references onto one target (#368)",
expect(desc.targetEntityName).toBe("Match");
expect(desc.targetField).toBe("homeTeamId"); // was "awayTeamId"
});
+
+ // Both tests above resolve by NAME PAIRING (ladder step 3), so they stay green if
+ // the @sourceRefField argument is dropped at either call site — nothing proved it
+ // was ever read here. These two models name the relationships so they pair with
+ // NOTHING ("winner" / "loser" against alphaRef / betaRef), which makes a declared
+ // @sourceRefField (ladder step 2) the only thing that can resolve them: drop the
+ // 4th argument at either call site and the resolver throws "has no
+ // identity.reference targeting 'Team'".
+
+ test("one-side: @sourceRefField reaches the resolver when the name pairs with nothing", async () => {
+ const root = await load(SOURCE_REF_FIELD_MODEL);
+ const match = root.findObject("Match")!;
+ // Deliberately crossed against declaration order: winner -> betaFk (the SECOND
+ // reference), loser -> alphaFk.
+ expect(resolveRelationDescriptor(match, "winner", root).sourceField).toBe("betaFk");
+ expect(resolveRelationDescriptor(match, "loser", root).sourceField).toBe("alphaFk");
+ });
+
+ test("many-side: @sourceRefField reaches the inverse resolver too", async () => {
+ const root = await load(SOURCE_REF_FIELD_MODEL);
+ const team = root.findObject("Team")!;
+ // Team's only reachable inverse name is "matches", which resolves to Match's
+ // FIRST cardinality:one relationship targeting Team — "winner", whose
+ // @sourceRefField names betaFk here. alphaRef is declared FIRST, so a resolver
+ // that ignored @sourceRefField would answer "alphaFk".
+ const desc = resolveRelationDescriptor(team, "matches", root);
+ expect(desc.cardinality).toBe("many");
+ expect(desc.targetEntityName).toBe("Match");
+ expect(desc.targetField).toBe("betaFk");
+ });
});
From 53edf3f1b9d58df7eec210f6a23610734c96023f Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 22:14:39 -0400
Subject: [PATCH 28/31] =?UTF-8?q?fix(loader):=20#368=20=E2=80=94=20rule=20?=
=?UTF-8?q?(d)'s=20message=20names=20both=20legal=20homes;=20correct=20rul?=
=?UTF-8?q?e=20(e)'s=20scope=20note?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
.../MetaObjects/Loader/ValidationPasses.cs | 14 ++++++++++----
.../com/metaobjects/loader/ValidationPhase.java | 17 +++++++++++++----
...e368RelationshipReferenceValidationTest.java | 6 +++++-
.../src/metaobjects/loader/validation_passes.py | 15 +++++++++++----
.../metadata/src/loader/validation-passes.ts | 14 ++++++++++----
5 files changed, 49 insertions(+), 17 deletions(-)
diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs
index 38af137b8..01eefa9fd 100644
--- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs
+++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs
@@ -3202,7 +3202,9 @@ public static IReadOnlyList ValidateRelationships(MetaData root)
if (hasSourceRefField && !isCardinalityOne)
{
errors.Add(new MetaError(
- $"relationship \"{declaringEntity.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.",
+ $"relationship \"{declaringEntity.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is neither a M:N " +
+ $"relationship (requires @{RELATIONSHIP_ATTR_THROUGH} with @{RELATIONSHIP_ATTR_CARDINALITY}: \"{CARDINALITY_MANY}\") " +
+ $"nor a @{RELATIONSHIP_ATTR_CARDINALITY}: \"{CARDINALITY_ONE}\" relationship.",
ErrorCode.ERR_INVALID_RELATIONSHIP,
Envelope: rel.Source));
}
@@ -3306,9 +3308,13 @@ public static IReadOnlyList ValidateRelationships(MetaData root)
// above) — same deferred-resolution timing (after all files load + extends
// resolution).
//
- // Scope differs deliberately from rule (d): rule (d) validates attrs that
- // travel with the relationship's OWN declaration (@through/@symmetric/
- // @sourceRefField), so own-scoping there is correct — those attrs don't
+ // Scope differs deliberately from rule (d) — in SUBJECT, not in which
+ // relationships each pass walks (both walk the EFFECTIVE set; rule (d) is
+ // no longer own-scoped, or an M:N declaration reached only via extends
+ // would go unchecked). Rule (d) validates attrs that travel with the
+ // relationship's OWN declaration (@through/@symmetric/@sourceRefField), so
+ // it checks each declaration EXACTLY ONCE — deduped by node identity, and
+ // reported against the entity that declares it — because those attrs don't
// change meaning depending on who inherits the relationship. Rule (e)
// instead validates whether THIS entity's reference set resolves the
// relationship uniquely, which is a property of the EFFECTIVE entity, not of
diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
index 145842c54..b37b0e553 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
@@ -1819,7 +1819,12 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj,
ErrorMessageConstants.ERR_INVALID_RELATIONSHIP
+ ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName()
+ "\" sets @" + MetaRelationship.ATTR_SOURCE_REF_FIELD
- + " but is not a M:N relationship.",
+ + " but is neither a M:N relationship (requires @"
+ + MetaRelationship.ATTR_THROUGH + " with @"
+ + MetaRelationship.ATTR_CARDINALITY + ": \""
+ + MetaRelationship.CARDINALITY_MANY + "\") nor a @"
+ + MetaRelationship.ATTR_CARDINALITY + ": \""
+ + MetaRelationship.CARDINALITY_ONE + "\" relationship.",
ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()));
}
if (symmetric) {
@@ -1926,9 +1931,13 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj,
// above) — same deferred-resolution timing (after all files load + extends
// resolution).
//
- // Scope differs deliberately from rule (d): rule (d) validates attrs that
- // travel with the relationship's OWN declaration (@through/@symmetric/
- // @sourceRefField), so own-scoping there is correct — those attrs don't
+ // Scope differs deliberately from rule (d) — in SUBJECT, not in which
+ // relationships each pass walks (both walk the EFFECTIVE set; rule (d) is
+ // no longer own-scoped, or an M:N declaration reached only via extends
+ // would go unchecked). Rule (d) validates attrs that travel with the
+ // relationship's OWN declaration (@through/@symmetric/@sourceRefField), so
+ // it checks each declaration EXACTLY ONCE — deduped by node identity, and
+ // reported against the entity that declares it — because those attrs don't
// change meaning depending on who inherits the relationship. Rule (e)
// instead validates whether THIS entity's reference set resolves the
// relationship uniquely, which is a property of the EFFECTIVE entity, not of
diff --git a/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java
index 99369c8a7..71bc31142 100644
--- a/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java
+++ b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java
@@ -224,7 +224,11 @@ public void sourceRefFieldOnAbsentCardinalityStillErrors() {
assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP);
assertTrue(outcome.joinedMessages(), outcome.joinedMessages().contains("Week.program"));
assertTrue(outcome.joinedMessages(), outcome.joinedMessages()
- .contains("sets @sourceRefField but is not a M:N relationship"));
+ .contains("sets @sourceRefField but is neither a M:N relationship"));
+ // The message names BOTH legal homes for the attribute -- M:N and
+ // @cardinality: "one" -- so it can't read as "never allowed here".
+ assertTrue(outcome.joinedMessages(), outcome.joinedMessages()
+ .contains("nor a @cardinality: \"one\" relationship"));
}
@Test
diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py
index e465878e7..367f1f934 100644
--- a/server/python/src/metaobjects/loader/validation_passes.py
+++ b/server/python/src/metaobjects/loader/validation_passes.py
@@ -2698,7 +2698,10 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
if has_source_ref_field and not is_cardinality_one:
errors.append(MetaError(
f'relationship "{declaring_entity.name}.{rel.name}" sets '
- f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.',
+ f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is neither a M:N '
+ f'relationship (requires @{RELATIONSHIP_ATTR_THROUGH} with '
+ f'@{RELATIONSHIP_ATTR_CARDINALITY}: "{CARDINALITY_MANY}") nor a '
+ f'@{RELATIONSHIP_ATTR_CARDINALITY}: "{CARDINALITY_ONE}" relationship.',
ErrorCode.ERR_INVALID_RELATIONSHIP,
envelope=rel.source,
))
@@ -2807,9 +2810,13 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
# above) — same deferred-resolution timing (after all files load + extends
# resolution).
#
-# Scope differs deliberately from rule (d): rule (d) validates attrs that
-# travel with the relationship's OWN declaration (@through/@symmetric/
-# @sourceRefField), so own-scoping there is correct — those attrs don't
+# Scope differs deliberately from rule (d) — in SUBJECT, not in which
+# relationships each pass walks (both walk the EFFECTIVE set; rule (d) is
+# no longer own-scoped, or an M:N declaration reached only via extends
+# would go unchecked). Rule (d) validates attrs that travel with the
+# relationship's OWN declaration (@through/@symmetric/@sourceRefField), so
+# it checks each declaration EXACTLY ONCE — deduped by node identity, and
+# reported against the entity that declares it — because those attrs don't
# change meaning depending on who inherits the relationship. Rule (e)
# instead validates whether THIS entity's reference set resolves the
# relationship uniquely, which is a property of the EFFECTIVE entity, not of
diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts
index 0d91f0f4e..a5d17f97b 100644
--- a/server/typescript/packages/metadata/src/loader/validation-passes.ts
+++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts
@@ -2058,7 +2058,9 @@ export function validateRelationships(root: MetaData): ParseError[] {
if (hasSourceRefField && cardinality !== CARDINALITY_ONE) {
errors.push(
new ParseError(
- `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.`,
+ `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is neither a M:N ` +
+ `relationship (requires @${RELATIONSHIP_ATTR_THROUGH} with @${RELATIONSHIP_ATTR_CARDINALITY}: "${CARDINALITY_MANY}") ` +
+ `nor a @${RELATIONSHIP_ATTR_CARDINALITY}: "${CARDINALITY_ONE}" relationship.`,
{ code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
),
);
@@ -2168,9 +2170,13 @@ export function validateRelationships(root: MetaData): ParseError[] {
// above) — same deferred-resolution timing (after all files load + extends
// resolution).
//
-// Scope differs deliberately from rule (d): rule (d) validates attrs that
-// travel with the relationship's OWN declaration (@through/@symmetric/
-// @sourceRefField), so own-scoping there is correct — those attrs don't
+// Scope differs deliberately from rule (d) — in SUBJECT, not in which
+// relationships each pass walks (both walk the EFFECTIVE set; rule (d) is
+// no longer own-scoped, or an M:N declaration reached only via extends
+// would go unchecked). Rule (d) validates attrs that travel with the
+// relationship's OWN declaration (@through/@symmetric/@sourceRefField), so
+// it checks each declaration EXACTLY ONCE — deduped by node identity, and
+// reported against the entity that declares it — because those attrs don't
// change meaning depending on who inherits the relationship. Rule (e)
// instead validates whether THIS entity's reference set resolves the
// relationship uniquely, which is a property of the EFFECTIVE entity, not of
From e2b32f890e110e3f65a8ca4872bf3749730a4848 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 22:14:49 -0400
Subject: [PATCH 29/31] =?UTF-8?q?docs:=20#368=20=E2=80=94=20correct=20the?=
=?UTF-8?q?=20Kotlin=20claim,=20add=20the=20rule-(e)=20zero-candidate=20ga?=
=?UTF-8?q?p,=20state=20the=20composite=20fallback?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
CHANGELOG.md | 37 ++++++++++++++++++++++++----------
docs/features/relationships.md | 16 ++++++++++++---
2 files changed, 39 insertions(+), 14 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa3049c4a..fac7472bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -96,12 +96,13 @@ here.**
even when the hop explicitly named the other reference, and the docs-site link graph
drew the wrong edge. All four produce a join that typechecks, emits correct DDL, and
passes `meta verify` — the only symptom is wrong rows. The referential-actions
- correlation (TypeScript's `migrate-ts` and the C# port; Python and Java do not
- implement this correlation and are untouched) 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 action on whichever FK wasn't
- examined first.
+ correlation (TypeScript's `migrate-ts`, the C# port, and the JVM tree's Kotlin
+ Exposed table generator; Python does not implement this correlation) 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
+ action on whichever FK wasn't examined first. All three are now correlated by
+ inverting the same ladder, and fail closed rather than guessing.
Resolution is now explicit and identical across TypeScript, Python, C# and Java: an
ambiguous `@cardinality: one` reference set resolves by a ladder — the sole
@@ -112,11 +113,25 @@ here.**
Amendment 1). `@sourceRefField` is now legal on a `@cardinality: one` relationship —
it previously failed to load there as an M:N-only attribute. Two limits are
documented rather than fixed here: the ladder matches a candidate's first FK field
- only, so two composite references sharing a first column stay indistinguishable, and
- the loader gate covers `@cardinality: one` relationships only — a `many`-cardinality
- relationship, or a bare `identity.reference` pair with no relationship wrapper, still
- reaches codegen unvalidated. See
- [`docs/features/relationships.md`](docs/features/relationships.md).
+ only, so two composite references sharing a first column stay indistinguishable (and
+ resolve to the first, rather than being refused); the loader gate covers
+ `@cardinality: one` relationships only — a `many`-cardinality relationship, or a bare
+ `identity.reference` pair with no relationship wrapper, still reaches codegen
+ unvalidated; and the gate needs at least one candidate on the holder, so an inverted
+ shape with both FKs on the far side loads clean and codegen then silently drops the
+ relation. See [`docs/features/relationships.md`](docs/features/relationships.md).
+
+ Two consequences worth stating outright. **Java's relationship validation changed
+ from eager-throw-on-first-violation to collect-all-findings** —
+ `validateRelationshipsM2M` now returns a `List` rather than
+ throwing, and the new rule-(e) pass collects the same way, so a Java loader run
+ reports every broken relationship where it previously reported only the first; that
+ is a change to Java loader OUTPUT, not an internal refactor. And **adopters with the
+ affected shapes will see FK referential-action diffs on their next `meta migrate`**:
+ a second FK to the same target now resolves its OWN `@onDelete` / `@onUpdate`
+ instead of inheriting the first relationship's, so the generated `ON DELETE` /
+ `ON UPDATE` (and a Kotlin Exposed table's `ReferenceOption` arguments) legitimately
+ change.
No vocabulary was added, removed or retyped — the only change to
`expected-registry.json` corrects `@sourceRefField`'s own description, which no
diff --git a/docs/features/relationships.md b/docs/features/relationships.md
index d247baecc..06fe68dd3 100644
--- a/docs/features/relationships.md
+++ b/docs/features/relationships.md
@@ -209,13 +209,23 @@ Resolution follows a ladder, checked in order:
**Limitations, documented rather than fixed:**
- The ladder matches a candidate's **first** FK field only, so two composite
- references sharing a first column are indistinguishable from each other. The load
- error still renders each candidate's full field tuple (`name(fieldA, fieldB)`) so the
- ambiguity is visible even where `@sourceRefField` cannot resolve it.
+ references sharing a first column are indistinguishable from each other — and this
+ does **not** refuse. Rule (e) accepts a `@sourceRefField` that matches *any*
+ candidate's first column (`candidates.some(c => c.fields[0] === declared)`), and the
+ ladder's `.find()` then returns the **first** such candidate. The model loads clean
+ and resolves to whichever composite reference is declared first, which may not be the
+ one meant. Where an error *is* raised, it still renders each candidate's full field
+ tuple (`name(fieldA, fieldB)`) so the collision is at least visible.
- The load-time gate covers `@cardinality: one` relationships only. A
`many`-cardinality relationship, and a bare `identity.reference` pair with no
relationship wrapper at all, reach codegen unvalidated — an ambiguous reference set
in either shape is not caught at load.
+- The load-time gate covers a `@cardinality: one` relationship only when its **holder
+ declares at least one `identity.reference` at the target**. Rule (e)'s
+ `candidates.length <= 1` skip (`validation-passes.ts`, and its three ports) skips
+ **zero** as well as one, so an inverted shape — the relationship on one entity, both
+ FKs on the far side — loads clean, and codegen then **silently drops the relation**:
+ no join, no error, no diagnostic.
- A projection's `@via` hop (above) resolves the identical ambiguity for the hop it
names, but `origin.first`'s own `@via` is never consulted for its base↔child
correlation — an ambiguous target there has no `@via`-based fix; the only escape is
From 7edee57f0eaaf7fb126c205d379fc68bfc4d6897 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 22:17:33 -0400
Subject: [PATCH 30/31] docs(conformance): register the dotted-@references
fixture in relationships.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)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
docs/features/relationships.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/docs/features/relationships.md b/docs/features/relationships.md
index 06fe68dd3..b4ee52e8a 100644
--- a/docs/features/relationships.md
+++ b/docs/features/relationships.md
@@ -366,6 +366,7 @@ The following conformance fixtures gate this feature's behavior across ports:
- [`fixtures/conformance/relationship-one-two-refs-sourcerefield/`](../../fixtures/conformance/relationship-one-two-refs-sourcerefield/) — two `identity.reference` nodes onto the same target, disambiguated by `@sourceRefField` (ladder stage 2)
- [`fixtures/conformance/relationship-one-two-refs-name-pairing/`](../../fixtures/conformance/relationship-one-two-refs-name-pairing/) — the same shape resolved by name-pairing alone (ladder stage 3)
- [`fixtures/conformance/error-relationship-one-refs-ambiguous/`](../../fixtures/conformance/error-relationship-one-refs-ambiguous/) — neither `@sourceRefField` nor a pairing name given: `ERR_INVALID_RELATIONSHIP` at load (#368, ADR-0029 Amendment 1)
+- [`fixtures/conformance/relationship-one-two-refs-dotted-references/`](../../fixtures/conformance/relationship-one-two-refs-dotted-references/) — the same two-reference shape with `@references` in the dotted `Entity.field` form: the entity half is the segment before the first `.`, so the ladder resolves identically to the bare form
Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these
via their respective conformance runners. See [`docs/CONFORMANCE.md`](../CONFORMANCE.md)
From 7dafb055ec13efad830d1f1b5feb6f09a172c7d7 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 13 Sep 2026 22:33:27 -0400
Subject: [PATCH 31/31] docs(changelog): three limits are documented, not two
The list gained the rule-(e) zero-candidate gap in e2b32f890 but the
count word was not updated with it.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fac7472bf..b961a208c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -111,7 +111,7 @@ here.**
else `ERR_INVALID_RELATIONSHIP` at load, naming every candidate
([ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md)
Amendment 1). `@sourceRefField` is now legal on a `@cardinality: one` relationship —
- it previously failed to load there as an M:N-only attribute. Two limits are
+ it previously failed to load there as an M:N-only attribute. Three limits are
documented rather than fixed here: the ladder matches a candidate's first FK field
only, so two composite references sharing a first column stay indistinguishable (and
resolve to the first, rather than being refused); the loader gate covers