Skip to content

Commit 1f47ce6

Browse files
dmealingclaude
andcommitted
test(api-contract): gate writeOnce over HTTP in all five ports, both lanes
FR-037 R1's acceptance half. The shared Author entity gains an `@mutability: "writeOnce"` column (issuedCurrency) and a `writeonce-patch-stripped` scenario: POST sets it, a later PATCH carrying a NEW value returns 200 with the STORED value unchanged, and a present-null does not clear it. Every port runs it in BOTH lanes — the hand-rolled reference server and the generated artifact booted over HTTP. IT IMMEDIATELY FOUND TWO REAL DEFECTS, both the same shape, and both invisible to the unit tests that already passed. This is ADR-0045 exactly: the OUTERMOST generated write artifact enforces the mode, and in two ports that artifact is not the one I had fixed. PYTHON — excluding writeOnce from <Entity>Patch was NOT enough. The generated FastAPI handler binds `dto: dict[str, Any]` (the RAW body), validates it against the patch model, then hands the raw dict to the repository — so a field merely absent from the model still reached the row. @autoset survives this only because the router OVERWRITES its key server-side; writeOnce has no server value to clobber. Fixed by emitting a _FROZEN_FIELDS strip in the router, vanilla and TPH. C# — SetAfterSaveBehavior(Ignore) governs EF's SAVE, but the merge loop assigns entry.CurrentValues[target] and the handler returns the in-memory `existing` entity, so a caller-supplied value was echoed in the RESPONSE even where EF omitted the column from the UPDATE. Fixed by skipping the key in the merge loop itself, exactly as @autoset is skipped — vanilla and the TPH per-subtype route. Both were caught by the generated lane and would have shipped otherwise. The reference servers needed no strip in Python, C#, Java or Kotlin: each builds its UPDATE from an explicit allowlist, so an unlisted column is excluded by construction. Each now says so at the omission site, because the failure mode is a future reader "helpfully" adding the missing branch. TS is the exception — its reference destructures the body — so it drops the key the same way it already drops @autoset:onCreate. r3 (the present-null arm) deliberately carries a readWrite field alongside the null. Sending the null ALONE would also probe the empty-PATCH contract, which this corpus has never settled cross-port — TS no-ops and returns the row, the C# and Java references 400 on an empty SET — and settling that is not this scenario's business. The scenario says so inline so the next reader does not "simplify" it. Verified per port, both lanes, 27 scenarios each: TS 27+27 · C# 27+27 · Java 27 + BUILD SUCCESS · Kotlin 27+27 · Python 72 (both lanes together). Python unit 1723 · C# four Passed! lines 0 error CS · TS typecheck clean · ci-local.sh --only gates PASSED. Also lands the batch close-out: - CHANGELOG [0.24.0] — the ⚠️ callout is EXTENDED to name all four retirements (A template direction, B requirement vocabulary, C @mutability, D origin.collection) rather than gaining a second callout, plus a section each for U1/U2/U3 carrying the reasoning, not just the rewrite. - docs/features/migrations/readonly-to-mutability.md — the U3 guide, leading with the measured zero adopter cost and the writeOnce-on-an-assigned-key asymmetry that justifies the enum. - spec/roadmap.md — the breaking window is marked SHIPPED with a per-unit table, and the "open tension" paragraph is RESOLVED in place (batch first, then 1.0) rather than deleted, so the decision is legible. - docs/1.0-readiness.md — §G3 quiet-period clock RESET to 0.24.0, stated as an adjudicated cost with the reasoning, because the failure mode is a later reader finding the reset and treating it as an accident. Remaining for FR-037 R1: a TPH-flavoured writeOnce scenario. Every port's TPH code path is already patched and stated (C# AppendTphSubtypeRoutes, Java's settableFields TPH overload, Python's TPH router branch, Kotlin's TPH controller) — but patched is not gated, and an ungated TPH path is the precise 0.19.4 failure mode. Tracked as the last acceptance item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
1 parent 07fcf77 commit 1f47ce6

19 files changed

Lines changed: 753 additions & 78 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 189 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,23 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
99

1010
## [0.24.0] — npm `0.24.0` · PyPI `0.24.0` · NuGet `0.24.0` · Maven `7.24.0`
1111

