Skip to content

fix: BA-15 broken-arbiter blockers (sensor + verifier seams)#21

Merged
hamr0 merged 8 commits into
mainfrom
fix/ba15-broken-sensor
Jul 20, 2026
Merged

fix: BA-15 broken-arbiter blockers (sensor + verifier seams)#21
hamr0 merged 8 commits into
mainfrom
fix/ba15-broken-sensor

Conversation

@hamr0

@hamr0 hamr0 commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

Adopter feedback ("faulty primitives" / forbidden-zone audit of the close chain) validated against shipped code, then fixed. A caller arbiter that breaks — the refineLeaf.sensor or a caller opts.evaluate verifier that throws or returns a malformed verdict — is now a named blocker, never coerced into a model failure.

Pre-fix pathologies (all reproduced by poc/ba15-broken-sensor.mjs, deterministic/offline, pre-fix run exits 1):

  • Sensor throw → bare {incomplete, best:null}, byte-identical to a provider death; work destroyed
  • Sensor garbage ({}) → refine burned every attempt with zero feedback, then surfaced converged-shaped {result, verdict:{}}
  • Caller-verifier throw → crashed the whole recurse() run (plain worker) / bare {incomplete} (refineLeaf)
  • Caller-verifier garbage → converged-shaped {result, verdict:{}}

Fix (recurse.js only): both caller arbiters wrapped; first broken close stops the loop; labeled {incomplete, blocker:'broken-sensor'|'broken-verifier'} + receipts.blockerDetail; best preserves the unjudged work (BA-5). One verifyOrBlock helper covers all five dispatch paths. Default Evaluator rubric path never labeled (narrowest guard). HaltError clean on both seams — return await load-bearing (POC's Halt-control arm caught the un-awaited-promise regression mid-refactor).

Evidence

  • POC: 25 checks green post-fix; falsifier arm proves the harness exercises the critique wiring
  • +13 mutation-checked tests; full suite 756 pass / 0 fail; typecheck clean
  • Docs shipped with the code: CHANGELOG [Unreleased], CLAUDE.md, bareagent.context.md, README, RLM_PRD dated entry (including the corrected "no live laundering path" first-pass assessment)

MINOR bump when released (new public blocker values + receipts.blockerDetail).

🤖 Generated with Claude Code

https://claude.ai/code/session_016qq7RsZQw1SrqHNM9sDpmf

hamr0 and others added 8 commits July 20, 2026 00:02
…d, never coerced into a model failure

Adopter feedback (forbidden-zone audit of the close chain) validated against
shipped code with a deterministic pre-fix POC (poc/ba15-broken-sensor.mjs):

Sensor seam (refineLeaf.sensor):
- a THROWING sensor fell through to a bare {incomplete, best:null} —
  byte-identical to a provider death, the model's unjudged work destroyed
- a MALFORMED verdict ({}, 'ok', {ok:true}) read as pass:false with
  critique:null, so refine burned every attempt re-sending the PLAIN task
  (zero feedback), then surfaced converged-shaped as {result, verdict:{}}

Verifier seam (caller opts.evaluate; found by a follow-up probe that
falsified the first-pass "no live laundering path" assessment):
- a THROWING caller verifier CRASHED the whole recurse() run plain-worker
  (and laundered to a bare {incomplete} under refineLeaf)
- a GARBAGE return rode out converged-shaped as {result, verdict:{}}

Fix (recurse.js only, refine.js untouched): both caller arbiters are
wrapped — a non-Halt throw or a malformed return (neither boolean `pass`
nor valid tri-state `status`) stops at the FIRST broken close and returns
a labeled {incomplete, blocker:'broken-sensor'|'broken-verifier'} +
receipts.blockerDetail, with `best` preserving the unjudged work (BA-5).
One verifyOrBlock helper covers all five dispatch paths; the default
Evaluator rubric path is never labeled (provider-class faults — narrowest
guard); HaltError passes through clean on both seams. `return await` is
load-bearing at the call sites (the POC's Halt-control arm caught an
un-awaited returned promise escaping the try's catch mid-refactor).

+13 mutation-checked tests; suite 756 pass / 0 fail; typecheck clean.
New public surfaces (MINOR): blocker 'broken-sensor'|'broken-verifier',
receipts.blockerDetail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qq7RsZQw1SrqHNM9sDpmf
…back finding #2, F26 borrow)

Token-free audit over the poc/ evidence archive + tests: per knob, does
≥1 one-knob pair with a differing OUTCOME CLASS exist, or only
mechanism/wiring proof? Citations verified against the archive.

- 12 knobs outcome-class proven (retrieval, window, passes, count,
  partition/workerBudget, synthesize, context, refineLeaf temps/buffer,
  maxDepth-as-capability, retrieval:'tools', maxConsecutiveDenials)
- 2 flagged unproven-for-outcome, confirming the feedback's exact
  suspects: persona (adoption proven, outcome delta never) and the
  capability-scrub prompt WORDING (presence mutation-tested, effect
  never toggle-tested); plus contract, NB-5 wording, maxIterations,
  maxIdenticalToolErrors noted mechanism-only
- Disposition: flags are documentation, not build orders (lean bar);
  the F26 version+prompt-hash receipt is PARKED (no consumer today)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qq7RsZQw1SrqHNM9sDpmf
…ona EFFECT, scrub wording NULL)

Prereg'd probes (pre-worded readouts, code-scored evidence-only outcomes,
harness-confound guards), claude-haiku-4-5, 8 trials/arm:

- persona → OUTCOME-PROVEN (poc/audit2-persona-outcome.mjs): through the
  shipped recurse() (maxDepth:0 single-shot leaf), a risk-averse-SRE
  stance flipped a borderline deploy judgment SHIP 8/8 → HOLD 8/8.
  Two prior cells were NO-HEADROOM (haiku security-sweeps any
  find-the-risk task unprompted) — recorded, not discarded.
- capability-scrub prompt wording → MEASURED-NULL, bracketed
  (poc/audit2-scrub-wording.mjs): "PREFER DIRECT ACTION" on/off with an
  identical spawn tool — trivial task 0/8 delegation both arms,
  policy-example-mirroring task 8/8 both arms (3 spawns every trial).
  The tool contraction carries the scrub, not the prose. Wording
  retained (one model, two task shapes — toy-fixtures caution); the
  load-bearing claim is downgraded to measured-null.

Zero shipped-code changes. Method note recorded: first cells were
NO-HEADROOM — the harness had to create the precondition before any
verdict meant anything (§2.C red-team-precondition rule).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qq7RsZQw1SrqHNM9sDpmf
…BA-5 best, typed arbiter error

A /code-review workflow lost 7 of 16 agents to a spend limit (one whole
correctness finder + six verifiers), so its 3-cleanup-finding verdict sat
on top of un-adjudicated candidates. Recovered all 13 raw candidates from
journal.jsonl and verified them against the code by hand. Five were real:

1. npm run typecheck was FAILING (TS2722/TS18048): the caller-verifier
   async IIFE broke `typeof opts.evaluate === 'function'` narrowing inside
   the closure. CI-gating and publish-blocking. My earlier "typecheck
   clean" claim was false — asserted from a `&& echo` whose echo never
   fired. Hoisted to a narrowed const; now verified by exit code.
2. A status-only {status:'satisfied'} verdict burned every iteration:
   the advertised contract accepts it but refine.js stops on verdict.pass,
   so a satisfied close never stopped and reported passed:false.
   runArbiter now derives pass = status==='satisfied' (copy, no mutation).
3. BA-5 violation: refineLeaf's halt/deny/generic-fault branches returned
   best:null while the plain-worker path preserved best:out.text. All
   three now preserve the last non-empty attempt.
4. A detached JSDoc block — the new helpers were inserted between
   recurseRefineLeaf's doc comment and the function, silently dropping
   that function's @param typing. Helpers moved above the doc.
