diff --git a/CHANGELOG.md b/CHANGELOG.md index cc1dae242..fecc3d33a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,49 @@ 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. Two details of the emitted shape: the property's NULLABILITY follows the +column — a `@required` map is a non-null dictionary with an empty-dictionary initializer, +any other map a nullable dictionary with no initializer, because the migration's column is +nullable by default and a non-nullable property over it makes EF Core 8 skip the shaper's +NULL check (one NULL cell — a row written by another port, or before the field existed — +would 500 every read arm), and NULL stays distinct from a present `{}`. And the shared +serializer options carry a `JsonStringEnumConverter`, so a `field.enum` member of the map's +value object persists as its member SYMBOL — the rule the owned-`field.object` jsonb column +already follows; System.Text.Json's default int ordinal is a value no sibling port writes +for the same declared field. + +**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 diff --git a/agent-context/skills/metaobjects-audit/SKILL.md b/agent-context/skills/metaobjects-audit/SKILL.md index 4531569ed..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 port before recommending this rung: `field.map` completes on TypeScript, Python and Kotlin only.** On Java `SpringTypeMapper` has no `MapField` arm and a mapped field reaches its `unsupported Spring DTO type mapping` throw; on C# the property is emitted but the EF model gets no column mapping. No persistence- or api-contract-conformance fixture exercises `field.map` on ANY port — it is loader-gated only — so on Java/C# a stable-keyed map is better declared as a value object, and a genuinely dynamic one stays a bag until the gap closes. 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 db5237422..2457b0d09 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 @@ -575,15 +575,30 @@ Two things that read as reasons to take the bag, and are not: not the bag. **Port coverage, stated plainly.** The `isArray` and `object.value` rungs round-trip on every -port through the persistence and api-contract corpora. `field.map` emits the typed handle in -TypeScript, Python and Kotlin; on **Java** the Spring DTO type mapper has no `MapField` arm and -a mapped field reaches its `unsupported Spring DTO type mapping` throw, and on **C#** the -property is emitted but the EF model gets no column mapping. **No persistence- or -api-contract-conformance fixture exercises `field.map` on any port — it is loader-gated only.** -So on Java/C# a stable-keyed map is better declared as a value object, and a genuinely dynamic -one stays a bag until the gap closes. 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. +port through the persistence and api-contract corpora. `field.map` now emits the typed handle on +**all five ports** — Java types it `java.util.Map` and reaches a map's `@objectRef` +value object in the emission walk; C# emits the `Dictionary` property *and* the EF +jsonb storage mapping. **But that is CODEGEN only: 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 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, and a genuinely dynamic key set stays a bag. + +**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 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. ## YAML sigil-free authoring + the coercion footgun diff --git a/docs/features/field-types.md b/docs/features/field-types.md index e611d1cf8..46073ff9f 100644 --- a/docs/features/field-types.md +++ b/docs/features/field-types.md @@ -26,7 +26,7 @@ across ports — a `field.currency` is integer minor units everywhere; a | `field.inet` | `string` | `InetAddress` | `InetAddress` | `IPAddress` | `IPvAnyAddress` | `inet` | | `field.enum` | union + `z.enum` | `Enum` | `enum class` | `enum` | `Enum` | `varchar` + `CHECK` | | `field.object` | nested type | nested class | nested data class | nested record | nested dataclass | per `@storage` | -| `field.map` | `Record` | — (see below) | `Map` | `Dictionary` | `dict[str, V]` | `jsonb` | +| `field.map` | `Record` | `Map` | `Map` | `Dictionary` | `dict[str, V]` | `jsonb` | That is the whole registered vocabulary — 17 concrete subtypes. `field.base` is an abstract registry anchor, never authored (`ERR_ABSTRACT_SUBTYPE_AUTHORED`). @@ -39,14 +39,46 @@ Three rows need a footnote: `text`. - **`field.map`** is the typed dict: string keys, and a value type set by exactly one of `@valueType` (a scalar subtype) or `@objectRef` (a value object) — `V` above. It is one - jsonb column holding the JSON object; `isArray` does not apply. **Java does not complete - this rung:** `SpringTypeMapper.javaTypeName` has no `MapField` arm, so a mapped field on - a Spring entity reaches its `unsupported Spring DTO type mapping` throw. On C# the - property and its `[Column]` annotation are emitted but `DbContextGenerator` writes no EF - storage mapping for the dictionary, so EF does not persist it. **No persistence- or - api-contract-conformance fixture exercises `field.map` on any port — it is loader-gated - only.** Until that closes, a stable key set is better declared as an `object.value` - behind `field.object`; see [ADR-0037](../../spec/decisions/ADR-0037-metamodel-vocabulary-expansion-decision-framework.md) + jsonb column holding the JSON object; `isArray` does not apply, so no port wraps the map + type in a list. **Codegen is now complete on all five ports.** Java emits + `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, so the property lands on the `jsonb` column the TS-owned + migration creates instead of on whatever an unmapped dictionary would resolve to. + + > ⚠️ **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 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 + loader- and codegen-gated only. Of the runtime persistence layers, only Python's + `ObjectManager` encodes a map (its jsonb write codec names `FIELD_SUBTYPE_MAP` + alongside `FIELD_SUBTYPE_OBJECT`). TypeScript's `runtime-ts`, Java's OMDB and the Kotlin + Exposed persistence lane carry no map handling at all — OMDB's jsonb path in particular + is gated on the `@storage` attr, which a map does not have, and serializes through a + per-`MetaObject` Gson adapter that has no map-of-value-object binding. Closing that is a + cross-port runtime workstream, not a codegen change, and it is the prerequisite for the + shared `op: roundtrip` persistence scenario that would gate this subtype the way every + other persistable subtype is gated. + + So: for data you intend to READ BACK THROUGH A PORT RUNTIME today, a stable key set is + still better declared as an `object.value` behind `field.object`, which every runtime + does round-trip. See [ADR-0037](../../spec/decisions/ADR-0037-metamodel-vocabulary-expansion-decision-framework.md) for which of the two a shape belongs in. ## Common field attributes 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 4531569ed..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 port before recommending this rung: `field.map` completes on TypeScript, Python and Kotlin only.** On Java `SpringTypeMapper` has no `MapField` arm and a mapped field reaches its `unsupported Spring DTO type mapping` throw; on C# the property is emitted but the EF model gets no column mapping. No persistence- or api-contract-conformance fixture exercises `field.map` on ANY port — it is loader-gated only — so on Java/C# a stable-keyed map is better declared as a value object, and a genuinely dynamic one stays a bag until the gap closes. 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 db5237422..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 @@ -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 @@ -575,15 +575,30 @@ Two things that read as reasons to take the bag, and are not: not the bag. **Port coverage, stated plainly.** The `isArray` and `object.value` rungs round-trip on every -port through the persistence and api-contract corpora. `field.map` emits the typed handle in -TypeScript, Python and Kotlin; on **Java** the Spring DTO type mapper has no `MapField` arm and -a mapped field reaches its `unsupported Spring DTO type mapping` throw, and on **C#** the -property is emitted but the EF model gets no column mapping. **No persistence- or -api-contract-conformance fixture exercises `field.map` on any port — it is loader-gated only.** -So on Java/C# a stable-keyed map is better declared as a value object, and a genuinely dynamic -one stays a bag until the gap closes. 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. +port through the persistence and api-contract corpora. `field.map` now emits the typed handle on +**all five ports** — Java types it `java.util.Map` and reaches a map's `@objectRef` +value object in the emission walk; C# emits the `Dictionary` property *and* the EF +jsonb storage mapping. **But that is CODEGEN only: 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 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, and a genuinely dynamic key set stays a bag. + +**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 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. ## YAML sigil-free authoring + the coercion footgun 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 4531569ed..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 port before recommending this rung: `field.map` completes on TypeScript, Python and Kotlin only.** On Java `SpringTypeMapper` has no `MapField` arm and a mapped field reaches its `unsupported Spring DTO type mapping` throw; on C# the property is emitted but the EF model gets no column mapping. No persistence- or api-contract-conformance fixture exercises `field.map` on ANY port — it is loader-gated only — so on Java/C# a stable-keyed map is better declared as a value object, and a genuinely dynamic one stays a bag until the gap closes. 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 db5237422..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 @@ -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 @@ -575,15 +575,30 @@ Two things that read as reasons to take the bag, and are not: not the bag. **Port coverage, stated plainly.** The `isArray` and `object.value` rungs round-trip on every -port through the persistence and api-contract corpora. `field.map` emits the typed handle in -TypeScript, Python and Kotlin; on **Java** the Spring DTO type mapper has no `MapField` arm and -a mapped field reaches its `unsupported Spring DTO type mapping` throw, and on **C#** the -property is emitted but the EF model gets no column mapping. **No persistence- or -api-contract-conformance fixture exercises `field.map` on any port — it is loader-gated only.** -So on Java/C# a stable-keyed map is better declared as a value object, and a genuinely dynamic -one stays a bag until the gap closes. 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. +port through the persistence and api-contract corpora. `field.map` now emits the typed handle on +**all five ports** — Java types it `java.util.Map` and reaches a map's `@objectRef` +value object in the emission walk; C# emits the `Dictionary` property *and* the EF +jsonb storage mapping. **But that is CODEGEN only: 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 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, and a genuinely dynamic key set stays a bag. + +**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 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. ## YAML sigil-free authoring + the coercion footgun 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 4531569ed..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 port before recommending this rung: `field.map` completes on TypeScript, Python and Kotlin only.** On Java `SpringTypeMapper` has no `MapField` arm and a mapped field reaches its `unsupported Spring DTO type mapping` throw; on C# the property is emitted but the EF model gets no column mapping. No persistence- or api-contract-conformance fixture exercises `field.map` on ANY port — it is loader-gated only — so on Java/C# a stable-keyed map is better declared as a value object, and a genuinely dynamic one stays a bag until the gap closes. 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 db5237422..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 @@ -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 @@ -575,15 +575,30 @@ Two things that read as reasons to take the bag, and are not: not the bag. **Port coverage, stated plainly.** The `isArray` and `object.value` rungs round-trip on every -port through the persistence and api-contract corpora. `field.map` emits the typed handle in -TypeScript, Python and Kotlin; on **Java** the Spring DTO type mapper has no `MapField` arm and -a mapped field reaches its `unsupported Spring DTO type mapping` throw, and on **C#** the -property is emitted but the EF model gets no column mapping. **No persistence- or -api-contract-conformance fixture exercises `field.map` on any port — it is loader-gated only.** -So on Java/C# a stable-keyed map is better declared as a value object, and a genuinely dynamic -one stays a bag until the gap closes. 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. +port through the persistence and api-contract corpora. `field.map` now emits the typed handle on +**all five ports** — Java types it `java.util.Map` and reaches a map's `@objectRef` +value object in the emission walk; C# emits the `Dictionary` property *and* the EF +jsonb storage mapping. **But that is CODEGEN only: 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 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, and a genuinely dynamic key set stays a bag. + +**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 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. ## YAML sigil-free authoring + the coercion footgun 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 4531569ed..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 port before recommending this rung: `field.map` completes on TypeScript, Python and Kotlin only.** On Java `SpringTypeMapper` has no `MapField` arm and a mapped field reaches its `unsupported Spring DTO type mapping` throw; on C# the property is emitted but the EF model gets no column mapping. No persistence- or api-contract-conformance fixture exercises `field.map` on ANY port — it is loader-gated only — so on Java/C# a stable-keyed map is better declared as a value object, and a genuinely dynamic one stays a bag until the gap closes. 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/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 db5237422..2457b0d09 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 @@ -575,15 +575,30 @@ Two things that read as reasons to take the bag, and are not: not the bag. **Port coverage, stated plainly.** The `isArray` and `object.value` rungs round-trip on every -port through the persistence and api-contract corpora. `field.map` emits the typed handle in -TypeScript, Python and Kotlin; on **Java** the Spring DTO type mapper has no `MapField` arm and -a mapped field reaches its `unsupported Spring DTO type mapping` throw, and on **C#** the -property is emitted but the EF model gets no column mapping. **No persistence- or -api-contract-conformance fixture exercises `field.map` on any port — it is loader-gated only.** -So on Java/C# a stable-keyed map is better declared as a value object, and a genuinely dynamic -one stays a bag until the gap closes. 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. +port through the persistence and api-contract corpora. `field.map` now emits the typed handle on +**all five ports** — Java types it `java.util.Map` and reaches a map's `@objectRef` +value object in the emission walk; C# emits the `Dictionary` property *and* the EF +jsonb storage mapping. **But that is CODEGEN only: 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 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, and a genuinely dynamic key set stays a bag. + +**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 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. ## YAML sigil-free authoring + the coercion footgun 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 4531569ed..6f52ab680 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 port before recommending this rung: `field.map` completes on TypeScript, Python and Kotlin only.** On Java `SpringTypeMapper` has no `MapField` arm and a mapped field reaches its `unsupported Spring DTO type mapping` throw; on C# the property is emitted but the EF model gets no column mapping. No persistence- or api-contract-conformance fixture exercises `field.map` on ANY port — it is loader-gated only — so on Java/C# a stable-keyed map is better declared as a value object, and a genuinely dynamic one stays a bag until the gap closes. 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/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md index db5237422..2457b0d09 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 @@ -575,15 +575,30 @@ Two things that read as reasons to take the bag, and are not: not the bag. **Port coverage, stated plainly.** The `isArray` and `object.value` rungs round-trip on every -port through the persistence and api-contract corpora. `field.map` emits the typed handle in -TypeScript, Python and Kotlin; on **Java** the Spring DTO type mapper has no `MapField` arm and -a mapped field reaches its `unsupported Spring DTO type mapping` throw, and on **C#** the -property is emitted but the EF model gets no column mapping. **No persistence- or -api-contract-conformance fixture exercises `field.map` on any port — it is loader-gated only.** -So on Java/C# a stable-keyed map is better declared as a value object, and a genuinely dynamic -one stays a bag until the gap closes. 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. +port through the persistence and api-contract corpora. `field.map` now emits the typed handle on +**all five ports** — Java types it `java.util.Map` and reaches a map's `@objectRef` +value object in the emission walk; C# emits the `Dictionary` property *and* the EF +jsonb storage mapping. **But that is CODEGEN only: 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 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, and a genuinely dynamic key set stays a bag. + +**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 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. ## YAML sigil-free authoring + the coercion footgun diff --git a/server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs index bac86d799..03fe73fe7 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs @@ -12,13 +12,26 @@ // field.enum, so the FLATTENED owner's per-member `.HasColumnName(...).HasConversion(...)` // chain — on an OwnedNavigationBuilder's PropertyBuilder, naming the VO-nested // enum type and the UnmappedEnumValue helper — is proven to resolve, not just to read +// It ALSO carries a field.map `hints`, so the flattened owner pins that member to its +// `_` jsonb column through the same MapJsonb converter/comparer pair — on an +// OwnedNavigationBuilder's PropertyBuilder>, a different receiver +// from the entity-level one, which only a compile can prove resolves // - object.entity Order with: // scalar enum "status" → .HasConversion() // array enum "statuses" (isArray) → .PrimitiveCollection().ElementType().HasConversion() // array string "tags" (isArray) → .PrimitiveCollection() // field.object homeAddress @storage flattened → OwnsOne(...) per-property column names // field.object config (default storage) → OwnsOne(...).ToJson(...) -// - object.projection ProgramSummary (view-kind source, keyless) → .ToView(...).HasNoKey() +// field.map labels (@valueType) → .HasColumnType("jsonb").HasConversion( +// MapJsonb.Converter(), MapJsonb.Comparer()) +// field.map sites (@objectRef) → the same, typed by the value object. +// These prove the EF API surface actually RESOLVES: the two-arg +// HasConversion(ValueConverter, ValueComparer) overload, and a generic helper +// returning ValueConverter,string>. A string-contains test +// cannot tell a real overload from a plausible-looking one. +// - object.projection ProgramSummary (view-kind source, keyless) → .ToView(...).HasNoKey(), +// carrying a field.map so the PROJECTION arm of the map config compiles too — its emitter +// is a separate call site from the entity one, and the property is emitted either way // - object.entity Invoice — a #214 WRITE-THROUGH entity (table invoices + replica view // v_invoice_with_client + a derived origin.passthrough clientName): the derived-free // write entity, the view-mapped InvoiceView read model (.ToView), the InvoiceView DbSet, @@ -41,6 +54,7 @@ using MetaObjects.Codegen.Generators; using MetaObjects.Loader; using MetaObjects.Meta; +using System.Reflection; using Xunit; namespace MetaObjects.Codegen.Tests; @@ -58,7 +72,8 @@ public class DbContextCompileTests { "field.string": { "name": "street", "@required": true, "@maxLength": 120 } }, { "field.string": { "name": "city", "@maxLength": 80 } }, { "field.enum": { "name": "kind", "@values": ["HOME", "WORK"] } }, - { "field.enum": { "name": "tier", "@values": ["A", "B"], "@intValueMap": { "A": 1, "B": 2 } } } + { "field.enum": { "name": "tier", "@values": ["A", "B"], "@intValueMap": { "A": 1, "B": 2 } } }, + { "field.map": { "name": "hints", "@valueType": "string" } } ]}}, { "object.entity": { "name": "Order", "children": [ { "source.rdb": { "@table": "orders" } }, @@ -70,12 +85,15 @@ public class DbContextCompileTests { "field.string": { "name": "tags", "isArray": true } }, { "field.object": { "name": "homeAddress", "@objectRef": "Address", "@storage": "flattened" } }, { "field.object": { "name": "config", "@objectRef": "Address" } }, + { "field.map": { "name": "labels", "@valueType": "string" } }, + { "field.map": { "name": "sites", "@objectRef": "Address" } }, { "identity.primary": { "@fields": "id" } } ]}}, { "object.projection": { "name": "ProgramSummary", "children": [ { "source.rdb": { "@kind": "view", "@table": "v_program_summary" } }, { "field.long": { "name": "id" } }, - { "field.int": { "name": "weekCount" } } + { "field.int": { "name": "weekCount" } }, + { "field.map": { "name": "tallies", "@valueType": "int" } } ]}}, { "object.entity": { "name": "Client", "children": [ { "source.rdb": { "@kind": "table", "@table": "clients" } }, @@ -201,6 +219,105 @@ public void Generated_AppDbContext_and_entities_compile_against_EF_Core_8() Assert.Contains("[Column(OrderNames.StatusColumn)]", orderSrc); } + // The map's value object is serialized by the MapJsonb helper's shared JsonSerializerOptions, + // and System.Text.Json writes enums as NUMBERS by default — so a field.enum member of the + // map's @objectRef value object persisted as its ORDINAL, while the same member of the same + // value object reached through a field.object ToJson column persisted as its SYMBOL + // (JsonEnumConversions: inside a JSON document there is no column, so the symbol is written + // unconditionally and @intValueMap is deliberately not consulted). TypeScript, Java, Kotlin + // and Python all write the symbol, so the ordinal form was a silent cross-port wire break. + // This EXECUTES the emitted code: the generated files are compiled against real EF Core 8, + // the assembly is loaded, and the emitted Converter
's own delegates are invoked + // over a map value carrying BOTH a string-backed enum member (kind) and an int-backed one + // (tier) — Address here carries exactly those two. + [Fact] + public void MapJsonb_converter_persists_enum_members_as_their_symbol() + { + var ctx = Ctx(Load()); + var entityFiles = new EntityGenerator().Generate(ctx).ToList(); + var dbContextFiles = new DbContextGenerator().Generate(ctx).ToList(); + var namesFiles = new NamesGenerator().Generate(ctx).ToList(); + + var dbctx = Assert.Single(dbContextFiles).Content; + Assert.Contains("new System.Text.Json.Serialization.JsonStringEnumConverter()", dbctx); + + var allSources = entityFiles.Concat(dbContextFiles).Concat(namesFiles).ToList(); + var trees = allSources.Select(f => CSharpSyntaxTree.ParseText( + f.Content, new CSharpParseOptions(LanguageVersion.CSharp12))).ToList(); + var comp = CSharpCompilation.Create( + "mapjsonenum_" + Guid.NewGuid().ToString("N"), + trees, BuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var errors = comp.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"{d.Id}: {d.GetMessage()}") + .ToList(); + Assert.True( + errors.Count == 0, + "Generated entity + AppDbContext should compile against EF Core 8, but got errors:\n" + + string.Join("\n", errors)); + + using var pe = new MemoryStream(); + var emit = comp.Emit(pe); + Assert.True( + emit.Success, + string.Join("; ", emit.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => d.GetMessage()))); + var asm = Assembly.Load(pe.ToArray()); + + var appDbContext = asm.GetType("Acme.Generated.AppDbContext"); + Assert.NotNull(appDbContext); + var helper = appDbContext.GetNestedType("MapJsonb", BindingFlags.NonPublic); + Assert.NotNull(helper); + var converterOf = helper.GetMethod("Converter", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(converterOf); + + var addressType = asm.GetType("Acme.Generated.Address"); + Assert.NotNull(addressType); + // A non-required VO enum emits as Nullable — unwrap to the underlying enum + // before parsing. The stored jsonb keys are the metadata field names, pinned by the + // POCO's [JsonPropertyName] to the cross-port wire contract (Program D). + var kind = addressType.GetProperty("Kind"); + Assert.NotNull(kind); + var tier = addressType.GetProperty("Tier"); + Assert.NotNull(tier); + var kindEnum = Nullable.GetUnderlyingType(kind.PropertyType) ?? kind.PropertyType; + var tierEnum = Nullable.GetUnderlyingType(tier.PropertyType) ?? tier.PropertyType; + Assert.True(kindEnum.IsEnum); + Assert.True(tierEnum.IsEnum); + + var address = Activator.CreateInstance(addressType)!; + var homeKind = Enum.Parse(kindEnum, "HOME"); + var tierA = Enum.Parse(tierEnum, "A"); + kind.SetValue(address, homeKind); + tier.SetValue(address, tierA); + var map = (System.Collections.IDictionary)Activator.CreateInstance( + typeof(Dictionary<,>).MakeGenericType(typeof(string), addressType))!; + map["hq"] = address; + + var converter = converterOf.MakeGenericMethod(addressType).Invoke(null, null)!; + // DeclaredOnly: ValueConverter re-declares these properties with + // `new` over the non-generic base's same-named ones, so a plain GetProperty is ambiguous. + var toProvider = (Delegate)converter.GetType() + .GetProperty("ConvertToProvider", BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)! + .GetValue(converter)!; + var json = (string)toProvider.DynamicInvoke(map)!; + + Assert.Contains("\"kind\":\"HOME\"", json); + Assert.Contains("\"tier\":\"A\"", json); + Assert.DoesNotContain("\"kind\":0", json); + Assert.DoesNotContain("\"tier\":0", json); + + var fromProvider = (Delegate)converter.GetType() + .GetProperty("ConvertFromProvider", BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)! + .GetValue(converter)!; + var roundTripped = (System.Collections.IDictionary)fromProvider.DynamicInvoke(json)!; + var back = roundTripped["hq"]!; + Assert.Equal(homeKind, kind.GetValue(back)); + Assert.Equal(tierA, tier.GetValue(back)); + } + // #214 review defects [0] + [1] — the write-through read model (View) must carry // BOTH a per-field TYPE-converter column (field.uri docUrl) AND a non-derived jsonb // value-object column (field.object @storage:jsonb billingAddress), and the DbContext must diff --git a/server/csharp/MetaObjects.Codegen.Tests/MapFieldCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/MapFieldCodegenTests.cs index 38c8e5993..35846459d 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/MapFieldCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/MapFieldCodegenTests.cs @@ -4,6 +4,8 @@ using MetaObjects.Codegen.Generators; using MetaObjects.Loader; using MetaObjects.Meta; +using System.Linq.Expressions; +using System.Reflection; using Xunit; namespace MetaObjects.Codegen.Tests; @@ -26,14 +28,15 @@ public class MapFieldCodegenTests { "field.string": { "name": "name", "@required": true } }, { "field.map": { "name": "labels", "@valueType": "string" } }, { "field.map": { "name": "addresses", "@objectRef": "Address" } }, + { "field.map": { "name": "channels", "@valueType": "string", "@required": true } }, { "identity.primary": { "@fields": "id" } } ]}} ]}} """; - private static MetaRoot Load() + private static MetaRoot Load(string model = Model, string id = "map.json") { - var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "map.json")]); + var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: id)]); Assert.Empty(r.Errors); return r.Root; } @@ -53,9 +56,17 @@ public void Scalar_valued_map_emits_a_string_value_dictionary() var files = new EntityGenerator().Generate(Ctx(Load())).ToList(); var customer = files.Single(f => f.Path == "Customer.g.cs").Content; - // @valueType:string → Dictionary, [Column]-mapped, never null. + // @valueType:string → Dictionary, [Column]-mapped. NULLABILITY + // FOLLOWS THE COLUMN (ObjectNavProperty's rule): the migration creates a map + // column NULLABLE by default, and a non-nullable property over it made EF Core 8 + // skip the shaper's IsDBNull check — a 500 on every read of a NULL-map row (the + // MapNullColumnGeneratedServerTest lane drives that end-to-end). So a + // non-required map is NULLABLE with NO initializer (absent stays null, round-trips + // as SQL NULL — never silently rewritten to {}), while a @required map keeps the + // non-null empty-dictionary initializer. Assert.Contains("[Column(CustomerNames.LabelsColumn)]", customer); // §A6 (task 4) - Assert.Contains("public Dictionary Labels { get; set; } = new();", customer); + Assert.Contains("public Dictionary? Labels { get; set; }", customer); + Assert.Contains("public Dictionary Channels { get; set; } = new();", customer); } [Fact] @@ -64,12 +75,121 @@ public void Object_valued_map_emits_a_value_object_value_dictionary() var files = new EntityGenerator().Generate(Ctx(Load())).ToList(); var customer = files.Single(f => f.Path == "Customer.g.cs").Content; - // @objectRef:Address → Dictionary; the VO is emitted as a POCO. + // @objectRef:Address → Dictionary (nullable — see the scalar test + // for the column-following rule); the VO is emitted as a POCO. Assert.Contains("[Column(CustomerNames.AddressesColumn)]", customer); // §A6 (task 4) - Assert.Contains("public Dictionary Addresses { get; set; } = new();", customer); + Assert.Contains("public Dictionary? Addresses { get; set; }", customer); Assert.Contains("public class Address", files.Single(f => f.Path == "Address.g.cs").Content); } + [Fact] + public void Scalar_valued_map_gets_a_jsonb_storage_mapping_in_the_DbContext() + { + var dbCtx = Assert.Single(new DbContextGenerator().Generate(Ctx(Load()))).Content; + + // WHY an explicit mapping is needed at all is stated once, at the emission site + // (the map loop in DbContextGenerator.EmitFieldTypeConfig): an unmapped Dictionary + // does not land on the jsonb column the migration creates. + Assert.Contains( + "modelBuilder.Entity().Property(x => x.Labels).HasColumnType(\"jsonb\")" + + ".HasConversion(MapJsonb.Converter(), MapJsonb.Comparer());", + dbCtx); + // The @required map's property is non-null, so its converter is the non-null + // factory — EF Core 8's nullability-aware HasConversion checks the converter's + // model type against the property's own annotation (a mismatch is CS8620). + Assert.Contains( + "modelBuilder.Entity().Property(x => x.Channels).HasColumnType(\"jsonb\")" + + ".HasConversion(MapJsonb.RequiredConverter(), MapJsonb.Comparer());", + dbCtx); + } + + [Fact] + public void Object_valued_map_gets_a_jsonb_storage_mapping_typed_by_the_value_object() + { + var dbCtx = Assert.Single(new DbContextGenerator().Generate(Ctx(Load()))).Content; + + Assert.Contains( + "modelBuilder.Entity().Property(x => x.Addresses).HasColumnType(\"jsonb\")" + + ".HasConversion(MapJsonb.Converter()" + + ", MapJsonb.Comparer());", + dbCtx); + } + + [Fact] + public void Map_jsonb_helper_is_emitted_only_when_a_map_is_present() + { + var withMap = Assert.Single(new DbContextGenerator().Generate(Ctx(Load()))).Content; + + // The shared converter/comparer pair. The COMPARER is the load-bearing half: with a + // value converter and no comparer EF snapshots the dictionary by reference, so an + // in-place `entity.Labels["k"] = v` is never detected and the UPDATE never fires -- + // the same silent non-persistence this mapping exists to fix. + Assert.Contains("private static class MapJsonb", withMap); + // The nullable type arguments match the nullable map property (EF Core 8's + // nullability-aware HasConversion expects them; see EmitMapJsonbHelper remarks). + Assert.Contains("Dictionary?, string> Converter()", withMap); + Assert.Contains("Dictionary?> Comparer()", withMap); + + // Equality must be ENTRY-WISE, not a comparison of serialized JSON. JSON string + // equality is key-ORDER sensitive, so a dictionary rebuilt in a different order would + // read as changed and issue an UPDATE for a row nothing touched — and it would + // serialize both dictionaries on every check. Scalars take the default comparer and + // never serialize; only a value-object value falls through to JSON. + Assert.Contains("if (!b.TryGetValue(kv.Key, out var other)) return false;", withMap); + Assert.Contains("EqualityComparer.Default.Equals(kv.Value, other)) continue;", withMap); + + // A model with no field.map must stay byte-identical -- the helper is gated, exactly + // as the UnmappedEnumValue helper is. + const string noMap = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Plain", "children": [ + { "source.rdb": { "@table": "plains" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + var without = Assert.Single( + new DbContextGenerator().Generate(Ctx(Load(noMap, "nomap.json")))).Content; + Assert.DoesNotContain("MapJsonb", without); + } + + [Fact] + 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: 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": [ + { "source.rdb": { "@kind": "view", "@table": "v_customer_summary" } }, + { "field.long": { "name": "id" } }, + { "field.map": { "name": "tallies", "@valueType": "int" } } + ]}} + ]}} + """; + var ctx = Ctx(Load(model, "proj.json")); + + // The property is emitted... (nullable, no initializer — the view's map column is + // nullable like any other, and a NULL cell must read as null, not 500.) + var entity = Assert.Single(new EntityGenerator().Generate(ctx)).Content; + Assert.Contains("public Dictionary? Tallies { get; set; }", entity); + + // ...so the storage mapping must be too. + var dbCtx = Assert.Single(new DbContextGenerator().Generate(ctx)).Content; + Assert.Contains( + "modelBuilder.Entity().Property(x => x.Tallies).HasColumnType(\"jsonb\")" + + ".HasConversion(MapJsonb.Converter(), MapJsonb.Comparer());", + dbCtx); + // ...and the helper it names must be declared, or the generated file will not compile. + Assert.Contains("private static class MapJsonb", dbCtx); + } + [Fact] public void Generated_entities_and_value_objects_compile_together() { @@ -89,4 +209,98 @@ public void Generated_entities_and_value_objects_compile_together() .Select(d => $"{d.Id}: {d.GetMessage()}").ToList(); Assert.True(errors.Count == 0, "generated entity + value object should compile, got: " + string.Join("; ", errors)); } + + // A NULL jsonb cell materializes as a null Dictionary -- the property's `= new()` + // initializer does not survive EF's shaper -- and EF Core 8's TYPED + // ValueComparer.GetHashCode/Snapshot invoke the compiled lambdas with no null + // guard of their own (only the object?-typed overloads guard), which is why EF's own + // built-in comparers null-guard inside the lambdas. So the emitted Hash/Snap must + // tolerate null exactly as Eq already does. This EXECUTES the emitted code rather + // than matching its text: the generated files are compiled against real EF Core 8, + // the assembly is loaded, and the comparer's own compiled lambdas -- the same + // delegates EF change tracking calls -- are invoked with a null dictionary. + [Fact] + public void MapJsonb_comparer_hash_and_snapshot_tolerate_a_null_dictionary() + { + var ctx = Ctx(Load()); + var files = new EntityGenerator().Generate(ctx) + .Concat(new DbContextGenerator().Generate(ctx)) + .Concat(new NamesGenerator().Generate(ctx)).ToList(); + var trees = files.Select(f => + CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12))).ToList(); + var comp = CSharpCompilation.Create("mapnull_" + Guid.NewGuid().ToString("N"), + trees, DbContextCompileTests.BuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var diagnostics = comp.GetDiagnostics().ToList(); + Assert.True(diagnostics.All(d => d.Severity != DiagnosticSeverity.Error), + "generated output should compile against EF Core 8, got: " + + string.Join("; ", diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.GetMessage()))); + // The generated file stamps #nullable enable, so it must compile nullability-clean + // as well -- the comparer's signatures carry nullable annotations. + var nullableWarnings = diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Warning && d.Id.StartsWith("CS86")) + .Select(d => $"{d.Id}: {d.GetMessage()}").ToList(); + Assert.True(nullableWarnings.Count == 0, + "generated output should carry no nullable-analysis warnings: " + + string.Join("; ", nullableWarnings)); + + using var pe = new MemoryStream(); + var emit = comp.Emit(pe); + Assert.True(emit.Success, + string.Join("; ", emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.GetMessage()))); + var asm = Assembly.Load(pe.ToArray()); + + var appDbContext = asm.GetType("Acme.Generated.AppDbContext"); + Assert.NotNull(appDbContext); + var helper = appDbContext.GetNestedType("MapJsonb", BindingFlags.NonPublic); + Assert.NotNull(helper); + var comparerOf = helper.GetMethod("Comparer", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(comparerOf); + var scalar = comparerOf.MakeGenericMethod(typeof(string)).Invoke(null, null)!; + var voType = asm.GetType("Acme.Generated.Address"); + Assert.NotNull(voType); + var objectValued = comparerOf.MakeGenericMethod(voType).Invoke(null, null)!; + + // A null map hashes to a stable constant and snapshots as null, on BOTH value-type + // arms -- Hash used to dereference v.Count and the scalar Snap arm passed v to the + // Dictionary copy constructor, so a null map threw inside EF change tracking. + Assert.Equal(0, (int)InvokeLambda(scalar, "HashCodeExpression", (object?)null)!); + Assert.Null(InvokeLambda(scalar, "SnapshotExpression", (object?)null)); + Assert.Equal(0, (int)InvokeLambda(objectValued, "HashCodeExpression", (object?)null)!); + Assert.Null(InvokeLambda(objectValued, "SnapshotExpression", (object?)null)); + + // Semantics beyond null handling are unchanged: entry-wise order-independent + // equality and hashing, null-vs-instance inequality, and a snapshot that is a copy + // rather than the same instance. + var map = new Dictionary { ["a"] = "1", ["b"] = "2" }; + var reordered = new Dictionary { ["b"] = "2", ["a"] = "1" }; + var changed = new Dictionary { ["a"] = "1", ["b"] = "9" }; + Assert.True((bool)InvokeLambda(scalar, "EqualsExpression", map, reordered)!); + Assert.False((bool)InvokeLambda(scalar, "EqualsExpression", map, changed)!); + Assert.False((bool)InvokeLambda(scalar, "EqualsExpression", (object?)null, map)!); + Assert.True((bool)InvokeLambda(scalar, "EqualsExpression", (object?)null, (object?)null)!); + Assert.Equal( + InvokeLambda(scalar, "HashCodeExpression", map), + InvokeLambda(scalar, "HashCodeExpression", reordered)); + var snap = InvokeLambda(scalar, "SnapshotExpression", map); + Assert.IsType>(snap); + Assert.NotSame(map, snap); + Assert.True((bool)InvokeLambda(scalar, "EqualsExpression", map, snap)!); + } + + // Compiles one of the comparer's expression properties -- HashCodeExpression / + // SnapshotExpression / EqualsExpression, the same expressions EF Core registers and + // invokes -- and calls the resulting delegate with the given arguments. + private static object? InvokeLambda(object comparer, string expressionProperty, params object?[] arguments) + { + // DeclaredOnly: ValueComparer re-declares these properties with `new` over the + // non-generic base's same-named ones, so a plain GetProperty is ambiguous. + var lambda = (LambdaExpression)comparer.GetType() + .GetProperty(expressionProperty, BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)! + .GetValue(comparer)!; + return lambda.Compile().DynamicInvoke(arguments); + } } diff --git a/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs index ab77920c8..e83f6f1f5 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs @@ -217,13 +217,15 @@ public void DbContext_names_every_flattened_member_the_migration_creates() dbContext); } - // The one member kind deliberately NOT named. This port configures a top-level field.map - // nowhere either, so there is no proven mapping to mirror, and forcing b.Property onto a - // Dictionary can make EF's model builder throw where it currently ignores the - // member — trading a wrong column for a broken build. The requirement is that it be LOUD: - // a silent skip here is exactly the defect class this whole test exists for. + // A field.map member USED to be the one member kind deliberately not named: the port + // configured a top-level field.map nowhere either, so there was no proven mapping to + // mirror and the generator warned rather than risk binding a wrong column. Now that the + // top-level map branch exists, the same converter/comparer pair pins this member to the + // `_` jsonb column the migration creates — which is what the warning was + // standing in for. The requirement never was "warn"; it was "do not bind silently to a + // column the migration does not create". [Fact] - public void A_flattened_map_member_warns_instead_of_binding_a_wrong_column() + public void A_flattened_map_member_binds_its_prefixed_jsonb_column() { var warnings = new List(); var root = Load(); @@ -235,9 +237,19 @@ public void A_flattened_map_member_warns_instead_of_binding_a_wrong_column() }; var dbContext = new DbContextGenerator().Generate(ctx).Single().Content; - Assert.Contains(warnings, w => w.Contains("\"prefs\"") && w.Contains("profile_prefs")); - // ...and it must not have quietly emitted a mapping for it either. - Assert.DoesNotContain("p.Prefs", dbContext); + // Pinned to the migration's flattened column name, typed jsonb, and converted through + // the shared helper. The VO value type is FULLY QUALIFIED — the DbContext's usings + // cover entity namespaces only, and a value object is not an entity. + Assert.Contains( + "b.Property(p => p.Prefs).HasColumnName(\"profile_prefs\").HasColumnType(\"jsonb\")" + + ".HasConversion(MapJsonb.Converter()" + + ", MapJsonb.Comparer());", + dbContext); + // EF's own `