Skip to content

[desgin-docs] Phase 1: design set, implementation plan, and Stage 0 + Stage 1 of failproofaid - #630

Merged
NiveditJain merged 11 commits into
luv-629from
luv-630-phase-1-impl
Jul 30, 2026
Merged

[desgin-docs] Phase 1: design set, implementation plan, and Stage 0 + Stage 1 of failproofaid#630
NiveditJain merged 11 commits into
luv-629from
luv-630-phase-1-impl

Conversation

@NiveditJain

@NiveditJain NiveditJain commented Jul 30, 2026

Copy link
Copy Markdown
Member

Phase 1 of failproofaid: the design set, the implementation plan, and Stage 0 + Stage 1 implemented.

The enforcement plane now has a working privileged evaluator behind a Unix socket. It is dead code unless FAILPROOFAI_DAEMON_MODE is set — no configuration default, no wizard default. A revert is one git revert; a field rollback is one environment variable.


The thing that would have been silently broken

Execution tiers are derived from a policy's resolved import graph. All 39 builtins shared one module importing child_process, so derivation would have routed every one of them to user-context and left the sealed tier empty — an architecture that looks implemented and delivers no verdict integrity at all. The 39 now split 32 / 7 by capability, and __tests__/hooks/builtin-tier-split.test.ts walks the real transitive graph rather than trusting the file layout.

That test caught itself. Its import-specifier regex used a newline-excluding character class, which matches none of this codebase's multi-line imports — so the walk stopped at the entry file and the suite went green while payload-only.ts transitively imported node:fs. Found by injecting node:fs into shared.ts and watching nothing fail. It now asserts the exact module set it visited, so "the walk found nothing" is a failure rather than a pass.

Stage 0

P1 39 builtins → builtin/payload-only.ts (32) + builtin/host-access.ts (7). Registry order, categories and defaults snapshot-identical.
P2 home / projectDir travel as request data. The daemon evaluates for another UID, so its own homedir() is the service account's — and isAgentInternalPath / block-read-outside-cwd both widen the allow set, the direction that does not announce itself.
P3 evaluatePoliciesevaluateVerdicts + encodeResponse. The twelve-vendor response matrix moved character-identically; a "semantically equivalent" rewrite is a silent-allow generator.
P4 Canonical request envelope with per-field provenance. resolveCodexMode's unbounded scan of ~/.codex/sessions bounded to a 256 KiB head window — measured: first turn_context at line 8 in every real transcript sampled, 3.07 ms → 0.54 ms on a 2.7 MB file.

Plus: 5,568 parity fixtures across every CLI × event × decision × tool-presence × policy-count, a coverage tripwire over all 348 (cli, event) cells, generated canonicalization tables with a drift gate, and a cold-start latency harness (scripts/bench-hook.ts).

The baseline artifact is not committed yet. The first matrix run had 27,345 of 36,888 iterations fail — a concurrent build rewrote the gitignored dist/ the harness was reading live — and the script wrote the file anyway. That is the worse half of the bug: a biased survivor set masquerading as a measurement. The harness now snapshots dist/, aborts above a 1% failure rate, and records the failure rate in the artifact; the baseline lands once a clean run completes.

Stage 1

The sealed runtime bundles the 32 payload-only builtins, the registry and the full response encoder into one file evaluated inside QuickJS-ng with no bindings registered — no require, no module loader, no process, no filesystem, no sockets. node:path is replaced by a port proven equivalent to node:path.posix differentially over 8,000+ generated cases; host modules by stubs that throw a named capability; hook-logger / hook-telemetry / telemetry-id by inert no-ops, which removed a fetch() to PostHog from a tier the design requires to perform no unbounded I/O.

Three Rust crates: fpai-ipc (framing with a 1 MiB cap validated before allocation, envelope, peer credentials, and the combination lattice), fpai-canon (generated tables), failproofaid (one warm sealed worker, one enforcement lane, an interrupt handler that makes a runaway policy a deadline miss rather than a hung daemon).

home is derived at the socket from getpwuid_r(peer_uid) and a client-supplied one is rejected, not overwritten. Overwriting makes the attack a no-op but leaves the protocol looking like it accepts the field, so the next client implementation and the next reviewer both reasonably conclude it is meaningful. cwd / project_dir / env_facts genuinely cannot be derived, so they ride as client-asserted and any decision reading one is reported sealed_unattested — the honest version of "unforgeable".

How it is verified

