Skip to content

Commit 07fcf77

Browse files
dmealingclaude
andcommitted
feat(metamodel)!: @readonly becomes the @mutability enum (#338)
FR-037 R1. `@readOnly` (boolean) is retired across 18 registry entries and replaced by `@mutability: readWrite | writeOnce | readOnly` on field.base, registered by the CORE field provider. A legacy `@readOnly` fails a strict load with ERR_UNKNOWN_ATTR; there is no deprecation shim under ADR-0023. WHY AN ENUM, not a second boolean: readOnly and writeOnce are mutually exclusive modes of ONE axis — who may write, and when. One enum makes the illegal pair unrepresentable and gives inheritance a total order (readWrite < writeOnce < readOnly), so "may only tighten" is an INDEX comparison over the declaration order rather than a lookup table. A test pins that ordering, because reordering the array would silently invert the rule. Five rules, all five ports, each with a cross-port fixture or per-port unit test: ERR_MUTABILITY_AUTOSET_CONFLICT (new) — @autoset with writeOnce or readOnly. @autoset already says the SERVER supplies the value; @mutability says who may write it. The boolean era left readOnly x @autoset REPRESENTABLE but unvalidated — the enum closes both arms with one rule. Proven live in all four ports by sabotaging the fixture and watching each go red. ERR_MUTABILITY_DOWNGRADE (new) — replaces ERR_READONLY_DOWNGRADE, renamed because the rule now spans three modes and a READONLY name would misdescribe a writeOnce -> readWrite loosening. ERR_READONLY_ASSIGNED_PRIMARY — KEEPS its name. The condition really is readOnly-specific, and the asymmetry is the enum's whole justification: `writeOnce` on an assigned primary key is LEGAL and is in fact the natural declaration for one — a thing the boolean vocabulary could not say at all. WARN_MUTABILITY_VALUE_OBJECT (new) — replaces WARN_READONLY_VALUE_OBJECT. WARN_MUTABILITY_READONLY_HOST (new) — writeOnce on a projection / read-only @kind. Benign: inert, not wrong, and a projection may inherit it. writeOnce leaves the UPDATE shape and ONLY the update shape, through each port's existing excluded-settable-set seam (the one @autoset:onCreate already uses): TS updateSchemaFields + the combined zod walk, Python _emit_patch_lines, Java SpringDtoGenerator.settableFields (vanilla AND the TPH overload — stated twice deliberately, per the 0.19.4 lesson that TPH is a separate code path), Kotlin patchSettableFields, C# SetAfterSaveBehavior(Ignore). D1 VERIFIED, not assumed: a presented writeOnce key on PATCH is STRIPPED, not 400'd. The emitted <Entity>UpdateSchema is a plain z.object() (never .strict()), Zod drops unknown keys, and mountUpdateRoute safeParses that same schema — the TPH discriminator is additionally hand-stripped after parse, the same convention. Rejecting would break our own shipped client: the generated edit form submits EVERY registered field (0.19.2 switched the resolver to UpdateSchema on edit rather than diff-and-omit), so 400-on-present would fail every save on every generated edit form for an entity carrying one. That verification exposed a PRE-EXISTING doc defect, now corrected: docs/features/generated-mutations.md PATCH-3 has claimed "at the HTTP tier an unknown or non-settable key is a 400", which is false today for the non-settable case and has been. And a real CROSS-PORT DIVERGENCE, now closed: Python's <Entity>Patch INCLUDED @readonly fields — `_emit_patch_lines` skipped only the PK and derived fields, while TS excluded readOnly from UpdateSchema. Nobody hit it because @readonly has zero adopter uses; it would have bitten the first one. C# splits the two modes exactly where they differ. Both get SetAfterSaveBehavior(Ignore) (they share the UPDATE half); only `readOnly` gets a private setter. `writeOnce` keeps a PUBLIC setter deliberately — the caller must supply it on insert, and a private setter there would make the mode useless. So neither signal alone distinguishes the modes; only the pair does, and the test asserts the pair. The accessor lives on the field node in every port (MetaField.getMutability / .Mutability, fieldMutability, field_mutability) and is RESOLVING per ADR-0039, so a mode inherited through `extends` is honoured — an own-only read would report readWrite for a field whose abstract parent declared readOnly and emit a setter for a column nothing may write. Python's naming inversion is called out at the call site (`attr()` is own-only there; `get_meta_attr()` resolves). Fixtures: six field-readonly-* renamed to field-mutability-* and migrated; three ADDED for writeOnce, which had no corpus coverage at all — and coverage here is the whole story, because a peer scan of three adopter estates (826/8/75 metadata files, 14,860 field nodes) found ZERO uses of @readonly by two independent methods with controls firing. So the corpus is the only thing that exercises these modes, and ERR_MUTABILITY_AUTOSET_CONFLICT has no existing instance anywhere to migrate — it is purely forward-looking. The coverage ratchet fired on the rename and was regenerated only after confirming the diff is exactly 13x readOnly->mutability plus U2's -1 subtype: no coverage lost. Output equivalence pinned in TS and C#: an explicit @mutability: "readWrite" emits byte-identically to declaring nothing. Green: TS build + typecheck clean, 5939 tests 0 fail · C# four Passed! lines, 0 error CS, conformance 955 · Python 1723 · Java metadata 1461 / codegen-spring 292 / codegen-kotlin 202 / render 316, BUILD SUCCESS, ConformanceTest 576 · check-metamodel-version.mjs exit 0 (0.10 covers it) · ci-local.sh --only gates PASSED. Still to land for FR-037 R1: the api-contract-conformance writeOnce scenario (both lanes, every port, vanilla AND TPH). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
1 parent b99d5c3 commit 07fcf77

87 files changed

Lines changed: 2290 additions & 1017 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎agent-context/skills/metaobjects-audit/references/capability-checklist.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove
5050
- **`field.map`** (`@valueType`) — hunt ad-hoc open-keyed key/value bags stuffed into a jsonb
5151
column by hand.
5252
- **Common field attrs** — `@column` (hand column-name mapping), `@default` (hand default
53-
assignment), `@required` (hand presence checks), `@unique` (hand uniqueness), `@readOnly`
54-
(hand write-guards), `@filterable` / `@sortable` (hand filter/sort allowlists),
53+
assignment), `@required` (hand presence checks), `@unique` (hand uniqueness),
54+
`@mutability` (hand write-guards: `readOnly` for a column the DB/replication owns,
55+
`writeOnce` for one set at create and never changed — hunt hand-written
56+
"cannot be modified after creation" checks), `@filterable` / `@sortable`
57+
(hand filter/sort allowlists),
5558
`@dbColumnType` (hand native-type override), `@example` / `@instruction` (hand prompt
5659
hints), `@xmlText` (hand XML-text mapping). The `@db.indexed` attr suppresses the
5760
*`@filterable`-without-index* Loader warning (you assert the column is indexed by other

‎docs/features/generated-mutations.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,21 @@ but the semantics are one contract — call them **PATCH-1..7** (FR-035):
3232
*omitted* field is left alone. This is JSON-Merge-Patch present-key semantics
3333
with `null` meaning *set null*, not *delete*.
3434
- **PATCH-3 (settable set).** Never settable: the primary key, the TPH
35-
discriminator, `@readOnly` fields, `origin.*`-derived fields. `@autoSet:onUpdate`
36-
columns are stamped server-side regardless of the caller; `@autoSet:onCreate`
37-
columns are not patch-settable. At the HTTP tier an unknown or non-settable key
38-
is a `400 {"error":"validation"}`.
35+
discriminator, `@mutability: "readOnly"` fields, `@mutability: "writeOnce"` fields
36+
(settable on POST, frozen thereafter), `origin.*`-derived fields.
37+
`@autoSet:onUpdate` columns are stamped server-side regardless of the caller;
38+
`@autoSet:onCreate` columns are not patch-settable.
39+
**A non-settable key present in a PATCH body is STRIPPED, not rejected.** The
40+
generated `<Entity>UpdateSchema` is a plain `z.object()` (never `.strict()`) that
41+
simply omits every non-settable field, and Zod drops unknown keys — so
42+
`safeParse` succeeds and the key never reaches the `SET`. The TPH discriminator
43+
is additionally deleted by hand after parse, the same convention. A malformed
44+
*value* on a settable key is still `400 {"error":"validation"}`.
45+
This matters for `writeOnce` specifically: the generated edit form submits EVERY
46+
registered field (`handleSubmit` passes all values, and 0.19.2 switched the
47+
resolver to `UpdateSchema` on edit rather than diff-and-omit), so rejecting a
48+
present-but-frozen key would fail every save on every generated edit form for an
49+
entity carrying one.
3950
- **PATCH-4 (validation).** Assigned values run the same per-field validation and
4051
the same write codec as `create`.
4152
- **PATCH-5 (empty patch).** Zero assignments is a no-op — the current row is read

‎fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove
5050
- **`field.map`** (`@valueType`) — hunt ad-hoc open-keyed key/value bags stuffed into a jsonb
5151
column by hand.
5252
- **Common field attrs** — `@column` (hand column-name mapping), `@default` (hand default
53-
assignment), `@required` (hand presence checks), `@unique` (hand uniqueness), `@readOnly`
54-
(hand write-guards), `@filterable` / `@sortable` (hand filter/sort allowlists),
53+
assignment), `@required` (hand presence checks), `@unique` (hand uniqueness),
54+
`@mutability` (hand write-guards: `readOnly` for a column the DB/replication owns,
55+
`writeOnce` for one set at create and never changed — hunt hand-written
56+
"cannot be modified after creation" checks), `@filterable` / `@sortable`
57+
(hand filter/sort allowlists),
5558
`@dbColumnType` (hand native-type override), `@example` / `@instruction` (hand prompt
5659
hints), `@xmlText` (hand XML-text mapping). The `@db.indexed` attr suppresses the
5760
*`@filterable`-without-index* Loader warning (you assert the column is indexed by other

‎fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove
5050
- **`field.map`** (`@valueType`) — hunt ad-hoc open-keyed key/value bags stuffed into a jsonb
5151
column by hand.
5252
- **Common field attrs** — `@column` (hand column-name mapping), `@default` (hand default
53-
assignment), `@required` (hand presence checks), `@unique` (hand uniqueness), `@readOnly`
54-
(hand write-guards), `@filterable` / `@sortable` (hand filter/sort allowlists),
53+
assignment), `@required` (hand presence checks), `@unique` (hand uniqueness),
54+
`@mutability` (hand write-guards: `readOnly` for a column the DB/replication owns,
55+
`writeOnce` for one set at create and never changed — hunt hand-written
56+
"cannot be modified after creation" checks), `@filterable` / `@sortable`
57+
(hand filter/sort allowlists),
5558
`@dbColumnType` (hand native-type override), `@example` / `@instruction` (hand prompt
5659
hints), `@xmlText` (hand XML-text mapping). The `@db.indexed` attr suppresses the
5760
*`@filterable`-without-index* Loader warning (you assert the column is indexed by other

‎fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove
5050
- **`field.map`** (`@valueType`) — hunt ad-hoc open-keyed key/value bags stuffed into a jsonb
5151
column by hand.
5252
- **Common field attrs** — `@column` (hand column-name mapping), `@default` (hand default
53-
assignment), `@required` (hand presence checks), `@unique` (hand uniqueness), `@readOnly`
54-
(hand write-guards), `@filterable` / `@sortable` (hand filter/sort allowlists),
53+
assignment), `@required` (hand presence checks), `@unique` (hand uniqueness),
54+
`@mutability` (hand write-guards: `readOnly` for a column the DB/replication owns,
55+
`writeOnce` for one set at create and never changed — hunt hand-written
56+
"cannot be modified after creation" checks), `@filterable` / `@sortable`
57+
(hand filter/sort allowlists),
5558
`@dbColumnType` (hand native-type override), `@example` / `@instruction` (hand prompt
5659
hints), `@xmlText` (hand XML-text mapping). The `@db.indexed` attr suppresses the
5760
*`@filterable`-without-index* Loader warning (you assert the column is indexed by other

‎fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove
5050
- **`field.map`** (`@valueType`) — hunt ad-hoc open-keyed key/value bags stuffed into a jsonb
5151
column by hand.
5252
- **Common field attrs** — `@column` (hand column-name mapping), `@default` (hand default
53-
assignment), `@required` (hand presence checks), `@unique` (hand uniqueness), `@readOnly`
54-
(hand write-guards), `@filterable` / `@sortable` (hand filter/sort allowlists),
53+
assignment), `@required` (hand presence checks), `@unique` (hand uniqueness),
54+
`@mutability` (hand write-guards: `readOnly` for a column the DB/replication owns,
55+
`writeOnce` for one set at create and never changed — hunt hand-written
56+
"cannot be modified after creation" checks), `@filterable` / `@sortable`
57+
(hand filter/sort allowlists),
5558
`@dbColumnType` (hand native-type override), `@example` / `@instruction` (hand prompt
5659
hints), `@xmlText` (hand XML-text mapping). The `@db.indexed` attr suppresses the
5760
*`@filterable`-without-index* Loader warning (you assert the column is indexed by other

‎fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove
5050
- **`field.map`** (`@valueType`) — hunt ad-hoc open-keyed key/value bags stuffed into a jsonb
5151
column by hand.
5252
- **Common field attrs** — `@column` (hand column-name mapping), `@default` (hand default
53-
assignment), `@required` (hand presence checks), `@unique` (hand uniqueness), `@readOnly`
54-
(hand write-guards), `@filterable` / `@sortable` (hand filter/sort allowlists),
53+
assignment), `@required` (hand presence checks), `@unique` (hand uniqueness),
54+
`@mutability` (hand write-guards: `readOnly` for a column the DB/replication owns,
55+
`writeOnce` for one set at create and never changed — hunt hand-written
56+
"cannot be modified after creation" checks), `@filterable` / `@sortable`
57+
(hand filter/sort allowlists),
5558
`@dbColumnType` (hand native-type override), `@example` / `@instruction` (hand prompt
5659
hints), `@xmlText` (hand XML-text mapping). The `@db.indexed` attr suppresses the
5760
*`@filterable`-without-index* Loader warning (you assert the column is indexed by other

‎fixtures/conformance/ERROR-CODES.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@
5757
"ERR_SOURCE_UNRESOLVED": "Phase-1 metadata-source-resolution: a path source declared in .metaobjects/config.json does not exist on disk.",
5858
"ERR_PHYSICAL_NAME_KIND_MISMATCH": "FR-016 / ADR-0018: a source.rdb declares a kind-aware physical-name alias (@view/@materializedView/@proc/@function) that does not match its @kind. The legacy @table-for-non-table case warns rather than errors.",
5959
"ERR_PHYSICAL_NAME_MULTIPLE": "FR-016 / ADR-0018: a source.rdb declares two or more kind-aware physical-name aliases at once (e.g. both @table and @view). Exactly one is permitted.",
60-
"ERR_READONLY_ASSIGNED_PRIMARY": "FR-013: a field with @readOnly: true is the target of an identity.primary with @generation: \"assigned\". The application has no path to populate the identity value (no setter; not generated; not defaulted).",
61-
"ERR_READONLY_DOWNGRADE": "FR-013: a concrete entity declares @readOnly: false on a field whose extends-chain parent declares @readOnly: true. Read-only-ness can only be upgraded, never downgraded.",
60+
"ERR_MUTABILITY_AUTOSET_CONFLICT": "FR-037 R1: a field declares @autoSet together with @mutability \"writeOnce\" or \"readOnly\". @autoSet already means the SERVER supplies the value; @mutability says who may write it. The pair states two contradictory things about one column. The boolean era left readOnly x @autoSet representable but UNVALIDATED; the enum cut closes both arms with one rule.",
61+
"ERR_MUTABILITY_DOWNGRADE": "FR-037 R1: a subtype LOOSENS an inherited @mutability mode. A subtype may only tighten (readWrite < writeOnce < readOnly), never loosen. Replaces ERR_READONLY_DOWNGRADE, which was renamed because the rule now spans three modes and a code named READONLY would misdescribe a writeOnce -> readWrite loosening.",
62+
"ERR_READONLY_ASSIGNED_PRIMARY": "FR-037 R1: a field with @mutability: \"readOnly\" is the target of an identity.primary with @generation: \"assigned\". The application has no path to populate the identity value (no setter; not generated; not defaulted). KEEPS its readOnly-specific name because the condition is readOnly-specific: @mutability: \"writeOnce\" on an assigned primary key is LEGAL, and indeed the natural declaration for one.",
6263
"ERR_PARAMETER_REF_UNRESOLVED": "FR-015: source.rdb @parameterRef names an object that does not exist in the loaded model.",
6364
"ERR_PARAMETER_REF_NOT_VALUE_OBJECT": "FR-015: source.rdb @parameterRef references an object.entity instead of object.value. Input shapes are value-objects by definition (no identity).",
6465
"ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND": "FR-015: source.rdb @parameterRef is set with @kind: \"table\" / \"view\" / \"materializedView\". These kinds do not accept parameters; only \"storedProc\" / \"tableFunction\" do.",
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_MUTABILITY_AUTOSET_CONFLICT",
5+
"source": {
6+
"format": "json",
7+
"files": [
8+
"meta.demo.json"
9+
],
10+
"jsonPath": "$['metadata.root'].children[0]['object.entity'].children[2]['field.timestamp']"
11+
}
12+
}
13+
],
14+
"warnings": []
15+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"metadata.root": {
3+
"package": "demo",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Customer",
8+
"children": [
9+
{
10+
"source.rdb": {
11+
"@table": "customers"
12+
}
13+
},
14+
{
15+
"field.long": {
16+
"name": "id"
17+
}
18+
},
19+
{
20+
"field.timestamp": {
21+
"name": "createdAt",
22+
"@autoSet": "onCreate",
23+
"@mutability": "readOnly"
24+
}
25+
},
26+
{
27+
"identity.primary": {
28+
"name": "id",
29+
"@fields": "id"
30+
}
31+
}
32+
]
33+
}
34+
}
35+
]
36+
}
37+
}

0 commit comments

Comments
 (0)