Skip to content

Commit dbd7b86

Browse files
committed
Merge branch 'worktree-agent-a7158261af3b52cc6'
# Conflicts: # CHANGELOG.md
2 parents 976cd59 + c2bc4e2 commit dbd7b86

37 files changed

Lines changed: 1412 additions & 115 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 70 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,76 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Fixed — parent-side `relationship.composition` reaches the child's FK (migrate-ts; ADR-0047)
11+
12+
> **ADOPTER-VISIBLE MIGRATION — production delete semantics change.** After
13+
> upgrading, the first `meta migrate` against a live database emits a one-time
14+
> migration **adding `ON DELETE CASCADE` (or the relationship's declared/default
15+
> action) to FKs whose parent-side relationship was previously silently
16+
> ignored** — deleting a parent row then deletes its children instead of
17+
> failing. This is the documented semantic the metadata always declared, but it
18+
> was never enforced, so review that migration deliberately; pin
19+
> `@onDelete: "no-action"` on any relationship whose DB behavior you want kept.
20+
> The same applies to a child-side relationship authored with an FQN
21+
> `@objectRef`, which the old exact-string correlation silently missed.
22+
> Give this entry top billing at cut time.
23+
24+
The authoring the docs and the `metaobjects-authoring` skill teach — declaring the
25+
relationship on the PARENT (`relationship.composition { @objectRef: "Post",
26+
@cardinality: "many" }` on `Author`, with the subtype implying the default action) —
27+
contributed **nothing** to the foreign key: `resolveReferentialActions` correlated only
28+
relationships declared on the FK-owning child, so the documented "composition ⇒
29+
`ON DELETE CASCADE` default" never fired and deleting a parent with children was a raw
30+
FK violation at runtime. The resolver now correlates the **reverse relationship on the
31+
target entity** as a third precedence tier (reference-level attr → child-side
32+
relationship → parent-side relationship; each relationship contributing its explicit
33+
action, else its subtype default). Tier-2 correlation is now **package-aware**
34+
(`refMatchesObject` / ADR-0042) and excludes M:N `@through` relationships — an FQN
35+
child-side `@objectRef` previously missed the exact-string match and contributed
36+
nothing (post-fix it would otherwise have been OVERRIDDEN by the parent side), and a
37+
`@through` relationship wrongly armed cascade on a sibling direct FK to the same
38+
target. Guards on the new reverse tier, each failing closed: the M:N exclusion; a
39+
reverse relationship contributes nothing unless the child holds exactly one enforced
40+
reference to the target (it cannot say which FK carries the ownership edge); and an
41+
INFERRED set-null default (parent-side aggregation, no explicit `@onDelete`) on a NOT
42+
NULL FK drops its inferred contributions rather than turning a previously-valid model
43+
into a hard `SetNullNotNullableError` — while an explicitly authored `set-null` still
44+
errors loudly and an explicitly authored `@onUpdate` on that same relationship is
45+
still honored. Models whose FKs already resolved an action are byte-identical; the
46+
two intentional output changes both restore declared intent that was silently
47+
dropped. The Kotlin Exposed table generator's
48+
decorated `identity.reference` columns now resolve actions with the same precedence
49+
(they previously emitted raw relationship attrs only, and the canonical
50+
parent-relationship + child-reference shape lost the action entirely through the
51+
FK-dedup pass).
52+
53+
### Added — `@onDelete` / `@onUpdate` registered on `identity.reference` (all five ports; ADR-0047)
54+
55+
The migrate engine has long honored `@onDelete` / `@onUpdate` declared directly on
56+
`identity.reference` (highest precedence — the reference IS the FK), and
57+
`docs/features/relationships.md`'s own canonical example authored it — but the attrs
58+
were never registered, so a model `meta migrate` accepted and applied **failed strict
59+
`meta verify` outright** with `ERR_UNKNOWN_ATTR`. Those two must never disagree.
60+
[ADR-0047](spec/decisions/ADR-0047-referential-actions-on-identity-reference.md)
61+
rules the attrs REGISTERED (db-provider, like `@constraintName`; optional string,
62+
allowedValues `cascade | set-null | restrict | no-action`) in all five ports +
63+
`expected-registry.json`, with the written can't-be-computed justification ADR-0023
64+
requires: a reference-only FK (no relationship) and an M:N junction's FK sides have
65+
NO existing metadata implying any action, and the per-FK override is information the
66+
relationship layer does not carry. Recommended authoring stays relationship-level.
67+
Consequences: the legacy `"setnull"` alias is retired — `allowedValues` is enforced
68+
unconditionally at load, so it now fails BOTH migrate and verify with
69+
`ERR_BAD_ATTR_VALUE` (it was only ever reachable through the unregistered-attr hole);
70+
Java's `ReferenceIdentity` regains the accessors SP-G Unit 6a removed; and the JVM
71+
validation pass extends its referential-action value check to `identity.reference`.
72+
Gated by a new shared conformance fixture (`identity-reference-referential-actions`,
73+
strict-loaded by every port) exercising BOTH previously-untested shapes — the
74+
parent-side `many` composition relying on its subtype default, and the
75+
reference-level attrs — plus migrate emit tests asserting the action lands in the
76+
DDL, and the migrate referential-action test corpus now loads `strict: true`, pinning
77+
the durable invariant that **any model `meta migrate` accepts must load under strict
78+
`meta verify`**.
79+
1080
### Fixed — `meta gen` emitted duplicate imports under a split dependency tree (npm: `codegen-ts` + `cli`)
1181