In increasing strength:

  1. The sealed bundle loads and runs in a node:vm context with no host globals at all — whatever is not in the context does not exist, exactly as in QuickJS.
  2. The warm-worker soak. A 5,220-row corpus twice through one warm worker, then shuffled, then against a fresh context per row. The last is the one that matters: self-consistency alone is satisfied by a worker that is consistently wrong; comparing against a fresh context is what pins the resident worker to the per-event-process semantics it replaces.
  3. All 5,220 rows byte-identical to the legacy evaluator.
  4. 70,656 generated property cases proving deny > instruct > allow is associative, commutative, idempotent, equals the maximum, and that adding any number of user_context results never lowers a sealed deny — the formal statement of the entire two-tier argument.
  5. 13 daemon E2E tests over a real socket, including the negatives: forged home refused, unknown env fact refused, oversize frame refused without allocating, home genuinely derived from the peer UID.
  6. The real TypeScript client against the real Rust daemon. The only test that catches two independent implementations of one prose spec disagreeing — which is what happens by default, not by accident.

Live bugs found and fixed

All four found by survey, none invented by the plan:

  • Removing "prepare" alone would have published an empty package. publish.yml had no build step and depended entirely on that lifecycle script to populate the gitignored dist/ and .next/standalone/, both in the files allowlist. Now an explicit build plus a step that fails on a missing or zero-length artifact, --ignore-scripts on publish, and a version check that fails CI if any lifecycle script is re-added.
  • 17 design-doc files were shipping to npm on every publish. prune-standalone.mjs pruned "design-docs"; the directory is desgin-docs. Unpacking the tarball also showed Next's tracer was about to ship the new Cargo.toml and rust-toolchain.toml.
  • The bun cache has never invalidated since the initial import. All seven cache keys hashed a bun.lockb that does not exist.
  • The version-consistency check has passed vacuously since the initial import, looping over a packages/ directory that does not exist.

Plus one the bench harness surfaced: handler.ts armed a 10-second timeout per custom hook and never cleared it. Invisible today only because the bin entry point calls process.exit() on return — and about to stop being invisible in the resident worker and the per-user agent, exactly the processes this PR introduces. Measured at a 10,088 ms p95 in a harness without the hard exit.

Test status

Unit 2,920 passing (+450)
E2E 330 passing, 6 skipped
Rust 117 passing
tsc / eslint clean / 0 errors
cargo fmt / clippy -D warnings clean

The 15 remaining unit failures are pre-existing window.localStorage failures in project-list.test.tsx (a jsdom/env issue), unchanged from before this branch.

What is NOT in this PR

Stages 2–5 of Phase 1 remain. Stage 2 is full twelve-CLI shadow-mode parity against real vendor CLIs; Stage 3 the privileged install, _failproofai account, systemd/launchd registration and journal-backed transactional setup; Stage 4 the native client, per-user agent and signed schema catalog; Stage 5 collector convergence, which is blocked on an external dependencyagenteye-collector is a separate repository and its conformance corpus has to be vendored first.

Also outstanding and on the critical path: macOS codesigning and notarization, absent from all eight design documents. An unsigned failproofaid registered as a LaunchDaemon is killed on macOS 15, which blocks two of the four release targets.

🤖 Generated with Claude Code

Grounded in a full survey of the current code, not the design docs alone.
The sequencing is built around one idea: the existing TypeScript
implementation is not replaced, it is promoted to oracle. It stays in the
tree, keeps its tests, answers when the daemon cannot, and is what every
new implementation is diffed against byte-for-byte.

Also lands the per-user agent section in 03-daemon-architecture.md, which
closes the question of how a daemon running as _failproofai reads session
transcripts under 0700 homes: it does not. Capture and user-context
evaluation both run in one per-user agent at the requesting UID, because
both want the same thing and residency is already required by the
enforcement deadline. Granting the service account ACLs into every
enrolled user's home was considered and rejected — a chmod by the harness
silently caps the ACL mask, the SQLite sources need write rather than
read, and it would make a daemon compromise a read of every developer's
transcripts while buying no tamper-resistance.

Three decisions settled:

- The sealed tier runs QuickJS-ng, ~1 MB against V8's +30-45 MB on each of
  four tarballs npx downloads, with deny-by-default structural rather than
  a syscall filter. Gated by a Stage-0 spike against the real regex corpus.
- The 39 builtins are not ported to Rust. The boundary is the process, UID
  and absent bindings, not the language; porting buys no security, does
  nothing for user-authored policies, and the regex crate cannot express
  extractAbsolutePaths' lookbehind.
- Protected policy enablement moves to a root-owned machine.json. It
  currently lives in a user-writable file, so an agent deletes block-sudo
  from a JSON array and the unforgeable verdict never runs — the
  tamper-proof claim was not true as written.

The survey turned up four live bugs Stage 0 fixes: prepare is the only
thing populating dist/ before npm publish and publish.yml has no build
step, so removing it alone ships an empty package; prune-standalone.mjs
prunes "design-docs" while the directory is desgin-docs; every CI cache
key hashes a bun.lockb that does not exist, so the bun cache has never
invalidated; and the packages/wrapper version check has no-opped since the
initial import.

It also found the least visible failure mode in the project: all 39
builtins share a module importing child_process, so import-graph tier
derivation would route every one of them to user-context and leave the
sealed tier empty — an architecture that looks implemented and delivers no
verdict integrity.

Verification is seven layers ending in a full-stack Docker gate: a
contract-faithful events:add stub plus a debian:12 machine running systemd
as PID 1 with node and npm only, installed through the real npx setup.
Twelve assertions cover install, enforcement across 12 CLIs, both tiers,
the no-agent path, protected-vs-mutable, capture through spool to
delivery, crash durability with strace-verified fsync ordering, dashboard
access control, catalog rollback and uninstall. Two legs must never be
skipped: the no-init preflight refusal, and tamper refusing before
anything is written under /opt.

Six design-doc amendments are recorded. macOS codesigning and
notarization is the most urgent — absent from all eight documents, and an
unsigned LaunchDaemon is killed on macOS 15, which blocks two of the four
targets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjFAYdzWkGUPE2sMWWBC6E
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e4c9fbe6-5b26-4fa1-8b7f-01391f28064d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The enforcement plane now has a working privileged evaluator behind a Unix
socket, off unless FAILPROOFAI_DAEMON_MODE is set.

Stage 0's four prerequisite refactors come first, and P1 is the one that
mattered most. Execution tiers are derived from a policy's resolved import
graph, and all 39 builtins shared one module importing child_process — so
derivation would have routed every one of them to user-context and left the
sealed tier empty. An architecture that looks implemented and delivers no
verdict integrity. The 39 now split 32/7 by capability, and a test walks the
real transitive graph rather than trusting the file layout.

That test caught itself. Its specifier regex used a newline-excluding character
class, which matches no multi-line import in this codebase, so the walk stopped
at the entry file and the suite went green while payload-only transitively
imported node:fs. Found by injecting node:fs and watching nothing fail. It now
asserts the exact module set it visited, so "the walk found nothing" is a
failure rather than a pass.

P2 threads home and projectDir through the request. The daemon evaluates on
behalf of another UID, so its own homedir belongs to the service account — and
isAgentInternalPath and block-read-outside-cwd both WIDEN the allow set, which
is the direction that does not announce itself. P3 splits evaluatePolicies into
evaluateVerdicts + encodeResponse, moving the twelve-vendor response matrix
character-identically rather than rewriting it. P4 adds the canonical request
envelope and bounds resolveCodexMode's unbounded transcript scan to a 256 KiB
head window: measured, first turn_context at line 8 in every real transcript
sampled, 3.07ms to 0.54ms on a 2.7 MB file.

The sealed runtime bundles the 32 payload-only builtins, the registry, and the
full response encoder into one file evaluated inside QuickJS-ng with no
bindings registered. node:path is replaced by a port proven equivalent to
node:path.posix differentially over 8,000+ generated cases; the host modules by
stubs that throw a named capability; hook-logger, hook-telemetry and
telemetry-id by inert no-ops, which removed a fetch to PostHog from a tier the
design requires to perform no unbounded I/O.

Three Rust crates. fpai-ipc carries framing (1 MiB cap validated before
allocation, asserted with a counting global allocator), the envelope, peer
credentials, and the combination lattice — 70,656 generated property cases
proving that adding any number of user_context results never lowers a sealed
deny, which is the formal statement of the entire two-tier argument. fpai-canon
embeds the generated canonicalization tables. failproofaid is the daemon: one
warm sealed worker, one enforcement lane, an interrupt handler that makes a
runaway policy a deadline miss rather than a hung daemon.

home is derived at the socket from getpwuid_r(peer_uid) and a client-supplied
one is REJECTED, not overwritten. Overwriting makes the attack a no-op but
leaves the protocol looking like it accepts the field, so the next client
implementation and the next reviewer both reasonably conclude it is meaningful.
cwd, project_dir and env_facts genuinely cannot be derived, so they ride as
client-asserted and any decision reading one is reported sealed_unattested.
That is the honest version of unforgeable, and it beats a claim that quietly
is not true.

Verification, in increasing strength: the sealed bundle runs in a node:vm
context with no host globals at all; a 5,220-row corpus goes twice through one
warm worker, then shuffled, then against a fresh context per row — the last
being the assertion that pins the resident worker to per-event-process
semantics, since self-consistency alone is satisfied by a worker that is
consistently wrong; all 5,220 rows are byte-identical to the legacy evaluator;
and the real TypeScript client drives the real Rust daemon, which is the only
test that catches two independent implementations of one prose spec
disagreeing.

Four live CI and packaging bugs fixed, all found by survey rather than
invented. Removing the prepare script alone would have published an empty
package. prune-standalone pruned "design-docs" while the directory is
"desgin-docs", so 17 design-doc files were shipping to npm on every publish —
and unpacking the tarball showed Next's tracer was about to ship the new
Cargo.toml and rust-toolchain.toml too. Every CI cache key hashed a bun.lockb
that does not exist, so the bun cache has never invalidated since the initial
import. And the version-consistency check looped over a packages directory that
does not exist, passing vacuously the whole time.

Plus one real bug the bench harness surfaced: handler.ts armed a 10-second
timeout per custom hook and never cleared it. Invisible today only because the
bin entry point calls process.exit on return — and about to stop being
invisible in the resident worker and the per-user agent, exactly the processes
this change introduces. Measured at a 10,088ms p95 in a harness without the
hard exit.

Tests: 2,920 unit (+450), 330 e2e, 117 Rust. The 15 remaining unit failures are
pre-existing jsdom localStorage failures in project-list.test.tsx, unchanged
from before this branch. tsc clean, eslint 0 errors, cargo fmt and clippy with
-D warnings clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@socket-security

socket-security Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​tokio@​1.53.15810093100100
Addedcargo/​nix@​0.31.3801009310070
Addedcargo/​thiserror@​2.0.198010093100100
Addedcargo/​serde@​1.0.2298110093100100
Addedcargo/​serde_json@​1.0.1518210093100100
Addedcargo/​proptest@​1.11.09910093100100
Addedcargo/​rquickjs@​0.12.210010093100100

View full report

@socket-security

socket-security Bot commented Jul 30, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: cargo tokio is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: Cargo.lockcargo/tokio@1.53.1

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore cargo/tokio@1.53.1. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: cargo zerocopy is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?cargo/proptest@1.11.0cargo/zerocopy@0.8.55

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore cargo/zerocopy@0.8.55. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@NiveditJain NiveditJain changed the title [desgin-docs] Phase 1 implementation plan [desgin-docs] Phase 1: design set, implementation plan, and Stage 0 + Stage 1 of failproofaid Jul 30, 2026
NiveditJain and others added 9 commits July 30, 2026 22:27
…t set

Two problems with the previous commit.

The bench baseline it committed is not trustworthy. Another agent rebuilt
dist/ while the matrix run was in flight, and dist/ is a shared gitignored
build output the harness was reading live — 27,345 of 36,888 iterations failed
and the custom variant produced zero usable samples. The script wrote the
artifact anyway, which is the worse half of the bug: a biased survivor set
masquerading as a measurement. The artifacts are withdrawn rather than left in
place, because a baseline nobody can trust is worse than no baseline — the
whole point of the file is to be the number Stage 4 is judged against.

scripts/bench-hook.ts is hardened three ways: it snapshots dist/ into
.bench-hook-tmp/ at startup and points FAILPROOFAI_DIST_PATH at the snapshot,
so nothing a concurrent build does can reach a run; it aborts non-zero and
writes nothing above a 1% failure rate; and it records totalIterations,
failedIterations and failureRate in the artifact, so a degraded run is visible
in the committed file rather than only in a terminal nobody kept.

Second: the daemon hardcodes DEFAULT_SEALED_POLICIES, and nothing checked it
against the TypeScript default-enabled set. Hardcoding is deliberate — reading
the user's policies-config.json would make the sealed tier's enabled set come
from a user-writable file, so an agent could delete block-sudo from a JSON
array and the unforgeable verdict would never run. But a second copy drifts,
and this one would drift invisibly: the cross-implementation test feeds the
same list to both sides, so it compares encoders, not enabled sets. Add a
default-enabled builtin in TypeScript without touching server.rs and every
existing test still passes while the daemon quietly enforces one policy fewer
than the legacy path — a silent allow, on the exact upgrade path this project
exists to protect.

__tests__/parity/daemon-default-policies.test.ts parses the list out of
server.rs and asserts it equals BUILTIN_POLICIES.filter(defaultEnabled), in
order, that every entry is sealed-eligible, and that every entry names a policy
that exists. Mutation-checked: adding block-rm-rf to the Rust list fails it.
The two sets agree today, at 11 policies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two were severe and reproduced end-to-end against the running daemon.

## The enforcement deadline could not fire

The interrupt flag that turns a runaway policy into a deadline miss was only
ever set from inside the microtask pump loop — and a policy body is synchronous
JavaScript, so the pump does not get another turn until the call returns. There
was no watchdog. Any single policy that outran its deadline blocked the one
enforcement lane forever.

Not hypothetical, and not exotic. CURL_PIPE_SH_RE in block-curl-pipe-sh is
/(?:curl|wget)\s.*\|\s*(?:sh|bash|…)\b/ — the .* backtracks quadratically, and
"curl ".repeat(n) drives it. Measured against a 200 ms deadline with no
watchdog: 40 KB took 7.1 s and 80 KB took 30 s, both returning Ok, never
DeadlineExceeded. The frame cap is 1 MiB, so a *legal* request extrapolates
past an hour. block-curl-pipe-sh is default-enabled and sealed;
protect-env-vars and block-failproofai-commands have the same regex shape and
are also default-enabled. Against the live daemon, one 200 KB request left
every subsequent client falling back for the rest of the process's life.

Now a long-lived watchdog thread arms before entering QuickJS and disarms
after. An interrupt unwinds as an ordinary exception, so the two catch sites
check the flag and report DeadlineExceeded rather than an evaluation failure —
otherwise a merely slow policy would trip its circuit breaker.

My own test is why this survived: it armed the interrupt *before* calling eval,
which proves the handler works and says nothing about whether anything arms it.
The replacement does not pre-arm, and asserts the lane still answers afterward.

## Connection threads parked forever, then the listener died

Every connection thread waited on a bare reply_rx.recv(). The client gives up
at its own 800 ms budget and falls back, but the thread stayed blocked for an
answer nobody would read — one leaked thread per hook event, unbounded. serve()
propagates a failed accept or spawn, so the first EMFILE/EAGAIN returned Err
out of the listener and main exited: "no daemon answers" escalating into "the
daemon is gone". Now recv_timeout, bounded by the request's deadline plus a
lane-queue grace.

## The daemon enforced a policy set the user never configured

handle_evaluate hardcoded the 11 defaultEnabled builtins and ignored what the
client had resolved. Reproduced against this repo's own policies-config.json,
which enables 30:

  rm -rf /          daemon: allow   legacy: deny (block-rm-rf)
  cat /etc/passwd   daemon: allow   legacy: deny (block-read-outside-cwd)

19 of 30 enabled builtins plus 100% of custom and convention policies stopped
enforcing the moment the daemon answered. The documented safety net could not
fire: the sealed worker computes needsUserContext by partitioning the list it
was handed, and a daemon-supplied list is all-sealed by construction, so it was
always empty. And since FAILPROOFAI_DAEMON_MODE is read from the hook process's
environment — which the governed agent inherits — an agent could have set one
variable to downgrade itself to eleven policies.

EvaluateHook now carries enabled_policies and the daemon evaluates that. An
empty list is refused rather than treated as "enforce nothing" or backfilled
with defaults; the first turns a client bug into a silent allow and the second
reinstates the defect. handler.ts reads config *before* the daemon call, and
skips the daemon outright when custom policies are configured, since those can
never be sealed-eligible — enforcing a subset would be worse than not using the
daemon at all. The expensive half of the legacy work, loadAllCustomHooks and
its per-tool-call temp files, still only runs on the fallback path.

DEFAULT_SEALED_POLICIES is deleted rather than kept in sync. Removing the
duplication beats testing it.

## The protocol documented a check that did not exist

PROTOCOL.md claimed fpai-canon re-derived canonicalization and rejected a
mismatch. It never did, and it is not implementable from this envelope —
re-deriving needs the raw vendor payload and only the canonicalized one is
sent. Believing the claim was worse than not making it: a reviewer would
reasonably assume something was catching a hostile client. The doc now says
what is true, explains that the exposure is bounded (the client runs as the
user whose events these are, and the one field that would be dangerous to
accept is the one the daemon derives), and defers the check to Stage 2. The
unused fpai-canon dependency is dropped so the crate graph stops implying
otherwise.

Tests: 2,934 unit, 116 Rust, 16 cross-implementation. The 15 remaining unit
failures are the pre-existing jsdom localStorage failures. clippy -D warnings,
cargo fmt, tsc and eslint all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hardened failure-rate guard added after the first corrupt capture was
itself unsound, and the regenerated artifact proved it: `failedIterations: 636`
sitting beside `failureRate: 0`, written by a run that exited 0. 636 of 37,524
is 1.69%, well past the 1% abort threshold.

The rate was computed once, before the calibration and repeatability phases —
which run below that point and share the same counters. So the guard saw a
clean matrix, passed, and then the later phases failed 636 times and inflated
the count while the rate stayed frozen at zero. A stale rate next to a live
count is worse than either alone: it reads as reassurance while contradicting
itself on the same screen.

Now the rate is a function of the current counters rather than a captured
value, the artifact recomputes it at write time, and the threshold is checked
again after every phase has run. The early check stays, so a doomed run still
fails fast instead of spending minutes on calibration first.

Second gate: refuse to write when the repeatability probe produced no rows.
That is what happened here — `rows: []` with `maxAbsDeltaMs: 0` and
`maxRelDelta: 0`, which renders as "no run-to-run drift observed" when the
truth is that nothing was observed at all. Zeros from an empty sample are not a
measurement.

This is the same failure shape as the import-graph tripwire earlier in this
branch and the deadline test after it: a guard that exists, looks like
coverage, and asserts nothing. Worth noting the pattern rather than just the
instance — all three needed a deliberate attempt to break them before they
became real.

No artifact is committed. The harness is the deliverable; the number needs one
clean capture on a quiet machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three of the four failed captures on this branch had the same cause, and I
misdiagnosed it twice before the real one turned up: two instances of
scripts/bench-hook.ts running at once. They share one working directory, so the
second one's buildWorkerBundle rewrites the first one's worker bundle and dist
snapshot mid-flight, and the first dies on a harness that was replaced under
it. The earlier explanations — a concurrent `bun run build:cli`, then an
external `dist/` rebuild — were both wrong; the rebuild was the *other
instance's* own ensureDistFresh.

The gates added after the first two failures do their job: a corrupt run now
writes nothing. But refusing to publish garbage is not the same as being able
to take a measurement, and nothing stopped the collision itself.

So: an exclusive lockfile at .bench-hook.lock, taken with O_EXCL before
anything else happens. A second capture is refused with the holder's pid and
start time rather than proceeding. A lock whose holder is no longer alive
(tested with signal 0) is taken over automatically, so a killed run leaves
nothing to clean up by hand.

The ordering matters more than the lock. It is acquired *before*
`process.on("exit", cleanup)` is registered, because cleanup removes the
working directory — so a refused run that had registered cleanup first would
delete the harness out from under the run it just deferred to. That is exactly
how one of the captures died.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stacked PR — one opened against another feature branch — ran no CI at all.
Not a reduced set: none. quality, test, build, docs, test-e2e and rust-quality
were all silently skipped, while the third-party checks that do run on any PR
still painted a green checkmark. That is the worst possible presentation of
'nothing was verified'.

Found the hard way. Phase 1 Stage 0 and Stage 1 — several thousand tests, three
Rust crates, a daemon — were pushed to a PR stacked on the design-doc branch,
and CI had not executed once. Retargeting that PR at main would have dragged 22
unrelated commits into its diff, so the trigger filter is what needed fixing.

The push trigger keeps its main-only filter; this only widens pull_request,
which by definition already has a review gate attached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NiveditJain
NiveditJain merged commit 443c681 into luv-629 Jul 30, 2026
@NiveditJain

Copy link
Copy Markdown
Member Author

Consolidated into #625 — that branch is now fast-forwarded to 443c681, the same commit this PR pointed at, so every commit here is carried there with no rebase and no diff change.

Closing this rather than retargeting it at main: #630 was stacked on luv-629, so retargeting would have pulled 22 unrelated design-doc commits into its diff. One PR against main is the cleaner shape.

The CI-trigger change pushed here earlier (widening pull_request beyond branches: [main]) has been reverted — ci.yml is back to its original state. Now that the work sits on a branch whose PR targets main, CI runs on it normally and no workflow change is needed.

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