12-
> ### ⚠️ BREAKING FOR METADATA AUTHORS — a template subtype's axis is DIRECTION
12+
> ### ⚠️ BREAKING FOR METADATA AUTHORS — four vocabulary retirements in ONE window
1313
>
1414
> This is the pre-1.0 breaking slot (MINOR), not a patch, **specifically so it is not
1515
> auto-adopted**: on a caret range `^0.23.x` resolves `<0.24.0`, so you pick this up only by
16-
> deliberately bumping your range. Read
17-
> **[the migration guide](docs/features/migrations/template-direction-outbound-vs-inbound.md)**
18-
> before upgrading — it carries the exact loader error and a rewrite rule for each change.
16+
> deliberately bumping your range.
17+
>
18+
> **Everything breaking rides this one release on purpose.** Under ADR-0023's sealed strict
19+
> registry a retirement has no deprecation shim — a legacy model fails to LOAD — so every one
20+
> of these is a migration you must perform. Two breaking MINORs back to back would mean two
21+
> migrations for work that was budgeted as one. Hence: one window, four retirements, one
22+
> `metamodelVersion` move.
23+
>
24+
> Read the guide for each change you are affected by; each carries the exact loader error and
25+
> a rewrite rule.
26+
>
27+
> **A. A template subtype's axis is DIRECTION** —
28+
> [migration guide](docs/features/migrations/template-direction-outbound-vs-inbound.md)
1929
>
2030
> 1. **A `@promptStyle` left on a `template.output` now fails the LOAD**
2131
> (`ERR_INVALID_TEMPLATE`) — it is prompt-only vocabulary, as is the new
@@ -31,17 +41,183 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
3141
> Java and Python checked only `@payloadRef`, so the same metadata failed one port's load
3242
> and passed four.
3343
>
44+
> **B. The requirement vocabulary becomes PRESCRIPTIVE-ONLY** —
45+
> [migration guide](docs/features/migrations/verified-by-retirement.md)
46+
>
47+
> `@verifiedBy` and `@supersededBy` are deregistered (`ERR_UNKNOWN_ATTR`), and `@status`
48+
> shrinks to **`planned | live | partial`** — `abandoned` and `superseded` now fail
49+
> `ERR_BAD_ATTR_VALUE`. A requirement states what SHOULD be true; it is not a journal of what
50+
> happened. **Retiring a capability is now DELETION of its requirement**, and the guide says
51+
> where the record goes — including the hard case where no sibling survives to carry it.
52+
>
53+
> **C. `@readOnly` becomes the `@mutability` enum** —
54+
> [migration guide](docs/features/migrations/readonly-to-mutability.md)
55+
>
56+
> `@mutability: readWrite | writeOnce | readOnly` replaces the boolean on every `field.*`
57+
> subtype. `@readOnly: true` → `@mutability: "readOnly"`; `@readOnly: false` → delete it.
58+
> The point is the new middle mode: **`writeOnce` — set on create, frozen after** — which the
59+
> boolean could not express at all, and which an assigned primary key has always wanted.
60+
>
61+
> **D. `origin.collection` is retired to reserved-not-registered** —
62+
> [migration guide](docs/features/migrations/origin-collection-retirement.md)
63+
>
64+
> Any use now fails `ERR_UNKNOWN_SUBTYPE`. It duplicated `origin.aggregate @agg: collect` on
65+
> a strictly smaller attr set and **nothing ever dispatched on it** — so unless you declared
66+
> it, nothing changes, and if you did, deleting the child changes no generated output.
67+
>
3468
> **This release also moves `metamodelVersion`, `0.9` → `0.10`** — the first time that
3569
> number has ever moved. It is the one that tells you your *metadata* needs work, as
36-
> distinct from your build.
37-
38-
A coordinated **MINOR** across all four registries, and the **pre-1.0 breaking slot**: the
39-
ADR-0052 template-direction split makes previously-valid metadata fail to load. Two further
40-
changes are DEFAULT FLIPS rather than corrections of previously-wrong behaviour — the Java
41-
Maven plugin now fails a build that a silently-empty model used to let pass, and Java and
42-
Python now follow symlinked directories where they previously did not. Pre-1.0, `^0.23.x`
43-
resolves `<0.24.0`, so all three are adopted deliberately while a PATCH would be taken
44-
automatically on a routine update — the same call, for the same reason, as `0.21.0`.
70+
> distinct from your build. One move covers all four retirements.
71+
72+
A coordinated **MINOR** across all four registries, and the **pre-1.0 breaking slot** — the
73+
one release in this cycle where previously-valid metadata is allowed to stop loading. It
74+
carries **four** vocabulary retirements, batched deliberately: the ADR-0052
75+
template-direction split, the requirement vocabulary going prescriptive-only (FR-038), the
76+
`@readOnly` → `@mutability` enum (FR-037 R1), and `origin.collection` (FR-037 R2). Two
77+
further changes are DEFAULT FLIPS rather than corrections of previously-wrong behaviour —
78+
the Java Maven plugin now fails a build that a silently-empty model used to let pass, and
79+
Java and Python now follow symlinked directories where they previously did not. Pre-1.0,
80+
`^0.23.x` resolves `<0.24.0`, so all of it is adopted deliberately while a PATCH would be
81+
taken automatically on a routine update — the same call, for the same reason, as `0.21.0`.
82+
83+
**Why one window.** Under ADR-0023's sealed strict registry a retirement has no deprecation
84+
shim, so each one is a migration an adopter must perform. Two breaking MINORs back to back
85+
means two migrations for changes that were budgeted as one. The cost of batching is paid
86+
elsewhere and stated plainly in `docs/1.0-readiness.md`: the §G3 quiet-period clock resets,
87+
so 1.0 now needs at least one coordinated release after this one with **no**
88+
metamodel-breaking change, to prove the rate actually dropped. That was adjudicated, not
89+
discovered.
90+
91+
### BREAKING — the requirement vocabulary becomes prescriptive-only (FR-038)
92+
93+
**A requirement states what SHOULD be true. It is never a journal of what happened.** Four
94+
pieces of `requirement.*` vocabulary retire on that one rule, on both subtypes, in all five
95+
ports: `@verifiedBy` and `@supersededBy` deregister (`ERR_UNKNOWN_ATTR`), and `@status`
96+
shrinks from five members to three — `planned | live | partial` — so `abandoned` and
97+
`superseded` fail `ERR_BAD_ATTR_VALUE`.
98+
99+
**The ruling was forced by two SHIPPED statements contradicting each other.** The byte-gated
100+
registry justified the dangling-`@implementedBy` exemption on `abandoned`/`superseded`
101+
because those nodes "are meant to be gone, and that is the entry doing its job"; the
102+
authoring guidance said deleting such an entry "destroys the record". Only one could be the
103+
rule.
104+
105+
**The deciding argument is second-order, and it is why this is worth a breaking slot.**
106+
Because `verify` was *silent* on unresolved `@implementedBy` refs for exactly those two
107+
statuses, one adopting estate was found holding **29 references that could never resolve,
108+
across 14 entries**, while `meta verify` reported zero dangling refs — true and incomplete at
109+
the same time. Retiring the statuses deletes that bug class; the exemption is the only thing
110+
that created it.
111+
112+
`@verifiedBy` goes for a different reason: it asked you to name a test, and `verify` then
113+
checked that the **name** occurred somewhere in your test sources — whole-word, any language,
114+
never running anything. It could prove a name existed and never that the named test verified
115+
the claim. Auditing one ledger by hand — 19 named tests — found **4 of 19 did not verify
116+
their claim**: one matched a comment, one a dependency-injection key, one a real test of a
117+
different claim, one a test of the entry's *output* where the claim was about its *source
118+
text*. `verify` reported zero errors throughout. The defect is structural, not a tuning
119+
problem: the author picks the string, so the cheapest way to satisfy the check is to find a
120+
name that already exists.
121+
122+
The `@verifiedBy` scan tier retires with it — `verify.testFiles` in `metaobjects.config.ts`,
123+
and the codes `ERR_REQUIREMENT_TEST_MISSING` / `WARN_REQUIREMENT_TEST_COMMENT_ONLY`. Note the
124+
irony rather than hiding it: **0.23.1 shipped the Failsafe fix and `verify.testFiles` for this
125+
exact scan, days before it is retired.** Nothing replaces the test link yet, deliberately —
126+
the replacement inverts the direction (a generator emits the test *from* the requirement), is
127+
additive, and ships separately. Until then a requirement carries no test link, and **that is
128+
a legitimate declared state**.
129+
130+
Migration cost measured across three estates (262 / 75 / 288 entries): **0, 15 and 88 edits**,
131+
with ~85% landing on one ledger and one estate untouched.
132+
[Migration guide](docs/features/migrations/verified-by-retirement.md) — including the case
133+
adopters ask about most, what to do when a whole subtree retires and no sibling survives to
134+
carry the note.
135+
136+
### BREAKING — `@readOnly` becomes the `@mutability` enum (FR-037 R1)
137+
138+
`@mutability: readWrite | writeOnce | readOnly` replaces the boolean `@readOnly` on every
139+
`field.*` subtype (18 registry entries), registered by the CORE field provider. A legacy
140+
`@readOnly` fails a strict load with `ERR_UNKNOWN_ATTR`; `@readOnly: true` becomes
141+
`@mutability: "readOnly"`, and `@readOnly: false` is simply deleted.
142+
143+
**The change exists because a real modelling need had no expression: "set once on create,
144+
never changed."** An assigned primary key is the clearest case — the caller must supply it
145+
and must never be able to change it, and neither boolean value could say that.
146+
147+
**Why an enum and not a second boolean.** `readOnly` and `writeOnce` are mutually exclusive
148+
modes of ONE axis — who may write, and when. One enum makes the illegal pair
149+
(`readOnly` + `writeOnce`) unrepresentable and gives inheritance a total order,
150+
`readWrite < writeOnce < readOnly`, so "a subtype may only tighten" is an index comparison
151+
over the declaration order rather than a lookup table.
152+
153+
`writeOnce` leaves the UPDATE shape and only the update shape, through each port's existing
154+
excluded-settable-set seam. **A value presented for it on PATCH is STRIPPED, not rejected —
155+
200, not 400.** That was verified rather than assumed: the emitted `<Entity>UpdateSchema` is
156+
a plain `z.object()` (never `.strict()`), Zod drops unknown keys, and the mounted route
157+
`safeParse`s that same schema — the TPH discriminator is additionally hand-stripped after
158+
parse, the same convention. Rejecting would break our own shipped client, because the
159+
generated edit form submits EVERY registered field (0.19.2 switched its resolver to
160+
`UpdateSchema` on edit rather than diff-and-omit), so 400-on-present would fail every save on
161+
every generated edit form for an entity carrying one.
162+
163+
Three loader rules come with it, all five ports:
164+
`ERR_MUTABILITY_AUTOSET_CONFLICT` (new — `@autoSet` with a non-`readWrite` mode; the boolean
165+
era left `readOnly` × `@autoSet` representable but **unvalidated**),
166+
`ERR_MUTABILITY_DOWNGRADE` (renamed from `ERR_READONLY_DOWNGRADE`, because a code named
167+
READONLY would misdescribe a `writeOnce → readWrite` loosening), and
168+
`ERR_READONLY_ASSIGNED_PRIMARY`, which **keeps** its name — the condition is genuinely
169+
readOnly-specific, and the asymmetry is the enum's justification: `writeOnce` on an assigned
170+
primary key is legal and is in fact the natural declaration for one. Two warnings replace
171+
their boolean-era predecessors: `WARN_MUTABILITY_VALUE_OBJECT` and the new
172+
`WARN_MUTABILITY_READONLY_HOST`.
173+
174+
**Adopter cost: measured at zero.** Across three estates — 826, 8 and 75 metadata files,
175+
**14,860 `field.*` nodes** — `@readOnly` is used zero times, by two independent counting
176+
methods with controls confirming the method finds `@autoSet` and `identity.primary` in the
177+
same trees. `ERR_MUTABILITY_AUTOSET_CONFLICT` likewise has no existing instance to migrate;
178+
it is purely forward-looking, and "we found zero" is a different claim from "we did not
179+
look." The attribute's heaviest use was this repository's own conformance corpus, which is
180+
therefore also the only thing that exercises the three modes — so the corpus gained fixtures
181+
for `writeOnce`, which previously had no coverage at all.
182+
183+
**Two real defects were caught by the api-contract corpus, not by unit tests**, and both are
184+
the same shape — the ADR-0045 rule that the OUTERMOST generated write artifact enforces the
185+
mode. Excluding `writeOnce` from the Python `<Entity>Patch` model was NOT enough: the
186+
generated FastAPI handler binds `dto: dict[str, Any]` (the raw body) and passes THAT to the
187+
repository, so the field still reached the row. (`@autoSet` survives this only because the
188+
router *overwrites* its key server-side; `writeOnce` has no server value to clobber.) The C#
189+
generated route had the twin: `SetAfterSaveBehavior(Ignore)` governs EF's save, but the merge
190+
loop assigns `entry.CurrentValues[target]` and the handler returns the in-memory entity, so a
191+
caller-supplied value was echoed in the RESPONSE. Both now strip at the route, vanilla and
192+
TPH.
193+
194+
Metadata declaring no `@mutability` generates byte-identically to before, and an explicit
195+
`@mutability: "readWrite"` emits byte-identically to declaring nothing — pinned in TS and C#.
196+
[Migration guide](docs/features/migrations/readonly-to-mutability.md).
197+
198+
### BREAKING — `origin.collection` retires to reserved-not-registered (FR-037 R2)
199+
200+
Any use now fails `ERR_UNKNOWN_SUBTYPE` in all five ports, and
201+
`ASSEMBLY_ORIGIN_SUBTYPES` shrinks to `aggregate | computed | first` in lockstep — so #210's
202+
value-host rule stays a property of the shared constant rather than of four branches.
203+
204+
**It cost adopters nothing functional, and that is the honest headline.** The subtype
205+
duplicated `origin.aggregate @agg: collect` on a strictly smaller attr set (`@via` only — no
206+
`@filter`, no `@orderBy`, no `@distinct`, so the split was a capability *loss*), and
207+
**nothing dispatched on it**: zero references in `codegen-ts`, `migrate-ts` or `runtime-ts`,
208+
no `collection` column kind in the view lowering, and its last real consumer — the payload-VO
209+
typing edge — was deleted in 0.20.16 (#270) for being actively **wrong**, substituting the
210+
`@via` relationship's target entity for the field's declared `@objectRef`. It was
211+
declarable-but-inert; deleting the child changes no generated output.
212+
213+
The one real gap is stated rather than papered over: **no surviving origin expresses a
214+
whole-object rollup along a relationship** — `@agg: collect` reduces a *column* via `@of`.
215+
That returns additively with [#335](https://github.com/metaobjectsdev/metaobjects/issues/335),
216+
which makes `@of` optional on `collect` and ships the view lowering with it. Reserved-not-
217+
registered, the re-entry bar (ADR-0007 Amendment 2) and that designated re-entry shape are
218+
recorded in [ADR-0040](spec/decisions/ADR-0040-index-type-and-secondary-key-purity.md), which
219+
now documents the treatment as a reusable pattern with all three applications to date.
220+
[Migration guide](docs/features/migrations/origin-collection-retirement.md).
45221

46222
### BREAKING — a template subtype's axis is DIRECTION (ADR-0052 / ADR-0053)
47223

‎docs/1.0-readiness.md‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,28 @@ Status legend: ✅ done · 🔶 in progress · ⬜ not started · ❓ **[RATIFY]
144144
backwards move on any registry.
145145
- ⬜ **G2. Remove the deprecated export** if A3=remove (one-time).
146146
- ⬜ **G3. A quiet period** — at least one coordinated release after the last breaking
147-
move (C3, the `0.15.x` passthrough type-preservation move) with **no
148-
metamodel-breaking changes**, to prove the rate has actually dropped. Requires
149-
A2/FR-024 to land additively or defer.
147+
move with **no metamodel-breaking changes**, to prove the rate has actually dropped.
148+
Requires A2/FR-024 to land additively or defer.
149+
150+
**CLOCK RESET by `0.24.0` (2026-08-21).** The coordinated pre-1.0 breaking batch —
151+
ADR-0052 template direction, FR-038 requirement vocabulary, FR-037 R1 `@mutability`,
152+
FR-037 R2 `origin.collection` — is four metamodel-breaking retirements in one release,
153+
and `metamodelVersion` moved `0.9` → `0.10` for the first time ever. So the counter
154+
starts again from `0.24.0`, and 1.0 now needs at least one coordinated release AFTER it
155+
carrying no metamodel-breaking change.
156+
157+
**This cost was adjudicated, not discovered.** The batch was ruled in knowing it would
158+
reset this clock, on the reasoning that under ADR-0023's sealed strict registry a
159+
retirement has no deprecation shim — so N breaking MINORs mean N adopter migrations, and
160+
batching converts four migrations into one. Paying one quiet period to buy that is the
161+
trade that was accepted. What would NOT be acceptable is discovering the reset later and
162+
treating it as a surprise; it is written here so the next reader sees a decision rather
163+
than an accident.
150164
- ⬜ **G4. Cut Metamodel 1.0** through the `releasing` skill (RC → smoke → confirm →
151165
promote), with the migration guide (B2) + compat policy (B1) published alongside.
152166

153167
---
154168

155-
_Last updated 2026-07-08 (post `0.15.1`/`7.7.1` + `0.15.2`; the `0.15.x` passthrough type-preservation move (#185) reset the quiet-period clock — C3/G3; A1 ratified — decouple).
169+
_Last updated 2026-08-21 (post the `0.24.0` coordinated pre-1.0 breaking batch — ADR-0052 + FR-038 + FR-037 R1/R2 — which reset the quiet-period clock again (G3) and moved `metamodelVersion` `0.9` → `0.10`. Earlier: `0.15.1`/`7.7.1` + `0.15.2`; the `0.15.x` passthrough type-preservation move (#185) had reset it before — C3/G3; A1 ratified — decouple).
156170
Owner: maintainer. Update the status marks as items land; ratify the remaining §A
157171
decisions (A2–A4) to unblock the rest._

0 commit comments

Comments
 (0)