1282
With a **globally-installed or linked `meta` CLI** and a project-local ts-poet (which
@@ -83,48 +153,6 @@ READMEs, `README.md`, `CLAUDE.md`, `docs/ports/typescript-client.md`,
83153
sweep the pair into lockstep), and the `SOURCE_ONLY` rationale in
84154
`scripts/check-publish-intent.sh`.
85155

86-
||||||| ba88c0d9
87-
88-
### Fixed — the `like` filter operator is case-sensitive SQL LIKE, uniformly (ADR-0049)
89-
90-
Resolves the 0.21.5 known-issue: cross-port `like` semantics contradicted each other and
91-
the shared corpora were case-aligned **by construction**, so they could not see it. The
92-
ruling ([ADR-0049](spec/decisions/ADR-0049-filter-like-is-case-sensitive-sql-like.md)):
93-
`like` binds the author-supplied pattern **verbatim** — `%`/`_` wildcards, no case
94-
folding — identically on every port and engine. The written record already said so
95-
(FR-009 defines `like` as "SQL LIKE semantics" and lists case-insensitive `ilike` as
96-
explicitly out of scope; ADR-0036 reserves `ilike` as a future additive operator), and
97-
the reference implementation was not univocal the other way: TS's Kysely/Drizzle/
98-
in-memory persistence drivers were always verbatim LIKE — only the HTTP filter parser
99-
dispatched ILIKE, so the TS reference api-contract lane and the TS generated lane
100-
answered the same request differently.
101-
102-
- **TS (`runtime-ts`, adopter-visible on Postgres)**: `parseFilterParams` no longer
103-
lowers `like` to ILIKE on Postgres — every Fastify/Hono mount (CRUD + read-only) now
104-
case-sensitive. On **sqlite/D1**, where the engine's native LIKE folds ASCII case,
105-
`like` lowers to **GLOB** with an exactly-translated pattern (`%`→`*`, `_`→`?`,
106-
GLOB's own metacharacters escaped), proven against a real sqlite engine — behavior,
107-
not SQL spelling, is the contract. The TS-only `?search` extension stays deliberately
108-
case-INSENSITIVE (it is a human search box, and is now documented as such).
109-
- **C# (test-only)**: the persistence-conformance adapter reflected Npgsql's `ILike`
110-
while the product ships `EF.Functions.Like` — the gate was testing semantics the
111-
product never had. The adapter now runs the product's dispatch. No C# product change.
112-
- **Java / Kotlin / Python**: already conformant (Java since 0.21.4's
113-
`Expression.LIKE`); no product change.
114-
- **Corpora de-blinded** (the actual gate): `filter-like-and-ne`
115-
(persistence-conformance) now seeds a case-mismatched pair
116-
(`Foundations`/`foundations lab`) and probes both casings — each prefix probe must
117-
match exactly one; `filter-like` (api-contract-conformance) adds lowercase `a%` and
118-
`a_an%` probes that must match nothing against the capitalized seed. Before the port
119-
fixes, the de-blinded corpus failed the TS generated lane (returned `[1, 2]` for
120-
`a%` on real Postgres) and the C# persistence lane (`title-like-prefix` matched both
121-
casings) — and passes all five ports after. The "case-aligned so the test passes
122-
either way" comment is deleted.
123-
124-
Adopters relying on the old TS-on-Postgres case-folding: normalize case in the data or
125-
the pattern, or use `?search`; a true case-insensitive operator remains chartered as a
126-
future additive extension gated on real consumer demand.
127-
128156
## [0.21.5] — npm `0.21.5` · PyPI `0.21.5` · NuGet `0.21.5` · Maven `7.21.5`
129157

