Skip to content

Commit f561e92

Browse files
dmealingclaude
andcommitted
test(api-contract): the TPH leg of the writeOnce gate — and Kotlin's TPH path was red
Completes FR-037 R1's acceptance. `issuedCurrency` is declared on the TPH BASE (`Auth`) so every subtype inherits it, which also exercises the resolving-accessor requirement: a port reading @mutability own-only would see readWrite on each subtype and let the write through. IT FOUND A THIRD DEFECT, and it is the 0.19.4 lesson landing exactly as the plan predicted it would. Kotlin's VANILLA lane was green and its TPH lane was RED: `KotlinTphPlan.subtypeSettableFields` is a separate SSOT from the vanilla controller's `patchSettableFields`, and only the latter had been taught the modes. Nothing but a TPH-flavoured scenario reaches it — which is precisely why the acceptance criteria demanded one rather than trusting that the vanilla gate generalises. The other four ports' TPH paths were already correct, but they were correct UNGATED, which is the same state Kotlin's was in until this scenario ran. Also folds `docs/features/migrations/0.x-to-1.0.md` — the cumulative adopter path gains §8 for the whole 0.24.0 batch (four retirements, each linking its own guide), and §7's 0.21.0 assembly-origin list is annotated rather than rewritten, since `origin.collection` was accurate there and has only now gone. TPH verified per port, both lanes where both exist: TS 18 (9 scenarios x 2) · C# 18 · Java TphGenerated 10 · Kotlin TphGenerated 10 · Python 13 collected including tph-writeonce-patch-stripped. JVM reactors BUILD SUCCESS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
1 parent 1f47ce6 commit f561e92

13 files changed

Lines changed: 184 additions & 24 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -180,16 +180,24 @@ look." The attribute's heaviest use was this repository's own conformance corpus
180180
therefore also the only thing that exercises the three modes — so the corpus gained fixtures
181181
for `writeOnce`, which previously had no coverage at all.
182182

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.
183+
**THREE real defects were caught by the api-contract corpus, not by the unit tests that
184+
already passed** — and all three are the ADR-0045 rule biting: the OUTERMOST generated write
185+
artifact enforces the mode, and in three ports that artifact was not the one first fixed.
186+
187+
1. **Python.** Excluding `writeOnce` from `<Entity>Patch` was NOT enough — the generated
188+
FastAPI handler binds `dto: dict[str, Any]` (the RAW body), validates against the patch
189+
model, then hands the raw dict to the repository, so the field still reached the row.
190+
`@autoSet` survives this only because the router OVERWRITES its key server-side;
191+
`writeOnce` has no server value to clobber. Now stripped in the router.
192+
2. **C#.** `SetAfterSaveBehavior(Ignore)` governs EF's SAVE, but the merge loop assigns
193+
`entry.CurrentValues[target]` and the handler returns the in-memory entity — so a
194+
caller-supplied value was echoed in the RESPONSE even where EF omitted the column from
195+
the UPDATE. Now skipped in the merge loop, exactly as `@autoSet` is.
196+
3. **Kotlin — and this one is the 0.19.4 lesson landing exactly as predicted.** The vanilla
197+
lane passed and the **TPH** lane did not: `KotlinTphPlan.subtypeSettableFields` is a
198+
separate SSOT from the vanilla controller's `patchSettableFields`, and only the latter had
199+
been taught the modes. Nothing but a TPH-flavoured scenario could have found it, which is
200+
why the acceptance criteria demanded one.
193201

194202
Metadata declaring no `@mutability` generates byte-identically to before, and an explicit
195203
`@mutability: "readWrite"` emits byte-identically to declaring nothing — pinned in TS and C#.

