Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
48dc28e
docs(plan): association->reference disambiguation for #368
dmealing Sep 13, 2026
5ebb69c
feat(metadata): association->reference resolution ladder (#368)
dmealing Sep 13, 2026
8167abb
feat(loader): @sourceRefField is legal on cardinality:one (#368)
dmealing Sep 13, 2026
23b7db6
feat(loader): ambiguous 1:N reference is a load error (#368)
dmealing Sep 13, 2026
763f7a3
fix(loader): rule (e) fix round 1 -- catch declared-but-unmatched sou…
dmealing Sep 13, 2026
df9ceb2
fix(codegen-ts): relations() joined every association on the first FK…
dmealing Sep 13, 2026
6b3d2e4
fix(runtime-ts): relation traversal read the first FK for every relat…
dmealing Sep 13, 2026
710074c
fix(loader): rule (e) must iterate the effective relationship set, no…
dmealing Sep 13, 2026
c09f4dc
fix(metadata,codegen-ts,docs): enumerate references instead of taking…
dmealing Sep 13, 2026
d49c3d9
fix(loader): rule (d) must also iterate the effective relationship se…
dmealing Sep 13, 2026
b1afdd9
fix(codegen-ts): stop discarding the @via hop's own name before the #…
dmealing Sep 13, 2026
7fab449
fix(loader,python): 1:N reference resolution parity (#368)
dmealing Sep 13, 2026
28d9223
fix(loader,csharp): 1:N reference resolution parity (#368)
dmealing Sep 13, 2026
441e4b4
fix(loader,java): 1:N reference resolution parity (#368)
dmealing Sep 13, 2026
6f1ee53
fix(loader,java): #368 fix round 1 -- cardinality raw-read, falsifiab…
dmealing Sep 13, 2026
48544b7
test(#368): backfill order-dependence + sibling-isolation regressions
dmealing Sep 13, 2026
90393ca
fix(migrate-ts,csharp): #368 round 2 -- referential-action correlatio…
dmealing Sep 13, 2026
895e787
test(conformance): 1:N reference disambiguation fixtures + sourceRefF…
dmealing Sep 14, 2026
9db0ce5
docs: ADR-0029 Amendment 1 + changelog for #368
dmealing Sep 14, 2026
45667f2
docs(relationship): extend @sourceRefField rules prose for #368 dual …
dmealing Sep 14, 2026
0aa3cb9
fix(codegen-ts): #368 round 2 -- the join-hop ambiguity message recom…
dmealing Sep 14, 2026
f78cc95
fix(codegen-ts): #368 round 2 fix-round-1 -- the join-hop message's @…
dmealing Sep 14, 2026
308e50f
docs: update conformance fixture count to 328
dmealing Sep 14, 2026
423959e
fix(python): #368 — the ladder was blind to the dotted @references form
dmealing Sep 14, 2026
bb4cac2
fix(codegen-kotlin): #368 — port the referential-action correlation fix
dmealing Sep 14, 2026
3937057
fix(codegen-ts): #368 — the origin.first refusal broke a legal mutual…
dmealing Sep 14, 2026
6b47b8d
test(runtime-ts): #368 — pin @sourceRefField at both relation-resolve…
dmealing Sep 14, 2026
53edf3f
fix(loader): #368 — rule (d)'s message names both legal homes; correc…
dmealing Sep 14, 2026
e2b32f8
docs: #368 — correct the Kotlin claim, add the rule-(e) zero-candidat…
dmealing Sep 14, 2026
7edee57
docs(conformance): register the dotted-@references fixture in relatio…
dmealing Sep 14, 2026
7dafb05
docs(changelog): three limits are documented, not two
dmealing Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/` (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.
Expand Down Expand Up @@ -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 `<Name>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.)
Expand Down
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,62 @@ 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`, 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
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. 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
`@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<MetaDataException>` 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
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
Expand All @@ -105,6 +161,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

Expand Down
6 changes: 3 additions & 3 deletions docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l`.

| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
|---|---|---|---|---|---|---|
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 325 | ✓ | ✓ | 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 | ✓ | — | — | — | ✓ |
Expand Down Expand Up @@ -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 (329)

| Fixture prefix | Feature doc |
|---|---|
Expand Down Expand Up @@ -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 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
Expand Down
Loading
Loading