130158
A coordinated PATCH across all four registries. Ten fixes, every one reproduced before

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ _Last refreshed 2026-08-09._
4040
- **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.
4141

4242
**Cross-port conformance corpora** (every port runs the shared corpus):
43-
- Metamodel: `fixtures/conformance/` (270 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.
43+
- Metamodel: `fixtures/conformance/` (271 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.
4444
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
4545
- 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".)
4646
- 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.

‎agent-context/skills/metaobjects-authoring/SKILL.md‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -548,8 +548,13 @@ The `[]` key-suffix declares an array field: `field.long[]: weekIds` lowers to
548548
canonical JSON. `@enforce` on a reference (default `true`) controls whether the
549549
backend physically enforces it (a SQL FK constraint); set `false` for a logical
550550
reference for navigation/typing/codegen only. Referential actions
551-
(`@onDelete`/`@onUpdate`) are NOT on `identity.reference` — they live on the
552-
`relationship.*` node (see Relationships below).
551+
(`@onDelete`/`@onUpdate`) normally live on the `relationship.*` node (see
552+
Relationships below — the subtype carries the default), but `identity.reference`
553+
also registers them as the **explicit per-FK override** (ADR-0047): use them for
554+
a reference-only FK with no relationship, an M:N junction's FK sides (no
555+
relationship ever correlates with a junction FK), or a single FK that must
556+
deviate from its relationship's action. A reference-level action always wins
557+
over the correlated relationship's.
553558

554559
`@references` resolves cross-package by **fully-qualified name**
555560
(`@references: "shared::billing::Account"`), the same rule as `extends`; a bare
@@ -658,6 +663,11 @@ The `PostTag` junction supplies the FK direction via its two references:
658663
] } }
659664
```
660665

666+
A junction's FK actions are declared on its `identity.reference` children
667+
directly (`"@onDelete": "cascade"` on `postRef`/`tagRef` above) — the M:N
668+
relationship's `@objectRef` names the far side, never the junction, so no
669+
relationship ever correlates with a junction FK (ADR-0047).
670+
661671
**Adoption footgun — pin BOTH actions.** `@onDelete` defaults *per subtype* (above)
662672
and `@onUpdate` defaults to `cascade` — but a plain SQL foreign key is `NO ACTION` on
663673
both. If you're adopting an existing database (matching metadata to a live schema),

‎agent-context/skills/metaobjects-verify/references/migration.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,11 @@ schema without a rewrite:
245245
- **`identity.reference @constraintName`** pins a foreign-key constraint name so the
246246
metadata can match an existing DB's naming convention without a destructive
247247
rename.
248+
- **`identity.reference @onDelete` / `@onUpdate`** pin a foreign key's referential
249+
actions directly on the FK (ADR-0047) — the way to match an adopted DB's existing
250+
`ON DELETE`/`ON UPDATE` behavior when the model declares no `relationship.*` for
251+
that FK (a relationship's explicit action or subtype default otherwise supplies
252+
it, whichever side of the FK declares the relationship).
248253

249254
## Not yet shipped
250255

‎docs/CONFORMANCE.md‎

Lines changed: 2 additions & 2 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) | 270 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
28+
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 271 | ✓ | ✓ | 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 (270)
72+
### `fixtures/conformance/` — metamodel loader + canonical serializer (271)
7373

7474
| Fixture prefix | Feature doc |
7575
|---|---|

‎docs/features/relationships.md‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,42 @@ metadata:
102102
| `@cardinality` | `relationship.composition` | `one` / `many` | Multiplicity on the target side |
103103
| `@fields` | `identity.reference` | One field name or array | The FK column(s) on this entity |
104104
| `@references` | `identity.reference` | Entity name | The target entity (PK on the other side) |
105-
| `@onDelete` | `relationship.*` only | `cascade` / `set-null` / `restrict` / `no-action` | RDB referential action. Default derives from the subtype: composition -> `cascade`, aggregation -> `set-null`, association -> `restrict`. |
106-
| `@onUpdate` | `relationship.*` only | same as `@onDelete` (default `cascade`) | RDB referential action |
105+
| `@onDelete` | `relationship.*` and `identity.reference` | `cascade` / `set-null` / `restrict` / `no-action` | RDB referential action. Default derives from the relationship subtype: composition -> `cascade`, aggregation -> `set-null`, association -> `restrict`. |
106+
| `@onUpdate` | `relationship.*` and `identity.reference` | same as `@onDelete` (default `cascade` when a relationship correlates) | RDB referential action |
107107

108108
`@onDelete` / `@onUpdate` are registered on `relationship.association` /
109-
`aggregation` / `composition`. They are **not** attributes of `identity.reference` —
110-
putting one there fails load with `ERR_UNKNOWN_ATTR`. Declare the action on the
111-
relationship; the reference stays a pure FK declaration (`@fields`, `@references`,
112-
`@constraintName`, `@enforce`).
109+
`aggregation` / `composition` **and** — as the explicit per-FK override
110+
([ADR-0047](../../spec/decisions/ADR-0047-referential-actions-on-identity-reference.md))
111+
— on `identity.reference` itself. Resolution precedence for the FK's emitted
112+
action, highest first:
113+
114+
1. `@onDelete` / `@onUpdate` declared directly on the `identity.reference` —
115+
the reference IS the FK, so the action may be declared right where the FK
116+
is. This is the only way to put an action on a **reference-only FK** (no
117+
relationship node) or on an **M:N junction's** FK sides (no relationship
118+
ever correlates with a junction FK).
119+
2. A relationship declared on the FK-owning (child) entity targeting the
120+
referenced entity (matched package-aware, so bare and FQN spellings pair
121+
correctly; an M:N `@through` relationship never correlates with a direct
122+
FK): its explicit action, else its subtype default.
123+
3. A relationship declared on the **referenced (parent) entity** pointing back
124+
at the FK-owning entity — the canonical parent-side authoring above
125+
(`Author` declares `posts`): its explicit action, else its subtype default.
126+
Guards: the M:N exclusion again; when the child holds more than one FK to
127+
the same parent the parent-side relationship contributes to none of them
128+
(it cannot say which FK carries the ownership edge); and an inferred
129+
set-null default (aggregation, no explicit `@onDelete`) on a NOT NULL FK
130+
contributes nothing — SET NULL cannot fire there (an explicit `set-null`
131+
instead fails migration generation loudly, telling you to make the FK
132+
nullable).
133+
4. None of the above → the FK is emitted with no `ON DELETE` / `ON UPDATE`
134+
clause (SQL `NO ACTION`).
135+
136+
Prefer declaring the action on the relationship (the subtype carries the
137+
semantics and the default); reach for the reference-level attr only when no
138+
relationship exists or a single FK needs to deviate. The values are
139+
load-validated everywhere — a misspelling (e.g. the retired `setnull` alias)
140+
fails load with `ERR_BAD_ATTR_VALUE`.
113141

114142
The kebab-case metamodel values map to the SCREAMING_SNAKE forms in Exposed / EF
115143
Core / the target ORM or DDL at codegen time.
@@ -248,6 +276,7 @@ The following conformance fixtures gate this feature's behavior across ports:
248276
- [`fixtures/conformance/relationship-one-to-many/`](../../fixtures/conformance/relationship-one-to-many/) — `relationship.composition` 1:N with the parent owning the collection
249277
- [`fixtures/conformance/identity-reference-simple/`](../../fixtures/conformance/identity-reference-simple/) — `identity.reference` declares the FK column-set on the child
250278
- [`fixtures/conformance/source-rdb-referential-actions/`](../../fixtures/conformance/source-rdb-referential-actions/) — `@onDelete` / `@onUpdate` on relationships
279+
- [`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)
251280
- [`fixtures/conformance/error-unknown-relationship-subtype/`](../../fixtures/conformance/error-unknown-relationship-subtype/) — unknown `relationship.<subtype>` rejected
252281

253282
Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these

0 commit comments

Comments
 (0)