Skip to content

Commit 3914a01

Browse files
committed
no-mistakes(review): Correct field.map unvalidated-write warning to true per-port scope
1 parent 7a96877 commit 3914a01

13 files changed

Lines changed: 106 additions & 64 deletions

File tree

  • agent-context/skills
  • docs/features
  • fixtures/agent-context-conformance
    • java-kotlin-react-tanstack/expected/.claude/skills
    • java-react/expected/.claude/skills
    • python/expected/.claude/skills
    • ts-react-tanstack/expected/.claude/skills
    • ts-requirements/expected/.claude/skills

‎agent-context/skills/metaobjects-audit/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
366366

367367
**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.
368368

369-
**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 recommend this rung freely where GENERATED CODE is the consumer, and prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type<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.
369+
**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 Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type<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.
370370

371371
---
372372

‎agent-context/skills/metaobjects-authoring/SKILL.md‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -584,14 +584,20 @@ Python's `ObjectManager` encodes a map today; `runtime-ts`, Java's OMDB and the
584584
lane carry no map handling at all. So a map you intend to read back through a PORT RUNTIME is
585585
still better declared as a value object, and a genuinely dynamic key set stays a bag.
586586

587-
**One sharp edge where generated code IS the consumer.** On a TPH (discriminator-rooted)
588-
entity, a `field.map @objectRef` writes its nested value-object values **unvalidated** — the
589-
generated TPH create/PATCH handlers validate field-by-field with `validateValue`, which does
590-
not cascade `@Valid`, and the explicit cascade the vanilla handler runs is not invoked there.
591-
A posted value violating the referenced `object.value`'s constraints is accepted and written,
592-
silently, and reading the adopter's own source will not reveal it. Scalar-valued maps
593-
(`@valueType`) are unaffected. Do not recommend this rung on a TPH entity without saying so;
594-
[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the fix. Every rung but the first keeps the
587+
**One sharp edge where generated code IS the consumer: nested map values can be written
588+
UNVALIDATED, per port.** TypeScript (`z.record` over the VO's insert schema) and Python
589+
(`dict[str, VO]` Pydantic) validate map values, and Kotlin writes no map column. **Java**
590+
validates them on its vanilla create/PATCH handlers but NOT on TPH (discriminator-rooted) write
591+
paths — those validate field-by-field with `validateValue`, which does not cascade `@Valid`.
592+
**C# validates them on NO write path** — vanilla create, vanilla PATCH, and TPH alike: the map
593+
never reaches the recursively-validating value-object arms (they admit `field.object` only),
594+
and the generic arms check the dictionary property itself, never its values. A posted value
595+
violating the referenced `object.value`'s constraints is accepted and written, silently, and
596+
reading the adopter's own source will not reveal it. Scalar-valued maps (`@valueType`) are
597+
unaffected. Do not recommend this rung for a Java TPH entity — or for C# at all — without
598+
saying so and pointing at boundary validation of map values before write;
599+
[issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks the Java TPH half, and the C# scope is wider
600+
than that issue. Every rung but the first keeps the
595601
column jsonb, so moving a column up the ladder is a codegen/contract change rather than a
596602
migration — read the emitted DDL before promising that.
597603

‎docs/features/field-types.md‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,22 @@ Three rows need a footnote:
4747
explicit converter/comparer pair, so the property lands on the `jsonb` column the TS-owned
4848
migration creates instead of on whatever an unmapped dictionary would resolve to.
4949

50-
> ⚠️ **On a TPH (discriminator-rooted) entity, a `field.map @objectRef` writes nested
51-
> value-object values UNVALIDATED.** The generated TPH create/PATCH handlers validate
52-
> field-by-field with `validateValue`, which does not cascade `@Valid` into a nested bean,
53-
> and the explicit cascade the vanilla handler runs is not invoked on the TPH path — so a
54-
> posted map value that violates the referenced `object.value`'s own constraints is
55-
> accepted and written. Scalar-valued maps (`@valueType`) are unaffected: there is no
56-
> nested bean to validate. This is generated code, so **reading your own source will not
57-
> reveal it** — the failure is silent acceptance, not an error. Validate map values at your
58-
> own boundary on TPH entities until this closes, or keep the map on a non-TPH entity.
59-
> Tracked as [issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362).
50+
> ⚠️ **A `field.map @objectRef` can write its nested value-object values UNVALIDATED — and
51+
> the scope is per port.** TypeScript (`z.record` over the VO's insert schema) and Python
52+
> (`dict[str, VO]` Pydantic) validate map values, and Kotlin writes no map column at all. The
53+
> hole is in the other two: **Java** validates nested map values on its vanilla
54+
> create/PATCH handlers but NOT on TPH (discriminator-rooted) write paths, which validate
55+
> field-by-field with `validateValue` — that does not cascade `@Valid` into a nested bean,
56+
> and the explicit cascade the vanilla handler runs is not invoked there. **C# validates
57+
> them on NO write path — not TPH, not vanilla create, not vanilla PATCH**: the map property
58+
> never reaches the recursively-validating value-object arms (they admit `field.object` only),
59+
> and the generic arms check the dictionary property itself, never its values. A posted map
60+
> value that violates the referenced `object.value`'s own constraints is accepted and
61+
> written. Scalar-valued maps (`@valueType`) are unaffected: there is no nested bean to
62+
> validate. This is generated code, so **reading your own source will not reveal it** — the
63+
> failure is silent acceptance, not an error. Validate map values at your own boundary
64+
> before write. [Issue #362](https://github.com/metaobjectsdev/metaobjects/issues/362) tracks
65+
> the Java TPH half; the C# surface is wider than that issue's scope.
6066
6167
**The RUNTIME tier is not there yet, and no conformance corpus covers it.** No
6268
persistence- or api-contract-conformance fixture exercises `field.map` on any port; it is

‎fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
366366

367367
**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.
368368

369-
**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 recommend this rung freely where GENERATED CODE is the consumer, and prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type<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.
369+
**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 Java TPH half, and the C# scope is wider than that issue. Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type<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.
370370

371371
---
372372

0 commit comments

Comments
 (0)