Skip to content

The harness doctrine

This document is the canonical reference for every // SOURCE: docs/harness/README.md citation in the codebase. It explains why each enforcement mechanism exists, which script implements it, and where the honest limits are. The per-gate reference lives in gates-catalog.md.

The one-sentence thesis: quality is a deterministic gate, not a request. Memory files (AGENTS.md, .claude/rules/*.md) are advisory context; hooks, gate scripts, and CI are the enforcement. The harness is built so the model produces green-on-first-try code and the gate rarely has to fire — but when it does fire, it cannot be talked out of it.

Every mechanism belongs to one of six layers:

# Layer Concrete mechanisms
1 Grounding / context AGENTS.md + .claude/rules/*.md, the pinned corpus (tools/mcp/corpus/index.json + the project's tools/mcp/corpus/project.json), specs/_template.md
2 Generation plan-mode design first; data structures before code (the quality bar in AGENTS.md)
3 In-loop verification mid-turn MCP tools (corpus_search, rls_verify), posttool-fast-check.mjs per-edit feedback
4 Provenance capture // SOURCE: + [corpus: <id>] comments, posttool-source-check.mjs, tools/check-sources.mjs, one ADR per slice (/adr)
5 Adversarial review read-only reviewer subagents (security-reviewer, web-security-reviewer, mobile-security-reviewer, torvalds-reviewer, accessibility-reviewer, design-reviewer, citation-verifier via /verify-citations)
6 Gated completion the Stop hook (stop-validate-gate.mjs) running the full validate chain with exit-2 semantics; CI as the floor

Layers 1–2 raise the probability of correct output; layers 3–6 make incorrect output unable to ship. Doctrine: never rely on a layer-1 instruction for anything a layer-3/6 gate could enforce deterministically.

tools/harness.config.mjs is the single source of truth for what "done" means: VALIDATE_STEPS (the 37-step chain pnpm validate runs) and STOP_HOOK_STEPS (what the Stop hook runs — validate plus the runtime suites). Three enforcement layers consume it and can therefore never disagree:

  1. pnpm validate → node tools/validate.mjs — the developer/agent fast path.
  2. The Stop hook → runs STOP_HOOK_STEPS directly (node tools/validate.mjs --report-all — every red at once — then the RLS, vitest, and jest-expo suites and the diff-coverage/duplication/i18n/test-quality/mobile-perf steps) — never through a package.json script name, because script indirection would let an agent redefine validate to true in package.json (an auto-accepted, unguarded edit) and pass a hollow gate. The Stop gate defines done locally.
  3. CI → re-runs node tools/validate.mjs --min-floor, which reads the FROZEN snapshot tools/validate.floor.json — a verbatim copy of all 37 canonical steps that the runner trusts OVER the local config, and FAILS CLOSED (missing/corrupt snapshot → exit 1) rather than degrade to a possibly-weakened config. The CI floor means editing the config can ADD steps but can never weaken the non-negotiable ones on a PR.

The gate config is harness-protected and mirrored in CI: harness.config.mjs, validate.mjs, validate.floor.json, every gate script, and the runners the Stop hook invokes are all write-guard-protected (see tamper evidence).

Exit-code semantics (the crux of the design):

  • exit 0 — proceed. Stdout may carry a structured JSON decision (hookSpecificOutput.permissionDecision: "deny" on PreToolUse blocks the call with a machine-readable reason).
  • exit 2 — block, and stderr is fed back to the model as the correction signal. On PreToolUse this blocks the tool call; on Stop it forces the turn to continue.
  • any other non-zero — non-blocking error; the action proceeds. Security hooks must therefore always use exit 2 (or the structured deny), never exit 1.
  • PostToolUse cannot un-run a tool; its exit 2 surfaces stderr so the model fixes what just landed. At exit 0, a hookSpecificOutput.additionalContext string in a JSON object on stdout is added to the model's context beside the tool result without blocking it: the source check's answer to an advisory-class site (1.1.0).
Event Matcher Script Enforces
PreToolUse Bash|Monitor|PowerShell .claude/hooks/pretool-bash-guard.mjs denies destructive shell, secret access, migration bypasses — on all three command-executing tools (0.6.0)
PreToolUse Edit|Write|MultiEdit|NotebookEdit .claude/hooks/pretool-write-guard.mjs blocks invariant-violating file content before it lands; denies edits to harness-owned paths
PreToolUse mcp__.* .claude/hooks/pretool-mcp-guard.mjs default-deny over tools/approved-tools.json: unregistered servers, tools outside a server's list, and write-shaped tool names on a readOnly server
PostToolUse Edit|Write|MultiEdit .claude/hooks/posttool-fast-check.mjs fast per-file feedback (Biome), non-blocking
PostToolUse Edit|Write|MultiEdit .claude/hooks/posttool-source-check.mjs flags decision sites lacking // SOURCE:: exits 2 when a site in a mandatory class is uncited (its stderr also lists the file's advisory-class sites, marked advisory); for advisory-class sites alone (vector-index, llm-sampling, tuning-constants, 1.1.0) exits 0 with a hookSpecificOutput.additionalContext note on stdout
Stop — .claude/hooks/stop-validate-gate.mjs runs the UNION of STOP_HOOK_STEPS and the frozen tools/stop.floor.json; exits 2 with failures on stderr until green; on a green turn shows the user any FALLBACK MODEL lines (1.1.0), the first ten gate NOTE lines with every one in .harness/stop-output/_notes.log when the message cannot hold them all, and the hook's own notes (2.0.3) as one JSON systemMessage, because stderr from a hook that exits 0 reaches only the debug log; a red turn's block lists a green step's NOTE lines too
SubagentStart * .claude/hooks/subagent-verdict.mjs (1.1.0) records the tree each reviewer is dispatched on in .harness/reviewer-dispatch.jsonl, never in the ledger; exits 0, because SubagentStart cannot block, and a missing record surfaces at Stop
SubagentStop * .claude/hooks/subagent-verdict.mjs reads each reviewer's terminal VERDICT: line from the payload's last_assistant_message, blocks a reviewer that gave none, and records the rest for Stop step reviewer-verdicts, with the tree digests at dispatch and at the verdict, and the model it ran on, read from the subagent's own transcript, beside pinned (1.1.0); it also blocks a PASS that lists a finding at a severity the body's Blocking: line names, and records each verdict's blocking findings and its round in the reviewer's review loop, which the Stop step holds to a budget (1.1.0)
SessionStart "" (all five sources) .claude/hooks/session-brief.mjs (1.1.0) prints the harness brief into context: version, base, tier and mode; parked upgrades; how the last turn in this directory ended; the reviewers the current diff owes. Enumerated fields, closed validators, capped at 1,200 characters; exits 0 on every path and writes nothing

Seven guard hooks, each invoked through the fail-closed launcher (1.0.0: node "$CLAUDE_PROJECT_DIR/.claude/hooks/launch.mjs" <hook>.mjs — a hook that cannot LOAD exits 2 and blocks, instead of the exit-1 fail-open a torn lib/hookio.mjs produced, which disarmed every guard at once; a torn launch.mjs itself is the stated residual, one tiny import-free file wide). The session-start brief is the one hook invoked directly (node "$CLAUDE_PROJECT_DIR/.claude/hooks/session-brief.mjs"): SessionStart cannot block, so the launcher's "failing closed, action blocked" would be false there, and the brief exits 0 on every path instead. Every command is node "$CLAUDE_PROJECT_DIR/…" (0.3.0). Before that the commands were bare paths relying on the executable bit, and check-gate-integrity hashes CONTENT and never MODE — so chmod -x on the Stop hook silently disarmed the turn gate while every sha256 still matched. The fix deletes the vulnerability rather than detecting it: the bit is no longer in the trust path at all. (A mode check would also have had to skip on win32, where there is no exec bit — and a skip that is never a pass cannot be written for a property half the platforms do not have.) gate-integrity now asserts the command SHAPE instead: every hook command names node and an existing file, so one rewritten to true reds.

Hooks fail closed (.claude/hooks/lib/hookio.mjs): uncaughtException/unhandledRejection handlers exit 2, because a crashed guard that exits 1 would be treated as a non-blocking hook error and wave the action through. Malformed, non-empty stdin throws → blocked. A broken harness blocks; it never silently passes.

While the gate is red, the hook sends the agent back to work. Details that matter: it imports STOP_HOOK_STEPS from the config UNIONED with the frozen tools/stop.floor.json, so projects may APPEND a step and may never subtract one (the config is manifest mode config, which gate-integrity's owned-file loop skips — so until 0.3.0 nothing hashed the list of checks that decide whether a turn may end). A floored step missing from the config still runs, and gate-integrity reds naming it; if the config cannot load it falls back to pnpm validate and warns — never skips. stop_hook_active escalates the message on repeat blocks — a payload field the docs do not list for Stop and every test supplies synthetically, so 0.6.0 observed it against a real invocation rather than trusting either (design/CONTROL-PLANE-FACTS.md). Failure output is bounded HEAD+TAIL, with the whole thing spilled to a file (0.10.0). Through 0.9.9 each failure was reported tail-first — out.slice(-4000) — on the reasoning that a gate's summary comes last. It does for most; for the gates that ENUMERATE (a type error list, a lint run) the head is the finding and the tail is only the count, so the model read "42 problems" and never saw the first one, and nothing could recover it because the child's output existed only inside the catch. Now the full output goes to .harness/stop-output/<gate>.log — one file per gate, so several simultaneous failures are several readable logs rather than one interleaved one — and the block message carries the first 1000 characters, the last 2000, the count of what was elided, and the path. Short output is passed through untouched: the common case gains neither a file nor a paragraph. If the spill cannot be written the excerpt is still returned and the turn still blocks — bookkeeping never decides a turn, and a hook that threw while REPORTING a failure would turn a red gate into a crashed one.

The block cap leaves a mark (0.6.0). CLAUDE_CODE_STOP_HOOK_BLOCK_CAP in .claude/settings.json is the safety valve so a genuinely stuck session terminates: after 8 CONSECUTIVE blocks Claude Code ends the turn anyway. The valve is right and it stays — a hook that can block forever is a bricked machine. It is not the only documented way a turn ends without a green gate: Claude Code also ends it when the hook reaches its timeout in .claude/settings.json, and it treats an exit 2 with nothing on stdout and a stderr that says a file is missing, such as No such file or directory, as a non-blocking error. This hook's red path prints nothing on stdout, so a failing step whose output says a file is missing may let the turn end at once. Through 0.5.0 a turn that ran out of blocks left exactly the trace a green turn leaves: none. So every outcome now appends to .harness/turn-outcomes.jsonl; the last block a turn is allowed says LAST CHANCE while the transcript can still act on it; and the next turn reports a predecessor that ended at the cap even when the tree is green again by then — which is precisely when the fact would otherwise be lost. subagent-verdict.mjs writes to the same ledger, because the cap is documented over both events in one sentence and a count that saw half of them would go quiet on the turns that needed the warning. The file is a diagnostic, not a control: it authorizes nothing, so a corrupt line is tolerated rather than fatal — the deliberate opposite of the reviewer ledger, which fails closed because it does authorize.

The telemetry log (1.0.4). The ledger above records THAT a turn blocked and keeps only its most recent rows; it says nothing about what each step cost or which rule fired, so a red fixed inside the same turn left no trace. Every Stop run now also appends to .harness/telemetry.jsonl: a stop-step record per step (ok, fail, or stamped when the step's own line was a stamp hit; its duration in whole milliseconds; and how many SKIPPED, STAMPED and, since 2.0.3, NOTE lines its output carried, a red step's included), and a validate-gate record per gate named in the LAST VALIDATE_TIMINGS line a step printed, green or red. The guards, the PostToolUse hooks and subagent-verdict.mjs append a hook-event record for each deny, provenance block, Biome warning and reviewer bounce, naming the rule id (or, at an inline deny site, a telemetry label that is not a rule id); an allowed call writes nothing. Since 1.1.0 the source check also appends one advisory record per class of each uncited advisory-class site, with the rule provenance/<class>. A record holds enumerated values, ids, timestamps and counts, never file content, command text, paths or messages. The file is written only inside an install, when .harness/manifest.json exists in the working directory, and it is never trimmed. To reset it a human deletes it: the write guard and the bash guard deny an agent's edits and deletions under .harness/. No gate reads it, and a test holds that. It is bookkeeping, so if it cannot be written, every exit code and every byte on stdout stay what they were.

Deterministic regex denial of the commands permission pattern-matching handles unreliably. A high-value tripwire, NOT a sandbox — obfuscated commands can evade substring checks; the settings.json deny list and CI are the primary controls. Denies: rm -rf, force-push, hard reset, --no-verify commits, fork bombs, reading .env* / .dev-auth/, knip --fix, bulk pnpm update (Renovate-owned), destructive raw SQL via psql, and any shell contact with store/signing credentials (EXPO_TOKEN, keystore/keychain material, store API keys — those live in CI secrets only). Since 1.1.0 it also denies the installer's apply-proposal verb, which only a human runs (see "Proposing a register edit" under Tamper evidence).

It matches three tools, not one (0.6.0). Through 0.5.0 the matcher was the single word Bash, and both omissions were live bypasses rather than theoretical ones. Monitor runs a command in the background; permission rules spelled Bash(...) do cover it, which is exactly what made the gap invisible — but a hook matcher is an exact tool name, not a permission namespace, so the identical command asked for under Monitor met no content check at all. PowerShell is the sharper of the two: on Windows without Git Bash, Claude Code does not register the Bash tool at all, so a Bash-only matcher never fired for those sessions — the guard was not weaker there, it was absent. PowerShell also carries its own PowerShell(...) permission namespace, so the settings deny list does not reach it either; this hook is the only layer that does. The rule table gained the canonical cmdlet spellings to match (Remove-Item, Get-Content, Set-Content, Copy-Item…) — PowerShell's bash-compatible aliases were already covered, and rm -Recurse -Force turned out to have been covered from the day the flag-class regex was written. check-wiring holds the matcher to naming all three; a project may add a tool, never drop one.

The only reliable place to stop forbidden code being written. Three duties: (1) tamper protection — denies edits to the PROTECTED list without HARNESS_ALLOW_SELF_EDIT=1; (2) append-only migrations — an Edit or Write to a supabase/migrations/*.sql that already exists is denied unless it is proven to be an untracked draft (1.0.4). Three proofs must hold, for the name the tool used and for where the bytes land: git reports exactly one entry for it, ?? <path> (git status --porcelain, run with no shell from CLAUDE_PROJECT_DIR, with a timeout), and the file has one hard link, since git judges a name and a second name for a committed migration's bytes reads ?? too; .harness/manifest.json parses and records no file at that path, because a file init planted is the harness's history; and nothing is unusual, meaning CLAUDE_PROJECT_DIR is set and none of GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE or GIT_COMMON_DIR is. A tracked, staged, ignored, hard-linked or git rm --cached migration fails the first proof, any git error or timeout denies, the deny names the proof that failed, and HARNESS_ALLOW_SELF_EDIT=1 does not open this rule. So the draft supabase migration new or supabase db diff -f leaves can be written, and the content checks in (3) still judge what it receives. The residual: untracked means absent from the index and HEAD, not from all history, so a migration someone applied to a shared database by hand without committing it still reads as a draft. Committed history stays guarded by the migrations gate and the CI append-only job, which judge it exactly as before, and the bash guard's rules on supabase/migrations/ do not change; (3) content checks on the written text: security-surface weakenings in app.config.ts/eas.json (cleartext/ATS exceptions, identity or runtimeVersion drift, secret-shaped extra keys), EXPO_PUBLIC_-prefixed secret-shaped names, session-scoped GUCs, WITH RECURSIVE without CYCLE/visited, mobile imports of server/db modules, keychain access outside the host/auth seam, and the two web-process credential mistakes (the service-role key, and getSession() in server-side web code). It deliberately does NOT blanket-protect the app config — adding a plugin or permission is routine vertical-slice work; only the specific weakenings are content-checked, and the reviewed data files (tools/expo-permissions.json, tools/expo-plugins.json) are where grants get their human eyes.

The harness protects its own machinery, but be precise about the claim: it is tamper-EVIDENT, not tamper-proof. An agent (or developer) with shell access can ultimately modify anything in the working tree. The design goal is that tampering (a) requires a deliberate, visible override, (b) leaves a diff a human reviews, and (c) is caught by CI even if the local session was fully compromised.

The layers, in order of engagement:

  1. Permission denies in .claude/settings.json — network exfiltration, .env and .dev-auth/ reads, ssh keys, force-push, hard reset, and direct writes to .claude/hooks/**, settings, .mcp.json, .harness/**.
  2. Write-guard PROTECTED paths — the gate config, validate.mjs + the floor snapshot, every tools/check-*.mjs, tools/lib/, the lockfiles gates verify against (identity.lock.json, prompts.lock.json, rls-exempt.json, the budget/manifest data files), the Stop-hook runners, lefthook.yml, .github/workflows/, and the lint/architecture config surface. A human who genuinely needs to change the harness sets HARNESS_ALLOW_SELF_EDIT=1 in the environment the session starts from. Nothing records the variable; the committed diff is the record (see below).
  3. The .harness manifest — the installer records a SHA-256 for every harness-owned file; npx next-expo-supabase-agent-harness@latest doctor re-hashes the tree so silent in-place edits are evident as drift, and the gate-integrity gate re-checks the enforcement files on every validate.
  4. The CI floor — CI does not trust the config: --min-floor enforces the frozen snapshot, fail-closed. Local bypasses change nothing about what merges.
  5. CODEOWNERS — harness-owned paths and auth/data surfaces require sign-off from {{SECURITY_OWNERS}}, so even an evident tamper needs a human accomplice to land.

Proposing a register edit (1.1.0). Layer 2 denies an agent every reviewed register under tools/: the allowlists, the budgets and the registers such as tools/i18n-allow.json, tools/approved-tools.json or tools/mcp/corpus/project.json. When an agent has a reason to change one, it does not describe the edit in prose and it does not ask for HARNESS_ALLOW_SELF_EDIT=1, which would lift the guard for every protected path at once. It writes the whole proposed file as one JSON document, harness-proposals/<id>.json, with five fields: version (1), target (the register's path), reason, base (the output of git rev-parse HEAD:<target>, or null when the file is not in HEAD) and content (the whole proposed file, as a string). It writes it the way JSON.stringify(proposal, null, 2) prints it, with one trailing newline, because format checks it. harness-proposals/ is committed and outside every path the deny list, the write guard and the bash guard name, so staging narrows no layer, and a proposal is inert: no gate reads it. A human then runs npx next-expo-supabase-agent-harness@latest apply-proposal <id> in a terminal. It shows the reason and a diff of the current file against the proposed one, and writes the file only after the human types the target path. --dry-run shows the same and writes nothing; with no id it lists the pending proposals, which doctor also lists as info. It refuses a target a proposal may not change (a proposal may target the escape lists in tools/lib/enforcement-surface.mjs and tools/field-notes.json, but not tools/perf-baseline.json or tools/mutation-baseline.json, which only their generators write), a base that is not the committed file's blob, a target with uncommitted changes, and a stdin or stdout that is not a terminal. The bash guard's apply-proposal-invocation rule denies an agent the command itself. The written register is left uncommitted, so gate-integrity fails until a human commits it, and the commit carries it into the pull request under CODEOWNERS.

An owned file has no proposal, so its deny names the human path (2.0.3). A WRITE_PROTECTED row in .claude/hooks/lib/guard-rules.mjs may carry its own message, which the write guard prints in place of the shared text. The shared text points at harness-proposals/, and no harness-owned file is proposable, so a row over an owned file that ordinary work leads an agent to names its own path. The census row, exports-walls-census over tools/exports-walls.json, is one: every new vertical needs a census entry, and the census is owned and hash-pinned. Its deny tells the agent to hand the {package, reason} entry to a human, who adds it under CODEOWNERS review and re-records the file's sha256 in .harness/manifest.json in the same reviewed commit (the upgrade runbook's "Forking an owned file"). HARNESS_ALLOW_SELF_EDIT=1 lifts the row like every other protected path, and a row without a message prints the shared text byte for byte.

Each check below looks for the exact string 1. The guards read the environment the session was started from, so putting the variable in front of one shell command does not lift them. The scripts read the environment of the command that runs them. The flag relaxes these checks and no others:

  • .claude/hooks/pretool-write-guard.mjs: the protected-path deny (layer 2) and the symlink-escape deny. The append-only migrations deny and the content checks still apply.
  • .claude/hooks/pretool-bash-guard.mjs: the rules shell-write-protected, interpreter-write-protected, gen-lock-writer, chmod-protected, rm-protected, truncate-protected, move-protected-away, git-restore-old-revision, self-rebaseline-writer and apply-proposal-invocation. Every other rule still denies.
  • tools/gen-agents-lock.mjs: --write refuses to run without it.
  • tools/check-gate-integrity.mjs: the commit rule, which reds an escape list or a threshold-bearing config that is modified but not committed. Its hash, retrofit-conflict, hook-command, Stop-floor and baseVersion checks still run. When the flag skips the commit rule, the gate's OK line names the rule as not run instead of reporting it clean.

It does not lift the permission denies in .claude/settings.json (layer 1). The Edit and Write tools stay denied on .claude/hooks/**, the settings files, .mcp.json and .harness/**, so edit those files outside the session. No other hook or gate reads the flag, and nothing records that it was set. The shipped workflows never set it, so a change made under it reaches CI only as a committed diff, which CODEOWNERS reviews (layer 5).

Doctrine (implemented in tools/lib/gate.mjs#skipOrFail): a gate that cannot run its real check — no install, no reachable database, the Expo toolchain unresolvable, the surface not yet created — SKIPS LOUDLY locally (SKIPPED — <reason> (this gate FAILS CLOSED in CI)) and FAILS CLOSED in CI (CI=true or HARNESS_REQUIRE_TOOLCHAINS=1). A skip must never be mistakable for a pass, and CI must never be green because a prerequisite was absent. Shape-awareness lives INSIDE each gate script, never in which steps run.

The same predicate turns off every stamp: a gate whose inputs are unchanged since its last green run reports that locally and never in CI. To see CI's verdict before you push, run node tools/validate.mjs --ci-parity (1.0.4). It sets HARNESS_REQUIRE_TOOLCHAINS=1 for the run, so a missing prerequisite fails and no stamp is honoured, prints that posture as its first line, and closes with one line per missing prerequisite a gate recorded, naming the step, the gate and the reason. node tools/validate.mjs --min-floor --ci-parity is the local counterpart of CI's static job. With --list the flag changes nothing, and it refuses --stop-chain: the Stop chain has no single CI equivalent, and its reviewer-verdicts step needs a live turn's identity, which only the Stop hook sets.

What the flag does not cover:

  • CI itself is not set, so a tool that reads CI directly keeps its local behaviour.
  • Tool caches stay: ESLint's --cache and TypeScript's *.tsbuildinfo.
  • The append-only migrations check diffs against origin/$GITHUB_BASE_REF when that variable is set, as on a pull request in CI, and against HEAD otherwise. The flag does not set it, so an edit to a migration already committed on your branch is caught only in CI, or locally with GITHUB_BASE_REF=<base branch> exported and that branch fetched.
  • types-drift skips on its own when no local stack is running, without consulting the predicate. It skips the same way in CI's static job, and it records nothing.
  • tests/rls/run-rls.mjs takes its fail-closed posture from CI alone (its stamp is off under either variable). It is a Stop step, and the flag refuses the Stop chain, so the flag never runs it.

A stamp is a local shortcut, never proof. A stamped gate hashes its declared inputs; when every one is byte-identical to its last GREEN run (the digest in .harness/<gate>.ok) and this is not CI, it prints <gate>: STAMPED — inputs unchanged since last green run (.harness/<gate>.ok; CI always re-runs) and exits 0 without running its check. CI=true or HARNESS_REQUIRE_TOOLCHAINS=1 (which validate --ci-parity sets) always runs the real check, and update and graduate delete every stamp, so the first run after either re-proves everything.

  • What is stamped. The gates that call stampGate in tools/lib/gate.mjs: build, contracts, db-limits, e2e, expo-policy, licenses, native-deps, query-shapes, rate-limits, security-headers, tenancy, version-sync and (1.1.0) web-compile in the validate chain, (1.1.0) essential-eight and conformance-map, the second and third scripts of the docs-sync step, the eas-update module gate, and (1.0.4) the rls-isolation Stop step. Nothing else is: steps are never chosen by classifying the diff. essential-eight runs its negative proof before it consults its stamp, and stamps only when the proof finds nothing (docs/harness/gates-catalog.md, the docs-sync section).
  • Inputs are reviewed data. Each list lives in tools/lib/stamp-inputs.mjs (a module gate declares its own through withMachinery), and a missing input class is a stale-pass bug. Every list carries .harness/manifest.json (an update or a graduation changes what a ramped gate concludes), the gate's own script, the machinery (lib/gate.mjs, lib/fs-walk.mjs, lib/stamp-inputs.mjs), and since 1.0.4 every tools/lib module the script reaches through static imports: an edit to lib/sql-parse.mjs re-runs tenancy. The harness repo's test suite reds a list that misses one.
  • A hit is its own status (1.0.4). Through 1.0.3 it printed an OK line, so a turn that ended on warm stamps read like one that re-proved everything. The Stop hook now lists the <gate>: STAMPED — lines its green steps printed beside its skipped layers, on a green turn and a red one, and its telemetry records count them.
  • The rls-isolation stamp carries two things no file does. The runner checks it after supabase status succeeds and mixes into the digest the supabase --version output and the running database's identity: the server's start time, which moves on pnpm db:reset and on pnpm db:down then pnpm db:up, and the applied migration versions, which move when a migration is applied without a restart. So a CLI change, a reset, a restart or an applied migration runs both suites again. It is honoured only when CI is empty or unset (this runner treats CI=false as CI) and HARNESS_REQUIRE_TOOLCHAINS is not 1, and it is recorded only after [rls] OK, never on a skip or a failure. If the identity cannot be read, both suites run and nothing is recorded. It cannot see SQL someone runs by hand against the running database; CI never rides it.

A stamp lets a gate skip locally, and CI never honours one. The merge gate has one CI-side shortcut of its own (1.1.0), and it is narrower than a stamp: a lane may reuse a pass only from another CI run, and only for an identical tree.

quality-gate.yml runs on a pull request and again on the push its merge produces, and the two runs never cancel each other. So an up-to-date squash merge used to re-run static, unit, mutation, runtime-rls, e2e-fast and integration-lane on the tree the pull request run had just proved. The push run was also the weaker judge: without GITHUB_BASE_REF, diff coverage, the mutation scoper and the append-only migration check all compare against HEAD and see an empty diff.

  • What is recorded. On a pull request, each of those lanes ends with a step that prints one marker: the job's name, git rev-parse HEAD^{tree} of the checked-out merge commit, and the pull request's head. It is the last step, so it runs only when every step before it passed. tools/ci/lane-reuse.mjs builds the marker, so the step's own text never contains it.
  • What is looked up. On a push, the first step after checkout asks the same script. It finds the one merged pull request whose merge commit is this commit, that pull request's newest quality-gate.yml run at its final head, this job (by name) in that run's latest attempt, and the marker in the job's log. A run belongs to the pull request when its head commit, head branch and head repository are the pull request's. GitHub's own list of a run's pull requests names only open ones, so it is empty once the pull request is merged and is never read. The lookup reports a hit only when the job concluded exactly success, the log holds exactly one marker, the marker's tree equals this checkout's tree, and its head equals the run's head.
  • What a hit does. Every later step carries steps.reuse.outputs.hit != 'true', so it is skipped. One step names the run the lane relied on, in the log and in the step summary, and gate-summary lists the lane as REUSED with that run beside its SKIPPED list. The job still concludes success, because it cites one. gate-summary's verdict does not change.
  • What misses. Everything else, and a miss runs every step: any event but push (pull_request, schedule and workflow_dispatch always run in full), a direct push, an associated pull request that is not exactly one or was not merged, a pull request from a fork (its run executed workflow text from a repository you do not control), a run of another pull request, any conclusion but success, a missing or malformed marker, and a merge that changed anything, such as a branch behind its base or a conflict resolution. An API or parse error is a miss too: the step prints why and exits 0.
  • What a tree does not pin. History (gate-integrity walks git log, and a squash commit's history differs from the merge commit's), the runner image, the network and the clock. The nightly run never reuses, so it stays the net for all four.
  • Permissions. The lookup reads this workflow's runs, their jobs and job logs, which GitHub files under Actions, and the pull requests associated with a commit, filed under Pull requests. So each of those lanes requests actions: read and pull-requests: read beside contents: read.
  • No job-level if:. Every condition is on a step, so live-controls still reads these lanes as running on every commit, and the enforcement-tier verdicts docs-sync gives do not change. Read a reused lane as "ran on this tree", not "ran on this commit".

Enforced as hooks + lint + depcruise + gates (defense-in-depth); the grounding rules restate them so the model rarely trips a gate: security-invariants.md (always loaded), encryption.md (always loaded; the encryption invariants whose checks run with the opt-in e2ee module off, and the stub every base file cites), provenance.md (always loaded), e2ee.md (path-scoped, best effort; the full encryption rule for the module, and the checks each bullet names are the invariant; since 2.0.0 it comes with the e2ee module, so enable e2ee installs .claude/rules/e2ee.md and an install without the module does not carry it), mobile-server-split.md (path-scoped; never rely on conditional loading for invariants).

Doctrine notes for the citations:

  • mobile-server split — the mobile app is an untrusted client: it holds a scoped bearer token and can send any request it likes; the OS keychain, the app scheme, and store review are containment, never authorization. THE authorization boundary is RLS at the database, not a code wrapper: every exposed table is ENABLE + FORCE ROW LEVEL SECURITY with owner_id = (select auth.uid()) policies, and auth.uid() reads the request's request.jwt.claims. The DAL (packages/verticals/<name>/src/data/*.ts) reaches Postgres only through a request-scoped supabase-js client built from the caller's JWT — PostgREST, never a raw SQL wrapper — and re-parses every row against the vertical's zod contract at its EXIT (rows enter as unknown; the compiler is never trusted for row shapes). Normal requests run as authenticated (the RLS-subject role); service_role BYPASSES RLS and is Edge-Function-only, ADR-governed.
  • the api-client one-door — every mobile request goes through the tRPC client (apps/mobile/src/lib/trpc/client.ts): createApiClient's httpBatchLink attaches the bearer token and the x-client-version header per request in headers(), and responses ride the tRPC envelope. There is no 401-refresh-retry in the client — session rotation is supabase-js's startAutoRefresh, on the provider. In the source harness, features once called the network directly with no auth header and every mocked lane stayed green; the one-door plus the live-api proof is the answer. (Through 0.11.1 this bullet named an apps/mobile/src/lib/api-client.ts that never shipped in this lineage — ancestor-port residue; the enforcement always pointed at the true door.) The session lives behind LargeSecureStore via src/host/** (expo-secure-store — iOS Keychain / Android Keystore), never in JS-visible app storage and never behind an EXPO_PUBLIC_ name (inlined into the shipped bundle).
  • GUC discipline — production identity rides the request-scoped supabase-js client's JWT (auth.uid() reads request.jwt.claims), never an app-side GUC. Tests and the mid-turn probe impersonate with SET LOCAL ROLE authenticated plus a transaction-local request.jwt.claims, and that GUC MUST be transaction-local by construction (set_config('request.jwt.claims', …, true) / SET LOCAL): a session-scoped GUC survives the transaction and LEAKS the previous user's identity to whoever gets the pooled connection next. [corpus: postgres/guc-set-local]
  • append-only migrations — editing an already-committed migration desynchronizes every database that ran the original. New state = a new timestamped migration; never edit or delete an applied one.
  • migration discipline — migrations carry structure, not data (DML needs -- harness-allow-dml: <reason>); destructive DDL must reference an ADR. Two-phase changes follow docs/runbooks/expand-contract.md — and the mobile fleet skews HARDER than a desktop fleet: store review lags, rollouts are staged, some installs never update.
  • CNG purity — android/ and ios/ are expo prebuild OUTPUT. A committed or hand-edited native dir is configuration that CNG will silently regenerate away; native intent lives in app.config.ts + reviewed config plugins (tools/expo-plugins.json), where gates can diff it.
  • graph queries — WITH RECURSIVE over graph-shaped data loops forever on cycles unless terminated: use the CYCLE clause (Postgres 14+) or a visited guard. [corpus: postgres/recursive-cycle]

The schema-rls gate proves policies exist; the runtime suite proves they isolate. node tests/rls/run-rls.mjs (the rls-isolation Stop-hook step / pnpm test:rls) resolves the Supabase CLI (the workspace copy in node_modules/.bin first, then PATH; PATH only on Windows) and probes the stack with supabase status. No CLI or no stack: a loud SKIP on a manual run, a FAIL in CI and under the Stop hook once supabase/migrations exists. Otherwise it runs the pgTAP suite with supabase test db, then the supabase-js suite with the API URL, the keys and SUPABASE_DB_URL, all read from supabase status -o env. It runs both against the stack as it stands: it applies no migration and restarts nothing, since migrations reach the database on the first pnpm db:up or on pnpm db:reset, and a stopped stack keeps its data. When nothing either suite reads has changed since its last green run, it prints rls-isolation: STAMPED instead (see Stamped gates). Per ISOLATION_TARGETS entry:

  • Seeded positive control — user A sees its OWN row first. Without this, a deny-all database would pass every negative assertion vacuously. The same doctrine applies to the mid-turn rls_verify MCP probe.
  • Cross-user SELECT returns zero rows, no error; cross-user UPDATE/DELETE match 0 rows; INSERT smuggling the other user's id fails with SQLSTATE 42501; the victim's data is untouched afterwards.
  • GUC-leak detector — pool max: 1 ON PURPOSE: after an impersonated transaction, the SAME physical connection must have no identity. Connection rotation would hide exactly the session-GUC bug class this exists to catch.
  • Catalog gate — facts from pg_catalog, not vibes: FORCE RLS flags, per-operation policies, leading-column owner indexes, patched pgvector, non-BYPASSRLS role.
  • Index shape, asserted statically — the tenant index must carry the ORDERING, not just the filter: (org_id, <ORDER BY columns, direction>), so one index serves the policy, the sort and the cursor range. The pgTAP structural suite and the schema-rls gate read that from pg_catalog, and the query-shapes gate closes it against the statements the DALs actually issue. There is deliberately no EXPLAIN plan probe in THIS suite — a plan is a planner opinion at one statistics snapshot, and against the handful of rows seed.sql writes it is not merely noisy but wrong: the planner correctly reads one page rather than using an index, so an assertion here would flap or pass for the wrong reason. The probe lives where the cardinality does — tools/check-db-perf.mjs in the path-filtered db-scale CI lane, over supabase/seeds/scale.sql. It asserts plan SHAPE (which index the planner chose, no Sort above a keyset leaf, no per-row SubPlan) and never milliseconds, and it refuses to certify a table below the reviewed row floor.

Tests impersonate the Supabase way — SET LOCAL ROLE authenticated plus a transaction-local request.jwt.claims whose sub is the user id (exactly what auth.uid() reads) — so the suite exercises the same RLS path a request-scoped client takes in production. The mid-turn probe (tools/mcp/rls-verify-server.mjs, the rls_verify MCP tool) is read-only, transaction-local, always rolled back, positive control first — anything preventing a real probe is a SKIP, never a green.

The chain runs corpus → code → check → ADR → verification → gate:

  1. Pinned corpus — tools/mcp/corpus/index.json (the harness's, owned) and tools/mcp/corpus/project.json (the project's, seeded) hold version-pinned entries for every external authority the code relies on; corpus_search serves both mid-turn — no network, honest NO_MATCH over fabricated results. A project adds an authority to project.json: editing the owned index would fork it. Both files pass the same lint (tools/lib/corpus.mjs), and a project id may not reuse an upstream one.
  2. In-code convention — every non-trivial decision carries // SOURCE: with [corpus: <id>] when pinned.
  3. Enforcement — posttool-source-check.mjs per edit; tools/check-sources.mjs tree-wide in validate/CI (identical heuristic, so the two can never disagree). A site in a mandatory class blocks the edit and reds the gate; a site whose classes are all advisory (vector-index, llm-sampling, tuning-constants, unless tools/decision-groups.json promotes one with "mandatory") is reported and blocks nothing (1.1.0).
  4. /adr — one ADR per slice into docs/adr/, its Sources section reconciled against every inline // SOURCE: in the slice.
  5. /verify-citations — the read-only citation-verifier subagent resolves each citation for existence AND support. A turn does not end with rejected citations.
  • Done means green gate. A turn is not finished until pnpm validate and the Stop-hook runtime suites pass. Do not summarize a red build as "mostly working".
  • Prove, don't claim. The gate output is the evidence.
  • No same-turn test edits. Deliberately a review-time rule (AGENTS.md contract + reviewer subagents + PR review), not a hook: a legitimate feature adds code and tests together, and a deterministic ban cannot tell the two apart.

For any change touching auth, RLS, migrations, the native config surface (app.config.ts / eas.json / config plugins / permissions), or the API contract: write specs/<feature>.md (from specs/_template.md), get human sign-off, then implement. The spec is necessary but not sufficient; the gate holds the line either way. Each ## heading of the template is an addressable section, so a reviewer brief or an ADR's Traceability cites specs/<feature>.md#<id> rather than the whole file: node tools/spec-anchor.mjs specs/<feature>.md#<id> prints that section, and with no #<id> it lists the ids.

Reviewer subagents are read-only by construction — file reads/searches only, so a prompt-injected reviewer cannot become a writer. citation-verifier additionally holds corpus_search (no WebFetch since 0.9.0: repo read + external fetch + an egressing report is all three trifecta legs in one agent — unresolvable external URLs are reported for the human to open), and the security reviewers the read-only rls_verify probe — never a write or shell tool. The claim is machine-asserted: the docs-sync gate parses every .claude/agents/*.md frontmatter (pinned grammar in tools/lib/agent-roster.mjs; unparseable frontmatter fails closed) and reds a reviewer holding anything outside the read-only allowlist or missing disallowedTools: Write, Edit, or an agent whose harnessFallbackModels list names nothing or repeats an entry.

  • security-reviewer — MUST run on any change to RLS SQL, migrations, the server-only data layer (tRPC procedures / Server Actions / a vertical's ./client), or service_role usage.
  • web-security-reviewer — MUST run on any change to Server Actions, the web Supabase seam (apps/web/lib/supabase/**), proxy.ts, the tRPC route handler (app/api/trpc/[trpc]/route.ts), or NEXT_PUBLIC_ env.
  • mobile-security-reviewer — MUST run on any change to the Supabase session storage (LargeSecureStore), app.config.ts/eas.json, permissions, or config plugins.
  • torvalds-reviewer — the quality red-team (data structures first, kill special cases, delete code) before a slice is declared done. Since 1.1.0 it is in the wholeTurn class of tools/reviewer-triggers.json: reviewer-verdicts owes its verdict on every non-empty diff.
  • accessibility-reviewer — RN accessibility review (roles/labels/hints, touch targets, screen-reader sanity) on UI-heavy slices.
  • design-reviewer — design-quality review (typography roles, spacing rhythm, accent discipline, motion tokens, state choreography — the designing-mobile-ui doctrine) on UI-touching slices; taste and choreography, where the gates cannot judge.
  • citation-verifier — the provenance verifier, via /verify-citations, and, like torvalds-reviewer, owed on every non-empty diff since 1.1.0 (wholeTurn).

Author agents (dal-author, migration-rls-author, test-author) keep their write tools; only the universal frontmatter fields apply to them.

What a change must bring (1.1.0). A reviewer body's rubric asks about the lines a diff contains, and a missing companion is on no line of it. So every reviewer body except citation-verifier's carries a ## WHAT MUST ACCOMPANY IT table before its closing paragraphs, with one row per companion that a rule the harness already states requires: id | The diff introduces | It must also bring | Stated in | Enforced by. A new table must bring its three revokes, its isolation-register rows and its audit trigger. A definer function must bring its REVOKE … FROM PUBLIC and FROM anon, and an Edge Function its ADR. A tRPC mutation or a Server Action must bring its rate-limit bucket, a screen its src/routes.ts entry, and a web page its page.meta.ts. A permission or a config plugin must bring its register row, and a new interface its second consumer. The reviewer reports each row that applies as <id>: present (file:line) or <id>: absent, and an absence is a finding at the severity the body already gives that rule. Stated in names the file that states the rule. Enforced by names the chain step that reds the absence, or says review only. A row that a step enforces stays in the table, because the database-backed proofs skip when no local stack is running. The web-page rows of accessibility-reviewer and design-reviewer widen those two bodies past the mobile UI, because tools/reviewer-triggers.json summons both on apps/web/app/**/page.tsx. No gate reads the tables. The harness repository's own tests hold the shipped bodies to them, and its maintainers score the reviewers with a factory eval when a body or a model pin changes: seeded changes, one absence case and one complete twin for each kind of change, so a reviewer that always answers the same verdict scores at most half.

Which model a verdict ran on (1.1.0). Each agent file pins one model:, and a reviewer can still run on another: a per-invocation model parameter, CLAUDE_CODE_SUBAGENT_MODEL (with CLAUDE_CODE_SUBAGENT_MODEL_FORCE), an availableModels allowlist that substitutes for a blocked model, or a fallbackModel chain. So the SubagentStop hook records, in each ledger entry, the model that wrote the verdict (read from the subagent's own transcript; null when it cannot be read) and pinned, whether that model matches the pin. Each reviewer file also carries a harnessFallbackModels: a, b list, the models the harness accepts in place of the pin; a security reviewer's list never names a weaker family than its pin. Claude Code ignores the key, so it chooses nothing: to run a reviewer on a listed model, pass it as the per-invocation model. A reviewer whose pin cannot run ends before SubagentStop and writes no entry, so the step's "did not run" red names the list. The file hash in tools/agents.lock.json covers the list, and the lock's models map keeps the pin. reviewer-verdicts judges the recorded model: the pin counts, and so does any model ID of the pin's family when the pin is an alias; a listed model counts and is named; any other model is named on a FALLBACK MODEL line, which the Stop hook shows you on a green turn too. For security-reviewer, web-security-reviewer and mobile-security-reviewer a model off the list, or null, does not count, and the step reds (a NOTE until 2.1.0 on an install whose baseVersion predates 1.1.0). docs-sync reds a list that names nothing or repeats an entry.

STOP_HOOK_STEPS ends with the runtime suites; the expensive validate steps are build (the export + bundle grep; stamped) and e2e (the jest-expo suite). Neither can be dropped locally: wiring reds a VALIDATE_STEPS that lacks any step named in tools/validate.floor.json. The Stop hook runs every step of tools/stop.floor.json whether or not STOP_HOOK_STEPS lists it, and gate-integrity reds a floored step that is missing or rewritten. HARNESS_ALLOW_SELF_EDIT=1 relaxes none of these checks.

An agent is dangerous when it combines (1) access to private data, (2) exposure to untrusted content, and (3) the ability to communicate externally. Break at least one leg for any agent that touches real data (see docs/security/sandbox-and-supply-chain.md):

  • No standing exfiltration — Bash network commands denied; WebFetch(domain:...) rules cover a small set of documentation domains and any other domain prompts (binding since 0.9.0, when the bare WebFetch/Bash/WebSearch allow entries that made the scoped rules decorative were removed — the wiring gate reds their return).
  • No privileged-role exposure — the service_role key (RLS-bypassing) lives only in ADR-governed Edge Functions and is kept out of the client bundle (build-check) and the mobile graph (depcruise); store/signing credentials exist only in CI secrets. RLS is the backstop.
  • Read-only reviewers — the subagents most exposed to untrusted content cannot write or execute.
  • Default-deny tooling — no MCP server or Skill runs unless registered in docs/security/approved-tools.md, version-pinned and reviewed.

.claude/statusline.mjs renders model | branch±dirty | gate: pnpm validate — a standing reminder of the gate command (a live validate per render would be too slow). It is one line and says nothing about the install's state: the session-start brief below does (1.1.0), once per session start and on demand, rather than on every render.

The brief says what state the install is in, on every SessionStart (startup, resume, clear, compact, fork) and on demand as node tools/harness-status.mjs, which prints the same bytes:

harness <version> (base <baseVersion>) · tier <core|standard|strict> · mode <bootstrap|retrofit>
parked: <n> (then up to five `<path>` entries under .harness/pending/)
last turn in this directory: green | none recorded | <n> consecutive block(s), cap <cap> | ended red at the cap (<gates>)
reviewers owed by the current diff: <n> (then up to five <agent> (`<path>`) entries)
local database: not answering, so … (only with migrations and nothing on the database port)

It is an injection surface, so it is closed rather than merely short. The fields are enumerated and printed in that order. Every value passes a closed validator (versions are x.y.z, tier and mode come from closed sets, agent and gate names are lowercase words, paths are repository-relative, at most 160 characters, from a closed set that admits the App Router's (, ), [ and ], with no empty, . or .. segment and none that starts with -) or prints as (unprintable). Each path prints in a code span, and a backtick is not in its set, so no path can close the span. No file content is ever read into it. The whole brief is capped at 1,200 characters and cut at a line with a fixed marker. A source that cannot be read prints <field>: unavailable. All of it lives in tools/lib/harness-brief.mjs, which the write guard covers; the hook and the CLI are thin wrappers. The name set, the path printer and (unprintable) come from tools/lib/closed-text.mjs (2.1.0), the one home of every closed printer: paths, symbols, SQL names, signatures rebuilt from tokens, case labels, dotted callees, actions, i18n keys, testIDs and the key12 id. Each prints a passing value in a code span and anything else as (unprintable); the advisory recorder (docs/harness/gates-catalog.md, "Shared behavior") prints through the same module, and nothing the harness prints from the tree into an agent's context uses a printer of its own.

  • The last turn is read over the whole turn ledger, every session's records: a new session's id matches none of the earlier ones, so a session that ended red at the cap would otherwise read as green. The cap is the one .claude/settings.json hands every hook through its env, so a terminal run prints the cap the session runs under.
  • The owed reviewers are the set Stop step reviewer-verdicts decides on, from the same libs: the reviewer ledger v2's set (the merge-base diff with deletions, plus the whole-turn reviewers) where v2 is live and the branch has an upstream, the 1.0.x set (uncommitted changes) otherwise. Outside a git repository it is unavailable, never 0, and so it is where v2 is live and tools/lib/git-diff.mjs is a fork that predates it, because the step's finding there is the fork, not a set.
  • The local database line (2.0.6) prints only when supabase/migrations/ exists and nothing accepts a connection on 127.0.0.1 at supabase/config.toml's [db] port. In that state Stop step rls-isolation fails closed, so the agent can start the stack with pnpm db:up before its work rather than at its first Stop. It is a hint, not that check: the step asks supabase status, and a port another program answers on prints nothing. Its text is fixed, and the port is never printed.
  • Read-only. Neither the hook nor the CLI writes anything, .harness/turn.lock included, and the hook reads no stdin, so no payload byte reaches the brief. The hook reads from $CLAUDE_PROJECT_DIR, so a resume after the shell moved into a subdirectory still reads the project root.

What this harness claims, precisely:

  • Tamper-evident, not tamper-proof. Shell access can modify anything; the design makes tampering visible and non-load-bearing (CI floor + CODEOWNERS decide what merges).
  • Guards are tripwires, not sandboxes. Regex guards catch the common dangerous forms; obfuscation can evade them. The permission deny list, the read-only reviewer construction, and CI are the layers that do not depend on pattern-matching.
  • RLS is the data boundary; everything client-side is UX. A compromised app binary can send any request it likes — it still authenticates as one user and FORCE RLS bounds the blast radius to that user's rows.
  • The Stop chain contains no on-device proof, and the a11y floor is lint + component tests, not a browser sweep. Both stated in full in the gates catalog's "Honest losses" — read it before trusting a green chain to mean "works on a phone".
  • Skips are visible by design. Any gate that could not run says so loudly and fails closed in CI; treat a SKIPPED line in local output as work remaining, not as green.