5. The magic-string fault channel (the workflow's top-ranked finding):
   'broken-sensor: ' prefixes encoded into Error.message and re-parsed by
   startsWith, replaced with a typed module-local BrokenArbiterError
   (instanceof + .tag/.detail) — no public export, never escapes recurse.

Also: defensive `instanceof HaltError` rethrow in verifyOrBlock, both
arbiter wrappers factored into one runArbiter, five duplicated comments
consolidated onto verifyOrBlock's JSDoc, and the "unjudged work" wording
softened to "last non-empty output" (best can be a prior attempt when the
latest is empty).

+3 regression tests; suite 759 pass / 0 fail; typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qq7RsZQw1SrqHNM9sDpmf
…round 1's own BA-5 fix

Re-ran the code-review workflow on the corrected code; this time all 19
agents completed (round 1 lost 7 to a spend limit), so the verdict is
trustworthy. Four confirmed defects, mutation-proven:

1. The BA-5 preservation missed the terminating attempt.
   `lastAttemptText` was captured AFTER the halt/error throws, so a
   FIRST-attempt halt discarded its own text and still returned
   best:null. Round 1's test only halted on attempt 2 — where a prior
   clean attempt had already populated it — so a too-weak test let a
   broken fix look fixed. Capture now precedes the throws.
2. recurseScan/recursePartition destroyed a finished result: round 1's
   `return await` routed a verifier HaltError into their catches, which
   returned best:null, discarding a fully code-counted scan. Both now
   preserve the computed result.
3. A child's blocker was laundered by its parent — the
   {incomplete, missingSlices} aggregation dropped a child's
   broken-sensor/broken-verifier label, so a nested broken sensor never
   named itself at the top (BA-15's own failure mode, one level up).
   Shared inheritedBlocker now carries it at all three sites.
4. A verify-slot halt after a PASSING sensor clobbered the refineLeaf
   receipt to passed:false, reporting a close that did happen as one
   that didn't.

Also: verdict shape inspection moved inside runArbiter's try (a throwing
accessor on a returned Proxy escaped untyped), and BrokenArbiterError
carries `cause`.

Accepted behavior change documented: opts.evaluate is now held to its
documented Verdict return; a loose object or bare boolean (always a
contract violation, previously silent) now yields
{incomplete, blocker:'broken-verifier'}. No shim — one would reopen the
laundering hole. Documented open item: instanceof HaltError is
realm-sensitive at 23 pre-existing sites.

+4 mutation-proven tests; suite 762 pass / 0 fail; typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qq7RsZQw1SrqHNM9sDpmf
…ns this branch introduced

Rounds 1-2 ran /code-review medium (5 then 4 findings); round 3 ran xhigh
(6 finders + gap sweep, clean 40/40 agents) and found 15. The yield rose
because the review got DEEPER, not because the code got worse — worth
stating plainly, since "the last round found less" was my own (wrong)
argument for convergence.

Every finding was reproduced against the unfixed branch by
poc/ba15-round3-validate.mjs BEFORE any edit; every fix is mutation-proven.

Regressions BA-15 itself introduced:
1. The status-only normalization used an object SPREAD (own-enumerable
   only), so a class-instance verdict with prototype getters passed the
   shape check then came out with status/critique ERASED — the critique
   never reached the retry prompt, reinstating the zero-feedback burn for
   a shape the validator blessed. Now a descriptor copy onto the same
   prototype.
2. A strict-boolean `pass` gate hard-blocked previously-CONVERGING
   sensors returning {pass:1}. A PRESENT `pass` now counts whatever its
   type — refine has always branched on truthiness, and BA-15 exists to
   catch a verdict with NO usable signal, not one answering in another
   dialect.
3. Round 1's typecheck fix DETACHED opts.evaluate, so a method-reference
   verifier went from this===opts to this===undefined and threw. Re-bound
   to opts; the honest limit (this is never the grader instance) is tested
   rather than overstated.

BA-15's guarantee failing at boundaries it did not cover:
4. The three HaltError catches never called inheritedBlocker (only the
   missingSlices branches did), so a gate tripping after a broken-sensor
   child dropped the label.
5. The spawn tool flattened a blocked child into a generic [incomplete],
   letting a parent re-delegate into the same broken judge — BA-15's own
   spend-burn, one level up.
6. Object.assign(node, inherited) stamped the label onto EVERY ancestor:
   receipts accused nodes whose sensor never ran, and one denied child
   re-labelled its parent governance-deny. The inherited label now rides
   receipts.blockerFrom + a new blockerTask naming the culprit.
7. recurseFanout never got round 2's BA-5 hoist and discarded a computed
   reduce on a verify-slot halt.
8. blockerDetail rode only receipts, not the result.
9. Diagnostics: a non-Error throw rendered as [object Object]; a verdict
   whose accessor throws was reported as "the sensor threw". Separated
   around the await itself, which probes .then and so fires the accessor
   before any shape check runs.
10. inheritedBlocker's 'broken-verifier' arm was UNREACHABLE (forChild
    strips evaluate) while CHANGELOG and JSDoc asserted it worked. Dead
    arm deleted, claims corrected.

