fix(compiler): align pool pre-check with write-time locator stripping - #176
Conversation
…#170) decidePoolEligibility used to refuse any chain containing a tenant_scoped locator; buildPoolRow strips those and pools survivors. Regenerate the live bundle (1/12 → 7/12) and record the decision as ADR-0019. Co-authored-by: Cursor <cursoragent@cursor.com>
Post-merge-queue review: not mergeable as-isReviewed against The direction of this PR is right
But the fixture path now breaks the fail-closed boundary
Reproduced locally on
Root cause (row 5 of the fixture recording)I instrumented the record → compile path from The mechanism is not the
So Suggested fixMirror the authority's locator taint/vocab check on the assertion target inside Second, smaller divergenceThe topology branch returns Also
Verified green on |
# Conflicts: # docs/architecture.md
…ing it The first cut of #170 aligned the pre-check by filtering the chain on strategy (tenant_scoped / text / placeholder / topology_only). That hit 7/12 on the live bundle and passed live-bundle-pool.test.ts, but shipped a pre-check that was *looser* than the authority — the one direction src/compiler/pool.ts documents as forbidden. A strategy filter cannot see a vocabulary violation. `testid: "dismiss-notice"` is neither tenant-tagged nor free text, so the filter kept it, while the authority's isPoolSafeTestId rejects it. The fixture recording carries that testid in assertion.target.locator, where buildPoolRow runs checkLocatorTaint and refuses the row as literal_in_assertion — so writeCacheRow threw CacheWriteRejectedError and record -> compile -> cache crashed. The gap predates #170; it was unreachable while the blanket `chain.some(tenant_scoped)` refusal caught those rows a step earlier, so the two implementations agreed by accident. Removing that refusal exposed it. decidePoolEligibility now calls checkLocatorTaint (src/cache/taint.ts) — the same predicate classifyLocators and assertionHasTenantLiteral use — and mirrors buildPoolRow's branch order: assertion first (including its own target.locator), then the chain, then the flow_topology degradation. Two copies of one vocabulary was the defect; one copy with two callers is the fix. - compileStep computes flow_topology before the pool decision, because the authority degrades to a topology_only row only when the row carries it. - topologyOnly is now provably implied by the chain (buildLocatorFallbackChain sets it only where it appends the sentinel); kept and documented, not read. - live-bundle-pool.test.ts runs its invariants over both committed bundles. Scoping them to the live bundle is what let a fixture-only divergence through. Divergence is 0 on all three corpora: live bundle (12 rows, 7/12 — recompiles byte-identical), fixture bundle (6 rows, 4/6 -> 3/6), and a freshly recorded fixture trajectory (6 rows, 3/6). Step 5 of the fixture bundle was never safe to pool; the pre-check could not see it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed a fix for the blocker I reported above, plus a What changed since Worth noting: this ADR's own Open Questions predicted it —
It wasn't a future risk; it was a present one. Marked resolved in ADR-0019. Verification — divergence is now 0 on all three corpora (live bundle 7/12, fixture bundle 3/6, freshly recorded fixture 3/6). The live bundle recompiles byte-identical, so the 7/12 alignment is a property of the rule rather than of one run. One reviewable judgment call: I also broadened |
Summary
Closes #170. The compiler's
pool_eligiblepre-check disagreed with the write-time authority (buildPoolRow) on the committed live bundle — 1/12 vs 7/12 — because it refused any chain that contained atenant_scopedlocator instead of stripping it and pooling the survivors. ADR-0019 records the decision (option 1: teach the pre-check to agree).The pre-check now calls the authority's own
checkLocatorTaint(src/cache/taint.ts) rather than re-deriving the vocabulary, and mirrorsbuildPoolRow's branch order:expected.templateresidue, the assertion's owntarget.locatorthroughcheckLocatorTaint, then the prose/selector heuristics.flow_topology— the authority's actual gate.Why not just filter by strategy
The first cut of this PR did exactly that, and it was wrong in the dangerous direction. A strategy filter cannot see a vocabulary violation:
testid: "dismiss-notice"is neither tenant-tagged nor free text, so the filter kept it, whileisPoolSafeTestIdrejects it. The fixture recording carries that testid inassertion.target.locator, sobuildPoolRowrefused the row asliteral_in_assertion,writeCacheRowthrewCacheWriteRejectedError, andrecord -> compile -> cachecrashed.That gap predates #170. It was unreachable while the blanket
chain.some(tenant_scoped)refusal caught those rows a step earlier — the two implementations agreed by accident. Removing that refusal (correctly) exposed it. Two copies of one vocabulary was the defect; one copy with two callers is the fix.Divergence: 0 on every corpus
true → false/literal_in_assertion. That row was never safe to pool; the pre-check could not see it.Also in this PR
live-bundle-pool.test.tsruns its two invariants over both committed bundles. Scoping them to the live bundle is what let a fixture-only divergence through — an invariant that only runs against one corpus is an invariant with a hole.flow_topologygate.compileStepcomputesflow_topologybefore the pool decision rather than after.topologyOnlyis now provably implied by the chain (buildLocatorFallbackChainsets it only where it appends the sentinel); kept for callers and documented, not read.main(resolves thedocs/architecture.mdconflict from docs(architecture): re-derive wiring diagram from current main #175).Test plan
npm run ci— green (secret-scan, contracts, lint, lint:docs, typecheck, unit, integration)tests/unit+tests/integrationnpm run test:canary— 52 passing, suite unmodifiedtests/integration/pipeline.test.ts— was the failure, now passescompile --to-cacheon both trajectories: noauthority pooled N step(s) the compiler pre-check did notline, noCacheWriteRejectedErrornpm run lint:docs— clean (68 docs)