From 1af5e85f495fedd3fea04c0c8930aa6c0a768d7d Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Wed, 9 Sep 2026 21:16:12 -0400
Subject: [PATCH 01/22] feat(codegen-spring): field.map reaches Java codegen
instead of failing the build
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`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`. 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
*/
- protected String resolveMapFieldType(com.metaobjects.field.MapField field,
+ protected String resolveMapFieldType(MapField field,
MetaDataLoader loader,
String nestedPkg,
Path outRoot,
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java
index 131fb8dca..3e2b5b2ec 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java
@@ -135,10 +135,7 @@ public static String javaTypeName(MetaField> field) {
// SpringDtoGenerator.isValueObjectJsonbField; a non-VO ObjectField never lands here.
if (field instanceof ObjectField of && of.hasMetaAttr(ObjectField.ATTR_OBJECTREF)) {
MetaObject ref = of.getObjectRef();
- if (ref != null) {
- String[] split = SpringNaming.splitFqn(ref.getName());
- return split[0].isEmpty() ? split[1] : split[0] + "." + split[1];
- }
+ if (ref != null) return fqJavaTypeName(ref);
}
// field.map -> java.util.Map: an open-keyed map stored in a SINGLE jsonb
// column (the map analog of the field.object arm above). Keys are always String (the
@@ -178,25 +175,17 @@ public static String javaTypeName(MetaField> field) {
* throw rather than guessing a value type, exactly as a bare {@link ObjectField} does.
*/
private static String mapValueJavaType(MapField field) {
- // ADR-0039: @objectRef / @valueType are EFFECTIVE properties -- hasMetaAttr/getMetaAttr
- // (the one-arg, RESOLVING forms) so a value inherited via `extends` is not dropped.
+ // ONE predicate answers "which value object does this map carry" — the same one the
+ // @Valid cascade and the value-object emission walk use. Answering it a second time here
+ // is what let the two disagree: this arm accepted ANY @objectRef target while
+ // mapValueObjectRefOf requires object.value, so an @objectRef naming an ENTITY typed the
+ // component as that entity while nothing ever emitted a record for it.
if (field.hasMetaAttr(MapField.ATTR_OBJECTREF)) {
- MetaObject ref;
- try {
- ref = field.getObjectRef();
- } catch (RuntimeException unresolved) {
- // MetaDataUtil.getObjectRef THROWS MetaDataNotFoundException on a dangling ref
- // rather than returning null. Caught so this method actually keeps the contract
- // its javadoc states, and so it agrees with its sibling
- // SpringDtoGenerator.mapValueObjectRefOf — otherwise one dangling ref produces
- // a not-found from the type mapper and a silent skip from the @Valid /
- // value-object-reachability path.
- return null;
- }
- if (ref == null) return null;
- String[] split = SpringNaming.splitFqn(ref.getName());
- return split[0].isEmpty() ? split[1] : split[0] + "." + split[1];
+ MetaObject ref = SpringDtoGenerator.mapValueObjectRefOf(field);
+ return ref == null ? null : fqJavaTypeName(ref);
}
+ // ADR-0039: @valueType is an EFFECTIVE property — getMetaAttr's one-arg RESOLVING form,
+ // so a value inherited via `extends` is not dropped.
if (!field.hasMetaAttr(MapField.ATTR_VALUE_TYPE)) return null;
String valueType = field.getMetaAttr(MapField.ATTR_VALUE_TYPE).getValueAsString();
if (valueType == null) return null;
@@ -373,6 +362,29 @@ public static List effectiveEnumValues(EnumField field) {
return (raw instanceof List) ? (List) raw : List.of();
}
+ /**
+ * The fully-qualified Java type name of a resolved {@link MetaObject} — {@code acme.crm.Address}
+ * for {@code acme::crm::Address}, or the bare short name at the root package. Fully qualified so
+ * a consuming DTO / {@code Patch} / value-object record needs no import.
+ */
+ static String fqJavaTypeName(MetaObject ref) {
+ String[] split = SpringNaming.splitFqn(ref.getName());
+ return split[0].isEmpty() ? split[1] : split[0] + "." + split[1];
+ }
+
+ /**
+ * True iff a declared {@code isArray} on {@code field} means the emitted component is wrapped
+ * in {@code java.util.List<...>}. THE home of that rule: a {@code field.map} is never wrapped —
+ * isArray does not apply to a map, and every other port emits the map type bare (Kotlin
+ * {@code Map}, TS {@code Record}, Python {@code dict[str,V]}), so wrapping
+ * would produce a {@code List>} no other port can round-trip. Stating it here
+ * rather than at each call site means a third caller cannot miss it.
+ */
+ public static boolean wrapsAsList(MetaField> field) {
+ // ADR-0039: isArrayType() is the effective flag; isArray() is the own-only native one.
+ return field.isArrayType() && !(field instanceof MapField);
+ }
+
/** Uppercase the first character of {@code s}; pass through unchanged when empty/already upper. */
private static String pascal(String s) {
if (s == null || s.isEmpty()) return s;
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java
index d68875dbb..82437bcf7 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java
@@ -6,24 +6,15 @@
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
-import javax.tools.DiagnosticCollector;
-import javax.tools.JavaCompiler;
-import javax.tools.JavaFileObject;
-import javax.tools.ToolProvider;
-import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
/**
* {@code field.map} — an open-keyed map ({@code Map}) stored in a single
@@ -143,7 +134,7 @@ public void aMapIsNotOfferedAsASortableColumn() throws Exception {
String controller = Files.readString(gen.resolve("acme/crm/CustomerController.java"));
int start = controller.indexOf("SORT_ALLOWLIST");
- org.junit.Assert.assertTrue("expected a SORT_ALLOWLIST in the controller", start >= 0);
+ assertTrue("expected a SORT_ALLOWLIST in the controller", start >= 0);
String allowlist = controller.substring(start, controller.indexOf(';', start));
assertFalse("a scalar-valued map must not be sortable; saw:\n" + allowlist,
allowlist.contains("\"labels\""));
@@ -166,8 +157,10 @@ public void patchValidatesTheValuesOfAValueObjectMap() throws Exception {
Path gen = generateAll("patch");
String controller = Files.readString(gen.resolve("acme/crm/CustomerController.java"));
+ // __el, not a map-specific name: the array-of-VO and map-of-VO branches share one
+ // emitter, which is what keeps the 400 envelope in a single place.
assertTrue("expected PATCH to iterate the map's VALUES; saw:\n" + controller,
- controller.contains("for (var __e : patch.addresses().values())"));
+ controller.contains("for (var __el : patch.addresses().values())"));
// A scalar-valued map has no nested bean, so it must NOT get a validation loop.
assertFalse("a scalar-valued map needs no nested validation; saw:\n" + controller,
controller.contains("patch.labels().values()"));
@@ -314,45 +307,9 @@ public void objectValuedMapComponentCascadesValidation() throws Exception {
@Test
public void generatedMapCarryingSourcesCompile() throws Exception {
- // The strongest proof: the emitted DTO + value-object records compile together.
- // This is what catches a Map (illegal type argument) or a DTO naming
- // a value object the reachability walk never emitted.
- Path gen = generate("compile");
-
- List sources;
- try (Stream s = Files.walk(gen)) {
- sources = s.filter(p -> p.toString().endsWith(".java"))
- .map(Path::toFile)
- .collect(Collectors.toList());
- }
- assertFalse("expected generated .java files under " + gen, sources.isEmpty());
-
- JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
- assertNotNull("JDK (not JRE) required — getSystemJavaCompiler() returned null", javac);
-
- Path classes = tmp.newFolder("classes-map").toPath();
- DiagnosticCollector diags = new DiagnosticCollector<>();
- var fm = javac.getStandardFileManager(diags, null, null);
- List opts = List.of(
- "-classpath", System.getProperty("java.class.path"),
- "-d", classes.toString());
-
- boolean ok = javac.getTask(null, fm, diags, opts, null,
- fm.getJavaFileObjectsFromFiles(sources)).call();
- if (!ok) {
- StringBuilder sb = new StringBuilder("generated map-carrying sources failed to compile:\n");
- for (var d : diags.getDiagnostics()) {
- sb.append(" ").append(d.getKind()).append(": ").append(d.getMessage(null)).append('\n');
- if (d.getSource() != null) {
- sb.append(" at ").append(d.getSource().getName())
- .append(':').append(d.getLineNumber()).append('\n');
- }
- }
- for (File f : sources) {
- sb.append("\n=== ").append(f.getName()).append(" ===\n");
- sb.append(Files.readString(f.toPath())).append('\n');
- }
- fail(sb.toString());
- }
+ // The strongest proof: the emitted DTO + value-object records compile together. This is
+ // what catches a Map (illegal type argument) or a DTO naming a value object
+ // the reachability walk never emitted.
+ SpringTestFixtures.compileGenerated(generate("compile"), tmp.newFolder("classes-map").toPath());
}
}
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTestFixtures.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTestFixtures.java
index 2337fbcb0..319d74a45 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTestFixtures.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTestFixtures.java
@@ -8,6 +8,14 @@
import com.metaobjects.template.MetaTemplate;
import com.metaobjects.template.TemplateConstants;
+import javax.tools.Diagnostic;
+import javax.tools.DiagnosticCollector;
+import javax.tools.JavaCompiler;
+import javax.tools.JavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.ToolProvider;
+
+import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
@@ -255,6 +263,58 @@ public static MetaDataLoader loadFixture(Path parent, String baseName, String fi
return loader;
}
+ /**
+ * Compile every {@code .java} under {@code genRoot} in-process against the test classpath,
+ * failing with the diagnostics AND a full source dump when it does not succeed.
+ *
+ *
Compiling is the only check that distinguishes generated code that READS right from
+ * generated code that IS right — a wrong type argument, or a reference to a record no
+ * generator emitted, is invisible to a string assertion. Sixteen test classes in this
+ * package had each hand-rolled this block; new tests should call this instead.
+ *
+ * @param genRoot directory the generators wrote into
+ * @param classesDir a fresh directory for {@code javac} output
+ */
+ static void compileGenerated(Path genRoot, Path classesDir) throws IOException {
+ List sources;
+ try (java.util.stream.Stream walk = Files.walk(genRoot)) {
+ sources = walk.filter(f -> f.toString().endsWith(".java"))
+ .map(Path::toFile)
+ .collect(java.util.stream.Collectors.toList());
+ }
+ if (sources.isEmpty()) {
+ throw new AssertionError("expected generated .java files under " + genRoot);
+ }
+
+ JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
+ if (javac == null) {
+ throw new AssertionError("JDK (not JRE) required — getSystemJavaCompiler() returned null");
+ }
+ DiagnosticCollector diags = new DiagnosticCollector<>();
+ StandardJavaFileManager fm = javac.getStandardFileManager(diags, null, null);
+ List opts = List.of(
+ "-classpath", System.getProperty("java.class.path"),
+ "-d", classesDir.toString());
+
+ if (javac.getTask(null, fm, diags, opts, null,
+ fm.getJavaFileObjectsFromFiles(sources)).call()) {
+ return;
+ }
+ StringBuilder sb = new StringBuilder("generated sources failed to compile:\n");
+ for (Diagnostic extends JavaFileObject> d : diags.getDiagnostics()) {
+ sb.append(" ").append(d.getKind()).append(": ").append(d.getMessage(null)).append('\n');
+ if (d.getSource() != null) {
+ sb.append(" at ").append(d.getSource().getName())
+ .append(':').append(d.getLineNumber()).append('\n');
+ }
+ }
+ for (File f : sources) {
+ sb.append("\n=== ").append(f.getName()).append(" ===\n")
+ .append(Files.readString(f.toPath())).append('\n');
+ }
+ throw new AssertionError(sb.toString());
+ }
+
/**
* Load the inline {@code fixtureJson} string into a fresh loader (writing to
* a system temp file) and return the named value-object, resolved by short
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java
index 5293723bc..fbf69c357 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java
@@ -7,6 +7,7 @@
import com.metaobjects.field.EnumField;
import com.metaobjects.field.IntegerField;
import com.metaobjects.field.LongField;
+import com.metaobjects.field.MetaField;
import com.metaobjects.field.StringField;
import com.metaobjects.field.TimeField;
import com.metaobjects.field.TimestampField;
@@ -210,15 +211,6 @@ private static com.metaobjects.field.MapField scalarMap(String name, String valu
return f;
}
- @Test
- public void scalarValuedMapFieldMapsToMapOfString() {
- // field.map @valueType:string -> java.util.Map. Keys are ALWAYS
- // String (the JSON-object constraint); the value is the named scalar. Cross-port:
- // Kotlin Map, TS Record, Python dict[str, str].
- assertEquals("java.util.Map",
- SpringTypeMapper.javaTypeName(scalarMap("labels", StringField.SUBTYPE_STRING)));
- }
-
@Test
public void mapValueTypeCoversEveryScalarSubtypeTheLoaderAllows() {
// The 11 scalar @valueType subtypes ValidationPhase.MAP_SCALAR_VALUE_SUBTYPES admits,
@@ -249,6 +241,35 @@ public void mapValueTypeCoversEveryScalarSubtypeTheLoaderAllows() {
SpringTypeMapper.javaTypeName(scalarMap("m", com.metaobjects.field.UuidField.SUBTYPE_UUID)));
}
+ @Test
+ public void everyMapValueTypeAgreesWithTheSameFieldSubtypesOwnMapping() {
+ // The map's @valueType table and javaTypeName's per-subtype arms are two hand-written
+ // copies of one rule, so adding a scalar subtype means editing both — and only one of
+ // them is covered by the unsupported-type throw. This pins them together: for every
+ // @valueType the loader admits, Map must carry EXACTLY the type the same
+ // field subtype maps to on its own. A new subtype added to one table and not the other
+ // fails here rather than shipping a silently divergent map.
+ java.util.Map> byValueType = new java.util.LinkedHashMap<>();
+ byValueType.put(StringField.SUBTYPE_STRING, new StringField("f"));
+ byValueType.put(IntegerField.SUBTYPE_INT, new IntegerField("f"));
+ byValueType.put(LongField.SUBTYPE_LONG, new LongField("f"));
+ byValueType.put(DoubleField.SUBTYPE_DOUBLE, new DoubleField("f"));
+ byValueType.put(com.metaobjects.field.FloatField.SUBTYPE_FLOAT, new com.metaobjects.field.FloatField("f"));
+ byValueType.put(com.metaobjects.field.DecimalField.SUBTYPE_DECIMAL, new com.metaobjects.field.DecimalField("f"));
+ byValueType.put(BooleanField.SUBTYPE_BOOLEAN, new BooleanField("f"));
+ byValueType.put(DateField.SUBTYPE_DATE, new DateField("f"));
+ byValueType.put(TimeField.SUBTYPE_TIME, new TimeField("f"));
+ byValueType.put(TimestampField.SUBTYPE_TIMESTAMP, new TimestampField("f"));
+ byValueType.put(com.metaobjects.field.UuidField.SUBTYPE_UUID, new com.metaobjects.field.UuidField("f"));
+
+ for (java.util.Map.Entry> e : byValueType.entrySet()) {
+ assertEquals(
+ "field.map @valueType:" + e.getKey() + " must carry the same Java type as field." + e.getKey(),
+ "java.util.Map",
+ SpringTypeMapper.javaTypeName(scalarMap("m", e.getKey())));
+ }
+ }
+
@Test
public void mapValueTypeTimestampIgnoresLocalTimeAndStaysInstant() {
// A map VALUE has no column of its own to be "timestamp without time zone", so
From e8d7aeca54f3a3bfbf11eac1c6e03551bb64ba98 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Wed, 9 Sep 2026 22:33:34 -0400
Subject: [PATCH 07/22] no-mistakes(document): Fix stale field.map
port-coverage claim in authoring skill
---
agent-context/skills/metaobjects-authoring/SKILL.md | 2 +-
.../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +-
.../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +-
.../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +-
.../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +-
.../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +-
6 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md
index f934f1e20..d5fbb21f9 100644
--- a/agent-context/skills/metaobjects-authoring/SKILL.md
+++ b/agent-context/skills/metaobjects-authoring/SKILL.md
@@ -546,7 +546,7 @@ the column.** It already names the type — that is the metadata. Take the first
|---|---|---|
| `list[str]` / `string[]` / `List` — plural name, typed elements | the element subtype + `isArray: true` | a native array — **never** a bag holding a list |
| a dataclass / DTO / record / `@Serializable` class — a fixed key set | an **`object.value`** (no identity, no source), then `field.object` + `@objectRef` + `@storage: jsonb` (`isArray: true` for a list of them) | the VO's own type: `.$type()` + its Zod schema, the Pydantic model (`Create` on the wire), a Jackson-coded Exposed column, an EF owned type — gated in all five ports |
-| `dict[str, X]` / `Record` / `Map` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map` over a Jackson jsonb codec (Kotlin). **Java and C# do not complete this rung — see below** |
+| `dict[str, X]` / `Record` / `Map` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map` over a Jackson jsonb codec (Kotlin), `java.util.Map` (Java), `Dictionary` over an EF jsonb converter (C#). **Codegen completes on all five ports; the runtime persistence tier does not — see below** |
| `dict[str, Any]` / `JsonNode` / `unknown`, and no reader pins a key | `field.string` + `@dbColumnType: jsonb` | the parsed value, untyped — the deliberate escape hatch |
Only the last row is an open bag, and there it is correct: a pass-through payload, a raw
diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
index f934f1e20..d5fbb21f9 100644
--- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -546,7 +546,7 @@ the column.** It already names the type — that is the metadata. Take the first
|---|---|---|
| `list[str]` / `string[]` / `List` — plural name, typed elements | the element subtype + `isArray: true` | a native array — **never** a bag holding a list |
| a dataclass / DTO / record / `@Serializable` class — a fixed key set | an **`object.value`** (no identity, no source), then `field.object` + `@objectRef` + `@storage: jsonb` (`isArray: true` for a list of them) | the VO's own type: `.$type()` + its Zod schema, the Pydantic model (`Create` on the wire), a Jackson-coded Exposed column, an EF owned type — gated in all five ports |
-| `dict[str, X]` / `Record` / `Map` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map` over a Jackson jsonb codec (Kotlin). **Java and C# do not complete this rung — see below** |
+| `dict[str, X]` / `Record` / `Map` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map` over a Jackson jsonb codec (Kotlin), `java.util.Map` (Java), `Dictionary` over an EF jsonb converter (C#). **Codegen completes on all five ports; the runtime persistence tier does not — see below** |
| `dict[str, Any]` / `JsonNode` / `unknown`, and no reader pins a key | `field.string` + `@dbColumnType: jsonb` | the parsed value, untyped — the deliberate escape hatch |
Only the last row is an open bag, and there it is correct: a pass-through payload, a raw
diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md
index f934f1e20..d5fbb21f9 100644
--- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -546,7 +546,7 @@ the column.** It already names the type — that is the metadata. Take the first
|---|---|---|
| `list[str]` / `string[]` / `List` — plural name, typed elements | the element subtype + `isArray: true` | a native array — **never** a bag holding a list |
| a dataclass / DTO / record / `@Serializable` class — a fixed key set | an **`object.value`** (no identity, no source), then `field.object` + `@objectRef` + `@storage: jsonb` (`isArray: true` for a list of them) | the VO's own type: `.$type()` + its Zod schema, the Pydantic model (`Create` on the wire), a Jackson-coded Exposed column, an EF owned type — gated in all five ports |
-| `dict[str, X]` / `Record` / `Map` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map` over a Jackson jsonb codec (Kotlin). **Java and C# do not complete this rung — see below** |
+| `dict[str, X]` / `Record` / `Map` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map` over a Jackson jsonb codec (Kotlin), `java.util.Map` (Java), `Dictionary` over an EF jsonb converter (C#). **Codegen completes on all five ports; the runtime persistence tier does not — see below** |
| `dict[str, Any]` / `JsonNode` / `unknown`, and no reader pins a key | `field.string` + `@dbColumnType: jsonb` | the parsed value, untyped — the deliberate escape hatch |
Only the last row is an open bag, and there it is correct: a pass-through payload, a raw
diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md
index f934f1e20..d5fbb21f9 100644
--- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -546,7 +546,7 @@ the column.** It already names the type — that is the metadata. Take the first
|---|---|---|
| `list[str]` / `string[]` / `List` — plural name, typed elements | the element subtype + `isArray: true` | a native array — **never** a bag holding a list |
| a dataclass / DTO / record / `@Serializable` class — a fixed key set | an **`object.value`** (no identity, no source), then `field.object` + `@objectRef` + `@storage: jsonb` (`isArray: true` for a list of them) | the VO's own type: `.$type()` + its Zod schema, the Pydantic model (`Create` on the wire), a Jackson-coded Exposed column, an EF owned type — gated in all five ports |
-| `dict[str, X]` / `Record` / `Map` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map` over a Jackson jsonb codec (Kotlin). **Java and C# do not complete this rung — see below** |
+| `dict[str, X]` / `Record` / `Map` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map` over a Jackson jsonb codec (Kotlin), `java.util.Map` (Java), `Dictionary` over an EF jsonb converter (C#). **Codegen completes on all five ports; the runtime persistence tier does not — see below** |
| `dict[str, Any]` / `JsonNode` / `unknown`, and no reader pins a key | `field.string` + `@dbColumnType: jsonb` | the parsed value, untyped — the deliberate escape hatch |
Only the last row is an open bag, and there it is correct: a pass-through payload, a raw
diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
index f934f1e20..d5fbb21f9 100644
--- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -546,7 +546,7 @@ the column.** It already names the type — that is the metadata. Take the first
|---|---|---|
| `list[str]` / `string[]` / `List` — plural name, typed elements | the element subtype + `isArray: true` | a native array — **never** a bag holding a list |
| a dataclass / DTO / record / `@Serializable` class — a fixed key set | an **`object.value`** (no identity, no source), then `field.object` + `@objectRef` + `@storage: jsonb` (`isArray: true` for a list of them) | the VO's own type: `.$type()` + its Zod schema, the Pydantic model (`Create` on the wire), a Jackson-coded Exposed column, an EF owned type — gated in all five ports |
-| `dict[str, X]` / `Record` / `Map` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map` over a Jackson jsonb codec (Kotlin). **Java and C# do not complete this rung — see below** |
+| `dict[str, X]` / `Record` / `Map` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map` over a Jackson jsonb codec (Kotlin), `java.util.Map` (Java), `Dictionary` over an EF jsonb converter (C#). **Codegen completes on all five ports; the runtime persistence tier does not — see below** |
| `dict[str, Any]` / `JsonNode` / `unknown`, and no reader pins a key | `field.string` + `@dbColumnType: jsonb` | the parsed value, untyped — the deliberate escape hatch |
Only the last row is an open bag, and there it is correct: a pass-through payload, a raw
diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md
index f934f1e20..d5fbb21f9 100644
--- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -546,7 +546,7 @@ the column.** It already names the type — that is the metadata. Take the first
|---|---|---|
| `list[str]` / `string[]` / `List` — plural name, typed elements | the element subtype + `isArray: true` | a native array — **never** a bag holding a list |
| a dataclass / DTO / record / `@Serializable` class — a fixed key set | an **`object.value`** (no identity, no source), then `field.object` + `@objectRef` + `@storage: jsonb` (`isArray: true` for a list of them) | the VO's own type: `.$type()` + its Zod schema, the Pydantic model (`Create` on the wire), a Jackson-coded Exposed column, an EF owned type — gated in all five ports |
-| `dict[str, X]` / `Record` / `Map` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map` over a Jackson jsonb codec (Kotlin). **Java and C# do not complete this rung — see below** |
+| `dict[str, X]` / `Record` / `Map` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map` over a Jackson jsonb codec (Kotlin), `java.util.Map` (Java), `Dictionary` over an EF jsonb converter (C#). **Codegen completes on all five ports; the runtime persistence tier does not — see below** |
| `dict[str, Any]` / `JsonNode` / `unknown`, and no reader pins a key | `field.string` + `@dbColumnType: jsonb` | the parsed value, untyped — the deliberate escape hatch |
Only the last row is an open bag, and there it is correct: a pass-through payload, a raw
From ca5a418a6a0023292f386fa65cd4ecd9caa99db1 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Wed, 9 Sep 2026 22:43:02 -0400
Subject: [PATCH 08/22] no-mistakes(document): Correct C# G7: field.map is
patch-settable, not in gap
---
server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md b/server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md
index 1b3ce8ffa..de47154a8 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md
+++ b/server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md
@@ -37,9 +37,11 @@ Update when a gap closes or a new one surfaces.
**Today.** We emit `{ "error": "not_found" }` for 404 and `{ "error": "validation", "message": "..." }` for the sort-validation 400. **FR-036 wired explicit body validation with the cross-port `{ "error": "validation" }` envelope:** a POST runs `Validator.TryValidateObject(input, ...)` and a PATCH runs `Validator.TryValidateProperty(value, ...)` per present value → 400 `{ "error": "validation" }` (ASP.NET minimal-API never runs DataAnnotations on its own, so the annotations were previously decorative at the wire tier). The `issues` array is still TS-only idiomatic (Tier 2).
-### G7 — Object/value-typed columns are non-PATCHable (deliberate, cross-port)
+### G7 — Value-object columns are non-PATCHable (deliberate, cross-port)
-**Contract.** A `field.object` column (a value-object mapped to jsonb, single or `@isArray`) is EF-mapped as an owned navigation (`.OwnsOne`/`.OwnsMany(...).ToJson`). A `field.map` column is NOT a navigation — it is a scalar-shaped property carrying a value converter (see G9) — but it is non-PATCHable for the same reason described below, since the merge loop skips it on the same cross-port Day-1 rule Java and Kotlin apply.
+**Contract.** A `field.object` column (a value-object mapped to jsonb, single or `@isArray`) is EF-mapped as an owned navigation (`.OwnsOne`/`.OwnsMany(...).ToJson`).
+
+**`field.map` is not in this gap.** A map column is not a navigation — it is a scalar-shaped property carrying a value converter (G9) — so `FindProperty` resolves it and the generic merge arm writes it on PATCH. Map columns are patch-settable here, as on Java; Kotlin alone stages them out of its patch set (see its `KNOWN_GAPS.md`).
**Today.** The partial-PATCH merge loop keys off `entry.Metadata.FindProperty(prop.Name)`, which returns null for a navigation, so a VO-typed column is skipped on PATCH (present values too). This is a **deliberate cross-port Day-1 simplification**, consistent with Java + Kotlin (both exclude `ObjectField` from the patch set) — NOT a C#-specific bug (FR-036 assessed a C#-only fix and rejected it: it would break the api-contract byte-identical parity). Making VO-typed columns PATCHable (bind the owned nav via `entry.Navigation(...).CurrentValue`, cross-port) is a separately-scoped follow-up FR.
From 468bd10063e661e3d15bd61bcab09639981cf17e Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Wed, 9 Sep 2026 22:52:07 -0400
Subject: [PATCH 09/22] =?UTF-8?q?no-mistakes(document):=20Correct=20C#=20G?=
=?UTF-8?q?7=20=E2=80=94=20vanilla=20VO=20columns=20are=20patch-settable?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md b/server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md
index de47154a8..5411ddd73 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md
+++ b/server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md
@@ -37,13 +37,17 @@ Update when a gap closes or a new one surfaces.
**Today.** We emit `{ "error": "not_found" }` for 404 and `{ "error": "validation", "message": "..." }` for the sort-validation 400. **FR-036 wired explicit body validation with the cross-port `{ "error": "validation" }` envelope:** a POST runs `Validator.TryValidateObject(input, ...)` and a PATCH runs `Validator.TryValidateProperty(value, ...)` per present value → 400 `{ "error": "validation" }` (ASP.NET minimal-API never runs DataAnnotations on its own, so the annotations were previously decorative at the wire tier). The `issues` array is still TS-only idiomatic (Tier 2).
-### G7 — Value-object columns are non-PATCHable (deliberate, cross-port)
+### G7 — Value-object columns on TPH entities are still skipped on PATCH
**Contract.** A `field.object` column (a value-object mapped to jsonb, single or `@isArray`) is EF-mapped as an owned navigation (`.OwnsOne`/`.OwnsMany(...).ToJson`).
**`field.map` is not in this gap.** A map column is not a navigation — it is a scalar-shaped property carrying a value converter (G9) — so `FindProperty` resolves it and the generic merge arm writes it on PATCH. Map columns are patch-settable here, as on Java; Kotlin alone stages them out of its patch set (see its `KNOWN_GAPS.md`).
-**Today.** The partial-PATCH merge loop keys off `entry.Metadata.FindProperty(prop.Name)`, which returns null for a navigation, so a VO-typed column is skipped on PATCH (present values too). This is a **deliberate cross-port Day-1 simplification**, consistent with Java + Kotlin (both exclude `ObjectField` from the patch set) — NOT a C#-specific bug (FR-036 assessed a C#-only fix and rejected it: it would break the api-contract byte-identical parity). Making VO-typed columns PATCHable (bind the owned nav via `entry.Navigation(...).CurrentValue`, cross-port) is a separately-scoped follow-up FR.
+**Today.** On the vanilla (non-TPH) PATCH/PUT handler a VO-typed column IS patch-settable. The handler passes the entity's VO fields into `AppendPartialMergeLoop` (`RoutesGenerator.cs:256`), which emits a Program D typed value-object arm for each one AHEAD of the generic `entry.Metadata.FindProperty(prop.Name)` arm — an owned navigation is invisible to `FindProperty`, so the generic scalar path alone would silently drop the column. Per arm: a present value is deserialized, recursively validated (`ValueObjectValidator.Validate`), and assigned to the CLR navigation property; a present `null` clears a nullable VO column — a nullable array-of-VO additionally takes a post-save raw UPDATE, since EF's `OwnsMany(...).ToJson` writes `[]` for a null collection nav rather than SQL NULL — or 400s a `@required` one; an absent key leaves the column untouched. This is the same Program D shipment Java's `KNOWN_GAPS.md` records as cross-port (TS / Python / Java / Kotlin / C#), gated by `fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-patch.yaml` in both lanes.
+
+**Residual.** TPH only: the per-subtype partial-update path passes an empty VO list (`RoutesGenerator.cs:961`), so VO columns on a TPH-rooted entity are still skipped on PATCH — the same staging-out Java's entry records for TPH.
+
+**Close status.** The vanilla-path behaviour above may mean this entry is already closed for everything except the TPH residual; that ruling is deliberately NOT made here because it needs Program D's intent, which this file does not own. It is filed for exactly that decision as [issue #359](https://github.com/metaobjectsdev/metaobjects/issues/359). Until ruled, the entry stays open against the TPH residual.
### G5 — `EfCoreFilterDispatch` ordered-comparison fallback
From be1631462ff4c12107b920286c731f8b23530cef Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Wed, 9 Sep 2026 23:07:53 -0400
Subject: [PATCH 10/22] no-mistakes(document): Correct Kotlin KNOWN_GAPS VO/map
PATCH bullet by shape
---
.../generator/kotlin/KNOWN_GAPS.md | 41 +++++++++++++++----
1 file changed, 33 insertions(+), 8 deletions(-)
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KNOWN_GAPS.md b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KNOWN_GAPS.md
index 004348f88..3e2db2468 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KNOWN_GAPS.md
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KNOWN_GAPS.md
@@ -64,14 +64,39 @@ value → set). Notes:
PATCH's present-null-on-`@required` is still rejected (explicit guard), but a
present-VALUE subtype constraint is not enforced (Java, using per-subtype `Dto`,
does enforce it). Minor pre-existing divergence, untested by any gate.
-- **Object/value-typed columns are non-PATCHable (deliberate, cross-port).** A
- `field.object`/`field.map` column (VO → jsonb, single or `@isArray`) — and a
- `field.string @dbColumnType=jsonb` open-bag — are EXCLUDED from the patch settable
- set (`patchSettableFields` skips `ObjectField`/`MapField`/`isJsonbOpenBag`), so a
- `PATCH` leaves them untouched. This is a **deliberate cross-port Day-1 simplification**
- (Java + Kotlin exclude `ObjectField`; C# skips the owned-nav) — NOT a per-port bug.
- Making VO/open-bag columns PATCHable is a separately-scoped cross-port follow-up FR;
- a single-port fix would break the api-contract byte-identical parity.
+- **Object/map-typed columns: PATCHability is per shape (split by Program D).** The
+ blanket rule this bullet used to carry — a `field.object`/`field.map` column is
+ EXCLUDED from the patch settable set, so a `PATCH` leaves it untouched — is stale
+ for one shape. What the vanilla path's `patchSettableFields` does today, shape by
+ shape:
+ - `field.object @storage jsonb` (the storage default, single or `@isArray`) —
+ SETTABLE: a present value binds via Jackson `treeToValue` into the VO record /
+ `List` and is validated in full; a present `null` clears a nullable column
+ or 400s a `@required` one; absent → untouched (the FR-035 tristate). The
+ sibling `codegen-spring` module's `KNOWN_GAPS.md` records the shipment as
+ cross-port (TS / Python / Java / Kotlin / C#), gated by
+ `fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-patch.yaml`.
+ - `field.object @storage flattened` — EXCLUDED: the Exposed table materialises it
+ as per-subfield columns; there is no single `Table.` to bind.
+ - `field.map` — EXCLUDED (`it !is MapField`): dict-of-VO, staged out.
+ - `field.string @dbColumnType=jsonb` open-bag — EXCLUDED (`isJsonbOpenBag`):
+ create-only (next bullet); its PATCH is the tracked Kotlin follow-up (the
+ kotlinx `parseToJsonElement` bridge) the sibling `codegen-spring`
+ `KNOWN_GAPS.md` lists under "Still staged out".
+ The remaining exclusions are deliberate staging (each for the substrate reason
+ above), not per-port bugs — save the map one, which is a live divergence from
+ Java and C# (both patch map columns; C#'s G7 records the split). Closing any of
+ them is separately-scoped follow-up work, not a piecemeal port fix.
+ **TPH residual.** The per-subtype path has its own SSOT —
+ `KotlinTphPlan.subtypeSettableFields` still filters `ObjectField` out outright —
+ so a VO column on a TPH-rooted entity is skipped on PATCH, the same TPH
+ staging-out Java's and C#'s entries record.
+ **Close status.** With the jsonb-VO shape settable, what remains here may mean
+ the gap this bullet tracked is already closed; that ruling is deliberately NOT
+ made here because it needs Program D's intent, which this file does not own. It
+ is filed for exactly that decision as
+ [issue #359](https://github.com/metaobjectsdev/metaobjects/issues/359). Until
+ ruled, the entry stays open against the residuals above.
- **`field.string @dbColumnType=jsonb` open-bag** is a **create-only** column on the
generated CRUD. The generated `create` writes it (bound from the `@Valid` DTO's
kotlinx `JsonElement` property — exercised by the `jsonb-open-bag-roundtrip` corpus),
From 6bd387ad9ce1554ae6fb43e079333ee57818fad2 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Wed, 9 Sep 2026 23:16:55 -0400
Subject: [PATCH 11/22] fix(docs): retire an unverified provider claim, and
test the one assumption that was only asserted
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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` binds to `hstore` on Npgsql and a `Dictionary`
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` 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
= 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)
Claude-Session: https://claude.ai/code/session_013sjn9TaHoZFdDPF2dLcppv
---
docs/features/field-types.md | 4 +-
.../Generators/DbContextGenerator.cs | 13 +++---
.../spring/SpringMapFieldCodegenTest.java | 44 +++++++++++++++++++
3 files changed, 54 insertions(+), 7 deletions(-)
diff --git a/docs/features/field-types.md b/docs/features/field-types.md
index 5893ee641..2c56c1257 100644
--- a/docs/features/field-types.md
+++ b/docs/features/field-types.md
@@ -44,8 +44,8 @@ Three rows need a footnote:
`java.util.Map` (and reaches a map's `@objectRef` value object in the
value-object emission walk, so the referenced record is actually generated); C# emits the
`Dictionary` property AND the EF jsonb storage mapping — a column type plus an
- explicit converter/comparer pair, because a `Dictionary` left unmapped
- binds to `hstore` on Npgsql rather than to the `jsonb` column the migration creates.
+ explicit converter/comparer pair, so the property lands on the `jsonb` column the TS-owned
+ migration creates instead of on whatever an unmapped dictionary would resolve to.
**The RUNTIME tier is not there yet, and no conformance corpus covers it.** No
persistence- or api-contract-conformance fixture exercises `field.map` on any port; it is
diff --git a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs
index e1f7b6a7b..2d0e881bd 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs
@@ -887,11 +887,14 @@ private void EmitFieldTypeConfig(
&& (!jsonbObjectsOnly || f.Storage != STORAGE_FLATTENED)))
if (OwnedTypeConfig(className, entity, f, ctx) is { } cfg) modelLines.Add(cfg);
- // field.map -> a single jsonb column holding the JSON object. Without this the
- // property gets NO storage mapping at all and does not persist the way the TS-owned
- // schema DDL declares it: on Npgsql a Dictionary binds to HSTORE by
- // default and a Dictionary binds to nothing, so the column the migration
- // creates (jsonb) and the column EF writes disagree. Not gated by jsonbObjectsOnly --
+ // field.map -> a single jsonb column holding the JSON object. Without this the property
+ // gets NO storage mapping at all: nothing tells EF the column type, and nothing tells it
+ // how to turn the dictionary into the column's value. What a given provider does with an
+ // unmapped Dictionary is then the PROVIDER's business, not this model's -- which is the
+ // problem, because the column the TS-owned migration creates is jsonb (ADR-0015) and only
+ // an explicit mapping makes EF agree with it. Stated as a property of the mapping rather
+ // than of any provider's defaults: those were asserted here from memory and never
+ // measured. Not gated by jsonbObjectsOnly --
// a map's storage is ALWAYS the single jsonb column, so a write-through entity's
// read model declares it too (same rule as the enum / decimal / timestamp loops).
// The C# analog of Kotlin's jsonb(col, encoder, decoder): an explicit (de)serializer,
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java
index 82437bcf7..6be7c1903 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java
@@ -206,6 +206,50 @@ public void aValueObjectNestingAMapOfValueObjectsCascadesValidation() throws Exc
Files.exists(gen.resolve("acme/crm/Badge.java")));
}
+ /** A nested bean for {@link #validAnnotationActuallyCascadesIntoMapValues()}. */
+ public record CascadeNested(@jakarta.validation.constraints.NotNull String street) {}
+
+ /** A holder whose map component carries the same {@code @Valid} the generator emits. */
+ public record CascadeHolder(
+ @jakarta.validation.Valid java.util.Map addresses) {}
+
+ @Test
+ public void validAnnotationActuallyCascadesIntoMapValues() {
+ // The generator emits `@Valid` on a value-object map component on the strength of one
+ // claim: that Bean Validation descends into a MAP's VALUES. Asserting that the
+ // annotation appears in the emitted source does NOT test that claim — it tests that a
+ // string is present. If the cascade did not happen, every one of those assertions would
+ // still pass while nested constraints went unenforced on POST.
+ //
+ // So run a real validator (Hibernate Validator is on this module's test classpath) over
+ // the exact shape the generator emits: @Valid on a Map whose value type
+ // carries @NotNull. A violation for the nested member is the proof.
+ try (jakarta.validation.ValidatorFactory factory =
+ jakarta.validation.Validation.buildDefaultValidatorFactory()) {
+ jakarta.validation.Validator validator = factory.getValidator();
+
+ java.util.Map bad = new java.util.HashMap<>();
+ bad.put("home", new CascadeNested(null)); // violates @NotNull one level down
+ java.util.Set> violations =
+ validator.validate(new CascadeHolder(bad));
+
+ assertTrue("@Valid on a Map component must cascade into its VALUES; got no violation"
+ + " for a nested @NotNull breach — the generator's cascade assumption is wrong",
+ !violations.isEmpty());
+ // ...and it is the NESTED member that is reported, not the map itself.
+ String path = violations.iterator().next().getPropertyPath().toString();
+ assertTrue("expected the violation path to name the nested member; saw: " + path,
+ path.contains("street"));
+
+ // A well-formed value produces no violation, so the assertion above is not passing
+ // for some unrelated reason.
+ java.util.Map good = new java.util.HashMap<>();
+ good.put("home", new CascadeNested("1 Main St"));
+ assertTrue("a valid nested value must produce no violation",
+ validator.validate(new CascadeHolder(good)).isEmpty());
+ }
+ }
+
@Test
public void aPayloadMapNamesThePayloadRecordNotTheSourceValueObject() throws Exception {
// A payload component must name a PAYLOAD record. Typing it as the source value
From 47dd8b707fe1677cbeea5d300d6dffcad5bb38d0 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Wed, 9 Sep 2026 23:23:38 -0400
Subject: [PATCH 12/22] no-mistakes(review): Retire remaining unverified Npgsql
map-binding claims in comments
---
.../MapFieldCodegenTests.cs | 7 ++++---
.../Generators/DbContextGenerator.cs | 15 ++++++++-------
2 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/server/csharp/MetaObjects.Codegen.Tests/MapFieldCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/MapFieldCodegenTests.cs
index 9f0b34ee7..cc15fe862 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/MapFieldCodegenTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/MapFieldCodegenTests.cs
@@ -139,9 +139,10 @@ public void A_read_only_projection_map_column_also_gets_its_jsonb_mapping()
{
// EntityGenerator emits the Dictionary property for a PROJECTION too, but the
// DbContext's projection loop emits only ToView + enum conversions — so a view
- // exposing a field.map got a Dictionary property with no mapping at all. That is
- // exactly the failure this whole mapping exists to prevent: a Dictionary
- // binds to nothing on Npgsql, so AppDbContext fails model building.
+ // exposing a field.map got a Dictionary property with no mapping at all: no column
+ // type and no converter, so only an explicit mapping makes EF agree with the jsonb
+ // column the TS-owned migration creates (ADR-0015). That is exactly the failure this
+ // whole mapping exists to prevent.
const string model = """
{ "metadata.root": { "package": "acme", "children": [
{ "object.projection": { "name": "CustomerSummary", "children": [
diff --git a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs
index 2d0e881bd..e5dece61a 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs
@@ -110,10 +110,10 @@ public virtual IEnumerable Generate(GenContext ctx)
foreach (var f in p.Fields().Where(f => f.SubType == FIELD_SUBTYPE_ENUM && !f.ResolvedIsArray()))
modelLines.Add($" modelBuilder.Entity<{name}>().Property(x => x.{CSharpNaming.Pascal(f.Name)}).{EnumConversionCall(name, p, f, ctx.Config)};");
// A field.map column on a view needs its jsonb mapping for the SAME reason:
- // EntityGenerator emits the Dictionary property for a projection too, and an
- // unmapped Dictionary binds to nothing on Npgsql, so the model fails
- // to build. This loop and EmitFieldTypeConfig's are the two call sites
- // NeedsMapJsonbHelper has to cover.
+ // EntityGenerator emits the Dictionary property for a projection too, so without
+ // this loop the property carries no column type and no converter -- only an
+ // explicit mapping makes EF agree with the jsonb column the TS-owned migration
+ // creates (ADR-0015).
foreach (var f in p.Fields().Where(f => f.SubType == FIELD_SUBTYPE_MAP))
modelLines.Add(MapJsonbConfig(name, f, ctx));
}
@@ -897,9 +897,10 @@ private void EmitFieldTypeConfig(
// measured. Not gated by jsonbObjectsOnly --
// a map's storage is ALWAYS the single jsonb column, so a write-through entity's
// read model declares it too (same rule as the enum / decimal / timestamp loops).
- // The C# analog of Kotlin's jsonb(col, encoder, decoder): an explicit (de)serializer,
- // no reliance on Npgsql's dynamic-JSON opt-in the generated code cannot make for a
- // consumer. The COMPARER is not optional -- see EmitMapJsonbHelper.
+ // The C# analog of Kotlin's jsonb(col, encoder, decoder): the converter is emitted
+ // explicitly, so the mapping does not depend on any host-side serializer configuration
+ // the generated code cannot make on a consumer's behalf. The COMPARER is not optional --
+ // see EmitMapJsonbHelper.
foreach (var f in fieldList.Where(f => f.SubType == FIELD_SUBTYPE_MAP))
modelLines.Add(MapJsonbConfig(className, f, ctx));
From a39d148e656b081f1c8aefaa2ac3a24bff662a47 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Wed, 9 Sep 2026 23:29:33 -0400
Subject: [PATCH 13/22] no-mistakes(review): Rename map-VO tests to match
assertion, not cascade claim
---
.../spring/SpringMapFieldCodegenTest.java | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java
index 6be7c1903..ecd98fc09 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java
@@ -167,9 +167,11 @@ public void patchValidatesTheValuesOfAValueObjectMap() throws Exception {
}
@Test
- public void aValueObjectNestingAMapOfValueObjectsCascadesValidation() throws Exception {
- // The entity DTO cascades into a map of value objects; a VALUE OBJECT nesting the same
- // shape must too, or the nested constraints go unenforced exactly one level down.
+ public void aValueObjectNestingAMapOfValueObjectsIsAnnotatedValid() throws Exception {
+ // The entity DTO gets @Valid on a map of value objects; a VALUE OBJECT nesting the same
+ // shape must too, or the nested constraints go unenforced exactly one level down. This
+ // asserts the annotation is EMITTED on the right component, not that it cascades —
+ // validAnnotationActuallyCascadesIntoMapValues() is the test that proves the cascade.
final String nested = """
{
"metadata.root": { "package": "acme::crm", "children": [
@@ -334,14 +336,16 @@ public void objectValuedMapBecomesAMapOfTheValueObjectRecord() throws Exception
}
@Test
- public void objectValuedMapComponentCascadesValidation() throws Exception {
+ public void objectValuedMapComponentIsAnnotatedValid() throws Exception {
Path gen = generate("valid");
String dto = Files.readString(gen.resolve("acme/crm/CustomerDto.java"));
// Parity with the TS zod emit, which types an @objectRef map as
// z.record(z.string(), InsertSchema) — i.e. the map VALUES are validated.
- // @Valid on a Map component cascades to its values under Bean Validation, so the
- // nested VO's own constraints (Address.street @NotNull/@Size) are enforced on POST.
+ // This asserts the generator EMITS @Valid on the value-object map component, so the
+ // nested VO's own constraints (Address.street @NotNull/@Size) are wired for enforcement
+ // on POST — validAnnotationActuallyCascadesIntoMapValues() is the test that proves Bean
+ // Validation actually cascades through that annotation.
assertTrue("expected @Valid on the value-object map component; saw:\n" + dto,
dto.contains("@Valid java.util.Map addresses"));
// A SCALAR-valued map has no nested bean to cascade into — it must NOT get @Valid.
From bd990ea718fc82243279481755d59e77eab610f9 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Wed, 9 Sep 2026 23:54:14 -0400
Subject: [PATCH 14/22] no-mistakes(document): Scope Java map-PATCH claims;
record TPH map-validation gap
---
.../com/metaobjects/generator/spring/KNOWN_GAPS.md | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md
index 2474f7ece..71aa3cd90 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md
@@ -160,8 +160,9 @@ array, distinct from present-null → SQL NULL).
**`field.map` (dict-of-VO) now ships** on the DTO / `Patch` / controller / value-object
walk: the component types as `Map`, `@Valid` cascades into the map's values on POST,
-and PATCH validates each value explicitly. That entry used to say a persistence-conformance
-roundtrip column was needed first; the CODEGEN rung did not in fact depend on it, and the gate
+and PATCH validates each value explicitly — vanilla handler; the TPH carve-out is below.
+That entry used to say a persistence-conformance roundtrip column was needed first; the
+CODEGEN rung did not in fact depend on it, and the gate
it named is still open and still the right one — for the RUNTIME tier, not this one. OMDB does
not read or write a map (its jsonb path keys off the `@storage` attr a map does not carry), so a
mapped column is generated-code-only until that lands. See `docs/features/field-types.md`.
@@ -169,7 +170,13 @@ mapped column is generated-code-only until that lands. See `docs/features/field-
**Still staged out** (tracked follow-ups): the Kotlin `field.string @dbColumnType=jsonb`
open-bag PATCH (needs a kotlinx `parseToJsonElement` bridge).
TPH entities with VO columns also remain out of scope (the TPH union skips
-`ObjectField`).
+`ObjectField`). A `field.map` is the one nested-value shape that DOES reach the TPH
+artifacts — the TPH settable set is `scalarFields` MINUS pk/discriminator/auto-set, and
+`scalarFields` skips only `ObjectField` — but the TPH write paths validate per field via
+`validateValue`, which does not cascade, so a map's values are accepted UNVALIDATED on TPH
+create/PATCH (the `@Valid` on the `Dto` component is decorative there). Before the
+`MapField` type-mapper arm, a map-bearing TPH entity failed generation outright, so this
+shape is newly reachable and untested by any gate.
## `SpringPayloadGenerator.resolveObjectByShortOrFqn` has zero in-repo callers
From a293eb4bf05462ddf79337ecf23531a55dac8deb Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 10 Sep 2026 00:00:49 -0400
Subject: [PATCH 15/22] no-mistakes(document): docs(changelog): record
field.map codegen completion in [Unreleased]
---
CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc1dae242..398690050 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,40 @@ found is in this entry and the ones below it.
## [Unreleased]
+### Fixed — `field.map` codegen completes: Java generates it, C# persists it
+
+The subtype is registered in all five ports and TypeScript, Kotlin and Python already
+generated it. Java and C# were the two halves left, failing in opposite directions — and
+**this entry is CODEGEN only**, a bound the last paragraph states because the headline
+invites the wider reading.
+
+**Java (`codegen-spring`) failed the build outright.** A `field.map` on an entity flowed
+into the DTO record and reached `SpringTypeMapper`'s unsupported-type throw, so any entity
+carrying one failed Java codegen. It now emits `java.util.Map` — `V` the scalar
+named by `@valueType` or the value object named by `@objectRef` — and the value-object
+emission walk now spans a map's `@objectRef`, so a record reached only through a map is
+actually generated rather than merely named by a DTO. `isArray` does not apply to a map, so
+the type is never wrapped in `List<>`; every other port emits the map bare.
+
+**C# (`MetaObjects.Codegen`) emitted the property but not the storage.**
+`EntityGenerator`'s `Dictionary` property was already there;
+`DbContextGenerator` had no map branch, so EF got no column type and no converter — the
+property did not persist onto the `jsonb` column the TS-owned migration creates (ADR-0015).
+It now emits an explicit jsonb column type plus a shared converter/comparer pair, on
+entities, read-only projections and flattened value-object members alike. The comparer is
+load-bearing, not decoration: EF snapshots a value-converted property by reference, so a
+converter alone would leave an in-place `entity.Labels["k"] = v` undetected and the UPDATE
+would never fire.
+
+**Scope.** No runtime persistence layer reads or writes a map except Python's
+`ObjectManager`, and no persistence- or api-contract-conformance corpus exercises
+`field.map` on any port — the subtype remains loader- and codegen-gated only, and
+[field-types.md](docs/features/field-types.md) carries the full runtime picture. An
+adopter who read "field.map now works" out of this entry would be over-reading it.
+**`metamodelVersion` does not move**: no registered vocabulary changed, and
+`expected-registry.json` is untouched.
+
+
### Added — `meta verify` advises when a provider still carries the prop 1.0 renamed away
`` does not typecheck. The `0.x → 1.0` migration note and
From 7a96877e5d42b9204875d0206c1f7d3132ccd6a2 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 10 Sep 2026 00:07:01 -0400
Subject: [PATCH 16/22] docs(field-types): warn at point of use that a TPH map
writes nested values unvalidated
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_013sjn9TaHoZFdDPF2dLcppv
---
agent-context/skills/metaobjects-authoring/SKILL.md | 12 ++++++++++--
docs/features/field-types.md | 11 +++++++++++
.../.claude/skills/metaobjects-authoring/SKILL.md | 12 ++++++++++--
.../.claude/skills/metaobjects-authoring/SKILL.md | 12 ++++++++++--
.../.claude/skills/metaobjects-authoring/SKILL.md | 12 ++++++++++--
.../.claude/skills/metaobjects-authoring/SKILL.md | 12 ++++++++++--
.../.claude/skills/metaobjects-authoring/SKILL.md | 12 ++++++++++--
7 files changed, 71 insertions(+), 12 deletions(-)
diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md
index d5fbb21f9..2cf29ba47 100644
--- a/agent-context/skills/metaobjects-authoring/SKILL.md
+++ b/agent-context/skills/metaobjects-authoring/SKILL.md
@@ -582,8 +582,16 @@ jsonb storage mapping. **But that is CODEGEN only: no persistence- or api-contra
fixture 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. So a map you intend to read back through a PORT RUNTIME is
-still better declared as a value object; the `field.map` rung is safe where generated code is
-the consumer, and a genuinely dynamic key set stays a bag. Every rung but the first keeps the
+still better declared as a value object, and a genuinely dynamic key set stays a bag.
+
+**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
+entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
+generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
+not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
+A posted value violating the referenced `object.value`'s constraints is accepted and written,
+silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
+(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/docs/features/field-types.md b/docs/features/field-types.md
index 2c56c1257..8e2855dee 100644
--- a/docs/features/field-types.md
+++ b/docs/features/field-types.md
@@ -47,6 +47,17 @@ Three rows need a footnote:
explicit converter/comparer pair, so the property lands on the `jsonb` column the TS-owned
migration creates instead of on whatever an unmapped dictionary would resolve to.
+ > ⚠️ **On a TPH (discriminator-rooted) entity, a `field.map @objectRef` writes nested
+ > value-object values UNVALIDATED.** The generated TPH create/PATCH handlers validate
+ > field-by-field with `validateValue`, which does not cascade `@Valid` into a nested bean,
+ > and the explicit cascade the vanilla handler runs is not invoked on the TPH path — so a
+ > posted map value that violates the referenced `object.value`'s own constraints is
+ > accepted and written. Scalar-valued maps (`@valueType`) are unaffected: there is no
+ > nested bean to validate. This is generated code, so **reading your own source will not
+ > reveal it** — the failure is silent acceptance, not an error. Validate map values at your
+ > own boundary on TPH entities until this closes, or keep the map on a non-TPH entity.
+ > Tracked as [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362).
+
**The RUNTIME tier is not there yet, and no conformance corpus covers it.** No
persistence- or api-contract-conformance fixture exercises `field.map` on any port; it is
loader- and codegen-gated only. Of the runtime persistence layers, only Python's
diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
index d5fbb21f9..2cf29ba47 100644
--- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -582,8 +582,16 @@ jsonb storage mapping. **But that is CODEGEN only: no persistence- or api-contra
fixture 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. So a map you intend to read back through a PORT RUNTIME is
-still better declared as a value object; the `field.map` rung is safe where generated code is
-the consumer, and a genuinely dynamic key set stays a bag. Every rung but the first keeps the
+still better declared as a value object, and a genuinely dynamic key set stays a bag.
+
+**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
+entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
+generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
+not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
+A posted value violating the referenced `object.value`'s constraints is accepted and written,
+silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
+(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md
index d5fbb21f9..2cf29ba47 100644
--- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -582,8 +582,16 @@ jsonb storage mapping. **But that is CODEGEN only: no persistence- or api-contra
fixture 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. So a map you intend to read back through a PORT RUNTIME is
-still better declared as a value object; the `field.map` rung is safe where generated code is
-the consumer, and a genuinely dynamic key set stays a bag. Every rung but the first keeps the
+still better declared as a value object, and a genuinely dynamic key set stays a bag.
+
+**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
+entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
+generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
+not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
+A posted value violating the referenced `object.value`'s constraints is accepted and written,
+silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
+(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md
index d5fbb21f9..2cf29ba47 100644
--- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -582,8 +582,16 @@ jsonb storage mapping. **But that is CODEGEN only: no persistence- or api-contra
fixture 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. So a map you intend to read back through a PORT RUNTIME is
-still better declared as a value object; the `field.map` rung is safe where generated code is
-the consumer, and a genuinely dynamic key set stays a bag. Every rung but the first keeps the
+still better declared as a value object, and a genuinely dynamic key set stays a bag.
+
+**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
+entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
+generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
+not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
+A posted value violating the referenced `object.value`'s constraints is accepted and written,
+silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
+(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
index d5fbb21f9..2cf29ba47 100644
--- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -582,8 +582,16 @@ jsonb storage mapping. **But that is CODEGEN only: no persistence- or api-contra
fixture 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. So a map you intend to read back through a PORT RUNTIME is
-still better declared as a value object; the `field.map` rung is safe where generated code is
-the consumer, and a genuinely dynamic key set stays a bag. Every rung but the first keeps the
+still better declared as a value object, and a genuinely dynamic key set stays a bag.
+
+**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
+entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
+generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
+not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
+A posted value violating the referenced `object.value`'s constraints is accepted and written,
+silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
+(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md
index d5fbb21f9..2cf29ba47 100644
--- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -582,8 +582,16 @@ jsonb storage mapping. **But that is CODEGEN only: no persistence- or api-contra
fixture 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. So a map you intend to read back through a PORT RUNTIME is
-still better declared as a value object; the `field.map` rung is safe where generated code is
-the consumer, and a genuinely dynamic key set stays a bag. Every rung but the first keeps the
+still better declared as a value object, and a genuinely dynamic key set stays a bag.
+
+**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
+entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
+generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
+not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
+A posted value violating the referenced `object.value`'s constraints is accepted and written,
+silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
+(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
From 3914a01709fbcd545b0251a6bf15a97846554261 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 10 Sep 2026 00:25:29 -0400
Subject: [PATCH 17/22] no-mistakes(review): Correct field.map
unvalidated-write warning to true per-port scope
---
.../skills/metaobjects-audit/SKILL.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 22 ++++++++++------
docs/features/field-types.md | 26 ++++++++++++-------
.../.claude/skills/metaobjects-audit/SKILL.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 22 ++++++++++------
.../.claude/skills/metaobjects-audit/SKILL.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 22 ++++++++++------
.../.claude/skills/metaobjects-audit/SKILL.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 22 ++++++++++------
.../.claude/skills/metaobjects-audit/SKILL.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 22 ++++++++++------
.../.claude/skills/metaobjects-audit/SKILL.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 22 ++++++++++------
13 files changed, 106 insertions(+), 64 deletions(-)
diff --git a/agent-context/skills/metaobjects-audit/SKILL.md b/agent-context/skills/metaobjects-audit/SKILL.md
index 08935869e..908fa18f8 100644
--- a/agent-context/skills/metaobjects-audit/SKILL.md
+++ b/agent-context/skills/metaobjects-audit/SKILL.md
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
**Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason.
- **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So recommend this rung freely where GENERATED CODE is the consumer, and prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
+ **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
---
diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md
index 2cf29ba47..eb6d7bf36 100644
--- a/agent-context/skills/metaobjects-authoring/SKILL.md
+++ b/agent-context/skills/metaobjects-authoring/SKILL.md
@@ -584,14 +584,20 @@ Python's `ObjectManager` encodes a map today; `runtime-ts`, Java's OMDB and the
lane carry no map handling at all. So a map you intend to read back through a PORT RUNTIME is
still better declared as a value object, and a genuinely dynamic key set stays a bag.
-**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
-entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
-generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
-not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
-A posted value violating the referenced `object.value`'s constraints is accepted and written,
-silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
-(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
-[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
+**One sharp edge where generated code IS the consumer: nested map values can be written
+UNVALIDATED, per port.** TypeScript (`z.record` over the VO's insert schema) and Python
+(`dict[str, VO]` Pydantic) validate map values, and Kotlin writes no map column. **Java**
+validates them on its vanilla create/PATCH handlers but NOT on TPH (discriminator-rooted) write
+paths — those validate field-by-field with `validateValue`, which does not cascade `@Valid`.
+**C# validates them on NO write path** — vanilla create, vanilla PATCH, and TPH alike: the map
+never reaches the recursively-validating value-object arms (they admit `field.object` only),
+and the generic arms check the dictionary property itself, never its values. A posted value
+violating the referenced `object.value`'s constraints is accepted and written, silently, and
+reading the adopter's own source will not reveal it. Scalar-valued maps (`@valueType`) are
+unaffected. Do not recommend this rung for a Java TPH entity — or for C# at all — without
+saying so and pointing at boundary validation of map values before write;
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider
+than that issue. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/docs/features/field-types.md b/docs/features/field-types.md
index 8e2855dee..4fdfde117 100644
--- a/docs/features/field-types.md
+++ b/docs/features/field-types.md
@@ -47,16 +47,22 @@ Three rows need a footnote:
explicit converter/comparer pair, so the property lands on the `jsonb` column the TS-owned
migration creates instead of on whatever an unmapped dictionary would resolve to.
- > ⚠️ **On a TPH (discriminator-rooted) entity, a `field.map @objectRef` writes nested
- > value-object values UNVALIDATED.** The generated TPH create/PATCH handlers validate
- > field-by-field with `validateValue`, which does not cascade `@Valid` into a nested bean,
- > and the explicit cascade the vanilla handler runs is not invoked on the TPH path — so a
- > posted map value that violates the referenced `object.value`'s own constraints is
- > accepted and written. Scalar-valued maps (`@valueType`) are unaffected: there is no
- > nested bean to validate. This is generated code, so **reading your own source will not
- > reveal it** — the failure is silent acceptance, not an error. Validate map values at your
- > own boundary on TPH entities until this closes, or keep the map on a non-TPH entity.
- > Tracked as [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362).
+ > ⚠️ **A `field.map @objectRef` can write its nested value-object values UNVALIDATED — and
+ > the scope is per port.** TypeScript (`z.record` over the VO's insert schema) and Python
+ > (`dict[str, VO]` Pydantic) validate map values, and Kotlin writes no map column at all. The
+ > hole is in the other two: **Java** validates nested map values on its vanilla
+ > create/PATCH handlers but NOT on TPH (discriminator-rooted) write paths, which validate
+ > field-by-field with `validateValue` — that does not cascade `@Valid` into a nested bean,
+ > and the explicit cascade the vanilla handler runs is not invoked there. **C# validates
+ > them on NO write path — not TPH, not vanilla create, not vanilla PATCH**: the map property
+ > never reaches the recursively-validating value-object arms (they admit `field.object` only),
+ > and the generic arms check the dictionary property itself, never its values. A posted map
+ > value that violates the referenced `object.value`'s own constraints is accepted and
+ > written. Scalar-valued maps (`@valueType`) are unaffected: there is no nested bean to
+ > validate. This is generated code, so **reading your own source will not reveal it** — the
+ > failure is silent acceptance, not an error. Validate map values at your own boundary
+ > before write. [Issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks
+ > the Java TPH half; the C# surface is wider than that issue's scope.
**The RUNTIME tier is not there yet, and no conformance corpus covers it.** No
persistence- or api-contract-conformance fixture exercises `field.map` on any port; it is
diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md
index 08935869e..908fa18f8 100644
--- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md
+++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
**Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason.
- **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So recommend this rung freely where GENERATED CODE is the consumer, and prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
+ **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
---
diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
index 2cf29ba47..eb6d7bf36 100644
--- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -584,14 +584,20 @@ Python's `ObjectManager` encodes a map today; `runtime-ts`, Java's OMDB and the
lane carry no map handling at all. So a map you intend to read back through a PORT RUNTIME is
still better declared as a value object, and a genuinely dynamic key set stays a bag.
-**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
-entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
-generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
-not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
-A posted value violating the referenced `object.value`'s constraints is accepted and written,
-silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
-(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
-[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
+**One sharp edge where generated code IS the consumer: nested map values can be written
+UNVALIDATED, per port.** TypeScript (`z.record` over the VO's insert schema) and Python
+(`dict[str, VO]` Pydantic) validate map values, and Kotlin writes no map column. **Java**
+validates them on its vanilla create/PATCH handlers but NOT on TPH (discriminator-rooted) write
+paths — those validate field-by-field with `validateValue`, which does not cascade `@Valid`.
+**C# validates them on NO write path** — vanilla create, vanilla PATCH, and TPH alike: the map
+never reaches the recursively-validating value-object arms (they admit `field.object` only),
+and the generic arms check the dictionary property itself, never its values. A posted value
+violating the referenced `object.value`'s constraints is accepted and written, silently, and
+reading the adopter's own source will not reveal it. Scalar-valued maps (`@valueType`) are
+unaffected. Do not recommend this rung for a Java TPH entity — or for C# at all — without
+saying so and pointing at boundary validation of map values before write;
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider
+than that issue. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md
index 08935869e..908fa18f8 100644
--- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md
+++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
**Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason.
- **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So recommend this rung freely where GENERATED CODE is the consumer, and prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
+ **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
---
diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md
index 2cf29ba47..eb6d7bf36 100644
--- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -584,14 +584,20 @@ Python's `ObjectManager` encodes a map today; `runtime-ts`, Java's OMDB and the
lane carry no map handling at all. So a map you intend to read back through a PORT RUNTIME is
still better declared as a value object, and a genuinely dynamic key set stays a bag.
-**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
-entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
-generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
-not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
-A posted value violating the referenced `object.value`'s constraints is accepted and written,
-silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
-(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
-[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
+**One sharp edge where generated code IS the consumer: nested map values can be written
+UNVALIDATED, per port.** TypeScript (`z.record` over the VO's insert schema) and Python
+(`dict[str, VO]` Pydantic) validate map values, and Kotlin writes no map column. **Java**
+validates them on its vanilla create/PATCH handlers but NOT on TPH (discriminator-rooted) write
+paths — those validate field-by-field with `validateValue`, which does not cascade `@Valid`.
+**C# validates them on NO write path** — vanilla create, vanilla PATCH, and TPH alike: the map
+never reaches the recursively-validating value-object arms (they admit `field.object` only),
+and the generic arms check the dictionary property itself, never its values. A posted value
+violating the referenced `object.value`'s constraints is accepted and written, silently, and
+reading the adopter's own source will not reveal it. Scalar-valued maps (`@valueType`) are
+unaffected. Do not recommend this rung for a Java TPH entity — or for C# at all — without
+saying so and pointing at boundary validation of map values before write;
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider
+than that issue. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md
index 08935869e..908fa18f8 100644
--- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md
+++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
**Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason.
- **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So recommend this rung freely where GENERATED CODE is the consumer, and prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
+ **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
---
diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md
index 2cf29ba47..eb6d7bf36 100644
--- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -584,14 +584,20 @@ Python's `ObjectManager` encodes a map today; `runtime-ts`, Java's OMDB and the
lane carry no map handling at all. So a map you intend to read back through a PORT RUNTIME is
still better declared as a value object, and a genuinely dynamic key set stays a bag.
-**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
-entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
-generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
-not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
-A posted value violating the referenced `object.value`'s constraints is accepted and written,
-silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
-(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
-[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
+**One sharp edge where generated code IS the consumer: nested map values can be written
+UNVALIDATED, per port.** TypeScript (`z.record` over the VO's insert schema) and Python
+(`dict[str, VO]` Pydantic) validate map values, and Kotlin writes no map column. **Java**
+validates them on its vanilla create/PATCH handlers but NOT on TPH (discriminator-rooted) write
+paths — those validate field-by-field with `validateValue`, which does not cascade `@Valid`.
+**C# validates them on NO write path** — vanilla create, vanilla PATCH, and TPH alike: the map
+never reaches the recursively-validating value-object arms (they admit `field.object` only),
+and the generic arms check the dictionary property itself, never its values. A posted value
+violating the referenced `object.value`'s constraints is accepted and written, silently, and
+reading the adopter's own source will not reveal it. Scalar-valued maps (`@valueType`) are
+unaffected. Do not recommend this rung for a Java TPH entity — or for C# at all — without
+saying so and pointing at boundary validation of map values before write;
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider
+than that issue. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md
index 08935869e..908fa18f8 100644
--- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md
+++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
**Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason.
- **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So recommend this rung freely where GENERATED CODE is the consumer, and prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
+ **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
---
diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
index 2cf29ba47..eb6d7bf36 100644
--- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -584,14 +584,20 @@ Python's `ObjectManager` encodes a map today; `runtime-ts`, Java's OMDB and the
lane carry no map handling at all. So a map you intend to read back through a PORT RUNTIME is
still better declared as a value object, and a genuinely dynamic key set stays a bag.
-**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
-entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
-generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
-not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
-A posted value violating the referenced `object.value`'s constraints is accepted and written,
-silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
-(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
-[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
+**One sharp edge where generated code IS the consumer: nested map values can be written
+UNVALIDATED, per port.** TypeScript (`z.record` over the VO's insert schema) and Python
+(`dict[str, VO]` Pydantic) validate map values, and Kotlin writes no map column. **Java**
+validates them on its vanilla create/PATCH handlers but NOT on TPH (discriminator-rooted) write
+paths — those validate field-by-field with `validateValue`, which does not cascade `@Valid`.
+**C# validates them on NO write path** — vanilla create, vanilla PATCH, and TPH alike: the map
+never reaches the recursively-validating value-object arms (they admit `field.object` only),
+and the generic arms check the dictionary property itself, never its values. A posted value
+violating the referenced `object.value`'s constraints is accepted and written, silently, and
+reading the adopter's own source will not reveal it. Scalar-valued maps (`@valueType`) are
+unaffected. Do not recommend this rung for a Java TPH entity — or for C# at all — without
+saying so and pointing at boundary validation of map values before write;
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider
+than that issue. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/SKILL.md
index 08935869e..908fa18f8 100644
--- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/SKILL.md
+++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/SKILL.md
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
**Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason.
- **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So recommend this rung freely where GENERATED CODE is the consumer, and prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
+ **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
---
diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md
index 2cf29ba47..eb6d7bf36 100644
--- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -584,14 +584,20 @@ Python's `ObjectManager` encodes a map today; `runtime-ts`, Java's OMDB and the
lane carry no map handling at all. So a map you intend to read back through a PORT RUNTIME is
still better declared as a value object, and a genuinely dynamic key set stays a bag.
-**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
-entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
-generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
-not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
-A posted value violating the referenced `object.value`'s constraints is accepted and written,
-silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
-(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
-[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
+**One sharp edge where generated code IS the consumer: nested map values can be written
+UNVALIDATED, per port.** TypeScript (`z.record` over the VO's insert schema) and Python
+(`dict[str, VO]` Pydantic) validate map values, and Kotlin writes no map column. **Java**
+validates them on its vanilla create/PATCH handlers but NOT on TPH (discriminator-rooted) write
+paths — those validate field-by-field with `validateValue`, which does not cascade `@Valid`.
+**C# validates them on NO write path** — vanilla create, vanilla PATCH, and TPH alike: the map
+never reaches the recursively-validating value-object arms (they admit `field.object` only),
+and the generic arms check the dictionary property itself, never its values. A posted value
+violating the referenced `object.value`'s constraints is accepted and written, silently, and
+reading the adopter's own source will not reveal it. Scalar-valued maps (`@valueType`) are
+unaffected. Do not recommend this rung for a Java TPH entity — or for C# at all — without
+saying so and pointing at boundary validation of map values before write;
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider
+than that issue. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
From 38afbe4f3ce3c18025fa8bc494fd6e44869188b1 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 10 Sep 2026 01:06:55 -0400
Subject: [PATCH 18/22] no-mistakes(review): fix stale #362 scoping; null-guard
MapJsonb comparer Hash/Snap
---
.../skills/metaobjects-audit/SKILL.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 3 +-
docs/features/field-types.md | 2 +-
.../.claude/skills/metaobjects-audit/SKILL.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 3 +-
.../.claude/skills/metaobjects-audit/SKILL.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 3 +-
.../.claude/skills/metaobjects-audit/SKILL.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 3 +-
.../.claude/skills/metaobjects-audit/SKILL.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 3 +-
.../.claude/skills/metaobjects-audit/SKILL.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 3 +-
.../MapFieldCodegenTests.cs | 96 +++++++++++++++++++
.../Generators/DbContextGenerator.cs | 21 ++--
15 files changed, 121 insertions(+), 28 deletions(-)
diff --git a/agent-context/skills/metaobjects-audit/SKILL.md b/agent-context/skills/metaobjects-audit/SKILL.md
index 908fa18f8..6f52ab680 100644
--- a/agent-context/skills/metaobjects-audit/SKILL.md
+++ b/agent-context/skills/metaobjects-audit/SKILL.md
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
**Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason.
- **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
+ **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the gap. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
---
diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md
index eb6d7bf36..2457b0d09 100644
--- a/agent-context/skills/metaobjects-authoring/SKILL.md
+++ b/agent-context/skills/metaobjects-authoring/SKILL.md
@@ -596,8 +596,7 @@ violating the referenced `object.value`'s constraints is accepted and written, s
reading the adopter's own source will not reveal it. Scalar-valued maps (`@valueType`) are
unaffected. Do not recommend this rung for a Java TPH entity — or for C# at all — without
saying so and pointing at boundary validation of map values before write;
-[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider
-than that issue. Every rung but the first keeps the
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the gap. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/docs/features/field-types.md b/docs/features/field-types.md
index 4fdfde117..46073ff9f 100644
--- a/docs/features/field-types.md
+++ b/docs/features/field-types.md
@@ -62,7 +62,7 @@ Three rows need a footnote:
> validate. This is generated code, so **reading your own source will not reveal it** — the
> failure is silent acceptance, not an error. Validate map values at your own boundary
> before write. [Issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks
- > the Java TPH half; the C# surface is wider than that issue's scope.
+ > the gap.
**The RUNTIME tier is not there yet, and no conformance corpus covers it.** No
persistence- or api-contract-conformance fixture exercises `field.map` on any port; it is
diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md
index 908fa18f8..6f52ab680 100644
--- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md
+++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
**Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason.
- **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
+ **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the gap. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
---
diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
index eb6d7bf36..2457b0d09 100644
--- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -596,8 +596,7 @@ violating the referenced `object.value`'s constraints is accepted and written, s
reading the adopter's own source will not reveal it. Scalar-valued maps (`@valueType`) are
unaffected. Do not recommend this rung for a Java TPH entity — or for C# at all — without
saying so and pointing at boundary validation of map values before write;
-[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider
-than that issue. Every rung but the first keeps the
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the gap. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md
index 908fa18f8..6f52ab680 100644
--- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md
+++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
**Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason.
- **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
+ **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the gap. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
---
diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md
index eb6d7bf36..2457b0d09 100644
--- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -596,8 +596,7 @@ violating the referenced `object.value`'s constraints is accepted and written, s
reading the adopter's own source will not reveal it. Scalar-valued maps (`@valueType`) are
unaffected. Do not recommend this rung for a Java TPH entity — or for C# at all — without
saying so and pointing at boundary validation of map values before write;
-[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider
-than that issue. Every rung but the first keeps the
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the gap. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md
index 908fa18f8..6f52ab680 100644
--- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md
+++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
**Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason.
- **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
+ **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the gap. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
---
diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md
index eb6d7bf36..2457b0d09 100644
--- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md
+++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md
@@ -596,8 +596,7 @@ violating the referenced `object.value`'s constraints is accepted and written, s
reading the adopter's own source will not reveal it. Scalar-valued maps (`@valueType`) are
unaffected. Do not recommend this rung for a Java TPH entity — or for C# at all — without
saying so and pointing at boundary validation of map values before write;
-[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider
-than that issue. Every rung but the first keeps the
+[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the gap. Every rung but the first keeps the
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
migration — read the emitted DDL before promising that.
diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md
index 908fa18f8..6f52ab680 100644
--- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md
+++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
**Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason.
- **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor.
+ **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — a `field.map @objectRef` writes its nested value-object values **unvalidated** on Java TPH (discriminator-rooted) write paths (per-field `validateValue` does not cascade `@Valid`) and on EVERY C# write path — vanilla create, vanilla PATCH, and TPH alike (the map never reaches the recursively-validating value-object arms; the generic arms check the dictionary property itself, never its values). TypeScript (`z.record` over the VO's insert schema) and Python (`dict[str, VO]` Pydantic) validate map values, Kotlin writes no map column, and scalar-valued maps (`@valueType`) are unaffected everywhere. Recommend the rung for a Java TPH entity or for C# only with that said and the adopter validating map values at their own boundary before write; [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the gap. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `