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 gate is the enforcement
Section titled “The gate is the enforcement”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.
One gate config, three enforcement layers
Section titled “One gate config, three enforcement layers”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:
pnpm validate→node tools/validate.mjs— the developer/agent fast path.- The Stop hook → runs
STOP_HOOK_STEPSdirectly (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 redefinevalidatetotruein package.json (an auto-accepted, unguarded edit) and pass a hollow gate. The Stop gate defines done locally. - CI → re-runs
node tools/validate.mjs --min-floor, which reads the FROZEN snapshottools/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).
The hook map (deterministic enforcement)
Section titled “The hook map (deterministic enforcement)”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
PreToolUsethis blocks the tool call; onStopit 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.
PostToolUsecannot un-run a tool; its exit 2 surfaces stderr so the model fixes what just landed. At exit 0, ahookSpecificOutput.additionalContextstring 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.
stop-validate-gate
Section titled “stop-validate-gate”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.
pretool-bash-guard
Section titled “pretool-bash-guard”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.
pretool-write-guard
Section titled “pretool-write-guard”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.
Tamper evidence (honest limits)
Section titled “Tamper evidence (honest limits)”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:
- Permission denies in
.claude/settings.json— network exfiltration,.envand.dev-auth/reads, ssh keys, force-push, hard reset, and direct writes to.claude/hooks/**, settings,.mcp.json,.harness/**. - Write-guard PROTECTED paths — the gate config,
validate.mjs+ the floor snapshot, everytools/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 setsHARNESS_ALLOW_SELF_EDIT=1in the environment the session starts from. Nothing records the variable; the committed diff is the record (see below). - The
.harnessmanifest — the installer records a SHA-256 for every harness-owned file;npx next-expo-supabase-agent-harness@latest doctorre-hashes the tree so silent in-place edits are evident as drift, and thegate-integritygate re-checks the enforcement files on every validate. - The CI floor — CI does not trust the config:
--min-floorenforces the frozen snapshot, fail-closed. Local bypasses change nothing about what merges. - 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.
What HARNESS_ALLOW_SELF_EDIT=1 relaxes
Section titled “What HARNESS_ALLOW_SELF_EDIT=1 relaxes”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 rulesshell-write-protected,interpreter-write-protected,gen-lock-writer,chmod-protected,rm-protected,truncate-protected,move-protected-away,git-restore-old-revision,self-rebaseline-writerandapply-proposal-invocation. Every other rule still denies.tools/gen-agents-lock.mjs:--writerefuses 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 andbaseVersionchecks 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).
Skip-local / fail-closed-CI asymmetry
Section titled “Skip-local / fail-closed-CI asymmetry”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:
CIitself is not set, so a tool that readsCIdirectly keeps its local behaviour.- Tool caches stay: ESLint's
--cacheand TypeScript's*.tsbuildinfo. - The append-only migrations check diffs against
origin/$GITHUB_BASE_REFwhen that variable is set, as on a pull request in CI, and againstHEADotherwise. The flag does not set it, so an edit to a migration already committed on your branch is caught only in CI, or locally withGITHUB_BASE_REF=<base branch>exported and that branch fetched. types-driftskips on its own when no local stack is running, without consulting the predicate. It skips the same way in CI'sstaticjob, and it records nothing.tests/rls/run-rls.mjstakes its fail-closed posture fromCIalone (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.
Stamped gates
Section titled “Stamped gates”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
stampGateintools/lib/gate.mjs:build,contracts,db-limits,e2e,expo-policy,licenses,native-deps,query-shapes,rate-limits,security-headers,tenancy,version-syncand (1.1.0)web-compilein the validate chain, (1.1.0)essential-eightandconformance-map, the second and third scripts of thedocs-syncstep, theeas-updatemodule gate, and (1.0.4) therls-isolationStop step. Nothing else is: steps are never chosen by classifying the diff.essential-eightruns its negative proof before it consults its stamp, and stamps only when the proof finds nothing (docs/harness/gates-catalog.md, thedocs-syncsection). - Inputs are reviewed data. Each list lives in
tools/lib/stamp-inputs.mjs(a module gate declares its own throughwithMachinery), and a missing input class is a stale-pass bug. Every list carries.harness/manifest.json(anupdateor 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 everytools/libmodule the script reaches through static imports: an edit tolib/sql-parse.mjsre-runstenancy. 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
OKline, 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-isolationstamp carries two things no file does. The runner checks it aftersupabase statussucceeds and mixes into the digest thesupabase --versionoutput and the running database's identity: the server's start time, which moves onpnpm db:resetand onpnpm db:downthenpnpm 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 whenCIis empty or unset (this runner treatsCI=falseas CI) andHARNESS_REQUIRE_TOOLCHAINSis not1, 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.
Post-merge lane reuse
Section titled “Post-merge lane reuse”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.mjsbuilds 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.ymlrun 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 exactlysuccess, 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, andgate-summarylists the lane asREUSEDwith that run beside itsSKIPPEDlist. The job still concludessuccess, 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,scheduleandworkflow_dispatchalways 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 butsuccess, 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-integritywalksgit 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: readandpull-requests: readbesidecontents: read. - No job-level
if:. Every condition is on a step, solive-controlsstill reads these lanes as running on every commit, and the enforcement-tier verdictsdocs-syncgives do not change. Read a reused lane as "ran on this tree", not "ran on this commit".
The security invariants
Section titled “The security invariants”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 SECURITYwithowner_id = (select auth.uid())policies, andauth.uid()reads the request'srequest.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 asunknown; the compiler is never trusted for row shapes). Normal requests run asauthenticated(the RLS-subject role);service_roleBYPASSES 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'shttpBatchLinkattaches the bearer token and thex-client-versionheader per request inheaders(), and responses ride the tRPC envelope. There is no 401-refresh-retry in the client — session rotation is supabase-js'sstartAutoRefresh, 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 anapps/mobile/src/lib/api-client.tsthat never shipped in this lineage — ancestor-port residue; the enforcement always pointed at the true door.) The session lives behindLargeSecureStoreviasrc/host/**(expo-secure-store — iOS Keychain / Android Keystore), never in JS-visible app storage and never behind anEXPO_PUBLIC_name (inlined into the shipped bundle). - GUC discipline — production identity rides the request-scoped supabase-js client's
JWT (
auth.uid()readsrequest.jwt.claims), never an app-side GUC. Tests and the mid-turn probe impersonate withSET LOCAL ROLE authenticatedplus a transaction-localrequest.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 followdocs/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/andios/areexpo prebuildOUTPUT. A committed or hand-edited native dir is configuration that CNG will silently regenerate away; native intent lives inapp.config.ts+ reviewed config plugins (tools/expo-plugins.json), where gates can diff it. - graph queries —
WITH RECURSIVEover graph-shaped data loops forever on cycles unless terminated: use theCYCLEclause (Postgres 14+) or a visited guard. [corpus: postgres/recursive-cycle]
RLS testing doctrine
Section titled “RLS testing doctrine”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_verifyMCP 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: 1ON 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 theschema-rlsgate read that frompg_catalog, and thequery-shapesgate 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 rowsseed.sqlwrites 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.mjsin the path-filtereddb-scaleCI lane, oversupabase/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 provenance pipeline
Section titled “The provenance pipeline”The chain runs corpus → code → check → ADR → verification → gate:
- Pinned corpus —
tools/mcp/corpus/index.json(the harness's, owned) andtools/mcp/corpus/project.json(the project's, seeded) hold version-pinned entries for every external authority the code relies on;corpus_searchserves both mid-turn — no network, honestNO_MATCHover fabricated results. A project adds an authority toproject.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. - In-code convention — every non-trivial decision carries
// SOURCE:with[corpus: <id>]when pinned. - Enforcement —
posttool-source-check.mjsper edit;tools/check-sources.mjstree-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, unlesstools/decision-groups.jsonpromotes one with"mandatory") is reported and blocks nothing (1.1.0). /adr— one ADR per slice intodocs/adr/, its Sources section reconciled against every inline// SOURCE:in the slice./verify-citations— the read-onlycitation-verifiersubagent resolves each citation for existence AND support. A turn does not end with rejected citations.
The validate contract
Section titled “The validate contract”- Done means green gate. A turn is not finished until
pnpm validateand 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.
Spec-first SOP
Section titled “Spec-first SOP”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.
Adversarial review (the agent roster)
Section titled “Adversarial review (the agent roster)”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), orservice_roleusage.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), orNEXT_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 thewholeTurnclass oftools/reviewer-triggers.json:reviewer-verdictsowes 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 — thedesigning-mobile-uidoctrine) on UI-touching slices; taste and choreography, where the gates cannot judge.citation-verifier— the provenance verifier, via/verify-citations, and, liketorvalds-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 cost
Section titled “Stop-hook cost”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.
The lethal-trifecta posture
Section titled “The lethal-trifecta posture”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 bareWebFetch/Bash/WebSearchallow entries that made the scoped rules decorative were removed — thewiringgate reds their return). - No privileged-role exposure — the
service_rolekey (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.
Statusline
Section titled “Statusline”.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 session-start brief
Section titled “The session-start brief”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.jsonhands every hook through itsenv, so a terminal run prints the cap the session runs under. - The owed reviewers are the set Stop step
reviewer-verdictsdecides 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 isunavailable, never 0, and so it is where v2 is live andtools/lib/git-diff.mjsis 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 on127.0.0.1atsupabase/config.toml's[db]port. In that state Stop steprls-isolationfails closed, so the agent can start the stack withpnpm db:upbefore its work rather than at its first Stop. It is a hint, not that check: the step askssupabase 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.lockincluded, 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.
Threat model / honest limits
Section titled “Threat model / honest limits”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
SKIPPEDline in local output as work remaining, not as green.