Skip to content

Commit 9ed298e

Browse files
dmealingclaude
andcommitted
docs(audit-skill): signature 12 hunted readers and under-counted; the writer is the evidence
Shipped signature 12 with a reader-side discriminator — raw-SQL `->>'key'` paths, casts, destructuring — measured five adopter projects with it, and reported that 74 open bags reduced to 2 real findings in one project. The maintainer challenged the number. They were right and the method was the flaw. Raw-SQL JSON paths are the RAREST way to consume a jsonb column. A Python, Kotlin or TypeScript consumer parses the value into a type; it never writes `->>`. So the hunt found the one project that happens to query jsonb from SQL and was blind to every project that does the normal thing, which is most of them. The writer is the strongest and cheapest evidence and the shipped text did not mention it. The function that writes the column declares the value's type in its own signature. On one estate's telemetry recorder, columns declared as open bags are written by parameters typed `related_memory_ids: list[str]`, `beliefs_involved: list[str]`, `vector_scores: list[float]`, `tools_offered: list[dict[str, Any]]` — the code states the type in three files each while the metadata says `unknown`. Only the `dict[str, Any]` ones on the same recorder are genuinely open, and those are correct as they stand. So the signature now leads with the writer and demotes reader tells to second, and names the sub-case that turns out to be the commonest and is fully mechanical: **a writer typed `list[str]` / `string[]` / `List<X>` is the base subtype plus `isArray: true`** — not an object, and not an open bag. Declaring it as one loses the element type, so nothing stops a writer putting a map where every reader expects a list. A plural field name plus a typed-collection writer is the whole test. A `*Json` suffix is a second free tell: a field named `conditionJson` / `configJson` / `definitionJson` is admitting it carries a shape. The estate-wide count is not restated here because the corrected method has not been run to completion across all five projects; what is fixed is the method that produces it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
1 parent cc119fa commit 9ed298e

12 files changed

Lines changed: 66 additions & 24 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,9 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
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

360360

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.
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 code demonstrably knows the shape. **Look at the WRITER FIRST — it is the strongest and cheapest evidence, and a reader-only hunt badly under-counts.** The function that writes the column declares the value's type in its own signature: `related_memory_ids: list[str]`, `vector_scores: list[float]`, `tools_offered: list[dict[str, Any]]`, `preferences: dict[str, Preference]`, a serialized dataclass / DTO / `@Serializable` class. If the writer's type is anything but a genuinely open map, the metadata is claiming less than the code already knows. Reader-side tells come second: a FIXED KEY SET in raw-SQL JSON-path reads (`->>'decision'`, `#>>'{a,b}'`), casts to a concrete type (`row.output as { draftText?: string }`), destructuring, typed parsing (`model_validate`, `readValue(…, X.class)`, `decodeFromString<T>`), or a hand-written validator over the parsed value. Measured: hunting reader-side raw SQL ALONE found 2 columns on an estate; adding the writer signatures found many more on the same estate, because raw-SQL JSON paths are the rarest way to consume a jsonb column — most consumers parse it into a type.
362+
363+
**The `isArray` sub-case is the commonest and is mechanical.** A bag whose writer types it `list[str]` / `string[]` / `List<X>` is not an object and not an open bag: it is the BASE SUBTYPE plus `isArray: true` (`field.string @isArray`, `field.double @isArray`). Declaring it as an open jsonb bag loses the element type entirely, so nothing stops a writer putting a map where every reader expects a list. A plural field name (`scopes`, `relatedMemoryIds`, `participantEmails`, `capabilities`) plus a typed-collection writer is the whole test. A `*Json` SUFFIX is a second free tell — a field named `conditionJson` / `configJson` / `definitionJson` is admitting it carries a structured shape. 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.
362364

