Skip to content

Commit 5d9c0c7

Browse files
dmealingclaude
andcommitted
docs(program-d): spec for cross-port value-object jsonb-column PATCH parity
Corrected design (Fable-reviewed) for making value-object jsonb columns (field.object @storage:jsonb, single + @isarray) PATCH-able on Java/Kotlin/C# to TS/Python parity — the work FR-036 deferred. Bakes in the review's load-bearing correction (jakarta validateValue does NOT cascade @Valid → use explicit validator.validate(voElement) per element) + the per-port scope corrections (Java needs a new VO-record emitter; Kotlin needs read+create+patch; C# needs VO attributes + POST + metadata-driven required-null, typed CLR-property arms not Navigation.CurrentValue). field.map + the Kotlin open-bag are staged out with reasons. Execution deferred to a fresh session (brainstorm→spec→plan→implement). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent dbc4b24 commit 5d9c0c7

1 file changed

Lines changed: 82 additions & 0 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Program D — cross-port value-object jsonb-column PATCH parity (Java / Kotlin / C#)
2+
3+
**Date:** 2026-07-14
4+
**Status:** Planned. Execution deferred to a FRESH session (this doc is the self-contained brief). Design reviewed by a Fable subagent (2026-07-14) which found + corrected a load-bearing flaw and several scope errors — the corrections are baked in below.
5+
**Context:** FR-036 (`0.16.0`/`7.8.0`, shipped) DEFERRED "Program D": object/value-typed jsonb columns are non-PATCH-able on Java/Kotlin/C# by a **deliberate Day-1 simplification** (Java/Kotlin exclude `ObjectField` from the patch set; C# skips the EF owned-nav via a `FindProperty`-miss). TS + Python already PATCH VO columns (verified: TS `UpdateSchema` embeds `<Ref>InsertSchema`; Python `<Name>Patch` types VO fields as the VO model). This closes the gap for **value objects (single + `@isArray`)** so a C#/Java/Kotlin single-port fix can't break the api-contract byte-identical parity.
6+
7+
**Owner rulings (2026-07-14):**
8+
1. **Full nested VO validation on PATCH** (byte-identical with TS's `z.array(<Ref>InsertSchema)`), not bind+write-only.
9+
2. **Scope = value-object columns only** (`field.object @storage:jsonb`, single AND `@isArray`). `field.map` and the Kotlin `field.string @dbColumnType:jsonb` open-bag are **STAGED OUT** to tracked follow-ups (see §6).
10+
11+
---
12+
13+
## 0. The load-bearing correction (Fable, empirically proven — do NOT re-derive)
14+
15+
`jakarta.validation.Validator.validateValue(BeanClass, propertyName, value)` **does NOT cascade `@Valid`** into a nested VO's constraints — this is Jakarta Bean-Validation spec behavior (`validateProperty`/`validateValue` never cascade), proven by compiling + running against the repo's `hibernate-validator-8.0.1.Final`: `validateValue(single VO, @Valid)=0` and `validateValue(List<VO>, @Valid)=0`, while `validate(bean)=2` and `validate(vo)=1`. **The FR-036 vanilla + TPH PATCH paths are built on `validateValue`** (Java `SpringControllerGenerator.java:316,568,595`; Kotlin `KotlinSpringControllerGenerator.kt:440,444,838,842`).
16+
17+
**Correct mechanism:** keep `validateValue(Bean.class, prop, value)` for the property's OWN constraints (`@NotNull`, `validator.array` `@Size` — those DO fire), and ADD explicit `validator.validate(voInstance)` per present VO value (iterate for `List<VO>`). For VO-nested-in-VO (depth ≥ 2), `validate(vo)` only cascades through `@Valid` on the VO class's own members, so emit `@Valid` on nested-VO members of the generated JVM VO classes (or recurse explicitly). C#: per-element `Validator.TryValidateObject(vo, …, validateAllProperties: true)` + manual recursion into nested VO members (`TryValidateObject` is non-recursive).
18+
19+
---
20+
21+
## 1. Parity baseline (verified — the goal is correctly scoped, not over-built)
22+
23+
- **TS: validates nested VO on PATCH.** `UpdateSchema` embeds `<Ref>InsertSchema` (`zod-validators.ts:396-422`) with `.optional()`+`.nullable()` for non-required (`:322-336`); routes bind `updateSchema` (`routes-file.ts:173,197`).
24+
- **Python: validates nested VO on PATCH.** `<Name>Patch` types VO fields as the VO Pydantic model (`entity_model.py:482-507`, import `:212-220`); router runs `<Name>Patch(**dto)` (`router_generator.py:341`); Pydantic recurses.
25+
- **Caveat to pin (Python):** the VO read model keys by `@column` when present (`entity_model.py:266`) — a VO member with a snake_case `@column` validates under the column name, not the wire name. Add a wire-name pin for the VO validation model.
26+
- **Nothing gates VO PATCH over HTTP today** — the new scenarios are first-exercise for ALL 5 ports, both lanes; they may flush latent TS/Python bugs. Budget for it.
27+
28+
---
29+
30+
## 2. Per-port work (each is a DIFFERENT size — verify each file:line)
31+
32+
### Java (`codegen-spring`) — the BIGGEST; a new VO layer
33+
`codegen-spring` generates NOTHING for `object.value` today (`SpringDtoGenerator.execute()` filters entity/projection `:88-89`; class javadoc `:44-48` defers the `field.object` arm). Required:
34+
1. **New VO-record emitter**: a Java record per `object.value` with jakarta constraints on its members (reuse `validationAnnotations` `:572-655`) + `@Valid` on nested-VO members.
35+
2. `SpringDtoGenerator`: stop excluding `ObjectField` from `settableFields`/`scalarFields` (`:443` only filters `ObjectField` for scalars; `:291-304`); add object component-type arms to the `<Entity>Dto` + `<Entity>Patch` (bind via Jackson `treeToValue` to the VO record / `List<VO>`). VO-inside-VO enum qualification: `patchComponentType` (`:311-316`) doesn't reach into VO records — handle.
36+
3. **Controller PATCH**: `validateValue(<Dto>.class, field, value)` (own constraints) + explicit `validator.validate(voElement)` per element (§0). Also POST — Java's create already runs `validator.validate(dto)`; ensure `@Valid` cascade there is likewise replaced by explicit per-element `validate` OR rely on the fact that `validate(dto)` DOES cascade `@Valid` on `dto`'s VO field (validate(bean) cascades; only validateValue doesn't — so POST may already work if the DTO's VO field carries `@Valid`; VERIFY).
37+
4. Repo seam is DTO-typed (`SpringRepositoryGenerator.java:123`) — the DTO must carry the VO field so GET returns it + `patch` writes the jsonb column.
38+
39+
### Kotlin (`codegen-kotlin`) — read + create + patch (not PATCH-only)
40+
The entity data class + Exposed table are already VO-capable (#187 Jackson `MetaJsonbMapper`). But the CONTROLLER skips VO in `rowToEntity` (`KotlinSpringControllerGenerator.kt:302-305`, read) and the create insert loop (`:375-387`) — PATCH-only would make write-only columns invisible in every response → read-back scenarios fail. Required: include VO in read + create + patch; stop excluding `ObjectField` from `patchSettableFields`; bind via `MetaJsonbMapper`; validate = `validateValue` (own) + explicit `validate(voElement)` per element (Kotlin VO data classes already carry constraints — `KotlinEntityGenerator.kt:152-158,494-498`; add `@field:Valid` on nested-VO members). allWarningsAsErrors-clean.
41+
42+
### C# (`codegen`) — merge-loop arms + VO attributes + POST + metadata-required
43+
Entity class, create-bind, and read already work. Required:
44+
1. **Merge loop**: when `FindProperty` misses (`RoutesGenerator.cs:AppendPartialMergeLoop`, the `if (target is null) continue;`), emit **typed per-field arms** (codegen knows the VO fields) that deserialize the JSON to the VO type and assign the **CLR property** on the tracked entity (`existing.<Nav> = <deserialized VO / List<VO>>`) — NOT `entry.Navigation(...).CurrentValue` (unverified; wrong pattern). EF Core 8 `.OwnsOne/.OwnsMany(...).ToJson` persists via `DetectChanges` full-document replacement.
45+
2. **VO validation attributes**: the VO POCOs are emitted attribute-free (`EntityGenerator.cs:596`, `:759-761`). Split validation-attr emission from EF-mapping attrs (the `withAttributes` flag) so VO POCOs carry `[Required]`/`[StringLength]`/etc.
46+
3. **Nested validation on BOTH PATCH and POST**: `TryValidateObject(..., validateAllProperties: true)` is non-recursive — recurse per VO element into nested members. C# doesn't validate nested VO on CREATE either → cover POST in the same scenario.
47+
4. **Metadata-driven required-null 400**: the present-null-on-`@required` check keys off `target.IsNullable` (`RoutesGenerator.cs:513`) — but EF models an owned-nav nullable even when `@required` (`DbContextGenerator.cs:365-369`), so a present-null on a `@required` VO column would 500 (NOT NULL) instead of 400. Emit the required-ness from **metadata at codegen**, never EF model metadata.
48+
49+
---
50+
51+
## 3. The FR-035 tristate for VO columns
52+
Present `[]` vs present-`null` vs absent is unambiguous in Jackson/STJ/Zod/Pydantic — no empty-array-as-null coercion trap. Semantics (identical to scalars): absent→untouched; present-`null`→clears a nullable column / **400 on `@required`** (via the metadata-driven check, §2 C#-4); present-`[]`→writes empty array (not null); present-value→validate+write. C# non-required VO array null-vs-`[]` is deliberately distinct (nullable collection, no initializer — `EntityGenerator.cs:1016-1026`); don't "fix" it. **TS open-bag caveat** (`z.unknown()` accepts null, `zod-validators.ts:390-394`) — irrelevant here since the open bag is staged out, but keep any open-bag gate column non-required.
53+
54+
---
55+
56+
## 4. Gate — extend `fixtures/api-contract-conformance/jsonb/` (Fable-confirmed lower-churn)
57+
The `row` assertion is a per-key **subset** match (`ApiContractAssertions.kt:79-87`) so adding NULLABLE VO columns to `Document` leaves `jsonb-open-bag-roundtrip.yaml` green. All 10 lane surfaces are pre-wired (TS/C#/Java/Kotlin/Python each have a jsonb reference server + generated harness; the Kotlin jsonb lane already runs Testcontainers PG). Add to `Document`: a nullable single-VO column + a nullable array-of-VO column (reuse a `Label`-style `object.value`), and one `@required` VO variant IF the C# metadata-required fix (§2 C#-4) is in scope. New scenarios (both lanes, all 5 ports; the C# generated lane is full-stack vs Testcontainers PG so **every scenario re-reads via GET** to convict persistence):
58+
- present-value valid → 200 + persisted re-read.
59+
- present-value nested-constraint violation → 400 (**cover POST too** — flushes C#'s CREATE gap).
60+
- present-null → clear (non-required); present-`[]` → empty array persists; absent → untouched.
61+
- (if in scope) present-null on `@required` VO → 400.
62+
63+
Each port's jsonb reference server gets matching handling (mechanical column additions + seed).
64+
65+
**Persistence proof already exists** for VO single+array: `roundtrip-all-types.yaml` (`settings` single-VO + `labels` array-of-VO). No new persistence-conformance column needed for the VO scope (only `field.map` would need one — staged out).
66+
67+
---
68+
69+
## 5. Sequencing
70+
1. Gate first (RED): extend the `jsonb/Document` fixture + scenarios; confirm RED on Java/Kotlin/C# generated + reference lanes, GREEN on TS/Python (fix any latent TS/Python bug found).
71+
2. C# (smallest): merge-loop typed arms + VO attributes + POST nested validation + metadata-required.
72+
3. Java (biggest): VO-record emitter → DTO/Patch arms → controller validate → repo seam.
73+
4. Kotlin: read+create+patch inclusion + validate.
74+
5. Fork-per-port (the FR-036 pattern); code-review + simplify per unit; independently re-run each port's gate (don't trust a fork's green).
75+
76+
## 6. Explicitly STAGED OUT (tracked follow-ups, NOT in this program)
77+
- **`field.map @objectRef`** (dict-of-VO jsonb): no fixture exists anywhere (never proven to persist), C# has no EF mapping for `Dictionary<string,VO>` (`EntityGenerator.cs:1042-1048`, no `DbContextGenerator` map arm → EF finalization rejects), and it would hit the Java DTO generator's unsupported-type throw today (`SpringTypeMapper.javaTypeName` has no map arm). **Sequence: a persistence-conformance `field.map` roundtrip column FIRST (SP-H), then the HTTP tier.**
78+
- **Kotlin `field.string @dbColumnType:jsonb` open-bag PATCH**: #187's `MetaJsonbMapper` covers object/map ONLY; the open bag still decodes to kotlinx `JsonElement` (`KotlinExposedTableGenerator.kt:334-350`, `KotlinTypeMapper.kt:313-314`), which Jackson `treeToValue` can't produce (`codegen-kotlin/.../KNOWN_GAPS.md:75-83` still stands). Solvable via a `kotlinx.serialization.json.Json.parseToJsonElement(node.toString())` bridge (the "#179 guard" is import-hygiene, not a hard blocker) — but a distinct mechanism. Java's open-bag is already PATCH-able (typed `Object`), C#'s is an EF scalar property; **Kotlin is the only laggard**, and the all-ports corpus discipline means the open-bag PATCH *scenario* can't land until Kotlin is fixed.
79+
- **TPH entities with VO columns**: Java/Kotlin TPH unions skip `ObjectField` (`SpringDtoGenerator.java:327`). Out of scope.
80+
81+
## 7. Discipline (FR-036 lessons — every one burned someone)
82+
Two-lane real-boot gates, not goldens alone; a golden may ENCODE the bug (verify before regenerating); each new shared wire-tier scenario needs the hand-rolled REFERENCE servers taught the same handling (they lag the generated lane); run an xhigh code-review BEFORE release (FR-036's caught 11 cross-port divergences the corpus missed); JVM generated code compiles under `allWarningsAsErrors`; PG-testcontainer readiness flakes under load (re-run); PUBLIC repo hygiene (no home paths / private names). Study the reference implementation (TS/Python) for the exact nested-validation semantics; don't re-derive.

0 commit comments

Comments
 (0)