Skip to content

Commit cc119fa

Browse files
dmealingclaude
andcommitted
docs(audit-skill): the open JSON bag is only a defect when the bag is not open
Auditing an adopter, I found six `field.string` + `@dbColumnType: jsonb` columns and wrote them up as a wrong-native-type finding — the axis-H2 logic that catches `field.string` + `@dbColumnType: uuid`. That was wrong, and the repo says so in three places: the zod emitter calls it "the sanctioned open JSON bag escape hatch — a genuinely untyped JSON column with no value-object to reference", the inferred-types emitter renders it `unknown` deliberately and in lock-step, and `fixtures/api-contract-conformance/jsonb/` gates the contract cross-port. The pattern is live vocabulary. An audit that flags it on sight convicts every correct use. The real defect is narrower and worth its own signature: a bag that is not open. When the consuming code pins a FIXED KEY SET — raw-SQL `->>'key'` reads, casts to a concrete type, destructuring, a hand validator over the parsed value — the shape exists; it is just declared in N readers instead of once in the metadata. That is drift signature 6 with zero declarations rather than several, and no gate can see it: `verify --db` finds a jsonb column that matches, `--codegen` finds generated files that are correct for an open bag. Measured rather than asserted. On the audited estate one column carried 12 distinct keys across 31 raw-SQL path reads and 7 casts in 10 files. Estate-wide the pattern appears 74 times across five projects, which is the argument FOR the discriminator: without it this signature reports 74 findings, most of them correct code. Remedy stated with its limit: declare an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. The column stays jsonb, so it is a contract change rather than necessarily a migration — verify that per column before promising it. Converting also promotes those keys into signature 11's scope, where the `->>'key'` literals were previously exempt precisely because the metadata owned nothing. Carries its own do-not-flag list: a pass-through bag, a third-party or LLM raw response whose shape is not the adopter's to declare, an array of scalars (that is `isArray`), and a bag whose metadata comment already justifies it as open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
1 parent 9c1dbf2 commit cc119fa

12 files changed

Lines changed: 108 additions & 0 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,15 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
357357

358358
**A deferral must carry its measurement forward.** "Wire the names tier" is a real thing to postpone — on the observed estate it meant a new generated tier, a new dev-loop step, a new `verify --codegen` gate and edits to 4 service and 6 test modules that could only be validated against a live database, and the branch in hand deliberately changed nothing in the running application. That is a legitimate decision and belongs in the report. But record it with **the site count, the exact command that emits the artifact, and the modules that would change** — never as intent alone — and **re-raise it in every later audit until the ratio moves**. A deferral with no number attached reads as a nice-to-have on the next reading and silently becomes permanent; the one carrying `0/53` does not.
359359

360+
361+
12. **An "open JSON bag" whose readers all agree on a shape.** `field.string` + `@dbColumnType: jsonb` is SANCTIONED vocabulary, not a defect — it is the deliberate escape hatch for a genuinely untyped JSON column with no value object to reference, it emits `unknown` / `z.unknown()` by design, and a cross-port corpus (`fixtures/api-contract-conformance/jsonb/`) gates that contract. **Never flag the pattern on sight.** Flag it only when the bag is not actually open: when the consuming code demonstrably knows the shape and spells it out. The tell is a FIXED KEY SET — raw-SQL JSON-path reads (`->>'decision'`, `#>>'{a,b}'`), casts to a concrete type (`row.output as { draftText?: string }`), destructuring, or a hand-written validator over the parsed value. At that point the shape exists; it is simply declared in N readers instead of once in the metadata, which is drift signature 6 with zero declarations rather than several — and it is invisible to every gate, because `verify --db` sees a jsonb column that matches and `--codegen` sees generated files that are correct for an open bag.
362+
363+
**Measured on one estate:** 6 such columns, and one of them (`AuditEntry.output`) carried **12 distinct keys across 31 raw-SQL path reads plus 7 casts** in 10 files. Nothing in that is open. Estate-wide the pattern is common — 74 `field.string` + jsonb fields across five projects — which is exactly why the discriminator matters more than the count: report the ones whose readers pin a shape, not the ones whose readers treat the value as opaque.
364+
365+
**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.
366+
367+
**Remedy:** declare the shape as an `object.value` and point the field at it — `field.object` + `@objectRef` + `@storage: jsonb`. 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.
368+
360369
---
361370