‎docs/features/migrations/0.x-to-1.0.md‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ covering projections, entities, values, and parameter refs alike.
103103
Three coordinated breaking changes in one cut (#210 / #212):
104104

105105
- A field hosted on an `object.value` may no longer carry an **assembly origin**
106-
(`origin.aggregate` / `origin.computed` / `origin.collection` / `origin.first`)
106+
(`origin.aggregate` / `origin.computed` / `origin.collection` / `origin.first` —
107+
`origin.collection` was itself retired in `0.24.0`, see §8D, so on a current
108+
release the rule reads as the three survivors)
107109
— `ERR_SUBTYPE_RULE_VIOLATION`. Re-host the payload as a **sourceless
108110
`object.projection`**; `@payloadRef`/`@responseRef` now accept one.
109111
`origin.passthrough` on a value (FR-015 parameter lineage) stays legal.
@@ -117,7 +119,50 @@ Three coordinated breaking changes in one cut (#210 / #212):
117119
- **Full mechanical rewrites for all three:**
118120
[value-assembly-origins-and-source-role-shrink.md](value-assembly-origins-and-source-role-shrink.md).
119121

120-
## 8. Deprecated `codegen-ts/generators` export removed (at the 1.0 cut)
122+
## 8. The coordinated breaking batch (shipped `0.24.0` / Maven `7.24.0`)
123+
124+
**Four vocabulary retirements in one release**, batched deliberately: under ADR-0023's sealed
125+
strict registry a retirement has no deprecation shim, so each is a migration you must
126+
perform, and two breaking MINORs back to back would mean two migrations for work budgeted as
127+
one. `metamodelVersion` moved `0.9` → `0.10`, once, covering all four.
128+
129+
Each has its own guide with the exact loader error and a rewrite rule. In rough order of how
130+
likely you are to be affected:
131+
132+
**8A. `@readOnly` → `@mutability`** —
133+
[readonly-to-mutability.md](readonly-to-mutability.md)
134+
135+
`@mutability: readWrite | writeOnce | readOnly` replaces the boolean on every `field.*`
136+
subtype. `@readOnly: true` → `@mutability: "readOnly"`; `@readOnly: false` → delete it
137+
(`readWrite` is the default). The new middle mode is the point: **`writeOnce`** — settable on
138+
create, frozen after — which the boolean could not express, and which an assigned primary key
139+
has always wanted. Measured at **zero uses** across three adopting estates, so this is likely
140+
an empty migration.
141+
142+
**8B. The requirement vocabulary becomes prescriptive-only** —
143+
[verified-by-retirement.md](verified-by-retirement.md)
144+
145+
`@verifiedBy` and `@supersededBy` deregister (`ERR_UNKNOWN_ATTR`); `@status` shrinks to
146+
`planned | live | partial`, so `abandoned` and `superseded` fail `ERR_BAD_ATTR_VALUE`.
147+
Retiring a capability is now DELETION of its requirement. Only affects projects that declare
148+
`requirement.*` nodes at all.
149+
150+
**8C. A template subtype's axis is DIRECTION** —
151+
[template-direction-outbound-vs-inbound.md](template-direction-outbound-vs-inbound.md)
152+
153+
`@promptStyle` on a `template.output` fails the load; the inbound tier (parser, tolerant
154+
extractor, response-format fragment) belongs to a `template.prompt` carrying `@responseRef`,
155+
and emitted paths follow the direction.
156+
157+
**8D. `origin.collection` retires to reserved-not-registered** —
158+
[origin-collection-retirement.md](origin-collection-retirement.md)
159+
160+
Any use now fails `ERR_UNKNOWN_SUBTYPE`. It duplicated `origin.aggregate @agg: collect` on a
161+
smaller attr set and nothing ever dispatched on it, so deleting the child changes no
162+
generated output. A whole-object rollup returns additively with
163+
[#335](https://github.com/metaobjectsdev/metaobjects/issues/335).
164+
165+
## 9. Deprecated `codegen-ts/generators` export removed (at the 1.0 cut)
121166

122167
Importing the built-in generators from `@metaobjectsdev/codegen-ts/generators`
123168
(`entityFile` / `queriesFile` / `routesFile` / `barrel`) is **removed** at 1.0.

‎fixtures/api-contract-conformance/tph/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
{ "field.long": { "name": "id", "@filterable": true, "@sortable": true } },
1111
{ "field.enum": { "name": "type", "@values": ["Bridge", "Copay", "PriorAuth"], "@filterable": true } },
1212
{ "field.string": { "name": "reference", "@required": true, "@maxLength": 80, "@filterable": true, "@sortable": true } },
13+
{ "field.string": { "name": "issuedCurrency", "@maxLength": 3, "@mutability": "writeOnce" } },
1314
{ "field.timestamp": { "name": "autoCreatedAt", "@autoSet": "onCreate" } },
1415
{ "field.timestamp": { "name": "autoUpdatedAt", "@autoSet": "onUpdate" } },
1516
{ "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: tph-writeonce-patch-stripped
2+
description: >
3+
FR-037 R1 / ADR-0045 (TPH leg) — `@mutability: "writeOnce"` is honored by the deployed
4+
TPH per-subtype API surface, not merely by the vanilla one.
5+
6+
This scenario exists because TPH is a SEPARATE code path in every port — the 0.19.4
7+
lesson, where @autoSet stamping was correct on the vanilla route and absent on the TPH
8+
per-subtype route in three ports at once. Java has a distinct `settableFields` overload
9+
for the per-subtype patch, C# a distinct `AppendTphSubtypeRoutes` merge, Python a distinct
10+
TPH router branch, Kotlin a distinct TPH controller. Each of those was written to exclude
11+
the mode; without this scenario none of them is GATED, and "written" is not "enforced".
12+
13+
`issuedCurrency` is declared on the TPH BASE (`Auth`), so every subtype inherits it — which
14+
also exercises the resolving-accessor requirement (ADR-0039): a port reading the mode
15+
own-only would see `readWrite` on every subtype and let the write through.
16+
17+
Seeded Bridge row 1 carries "USD". A PATCH on the per-subtype path carrying a NEW value
18+
must return 200 with the stored value unchanged, while the ordinary readWrite field in the
19+
same body IS applied — the pairing is what makes this about the MODE rather than about the
20+
request being dropped wholesale.
21+
requests:
22+
- id: r1
23+
method: PATCH
24+
path: /api/auths/bridge/1
25+
body:
26+
reference: "REF-1-writeOnce"
27+
issuedCurrency: "JPY"
28+
expect:
29+
status: 200
30+
body:
31+
row:
32+
id: 1
33+
# The readWrite sibling IS applied...
34+
reference: "REF-1-writeOnce"
35+
# ...while the inherited writeOnce column keeps its created value. 200, not 400.
36+
issuedCurrency: "USD"
37+
- id: r2
38+
method: PATCH
39+
path: /api/auths/bridge/1
40+
body:
41+
# A readWrite field rides along deliberately — see the vanilla
42+
# writeonce-patch-stripped scenario for why (the empty-PATCH contract is not
43+
# settled cross-port, and settling it is not this scenario's business).
44+
reference: "REF-1-null-probe"
45+
issuedCurrency: null
46+
expect:
47+
status: 200
48+
body:
49+
row:
50+
id: 1
51+
reference: "REF-1-null-probe"
52+
# Clearing is a write, so the FR-035 present-null path must not reach it either.
53+
issuedCurrency: "USD"
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"auths": [
3-
{ "id": 1, "type": "Bridge", "reference": "REF-1", "quantity": 5, "copayAmount": null, "approver": null, "autoCreatedAt": "2000-01-01T00:00:00", "autoUpdatedAt": "2000-01-01T00:00:00" },
4-
{ "id": 2, "type": "Copay", "reference": "REF-2", "quantity": null, "copayAmount": "12.50", "approver": null, "autoCreatedAt": "2000-01-01T00:00:00", "autoUpdatedAt": "2000-01-01T00:00:00" },
5-
{ "id": 3, "type": "PriorAuth", "reference": "REF-3", "quantity": null, "copayAmount": null, "approver": "Dr. Smith", "autoCreatedAt": "2000-01-01T00:00:00", "autoUpdatedAt": "2000-01-01T00:00:00" }
3+
{ "id": 1, "type": "Bridge", "reference": "REF-1", "quantity": 5, "copayAmount": null, "approver": null, "issuedCurrency": "USD", "autoCreatedAt": "2000-01-01T00:00:00", "autoUpdatedAt": "2000-01-01T00:00:00" },
4+
{ "id": 2, "type": "Copay", "reference": "REF-2", "quantity": null, "copayAmount": "12.50", "approver": null, "issuedCurrency": "GBP", "autoCreatedAt": "2000-01-01T00:00:00", "autoUpdatedAt": "2000-01-01T00:00:00" },
5+
{ "id": 3, "type": "PriorAuth", "reference": "REF-3", "quantity": null, "copayAmount": null, "approver": "Dr. Smith", "issuedCurrency": "EUR", "autoCreatedAt": "2000-01-01T00:00:00", "autoUpdatedAt": "2000-01-01T00:00:00" }
66
]
77
}

‎server/csharp/MetaObjects.IntegrationTests/Api/TphFixture.cs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ internal static class TphFixture
3434
"quantity" INTEGER,
3535
"copayAmount" NUMERIC(10,2),
3636
"approver" VARCHAR(80),
37+
-- FR-037 R1: the @mutability "writeOnce" column, declared on the TPH BASE so
38+
-- every subtype inherits it.
39+
"issuedCurrency" VARCHAR(3),
3740
"autoCreatedAt" TIMESTAMPTZ,
3841
"autoUpdatedAt" TIMESTAMPTZ,
3942
CONSTRAINT "auths_pkey" PRIMARY KEY ("id"),
@@ -42,7 +45,7 @@ internal static class TphFixture
4245
""";
4346

4447
private static readonly string[] Cols =
45-
{ "id", "type", "reference", "quantity", "copayAmount", "approver", "autoCreatedAt", "autoUpdatedAt" };
48+
{ "id", "type", "reference", "quantity", "copayAmount", "approver", "issuedCurrency", "autoCreatedAt", "autoUpdatedAt" };
4649

4750
/// <summary>Create the single `auths` table on a fresh container.</summary>
4851
public static async Task ProvisionSchemaAsync(string connString)

‎server/csharp/MetaObjects.IntegrationTests/Api/TphReferenceServer.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ internal sealed class TphReferenceServer : IAsyncDisposable
3636
};
3737

3838
// Every column the `auths` table carries (base + every subtype-only column).
39+
// FR-037 R1: `issuedCurrency` is @mutability "writeOnce" — it is DELIBERATELY absent
40+
// from this caller-settable projection, so a presented value is STRIPPED on PATCH
41+
// rather than rejected (the same treatment autoCreatedAt/autoUpdatedAt get by being
42+
// absent here). It IS written on create, explicitly, below. Do NOT add it here.
3943
private static readonly string[] AllCols = { "id", "type", "reference", "quantity", "copayAmount", "approver" };
4044

4145
private readonly PostgresContainer _pg;
@@ -266,6 +270,11 @@ private async Task DispatchAsync(HttpListenerContext ctx)
266270
// body-driven loop above never sets them from the request). A fresh row's
267271
// autoUpdatedAt therefore equals its autoCreatedAt.
268272
var autoNow = DateTimeOffset.UtcNow;
273+
// FR-037 R1: writeOnce IS bound on create — settable exactly once.
274+
if (body is not null && body.TryGetPropertyValue("issuedCurrency", out var __ic) && __ic is not null)
275+
{
276+
cols.Add("issuedCurrency"); vals.Add(__ic.GetValue<string>());
277+
}
269278
cols.Add("autoCreatedAt"); vals.Add(autoNow);
270279
cols.Add("autoUpdatedAt"); vals.Add(autoNow);
271280

‎server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTphPlan.kt‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,14 @@ object KotlinTphPlan {
131131
it is ObjectField || it is MapField || KotlinTypeMapper.isJsonbOpenBag(it)
132132
}.filter {
133133
it.name != pk && it.name != discField &&
134-
!KotlinGenUtil.isAutoSetField(it) // #203/ADR-0045: server-owned; controller stamps it
134+
!KotlinGenUtil.isAutoSetField(it) && // #203/ADR-0045: server-owned; controller stamps it
135+
// FR-037 R1: both non-readWrite @mutability modes leave the PATCH settable set —
136+
// readOnly is never written, writeOnce is frozen after create. Stated HERE and not
137+
// only in the vanilla controller's patchSettableFields because TPH is a SEPARATE
138+
// code path (the 0.19.4 lesson), and this is its SSOT: the per-subtype patch shape,
139+
// the <Sub>Validation shape and the settable-name list all read it.
140+
!KotlinGenUtil.isReadOnlyMutability(it) &&
141+
!KotlinGenUtil.isWriteOnceMutability(it)
135142
}
136143
}
137144

‎server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/tph/generated/GeneratedTphControllerHarness.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ class GeneratedTphControllerHarness(
152152
authTable.insert { stmt ->
153153
stmt[columnFor("type")] = enumValueFor(r["type"] as String)
154154
stmt[columnFor("reference")] = r["reference"] as String?
155+
// FR-037 R1: the @mutability "writeOnce" column (declared on the TPH base),
156+
// seeded verbatim so a later PATCH is observed to leave it alone.
157+
stmt[columnFor("issuedCurrency")] = r["issuedCurrency"] as String?
155158
stmt[columnFor("autoCreatedAt")] = parseSeedInstant(r["autoCreatedAt"] as String)
156159
stmt[columnFor("autoUpdatedAt")] = parseSeedInstant(r["autoUpdatedAt"] as String)
157160
stmt[columnFor("quantity")] = (r["quantity"] as? Number)?.toInt()

‎server/java/integration-tests/src/test/java/com/metaobjects/integration/api/tph/generated/GeneratedTphControllerHarness.java‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,10 @@ public Instant deserialize(JsonParser p, DeserializationContext ctx)
156156
// ADR-0045 (#203/#229): the DTO record now carries the two @autoSet timestamp columns
157157
// (autoCreatedAt onCreate, autoUpdatedAt onUpdate) right after the base's own scalars, in
158158
// declared order — before the folded-in subtype-only columns.
159+
// FR-037 R1 adds `issuedCurrency` (@mutability "writeOnce") on the BASE, so it lands
160+
// among the base's own scalars in declared order, before the @autoSet pair.
159161
this.dtoCtor = dtoClass.getDeclaredConstructor(
160-
Long.class, authTypeClass, String.class, Instant.class, Instant.class,
162+
Long.class, authTypeClass, String.class, String.class, Instant.class, Instant.class,
161163
Integer.class, BigDecimal.class, String.class);
162164
Class<?> repoInterface = classLoader.loadClass(REPO_FQCN);
163165
this.controllerCtor = classLoader.loadClass(CONTROLLER_FQCN)
@@ -216,6 +218,8 @@ private Object dtoFromRow(Map<String, Object> row) throws Exception {
216218
Long id = row.get("id") == null ? null : ((Number) row.get("id")).longValue();
217219
String type = (String) row.get("type");
218220
String reference = (String) row.get("reference");
221+
// FR-037 R1: the @mutability "writeOnce" column, seeded verbatim.
222+
String issuedCurrency = (String) row.get("issuedCurrency");
219223
Object autoCreatedRaw = row.get("autoCreatedAt");
220224
Instant autoCreatedAt = autoCreatedRaw == null ? null : parseInstant(String.valueOf(autoCreatedRaw));
221225
Object autoUpdatedRaw = row.get("autoUpdatedAt");
@@ -228,7 +232,8 @@ private Object dtoFromRow(Map<String, Object> row) throws Exception {
228232
@SuppressWarnings({ "unchecked", "rawtypes" })
229233
Object typeEnum = type == null ? null : Enum.valueOf((Class) authTypeClass, type);
230234
return dtoCtor.newInstance(
231-
id, typeEnum, reference, autoCreatedAt, autoUpdatedAt, quantity, copayAmount, approver);
235+
id, typeEnum, reference, issuedCurrency, autoCreatedAt, autoUpdatedAt,
236+
quantity, copayAmount, approver);
232237
}
233238

234239
/**

0 commit comments

Comments
 (0)