Skip to content

Commit 78fbf90

Browse files
dmealingclaude
andcommitted
feat(metamodel): value objects may carry navigation-only references (ADR-0046, #238)
A non-persisted shape (API DTO, event payload, wire-protocol message) can now declare that a field references an entity, via the chartered navigation-only reference form: an `object.value` may carry an `identity.reference` with explicit `@enforce: false`. The loader resolves the `@references` target (dangling → ERR_INVALID_REFERENCE) and codegen emits no FK/DDL; a value's own identity (primary/secondary) and any enforced reference stay banned (ERR_SUBTYPE_RULE_VIOLATION). Zero new registry vocabulary — a child-licensing relaxation (ADR-0037 step 0), so registry-conformance is unaffected and existing `meta gen` output is byte-identical. Also closes the M:N junction guard this unmasks: value purity used to implicitly guarantee a `@through` junction was an entity (a value couldn't hold references); the `@through` validation now explicitly requires the junction resolve to an object.entity (ERR_INVALID_RELATIONSHIP otherwise) — a junction is a physical join table. Cross-port loader change (TS/Java/Python/C#; Kotlin inherits the JVM loader), gated by four new shared conformance fixtures. All four ports green on the metamodel conformance corpus (TS 498, Java 498, C# 816, Python 253). Surfaced by a reference-impl consumer modeling a wire protocol as MetaObjects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014xy8powhHYJ6gfFt9Ut8dL
1 parent 96af847 commit 78fbf90

18 files changed

Lines changed: 629 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,37 @@ directional prompt-construction data-flow diagram (npm-only; `docs-site` + `cli`
2727
Additive: markdown surfaces and non-pipeline site output are unchanged. Gated by the
2828
`docs-site` golden (its AI fixture now exercises the full pipeline) + a link-graph unit test.
2929

30+
### Added — value objects may carry navigation-only references (ADR-0046, #238)
31+
32+
A non-persisted shape — an API DTO, an event payload, a wire-protocol command message —
33+
can now declare that a field references an entity, using the chartered navigation-only
34+
reference form. Previously value purity (ADR-0028) banned **all** identity/reference
35+
constructs on `object.value`, so a message carrying `tableId` had no way to say it
36+
references `Table`.
37+
38+
- An `object.value` may now carry an `identity.reference` child **when it declares
39+
explicit `@enforce: false`** (the already-chartered logical-reference form). The loader
40+
resolves its `@references` target exactly as on an entity — a dangling target fails the
41+
load with `ERR_INVALID_REFERENCE` — and codegen emits **no** FK/DDL (a value has no
42+
table). A value's own identity (`identity.primary`/`identity.secondary`) and any
43+
enforced reference stay banned (`ERR_SUBTYPE_RULE_VIOLATION`).
44+
- **Zero new vocabulary** — a child-licensing relaxation (ADR-0037 step 0), not an
45+
expansion; `registry-conformance` is unaffected. The value↔entity dichotomy just gained
46+
the "referencing DTO" it lacked without a new subtype.
47+
- **M:N junction guard made explicit.** Value purity used to *implicitly* guarantee that an
48+
M:N `@through` junction was an `object.entity` (a value couldn't hold the two required
49+
`identity.reference` children). Allowing navigation-only references on values removes that
50+
implicit guard, so the `@through` validation now explicitly requires the junction resolve
51+
to an `object.entity` (`ERR_INVALID_RELATIONSHIP` otherwise) — a junction is a physical
52+
join table.
53+
54+
Additive: no existing metadata triggers the relaxed rule, so `meta gen` output is
55+
byte-identical. This is a **cross-port loader change** (TS / Java / Python / C#; Kotlin
56+
inherits the JVM loader), gated by four new shared conformance fixtures
57+
(`value-reference-navigation-only` loads + resolves; `error-value-reference-enforced`,
58+
`error-value-reference-unresolved`, and `error-m2m-through-value` fail). See
59+
[ADR-0046](spec/decisions/ADR-0046-value-navigation-only-references.md).
60+
3061
## [0.20.2] — 2026-07-25
3162

3263
**npm `0.20.2` only** (NuGet `0.19.3` / PyPI `0.19.5` / Maven Central `7.11.3` unchanged — no changed product file; D1 is a TS-only dialect). A bug-fix patch, no API or vocabulary change.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ _Last refreshed 2026-07-25._
3030
- **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.
3131

3232
**Cross-port conformance corpora** (every port runs the shared corpus):
33-
- Metamodel: `fixtures/conformance/` (249 fixtures; 19 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
33+
- Metamodel: `fixtures/conformance/` (253 fixtures; 19 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
3434
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
3535
- 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".)
3636
- 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.

docs/CONFORMANCE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l`.
2525

2626
| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
2727
|---|---|---|---|---|---|---|
28-
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 249 ||| inherits via `metadata-ktx` |||
28+
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 253 ||| inherits via `metadata-ktx` |||
2929
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 15 | 15 / 15 | 14 / 15 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 14 / 15 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 15 / 15 |
3030
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 ||| inherits via Java |||
3131
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 ||||||
@@ -69,7 +69,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.
6969

7070
## Fixture-to-doc mapping
7171

72-
### `fixtures/conformance/` — metamodel loader + canonical serializer (249)
72+
### `fixtures/conformance/` — metamodel loader + canonical serializer (253)
7373

7474
| Fixture prefix | Feature doc |
7575
|---|---|
@@ -139,7 +139,7 @@ the port's own GENERATED API artifact booted over HTTP.
139139

140140
## Orphaned fixtures (tested but not yet documented)
141141

142-
The fixtures in the six corpora mapped above (metamodel 249 + yaml 15 + verify 31
142+
The fixtures in the six corpora mapped above (metamodel 253 + yaml 15 + verify 31
143143
+ render 15 + persistence 30 + api-contract 41) each map to a feature doc. None
144144
are orphaned today. The remaining corpora in the totals table gate tooling
145145
contracts (registry manifests, provider composition, agent context, docs emit)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"errors": [
3+
{
4+
"code": "ERR_INVALID_RELATIONSHIP",
5+
"source": {
6+
"format": "json",
7+
"files": [
8+
"meta.commerce.json"
9+
],
10+
"jsonPath": "$['metadata.root'].children[0]['object.entity'].children[1]['relationship.association']"
11+
}
12+
}
13+
],
14+
"warnings": []
15+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme::commerce",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Post",
8+
"children": [
9+
{ "field.long": { "name": "id" } },
10+
{
11+
"relationship.association": {
12+
"name": "tags",
13+
"@cardinality": "many",
14+
"@objectRef": "acme::commerce::Tag",
15+
"@through": "PostTag"
16+
}
17+
},
18+
{ "identity.primary": { "name": "id", "@fields": "id" } }
19+
]
20+
}
21+
},
22+
{
23+
"object.entity": {
24+
"name": "Tag",
25+
"children": [
26+
{ "field.long": { "name": "id" } },
27+
{ "identity.primary": { "name": "id", "@fields": "id" } }
28+
]
29+
}
30+
},
31+
{
32+
"object.value": {
33+
"name": "PostTag",
34+
"children": [
35+
{ "field.long": { "name": "postId" } },
36+
{ "field.long": { "name": "tagId" } },
37+
{ "identity.reference": { "name": "postRef", "@fields": "postId", "@references": "acme::commerce::Post", "@enforce": false } },
38+
{ "identity.reference": { "name": "tagRef", "@fields": "tagId", "@references": "acme::commerce::Tag", "@enforce": false } }
39+
]
40+
}
41+
}
42+
]
43+
}
44+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"errors": [
3+
{
4+
"code": "ERR_SUBTYPE_RULE_VIOLATION",
5+
"source": {
6+
"format": "json",
7+
"files": [
8+
"meta.poker.json"
9+
],
10+
"jsonPath": "$['metadata.root'].children[1]['object.value'].children[1]['identity.reference']"
11+
}
12+
}
13+
],
14+
"warnings": []
15+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"metadata.root": {
3+
"package": "gaming::poker",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Table",
8+
"children": [
9+
{ "field.uuid": { "name": "id" } },
10+
{ "field.string": { "name": "label" } },
11+
{ "identity.primary": { "name": "id", "@fields": ["id"] } }
12+
]
13+
}
14+
},
15+
{
16+
"object.value": {
17+
"name": "AskForStake",
18+
"children": [
19+
{ "field.uuid": { "name": "tableId" } },
20+
{
21+
"identity.reference": {
22+
"name": "tableRef",
23+
"@fields": ["tableId"],
24+
"@references": "Table"
25+
}
26+
}
27+
]
28+
}
29+
}
30+
]
31+
}
32+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"errors": [
3+
{
4+
"code": "ERR_INVALID_REFERENCE",
5+
"source": {
6+
"format": "json",
7+
"files": [
8+
"meta.poker.json"
9+
],
10+
"jsonPath": "$['metadata.root'].children[0]['object.value'].children[1]['identity.reference']"
11+
}
12+
}
13+
],
14+
"warnings": []
15+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"metadata.root": {
3+
"package": "gaming::poker",
4+
"children": [
5+
{
6+
"object.value": {
7+
"name": "AskForStake",
8+
"children": [
9+
{ "field.uuid": { "name": "tableId" } },
10+
{
11+
"identity.reference": {
12+
"name": "tableRef",
13+
"@fields": ["tableId"],
14+
"@references": "NoSuchTable",
15+
"@enforce": false
16+
}
17+
}
18+
]
19+
}
20+
}
21+
]
22+
}
23+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"metadata.root": {
3+
"package": "gaming::poker",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Table",
8+
"children": [
9+
{
10+
"field.uuid": {
11+
"name": "id"
12+
}
13+
},
14+
{
15+
"field.string": {
16+
"name": "label"
17+
}
18+
},
19+
{
20+
"identity.primary": {
21+
"name": "id",
22+
"@fields": [
23+
"id"
24+
]
25+
}
26+
}
27+
]
28+
}
29+
},
30+
{
31+
"object.value": {
32+
"name": "AskForStake",
33+
"children": [
34+
{
35+
"field.uuid": {
36+
"name": "tableId"
37+
}
38+
},
39+
{
40+
"field.long": {
41+
"name": "amount"
42+
}
43+
},
44+
{
45+
"identity.reference": {
46+
"name": "tableRef",
47+
"@enforce": false,
48+
"@fields": [
49+
"tableId"
50+
],
51+
"@references": "Table"
52+
}
53+
}
54+
]
55+
}
56+
}
57+
]
58+
}
59+
}

0 commit comments

Comments
 (0)