fix(codegen): complete field.map codegen for the Java and C# ports - #360
Merged
Merged
Conversation
dmealing
force-pushed
the
fm/mo-fieldmap-jsonb-k4
branch
from
September 10, 2026 04:00
a46e3da to
de40933
Compare
…ng the build `field.map` is registered in all five ports, but the Spring port had no `MapField` arm at all: `scalarFields()` excludes only `ObjectField`, so a mapped field flowed straight into the DTO record and hit `SpringTypeMapper`'s unsupported-type throw. Any entity carrying one failed Java codegen outright. Kotlin is the reference implementation and is complete; this mirrors it rather than inventing semantics: - `javaTypeName` gains a `MapField` arm returning `java.util.Map<String, V>`. V is the value object named by `@objectRef` — resolved exactly as the `field.object` arm resolves its own — or the scalar named by `@valueType`, over the same 11 subtypes the loader admits. Scalars are the WRAPPED types (a Java type argument cannot be primitive); `@valueType: timestamp` is an absolute `Instant` unconditionally, since a map value has no column of its own to be "without time zone". - The two `List<>` wrap sites skip a map. isArray does not apply to one, and every other port emits the map bare — wrapping would produce a `List<Map<String,V>>` no other port can round-trip. - The value-object reachability walk now spans a map's `@objectRef`, mirroring the C# `ReferencesValueObject` predicate, which already did. A VO reached only through a map was never emitted, leaving the DTO naming a record that did not exist. - `@Valid` cascades onto a value-object map component. Bean Validation descends into a map's values, matching the TS zod emit's `z.record(z.string(), <VO>InsertSchema)`. A bare `field.map` (neither attr set) still throws rather than guessing a value type — the loader forbids that state, so the throw is the same contract a bare `ObjectField` gets. Tests: 11 mapper arms + a generator suite that asserts the emitted component types, the VO emission, the `@Valid` cascade, and — the strongest proof — that the generated sources actually compile. 241 green on `clean test`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013sjn9TaHoZFdDPF2dLcppv
…ead of none
`EntityGenerator.MapProperty` already emitted `Dictionary<string, V>` with a
`[Column(...)]`, but `DbContextGenerator` had no map branch at all, so EF got no
STORAGE mapping — no column type, no value converter. The property did not persist
the way the TS-owned schema DDL declares it: on Npgsql a `Dictionary<string,string>`
binds to HSTORE by default and a `Dictionary<string,int>` binds to nothing, so the
column the migration creates (jsonb) and the column EF writes disagreed. Silently.
- A top-level `field.map` now emits `.HasColumnType("jsonb").HasConversion(...)`
through a shared `MapJsonb` converter/comparer pair — the C# analog of Kotlin's
`jsonb(col, encoder, decoder)`: an explicit (de)serializer, with no reliance on
Npgsql's dynamic-JSON opt-in that generated code cannot make for a consumer.
- The COMPARER is not decoration. EF snapshots a value-converted property by
reference, so with a converter alone an in-place `entity.Labels["k"] = v` is never
detected and the UPDATE never fires — the same silent non-persistence being fixed
here. The snapshot deep-copies; equality compares the serialized JSON, which is
the right notion for a value object as well as a scalar.
- The helper is gated on the model carrying a map, so a map-free model stays
byte-identical — same discipline as `UnmappedEnumValue`. The gate spans the
flattened-VO case too, or a model whose only map sits inside a flattened value
object would name a helper the file never declares.
- A FLATTENED value object's map member now binds its `<prefix>_<col>` jsonb column
rather than warning. That warning's stated reason was that the port configured a
top-level map nowhere either, so there was no proven mapping to mirror; that is no
longer true. Its requirement was never "warn" — it was "do not bind silently to a
column the migration does not create", which an explicit `HasColumnName` satisfies
properly. The test that pinned the warning now pins the binding.
- The value object's type is emitted FULLY QUALIFIED: the DbContext's usings are a
fixed set covering entity namespaces only, and a value object is neither an entity
nor a view. Same rule the `System.Guid` / `System.Uri` emissions already follow.
The EF surface is proven by compilation, not by string matching: `DbContextCompileTests`
now carries scalar, value-object and flattened-member maps and compiles the emitted
context against real EF Core 8 assemblies, so the two-arg
`HasConversion(ValueConverter, ValueComparer)` overload and the generic helper are
shown to resolve on both receivers.
All C# suites green: 441 codegen, 1024 conformance, 291 render, 77 cli, and 120
Testcontainers-Postgres integration tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sjn9TaHoZFdDPF2dLcppv
…bearing entity The unsupported-type throw lived in the SHARED type mapper, so a `field.map` could break any generator that types a field — fixing the DTO path alone would have left the repository / controller / allowlist / names surfaces failing on the same model with nothing catching it. This runs all six over the fixture. Also pins that a map is NOT offered as a filterable column: no port can lower a filter operator over an open-keyed jsonb map, so admitting one would generate a query surface that fails at the engine. Asserted unconditionally — guarding it on `Files.exists` would let the check evaporate the day the artifact stops being emitted, gating nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013sjn9TaHoZFdDPF2dLcppv
Three places carry the "a JSON column is a ladder" guidance and are meant to agree: `docs/features/field-types.md`, the authoring skill and the audit skill. All three still described the two gaps just closed — Java reaching its unsupported-type throw, C# getting no EF column mapping — so leaving them would make the docs assert the opposite of the code. Corrected, and the honest remainder stated rather than dropped: codegen now completes on all five ports, but that is CODEGEN only. No persistence- or api-contract corpus exercises `field.map` on any port, and the RUNTIME persistence tier is uneven — only Python's ObjectManager encodes a map today; `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry no map handling at all (OMDB's jsonb path is gated on the `@storage` attr a map does not have, and serializes through a per-MetaObject Gson adapter with no map-of-value-object binding). That distinction is what the guidance now turns on, because it is the one that changes an adopter's decision: recommend the map rung where GENERATED CODE is the consumer; prefer a value object where a PORT RUNTIME must read the column back. The old "Java/C# can't do this at all" advice was wrong in a way that would send an adopter down the wrong rung; "it all works now" would be wrong in the other direction. The loader's ladder error message needed no change — it names the rungs without claiming port completeness. Expected-skill fixtures regenerated via `regen-agent-context-conformance.ts`; the sdk corpus test is green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013sjn9TaHoZFdDPF2dLcppv
…o longer throws Every finding is a consequence of the same thing: while `SpringTypeMapper` threw on a `field.map`, no downstream surface could ever see one. Removing the throw made six paths reachable that had never been exercised, and each had been written as "ObjectField or nothing". 1. PAYLOAD (medium-high). `SpringPayloadGenerator.resolveFieldType` routed a map past the nested-payload arm into the plain type mapper, typing the component as the SOURCE value object's FQN — a type the payload path never emits. `nestedTargetOf` matched only `ObjectField`, so the target never entered the emission closure either. A `@payloadRef` VO carrying a map-of-VO therefore emitted a `<Name>Payload` naming a record nothing generated. Now routed through `resolveMapFieldType`, which emits the nested payload and returns `Map<String, <Target>Payload>`; `nestedTargetOf` agrees, as its javadoc requires. 2. C# PROJECTION (medium). The jsonb map config lived only in `EmitFieldTypeConfig`, which never runs for a read-only `object.projection` — but `EntityGenerator` emits the `Dictionary<string, V>` property for one anyway. A view exposing a map got a property with no mapping, which is the exact failure the mapping exists to prevent. The config is now a shared `MapJsonbConfig` called from both sites. 3. SORT ALLOWLIST (medium). Every map name landed in `SORT_ALLOWLIST`, so `?sort=labels:asc` passed and lowered to `ORDER BY` over a jsonb column — meaningless ordering on Postgres, 400 elsewhere, api-contract divergence. The filter side was already safe, but only because no operator band matches a map. 4. UNRESOLVED REF (low). `mapValueJavaType` promised null for an unresolvable `@objectRef` while `MetaDataUtil.getObjectRef` throws — and its own sibling `mapValueObjectRefOf` catches. One dangling ref gave a not-found from the mapper and a silent skip from the validation path. Now caught, as documented. 5. VALUE-OBJECT `@Valid` (low). The entity DTO's cascade was widened to span maps but the VO generator's was not, so nested constraints went unenforced exactly one level down. 6. PATCH (low). POST cascades into a map's value objects via `@Valid`; PATCH validates per-field with `validateValue`, which does not cascade. PATCH could persist a nested value object POST rejects. It now validates each map value. Each fix carries a test that fails without it, including two the old assertions could not have caught: the sort test also asserts a plain string STAYS sortable (so it cannot pass on an empty allowlist), and the projection test asserts the property is emitted before asserting the mapping is. The projection arm is compile-proven by adding a map to `DbContextCompileTests`' projection, since it is a different emitter from the entity one. Java 246 green on `clean test`; C# 442 codegen + 1024 conformance + 291 render + 77 cli green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013sjn9TaHoZFdDPF2dLcppv
…the answer
Cleanup pass over the map work. Nothing here changes what a correct model generates,
with one deliberate exception noted below.
**Sort allowlist → the canonical band.** Both sort allowlists now ask
`FilterOps.supportsFiltering(subType)` — the cross-port answer the filter side already
uses and the loader itself checks in `validateSortableHasSupportedSubtype`. This
closes a hole the hand-listed version had left: the TPH base allowlist still skipped
only `ObjectField`, so a map was unsortable on a vanilla entity and sortable on a TPH
base. Hand-maintained exclusion lists are how that happened; a shared band cannot
drift from the loader.
**One predicate for "which value object does this map carry."** `SpringTypeMapper`
was resolving `@objectRef` itself and accepting ANY target, while its sibling requires
`object.value` — so `@objectRef` naming an entity typed a component as that entity
while nothing emitted a record for it. It now calls the sibling. The FQN rule gets a
name (`fqJavaTypeName`), and the array-wrap rule gets one (`wrapsAsList`) so its two
call sites state it once instead of twice.
**One emitter for element-wise PATCH validation.** The map branch was the array branch
copy-pasted with a renamed loop variable, leaving the 400 envelope in three places.
**C#: gate the helper on what was emitted, not a second derivation of it.**
`NeedsMapJsonbHelper` hand-mirrored three emission sites and was wrong about two —
it undercounted them and did not model `jsonbObjectsOnly` suppressing the flattened
arm. Replaced by a scan of `modelLines`, which is complete at that point: exact by
construction. `MapJsonbSuffix` likewise keeps the top-level and flattened tiers from
drifting.
**The one behavior change — the emitted comparer.** It compared serialized JSON, which
is key-ORDER sensitive: a dictionary rebuilt in a different order read as changed and
would issue an UPDATE for a row nothing touched. It also serialized both dictionaries
on every equality check and deep-copied on every snapshot. Now entry-wise, with
scalars settling on `EqualityComparer<T>.Default` and never serializing; only a
value-object value falls through to JSON, because the generated POCO compares by
reference. The snapshot deep-copies only when the value type needs it. Pinned by test.
**Two KNOWN_GAPS files that had become false.** C# G9 ("a field.map member of a
flattened value object gets no EF column mapping") described the gap this work closed,
prescribed "do not use field.map inside a flattened value object", and pinned itself
to a test renamed two commits ago — while the generator's surviving warning still
says "See KNOWN_GAPS.md". Marked closed with what replaced it. G7's contract clause
called a map an owned navigation, which the converter path contradicts. The Java entry
said field.map was "still staged out, needs a persistence-conformance roundtrip column
first" — the codegen rung did not depend on that gate, but the gate is still open and
still right for the RUNTIME tier, which is now what it says.
**Tests.** A drift gate pins the map's `@valueType` table against `javaTypeName`'s own
arms, so adding a scalar subtype to one and not the other fails instead of shipping a
divergent map. The hand-rolled javac block became
`SpringTestFixtures.compileGenerated` — the 17th copy in the package, and now the
last one that needs writing. Dropped a test wholly subsumed by the table test and a
compile-fixture field covered by two other receivers.
Java 246 green on `clean test`; C# 442 + 1024 + 291 + 77 green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sjn9TaHoZFdDPF2dLcppv
…ettable G7's "Today" paragraph claimed a VO-typed column is skipped on PATCH as a deliberate cross-port Day-1 rule. Program D landed the opposite: the vanilla PATCH/PUT handler passes voFields into AppendPartialMergeLoop, which emits typed value-object arms AHEAD of the generic FindProperty arm (present value -> deserialize + recursive VO validation + assign the CLR nav; present-null clears a nullable column or 400s a @required one; absent -> untouched). Re-scope the entry to the true residual — VO columns on TPH entities, whose per-subtype path passes an empty VO list — and state explicitly that the vanilla-path behavior may mean the entry is already closed except for TPH, with the ruling deliberately deferred to issue #359 (closing needs Program D's intent, which this file does not own). Co-Authored-By: Claude Code <noreply@anthropic.com>
…mption that was only asserted An audit of this branch's four load-bearing claims that came from reasoning rather than from the code. Two verified against the source, one now has a real test, one is deleted. **Deleted — the Npgsql/hstore claim.** Two places asserted that an unmapped `Dictionary<string,string>` binds to `hstore` on Npgsql and a `Dictionary<string,int>` to nothing: the generator's map-loop comment and, worse, `docs/features/field-types.md`, where an adopter reads it as fact. That came from model knowledge. It was never measured against Npgsql, a live database, or the EF provider — and it conflates two layers (Npgsql's ADO type mapping and what the EF provider does with an unmapped CLR property), which are not the same question. The FIX never depended on it, only the stated rationale did, so the rationale is now what can be defended: without this mapping the property has no column type and no converter, so what happens to it is the PROVIDER's business rather than the model's — and the column the TS-owned migration creates is jsonb (ADR-0015), which only an explicit mapping guarantees EF agrees with. That argument holds whatever any provider's defaults turn out to be. The claim also appears in two earlier commit messages on this branch. Those are pushed and are not being rewritten — a rewritten SHA trips the pipeline's custody check — so this commit is the correction of record. **Tested — `@Valid` cascades into a Map's VALUES.** The generator emits `@Valid` on a value-object map component entirely on the strength of that claim, and the only thing gating it was an assertion that the annotation appears in the emitted source. That tests for the presence of a string: if the cascade did not happen, the assertion would still have passed while nested constraints went unenforced on every POST. There is now a test that runs a real validator (Hibernate Validator is already in this module's test scope) over the exact shape the generator emits — `@Valid` on a `Map<String, Bean>` whose value type carries `@NotNull` — and asserts both that a violation is raised and that its path names the nested member. Confirmed it gates: removing the annotation makes it fail with the message naming the assumption. **Verified, kept as written** — the two claims the pipeline's document round wrote: `RoutesGenerator.AppendArrayNullClears` really does emit a post-save `UPDATE ... SET <col> = NULL` for a nullable array-of-VO, and Kotlin's VO PATCH really does bind through Jackson `treeToValue`. Both read in the source rather than taken on the finding's word. Java 247 green on `clean test`; C# 442 + 1024 + 291 + 77 green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013sjn9TaHoZFdDPF2dLcppv
…laims in comments
…letion in [Unreleased]
…values unvalidated A `KNOWN_GAPS.md` entry is the wrong home for this one. The other gaps describe things an adopter can see in their own source or schema; this is GENERATED code that silently accepts invalid nested values, so reading their own source will never reveal it and the failure mode is acceptance rather than an error. It belongs where someone decides to use `field.map`, not only where someone goes looking for gaps. Two places now carry it: - `docs/features/field-types.md`, in the `field.map` section itself, as a callout before the runtime-tier caveat. - The authoring skill's ladder guidance, which an agent reads when choosing the rung. That one also had a claim this makes false: it said the rung "is safe where generated code is the consumer" — generated code as the consumer is precisely where this bites. Corrected rather than merely appended to. The substance, verified in source rather than inferred: the TPH settable set is `scalarFields` minus pk/discriminator/auto-set and `scalarFields` skips only `ObjectField`, so a map is in it; the TPH write paths validate per field with `validateValue`, which does not cascade `@Valid`; and `appendValueObjectValidation` has exactly one call site, on the vanilla handler. Scalar-valued maps are unaffected — no nested bean exists to validate. Newly reachable rather than a regression: before the `MapField` type-mapper arm, a map-bearing entity failed Java codegen outright, so no shipped model can have been using the path. That is why it does not block the release, and why it still needs to be loud. Tracked as issue #362. Expected-skill goldens regenerated; the sdk corpus test is green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013sjn9TaHoZFdDPF2dLcppv
…rue per-port scope
…arer Hash/Snap
…es NULL-column 500s
…nvalidated-write fixes Drives the real generated C# server (Roslyn-compiled generator output hosted on Kestrel over Testcontainers Postgres) rather than reading source: - An_enum_valued_map_component_persists_its_symbol_not_its_ordinal — proves the MapJsonb JsonStringEnumConverter fix (85f87c1) actually persists a field.map @objectref VO's enum members as their symbol, by reading the raw jsonb column back with a direct SQL query independent of the app's own reader. - A_map_valued_address_missing_its_required_street_is_silently_accepted_on_create — confirms live the exact claim docs/features/field-types.md now carries: a nested VO value inside a field.map is unvalidated on the C# create path (201, not 400). Both passed when run against a live Postgres container in this validation round, alongside the pre-existing MapNullColumnGeneratedServerTest (NULL-column read/list/PATCH regression, also verified live).
dmealing
force-pushed
the
fm/mo-fieldmap-jsonb-k4
branch
from
September 10, 2026 07:02
de40933 to
10cd44d
Compare
dmealing
added a commit
that referenced
this pull request
Sep 10, 2026
…tamodel attr `ts-unit` has been failing on main since #360 (run 34448286129, the only red job in that run). The audit skill's new `field.map` rung explains that per-field `validateValue` does not cascade `@Valid` on Java TPH write paths — and the grounding test, which scans code spans for `@attr` tokens and demands each be registered vocabulary, convicted it. The gate is right to be strict: it exists because a shipped skill once taught metadata the loader rejects. This is the false-positive class it already carries an exemption list for, alongside `@RestController` and `@Serializable` — a framework annotation named in prose about a port. `@Valid` joins them with a comment saying which rung names it and why, per the list's convention. Fixing rather than reporting: main is red for everyone until this lands, and the verdict is unambiguous — `jakarta.validation.Valid` is not, and will never be, registered MetaObjects vocabulary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
dmealing
added a commit
that referenced
this pull request
Sep 10, 2026
…uld have mis-attributed 1.0.0 `## [Unreleased]` sat BELOW `## [1.0.0]`, left behind by the 1.0 cut: the cut wrote a short framing entry and never converted the old heading under it, so ~1200 lines of already-shipped 1.0.0 detail were still filed as unreleased. That is not cosmetic. `scripts/release.mjs` promotes by inserting the new version's heading directly after `## [Unreleased]` — so cutting 1.0.1 against this file would have relabelled the whole 1.0.0 body as 1.0.1. The dry run did exactly that, which is how it was found. Fixed by putting each entry under the release that shipped it: #360's `field.map` entry — the only `###` git shows added since v1.0.0 — moves into [1.0.1], the stale heading is dropped so the rest folds back under [1.0.0] where it belongs, and a fresh empty [Unreleased] goes on top for the next cut. My own one-line field.map bullet is removed as redundant now that #360's full entry sits in the section. Content is preserved: net -3 lines, and [1.0.0] keeps all 43 of its entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Docs-only round, required by the maintainer as a precondition of merging this branch. Re-submitted after the previous run failed on a TEST-REPORTING schema error, not a code failure.
TEST STEP - READ THIS FIRST. The prior run failed with: validate test analyzer findings: scenario 4 result "pass" requires live validation. That is a reporting-schema violation, not a broken test. Mark a scenario "pass" ONLY if you actually drove it live in THIS run and observed the result. If you did not execute it, do not report it as passing - report it honestly as not run, or run it. Do not carry a result forward from a previous run or infer one from the diff.
WHAT THIS ROUND IS. A review round found that the field.map codegen change made a surface REACHABLE that silently accepts invalid nested data, and that my first attempt to document it got the SCOPE WRONG. Both are now fixed in docs only.
The gap, verified in source rather than inferred: a field.map @objectref can write its nested value-object values UNVALIDATED, and the scope is per port.
WHY IT IS DOCUMENTED RATHER THAN FIXED, which is a maintainer ruling and not mine: the path is newly reachable (before the MapField type-mapper arm a map-bearing entity failed Java codegen outright, and on C# a map property was emitted but never persisted), so no existing adopter regresses, and holding the branch for a hole in a path nobody uses yet costs more than it buys. Tracked as issue #362.
WHY DOCS AND NOT ONLY KNOWN_GAPS, also a maintainer ruling: unlike the other gaps, this is GENERATED code that silently accepts invalid values - an adopter cannot discover it by reading their own source, and the failure mode is acceptance rather than an error. So the warning goes where someone decides to USE field.map: the field.map section of docs/features/field-types.md, and the authoring and audit skills' ladder guidance, which an agent reads when choosing the rung. Both skills previously told the reader the rung was safe where generated code is the consumer, which is exactly where this bites; both are corrected, and the expected-skill goldens regenerated.
Scope: documentation, skills and regenerated goldens ONLY. No generator, emission, runtime or test behavior changes, and no gap entry closed or narrowed.
This repository is PUBLIC: no private consumer project names, no personal information, no absolute local paths in any committed file or commit message.
What Changed
field.mapcodegen on the two ports that previously failed it: Java'sSpringTypeMapper/DTO/payload/controller generators now emitjava.util.Map<String, V>instead of hitting theunsupported Spring DTO type mappingthrow, and the value-object emission walk now reaches an@objectRefvalue object referenced only through a map. C#'sDbContextGeneratornow emits an explicitjsonbcolumn type plus a converter/comparer pair forDictionary<string, V>properties on entities, read-only projections and flattened value-object members, so the property actually persists to the TS-owned migration's column instead of being dropped by EF.MapJsonbcomparerHash/Snapshotto stop NULL-column reads from 500ing, and enum members inside a map's value object now serialize as their symbol viaJsonStringEnumConverterinstead of the default integer ordinal. Add matching coverage: Java'sSpringMapFieldCodegenTest, C#'sMapNullColumnGeneratedServerTestandMapValueObjectWriteBehaviorGeneratedServerTest, plus updates to the existingDbContextCompileTests/MapFieldCodegenTests/ObjectFieldCodegenTests/SpringTypeMapperTestsuites.field.map @objectRefcan write nested value-object values unvalidated, scoped per port (C#: no write path validates nested map values; Java: only TPH/discriminator write paths skip it) — recorded indocs/features/field-types.md, the C#/Java/KotlinKNOWN_GAPS.mdfiles, themetaobjects-authoring/metaobjects-auditskills, their regenerated golden fixtures, and theCHANGELOG.🤖 Generated with Claude Code
Risk Assessment
✅ Low: The docs round's claims were independently verified against source, and the two pipeline fix commits (MapJsonb null-guard, enum-symbol serialization) are narrow, exactly implement the user-prescribed remedies, and carry genuinely discriminating executed tests; the remaining unvalidated-nested-map-write gap is maintainer-authorized containment tracked as issue #362.
Testing
Stood up the real generated C# API (compiled from the actual generators, not a mock) on Kestrel against a live Testcontainers Postgres and drove three end-to-end scenarios: a NULL field.map column reading/listing/patching cleanly (the regression this test step originally caught and that was then fixed), a map-valued-object's enum member persisting as its symbol rather than its ordinal in the raw jsonb column, and a nested map value-object missing a required field being silently accepted on create (matching the newly-corrected per-port documentation). All three passed live. Wrote and committed one new integration test file covering the latter two scenarios, since no live test previously existed for them (only a non-live in-process compile test covered the enum case). Java's field.map codegen has no live-drivable runtime surface on this branch by design (codegen-only; no port persists a map at runtime except Python, and no conformance corpus touches it), so its regression safety was checked via its existing non-live codegen test suite (38 tests, all green) rather than a live scenario.
Evidence: Live C# field.map integration test run (3/3 passed)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 2 issues found → auto-fixed (2) ✅
docs/features/field-types.md:65- All three locations of the unvalidated-write warning end with a sentence scoping the tracking issue to Java only — field-types.md:65 "[Issue field.map @objectRef writes nested value-object values UNVALIDATED (C#: all write paths; Java: TPH only) #362] tracks the Java TPH half; the C# surface is wider than that issue's scope", agent-context/skills/metaobjects-authoring/SKILL.md:599 "tracks the Java TPH half, and the C# scope is wider than that issue", and the same phrase in agent-context/skills/metaobjects-audit/SKILL.md:369 — but issue field.map @objectRef writes nested value-object values UNVALIDATED (C#: all write paths; Java: TPH only) #362 was retitled at 2026-09-10T04:30:08Z to "field.map @objectref writes nested value-object values UNVALIDATED (C#: all write paths; Java: TPH only)" (verified live via the GitHub timeline API), so it now explicitly covers the C# surface. The docs commit was finalized at 04:42Z, after the rename: the sentence is stale as committed. A reader following any of the three links lands on an issue whose title contradicts the sentence, undermining the one pointer this round exists to give, and suggesting the C# half is untracked when it is not. The round's own user intent already says the whole per-port gap is "Tracked as issue field.map @objectRef writes nested value-object values UNVALIDATED (C#: all write paths; Java: TPH only) #362", so the correction aligns the text with the stated intent. Fix: update the sentence in the three source files (the technical per-port content stays exactly as-is) and regenerate the 10 expected-skill goldens via the sdk regen script.server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs:760- The generated MapJsonb helper null-guards Eq (line 741:if (a is null || b is null) return ReferenceEquals(a, b)) but not Hash (line 760-762:var h = v.Count;dereferences v) or Snap (line 770-775: a scalar-valued map takesnew Dictionary<string, TValue>(v), which throws ArgumentNullException on a null v). EF Core's own built-in value comparers guard null inside the typed lambdas (e.g. its string comparer isv => v == null ? 0 : v.GetHashCode()), and ValueComparer<T>.Snapshot/GetHashCode have no null guard of their own — the comparer contract expects the lambdas to handle null. The null state is reachable through this branch's own semantics: the map column is nullable by default (no @required), C# PATCH can null it (the branch's G7 rewrite states the generic merge arm writes map columns, including present-null), and other ports' inserts leave it NULL. A subsequent query materializes the property as null (the= new()initializer does not survive EF's shaper), and change tracking then runs Snap/Hash over it — a NullReferenceException/ArgumentNullException inside EF on an ordinary read of a row whose map column is NULL, for every scalar-valued map. The compile tests never execute the model, so nothing gates this. Fix: mirror Eq's guard —if (v == null) return 0;in Hash and a null early-return in Snap (returning null preserves current materialization semantics); the guards are dead code if EF never passes null, which is the safe direction.🔧 Fix applied.
1 warning still open:
server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs:724- The MapJsonb helper this branch adds serializes the whole map value with raw System.Text.Json (Options = new()at line 724, consumed by the Converter'sSerialize(v, Options)at 728, and by Eq/Snap's JSON arms), so anyfield.enummember of the map's@objectRefvalue object persists as its ORDINAL —{"tier": 0}— while every other jsonb path in this same generated model deliberately persists the member SYMBOL: the sibling owned-field.objectToJson path emitsHasConversion<string>()per enum member via JsonEnumConversions (DbContextGenerator.cs:1159) with the comment explicitly calling the ordinal form "a cross-port wire-contract break, and a positionally fragile one: reordering @values silently re-maps already-stored data"; TS (z.record+ JSON.stringify), Python (Pydantic) and Kotlin (Jackson) all write the symbol. Concrete trace: DbContextCompileTests' own fixture is the reproducer —Order.sitesisDictionary<string, Address>and Address carriesKind(HOME/WORK) and int-backedTier(@intValueMap A=1,B=2); a C# create through the generated routes persists{"hq":{"kind":0,"tier":0}}— for Tier, the ordinal 0 means neither A (mapped 1) nor B (mapped 2) — and a TS/Python reader of that row fails validation (z.enum/Pydantic reject the number), silently at write time. No gate catches it: no corpus exercises field.map, and the map fixtures in MapFieldCodegenTests/DbContextCompileTests have no enum-valued VO. Note the C# read side is permissive (STJ reads both ordinals and names), so this only corrupts at write. Smallest honest remedy: registerSystem.Text.Json.Serialization.JsonStringEnumConverteron the emittedOptions(matching the ToJson path's symbol convention for both string- and int-backed enums) and extendMapJsonb_comparer_hash_and_snapshot_tolerate_a_null_dictionary-style coverage to a VO with an enum member asserting the provider value carries the symbol — or, if the maintainer prefers the field.map @objectRef writes nested value-object values UNVALIDATED (C#: all write paths; Java: TPH only) #362 containment pattern for this newly-reachable surface, record the divergence there; but nothing in the intent or decisions chose ordinals.🔧 Fix applied.
✅ Re-checked - no issues remain.
🔧 **Test** - 2 issues found → auto-fixed ✅
server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs:1365- Every read arm of a C# adopter's generated API returns HTTP 500 when any row's field.map column is NULL. Live-reproduced against the real product (dotnet meta gen output, EF Core 8.0.10 + Npgsql 8.0.10, Postgres 16 in docker): GET /api/orders/{id}, GET list, and PATCH/PUT all throw System.InvalidCastException "Column 'labels' is null" from NpgsqlDataReader.GetString — the list endpoint is poisoned by a single NULL-map row (deterministic: delete the row → 200, re-insert → 500). Root cause: EntityGenerator.MapProperty emits the map as a NON-nullable CLR propertypublic Dictionary<string, V> X { get; set; } = new();while the column is nullable by default (the TS-owned migration creates nullable jsonb; the committed review discussion states "no @required"), and the new MapJsonb ValueConverter's provider type is string — EF Core 8 omits the shaper's IsDBNull check for non-nullable model properties, so materialization throws before change tracking ever runs. That means the null guards this branch committed in MapJsonb.Hash/Snap (38afbe4) are unreachable through the read path: the test MapJsonb_comparer_hash_and_snapshot_tolerate_a_null_dictionary validates the lambdas in isolation and stays green while the product 500s. NULL cells are reachable cross-port (any non-C# writer or raw insert leaves NULL — the write path itself writes {} via the initializer, so C#-only tables hide the bug). There is no authorable mitigation: @required on a field.map is accepted by the loader but changes nothing in the emission (verified by regenerating with @required — identical property). New on this branch's newly-reachable surface (before the EF mapping, the property was never read from a column); not covered by issue field.map @objectRef writes nested value-object values UNVALIDATED (C#: all write paths; Java: TPH only) #362 (that is the write-side unvalidated-acceptance hole), KNOWN_GAPS.md, or the field-types.md warning. Fix requires an emission decision the author must make (nullable Dictionary<string,V>? property with route/PATCH null-clear semantics, or model-level nullability config, or documenting the read-side gap) — out of bounds for this round's "no emission changes without maintainer instruction" scope.cd server/typescript/packages/sdk && bun test agent-context-conformanceexecuted in this run over the real agent-context/skills files and the 10 updated goldens: 5 pass, 0 failcd server/typescript/packages/sdk && bun test agent-context-conformance — 5 pass, 0 fail over the regenerated expected-skill goldensdotnet test server/csharp/MetaObjects.Codegen.Tests/MetaObjects.Codegen.Tests.csproj — 444 passed, 1 skipped, 0 failed (includes MapJsonb enum-symbol, null-dictionary, and map-mapping tests)cd server/java && mvn -pl codegen-spring -am test -Dtest='SpringMapFieldCodegenTest,SpringTypeMapperTest' -Dsurefire.failIfNoSpecifiedTests=false — 10 + 28 tests, 0 failures (surefire reports)dotnet run --project server/csharp/MetaObjects.Cli -- gen <scratch-meta> --out <scratch-out> --namespace Live.Drive — realdotnet meta genover a field.map model (labels @valueType, sites @objectRef VO with string-backed and int-backed enums)Live C# end-to-end: scratch ASP.NET host wiring the generated MapOrderRoutes exactly like the repo's reference server (JsonStringEnumConverter on HttpJsonOptions, UseNpgsql, EF Core 8.0.10 / Npgsql 8.0.10) against docker postgres:16; curl POST/PATCH/GET drives + psql inspection of raw jsonbLive null-map reproduction: raw-SQL row with NULL labels/sites → GET/GET-list/PATCH all 500 with System.InvalidCastException at NpgsqlDataReader.GetString; delete row → 200; re-insert → 500Live Java drive: consumer main invoking SpringDtoGenerator/SpringValueObjectGenerator/SpringNamesGenerator/SpringRepositoryGenerator/SpringControllerGenerator/SpringFilterAllowlistGenerator via their public API, in-process javac of the emitted CustomerDto/Address, then a real Hibernate Validator 8.0.1.Final cascade over valid and invalid nested map valuesContent inspection: docs/features/field-types.md callout, both skills' ladder paragraphs (stale #362 caveat gone, per-port split present), CHANGELOG [Unreleased] entry🔧 Fix applied.
✅ Re-checked - no issues remain.
dotnet test MetaObjects.IntegrationTests --filter FullyQualifiedName~MapNullColumn (live: Testcontainers Postgres + Kestrel-hosted generated server)dotnet test MetaObjects.IntegrationTests --filter FullyQualifiedName~MapValueObjectWriteBehavior (new test, live: same harness) — added and committed as 59590f0a3dotnet test MetaObjects.Codegen.Tests --filter DbContextCompileTests|MapFieldCodegenTests|ObjectFieldCodegenTests (non-live, executes compiled generated code in-process; regression smoke check)mvn -pl codegen-spring test -Dtest=SpringMapFieldCodegenTest,SpringTypeMapperTest (non-live codegen/compile tests; regression smoke check, Java side unchanged since round 1)gh-axi issue view 362 — confirmed retitled issue text matches the corrected docs/skill wording✅ **Document** - passed
✅ No issues found.
server/csharp/MetaObjects.Codegen.Tests/Issue203AutoSetStampingTests.cs:192- Pre-existing xUnit2031 analyzer warning (Where before Assert.Single) in a file this change never touched; surfaced by the Codegen.Tests build alongside the branch's files. Left unfixed as out of the change's scope — the fix would edit test code in an unrelated file.✅ **Push** - passed
✅ No issues found.