Skip to content

fix(compiler): align pool pre-check with write-time locator stripping - #176

Merged
myselfsiddharth merged 3 commits into
mainfrom
fix/170-pool-precheck-align
Aug 21, 2026
Merged

fix(compiler): align pool pre-check with write-time locator stripping#176
myselfsiddharth merged 3 commits into
mainfrom
fix/170-pool-precheck-align

Conversation

@myselfsiddharth

@myselfsiddharth myselfsiddharth commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #170. The compiler's pool_eligible pre-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 a tenant_scoped locator 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 mirrors buildPoolRow's branch order:

  1. Assertion first — expected.template residue, the assertion's own target.locator through checkLocatorTaint, then the prose/selector heuristics.
  2. Classify the chain with the same predicate. Any untainted survivor ⇒ eligible.
  3. Otherwise degrade to a locator-less topology row only if the row carries flow_topology — the authority's actual gate.
  4. Otherwise refuse, naming the first taint reason.

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, while isPoolSafeTestId rejects it. The fixture recording carries that testid in assertion.target.locator, so buildPoolRow refused the row as literal_in_assertion, writeCacheRow threw CacheWriteRejectedError, and record -> compile -> cache crashed.

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

Corpus Rows Compiler Authority Looser
Live gate bundle 12 7 7 0
Fixture bundle 6 3 3 0
Freshly recorded fixture 6 3 3 0
  • The live bundle recompiles byte-identical, so ADR-0019's 7/12 is a property of the rule, not of one run.
  • The fixture bundle moves 4/6 → 3/6: step 5 flips 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.ts runs 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.
  • Two unit tests pin the exact crashing shape and the flow_topology gate.
  • compileStep computes flow_topology before the pool decision rather than after.
  • topologyOnly is now provably implied by the chain (buildLocatorFallbackChain sets it only where it appends the sentinel); kept for callers and documented, not read.
  • Merged main (resolves the docs/architecture.md conflict 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)
  • 574 tests passing across tests/unit + tests/integration
  • npm run test:canary — 52 passing, suite unmodified
  • tests/integration/pipeline.test.ts — was the failure, now passes
  • compile --to-cache on both trajectories: no authority pooled N step(s) the compiler pre-check did not line, no CacheWriteRejectedError
  • npm run lint:docs — clean (68 docs)

…#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>
@myselfsiddharth
myselfsiddharth requested a review from a team as a code owner August 21, 2026 20:45
@github-actions github-actions Bot added size/L <= 600 changed lines documentation Improvements or additions to documentation proposal Design / governance proposal gate PRD section 9 gate measurement area: compiler Touches compiler labels Aug 21, 2026
@github-actions
github-actions Bot requested a review from OM152002 August 21, 2026 20:45
@myselfsiddharth

Copy link
Copy Markdown
Contributor Author

Post-merge-queue review: not mergeable as-is

Reviewed against main @ 14397c1. Two blockers: a merge conflict, and a fail-closed invariant breach that CI is correctly catching.

The direction of this PR is right

decidePoolEligibility refusing any chain that contains a tenant_scoped locator — instead of stripping it the way buildPoolRow does — is a real bug, and ADR-0019 is the right call. On the committed live bundle the alignment works: I re-ran the comparison across all 12 rows and found zero divergence.

But the fixture path now breaks the fail-closed boundary

tests/integration/pipeline.test.ts fails with:

CacheWriteRejectedError: caller requested pool_eligible=true but row failed allowlist/taint checks
  at writeCacheRow src/cache/write.ts:201

Reproduced locally on 41e7a27. This is the pre-check being more permissive than the authority — the direction src/compiler/pool.ts explicitly documents as forbidden:

This pre-check must never be more permissive than the authority — writeCacheRow throws CacheWriteRejectedError when a caller claims pool_eligible and B5 disagrees, so a permissive pre-check is a crash, not a leak, but a crash in the one path that has to work.

Root cause (row 5 of the fixture recording)

I instrumented the record → compile path from src/recorder/fixture.ts:

ROW 5: pre-check=true, authority=false
  authority reason : literal_in_assertion
  chain strategies : [role_name(tenant_scoped=true), testid, structural]
  assertion target : { strategy: "testid", testid: "dismiss-notice" }

