You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
**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. |
| 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
0 commit comments