Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1af5e85
feat(codegen-spring): field.map reaches Java codegen instead of faili…
dmealing Sep 10, 2026
58574fc
feat(csharp-codegen): a field.map now gets an EF storage mapping inst…
dmealing Sep 10, 2026
3749b75
test(codegen-spring): drive every entity-facing generator over a map-…
dmealing Sep 10, 2026
b20cf82
docs: the map rung's port table said Java throws — it no longer does
dmealing Sep 10, 2026
aea0dd5
review: close the six surfaces a map newly REACHES now that codegen n…
dmealing Sep 10, 2026
130a3cb
simplify: ask each question once, and in the place that already owns …
dmealing Sep 10, 2026
e8d7aec
no-mistakes(document): Fix stale field.map port-coverage claim in aut…
dmealing Sep 10, 2026
ca5a418
no-mistakes(document): Correct C# G7: field.map is patch-settable, no…
dmealing Sep 10, 2026
468bd10
no-mistakes(document): Correct C# G7 — vanilla VO columns are patch-s…
dmealing Sep 10, 2026
be16314
no-mistakes(document): Correct Kotlin KNOWN_GAPS VO/map PATCH bullet …
dmealing Sep 10, 2026
6bd387a
fix(docs): retire an unverified provider claim, and test the one assu…
dmealing Sep 10, 2026
47dd8b7
no-mistakes(review): Retire remaining unverified Npgsql map-binding c…
dmealing Sep 10, 2026
a39d148
no-mistakes(review): Rename map-VO tests to match assertion, not casc…
dmealing Sep 10, 2026
bd990ea
no-mistakes(document): Scope Java map-PATCH claims; record TPH map-va…
dmealing Sep 10, 2026
a293eb4
no-mistakes(document): docs(changelog): record field.map codegen comp…
dmealing Sep 10, 2026
7a96877
docs(field-types): warn at point of use that a TPH map writes nested …
dmealing Sep 10, 2026
3914a01
no-mistakes(review): Correct field.map unvalidated-write warning to t…
dmealing Sep 10, 2026
38afbe4
no-mistakes(review): fix stale #362 scoping; null-guard MapJsonb comp…
dmealing Sep 10, 2026
85f87c1
no-mistakes(review): Serialize map VO enums as symbols via JsonString…
dmealing Sep 10, 2026
4d536b9
no-mistakes(test): Verified C# field.map nullable-property fix resolv…
dmealing Sep 10, 2026
59590f0
no-mistakes(test): Add live coverage for the map-VO enum-symbol and u…
dmealing Sep 10, 2026
10cd44d
no-mistakes(document): Sync field.map docs to final emission; fix CS8…
dmealing Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, V>` — `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<string, V>` 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

`<EntityFetcherProvider value={f}>` does not typecheck. The `0.x → 1.0` migration note and
Expand Down
2 changes: 1 addition & 1 deletion agent-context/skills/metaobjects-audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, X>>()` 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<VO>()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `<Ref>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<Record<string, X>>()` 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<String, V>`; C# emits the `Dictionary<string, V>` 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<VO>()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `<Ref>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.

---

Expand Down
35 changes: 25 additions & 10 deletions agent-context/skills/metaobjects-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ the column.** It already names the type — that is the metadata. Take the first
|---|---|---|
| `list[str]` / `string[]` / `List<X>` — 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<VO>()` + its Zod schema, the Pydantic model (`<VO>Create` on the wire), a Jackson-coded Exposed column, an EF owned type — gated in all five ports |
| `dict[str, X]` / `Record<string, X>` / `Map<String, X>` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record<string, X>` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map<String, X>` over a Jackson jsonb codec (Kotlin). **Java and C# do not complete this rung — see below** |
| `dict[str, X]` / `Record<string, X>` / `Map<String, X>` — dynamic keys, KNOWN value type | **`field.map`** + `@objectRef` (a value object) or `@valueType` (a scalar) | `Record<string, X>` + `z.record(...)` (TS), `dict[str, X]` (Python), `Map<String, X>` over a Jackson jsonb codec (Kotlin), `java.util.Map<String, V>` (Java), `Dictionary<string, V>` 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
Expand Down Expand Up @@ -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<String, V>` and reaches a map's `@objectRef`
value object in the emission walk; C# emits the `Dictionary<string, V>` 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
Expand Down
50 changes: 41 additions & 9 deletions docs/features/field-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, V>` | — (see below) | `Map<String, V>` | `Dictionary<string, V>` | `dict[str, V]` | `jsonb` |
| `field.map` | `Record<string, V>` | `Map<String, V>` | `Map<String, V>` | `Dictionary<string, V>` | `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`).
Expand All @@ -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<String, V>` (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<string, V>` 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
Expand Down
Loading
Loading