363365
**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.
364366

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,14 @@ subtypes with opposite polarity: `requirement.functional` fails when NOTHING imp
275275
- **Open JSON bag vs a shape the readers already know (drift signature 12).** `field.string`
276276
+ `@dbColumnType: jsonb` is the SANCTIONED untyped-column escape hatch (emits `unknown`,
277277
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` +
278+
finding only when the code knows the shape. **Check the WRITER first** — the function writing
279+
the column declares the type (`related_memory_ids: list[str]`, `vector_scores: list[float]`, a
280+
serialized DTO); a reader-only hunt under-counts badly, because raw-SQL JSON paths are the
281+
rarest way to consume jsonb. Reader tells second: fixed-key `->>'key'` reads, casts, typed
282+
parsing (`model_validate` / `readValue` / `decodeFromString`), a hand validator. **Commonest
283+
sub-case, and mechanical: a writer typed `list[str]` / `string[]` / `List<X>` means the base
284+
subtype + `isArray: true`, never an object and never an open bag** — a plural name plus a typed
285+
collection is the whole test. A `*Json` suffix is a second free tell. Remedy: an `object.value` +
281286
`field.object` `@objectRef` `@storage: jsonb` — the column stays jsonb. Not a finding for a
282287
pass-through bag, a third-party/LLM raw response, or an array of scalars (`isArray`).
283288

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,9 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
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

360360

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.
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 code demonstrably knows the shape. **Look at the WRITER FIRST — it is the strongest and cheapest evidence, and a reader-only hunt badly under-counts.** The function that writes the column declares the value's type in its own signature: `related_memory_ids: list[str]`, `vector_scores: list[float]`, `tools_offered: list[dict[str, Any]]`, `preferences: dict[str, Preference]`, a serialized dataclass / DTO / `@Serializable` class. If the writer's type is anything but a genuinely open map, the metadata is claiming less than the code already knows. Reader-side tells come second: a FIXED KEY SET in raw-SQL JSON-path reads (`->>'decision'`, `#>>'{a,b}'`), casts to a concrete type (`row.output as { draftText?: string }`), destructuring, typed parsing (`model_validate`, `readValue(…, X.class)`, `decodeFromString<T>`), or a hand-written validator over the parsed value. Measured: hunting reader-side raw SQL ALONE found 2 columns on an estate; adding the writer signatures found many more on the same estate, because raw-SQL JSON paths are the rarest way to consume a jsonb column — most consumers parse it into a type.
362+
363+
**The `isArray` sub-case is the commonest and is mechanical.** A bag whose writer types it `list[str]` / `string[]` / `List<X>` is not an object and not an open bag: it is the BASE SUBTYPE plus `isArray: true` (`field.string @isArray`, `field.double @isArray`). Declaring it as an open jsonb bag loses the element type entirely, so nothing stops a writer putting a map where every reader expects a list. A plural field name (`scopes`, `relatedMemoryIds`, `participantEmails`, `capabilities`) plus a typed-collection writer is the whole test. A `*Json` SUFFIX is a second free tell — a field named `conditionJson` / `configJson` / `definitionJson` is admitting it carries a structured shape. 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.
362364

363365
**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.
364366

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,14 @@ subtypes with opposite polarity: `requirement.functional` fails when NOTHING imp
275275
- **Open JSON bag vs a shape the readers already know (drift signature 12).** `field.string`
276276
+ `@dbColumnType: jsonb` is the SANCTIONED untyped-column escape hatch (emits `unknown`,
277277
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` +
278+
finding only when the code knows the shape. **Check the WRITER first** — the function writing
279+
the column declares the type (`related_memory_ids: list[str]`, `vector_scores: list[float]`, a
280+
serialized DTO); a reader-only hunt under-counts badly, because raw-SQL JSON paths are the
281+
rarest way to consume jsonb. Reader tells second: fixed-key `->>'key'` reads, casts, typed
282+
parsing (`model_validate` / `readValue` / `decodeFromString`), a hand validator. **Commonest
283+
sub-case, and mechanical: a writer typed `list[str]` / `string[]` / `List<X>` means the base
284+
subtype + `isArray: true`, never an object and never an open bag** — a plural name plus a typed
285+
collection is the whole test. A `*Json` suffix is a second free tell. Remedy: an `object.value` +
281286
`field.object` `@objectRef` `@storage: jsonb` — the column stays jsonb. Not a finding for a
282287
pass-through bag, a third-party/LLM raw response, or an array of scalars (`isArray`).
283288

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,9 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
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

360360

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.
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 code demonstrably knows the shape. **Look at the WRITER FIRST — it is the strongest and cheapest evidence, and a reader-only hunt badly under-counts.** The function that writes the column declares the value's type in its own signature: `related_memory_ids: list[str]`, `vector_scores: list[float]`, `tools_offered: list[dict[str, Any]]`, `preferences: dict[str, Preference]`, a serialized dataclass / DTO / `@Serializable` class. If the writer's type is anything but a genuinely open map, the metadata is claiming less than the code already knows. Reader-side tells come second: a FIXED KEY SET in raw-SQL JSON-path reads (`->>'decision'`, `#>>'{a,b}'`), casts to a concrete type (`row.output as { draftText?: string }`), destructuring, typed parsing (`model_validate`, `readValue(…, X.class)`, `decodeFromString<T>`), or a hand-written validator over the parsed value. Measured: hunting reader-side raw SQL ALONE found 2 columns on an estate; adding the writer signatures found many more on the same estate, because raw-SQL JSON paths are the rarest way to consume a jsonb column — most consumers parse it into a type.
362+
363+
**The `isArray` sub-case is the commonest and is mechanical.** A bag whose writer types it `list[str]` / `string[]` / `List<X>` is not an object and not an open bag: it is the BASE SUBTYPE plus `isArray: true` (`field.string @isArray`, `field.double @isArray`). Declaring it as an open jsonb bag loses the element type entirely, so nothing stops a writer putting a map where every reader expects a list. A plural field name (`scopes`, `relatedMemoryIds`, `participantEmails`, `capabilities`) plus a typed-collection writer is the whole test. A `*Json` SUFFIX is a second free tell — a field named `conditionJson` / `configJson` / `definitionJson` is admitting it carries a structured shape. 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.
362364

363365
**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.
364366

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,14 @@ subtypes with opposite polarity: `requirement.functional` fails when NOTHING imp
275275
- **Open JSON bag vs a shape the readers already know (drift signature 12).** `field.string`
276276
+ `@dbColumnType: jsonb` is the SANCTIONED untyped-column escape hatch (emits `unknown`,
277277
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` +
278+
finding only when the code knows the shape. **Check the WRITER first** — the function writing
279+
the column declares the type (`related_memory_ids: list[str]`, `vector_scores: list[float]`, a
280+
serialized DTO); a reader-only hunt under-counts badly, because raw-SQL JSON paths are the
281+
rarest way to consume jsonb. Reader tells second: fixed-key `->>'key'` reads, casts, typed
282+
parsing (`model_validate` / `readValue` / `decodeFromString`), a hand validator. **Commonest
283+
sub-case, and mechanical: a writer typed `list[str]` / `string[]` / `List<X>` means the base
284+
subtype + `isArray: true`, never an object and never an open bag** — a plural name plus a typed
285+
collection is the whole test. A `*Json` suffix is a second free tell. Remedy: an `object.value` +
281286
`field.object` `@objectRef` `@storage: jsonb` — the column stays jsonb. Not a finding for a
282287
pass-through bag, a third-party/LLM raw response, or an array of scalars (`isArray`).
283288

0 commit comments

Comments
 (0)