362371
## Owned-codegen & scaffold-and-own assessment

‎agent-context/skills/metaobjects-audit/references/capability-checklist.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,15 @@ subtypes with opposite polarity: `requirement.functional` fails when NOTHING imp
272272

273273
## Cross-cutting
274274

275+
- **Open JSON bag vs a shape the readers already know (drift signature 12).** `field.string`
276+
+ `@dbColumnType: jsonb` is the SANCTIONED untyped-column escape hatch (emits `unknown`,
277+
gated by `fixtures/api-contract-conformance/jsonb/`) — never a finding on sight. It is a
278+
finding only when the consuming code pins a FIXED KEY SET: raw-SQL `->>'key'` reads, casts
279+
to a concrete type, destructuring, a hand validator over the parsed value. Then the shape
280+
exists and is declared in N readers instead of once. Remedy: an `object.value` +
281+
`field.object` `@objectRef` `@storage: jsonb` — the column stays jsonb. Not a finding for a
282+
pass-through bag, a third-party/LLM raw response, or an array of scalars (`isArray`).
283+
275284
- **A metadata-derived value spelled a second time (drift signature 11 — one rule, not two halves).**
276285
A literal in non-generated code or config that must EQUAL a value the metadata declares or a
277286
generator derives is a second spelling. The constants to reference: `<Entity>Names` (physical

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,15 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
357357

358358
**A deferral must carry its measurement forward.** "Wire the names tier" is a real thing to postpone — on the observed estate it meant a new generated tier, a new dev-loop step, a new `verify --codegen` gate and edits to 4 service and 6 test modules that could only be validated against a live database, and the branch in hand deliberately changed nothing in the running application. That is a legitimate decision and belongs in the report. But record it with **the site count, the exact command that emits the artifact, and the modules that would change** — never as intent alone — and **re-raise it in every later audit until the ratio moves**. A deferral with no number attached reads as a nice-to-have on the next reading and silently becomes permanent; the one carrying `0/53` does not.
359359

360+
361+
12. **An "open JSON bag" whose readers all agree on a shape.** `field.string` + `@dbColumnType: jsonb` is SANCTIONED vocabulary, not a defect — it is the deliberate escape hatch for a genuinely untyped JSON column with no value object to reference, it emits `unknown` / `z.unknown()` by design, and a cross-port corpus (`fixtures/api-contract-conformance/jsonb/`) gates that contract. **Never flag the pattern on sight.** Flag it only when the bag is not actually open: when the consuming code demonstrably knows the shape and spells it out. The tell is a FIXED KEY SET — raw-SQL JSON-path reads (`->>'decision'`, `#>>'{a,b}'`), casts to a concrete type (`row.output as { draftText?: string }`), destructuring, or a hand-written validator over the parsed value. At that point the shape exists; it is simply declared in N readers instead of once in the metadata, which is drift signature 6 with zero declarations rather than several — and it is invisible to every gate, because `verify --db` sees a jsonb column that matches and `--codegen` sees generated files that are correct for an open bag.
362+
363+
**Measured on one estate:** 6 such columns, and one of them (`AuditEntry.output`) carried **12 distinct keys across 31 raw-SQL path reads plus 7 casts** in 10 files. Nothing in that is open. Estate-wide the pattern is common — 74 `field.string` + jsonb fields across five projects — which is exactly why the discriminator matters more than the count: report the ones whose readers pin a shape, not the ones whose readers treat the value as opaque.
364+
365+
**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.
366+
367+
**Remedy:** declare the shape as an `object.value` and point the field at it — `field.object` + `@objectRef` + `@storage: jsonb`. 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.
368+
360369
---
361370

362371
## Owned-codegen & scaffold-and-own assessment

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,15 @@ subtypes with opposite polarity: `requirement.functional` fails when NOTHING imp
272272

273273
## Cross-cutting
274274

275+
- **Open JSON bag vs a shape the readers already know (drift signature 12).** `field.string`
276+
+ `@dbColumnType: jsonb` is the SANCTIONED untyped-column escape hatch (emits `unknown`,
277+
gated by `fixtures/api-contract-conformance/jsonb/`) — never a finding on sight. It is a
278+
finding only when the consuming code pins a FIXED KEY SET: raw-SQL `->>'key'` reads, casts
279+
to a concrete type, destructuring, a hand validator over the parsed value. Then the shape
280+
exists and is declared in N readers instead of once. Remedy: an `object.value` +
281+
`field.object` `@objectRef` `@storage: jsonb` — the column stays jsonb. Not a finding for a
282+
pass-through bag, a third-party/LLM raw response, or an array of scalars (`isArray`).
283+
275284
- **A metadata-derived value spelled a second time (drift signature 11 — one rule, not two halves).**
276285
A literal in non-generated code or config that must EQUAL a value the metadata declares or a
277286
generator derives is a second spelling. The constants to reference: `<Entity>Names` (physical

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,15 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
357357

358358
**A deferral must carry its measurement forward.** "Wire the names tier" is a real thing to postpone — on the observed estate it meant a new generated tier, a new dev-loop step, a new `verify --codegen` gate and edits to 4 service and 6 test modules that could only be validated against a live database, and the branch in hand deliberately changed nothing in the running application. That is a legitimate decision and belongs in the report. But record it with **the site count, the exact command that emits the artifact, and the modules that would change** — never as intent alone — and **re-raise it in every later audit until the ratio moves**. A deferral with no number attached reads as a nice-to-have on the next reading and silently becomes permanent; the one carrying `0/53` does not.
359359

360+
361+
12. **An "open JSON bag" whose readers all agree on a shape.** `field.string` + `@dbColumnType: jsonb` is SANCTIONED vocabulary, not a defect — it is the deliberate escape hatch for a genuinely untyped JSON column with no value object to reference, it emits `unknown` / `z.unknown()` by design, and a cross-port corpus (`fixtures/api-contract-conformance/jsonb/`) gates that contract. **Never flag the pattern on sight.** Flag it only when the bag is not actually open: when the consuming code demonstrably knows the shape and spells it out. The tell is a FIXED KEY SET — raw-SQL JSON-path reads (`->>'decision'`, `#>>'{a,b}'`), casts to a concrete type (`row.output as { draftText?: string }`), destructuring, or a hand-written validator over the parsed value. At that point the shape exists; it is simply declared in N readers instead of once in the metadata, which is drift signature 6 with zero declarations rather than several — and it is invisible to every gate, because `verify --db` sees a jsonb column that matches and `--codegen` sees generated files that are correct for an open bag.
362+
363+
**Measured on one estate:** 6 such columns, and one of them (`AuditEntry.output`) carried **12 distinct keys across 31 raw-SQL path reads plus 7 casts** in 10 files. Nothing in that is open. Estate-wide the pattern is common — 74 `field.string` + jsonb fields across five projects — which is exactly why the discriminator matters more than the count: report the ones whose readers pin a shape, not the ones whose readers treat the value as opaque.
364+
365+
**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.
366+
367+
**Remedy:** declare the shape as an `object.value` and point the field at it — `field.object` + `@objectRef` + `@storage: jsonb`. 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.
368+
360369
---
361370

362371
## Owned-codegen & scaffold-and-own assessment

‎fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,15 @@ subtypes with opposite polarity: `requirement.functional` fails when NOTHING imp
272272

273273
## Cross-cutting
274274

275+
- **Open JSON bag vs a shape the readers already know (drift signature 12).** `field.string`
276+
+ `@dbColumnType: jsonb` is the SANCTIONED untyped-column escape hatch (emits `unknown`,
277+
gated by `fixtures/api-contract-conformance/jsonb/`) — never a finding on sight. It is a
278+
finding only when the consuming code pins a FIXED KEY SET: raw-SQL `->>'key'` reads, casts
279+
to a concrete type, destructuring, a hand validator over the parsed value. Then the shape
280+
exists and is declared in N readers instead of once. Remedy: an `object.value` +
281+
`field.object` `@objectRef` `@storage: jsonb` — the column stays jsonb. Not a finding for a
282+
pass-through bag, a third-party/LLM raw response, or an array of scalars (`isArray`).
283+
275284
- **A metadata-derived value spelled a second time (drift signature 11 — one rule, not two halves).**
276285
A literal in non-generated code or config that must EQUAL a value the metadata declares or a
277286
generator derives is a second spelling. The constants to reference: `<Entity>Names` (physical

‎fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,15 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
357357

358358
**A deferral must carry its measurement forward.** "Wire the names tier" is a real thing to postpone — on the observed estate it meant a new generated tier, a new dev-loop step, a new `verify --codegen` gate and edits to 4 service and 6 test modules that could only be validated against a live database, and the branch in hand deliberately changed nothing in the running application. That is a legitimate decision and belongs in the report. But record it with **the site count, the exact command that emits the artifact, and the modules that would change** — never as intent alone — and **re-raise it in every later audit until the ratio moves**. A deferral with no number attached reads as a nice-to-have on the next reading and silently becomes permanent; the one carrying `0/53` does not.
359359

360+
361+
12. **An "open JSON bag" whose readers all agree on a shape.** `field.string` + `@dbColumnType: jsonb` is SANCTIONED vocabulary, not a defect — it is the deliberate escape hatch for a genuinely untyped JSON column with no value object to reference, it emits `unknown` / `z.unknown()` by design, and a cross-port corpus (`fixtures/api-contract-conformance/jsonb/`) gates that contract. **Never flag the pattern on sight.** Flag it only when the bag is not actually open: when the consuming code demonstrably knows the shape and spells it out. The tell is a FIXED KEY SET — raw-SQL JSON-path reads (`->>'decision'`, `#>>'{a,b}'`), casts to a concrete type (`row.output as { draftText?: string }`), destructuring, or a hand-written validator over the parsed value. At that point the shape exists; it is simply declared in N readers instead of once in the metadata, which is drift signature 6 with zero declarations rather than several — and it is invisible to every gate, because `verify --db` sees a jsonb column that matches and `--codegen` sees generated files that are correct for an open bag.
362+
363+
**Measured on one estate:** 6 such columns, and one of them (`AuditEntry.output`) carried **12 distinct keys across 31 raw-SQL path reads plus 7 casts** in 10 files. Nothing in that is open. Estate-wide the pattern is common — 74 `field.string` + jsonb fields across five projects — which is exactly why the discriminator matters more than the count: report the ones whose readers pin a shape, not the ones whose readers treat the value as opaque.
364+
365+
**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.
366+
367+
**Remedy:** declare the shape as an `object.value` and point the field at it — `field.object` + `@objectRef` + `@storage: jsonb`. 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.
368+
360369
---
361370

362371
## Owned-codegen & scaffold-and-own assessment

‎fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,15 @@ subtypes with opposite polarity: `requirement.functional` fails when NOTHING imp
272272

273273
## Cross-cutting
274274

275+
- **Open JSON bag vs a shape the readers already know (drift signature 12).** `field.string`
276+
+ `@dbColumnType: jsonb` is the SANCTIONED untyped-column escape hatch (emits `unknown`,
277+
gated by `fixtures/api-contract-conformance/jsonb/`) — never a finding on sight. It is a
278+
finding only when the consuming code pins a FIXED KEY SET: raw-SQL `->>'key'` reads, casts
279+
to a concrete type, destructuring, a hand validator over the parsed value. Then the shape
280+
exists and is declared in N readers instead of once. Remedy: an `object.value` +
281+
`field.object` `@objectRef` `@storage: jsonb` — the column stays jsonb. Not a finding for a
282+
pass-through bag, a third-party/LLM raw response, or an array of scalars (`isArray`).
283+
275284
- **A metadata-derived value spelled a second time (drift signature 11 — one rule, not two halves).**
276285
A literal in non-generated code or config that must EQUAL a value the metadata declares or a
277286
generator derives is a second spelling. The constants to reference: `<Entity>Names` (physical

0 commit comments

Comments
 (0)