All six aggregation/halt paths now route through one incompleteWithBlocker,
which also removes the 3x copy-paste whose next edit would have missed a path.

KNOWN LIMIT, documented not fixed: a halt DURING scanCount still returns
best:null — scanCount throws without surfacing the windows it judged, so
nothing exists at this seam to preserve. The comment implying BA-5 coverage
was corrected rather than left to mislead.

Two of the harness's own checks were CONFOUNDED and caught only because
every fix was mutation-proven, not merely re-run: a malformed Planner stub
ended the run before the path under test (a false REPRODUCED), and a
defensively-rewritten test stopped exercising the crash it existed to catch.

+28 mutation-checked tests; suite 889 pass / 0 fail; typecheck clean by
exit code; both POCs exit 0. Docs synced same commit (CHANGELOG, CLAUDE.md,
context, RLM_PRD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the audit log

/security on the round-3 changes caught a leak the round-3 fix itself
introduced. describeThrown used JSON.stringify on a non-Error throw, and a
thrown non-Error is typically a spawn/exec RESULT carrying a full stdout
buffer and an env snapshot. blockerDetail rides into receipts, which a wired
gate serializes VERBATIM into a plaintext audit log — so this wrote caller
secrets to disk. Measured: a 200KB detail string containing an sk-live-
token. Same leak class as F16/BA-1's audit-ctx provider strip.

Now takes only conventional diagnostic fields (name/code/errno/syscall/path/
status/signal/message), clamped to 200 chars — still names the fault
(code=ENOENT) without dumping the payload. Measured after: 25 chars, no
secret. Prototype pollution via the new descriptor-copy path was probed
separately and is clean.

+1 mutation-proven regression test (restore the JSON.stringify dump and it
goes red). Suite 890 pass / 0 fail; typecheck clean by exit code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eams)

A faulty ARBITER is named, never coerced into a model failure. A
refineLeaf.sensor or caller opts.evaluate that throws (non-Halt) or returns a
malformed verdict stops the run at the FIRST broken close and returns a
labeled {incomplete, blocker:'broken-sensor'|'broken-verifier'} with
blockerDetail, best preserving the model's unjudged work (BA-5).

Three review rounds (the third at xhigh, a clean 40/40) plus a /security pass;
all findings reproduced against unfixed code before being touched, all fixes
mutation-proven.

MINOR: new public surfaces (blockerDetail/blockerTask on RecurseResult,
receipts.blockerFrom) and documented behavior changes.

Suite 890 pass / 0 fail; typecheck clean by exit code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hamr0
hamr0 merged commit a5af8de into main Jul 20, 2026
2 checks passed
@hamr0
hamr0 deleted the fix/ba15-broken-sensor branch July 20, 2026 10:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant