id-377 trial B — id-317 content_items residual-sweep re-verification
Trial B — content_items vocabulary/reference retirement (id-317 re-verification)
Section titled “Trial B — content_items vocabulary/reference retirement (id-317 re-verification)”Date: 2026-07-29. Corpus: canonical @ ast-dataflow-efficacy-trials (clean, head 8b397be1).
Protocol: for each question — tool answer, then independent rg/Read ground truth, then verdict.
Tool run as a user (CLI cold path only; warm MCP server not registered — Trial A G7).
No canonical edits were made; no trial-blocking tool bug was found.
Row ids are B* to avoid collision with Trial A’s R*; columns, gap_class vocabulary and
gap numbering are shared. Trial A’s G1 / G2 / G3 / G6 / G7 are re-hit here and cross-referenced
explicitly. G8 was filed mid-flight by the Trial A verifier (not by Trial A) and is
independently corroborated here at B17/B18 on different columns; this trial’s own new gaps
therefore start at G9 (O3+ for opportunities).
Note on inherited framing: Trial A’s scorecard claim that column-reads/column-writes are
“the stars, zero over-reports” has been refuted for column-writes and is not carried over —
see G8. Rows B6 and B8 were run before that hand-off arrived; both were re-audited
against it and stand (B18).
Protocol rows
Section titled “Protocol rows”| # | question | command | wall clock (durationMs) | answer | ground truth | verdict | gap_class |
|---|---|---|---|---|---|---|---|
| B1 | Pre-registered datapoint: reproduce string-literal-uses 'content_items' (recorded 27 hits / 11 files) | bun run ast-dataflow string-literal-uses --value content_items | 12.1s (10237) | 7 hits / 7 files, ALL kind: argument, ALL under __tests__/ | rg tracked-file census: 345 files contain the string. The 4 script files in the recorded set are gone from the result: verify-intelligence-classification.ts, snapshot-content-state.ts, propagate-cert-metadata.ts, audit-opaque-json-rpcs.ts deleted; quality-gate.ts / eval-classification.ts / mcp-eval/protocol-compliance.ts re-pointed — bl-495 PR #124 (c6d28d2a, a0178af4). Datapoint has decayed because the debt shrank, not because the tool changed | sufficient (as a literal probe) | — |
| B2 | Decompose the tool-vs-grep delta by cause | classifier over git grep -l content_items × corpus-membership probe (see table below) | — | 7 visible / 338 invisible, split 5 ways; every bucket reconciles to 345 | see 27-vs-186 decomposition table | gap ×3 | detector-missing + corpus-boundary |
| B3 | Which of the 3 exact-literal-in-code files does the tool miss, and why? | diff tool result vs classifier | — | Misses __tests__/lib/intelligence/pipeline.test.ts:375 (c.table === 'content_items', comparison) and __tests__/scripts/propagation/payload-contract.test.ts:83 (array element). Correctly ignores eslint-rules/tests/no-supabase-record-cast.test.ts ×10 (.from('content_items') nested inside RuleTester code: template literals — code-in-a-string, not code) | rg + Read on all three. (Verification pass: these are the only two files carrying the bare 'content_items' literal, but 15 more in-corpus files carry the token inside a longer literal — see the corrected decomposition bucket 2, incl. three prod strings that ship at runtime.) | gap (2) / sufficient precision (1) | detector-missing |
| B4 | W0 / bl-495 — any script still carrying live .from('content_items')? Could the tool see them? | corpus probe via createProject(tsconfig); rg "from\(\s*['\"]content_items['\"]" | 5.9s probe | Zero live .from('content_items') repo-wide. Surviving matches: 10 ESLint rule-test fixture strings, 3 migration comments, 2 test comments. Corpus probe: 51 of 82 scripts/*.ts ARE in corpus (transitively pulled in via __tests__/scripts/*, despite tsconfig.exclude:["scripts"]) — but scripts/quality-gate.ts, the hot file, is OUT today; scripts/eval-classification.ts and scripts/mcp-eval/protocol-compliance.ts are IN | git log confirms PR #124 merged; 4 files deleted on disk. (Verification pass: quality-gate.ts was IN corpus before PR #124 and supplied 17 of the 27 original hits — PR #124’s rewrite is what dropped it out. It carried 18 occurrences then, 7 comment-only ones now, never 8.) | W0 RESOLVED; the tool could have driven W0 at the time, and stopped being able to only after the fix landed | corpus-boundary (G12) |
| B5 | Cross-language recipe — what does schema-coverage report for a RETIRED table? | bun run ast-dataflow-py schema-uses --exclude-tests > /tmp/py.json; bun run ast-dataflow schema-coverage --table content_items | 1.3s (1024) / 0.03s (30) | Sidecar: 765 rows, 19 tables, 8 files, sqlglot:true; zero content_item* tables. schema-coverage: hard error.kind: "unknown_table", message “Table ‘content_items’ is not in Database[‘public’][‘Tables’] (70 tables in schema)”, hint “Dropped or misspelled tables report loudly instead of a silent 0/0.” | Table dropped by id-131 — correct | sufficient (best-in-trial) — output is unambiguous that the table is gone, not “clean” | — |
| B6 | Same probe via column-reads/column-writes — does the loud-error contract hold? | column-reads --table content_items --column title; column-writes … | 5.2s (5188) / 2.5s (2531) | Both return silent results: [], no error, for a table that does not exist at all | — | gap — Trial A G3 CONFIRMED and widened: it is not just dropped columns; a dropped table is silent too, while schema-coverage (same binary, same generated types) reports it loudly. The fix already exists in-repo | ergonomics |
| B7 | DR-102 rule in real output — declared-only ⇒ undecidable, never wired? | schema-coverage --evidence /tmp/py.json | 7.5s (3094) | 807 rows. Rows with verdict wired and all five evidence counters zero: 0. undecidable: 547, of which 2 zero-counter. Merged run rewrites caveats.scan to name the merged sidecar and removes "the Python pipeline (scripts/**/*.py)" from invisibleSurfaces | manual counter audit over all 807 rows | sufficient — DR-102 v1 holds | — |
| B8 | Evidence-merge diff — does --evidence change verdicts? | schema-coverage vs schema-coverage --evidence /tmp/py.json | 6.0s (4181) vs 7.5s (3094) | 807 rows both. Plain: unwired 85, undecidable 722, wired/read-only/write-only 0. Evidence: unwired 78, undecidable 547, read-only 67, write-only 58, wired 57. 183 / 807 verdicts change (22.7 %) — see transition table below | spot-checked pair_resolver.py:161/181 and flow.py:2295/2307 | sufficient (high value) — 6 columns flip out of unwired, i.e. the un-merged run reads them as dead schema | — |
| B9 | W2 identifiers — can references produce exact per-identifier rename scopes? | references --symbol lib/ai/change-reports.ts:ContentItemRow; …template-coverage.ts:ContentItemForMatching; …lib/attention.ts:produceStaleContentItems; callers …produceStaleContentItems | 0.5s (544) / 1.4s (1366) / 0.9s (917) / — | 5 / 11 / 8 exact sites. ContentItemRow is declared 4 separate times repo-wide (change-reports.ts:52, app/api/cron/quality-score/route.ts:48, lib/content/content-suggestions.ts:74, plus PolledContentItemRow); the query returned only the 5 change-reports.ts sites — no bleed | rg spot-check: ContentItemForMatching = 12 rg lines, tool returned 11 — the omitted template-coverage.test.ts:487 is a comment. produceStaleContentItems = 9 rg lines, tool returned 8 — the omitted attention.test.ts:111 is describe('produceStaleContentItems', …), a string label | sufficient (strong) for named symbols. Caveat: a rename still needs the describe label, which only a literal query would find | — |
| B10 | W2 pair — contentItemId write-back + sweep, renamed as one unit | references --symbol lib/edit-intent/write-back.ts:contentItemId; …sweep.ts:contentItemId; …write-back.ts:WriteBackParams | 0.5s / 0.7s / 0.9s | Both contentItemId probes: error.kind: out_of_corpus, “Symbol not found … Looked at functions, classes, methods, variables, interfaces, type aliases, enums, and named exports.” The containing interface resolves fine (2 sites) | rg: contentItemId spans 24 files as a property key on ≥4 independent interfaces (write-back.ts:348, sweep.ts:71,94, arbitrate.ts:45, app/api/q-a-pairs/[id]/route.ts:117) | gap — no member-level resolution; the pair-rename scope is not derivable | detector-missing (G9) |
| B11 | W3(a) — prove closure of the queryKeys.contentItems atomic set | string-literal-uses --value content-items; references --symbol lib/query/query-keys.ts:queryKeys | 6.0s (5950) / 0.9s (940) | Kebab literal: 0 results (all 7 real sites are ['content-items'] as const array elements). references: 200 results, truncated: true, spanning ~80 files — it resolves the whole queryKeys object, not the contentItems member | rg queryKeys.contentItems → 5 code files: use-library-data.ts:135 ✓, use-batch-create.ts:160 ✓, use-library-bulk-actions.ts:**104** (plan says 88), publication-review-action-bar.tsx:**117** (plan says 114), components/review/PublicationReviewQueue.tsx:174 — not in the plan. Plus __tests__/lib/content-browsing/use-content-bulk-runner.test.ts:34,112 builds ['content-items'] raw, bypassing the key factory | gap ×2 — closure not provable | detector-missing (G1 re-confirmed, array element) + G9/G10 |
| B12 | W4 persisted values — does string-literal-uses find the 3 named entity_type='content_item' sites? | string-literal-uses --value content_item | 5.5s (5549) | 6 hits: 5 test argument sites + lib/mcp/resources.ts:58. Zero of the three named sites | The named sites moved and multiplied: review.ts:**403**, governance.ts:**1306**, content.ts:**1871** — and rg finds 24 property-value write sites across 11 files (app/api/cron/{freshness-transitions ×9, review-cadence ×4, quality-score ×2, classification-quality}, source-documents/[id]/send-to-review ×2, content-owners/bulk-assign, review/assignments, governance/review, + the 3 MCP tools). All are entityType: 'content_item', — object-property values | gap — 0/24 detection | detector-missing (G1 re-confirmed, property value) |
| B13 | W4 RPC payload fields — content_item_count, dashboard content_items payload | string-literal-uses --value content_item_count | ~5s | 0 results | rg: 20+ real sites (lib/validation/schemas.ts:2716, lib/mcp/formatters/entities.ts:139,238,243, lib/mcp/tools/content.ts:1987,2040,2077, app/api/certifications/route.ts ×6, app/api/entities/[canonical_name]/route.ts:148, hooks/use-entity-detail.ts:37, components/source-document/source-document-history.tsx:33,244). These are identifiers / object keys, never quoted literals — so the zero is correct for this query and there is no query in the tool that finds them | gap (coverage, not correctness) | detector-missing (G11) |
| B14 | W5 MCP wire — tool names, arg, resource type | string-literal-uses --value {create,update,delete,supersede}_content_item; --value content_item_id | ~5–8s each | create_content_item 15, delete_content_item 13, update_content_item 6, supersede_content_item 2 = 36 sites, incl. every registration site (content.ts:408, content.ts:923, governance.ts:79, supersession.ts:62) and the eval consumers in scripts/mcp-eval/functional-correctness.ts. Resource type found at lib/mcp/resources.ts:58. content_item_id = 0 | rg: content_item_id at search.ts:647 is a Zod object key in the tool-arg shape (invisible, same class as B13); telemetry names are property values (pipelineName: 'mcp_create_content_item' ×6 in content.ts) → invisible (G1) | sufficient for tool names (registerTool/callTool args) — the one wave the tool genuinely drives; gap for arg + telemetry | mcp-surface + detector-missing |
| B15 | KEEP-FOREVER guards — excluded / correctly classified, not reported as debt? | ast-dataflow-py schema-uses with and without --exclude-tests; rg for the 3 guards | 1.0s / 1.1s | All 3 present: scripts/tests/test_cocoindex_flow_write_path.py:2198 (test_content_items_is_structurally_absent), scripts/tests/test_producer_bi28_bidoutcome_proposal.py:285 (TestNoContentItemsWrite), scripts/tests/test_cocoindex_url_write_path.py:936 (ci-seed mirror-guard). Neither py run reports any content_item* table — the guards assert absence, so there is nothing to detect. --exclude-tests: 825→765 rows, 9→8 files (drops scripts/tests/) | Correct: guards are not surfaced as debt in any query | sufficient — but see B16 | — |
| B16 | Are there absence guards the KEEP-FOREVER list misses? | fallout of B3 | — | Yes — a 4th, TS-side: __tests__/scripts/propagation/payload-contract.test.ts:80-92 asserts content_items (and 5 siblings) are excluded from PAYLOAD_CONTRACT. Not on the KEEP-FOREVER list; invisible to the tool (array element, G1) — so a literal-driven sweep would neither protect it nor see it | Read of the test body | gap (inventory) | detector-missing |
| B17 | G8 corroboration (coordinator hand-off) — is column-writes over-reporting at table level? Independent measurement on this trial’s own columns | column-writes --table record_lifecycle --column lifecycle_type (anchor); --table notifications --column entity_type; --table notifications --column read_at | 2.3s (2274) / 1.9s (1911) / ~2s | Anchor reproduced: 5 sites, all confidence: indirect. entity_type: 8 sites. read_at: 4 sites | Every site opened. Anchor: governance.ts:606 (upsert literal — owner_kind/*_id/governance_review_status, no lifecycle_type), governance.ts:1257 and route.ts:231 (.update(updateData), variable) — and the string lifecycle_type does not occur anywhere in either file; content.ts:1226 + test-data-fixture.ts:305 are true. notifications.entity_type: 8/8 true (both indirect payloads — notifications.ts:96’s rows builder at :84-94 and test-data-fixture.ts’s array at :594-608 — do carry the column). read_at: notifications/read/route.ts:30 ✓, test-data-fixture.ts:611 ✓, data-factory.ts:204 defensible (...overrides spread), lib/notifications.ts:96 FALSE — its rows builder sets 7 columns, none of them read_at | gap — G8 CONFIRMED independently | correctness |
| B18 | What is the actual mechanism, and is schema-coverage (and therefore B7/B8) exposed? | audit of confidence field across B17 + exactWrites/indirectWrites over all 807 schema-coverage rows | — | All 17 write sites across the three probes carry confidence: indirect; zero exact writes appeared in any probe. Repo-wide: only 115 of 807 columns have exactWrites>0 vs 616 with indirectWrites>0. Smoking gun: lib/notifications.ts:96 is returned for BOTH entity_type (true) and read_at (false) — the same site attributed to two different columns. But schema-coverage quarantines the class: of 585 (plain) / 522 (evidence) rows whose only write evidence is indirect, 0 are verdicted wired or write-only — all land in undecidable (or read-only, promoted by read evidence). And all 6 of B8’s unwired→live flips rest on confidence: exact sqlglot rows (pair_resolver.py:181 literal INSERT … (name_a, name_b, entity_type, decision, op_id), flow.py:2295/2307 declare_row) | Read of pair_resolver.py:178-184, flow.py:2293-2297; counter audit of all 807 rows | G8 is a presentation-layer defect in column-writes, not an analysis-layer one — the same counter is handled conservatively one layer up. B7/B8 unaffected | output-shape |
The 27-vs-186 decomposition
Section titled “The 27-vs-186 decomposition”Reconciliation of the two headline numbers first, since both have moved:
| measure | recorded in id-317 | today (2026-07-29) |
|---|---|---|
ast-dataflow string-literal-uses 'content_items' | 27 hits / 11 files (7 tests + 4 scripts) | 7 hits / 7 files (7 tests, 0 scripts) — bl-495 PR #124 removed the script set |
repo grep, non-test/e2e .ts/.tsx/.py/.sql | 186 files | 182 files |
…minus supabase/ (historical DDL) | 147 (“research wave re-measure”) | 145 files |
| every tracked file containing the string | — | 345 files |
Decomposition of the full 345-file grep set by why the tool can or cannot see it
(classifier: comment-stripped per language, cross-checked against the tool’s own result set;
corpus membership from a createProject(tsconfig.json) probe):
| # | cause | files | hits the tool returns | why |
|---|---|---|---|---|
| 1 | VISIBLE — exact 'content_items' literal in a CallExpression argument | 7 | 7 | the only shape string-literal-uses classifies here (kind: argument) |
| 2 | in-corpus TS, string literal in a non-call-site position | 17 (3 non-test) | 0 | 2 are the bare 'content_items' literal — comparison (pipeline.test.ts:375) and array element (payload-contract.test.ts:83), both genuine misses (G1). The other 15 carry the token inside a longer literal: it()/describe() names, mock error messages, the ESLint RuleTester code: fixtures ×10 (a correct exclusion), and three prod strings that ship at runtime — lib/mcp/tools/content.ts:477,484,491 (Zod .describe() copy), lib/mcp/tools/review.ts:219 (MCP tool description, i.e. W5 wire copy), lib/content/content-suggestions.ts:202 (sb() telemetry label) |
| 3 | in-corpus TS, identifier / property key, never a quoted literal | 24 (11 non-test) | 0 | content_items: API-response keys and type members. No identifier-text query exists (G11). (content_item_count / content_item_id are a different needle and are only in this 345-set when the file also carries content_items) |
| 4 | in-corpus TS, comment-only | 221 (116 outside __tests__) | 0 | by design and correct — this is the tool’s precision advantage over rg |
| 5 | TS out-of-corpus | 8 (all scripts/) | 0 | tsconfig excludes scripts, but membership is unpredictable — 10 of the 18 scripts/ files carrying the string are pulled in transitively anyway, as is supabase/types/database-overrides.ts (G12) |
| 6 | non-TS | 68 (.sql 36, .py 23, .md 7, .json 1, .js 1) | 0 | .py partly reachable via ast-dataflow-py; .sql/.md never |
| total | 345 | 7 |
(Buckets 2–5 were re-derived by the verification pass against a ts-morph AST classifier — the first-pass numbers were 3 / 41 / 207 / 19. Bucket 1, bucket 6 and the 345 total reproduced exactly; the correction moved 11 files out of “out-of-corpus” and surfaced 14 literal-bearing files that had been counted as prose. The conclusion is unchanged — see below.)
Headline reading. 221 of 345 files (64 %) are comment-only — the single largest bucket, and
the one the tool is right to ignore. Of the 124 code-bearing files, only 7 sit in a shape
string-literal-uses recognises. The boundary this draws: ast-dataflow is a dataflow tool, and
content_items is 64 % prose + 20 % out-of-language + 7 % identifier. A vocabulary sweep is not
a dataflow question, and no amount of detector work changes buckets 4 and 6.
Evidence-merge diff (Q4, actual numbers)
Section titled “Evidence-merge diff (Q4, actual numbers)”Sidecar: ast-dataflow-py schema-uses --exclude-tests → 765 rows / 19 tables / 8 files / 1024 ms.
Both schema-coverage runs return 807 column rows.
| verdict | plain | with --evidence | Δ |
|---|---|---|---|
unwired | 85 | 78 | −7 |
undecidable | 722 | 547 | −175 |
read-only | 0 | 67 | +67 |
write-only | 0 | 58 | +58 |
wired | 0 | 57 | +57 |
183 of 807 verdicts change (22.7 %). Without a sidecar the TS-only run cannot emit
wired/read-only/write-only at all — every column is either unwired or undecidable.
| transition | n | notes |
|---|---|---|
undecidable → read-only | 67 | |
undecidable → write-only | 56 | |
undecidable → wired | 53 | |
unwired → wired | 4 | entity_pair_resolutions.{decision,entity_type,name_a,name_b} — read pair_resolver.py:161, written :181 |
unwired → write-only | 2 | q_a_extractions.evaluation_criteria (flow.py:2295), .extraction_metadata (flow.py:2307) |
unwired → undecidable | 1 | record_embeddings.updated_at (qa_dedup_proposer.py:200) |
The 6 unwired → live flips are the load-bearing result: 7 % of the plain run’s unwired
verdicts are wrong, and unwired is exactly the verdict a schema-cleanup wave would act on.
The plain run does list "the Python pipeline (scripts/**/*.py)" under caveats.invisibleSurfaces
— the caveat is present but unquantified (O4).
Refreshed W0–W5 premise-verdict table (id-317, re-verified 2026-07-29)
Section titled “Refreshed W0–W5 premise-verdict table (id-317, re-verified 2026-07-29)”| wave | premise as written | verdict | current counts | who can drive it |
|---|---|---|---|---|
W0 functional — 4 scripts with live .from('content_items') + delete 3 dead one-offs | RESOLVED | 0 live .from('content_items') repo-wide. verify-intelligence-classification.ts + all 3 one-offs deleted; quality-gate.ts, eval-classification.ts, mcp-eval/protocol-compliance.ts re-pointed (comments only). bl-495 PR #124, c6d28d2a + a0178af4 | rg only today. scripts/quality-gate.ts is out-of-corpus at HEAD, so the tool cannot verify W0’s hot file now (G12) — though it was in corpus pre-#124 and reported 17 of that file’s sites. Compensated with rg + a corpus-membership probe; ast-dataflow-py --scan-dir covers .py but the W0 files are .ts | |
| W1 comment-only sweep (~99 files) | HOLDS (slightly larger) | 116 comment-only files outside __tests__ (221 incl. tests), across app 33, lib 33, e2e 15, components 12, scripts 10, hooks 8, types 4, supabase 1 | rg / human only — by design. ast-dataflow’s value here is as the post-sweep verifier: its silence on all 221 is correct precision, so a non-zero result after the sweep = a real regression | |
| W2 internal identifiers | HOLDS, half-drivable | ContentItemRow 5 sites in lib/ai/change-reports.ts (+3 unrelated same-named local decls elsewhere — do not co-rename); ContentItemForMatching 11; produceStaleContentItems 8 (2 prod, 6 test) | ast-dataflow drives the named-symbol half — references was exact on all three, file-scoped, comment-excluded, zero over-report. Cannot drive the contentItemId pair: no member-level resolution (G9). Rename also needs the describe('produceStaleContentItems') label, which references correctly excludes but a rename must patch | |
W3(a) queryKeys.contentItems + 'content-items' atomic set | DECAYED — inventory grew | 5 code consumers, not 4: adds components/review/PublicationReviewQueue.tsx:174. Two plan line refs stale (use-library-bulk-actions 88→104, publication-review-action-bar 114→117). New hazard: __tests__/lib/content-browsing/use-content-bulk-runner.test.ts:34,112 builds ['content-items'] raw, bypassing the key factory | rg only. Kebab literal query = 0 (array element, G1); references --symbol queryKeys = 200 truncated and member-blind (G9/G10). Closure is not provable with the current tool | |
W3(b) API response keys content_items / content_item_count | HOLDS | 24 files carry the content_items identifier form (11 non-test), plus 25 files on the separate content_item_count needle (82 lines) and 44 on content_item_id: certifications route + cert-summary-card + framework-summary-card, entities route + entity-detail-panel + use-entity-detail, guides route + guide-content + guide-section, formatters/dashboard.ts, validation/schemas.ts, mcp/tools/{content,review}.ts, content-suggestions.ts | rg only — no identifier-text query exists (G11). Server+client lockstep risk unchanged | |
| W4 persisted / RPC | DECAYED — badly under-inventoried | Plan named 3 entity_type='content_item' sites; actual 24 property-value sites across 11 files (cron freshness-transitions ×9, review-cadence ×4, quality-score ×2, classification-quality, source-documents/send-to-review ×2, content-owners/bulk-assign, review/assignments, governance/review, MCP review.ts:403 / governance.ts:1306 / content.ts:1871). content_item_count 20+ sites. Backfill still not needed (S483 finding unchanged) | rg only. string-literal-uses --value content_item found 0 of the 24 (property values, G1) and 0 content_item_count (G11). schema-coverage correctly reports the table as gone (B5) but says nothing about the persisted value | |
| W5 MCP public wire | HOLDS — ownership unchanged (id-71) | Tool names 36 sites: create_content_item 15, delete_content_item 13, update_content_item 6, supersede_content_item 2; registrations at content.ts:408,923, governance.ts:79, supersession.ts:62. Resource type resources.ts:58. content_item_id at search.ts:647. Telemetry mcp_create_content_item ×6 in content.ts | ast-dataflow DRIVES this wave. Tool names + resource type are CallExpression arguments → fully visible with zero noise. Except: content_item_id is a Zod object key (G11, 0 hits) and telemetry names are property values (G1, 0 hits). Verdict process stays id-71’s | |
| KEEP-FOREVER 3 absence guards | HOLDS + 1 addition | All 3 py guards present and correctly not reported as debt (no content_item* table in either py run — the guards assert absence, so there is nothing to detect). --exclude-tests drops scripts/tests/ cleanly (825→765 rows). New: a 4th, TS-side guard — __tests__/scripts/propagation/payload-contract.test.ts:80-92 asserts content_items is excluded from PAYLOAD_CONTRACT; recommend adding it to the KEEP-FOREVER list | tool-safe; the 4th guard is invisible to the tool (array element, G1) so a literal-driven sweep would neither see nor protect it |
Net: one wave resolved (W0), two decayed toward more work (W3a, W4), four hold. ast-dataflow can drive exactly one wave end-to-end (W5 tool names) and half of one (W2 named symbols). W1, W3, W4 and the W2 property-pair remain rg + human.
Gaps & opportunities
Section titled “Gaps & opportunities”| id | gap_class | what | repro | proposed fix |
|---|---|---|---|---|
| G1 ↩ | detector-missing | CONFIRMED, three more shapes. Trial A found comparison / property / array / type-position literals dropped. Trial B re-hits all of them in one task: comparison (pipeline.test.ts:375), array element (query-keys.ts:22-28 ×5, payload-contract.test.ts:83, use-content-bulk-runner.test.ts:34,112), property value (24 × entityType: 'content_item', 6 × pipelineName: 'mcp_create_content_item'). Detection on W4 is 0/24 | string-literal-uses --value content-items → 0 vs lib/query/query-keys.ts:22 | unchanged from Trial A: add comparison, caseClause, propertyValue, arrayElement, typeLiteral, initializer kinds. Trial B raises the priority — property-value is the single most common persisted-enum shape in this repo |
| G2 ↩ | output-shape | CONFIRMED with a counter-example. string-literal-uses zeros (B11 content-items, B13 content_item_count, B14 content_item_id) carry no caveat — yet schema-coverage in the same binary emits a full caveats block naming scanned surfaces, invisible surfaces and merged sidecars (B5/B7). The good implementation already exists | contrast string-literal-uses --value content_item_count vs schema-coverage output | port schema-coverage’s caveats block to every query — no new analysis needed |
| G3 ↩ | ergonomics | CONFIRMED and widened. Trial A: dropped column → silent []. Trial B: a dropped table is also silent from column-reads/column-writes, while schema-coverage --table content_items reports unknown_table + a hint. Same binary, same generated types, opposite behaviour | column-reads --table content_items --column title → []; schema-coverage --table content_items → unknown_table | reuse the existing unknown_table validator in column-reads/column-writes; add unknown_column alongside |
| G6/G12 ↩ | corpus-boundary | CONFIRMED and sharpened into a worse finding. The boundary is not just “scripts are excluded” — it is unpredictable: tsconfig.exclude lists scripts, yet 51 of 82 scripts/*.ts are in corpus via transitive import from __tests__/scripts/*. scripts/eval-classification.ts IN, scripts/quality-gate.ts OUT — with no way to tell from the CLI. A zero result is therefore uninterpretable — and membership is not even stable: quality-gate.ts was IN before PR #124 (17 hits) and is OUT after it, so the same query silently changed meaning across an unrelated refactor | bun run ast-dataflow string-literal-uses --value content_items at 74a56c7d → 27 hits incl. 17 from scripts/quality-gate.ts; at 8b397be1 → 7 hits, that file no longer in corpus | add a CLI corpus-info (MCP-only today) and a per-response corpusSummary (file count, top-level dirs, “your --file/--symbol target is/ is not a member”) |
| G7 ↩ | performance | CONFIRMED. ~0.5–1.4 s for references, but 5–12 s per string-literal-uses (cold project load dominates); this trial spent ~2 min across 11 literal probes | any two consecutive literal queries | unchanged: accept multiple --value args per invocation |
| G8 ↩ | correctness | Filed by the Trial A verifier, CONFIRMED here on different columns. column-writes returns confidence: indirect sites — writes whose payload it could not statically attribute — under an exact columnPath, so a site that writes some column of the table is presented as writing the named one. Independent measurement (B17), 17 sites, every one opened: record_lifecycle.lifecycle_type 3/5 false (60 %) — the string lifecycle_type does not occur anywhere in lib/mcp/tools/governance.ts or app/api/governance/review/route.ts; notifications.entity_type 0/8 false (0 %); notifications.read_at 1/4 clean false + 1 defensible (25 %). Pooled 4/17 clean false positives = 23.5 %. Smoking gun: lib/notifications.ts:96 is returned for BOTH entity_type (true) and read_at (false). The FP rate is not uniform — it tracks how many of a table’s columns a typical payload omits, so wide optional-column tables (facets) are worst and NOT-NULL-heavy tables look clean. Scope limit (B18): this is a presentation-layer defect. schema-coverage consumes the same counter and quarantines it — 0 of 585 (plain) / 522 (evidence) indirect-only-write rows are verdicted wired/write-only; all stay undecidable. column-reads is not implicated in these probes | column-writes --table record_lifecycle --column lifecycle_type → 5 sites, 3 in files that never mention the column | (a) suppress indirect writes from results by default behind --include-indirect; or (b) keep them but drop columnPath and set an explicit attributed: false + "writes an unattributed column of <table>" message. Either way the count must stop reading as a per-column answer. Trial A’s scorecard line “column-reads/column-writes … zero over-reports” is refuted and must not be inherited |
| G9 | detector-missing | references has no member-level resolution. file.ts:propName for an interface/type property errors out_of_corpus — the wrong kind (same mis-kind Trial A flagged in G4: “unsupported granularity” reported as “not in corpus”). This blocks the W2 contentItemId write-back+sweep pair and W3’s queryKeys.contentItems closure — both explicitly atomic-set problems, the exact case where a rename must be provably complete | references --symbol lib/edit-intent/write-back.ts:contentItemId → out_of_corpus | support File.ts:Interface.member and File.ts:Object.member addressing; distinguish unsupported_granularity from out_of_corpus |
| G10 | output-shape | references on a hub object (queryKeys) returns 200 rows with truncated: true, totalEstimated: 285, and no hint and no way to narrow. The count is honest; what is missing is the narrowing path — for closure proof you need the 85 unseen rows, and no argument shape can ask for them | references --symbol lib/query/query-keys.ts:queryKeys → 200 of 285 | when truncated, add a hint pointing at member-scoped addressing (G9) and/or a --offset/--limit pair so the tail is reachable. (Verification pass: totalEstimated already ships — the original “indistinguishable from ≥200 and unbounded” reading was wrong and the fix narrows to the hint + pagination.) |
| G11 | detector-missing | No identifier-text query exists. The dominant residual shape in this task — content_items: response keys, content_item_count, content_item_id — is 24 files on the content_items needle alone, and 306 TS files across the three needles and is addressable by neither string-literal-uses (not a literal) nor references (no single declaration; the same key is an independent member of ~10 unrelated interfaces). This is a coverage hole, not a bug | string-literal-uses --value content_item_count → 0 vs 20+ real sites | add identifier-uses --name <ident> over Identifier/PropertyAssignment/PropertySignature nodes — cheap (no type resolution), and it is what a vocabulary sweep actually needs |
| O3 | opportunity | schema-coverage is the reference implementation for output ergonomics in this codebase: loud unknown_table + hint (B5), a caveats block that changes when a sidecar is merged (B7), a summary verdict histogram. Every other query has none of it | B5/B7 vs B6/B11 | treat schema-coverage’s response envelope as the house style and retrofit; closes G2 and G3 with existing code |
| O4 | opportunity | unwired is under-qualified. 6 of 85 plain-run unwired verdicts (7 %) are live Python-written columns. The caveat block does name the Python pipeline as invisible, but the verdict token itself reads as “dead” | B8 transition table | rename to unwired-in-ts (or add externalSurfacesUnmeasured: true per row) whenever the run has no sidecar and scripts/**/*.py exists |
| O5 | opportunity | The absence-guard shape (expect(tables.has('content_items')).toBe(false)) is a first-class artefact of any retirement task and is currently invisible (array/comparison position, G1). B16 found an unlisted 4th guard by accident | B3/B16 | once G1 lands, a --kind comparison,arrayElement sweep becomes a mechanical way to find absence guards — worth a documented recipe in the skill |
Tool scorecard (this task’s shape: vocabulary/reference retirement sweep)
Section titled “Tool scorecard (this task’s shape: vocabulary/reference retirement sweep)”schema-coverage+--evidence: the star, and the best-engineered surface in either trial. Loudunknown_tableon the retired table (no misleading “clean” 0/0), a caveats block that correctly drops the Python pipeline frominvisibleSurfacesonce a sidecar is merged, DR-102 v1 holding exactly (0 declared-only rows reportedwired), and 183/807 verdict flips incl. 6unwired→live that would otherwise read as dead schema. Run it with the sidecar or not at all. Also the only surface that handles the indirect-write class correctly (G8/B18) — it never promotes indirect-only evidence pastundecidable.column-writes: do not trust the counts. 23.5 % pooled false positives across 17 hand-checked sites (60 % onrecord_lifecycle.lifecycle_type, 0 % onnotifications.entity_type, 25 % onnotifications.read_at) — every returned site wasconfidence: indirect, i.e. an unattributed payload presented under an exactcolumnPath(G8). The FP rate scales with how many optional columns the table has, so it is worst exactly on the wide facet tables a retirement wave cares about.column-readswas not implicated in these probes and still looks clean, but it shares G3 (silent[]for an unknown table and an unknown column).references: precise and correctly scoped where a named declaration exists — 5/5, 11/11, 8/8 with comments and string labels correctly excluded, and no bleed into three same-named localContentItemRowdeclarations. Blind to members (G9), which is where the atomic-set work lives.string-literal-uses: wrong shape for this task. 7 of 345 files, 0 of 24 W4 sites, 0 of the W3 kebab set. It is excellent at exactly one thing here — W5 MCP tool names (36 sites, zero noise), becauseregisterTool/callToolput them in argument position. Everywhere else the literals live in property, array or comparison position (G1) or are not literals at all (G11).ast-dataflow-py: quiet and correct. 765 rows in 1.0 s,--exclude-testsclean, no false-positivecontent_item*table from the synthetic-arg test fixtures, and the 3 KEEP-FOREVER absence guards correctly produce nothing.- Cross-trial corroboration: Trial A’s G1, G2, G3, G6, G7 all re-confirmed independently on a
different task shape; G3 and G6 widened (dropped table also silent; corpus membership is
unpredictable, not merely exclusive). G8 (filed by the Trial A verifier) reproduced on three
columns Trial A never touched, with the FP rate quantified and bounded to the presentation layer.
Trial A’s diagnosis that
string-literal-uses“would BEAT rg if G1’s kinds existed” is supported: on the 221 comment-only files its silence is exactly right, and its 10 correct suppressions of ESLintcode:fixture strings are noise rg cannot filter. Trial A’s “column-reads / column-writes are the stars, zero over-reports” is refuted forcolumn-writesand not inherited. - Verdict pattern: 6 sufficient / 11 gap / 1 mixed across 18 rows.
Verification pass (2026-07-29)
Section titled “Verification pass (2026-07-29)”Independent audit by a second agent. Read-only against canonical @ ast-dataflow-efficacy-trials,
head 8b397be1, working tree clean, except for the in-place corrections marked (Verification pass: …)
above. Every headline number re-derived from the tool and from source — not from this log’s own output.
1. Gap-id collision — RESOLVED
Section titled “1. Gap-id collision — RESOLVED”Two defects were both numbered G8. Agreed final numbering, one id per defect across both logs:
| id | owner | defect |
|---|---|---|
| G1–G7 | Trial A | unchanged |
| G8 | Trial A’s verification pass (filed first) | column-writes reports table-level writes under an exact columnPath |
| G9 | Trial B | references has no member-level resolution (file.ts:propName → out_of_corpus) |
| G10 | Trial B | references truncates a hub object at 200 with no hint and no pagination |
| G11 | Trial B | no identifier-text query exists |
| G12 | Trial B | corpus membership is transitive, unpredictable and unstable across refactors (widening of G6) |
Scheme note: the brief proposed G8→G10 / G9→G11, but Trial B already used G10 and G11, so a literal application would have double-assigned both. A monotonic +1 shift of Trial B’s four new ids (8→9, 9→10, 10→11, 11→12) preserves this log’s internal ordering, leaves Trial A untouched, and gives one id per defect. Applied throughout: the numbering preamble, rows B4/B10/B11/B13, the decomposition table, all seven wave rows, the gaps table, G10’s back-reference, and the scorecard.
2. Independent column-writes false-positive measurement — G8 CONFIRMED
Section titled “2. Independent column-writes false-positive measurement — G8 CONFIRMED”Measured on three columns across three tables Trial A never touched, disjoint from B17’s set. Every returned site was opened and its payload read.
| probe | sites | false | rate |
|---|---|---|---|
source_documents.publication_status | 12 | 3 | 25 % |
q_a_pairs.answer_standard | 26 | 3 | 11.5 % |
form_instances.workflow_state | 10 | 2 | 20 % |
| this pass | 48 | 8 | 16.7 % |
B17 (record_lifecycle.lifecycle_type, notifications.entity_type, notifications.read_at) | 17 | 4 | 23.5 % |
| pooled, 6 columns / 5 tables | 65 | 12 | 18.5 % |
The eight: id138-erasure-cascade.integration.test.ts:172 (spread can only ever add created_at),
test-data-fixture.ts:239 and :734 (mapped insert bodies with no such key),
governance.ts:560, :696, :1019 (the q_a_pairs leg of three owner-kind splits — the payloads are
publication_status + archive metadata, never answer_standard), test-data-fixture.ts:492 and
data-factory.ts:65 (form_instances inserts that never set workflow_state; the latter’s ...overrides
is never called with it).
Sharper repro than any in either log — the column argument carries no information at all.
column-writes --table source_documents --column zzz_not_real returns 10 hits for a column that does
not exist, each stamped columnPath: "zzz_not_real". Ten of the twelve real publication_status rows are
in that same set, so only 2 of 12 are column-attributable. Noise floors: source_documents 10,
q_a_pairs 7, form_instances 4, record_lifecycle 4, notifications 3 — 21 of this pass’s 48 rows
(44 %) are rows the tool returns for any column name whatsoever.
Two mechanism findings to carry into the fix:
confidencedoes not discriminate. All 65 pooled rows areindirectandisTyped: false; the code path that would emitexact(inspectWriteArg,column-writes.ts:106-173) requires a typed client, anddetectIsTypedreturns false on every.from()chain these probes touched. So in this repo everycolumn-writesrow isindirectand the label separates nothing.- The one-hop chase in
resolveOneHopObjectLiteralonly followsconst x = { … }..insert(arr.map(…)),.insert(identifier-built-by-loop)andsatisfies/as-wrapped literals all fall through to “cannot rule out”, which is why fixture builders dominate the false set.
B18’s scope limit independently verified and it holds. Plain run: 585 rows have indirect-only write
evidence, 0 verdicted wired/write-only (all undecidable). Evidence run: 522 indirect-only rows,
0 verdicted wired/write-only (459 undecidable, 63 promoted to read-only by read evidence);
115 rows carry exactWrites>0, 616 carry indirectWrites>0. schema-coverage never promotes the
suspect counter, so B7 and B8 are unaffected by G8 — the defect is confined to column-writes’ own
presentation layer.
G3 widening re-confirmed. column-reads/column-writes --table zzz_not_a_table --column id both
return silent []; schema-coverage --table zzz_not_a_table errors unknown_table with a hint. One
refinement for the fix: for an unknown column on a real table the two queries diverge —
column-reads returns 4 honestly-labelled columnPath: "*" / confidence: "wildcard" rows, while
column-writes returns 10 rows falsely stamped with the bogus column name. “Silent []” understates
column-writes: it fabricates.
3. Headline numbers — re-derived
Section titled “3. Headline numbers — re-derived”The 27 → 7 drop is real decay, and now provable rather than inferred. A detached worktree at
74a56c7d (PR #124’s parent, 2026-07-17) running that commit’s own tool binary returns
27 hits / 11 files exactly: 7 test files × 1 hit, plus scripts/quality-gate.ts 17,
verify-intelligence-classification.ts 1, eval-classification.ts 1, mcp-eval/protocol-compliance.ts 1
= 20 script hits. Today’s 7 files are precisely that measurement’s 7 test files. Not a methodology
difference: string-literal-uses.ts has two commits in its history and the later one (aa494e49,
2026-07-27) only swaps in truncateSpatial — classifyLiteralKind and the five call-site kinds are
untouched, and 7 ≪ the 200 limit either way. The one correction this forces is to the
“quality-gate.ts was never in scope” reading — see §4.
Decomposition. Total 345 and the non-TS split (.sql 36, .py 23, .md 7, .json 1, .js 1 = 68)
reproduce exactly, as do the 7 visible files. Buckets 2–5 did not; corrected in place from an independent
ts-morph classifier (literal-node scan vs identifier scan vs comment-only, corpus membership from
createProject(tsconfig.json)):
| bucket | logged | verified |
|---|---|---|
| literal in non-call-site position | 3 | 17 |
| identifier / property key | 41 | 24 |
| comment-only | 207 | 221 |
| TS out-of-corpus | 19 | 8 |
The out-of-corpus figure was the log contradicting itself: B4 correctly reports 51 of 82 scripts/*.ts
in corpus, yet the table assigned all 18 scripts/ files plus supabase/types/database-overrides.ts
to “excluded”. Ten of those scripts files and the overrides file are members.
The comment-only correction matters more, and it is the failure mode the audit was looking for: three
production files sat in the prose bucket while carrying live, runtime-shipped strings —
lib/mcp/tools/review.ts:219 (an MCP tool description, i.e. W5 public wire copy),
lib/mcp/tools/content.ts:477,484,491 (Zod .describe() argument help), and
lib/content/content-suggestions.ts:202 (sb() telemetry label 'content_items.forSuggestions').
The other eleven are test names and mock error messages. By the log’s own W5 standard — which counts
pipelineName: 'mcp_create_content_item' as a residual — these are residuals, and the review.ts one
is arguably W5 scope. The conclusion is unchanged: prose still dominates (64 % rather than 60 %),
the tool still sees 7 of 345, and W1/W3/W4 still need rg + human.
Two numbers left as logged, both non-load-bearing: the “182 / 145 files” row reproduces as 183 / 146
under git grep -l -- '*.ts' '*.tsx' '*.py' '*.sql' minus ^__tests__/|^e2e/ (a one-file
filter-definition difference), and B7’s “547 undecidable, of which 2 zero-counter” holds only on the
five core counters — both rows (reference_items.superseded_by, .thumbnail_url) carry
unattributableTableSites: 1, so on all six counters the answer is 0.
Evidence merge — exact. Regenerated sidecar: 765 rows / 19 tables / 8 files / sqlglot: true.
Both runs 807 rows. Plain unwired 85 / undecidable 722 / 0 elsewhere; evidence unwired 78 /
undecidable 547 / read-only 67 / write-only 58 / wired 57. 183 of 807 changed = 22.7 %.
Transition table matches row for row (67 / 56 / 53 / 4 / 2 / 1). DR-102 v1 holds: 0 wired rows with
all counters zero. The caveats.invisibleSurfaces rewrite is confirmed — the plain run lists
"the Python pipeline (scripts/**/*.py)", the merged run does not.
Both spot-checked unwired→live flips confirmed at source:
entity_pair_resolutions.decision — pair_resolver.py:161 SELECT decision FROM public.entity_pair_resolutions …
and :181 INSERT INTO public.entity_pair_resolutions (name_a, name_b, entity_type, decision, op_id);
q_a_extractions.evaluation_criteria / .extraction_metadata — flow.py:2295/:2307 in the
declare_row payload and flow.py:3127 in a literal raw-SQL insert column list. Both rest on
confidence: exact sidecar rows, so B18’s “the flips do not depend on the G8 counter” holds.
4. Wave re-scores
Section titled “4. Wave re-scores”- W0 RESOLVED — confirmed.
git grepover all tracked files (corpus-independent, so the out-of-corpus scripts are covered by construction) finds zero live.from('content_items'). Survivors: 10 ESLint RuleTester fixture strings, 2 migration comments, 2 test comments, 2 lines in.claude/checks/multi-user-patterns.md. (Logged as “3 migration comments”; immaterial.) - W0’s tool-coverage claim REFUTED. “
scripts/quality-gate.ts(8 sites) was out-of-corpus; the tool never saw the hot file” is wrong twice: the file was in corpus before PR #124 and the tool reported 17 of its sites (§3), and it has never had 8 — 18 occurrences then, 7 comment-only now. It is out of corpus today because PR #124 rewrote it and its test. Corrected in place. This makes G12 worse, not better: membership is not just unpredictable, it is unstable — an unrelated refactor silently changed what a fixed query means. - W4 DECAYED — confirmed exactly. 24
entity_type/entityType: 'content_item'property-value sites across 11 files:cron/freshness-transitions×9,cron/review-cadence×4,cron/quality-score×2,cron/classification-quality,source-documents/[id]/send-to-review×2,content-owners/bulk-assign,review/assignments,governance/review, plusreview.ts:403,governance.ts:1306,content.ts:1871. All three plan-named sites have moved to exactly those lines.string-literal-uses --value content_itemreturns 6 (5 testargument+resources.ts:58) — 0 of the 24. - W3(a) DECAYED — confirmed.
queryKeys.contentItemshas five code consumers; the fifth,components/review/PublicationReviewQueue.tsx:174, is genuinely absent from the plan. Both stale line refs confirmed (use-library-bulk-actions→ 104,publication-review-action-bar→ 117). Factory bypass confirmed:['content-items']is built raw at__tests__/lib/content-browsing/use-content-bulk-runner.test.ts:34and:112, outsidequery-keys.ts:22. - 4th KEEP-FOREVER guard — confirmed.
__tests__/scripts/propagation/payload-contract.test.ts:80-92,it('excludes client-provenance tables from the payload set'), assertscontent_items∉PAYLOAD_CONTRACT. The 3 Python guards are present at the logged lines and correctly produce nothing in either py run. - Tool probes re-run, all exact:
references5 / 11 / 8; bothcontentItemIdprobesout_of_corpus;references queryKeys200 truncated (totalEstimated: 285);content-items,content_item_count,content_item_idall 0; MCP tool names 15 + 13 + 6 + 2 = 36;schema-coverage --table content_items→unknown_tablewith the logged message and hint verbatim.
5. Hygiene — PASS
Section titled “5. Hygiene — PASS”git status --short in canonical: empty. No tools/ast-dataflow* edit exists, consistent with
“no trial-blocking bug found”. The verification pass ran two throwaway ts-morph probes and one detached
worktree at 74a56c7d, all under /tmp and all removed; git worktree prune clean.
Docs-site attributable to Trial B: tasks/id-317.md (--numstat = 1 0, one appended Notes line,
frontmatter and status untouched) plus the untracked trials/ dir.
src/content/docs/ledgers/initiatives/12.md is NOT Trial B’s — its diff replaces “the pg_proc/migrations
scan is STILL MANUAL” with the S511 scripts/census/pg_evidence.py write-up, i.e. pure id-399 content, and
its mtime (19:25) sits with the id-399/id-396 lane, seven minutes before Trial B’s first write (19:32).
The initiative-12 write-back remains unclaimed by this lane. tasks/id-370.md, id-396.md, id-399.md
and the three parallel-lane spec dirs were not touched.
Verdict
Section titled “Verdict”Safe for the synthesis wave to consume, as corrected. Nine of the twelve gaps carried by the two logs
reproduce unchanged; G8 is independently confirmed on three further columns at a 16.7 % false-positive
rate (18.5 % pooled over six) and is now the best-evidenced defect in the register. Three claims were
refuted and fixed in place — quality-gate.ts’s corpus history, G10’s missing totalEstimated, and four
decomposition bucket counts — and none of them changes a conclusion or invalidates a proposed tool-work
item. The one finding a reader should not inherit uncorrected is the old bucket-4 reading: “comment-only,
and the tool is right to ignore it” was true of 221 files but not of the three production files carrying
live content_items strings, one of which is MCP wire copy.