Skip to content

Commit e57bfdc

Browse files
dmealingclaude
andcommitted
docs(fr-035): tristate decision — adopt present-key null-clears cross-port (§12)
Records the ruling on the open FR-035 scope fork: the generated partial-PATCH HTTP surface adopts PRESENT-KEY semantics uniformly — an omitted field is untouched, an explicit null clears a nullable column, an explicit null on a @required field is 400. Grounded in a re-verification that turned up a WORSE divergence than the tristate: `PATCH {"bio": null}` on a nullable field today produces FOUR behaviors across the ports (TS 400s; Python + C#-TPH clear; C#-plain/Kotlin/Java silently no-op — 7fab436 split C# against itself), AND — separately — Java/Kotlin 400 any PATCH that OMITS a @required field (@NotNull / non-null ctor binding), while TS/Python/C# accept it. So "PATCH one optional field," the core partial-update gesture, works on 3 ports and 400s on 2. The existing omitted-field gate papers over this by sending the required field in the body. The §3 PATCH-2 claim ("matching what all five ports' JSON stacks naturally do") was factually wrong; §12 corrects it. The cheap "middle path" (stop C#/Kotlin skipping nulls) is illusory for the JVM ports: post-@Valid-DTO-binding, null IS absence, so clearing requires reading raw JSON presence — i.e. the tristate. Rewriting the Java/Kotlin handlers off full-DTO binding fixes both the null-clear and the required-omission divergence at once. Every runtime substrate already has native tristate; only the generated HTTP handlers lose presence. NOT YET IMPLEMENTED — this is the decision + plan only. It is a coordinated, breaking, ~1-week multi-port build (TS/C#/Python ~½ day each, Java 1–2, Kotlin the hard port at 2–3), gated by a new `update-explicit-null-clears.yaml` scenario red on all five ports until green, released with a breaking banner. Java's presence-tracked `<Entity>Patch` ships only as part of this (under option A it would be cancelled — presence degenerates to value-non-null, which the existing update(id, dto) already expresses). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd5NWZ1rKau63vJzrBSpLb
1 parent 29d52fd commit e57bfdc

1 file changed

Lines changed: 136 additions & 0 deletions

File tree

docs/superpowers/specs/2026-07-13-generated-mutation-surface-design.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,3 +761,139 @@ test/codegen-seam); Phase 5 is its own coordinated release with the ADR.
761761
`InsertSchema.partial()` silently replaces a caller-supplied `@autoSet onCreate` value with
762762
`now()` on update (verified against Zod 4.4.3 behavior — supplied keys run the transform;
763763
absent keys are dropped). The routes tier is unaffected (it uses `UpdateSchema`).
764+
765+
---
766+
767+
## 12. Tristate decision (2026-07-13, post-Phase-0/1 verification)
768+
769+
**Decision: DO THE TRISTATE (option B), scoped precisely as *present-key semantics at the
770+
generated HTTP mutation surface*, sequenced cheap-ports-first behind a red conformance gate.
771+
Java's presence-tracked `<Entity>Patch` is built as part of it — and ONLY as part of it (under
772+
option A it would be pointless; see §12.5).**
773+
774+
### 12.1 The verified ground truth that forced the decision
775+
776+
`PATCH /authors/{id}` with body `{"bio": null}` (`bio` = nullable, non-`@required`) currently
777+
produces **four different behaviors across the five ports — including a split inside C# itself**:
778+
779+
| Port / handler | Behavior | Evidence |
780+
|---|---|---|
781+
| TS (all three route runtimes) | **400** `{"error":"validation"}` | `UpdateSchema` fields are `.optional()` and never `.nullable()` (`server/typescript/packages/codegen-ts/src/templates/zod-validators.ts:307-310`, `appendValidatorChain` line 579); `mountUpdateRoute` safeParse→400 (`server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts:225-228`; same in `fastify/index.ts:195`, `hono/index.ts:192`). Confirmed empirically: Zod `.optional()` rejects `null`. |
782+
| Python | **200, clears the column** | Router binds `dto: dict[str, Any]` — raw present keys (`server/python/src/metaobjects/codegen/generators/router_generator.py:300`); repo/`ObjectManager.update` write every present key, `None` → SQL NULL (`server/python/tests/integration/generated_router_app.py:231`; `src/metaobjects/runtime/object_manager.py:270-306`). |
783+
| C# — plain entity | **200, silent no-op** | `if (prop.Value.ValueKind == JsonValueKind.Null) continue;` (`server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs:219`) — added by `7fab436b` as a deliberate deferral. |
784+
| C# — TPH subtype | **200, clears the column** | The per-subtype handler WRITES the null (`RoutesGenerator.cs:487-490`) — pre-existing; `7fab436b` split the port against itself. |
785+
| Kotlin | **200, silent no-op** | `if (dto.f != null) it[f] = dto.f` null-guard (`server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt:357-372`). |
786+
| Java | **200, silent no-op** | Controller binds the full `@Valid @RequestBody <Entity>Dto` — absent and null are indistinguishable after Jackson binding (`server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java:276-282`); the repository merge null-guards (reference: `server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryAuthorRepositorySource.java:110-114`). |
787+
788+
No conformance scenario exercises explicit-null (`update-partial-omitted-field-survives.yaml`
789+
lines 19-21 defer it by name), so the divergence is shipped and invisible to every gate.
790+
791+
Two corrections to earlier text in this document:
792+
- §3 PATCH-2's claim that present-key-null is "*matching what all five ports' JSON stacks
793+
naturally do*" is **false**: TS's Zod stack naturally REJECTS null, and Kotlin's/Java's DTO
794+
binding naturally cannot SEE it. Only Python and C#'s raw-JSON enumeration do it naturally.
795+
- §1.1's framing survives, but note the **second** shipped divergence found on the same path:
796+
Java (`@NotNull`/`@NotBlank` from `SpringDtoGenerator.java:375-379` + `@Valid` on PATCH) and
797+
Kotlin (non-null constructor params) **400 any PATCH that omits a `@required` field**;
798+
TS/Python/C# accept it. `update-partial-omitted-field-survives.yaml` papers over this by
799+
sending `createdAt` in the body (its own description admits it, lines 3-5). Patching a single
800+
optional field — the core partial-update gesture — currently works on three ports and 400s on
801+
two.
802+
803+
### 12.2 Why B and not A ("stop and document")
804+
805+
1. **This is the exact defect class the project exists to prevent.** A client cannot send
806+
`{"bio": null}` and get the same answer from two backends — 400 vs clears vs silent no-op.
807+
The silent no-op is the worst failure mode (a mutation API that quietly ignores an
808+
instruction), and it is especially hostile to the LLM-tools pillar: a model calling a
809+
generated CRUD tool will emit `{"field": null}` intending to clear, and on three ports
810+
nothing happens and nothing says so.
811+
2. **Null-to-clear is a standard adopter need, not theater** — unassign a nullable FK, clear a
812+
due date, blank a bio. RFC 7396 merge-patch made present-null-clears the lingua franca of
813+
partial updates.
814+
3. **Every port's runtime substrate already supports the tristate natively** — Drizzle `.set()`,
815+
EF tracked entities, Exposed `patch(id){}` (shipped in `ad632ac9`,
816+
`AuthorRepositoryBase.kt:52`), OMDB modified-fields, Python dict merge. Only the generated
817+
HTTP handlers lose presence. This is bounded generator work, not an architecture change.
818+
4. **The "cheap middle path" (just stop skipping nulls in C#/Kotlin) is illusory for the JVM
819+
ports.** After DTO binding, null IS absence — un-skipping nulls would re-introduce the
820+
`7fab436b` clobber bug. Any fix that clears columns requires reading raw JSON presence,
821+
i.e. the tristate. At the HTTP tier, "present-key semantics" and "the tristate" are the same
822+
work; there is no cheaper unification. (C# is the exception: it already reads raw JSON — its
823+
fix really is deleting one line and adding a guard.)
824+
5. **Fixing Java/Kotlin's handlers pays twice**: moving them off full-DTO `@Valid` binding to
825+
raw-presence reading also fixes the required-fields-must-be-present-in-every-PATCH wart
826+
(§12.1), unifying BOTH shipped divergences with one change per port.
827+
828+
Cost honestly stated: TS, C#, Python are each roughly half a day. Java is 1–2 days (new
829+
generated `<Entity>Patch` + controller rebind + reference repos). **Kotlin is the expensive
830+
port** (2–3 days): its controller must stop binding the typed data class and drive per-field
831+
JSON dispatch through the already-shipped `repo.patch {}` — this is §10 Phase 3, with its full
832+
controller-snapshot churn. Total ≈ one focused week across ports, gated the whole way.
833+
834+
### 12.3 PATCH-2, finalized (supersedes the §3 wording)
835+
836+
For the generated HTTP update handler (PATCH and PUT alike, per PATCH-7), per body key:
837+
838+
- **Absent** → the column is untouched (already gated by `update-partial-omitted-field-survives`).
839+
- **Present `null`** → non-`@required` field: the column is set SQL NULL, through the same write
840+
path as any value. `@required` field: **400** with the port's structured validation envelope.
841+
- **Present value** → written via the same codec as create (PATCH-4, unchanged).
842+
- A PATCH body may omit `@required` fields (they are simply untouched) — Java/Kotlin's current
843+
400 on that is retired by the same handler change.
844+
- **Unknown keys: silently ignored** — the shipped TS (Zod strip) and C# (`FindProperty` miss →
845+
`continue`) behavior, adopted uniformly. §3 PATCH-3's unknown-key-400 is explicitly DEFERRED
846+
as its own later decision (it would force `.strict()` into TS's Zod schemas — separate blast
847+
radius; today's ports diverge on this axis too, but at far lower severity).
848+
849+
Programmatic tier: unchanged where native tristate already exists (TS `<Entity>Patch` via
850+
`z.input` — needs `.nullable()` to actually admit null; Kotlin `repo.patch {}`; C# tracked
851+
entity; Python dict). Java gains the §4.3 presence-tracked `<Entity>Patch` because its HTTP
852+
handler needs it anyway.
853+
854+
### 12.4 Per-port work and the gate
855+
856+
- **Gate first (red by design):** new
857+
`fixtures/api-contract-conformance/scenarios/update-explicit-null-clears.yaml`, both lanes,
858+
all five ports: r1 `PATCH {"bio": null}` → 200 with `bio: null`; r2 `GET``bio: null`
859+
persisted; r3 `PATCH {"bio": "restored"}` → 200 (provably settable again; leaves state
860+
deterministic); r4 `PATCH {"name": null}` → 400 envelope (required-null). The body of r1
861+
deliberately omits `name`/`createdAt` — it simultaneously gates the required-field-omission
862+
fix. Expected initial state: red on all five (TS 400s r1; Python lacks the r4 guard; C# base
863+
no-ops r1; Kotlin/Java 400 r1 for the wrong reason). Optionally add an explicit-null column to
864+
a persistence-conformance `op: update` scenario to gate the runtime codecs, which are believed
865+
already correct.
866+
- **TS:** in `zod-validators.ts`, non-`@required` UpdateSchema fields append `.nullable()` (keyed
867+
on required-ness, NOT `fieldWillBeOptional` — a required-with-default field must still reject
868+
null); `.set({bio: null})` already writes NULL. Goldens + queries tests.
869+
- **Python:** write path already correct; `router_generator.py` emits a `_REQUIRED_FIELDS`
870+
frozenset (sibling of the filter allowlist) and the update handler 400s present-null on those
871+
keys before calling the repo.
872+
- **C#:** `RoutesGenerator.cs` — base handler replaces the line-219 `continue` with the TPH
873+
branch's null-write, PLUS a `!target.IsNullable` → 400 guard; the TPH handler (487-490) gains
874+
the same guard (today it would 500 on save). One shared emitter for both, per §10 Phase 1.
875+
- **Java:** `<Entity>Patch.fromJson(JsonNode, ObjectMapper)` — bind values by
876+
`mapper.treeToValue` per present field (Jackson handles every subtype exactly as create does;
877+
no hand-rolled parse arms), track presence/null from the node, 400 on required-null; controller
878+
binds `JsonNode` and calls `repository.patch(id, patch)`; `update(id, dto)` stays for
879+
programmatic full-merge use; reference in-memory repos implement `patch` (~15 lines each).
880+
- **Kotlin (the hard one):** controller update handler binds `@RequestBody body: JsonNode`
881+
instead of the `@Valid` data class; per present field, `mapper.treeToValue(node, FieldType)`
882+
(per-field, so non-null ctor params never bite) into the existing generated
883+
`repo.patch(id) { it[col] = v }`; required-null → 400. Reuses the per-field spec list the
884+
generator already builds for filter dispatch. Absorbs the §10 Phase-3 controller-snapshot
885+
churn — commit separately, behavior-gated by the api-contract generated lane across the
886+
refactor.
887+
- **Release shape:** coordinated across npm/NuGet/Maven (+ PyPI), CHANGELOG breaking-banner:
888+
explicit-null in PATCH now clears on C#/Kotlin/Java instead of being silently ignored; TS no
889+
longer 400s it; required-field explicit-null is 400 everywhere; Java/Kotlin PATCH no longer
890+
requires `@required` fields in the body. Land TS/C#/Python first, then Java, then Kotlin;
891+
nothing releases until all five are green on the new scenario.
892+
893+
### 12.5 The Java-patch coupling (why A would also cancel `<Entity>Patch`)
894+
895+
Under option A (present-non-null forever), a presence-tracked `AuthorPatch` is pointless: fed
896+
from a null-guarded merge, presence degenerates to value-non-null — exactly what the existing
897+
compile-safe `update(id, dto)` full-DTO already expresses. Java's patch type earns its existence
898+
only by carrying the absent/null distinction the DTO cannot. The two decisions are one decision;
899+
this section makes them together.

0 commit comments

Comments
 (0)