The mechanism is not the topology_only branch — it's the assertion check:

  • The authority's assertionHasTenantLiteral runs checkLocatorTaint(assertion.target.locator) (src/cache/write.ts:59-60). That's a vocabulary allowlist, and testid: "dismiss-notice" fails it → non_vocab_testid.
  • The compiler's assertion check only does the expected.template residue test plus the looksLikeTenantLiteral / looksLikeTenantSelector prose heuristics. "dismiss-notice" is a plain kebab string, so it passes.

So decidePoolEligibility has never mirrored the authority's vocab check on assertion.target.locator. That gap was previously masked: row 5's chain carries a tenant_scoped role_name, and the old blanket chain.some(l => l.tenant_scoped === true) refused the row first — pre-check and authority agreed by accident. Removing the blanket refusal (correctly) unmasks the pre-existing gap.

Suggested fix

Mirror the authority's locator taint/vocab check on the assertion target inside decidePoolEligibility, not just the prose heuristics — i.e. run the same checkLocatorTaint over assertion.target.locator and return literal_in_assertion when it's tainted. Worth a unit test pinning row 5's exact shape (non-vocab testid in the assertion target + a strippable tenant locator in the chain), since that's the combination the live bundle does not exercise.

Second, smaller divergence

The topology branch returns pool_eligible: true on topologyOnly || chain.some(l => l.strategy === "topology_only"), but the authority gates its topology degrade on candidate.flow_topology being present (write.ts:163). A row that is topology-only with no flow_topology would be pooled by the pre-check and refused by the authority — same illegal direction, currently unreached by either bundle.

Also

  • Merge conflict with main (docs/architecture.md, docs/README.mddocs(architecture): re-derive wiring diagram from current main #175 landed after this branch).
  • The PR rewrites artifacts/compiled/traj-example-grafana-login-nav.bundle.json (+289/-20). Worth calling out explicitly in the description, since committed bundle artifacts are what every downstream pool-eligibility claim is read from.

Verified green on main before and after the other five open PRs, so this failure is specific to this branch and not pre-existing.

myselfsiddharth and others added 2 commits August 21, 2026 15:07
# 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>
@github-actions github-actions Bot added size/XL > 600 changed lines — consider splitting and removed size/L <= 600 changed lines labels Aug 21, 2026
@myselfsiddharth

Copy link
Copy Markdown
Contributor Author

Pushed a fix for the blocker I reported above, plus a main merge to clear the conflict.

What changed since 41e7a27: rather than patching the one row, decidePoolEligibility now calls the authority's own checkLocatorTaint instead of maintaining a second copy of the vocabulary. The strategy-based filter was the root problem — it can't see a vocabulary violation, so it was always going to drift from isPoolSafeTestId / isAllowedRole / isPoolSafeStructuralPath. Branch order now mirrors buildPoolRow, including the flow_topology gate on the topology degrade (which was the second, unreached divergence I flagged).

Worth noting: this ADR's own Open Questions predicted it —

Whether decidePoolEligibility should eventually call the same checkLocatorTaint the authority uses… a future taint rule could re-open a gap if the compiler's tagging and the cache's checker diverge again.

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. pipeline.test.ts passes; npm run ci and the 52-test canary suite are green; --to-cache on both trajectories produces no divergence line and no CacheWriteRejectedError.

One reviewable judgment call: src/compiler/pool.ts now imports from src/cache/. That is not a new edge — src/compiler/cli.ts has imported ingestBundle / writeCacheRow / JsonlCacheStore since #166 — and taint.ts is a leaf (only allowlist.ts + types.ts). But it does point the compiler at a downstream package. If you'd rather not have that direction, the alternative is moving the shared vocabulary into src/shared/ on the #74 pattern; I noted it in ADR-0019's Open Questions rather than deciding it unilaterally.

I also broadened live-bundle-pool.test.ts to cover both committed bundles. It only checked the live one, which is precisely why it stayed green while pipeline.test.ts crashed — the divergent shape doesn't occur in the live gate task.

@myselfsiddharth
myselfsiddharth merged commit 675892c into main Aug 21, 2026
13 checks passed
@myselfsiddharth
myselfsiddharth deleted the fix/170-pool-precheck-align branch August 21, 2026 22:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: compiler Touches compiler documentation Improvements or additions to documentation gate PRD section 9 gate measurement proposal Design / governance proposal size/XL > 600 changed lines — consider splitting

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reconcile decidePoolEligibility with writeCacheRow: the pre-check refuses 6 rows the authority pools

1 participant