Gates catalog
Companion to the harness doctrine. One section per default-on gate (the
37-step VALIDATE_STEPS chain in tools/harness.config.mjs), the Stop-hook runtime
suites, the CI-only lanes, every opt-in module, and the gates we considered and rejected.
Every section carries an anti-vacuity proof: how to inject a violation and watch the
gate fail. A gate whose failure you have never seen is a gate you should not trust —
exercise any of them in a scratch branch. (The write guard blocks some injections
in-session; inject via a plain editor or HARNESS_ALLOW_SELF_EDIT=1 when noted.)
Shared behavior: gates self-skip LOUDLY when their prerequisite (an install, a database,
the resolved Expo toolchain, the surface itself) is absent locally, and fail closed in CI
(CI=true / HARNESS_REQUIRE_TOOLCHAINS=1). See the doctrine's
"skip-local / fail-closed-CI asymmetry".
Every gate failure ends with a FIX[<gate>]: line naming the reproduce command and this
catalog. Since 1.1.0 a project can add its own line under it: when the seeded
tools/field-notes.json holds a note for that gate, the FIX line is followed by
FIELD-NOTE[<gate>]: <text>. The file is {"notes": {"<gate>": "one string"}}, and its
keys are gate tokens: the <gate> that <gate>: FAIL and FIX[<gate>] print, which is
the script's own name and not always its step's (the docs-sync step's scripts report as
docs-sync, essential-eight and conformance-map, and check-framework-floor.mjs
reports as floor-review). Whitespace collapses to single spaces, control and format
characters are removed, and the text is capped at FIELD_NOTE_MAX_CHARS code points
(tools/lib/gate.mjs), ending in … when cut. A key outside [a-z0-9-] and a value that
is not a string are ignored, and a file that is not valid JSON prints one line saying so
and no note. A note is print-only: it is read after the FAIL and FIX lines of a failing
run, so it cannot change an exit code or hide a finding, and it never prints on a pass, a
stamp hit or a local skip. The file is write-guarded (field-notes), because its text
reaches an agent while it decides how to make the red go away. Its scope stops where
tools/lib/gate.mjs's failure paths stop. format, types, lint, dead-code,
architecture, unit and mobile-unit run third-party tools, and rls-isolation's
runner prints its own [rls] lines, so none of them prints a <gate>: FAIL line or a
note. And when a failed Stop step's output is long, the Stop hook keeps its head and tail
and writes the whole output to .harness/stop-output/<step>.log, so a note from a gate in
the middle of a long validate --report-all run may appear only in that file.
Since 2.1.0 a gate can also report an advisory finding as a closed record rather than a
free-text NOTE. noteAdvisory(record) (tools/lib/gate.mjs) checks the record against a
closed schema: v: 1, a producer from a closed set (duplication, query-shapes,
parity, contracts, i18n, embeddings), a rule from that producer's closed family
list, a status of advisory, ramp-withheld or blocking, a subject of one or two
subject ids, a 12-hex fp, an optional until, integer counts, and facts holding only
numbers, booleans and values the printers in tools/lib/closed-text.mjs accept, nested at
most three deep. A record that fits prints one <producer>: NOTE — line rendered from the
record alone, naming its family and its key12 (the first 12 hex digits of
sha256(producer|rule|subject), the one id the harness prints); an advisory line never
contains the word ramp, and a ramp-withheld line contains (ramp), so graduate counts
it as outstanding. A record that does not fit is not written, and a fixed line naming only
its producer prints instead. noteComplete({ producer, leg }) records that a producer's leg
ran to its verdict; a gate writes it immediately before its final verdict and never on an
early exit, so a skip, a fail(), a stamp hit or a crash leaves the leg incomplete.
duplication is the first producer to write one (leg l0). Both append one JSON line to
<dir>/<pid>.jsonl when HARNESS_ADVISORY_REPORT_DIR names a directory and write nothing
when it is unset. Like the --ci-parity record, they print nothing else, decide no verdict,
swallow their own errors and never write into the project tree. rampNote takes an
optional subject: { rule, subject } beside until: when the ramp withholds, it also
writes a ramp-withheld record, and the NOTE line it prints does not change.
Honest losses (stated plainly, so nobody discovers them in an incident)
Section titled “Honest losses (stated plainly, so nobody discovers them in an incident)”- The audit trail covers mutations only.
audit.eventsrecords every INSERT, UPDATE and DELETE on every org-scoped table, and nothing at all about reads. A user who only looks leaves no trace, so the trail cannot answer "who saw this" — the question a data-access investigation usually starts from.SELECTauditing needspgauditconfiguration that is not expressible in a migration, so it stays out of scope until that changes — a permanent scoping CONDITION in thetenancy.jsondirectoryRpcstyle, deliberately carrying no release number (this sentence read "for 0.2.0" through four releases that shipped after it, a deferral that had quietly become a permanent state while still reading as a plan), re-opened if pgaudit's SELECT-audit configuration ever becomes expressible in a migration. Related:request_idon an audit row is a correlation field, not evidence — it is server-minted on the paths the server controls and forgeable by a client talking straight to PostgREST.actor_idis the field with integrity, because it comes from the verified JWT and the insert policy re-checks it against the database's own opinion. Do not build an investigation on the wrong one.
Two things the desktop original's chain had that this chain does NOT have, recorded here deliberately rather than papered over:
- The Stop chain contains no on-device proof. The
e2egate and themobile-unitsuite run jest-expo + React Native Testing Library under Node — the shipped screens, expo-router navigation, the tRPC client and i18n run for real, but Hermes bytecode load, native module init, Fabric layout, and the OS keychain do not. The on-device half is the Maestro emulator lane plus the startup-budget measurement, and those are CI-ONLY (quality-gatemobile-e2e+perf-lane, path-filtered + nightly, and skipped on a pull request while a livetools/surfaces.jsonrow defers the mobile surface;mobile-perf --closurein the Stop chain is the static half that guarantees every screen HAS a flow and a budget row the day it registers). A turn can end green having never booted the app on a device. That is a deliberate trade (seconds-fast, laptop-complete agent loop), not an oversight — and the selftest maestro-smoke job proves the trade is real coverage, not decoration: a broken container testID leaves the jest lane green (asserted) while the device sweep reds. - The a11y floor is weaker than a browser-driven axe sweep. The desktop original
swept every route with axe-core in a real browser engine per theme. There is no axe
for React Native: this harness's floor is eslint-plugin-react-native-a11y (every rule
at error) plus the RNTL suites' role/label/state assertions (
primitives-a11y, the states sweep). That catches missing roles, labels, hints, and touch-target props — it does NOT compute painted contrast against real pixels (the styleguide gate computes contrast from the OKLCH tokens instead), and it cannot see what a real screen reader announces. Treat an on-device screen-reader pass (TalkBack / VoiceOver) as a release-checklist item, not something a gate has proven.
Default-on gates (pnpm validate, in order, cheap → expensive)
Section titled “Default-on gates (pnpm validate, in order, cheap → expensive)”1. format — pnpm exec biome ci .
Section titled “1. format — pnpm exec biome ci .”Formatting, import organization, read-only (CI-grade ci, not check --write).
Fix with pnpm format. Generated-but-committed artifacts (the design-tokens adapters,
the Supabase type mirror, the committed contract inventories) are excluded —
byte-stability there belongs to their regen-diff gates.
Anti-vacuity: mis-indent any .ts file → FAIL naming the file.
2. gate-integrity — node tools/check-gate-integrity.mjs
Section titled “2. gate-integrity — node tools/check-gate-integrity.mjs”Recomputes sha256 over the RAW bytes of every harness-owned enforcement file recorded
in .harness/manifest.json (tools/, .claude/hooks/, the RLS and migration
runners) and fails on any mismatch or missing file — a raw write that slipped past the
write-guard hook (shell redirection, sed -i, an external editor) reds the very next
validate run. config and seeded entries are human-tunable and skipped. Deliberately
the first gate after format: tampered gates must not get to run.
It also checks, by value, that every hook command in .claude/settings.json runs node on
a file that exists; that STOP_HOOK_STEPS keeps every step of tools/stop.floor.json with
its command unchanged; that every retrofit conflict is merged or accepted in
tools/retrofit-accept.json; that baseVersion never went backwards in git history; and
that no escape list or threshold-bearing config is modified but not committed.
HARNESS_ALLOW_SELF_EDIT=1 switches off only the last of these, the commit rule (see
"What HARNESS_ALLOW_SELF_EDIT=1 relaxes" in the doctrine).
The escape-list commit rule (sub-check 3) and its one exemption. Every file in
ESCAPE_LISTS (tools/lib/enforcement-surface.mjs) that git status --porcelain reports is
a finding: exempting code from a gate or raising a budget is a reviewed commit, never a
working-tree edit. The exemption is the list the harness itself just planted: init and
update write escape lists untracked, and accusing the consumer of widening a file on the run
that delivered it is the wrong verdict. An untracked list is a plant when three facts hold:
it is untracked, its sha256 matches its .harness/manifest.json record, and (since 1.1.0)
a harness release planted exactly those bytes, according to tools/lib/planted-shas.json. That file
is generated from the released-sha tables' planted maps, is owned and hash-pinned by the
first check above, and arrives through update in the same run as any new escape list. A list
carrying a placeholder (tools/rls-exempt.json, tools/backup-posture.json) is compared after
its tokens are put back with the manifest's answers (tools/lib/derender.mjs). The record
alone stopped being enough in 1.0.2, when re-recording a sha became the supported way to keep
a fork, because the manifest need not be committed. A plant prints a NOTE; an untracked list
that matches its record but no release variant is a finding behind a ramp (a NOTE for
installs below baseVersion 1.1.0 until harness 1.2.0, which expired at 2.0.0, so hard for every install since); tracked-and-modified, untracked with
no record and untracked with a different sha are red as before. The threshold-config rule
(3b) still trusts the record alone, a stated limit.
Anti-vacuity: echo '// x' >> tools/check-migrations.mjs from a plain terminal →
FAIL naming the file. For the plant exemption, on a committed scaffold at baseVersion
1.1.0 or later: (A) printf '{ "allow": [] }\n' > tools/secret-scan-allow.json, then add
"tools/secret-scan-allow.json": { "mode": "seeded", "sha256": "<its sha256>" } to the
manifest's files → gate-integrity: FAIL (1), then - tools/secret-scan-allow.json: escape hatch present but not committed, and no harness release planted these bytes. …; (B) untrack
tools/approved-tools.json in a commit, append a server to it and re-record its sha → FAIL
naming tools/approved-tools.json. Before 1.1.0 both passed as a plant NOTE.
3. wiring — node tools/check-wiring.mjs
Section titled “3. wiring — node tools/check-wiring.mjs”The enforcement layers are actually CONNECTED to this project. Five load-bearing
invariants had exactly one check between them — installer doctor — and nothing ran
it: not the Stop chain, not a validate step, not a CI lane. A control whose only trigger
is a human remembering is not a control, so an install could carry every gate script, pass
every hash, and still have a hook unwired, pnpm validate pointed somewhere else, a
CLAUDE.md that silently replaced project memory, an enforcement-surface path no CODEOWNERS
rule covers, and defaultMode: "bypassPermissions" — with the whole chain green.
Two things wiring a hook does not settle, both added in 0.6.0. First, whether it is AIMED
correctly: the command guard's matcher must name Bash, Monitor and PowerShell. A
Bash(...) permission rule covers Monitor, which is what made the omission invisible — but a
hook matcher is an exact tool name, so a Bash-only matcher left every command-content check
reachable-around via Monitor, and on Windows without Git Bash (where Claude Code registers no
Bash tool at all) it never fired. A project may ADD a tool to that matcher; it may not drop one.
Second, whether a deny rule is CONSULTED: Claude Code honours file-permission rules under
Edit(...) and Read(...) only, so every Write(path) deny must carry an Edit(path) twin or
it reads as protection and enforces nothing.
Asserts, by value: every hook wired — a UNION of a required floor and every top-level
.claude/hooks/*.mjs present, so rm-ing a hook cannot delete its own requirement and a new
hook nobody wired still reds (0.6.0 learned this when a hand-kept list of six silently failed
to require the seventh); the permission posture
(disableBypassPermissionsMode == "disable", defaultMode != "bypassPermissions") as a
hard red; package.json's validate script still runs tools/validate.mjs; CLAUDE.md
is a pure @AGENTS.md include; VALIDATE_STEPS ⊇ tools/validate.floor.json; and
CODEOWNERS coverage over every escape list, every threshold config and every
enforcement-surface prefix — including the empty-owner spelling, which is valid CODEOWNERS
syntax that silently disables review while reading, to a human skimming the file, exactly
like a rule. Parked .harness/pending/ upgrades and a dormant lefthook are NOTEs, never
reds.
Placed directly after gate-integrity: integrity proves the enforcement FILES are the ones
the harness wrote, this proves they are WIRED, and both must hold before any later gate's
verdict means anything.
Anti-vacuity: delete the pretool-mcp-guard group from .claude/settings.json → FAIL
naming it; set permissions.defaultMode to bypassPermissions → FAIL; append a bare
/tools/** line (no owner) to .github/CODEOWNERS → FAIL naming the paths it silently
un-reviews. (tests/gates/check-wiring.test.mjs; selftest Canary 25.)
4. secrets — node tools/check-secrets.mjs
Section titled “4. secrets — node tools/check-secrets.mjs”A hermetic credential scan inside the chain. lefthook.yml prints SKIP secrets scan when
the gitleaks binary is absent and .github/workflows/gitleaks.yml only scans after a
push — both correct for what they are, and together they leave one hole: on any machine
without gitleaks a turn could end green with a service-role key in a tracked file, and the
first thing to notice would be a workflow running after the bytes reached the remote. This
gate is zero-dependency node, so it is present on every machine and in every turn.
Deliberately not a Go-regex translation of .gitleaks.toml: two scanners that quietly
disagree about what a secret looks like are worse than one, because each gets trusted for
the other's coverage. What it asserts instead is rule-id lockstep between
tools/secret-patterns.json and .gitleaks.toml, both ways — the two policies may differ
in expression, never in scope. gitleaks keeps entropy analysis, the default ruleset and
history scanning.
Shapes: sb_secret_ (the credential that BYPASSES RLS), credentialed postgres:// DSNs,
PEM private-key bodies, GCP service-account JSON, a literal Expo access-token assignment,
keystore passwords, Sentry org tokens. Findings never echo the matched value — a gate that
printed the credential it found would have copied it into the CI log, the Stop block and
the transcript. Per-rule allowlists are first-class data ported from .gitleaks.toml
(a fixture must LOOK like the real shape, so it is distinguished by SAYING so in the
value); tools/secret-scan-allow.json is a tolerated-absent, per-finding acceptance whose
entries each need a real reason and must be committed.
Anti-vacuity, both directions: write a real sb_secret_… key into a tracked file → FAIL
naming file and line; and the gate self-tests every rule against its own synthetic
positive at startup, so a decayed regex reports ITSELF instead of a clean tree. Scanning
zero files is also a hard FAIL. (tests/gates/check-secrets.test.mjs; selftest Canary 26.)
5. types — pnpm exec tsc -b
Section titled “5. types — pnpm exec tsc -b”Solution-mode strict TypeScript across all six projects (composite project
references: contracts, schema, importer, eval, server, mobile). Catches
cross-package breakage the per-package editors miss.
Anti-vacuity: change a DTO field name in @app/contracts without updating the
server → FAIL in the dependent project.
6. lint — pnpm exec eslint . --max-warnings 0 --cache
Section titled “6. lint — pnpm exec eslint . --max-warnings 0 --cache”Type-aware rules (strictTypeChecked + stylisticTypeChecked via projectService),
react-hooks (including the React Compiler rule set) on the Expo app,
eslint-plugin-react-native + eslint-plugin-react-native-a11y with EVERY rule at
error, sonarjs cognitive-complexity ≤ 15, plus the boundary bans: global fetch
outside the tRPC client (src/lib/trpc/client.ts), expo-secure-store outside src/host/** +
src/auth/**, chart libraries in the dense features, raw text outside AppText,
bare console outside src/lib/log.ts.
0.9.5 adds two custom rules. env-through-register (the env-register-gate
discharge): the environment is read through @app/env — a raw process.env read
in apps/**/packages/** reds, computed reads and bare process.env red as
smuggle shapes, while literal NEXT_PUBLIC_*/EXPO_PUBLIC_*/NODE_ENV reads
stay exempt (build-time inlining requires the member text) and the register's own
reader files, tests, e2e specs and the playwright config are outside the globs —
zero standing allowlist. no-suppressed-complexity: the ≤ 15 ceiling's one hole
was the suppression comment — the directive itself is now the lint error, a
directive naming this rule reds too (line-level stacking never terminates), and
the rule's header states the honest file-level residual plus the controls that
cover it (the factory's pinned config text, the torvalds rubric).
1.1.0 reaches the Edge Functions. Until 1.1.0 the global ignore read supabase/**,
and a later block cannot reach anything a global ignore lists, so the three blocks that
name supabase/functions (no-unverified-session, service-role-edge-functions-only,
crypto-primitives-one-door) reached nothing there. The ignore now lets
supabase/functions/ back in; a block gives supabase/functions/**/*.ts the TypeScript
parser with no project service (no tsconfig covers Deno code, so type-aware rules stay
off; deno check in the edge-functions lane is the type half), and the security blocks
reach the functions unedited and unramped — a getSession() there is an authentication
bypass, not a debt. service-role-edge-functions-only stays off them: they are its
sanctioned home. A second block holds them to cognitive complexity ≤ 15 and
no-suppressed-complexity, every function file included. 1.1.0 exempted the seeded
delete-account index.ts every 1.0.x install carries, which measures 16, for one release,
paired with the seededSourceFixes instruction to pull the split
(update --refresh-seeded supabase/functions/delete-account/); 2.0.0, the first release at
or past its 1.2.0 deadline, removed the exemption.
Anti-vacuity: import * as SecureStore from 'expo-secure-store' in a random
feature → FAIL no-restricted-imports; call fetch() in a screen → FAIL
no-restricted-globals (depcruise walls the same seams at the module-graph level —
defense in depth); read process.env['UPSTASH_REDIS_REST_TOKEN'] in a web lib →
FAIL env-through-register; add // eslint-disable-next-line sonarjs/cognitive-complexity above a fat function → FAIL no-suppressed-complexity
(RuleTester red-proofs: tests/gates/eslint-custom-rules.test.mjs); append a
getSession() call to supabase/functions/delete-account/index.ts → FAIL
no-unverified-session (selftest Canary 37; against the 1.0.3 config lint stays green).
Papercut: --cache keys on file content + eslint config, NOT on tsconfig —
after a tsconfig change fixes a typed-lint error, the stale .eslintcache can
keep reporting it (observed live: a jest.setup.ts include fix stayed red until
the cache was dropped). If lint contradicts a fix you just made, rm .eslintcache and re-run before debugging further.
7. suppressions — node tools/check-suppressions.mjs
Section titled “7. suppressions — node tools/check-suppressions.mjs”The inline-directive census over the product tree (apps/, packages/, supabase/), and
the scanner OUTSIDE ESLint that polices the off-switch ESLint cannot see: a rule-less
eslint-disable applies to every rule id INCLUDING the rule that would report it, so
the report is filtered before it surfaces — tools/eslint-rules/index.mjs records the
proven in-Linter attempt, and this step is the answer it names. Three shapes are hard
reds with no escape: the rule-less disable, @ts-ignore, and @ts-nocheck (the
self-expiring @ts-expect-error is the sanctioned form). Every surviving directive
must name its rule AND carry an inline reason of substance (eslint's native
-- <reason> suffix, biome-ignore's : <explanation>, @ts-expect-error <description> — >= 20 chars), and the census closes BOTH WAYS against the seeded
tools/suppressions-allow.json: a directive-carrying file with no register row is a
suppression nobody reviewed, a row whose directives went away is a stale acceptance
(the tools/eol.json direction), and counts are exact, so a second site cannot ride a
one-site review. Harness-owned tools/** is deliberately out of scope — the factory
ratchet (scripts/check-complexity-ratchet.mjs, which re-lints with
--no-inline-config) owns that half — and -- harness-allow-dml: markers are
censused, not re-policed (check-migrations.mjs owns their reason rule). The
register's dating is release-clockless by design: the ASD expiring-exception intent is
carried by the both-ways closure (a suppression cannot outlive its site, and adding
one is a two-place, write-guard-reviewed act), never by a calendar date a chain step
must not consult. Findings ramped for pre-1.0.0 installs until 1.1.0 (expired — hard
for every install since 1.1.0). Not covered: whether a reason is TRUE — that is the
reviewer's half, exactly as it is for every other reviewed register.
8. provenance — node tools/check-sources.mjs
Section titled “8. provenance — node tools/check-sources.mjs”Tree-wide scan for decision-site keywords (RLS SQL, set_config/SET LOCAL,
jwtVerify/JWKS/clockTolerance, vector index choices, sampling/retry/timeout
constants) lacking // SOURCE: (-- SOURCE: in SQL) within a 3-line window.
Identical heuristic to the PostToolUse hook, so in-session and CI can never disagree.
Cited corpus entries must carry a groups key covering the site's decision class
(reviewed cross-group escapes in tools/provenance-overrides.json); a bare-URL
citation grounds only on a tools/lib/citation-domains.mjs allowlisted host.
Consumer-added decision classes live in tools/decision-groups.json.
Mandatory and advisory classes (1.1.0). Three built-in classes are advisory:
vector-index, llm-sampling and tuning-constants (the owned
ADVISORY_DECISION_GROUPS in tools/lib/provenance-rules.mjs). An uncited site, or a
group-match miss, whose classes are all advisory prints
provenance: ADVISORY (n) — file:line [class] on every run and is not a red; the hook
answers it with additionalContext at exit 0 instead of blocking. Every other class is
mandatory: rls-policy, guc-identity, token-verification, cryptography, the seeded
mobile-security, any group a project adds and any built-in a later release adds. A site
that matches any mandatory class is judged whole, as before. A top-level
"mandatory": ["<key>"] in tools/decision-groups.json promotes an advisory class, and
nothing in that file can demote one; a mandatory that is not an array, or names a key
that is not a group, fails closed. Resolvability, the host allowlist, corpus integrity
and the coverage lockstep stay hard for every class.
The corpus is two files, merged by tools/lib/corpus.mjs: the harness's
tools/mcp/corpus/index.json (owned, hash-pinned, mandatory) and the project's
tools/mcp/corpus/project.json (seeded, { comment, entries }, absent counts as
empty; 1.0.4). A project adds an authority to project.json, never to the index.
Both files pass the same per-entry lint (sha256 over text, non-empty
title/url/version, a groups array of known keys), a project entry justifies only the
groups it declares, and a group a project adds may be covered from project.json
alone. The project file must parse as exactly { comment, entries }, and an id the
index already pins reds naming both files: a project adds authorities and never
replaces one.
Anti-vacuity: add await jwtVerify(token, jwks) with no citation → FAIL with
file:line (an uncited const timeoutMs = 5000 prints an ADVISORY line naming
tuning-constants and passes, and FAILs once "mandatory": ["tuning-constants"] promotes
it; "mandatory": ["x"] → FAIL); cite a corpus entry whose groups do not cover a
mandatory site's class → FAIL naming the mismatch; in tools/mcp/corpus/project.json,
change an entry's text without its sha256, drop its groups, empty its url, break
the JSON, make entries an object, add a top-level key, or reuse an id index.json pins
→ FAIL naming project.json (the last naming both files); cite an id neither file pins →
FAIL naming both; delete index.json beside a project.json → FAIL naming
index.json (fixtures: tests/gates/check-sources.test.mjs,
tests/gates/corpus-lib.test.mjs).
9. boundaries — node tools/check-exports-walls.mjs && node tools/check-workspace-deps.mjs
Section titled “9. boundaries — node tools/check-exports-walls.mjs && node tools/check-workspace-deps.mjs”The boundary triad, part 1 (part 2 — the import-graph layering — is the later
architecture step). Both consumers derive from the ONE census
tools/exports-walls.json, never a per-consumer copy. check-exports-walls: every
package that ships a ./client subpath export must carry a sanctioned {package, reason} entry (Metro does not tree-shake, so a ./client on a package holding a
server graph puts it one import from the native binary); two-way, so a sanction naming
a missing package reds too. sanctioned is MAY not MUST — a listed package shipping
only . is fine. An entry's module value (the opt-in-module dormancy arm) is closed
against the OWNED tools/modules.json since 1.0.0 — the shipped copy of the
installer's module list, write-guarded and refreshed by update, with a factory
lockstep test holding the two set-equal — so a typo'd module name can no longer park
the stale arm permanently dormant; unknown or retired names red (ramped for pre-1.0.0
installs until 1.1.0, exports-walls-module-closure-ramp-expiry, expired — hard for
every install since 1.1.0), and a missing or malformed module list fails closed unramped
naming update.
The census is harness-owned (2.0.3, #154). tools/exports-walls.json is hash-pinned by
gate-integrity, and every vertical needs an entry, because the anatomy law below requires
its ./client key. So each red whose remedy is a census edit names the human path instead of
"edit the census": a human adds the {package, reason} entry under {{SECURITY_OWNERS}}
review and re-records the file's sha256 in .harness/manifest.json in the same reviewed
commit, after which update parks each later upstream change to the census for them to merge
(the upgrade runbook's "Forking an owned file"). The write guard's exports-walls-census row
denies an agent's edit with the same path, and scaffold-slice prints it as a next: line.
No verdict changes: gate-integrity already redded the edit the row denies.
check-workspace-deps (the declared-dependency allow-matrix):
apps/mobile may take a runtime @app/* dependency only if it is sanctioned OR
universally-importable (the error/event kernel, the wire contracts, the RN-only design
system); @app/api must be an import-type-only devDependency; verticals never depend on
each other; shared never depends on a vertical; apps/web never carries the RN-only design
system.
Vertical anatomy + intra-vertical layering (0.9.5, behavior-keyed since 1.0.0; part 3
of the same step — tools/lib/vertical-anatomy.mjs). The worked
packages/verticals/notes pattern as law, scoped to what is mechanically checkable:
every vertical ships BOTH barrels (. + ./client, declared and present) and the
barrels are PURE (export statements only — a barrel with logic breaks the Metro-safe
claim); src/domain/** imports only sibling domain files, @app/contracts and zod
(no I/O, no clock, no error kernel — domain returns values, data returns outcomes); the
DAL laws key on BEHAVIOR, not directory names — a value-import of
@app/supabase/@supabase/* reds ANYWHERE under src/** (import type of the port
shapes is sanctioned), and any file that speaks PostgREST (.from(/.rpc(/.select(
in call position) is the DAL wherever it lives and must import a port.ts that exists
(the witness: src/data/port.ts; single-hop, stated — the import must be in the
calling file). Until 1.0.0 both laws read the src/data/ directory name, which a
vertical naming its data layer src/repo/ escaped by construction — the discharged
row vertical-anatomy-folder-name-coupling; the re-keying also corrects that shape's
inversion, so a data directory that never speaks PostgREST owes no port.
src/events.ts speaks only @app/events + @app/contracts; and select('*') is
banned across vertical src (the explicit projection is the wire contract).
Deliberately NOT law: the presence of domain/ or events.ts at all — a thin
read-only vertical legitimately has neither, and a directory-shaped mandate would be
the max-lines mistake in another form; a vertical that names those two differently
escapes their convention-keyed laws and owes the architecture-reviewer's judgment,
while the tree-wide laws above hold whatever it is called. Escape: reviewed
tools/vertical-anatomy-allow.json (seeded, {package, law, path?, reason ≥ 40, reviewedOn}), closed BOTH ways — a stale entry reds. Two ramps, partitioned by the
finding's vintage: the directory keying's findings ramped for pre-0.9.5 installs until
0.10.0 (boundaries-vertical-anatomy-ramp-expiry, expired — hard for every real
install); the behavior keying's WIDENED findings ramped for pre-1.0.0 installs until
1.1.0 (boundaries-anatomy-widening-ramp-expiry, expired — hard for every install since
1.1.0). The allow-file shape problems and the zero-files-scanned floor are never ramped.
Anti-vacuity: add a ./client export to a package with no census entry → FAIL
naming it; make @app/api a runtime mobile dependency → FAIL "import type only"; make one
vertical depend on another → FAIL "verticals never import each other"; put a node:fs
import in a domain file → FAIL naming file and law; hollow out every vertical's src →
FAIL "scanned ZERO files".
A wall the worked example provides (2.0.0). The @app/notes row of
tools/exports-walls.json carries "demo": true: the package exists only where
init --with-demo planted it. On an install whose manifest records "demo": false the row
is dormant; where the manifest records the demo, or no choice at all (every install made
before 2.0.0), the row is live, and a missing package reds it as stale, naming eject and
the runbook. "demo" must be true when present, because any other value would park the
stale check, and a row cannot be both module- and demo-provided. Anti-vacuity: a
manifest recording the demo with the package gone → FAIL stale; "demo": "yes" → FAIL.
10. resilience — node tools/check-resilience.mjs
Section titled “10. resilience — node tools/check-resilience.mjs”The outbound-seam posture register, closed both ways. Every seam that calls OUT of
this system — the tRPC client, a server-side fetch, a supabase-js factory, an Edge
Function — has a retry/timeout/backoff posture whether anyone chose one or not: an
undeclared posture is "hang forever, retry never", decided by whichever runtime
default happens to apply. This step makes the posture REVIEWED DATA
(tools/resilience.json): an outbound-transport construction site
(createTRPCClient/httpBatchLink, a non-comment fetch(, new WebSocket(/new EventSource(, a supabase-js createClient in an @supabase/-importing file) with no
row reds; a row naming a missing file, or a file whose seam went away, reds the other
direction; and a row's CLAIMS must be true of its file — a non-null timeoutMs
requires AbortSignal.timeout in the file, retries > 0 requires a retry symbol,
backoffMs requires a backoff symbol and a non-zero retry count to pace. A declared
{timeoutMs: null, retries: 0} with a written why is LEGAL: the gate refuses
undeclared and false, never modest — the shipped register's eight rows include exactly
that shape where it is the reviewed choice (the tRPC client's no-auto-retry stance,
the delete-account function's ordering-not-retries correctness argument), and the one
non-null posture it ships (the rate limiter's 1s AbortSignal.timeout) is the
register agreeing with the code that already carried it. Posture constants are
provenance decision sites (SOURCE: on the line). Findings ramped for pre-1.0.0
installs until 1.1.0 (expired — hard for every install since 1.1.0). Not covered:
runtime behaviour (the unit/e2e lanes' half) and transports reached through a
dependency's own internals — stated per the Covers / Does NOT cover discipline.
11. observability — node tools/check-observability.mjs
Section titled “11. observability — node tools/check-observability.mjs”Vendor telemetry containment: the seam header's invariant
(packages/platform/observability/src/index.ts — "NO VENDOR SDK, on purpose. A crash
reporter or a tracing agent is a MODULE… and it attaches HERE, at the sink, behind the
redaction pass") made decidable. The gate scans every source file under apps/,
packages/ and supabase/functions/ (comments blanked; static import … from,
side-effect import, dynamic import() and require() all count; npm: and URL
specifiers normalize to their package coordinate) and reds any import matching the
telemetry-vendor detector outside the reviewed tools/observability.json sinks[]
register. The register is closed BOTH directions — an entry naming a missing file, and
an entry whose vendor import went away, both red — and each declared sink must
reference its declared redaction symbol in code (a symbol named only in a comment
satisfies nothing) with a reason of 40+ characters, because registering a sink is
licensing an off-device egress path for operational data. The detector is EXTEND-ONLY:
vendorSpecifiers may grow for a vendor the harness has never heard of, but the gate
reds when it is missing any entry of REQUIRED_VENDOR_FLOOR in
tools/lib/observability.mjs (owned, so detector improvements ride update; the
register is seeded, because its rows are the consumer's egress decisions).
Honest limits, stated per the enforcement-tiers discipline: the scan is static over
import specifiers. It cannot prove a vendor call's ARGUMENT flowed through
redactFields (no call graph — the sink file referencing the symbol is the bar), see a
vendor SDK reached through a transitive dependency, or notice telemetry sent by raw
fetch() to an ingest URL; test files are excluded. The redaction pass's BEHAVIOR is
the unit lane's (packages/platform/observability/src/index.test.ts). The pre-0.8.0
adoption ramp (a tree may have hand-wired a vendor transport before the gate existed)
expired on schedule at 0.9.0: the containment findings are hard reds on every vintage
now, and the '0.9.0 — the fifth alarm' section of docs/runbooks/harness-upgrade.md
carries the sweep.
Anti-vacuity: import @sentry/react-native in an unregistered file → FAIL naming
the file and both moves; register a sink whose file never references its redaction
symbol → FAIL quoting the ordering; narrow vendorSpecifiers below the shipped floor →
FAIL; a zero-file scan → FAIL rather than a vacuous green.
12. expo-policy — node tools/check-expo-policy.mjs
Section titled “12. expo-policy — node tools/check-expo-policy.mjs”Asserts over the RESOLVED Expo config (expo config --json --type public — dynamic
config executed, plugins expanded), the store/security surface the app actually
ships: store identity matches tools/identity.lock.json (bundle id == android
package == lock, scheme, EAS projectId — upgrade identity never drifts);
runtimeVersion stays exactly { policy: 'appVersion' }; engine floor (jsEngine
absent-or-hermes, newArchEnabled absent-or-true, useHermesV1 never false); no
arbitrary-loads ATS exceptions or cleartext traffic, extra.apiOrigin
https-or-loopback; android.permissions ↔ tools/expo-permissions.json and
resolved plugins ↔ tools/expo-plugins.json, bidirectional (unreviewed grant AND
stale entry both red); no secret-shaped keys in extra (it ships in the bundle);
the splash/adaptive-icon background hex equals the generated dark canvas token
(the anti-flash launch-frame lockstep); eas.json sanity (appVersionSource: "local", autoIncrement: false). CNG purity rides along: android//ios/ stay
untracked and ignored. Needs apps/mobile/node_modules — loud SKIP locally
without it, FAIL CLOSED in CI; unchanged inputs ride a content stamp.
The STORE-READINESS floor (0.1.2; SPLIT at 1.0.0 into the owned floor
tools/store-policy.json and the consumer's seeded tools/store-tunables.json —
export compliance, privacy-manifest rows, the account-deletion surface and icon
policy are the PROJECT's decisions, and the old single owned register made the
exact edit this gate's failure text demanded read as tampering; both halves are
write-guard-protected and malformed fails CLOSED — the checks can
never silently disarm): iOS *UsageDescription strings reviewed bidirectionally
against the ios[] list in tools/expo-permissions.json, never empty or
placeholder-shaped, and every plugin-implied key present (pre-prebuild honesty:
the implication map is keyed by PLUGIN — a bare npm dep is invisible until
prebuild, and Apple's post-submission validation is the backstop);
ITSAppUsesNonExemptEncryption explicitly DECLARED (undeclared re-asks the
export-compliance question on every build; true needs the reviewed
iosEncryption escape); ios.privacyManifests never REQUIRED (SDK packages
self-declare their own — absence gets a NOTE pointing at the store-metadata
sweep) but whatever is declared must use Apple's category vocabulary, real
reason codes, and the reviewed privacyAccessedApiTypes lockstep (both
directions red); App Tracking Transparency consistent BOTH ways (no tracking
SDK → an ATT string or NSPrivacyTracking claim reds; an SDK signal → all
three declarations must agree); Android targetSdk floored (declared value, or
the pinned per-Expo-SDK default — an unknown SDK major fails closed); icon
integrity via a zero-dependency PNG parse (tools/lib/png.mjs: marketing icon
1024×1024 and opaque, adaptive-icon layers 1024×1024, splash parses;
solid-color placeholder art NOTEs by default and reds when
icons.solidColorPlaceholder escalates to "error" — the pre-submission
step); and the ACCOUNT-DELETION closure (Apple 5.1.1(v)): an app shipping an
auth surface must register the deletion action (or route) AND back it with a
deployed Edge Function (supabase/functions/<edgeFunction>/index.ts on disk
and a [functions.<edgeFunction>] block in supabase/config.toml) — the
shipped session.deleteAccount action + the delete-account Edge Function is
the worked pattern, and the deletion's completeness is the RLS suite's live
sweep case, not this static check. The action surface reads the command
registry at apps/mobile/src/features/actions/registry.ts unless the optional
accountDeletion.registry (1.0.4) names another: a forward-slash .ts or
.tsx path under apps/mobile/src/ with no .. segment. The key is legal
only on the action surface, any other value fails the shape check, and a red
names the file the gate read. It moves where the gate looks, never whether it
checks. From 1.0.4 the stamp also hashes tools/store-tunables.json, both
mobile source roots and every Edge Function, so none of those reads can ride a
warm local stamp. The device lane closes the targetSdk half against the
GENERATED gradle project after prebuild.
Anti-vacuity: add a permission to app.config.ts without a reviewed
tools/expo-permissions.json entry (editor — the write guard also watches this
surface) → FAIL naming it; change the splash hex one nibble → FAIL the
lockstep; delete ITSAppUsesNonExemptEncryption → FAIL naming the declaration;
declare a usage string as "TODO" → FAIL; empty the deletion registry entry
while sign-in ships → FAIL citing 5.1.1(v); point accountDeletion.registry
at apps/mobile/src/routes.ts while sign-in ships → FAIL naming that file
(the factory's day0-empty-states lane runs this leg); give the key a ..
segment, or set it on a route surface → FAIL the shape check; after a green
local run, set actionId to an id the registry lacks, or add a secret-shaped
EXPO_PUBLIC_ name under apps/mobile/src → the next local run re-checks and
FAILS instead of honouring the stamp; swap the marketing icon for a 512×512 or
alpha-carrying PNG → FAIL with the measured dimensions.
13. native-deps — node tools/check-native-deps.mjs
Section titled “13. native-deps — node tools/check-native-deps.mjs”The native dependency floor for a CNG app: (1) expo install --check exits clean —
every Expo-managed package at the SDK-blessed version (an Expo package's MAJOR
tracks the SDK since 55, so drift is a native ABI risk, not a nit; the fix list
surfaces verbatim); (2) CNG purity (shared tools/lib/cng-purity.mjs); (3)
tools/expo-plugins.json integrity (parses; every entry has a reviewed reason);
(4) local config-plugin closure — every apps/mobile/plugins/* rewrites the
generated native project at prebuild time and must ship a same-basename test.
Deliberately hermetic: expo-doctor talks to the network and belongs to the CI
native lane, not here. Loud SKIP without the toolchain; FAIL CLOSED in CI.
Anti-vacuity: pin an expo-* package one patch off the SDK set → FAIL with
expo's own fix list; commit a file under apps/mobile/android/ → FAIL CNG purity.
14. version-sync — node tools/check-version-sync.mjs
Section titled “14. version-sync — node tools/check-version-sync.mjs”Root and apps/mobile move in LOCKSTEP AND the RESOLVED Expo config equals the
app.config.ts derivation formulas (ios.buildNumber = version; android.versionCode
= maj·1e6 + min·1e3 + pat), so replacing the derivation with literals reds on the
next bump, not at store review. apps/web is DELIBERATELY excluded from that lockstep
— it deploys on Vercel's push-to-main cadence (~1000× the store cadence), so coupling
it would force a web hotfix to cut a store submission or red the gate; its independence
is bounded instead by apps/web's MAJOR == @app/api's MAJOR (the tRPC skew middleware
rejects an x-client-version major mismatch, so a web release crossing a major the router
has not is a breaking client). runtimeVersion.policy stays appVersion; Node majors
agree across .nvmrc/.node-version/engines AND eas.json pins the same Node plus
the EXACT pnpm from packageManager (EAS ignores package.json's packageManager — the
eas.json fields are the only pin a cloud build obeys); expo / expo-router /
react-native / babel-preset-expo EXACT-pinned in the catalog; exactly one
zod instance resolves workspace-wide, and exactly one react resolves WITHIN each
surface's graph (web and mobile pin React independently — separate bundles — so two
versions across surfaces is correct; two within one bundle break the hooks dispatcher).
Stamped for warm runs; CI always re-runs.
The Claude Code floor (0.6.0). tools/cc-floor.json holds the tool this harness RUNS
INSIDE to a cited minimum — the one dependency whose compromise compromises every other control,
and the only one that had no floor. The scalar is DERIVED: this step recomputes it as the newest
patched among the advisories cited in the file and reds if the two disagree in either
direction, required.setBy must match the evidence both ways, and every row must carry an
openable github.com/advisories/ URL plus a sentence on what it does to this harness. The edit
worth catching is not lowering the number — it is deleting an advisory row, after which the
derived floor falls with it and nothing else would notice. Clockless and offline by design;
"has anyone re-queried the advisory database lately" is the scheduled floor-review job's
question, on a 45-day window.
The framework security floor (0.5.0). Every package in tools/framework-floor.json
resolves at or above the patched release for its major line — read from the RESOLVED
pnpm-lock.yaml, not the catalog string, so a transitive resolution below the floor reds
too. The floor is keyed by MAJOR LINE because that is the shape upstream security releases
take, and a single flat minimum would red a consumer legitimately sitting on an older but
patched line; a major with no row is judged unsupported, derived rather than listed twice.
Why this is not osv-scan, and why it is not pnpm audit. The osv PR lane is
diff-aware and fails only on NEWLY introduced vulnerabilities, so a pin that was already
in the tree when the advisory landed never reds there — which is the exact state this
harness shipped in for two releases. The osv full-tree lane is schedule-and-network bound,
and a live database makes a verdict that changes with the calendar, which is the same
reason pnpm audit is not in this chain at all. This half is CLOCKLESS and OFFLINE: same
lockfile, same floor, same verdict on any machine on any day. Whether the review is still
FRESH is the one time-dependent question, and it rides the scheduled floor-review job in
osv-scan.yml (reviewedUntil, schedule + workflow_dispatch only — never a PR, because a
lapsed review must not block a contributor's unrelated patch). The same job reviews the
surface register since 1.1.0, in a step of its own that a red floor step cannot hide (see
"Surface deferral" under the CI lanes).
The review WINDOW is judged here, though (0.6.0), because it is not a calendar question.
reviewedUntil - reviewedOn must be at most 31 days — one calendar month, matching the
roughly monthly cadence upstream now publishes security releases on. Before this the window
was the reviewer's free text, and the file shipped with 92 days: a review that read as live
across about three releases. Worse than long, it was an off switch reachable from inside
the file it protects — a single distant reviewedUntil retires the freshness control, and
the only check that would object is the scheduled one that edit just disarmed. So the
reviewer now supplies WHEN THEY LOOKED and the harness supplies HOW LONG THAT IS WORTH; the
arithmetic is over two committed dates, which is why it can red at the moment the window is
written rather than a quarter later. The floor is harness-OWNED,
so update carries a new advisory to existing installs, and gate-integrity sha-pins it,
so lowering a minimum without a reviewed commit reds at step 2.
The iOS build-toolchain floor (0.7.0). Apple has required uploads to build against
Xcode 26 / iOS 26 SDK or later since 2026-04-28 — a FIXED floor, not a moving
"current SDK" requirement, and macOS is excluded (facts: design/CONFORMANCE-FACTS.md
§3). The reviewed floor lives in tools/store-policy.json (iosToolchain: xcodeFloor,
inForceSince, source, why — the record the enforcement-tiers version-sync Target
discharges through), and the check is static, offline and clockless: it resolves the
production profile's ios.image through the eas.json extends chain and matches
-xcode-(\d+) against the floor. The red directions, and why each one reds: an
ABSENT image reds because no pin means nothing can red — a too-old toolchain would
burn a whole build-and-submit cycle with no gate output; auto, latest, sdk-NN
and any name without a literal -xcode-<major> red as UNVERIFIABLE, because an alias
moves under the build and a check that passes an unpinned profile passes every profile;
a matched major BELOW the floor reds naming both numbers, the in-force date and the
source; and a store-policy.json carrying no iosToolchain record while eas.json
exists reds, because a floor nobody reviewed is not a floor (the file is harness-owned —
update restores it). The template pins the CONCRETE image macos-tahoe-26.5-xcode-26.6
(the sdk-57 alias's resolution on 2026-08-08), never the alias. Ramped for pre-0.7.0
installs (their seeded eas.json predates the pin; the seededSourceFixes channel
carries it) with dated NOTEs until 0.8.0. Honest limit: no lane runs an EAS build, so an
image RETIRED upstream is invisible here — the pin's freshness rides the consumer's next
build and the floor's rides review.
The end-of-life census (0.9.9). The floor above asks whether a pin is PATCHED. Nothing
asked whether its vendor still supports it at all, and the two failures differ in kind:
a package below a floor has a fix waiting for it, and a package whose vendor has walked away
has none, ever. This can ride the chain for exactly one reason — pnpm records
deprecated: <message> on a lockfile package entry, copied from the npm registry at RESOLVE
time, so the vendor's own statement is a committed third-party artefact in the tree and no
live endpoint is consulted. A gate that asked the registry directly would turn an untouched
commit red overnight, which the hermeticity rule forbids and this repo has already paid for
once. tools/eol.json is judged against that census in both directions: a deprecated
resolution with no row reds, and a row matching nothing the lockfile deprecates reds too,
because an acceptance that outlives its subject makes the register read as reviewed when it
is merely old.
scope is COMPUTED, not believed, and that is the part worth copying. It is the field a
reviewer is most likely to write from memory — "it's only a test dependency" — and the one
that decides what the acceptance costs, so the gate derives the production dependency
closure from the lockfile and compares. Its first run disproved the note that was about to
be written here: five of the six deprecated packages a fresh scaffold resolves are
development-only, and uuid@7 is not — it arrives through expo →
@expo/config-plugins → xcode. A production-scope row therefore carries a
removalTarget release (ASD PA-13's verb is removed, so production-closure code is a debt,
and a debt with no date is a permanent acceptance wearing a plan's clothes); arrival is
judged against the installed harness version, never a calendar.
The trap that nearly shipped, recorded because the fix is not obvious. pnpm records a
RESOLVED PEER as an ordinary snapshot edge, with no marker separating I depend on this from
my consumer must supply this. expo-router peer-depends on @testing-library/react-native,
so a naive walk dragged jest 29 — and the deprecated glob@7 and inflight inside it — into
the "production" closure of a tree where the testing library is a devDependency. A control
that reds a correct tree is a control somebody deletes. The correction is narrow on purpose:
a peer edge is skipped only when the peer is satisfied by a dev-declared package, because
under autoInstallPeers a peer can have no declarer at all and dropping every peer edge
would quietly shrink the closure instead — the same defect pointed the safer-looking way.
The vendor support floor is REVIEWED DATA, and the research is why. products[] holds
the version lines each vendor still supports, and every row must carry the vendor's own
words plus the URL they were read from — a supported-set list with no quote behind it is this
repository's opinion about somebody else's product. It is not derived because there is
nothing to derive: Expo publishes no per-SDK end-of-life date in any form, its versions
API carries zero date fields across all 51 SDK entries, endoflife.date has no Expo product,
and its written policy defines unsupported operationally as removed from the
documentation. Computing "released June 2026 + approximately one year" would put this
file's arithmetic behind a vendor's commitment, so the register carries the set and the
quote and refuses the date. React Native is the exception that proves the rule and the
strongest row in the file: it publishes a numbered commitment — "we're committed to maintain
the latest 3 minor series" — and a dated table naming a support tier per minor series.
The upgrade cost, paid in the same diff. tools/eol.json is seeded and deliberately
planted, so an existing install receives the harness's own rows on its next update — and a
consumer's lockfile is a superset of the harness's, so those rows will not cover everything
their tree deprecates and may name something it no longer does. Both directions would red a
tree whose code did not change. Withholding the file was the obvious move and the wrong one:
the ramp on its absence expires at 0.10.0, so the install would trade a dated NOTE for a hard
red demanding a file update had refused to give it. So every finding this section
produces rides one ramp until 0.10.0, scoped to this section alone — the rest of
version-sync stays hard, an upgrading consumer gets a dated list of exactly what to
disposition, and a fresh scaffold never sees the ramp at all because its baseVersion is
already 0.9.9.
Honest limits, three. The census is only as current as the last RESOLUTION, since the
flag arrives at resolve time — hence the 31-day review window here and the lapse check in the
scheduled floor-review job. "In the production dependency closure" is not "in the
shipped bundle": bundling follows imports and a lockfile records dependencies, so a
production grade is a ceiling on exposure, not a measurement of it. And the register is an
ESCAPE LIST — appending a row is the cheap way past a red, which is why it is write-guarded,
CODEOWNERS-reviewed, and has a review window that forces a re-read.
Anti-vacuity: drift apps/mobile/package.json from root → FAIL listing the
disagreeing versions; set apps/web's major off @app/api's → FAIL the skew-contract
check; resolve two react versions inside one surface → FAIL naming the project;
replace the versionCode derivation with a literal → FAIL on the next version bump;
revert the next catalog pin below its floor → FAIL naming the resolved version, the
floor and the HIGH advisory ids; move a version onto a major line the floor has no row
for → FAIL naming the supported lines; a present pnpm-lock.yaml the scanner matches
ZERO packages in → FAIL, because that is the shape a silently vacuous floor takes;
push reviewedUntil more than 31 days past reviewedOn → FAIL naming the window, the
maximum, and roughly how many upstream releases that many days hides; drop the
production ios.image (or alias it to auto/latest/sdk-NN, or pin
-xcode-25.x) → FAIL per the toolchain directions above; delete the iosToolchain
record while eas.json exists → FAIL naming the missing review; add a vendor-deprecated
package with no tools/eol.json row → FAIL quoting the registry's own message and naming
what pulls it in; keep a row whose package the lockfile no longer deprecates → FAIL; record
a production-closure deprecation as development (or a dev-only one as production) →
FAIL, because scope is recomputed from the lockfile rather than believed; drop a
production row's removalTarget, or reach it → FAIL; empty a row's reason → FAIL; pin a
product onto a line its vendor no longer supports → FAIL quoting the vendor and the URL;
strip a products[] row's policy quote or source → FAIL; and delete tools/eol.json
outright on an installed tree → FAIL, because absence must never read as nothing-to-check.
15. prompts — node tools/check-prompts-lock.mjs
Section titled “15. prompts — node tools/check-prompts-lock.mjs”Two surfaces, locked for the same reason and judged with different strictness.
LLM prompts. Every file under packages/*/prompts/ / apps/*/prompts/ is
sha256-locked in tools/prompts.lock.json and versioned in its filename
(extract.v1.md). Pass by creating a NEW .vN file, re-running the eval, then
deliberately updating the lock (write-guard-protected — a human act).
The agent surface (0.2.0). .claude/{agents,commands,skills} — sha256 per file in
tools/agents.lock.json, plus each agent's pinned model id. This is the most privileged
prose in the repository: which reviewers exist, what they may touch, what a slash command
does, what the skills prescribe. Before the lock, nothing in the chain noticed it
changing — the docs-sync roster check reads reviewer FRONTMATTER (name, model, tools)
and, of a reviewer's body, only whether it demands the verdict line and (since 1.1.0)
whether it closes on that demand and states its Severities: and Blocking: lines. It
never judges the rest of the body, which is where the instructions actually are. An agent could soften security-reviewer.md, widen a skill or
repoint a command and stay green.
The model id is recorded beside the hash because they answer different questions: a hash
proves the file did not change, and a roster silently repointed from a frontier model to a
cheap one leaves every byte identical. An agent's harnessFallbackModels list (1.1.0) is
covered by the file hash, and models keeps the pin alone. Neither says which model a
verdict actually RAN on: that is recorded in the reviewer ledger and judged by
reviewer-verdicts.
The asymmetry is the design. "Not in the lock" is RAMPED — an install predating the
lock has files nobody has covered, and ambushing it on upgrade would break the harness's
own promise that projects grow into gates. "In the lock and the hash moved" is UNRAMPED at
every vintage: that is not a vintage gap, it is an edit to instructions somebody already
reviewed. In practice no install sees the ramp at all, because the installer writes the
lock from the install's own current files — at init always, at update only when
there is no lock yet. An update never REGENERATES one: doing so would launder every edit
made since, which is the act the lock exists to make visible. It re-records only the
entries of the agent-surface files it wrote itself, hash and model pin together. It does
not rewrite a copy you edited, so that entry does not move. update --force discards your
edit, and the entry then moves with the file it wrote.
Three layers, because one env var is not a control. tools/gen-agents-lock.mjs
refuses --write without HARNESS_ALLOW_SELF_EDIT=1; the bash-guard denies invoking any
gen-*lock*.mjs … --write by name shape; the lock file and all four .claude directories
are write-guard-protected. The distinction from the other three generators is worth
keeping straight: gen-action-inventory, gen-event-catalog and gen-query-shapes derive
their output from something else the gates check, so running them launders nothing. This
one's output is a hash OF the files being checked, so running it after an edit is exactly
how the edit disappears.
Anti-vacuity: edit one word in packages/eval/prompts/extract.v1.md → FAIL hash
mismatch; append one line to .claude/agents/security-reviewer.md → FAIL, unramped, at
every vintage; delete a locked reviewer → FAIL (removing an agent is a reviewed act, not a
cleanup); node tools/gen-agents-lock.mjs --write without the env var → the generator
refuses, and the bash-guard denies the invocation before it runs.
16. licenses — node tools/check-licenses.mjs
Section titled “16. licenses — node tools/check-licenses.mjs”The production npm dependency tree stays inside a permissive allowlist
(MIT/ISC/Apache-2.0/BSD/MPL-2.0 etc.); exceptions are reviewable data in
tools/license-exceptions.json. The Stop gate itself refuses a copyleft/unknown
dependency the moment an agent adds one.
Anti-vacuity: pnpm add any GPL-3.0-only package as a prod dep → FAIL naming it.
17. schema-rls — node tools/check-rls-manifest.mjs
Section titled “17. schema-rls — node tools/check-rls-manifest.mjs”Static <100ms cross-reference over the SQL, not substring vibes. Every table declared
in supabase/schemas/*.sql has ENABLE + FORCE ROW LEVEL SECURITY and per-operation
policies in the applied supabase/migrations/*.sql, or an entry in the human-reviewed
tools/rls-exempt.json. Policy predicates must be real (no USING (true)) and resolve
identity through the initPlan sub-select (select auth.uid()), not a per-row call. Every
migration-created table must be declared in a schema; every non-exempt table must appear
in BOTH runtime registries — ISOLATION_TARGETS (tests/rls/db-context.ts) and the pgTAP
rls_targets (supabase/tests/rls_structure.test.sql), on the same owner column — and
that owner column must be the LEADING column of some migration-declared index (an inline
PRIMARY KEY counts). Existence proof only — the runtime twins prove isolation and the
access path.
Four checks joined in 0.2.0, each closing a hole the gate provably had: the RLS
negation set (a later DISABLE ROW LEVEL SECURITY / NO FORCE / DISABLE TRIGGER
reds naming the migration — unramped, because no legitimate install ever turned RLS off);
helper-body resolution (a predicate calling a locally-defined SQL helper is judged on
the helper's inlined body, so relocating auth.uid() into a function no longer vacates
the initPlan rule); the correlated-subquery ban (a sub-select with a FROM is a
per-row SubPlan, not a hoisted InitPlan — ramped 0.2.0); and SECURITY DEFINER
discipline (allowlisted with a reason in tools/security-definer-allow.json,
SET search_path = '', no identity-shaped parameter — ramped 0.2.0; the EXECUTE
half below is not). On the EXECUTE surface the rule is not "no wide grant" but
prove the default was undone: PostgreSQL grants EXECUTE to PUBLIC on every
new function and Supabase's default privileges additionally grant anon, so a
migration that names no grants at all still ships an anon-callable definer function
— and a gate that only inspects GRANT statements reads it as clean. Every definer
function must therefore show a REVOKE … FROM PUBLIC and FROM anon; EXECUTE
to authenticated is legal only for an allowlisted function, because PostgREST
switches to the JWT's role before calling and there is no other way a client-callable
RPC can exist. Unramped, by the same reasoning as the negation set: the shipped
scaffold has no definer functions, so ramping would protect only a tree that added
one.
The POLICY → GRANT closure (0.6.0). Table privileges are checked BEFORE row
security, so a policy naming a role that holds no privilege on the table is
unreachable code that reads in review as a granted one — the statement raises
42501 (PostgREST: HTTP 403) before any policy is consulted. This gate has parsed
grants since 0.2.0 and consumed only the FUNCTION half, above; the TABLE half was dead
output, so a table with ENABLE + FORCE + four policies + both registries + an owner
index and no GRANT statement anywhere was green. It is invisible because
Supabase's default privileges hand anon/authenticated/service_role their
privileges on every new table in public — and those defaults stop applying to
projects created on or after 2026-10-30, which makes the same migration file work in
the project it was written against and 403 in the next one it is replayed into. So: for
every CREATE POLICY, for every operation it names (FOR ALL expands to four), for
every role in its TO clause, the GRANT/REVOKE history folded in statement order
must leave that role holding that privilege. Three carve-outs, each load-bearing — a
predicate that is literally false needs no grant (that is how the tenancy spine says
"never"), a RESTRICTIVE policy only subtracts rows and so carries no reachability
claim, and a policy with no TO clause names no role to close over. The reverse
direction is not asserted for service_role: GRANT SELECT, DELETE ON TABLE public.orgs TO service_role is a legitimate ADR'd grant with no policy behind it, because
service_role bypasses row security. (For anon and authenticated it is, from 1.1.0:
the grant bound below.) Ramped 0.6.0 → 0.7.0, unlike the negation set:
here there IS a legacy population, because on a pre-flip project the missing grant
genuinely works.
SOURCE: https://supabase.com/docs/guides/api (Data API grants and exposed schemas)
Anti-vacuity: declare a table with no migration → FAIL (no ENABLE, no FORCE, missing
policies); USING (true) → FAIL naming the vacuous predicate; a per-row auth.uid() →
FAIL "per row"; drop the owner index → FAIL naming the missing leading column; add a table
to one registry but not the other → FAIL naming the gap; append a DISABLE ROW LEVEL SECURITY to a fresh migration → FAIL naming the file (selftest Canary 22); delete the
notes GRANT line from the shipped tree (since 1.1.0 the one in the three-role revoke
migration, which re-grants after its REVOKE ALL) → FAIL naming the policy, the
date and the exact statement that discharges it (which is what makes the shipped-tree
green non-vacuous, as opposed to a fixture merely shaped like it).
The MFA RAIL closure (0.9.9). A closure over the shape of an aal2 rail, never over
its coverage — which tables warrant a second factor is a product decision, and a
low-sensitivity lookup table blocked at aal1 is a support ticket rather than a control.
Nothing here demands that any table carry the rail; what it demands is that a tree which
has one has the version that works.
That distinction earns a gate because the vendor-documented policy is broken in two
directions and the second is silent. As published it reads auth.mfa_factors as the
invoker, on which authenticated holds no privilege, so every request 403s with 42501 —
loud, and therefore harmless. "Fixed" with a GRANT SELECT ON auth.mfa_factors TO authenticated, that table still has RLS enabled and no policy, so it is default-deny:
count(id) is 0 for enrolled users too, the CASE falls through to
array['aal1','aal2'], and the policy accepts aal1. Verified against a live stack,
not reasoned about — installing the vendor shape let an enrolled user at aal1 read every
row and INSERT, UPDATE and DELETE freely.
So a policy whose predicate calls the MFA helper must be AS RESTRICTIVE (it ANDs and can
only subtract; a permissive one ORs, so a second permissive policy re-opens everything it
closed), must carry no FOR clause (Supabase's own second documentation page writes
this policy for update, gating writes while leaving SELECT wide open), and must carry
both USING and WITH CHECK (USING alone governs visibility, so an aal1 session
could still INSERT rows it cannot see). No migration may GRANT anything on
auth.mfa_factors; private.mfa_is_required() is SECURITY DEFINER precisely so that no
grant is needed. And supabase/tests/mfa_aal2.test.sql must exist, because nothing static
can show that the predicate binds — the published policy is textually similar and admits
aal1 for an enrolled user, which is the only case that tells them apart. The closure
runs the other way too: helpers defined with no policy using them is a rail nothing
references, which enforces nothing while reading, to the next person, as MFA already in
place. ADR: docs/adr/20260812-mfa-aal2.md.
The same release corrected a hole this rail would otherwise have opened: a RESTRICTIVE
policy no longer satisfies the per-operation requirement. A policy that can only subtract
grants nothing, so counting one would let a table whose permissive SELECT policy was
deleted stay green on the strength of a policy that denies. supabase/tests/rls_structure.test.sql
carries the same correction on the runtime side.
Anti-vacuity (the rail): add GRANT SELECT ON auth.mfa_factors → FAIL naming it as the
fail-open; make the MFA policy AS PERMISSIVE → FAIL; make it FOR UPDATE → FAIL naming
the operation; drop its WITH CHECK → FAIL; delete supabase/tests/mfa_aal2.test.sql while
the policy stands → FAIL; define the helpers with no policy using them → FAIL; make a
table's only SELECT policy RESTRICTIVE → FAIL "no PERMISSIVE policy FOR SELECT". The
executed twin is the pgTAP suite itself, run against a live stack both ways: green on
the shipped rail, red on the vendor policy with the two rows an enrolled aal1 session
should never have seen. Since 1.1.0 the suite runs the rail on a table it builds inside its
own transaction, public.pgtap_fixture, with the example's policy text renamed, so the
proof survives a project deleting the example; the example's own copy is held by the static
rules above.
The history fold (1.1.0). The shared parser (tools/lib/sql-parse.mjs) read neither
DROP TABLE nor ALTER POLICY, and this gate parsed DROP POLICY and threw the list
away. So a policy rewritten by ALTER POLICY … USING (true) was judged on its CREATE text
and passed; a dropped policy still covered its operation; and a table dropped and
re-created kept its old policies, grant and owner index, while one created and dropped
still redded as undeclared. The gate now reads the parser's one live-policy fold,
parseLivePolicies() — ALTER POLICY replaces the TO, USING and WITH CHECK clauses
it names, RENAME TO renames, DROP POLICY removes — and views in which DROP TABLE
removes the table with everything on it, its created set included (parseCreatedTables(),
in place of its own regex). A DROP TABLE without IF EXISTS, or an ALTER POLICY, whose
target no earlier migration left in place is a finding; DROP TABLE IF EXISTS on an
unknown table is a no-op, and a table made outside the migrations and dropped by an
applied one is acknowledged in tools/rls-exempt.json. A finding only the folded history
produces is a dated NOTE below baseVersion 1.1.0, through
rampNote('schema-rls', '1.1.0', 'the SQL history fold (DROP TABLE, ALTER POLICY and DROP POLICY)', { until: '1.2.0' }),
expired at 2.0.0, so hard for every install since;
tools/lib/sql-fold-ramp.mjs tells it apart by replaying the gate over the pre-fold history,
so a finding both readings produce stays hard at every vintage. Anti-vacuity:
ALTER POLICY thing_select_own ON public.thing USING (true) → FAIL "has a vacuous USING
(true)"; DROP TABLE public.thing and a bare re-create with ENABLE and FORCE → FAIL on all
four operations and the owner index; DROP TABLE public.ghost → FAIL unresolved; and a
table created then dropped → GREEN.
The grant bound, the three-role revoke doctrine and the generated grant assertions
(1.1.0). The closure above folds explicit statements only and starts empty, so it never
saw what Supabase's default privileges hand a role on a new public table — ALL, for
anon, authenticated and service_role — and the 1.0.2 escape was exactly a grant wider
than its policies, found by a runtime assertion after the local CLI moved, never by this
gate. tools/lib/table-grants.mjs now walks the history a second time in two halves: a
default-seeded fold that starts each public table with every table privilege of the
configured major for the three roles (eight on PostgreSQL 17, MAINTAIN among them; seven
below it, read from [db].major_version), and an explicit fold that starts it with
none. Three findings come of it, one ramp for all three:
- The bound. Every privilege
anonorauthenticatedholds in the default-seeded fold, directly, throughPUBLICor on columns, must be admitted by a PERMISSIVE policy for that operation (orALL) naming the role,publicor no role, whose predicate is not literallyfalse.TRUNCATE,REFERENCES,TRIGGERandMAINTAINare never admitted — row security does not apply to them — so they are revoked, or listed with a reason in the tolerated-absenttools/grant-bound-allow.jsonas{table, role, privilege, reason}. A row naming a privilege the role does not hold, or one a policy already admits, is a finding. - The doctrine (
docs/adr/20260930-three-role-revoke.md). For each of the three roles, the default-seeded fold holds nothing the explicit fold does not: the table revoked the default from every role, and whatever a role keeps was granted explicitly. That keepsservice_roleto ADR'd grants without judging them against policies, and it is what makes a committed expectation independent of whether the platform applied its default. - The generated file.
supabase/tests/rls_grants.generated.test.sqlmust equal whattools/gen-grant-assertions.mjsrenders from the explicit fold — oneis_emptyoverhas_table_privilege(…) IS DISTINCT FROM expectedfor every table a migration creates, each of the three roles and every table privilege, with its ownplan(), rows sorted by code unit. Checked while the doctrine holds; until then the generator refuses, naming the tables and printing the statements that clear them.
Every finding prints the exact REVOKE and GRANT that clear it. The parse is normalised
where an upper bound would fail OPEN: … TO authenticated WITH GRANT OPTION is a grant to
authenticated (not to a role of that name), GRANTED BY, CASCADE and GROUP are read
off, REVOKE GRANT OPTION FOR leaves the privilege, a column list and several tables in one
statement are read, a schema-wide statement reaches only the tables that exist at that
point, and a GRANT or REVOKE the fold cannot read is itself a finding. Out of scope:
sequences, custom roles, views and ALTER DEFAULT PRIVILEGES (the bound keeps assuming the
platform default). The hand-written assertions in rls_structure.test.sql stay: the
service_role allowlist and the seat and quota shapes state intent. Ramped through
rampNote('schema-rls', '1.1.0', 'the grant bound, the three-role revoke doctrine and the generated grant assertions', { until: '1.2.0' }):
an install seeded before 1.1.0 has tables that predate the doctrine (profiles and notes on
every vintage), and update withholds the migration and the generated file. That ramp
expired at 2.0.0, so hard for every install since: the runbook's 1.1.0 section is the sweep.
SOURCE: https://www.postgresql.org/docs/17/ddl-priv.html
Anti-vacuity: the shipped tree without
supabase/migrations/20260920000000_authenticated_write_revoke.sql → FAIL on all seven
read-only tables (it printed OK before), and without
supabase/migrations/20260930000000_three_role_revoke.sql → FAIL on profiles and notes
only; GRANT ALL, a grant behind a deny-all policy, WITH GRANT OPTION, a schema-wide
REVOKE before a later CREATE TABLE, a table that never revokes from service_role, a
stale allow row and a stale or missing generated file → FAIL; the generated file passes on a
live stack and fails on an injected GRANT TRUNCATE ON public.notes TO authenticated.
The MFA rail with no policy on it (2.0.0). A default scaffold defines the step-up
helpers (mfa_satisfied()) but no table's policy calls them yet: the worked example's was
the only one. Helpers no policy calls fail the step unless tools/rls-exempt.json carries
a reviewed mfaRailUnused row (reason of at least 40 characters, reviewedOn as
YYYY-MM-DD); with the row the step prints a NOTE naming it. The row goes stale, and reds,
the moment a policy uses the rail. Anti-vacuity: a short reason or a missing date → FAIL;
the row beside a policy that calls mfa_satisfied() → FAIL stale.
18. tenancy — node tools/check-tenancy.mjs
Section titled “18. tenancy — node tools/check-tenancy.mjs”The multi-tenant contract as reviewed data. schema-rls proves a predicate is REAL;
this proves it scopes by TENANT — org_id = (SELECT auth.uid()) (a tenant column
compared to a user id) passes every schema-rls rule and isolates nothing, which is
the exact hole this gate exists to close. The contract is tools/tenancy.json:
predicateForms is a CLOSED set of reviewed predicate shapes (owned, hash-pinned
data — the definition of correct, never an escape hatch), and every policy on every
tenant table (any table carrying tenantColumn) must carry one of them in EVERY
top-level OR arm after normalization — an AND inside an arm can only narrow, but
<scope> OR owner_id = (SELECT auth.uid()) is as open as its weakest arm. Failures
print the exact normalized predicate, so admitting a new reviewed form is a
copy-paste CODEOWNERS diff. On top of the form match: the correlated-argument
ban ((SELECT private.member_rank(org_id)) >= 30 is wrapped in (SELECT and
passes every wrapper check, but passing a column of the row under test makes it a
per-row SubPlan that re-enters the membership table's own policies); a rank floor
must be one of the configured roles values; tenant keys must be NOT NULL FKs to
the org table (folded across the whole history, so the expand→contract adoption
path — nullable ADD COLUMN, later SET NOT NULL — lands green); every UNIQUE/PK
on a tenant table must include the tenant column (partition-ready; per-constraint
escapes in uniqueWithoutTenantColumn with reasons) and each tenant table needs a
no-WHEN BEFORE UPDATE freeze trigger; the helpers must be zero-argument STABLE
SECURITY INVOKER with search_path = '' reading the membership table; the
membership table itself is held to the opposite shape — self-only SELECT, deny-all
writes to authenticated, no helper calls in a SELECT policy (the recursion SMELL
TEST; the executable recursion probe in supabase/tests is the proof — it asserts the
reads LIVE rather than naming a SQLSTATE, because with search_path = '' pinned the
failure arrives as 54001 stack-depth, not the 42P17 the docs lead you to expect); and
nonPublicSchemas must stay out of [api].schemas.
Two structural rules exist because the alternative fails silently. First, the
rpc writer role: every table ships FORCE ROW LEVEL SECURITY, so a
SECURITY DEFINER function's writes are policy-checked against the role that OWNS
it — the owner is not exempt. With seat writes denied to authenticated (as they
must be — a self-keyed INSERT policy is a self-service seat grant), a database in
which no other role holds a write policy is one where no seat can ever be created:
the first create_org fails 42501 and supabase db reset dies at seed. The
reviewed rpcWriterRole is that writer, and its policies are judged by the same
closed form set. Admitting it is not sufficient, though: a rank-scoped write policy
TO that role calls the rank helper, which is SECURITY INVOKER and therefore reads
the seat table as the rpc role. Give the role no SELECT policy and the read hits
RLS default-deny, the helper returns an empty map, every rank comparison is false,
and the write matches zero rows and reports success — every promotion in
production looks fine and changes nothing. So the gate requires the pair: any
helper-bearing write policy TO the rpc role obliges a self-only SELECT policy for
that role on the seat table (self-only because auth.uid() is GUC-derived and
role-switch-independent, and because a helper-bearing SELECT policy here would be
re-entered by the helper that called it).
Second, the org table is judged explicitly, with its own primary key
substituted as the scope column. It carries no tenant column, so column-driven
discovery never reaches the root of the whole model — USING (created_by = (SELECT auth.uid()) OR name IS NOT NULL) would otherwise pass every static gate in the repo
while publishing every org row to every signed-in user.
A predicate form may be narrowed to specific tables (which obliges a reason): that
is how the two writes performed by someone who is not yet a member — creating an
org, redeeming an invitation — stay reviewable instead of becoming a general licence
every tenant table can claim. The 0.2.0 ramp covers ADOPTION only (a pre-0.2.0
install with no tenant column NOTEs instead of failing); once any table carries the
tenant column, findings are hard reds regardless of manifest vintage.
dualScopedTables is the one escape in the harness with a deadline, and it exists
because an install that already holds production rows cannot become org-scoped in a
single migration: org_id must arrive NULLable on populated tables, be backfilled out
of band, and only then take NOT NULL — with the old owner-scoped policies alive
beside the new ones throughout, since permissive policies OR and dropping the old set
early blanks the product. An entry licenses exactly that state on exactly the named
table (the arm <ownerColumn> = (SELECT auth.uid()) becomes legal for that table, the
tenant key may be NULLable, and its pre-tenancy tenant-blind uniques stand) and carries
an until harness version. That version is compared against the manifest's
harnessVersion, not baseVersion: baseVersion moves only when a human
graduates a ramp, so a deadline measured against it is one the escape's own author
controls. On the happy path the entry never reaches its deadline — it reds the moment
the tenant key becomes NOT NULL, because from there it is pure widening.
docs/runbooks/tenancy-adoption.md is the procedure.
The audit trail (0.2.0). The same gate owns audit.events, and judges it by
different rules than every other tenant table because for this one the ordinary rules
are wrong rather than merely inconvenient: its tenant key must not be a foreign
key (an ON DELETE CASCADE makes deleting an org delete the record of what was done
inside it — the evidence destroyed by the act most likely to need investigating), and
it carries no freeze trigger, because it refuses UPDATE outright, which is strictly
stronger than freezing one column. It is correspondingly listed in
tools/rls-exempt.json, which moves its judgment here rather than removing it: the
per-operation policy manifest schema-rls enforces is the opposite of what an
append-only table needs, since the absence of an UPDATE/DELETE policy is itself
layer 1 of four.
What is required instead: no update/delete policy and no client grant (layers 1–2); a
BEFORE UPDATE OR DELETE row trigger (layer 3 — the only layer that binds a role
holding BYPASSRLS, and postgres on Supabase holds it); a BEFORE TRUNCATE
statement trigger on the parent and on every partition a migration creates,
because PostgreSQL clones row triggers to partitions (including ones created later)
but never TRUNCATE triggers, so a parent-only guard leaves the trail emptiable one
month at a time; an actor derived inside the writer rather than from a column
DEFAULT (a default is applied only when the writer omits the column, so it records
whoever the writer says they are); a writer and a reader that are separate roles;
and — the closure that makes all of the above non-vacuous — an AFTER INSERT OR UPDATE OR DELETE ... FOR EACH ROW trigger with no WHEN clause on every org-scoped
table. Without that last rule every other audit check is satisfiable by a beautifully
built trail that records nothing.
Value capture is closed both ways against tools/audit-columns.json and
tools/pii-columns.json: an undeclared capture is unreviewed, a declared capture with
no trigger argument is stale, and a capture of anything on the PII list is refused.
The deny list is itself checked against the live schema, so an entry naming a renamed
column reds instead of silently protecting nothing.
The pairing rule generalized in the same change. It previously covered only the rpc
writer; it now covers every non-authenticated role in a helper-bearing policy,
because the audit reader has the identical failure with the opposite consequence — a
write that silently changes nothing versus a read that silently returns nothing, which
an admin reads as "no activity" rather than as a fault.
Adoption ramps, correctness does not. A pre-0.2.0 install has no audit schema (the
migration is seedOnInitOnly, so update never plants it) and gets a NOTE. The moment
the table exists, every rule above is a hard red regardless of manifest vintage.
Anti-vacuity: change a notes policy USING to org_id = (SELECT auth.uid()) →
FAIL printing the normalized predicate; append OR owner_id = (SELECT auth.uid())
→ FAIL naming the scope-free OR arm; call private.member_rank(org_id) → FAIL
naming the correlated argument; delete the rpc role's self-only SELECT policy while
leaving its rank-scoped writes → FAIL naming the zero-rows-and-succeeds failure mode;
rewrite the orgs SELECT policy to created_by = (SELECT auth.uid()) OR name IS NOT NULL → FAIL naming the scope-free OR arm; an empty predicateForms, malformed JSON,
a missing section, a table-narrowed form with no reason, or a reasonless/stale escape
entry → FAIL (never fail-open); a dualScopedTables entry whose until the install
has passed, whose tenant key is already NOT NULL, whose ownerColumn does not
exist, or that has no .harness/manifest.json to measure its deadline against → FAIL
(and the licensed owner arm on any OTHER table still FAILs, so the escape cannot leak
into the general form set); and tests/gates/check-tenancy.test.mjs pins that an
ancient baseVersion cannot downgrade findings to NOTEs once the surface exists, nor
shelter an overdue transition. For the trail: delete the audit trigger on one
org-scoped table → FAIL naming the table; add a WHEN clause to it → FAIL; drop one
operation from its event list → FAIL naming the operation; make it BEFORE → FAIL;
remove the layer-3 row trigger → FAIL naming BYPASSRLS; remove a partition's TRUNCATE
guard → FAIL naming the non-cloning; give the tenant key a foreign key, or actor_id a
DEFAULT → FAIL; grant service_role on the trail → FAIL; make the writer
SECURITY INVOKER, or have it read the actor from the row → FAIL; set
auditReaderRole equal to auditWriterRole, or drop audit from nonPublicSchemas
→ FAIL closed. The runtime twin is supabase/tests/audit_immutability.test.sql, whose
32 assertions prove what no parser can: that the trigger fires for a BYPASSRLS role,
that TRUNCATE on a partition raises, and that the read path admits rank 40 and
refuses a rank-20 member of the same org — the bidirectional pair that separates a
working floor from one that refuses everybody.
The elevated role, ATTEMPTED rather than inferred (0.9.9). Six of those assertions are
new, and the reason is an evidence-tier one: ASD's assessment process guide ranks
documentation and interviews as poor evidence and testing with simulated activity as
excellent, and has_table_privilege('service_role', …) = false is a statement about
pg_class, not about the system. So service_role — the role that bypasses RLS, that no
policy in the repository constrains, and that an Edge Function actually holds — now tries
each of read, forge, modify, delete and truncate, and authenticated tries the read. The
forged INSERT is built before the role switch and stashed in a transaction-local GUC so
that it carries a real org_id: an INSERT that a NOT NULL constraint would refuse
whoever ran it proves nothing about privilege.
Which layer owns which property was established by experiment, and it corrected the
assumption this section was first written with. Granting service_role USAGE on the
schema and full DML on the trail — deleting layer 2 outright — reds exactly the read and
the forged insert, and leaves the UPDATE, DELETE and TRUNCATE assertions passing,
because the layer-3 row trigger and the layer-4 statement triggers refuse those with every
grant in place. The refusal is therefore not uniformly over-determined: reading and
forging are held by the grant alone. "Just grant the Edge Function read access to the audit
table" is a reasonable-sounding request, it is the single likeliest edit to this design,
and those two assertions are the only thing in the suite that catches it.
The history fold (1.1.0). The gate's live-policy state is now the parser's
parseLivePolicies(), which schema-rls reads too: besides CREATE and DROP POLICY it folds
ALTER POLICY (the clauses it names replace the policy's; RENAME TO renames) and
DROP TABLE, which removes the table's columns, indexes, triggers, grants and policies from
every view and clears the foreign keys into it. A predicate rewritten by ALTER POLICY is
therefore judged on the text the database runs, and a dropped table named in
untenantedTables is a stale entry. The header's rule stands for every finding the 1.0.x
reading also produces: a wrong predicate on an adopted surface is a hard red whatever the
manifest says. Only a finding the fold alone produces is a dated NOTE below baseVersion
1.1.0, through rampNote('tenancy', '1.1.0', 'the SQL history fold (DROP TABLE and ALTER POLICY)', { until: '1.2.0' }),
which expired at 2.0.0, so hard for every install since.
Anti-vacuity: ALTER POLICY notes_select_org ON public.notes USING (org_id = (SELECT auth.uid())) → FAIL "matches NO reviewed predicate form", exactly as the same predicate from
a CREATE does.
19. auth-posture — node tools/check-auth-posture.mjs
Section titled “19. auth-posture — node tools/check-auth-posture.mjs”The Supabase auth configuration matches the reviewed policy in
tools/auth-posture.json, BY VALUE and IN BOTH DIRECTIONS. It is the deferral CHANGELOG
0.3.0 recorded under "Deferred, with the reason" — "the Supabase [auth] posture gate (a
CLI-compatibility spike goes first)" — discharged in 0.6.0 after the spike ran.
The spike, and what it actually found. The stated blocker did not reproduce: against the
CLI the ^2.34.3 pin resolves to today (2.111.0), a config carrying
minimum_password_length, password_requirements, [auth.mfa] and [auth.mfa.totp] brought
the stack all the way up — Postgres started, all eight migrations applied, the seed ran, GoTrue
came up. What it found instead is the reason this gate is worth more than it looked: the CLI
parses config.toml leniently. An unknown key under [auth] produces no error and no
warning, so enable_refresh_token_rotaton = true — one letter short — reads to every reviewer
as a security property while GoTrue quietly applies its default. supabase/config.toml is the
most heavily commented file in the scaffold, and every one of those comments could have been
describing a posture the platform never applied.
Covers, all four closures both ways: the reviewed [auth*] values (jwt_expiry,
enable_refresh_token_rotation, refresh_token_reuse_interval, enable_anonymous_sign_ins,
enable_signup, enable_confirmations, double_confirm_changes) — a changed value reds and a
DELETED line reds, because the CLI silently applies its own default for an absent key; every
[auth*] key present in config appears in the policy (an unreviewed widening and a typo the
CLI is ignoring are indistinguishable to the gate, which is why it asks a human); project-valued
keys (site_url) reviewed for presence and SHAPE rather than equality, so a plaintext origin
reds while a filled-in one does not; the redirect allowlist's entry ceiling and wildcard ban,
because the provider hands the authorization code to any URL the list matches; and a section
census, since a Supabase config SECTION is a surface — [realtime] replays row changes through
its own policy check, [storage] has a separate bucket policy model, and both ship
enabled = false.
Does NOT cover the deployed project. config.toml governs the local stack; a production
project's posture lives in its [remotes] blocks or the Dashboard, and neither is visible from
here. auth.email.enable_confirmations is where that gap is loudest — false is correct
locally and wrong in production — and tools/auth-posture.json says so in writing.
Deferred to 2.1.0: asking the CLI directly (deferral ledger: auth-posture-cli-census).
A check that read the CLI's own deprecation
warnings was built, worked, and found a real defect — the harness shipped [inbucket] against a
CLI that renamed it to [local_smtp] and warns on every command, with nothing reading the
warning. It is not shipped, because no subcommand at this pin parses config.toml without side
effects: config push needs a project ref, functions list needs an access token, and
status — the one that works — binds to whatever stack holds the default ports (it reported
another project's containers during the spike) and prints SECRET_KEY and JWT_SECRET into
output the gate would then handle. A control that reads a neighbour's stack and handles their
credentials is not a control. The [inbucket] defect itself is fixed; the standing check waits
on an UPSTREAM condition — a side-effect-free config lint-shaped subcommand, re-checked at
every CLI pin bump — and the date is no longer decoration: this sentence, the twin notes in
tools/auth-posture.json and tools/check-auth-posture.mjs, and the tools/deferrals.json
entry are closed both ways by the docs-sync deferral scan, which reds the release the target
arrives. The first deferral of this check rolled past its own date with nothing reading it;
this one cannot — and the mechanism has now fired THREE times as designed: at 0.8.0 the arrival
forced the re-check (CLI 2.113.0, config group still push-only, the ask open as
supabase/cli#5894) and a reviewed four-site move to 0.9.0; at 0.9.0 it fired again against an
unchanged upstream (latest still 2.113.0, the v2.114.0 betas' only config-adjacent change a
docker image bump, #5894 still open with no milestone and no linked PR) and moved to 0.10.0;
at 0.10.0 (2026-08-13) it fired against a REAL pin bump for the first time — latest moved to
2.114.0 (GA 2026-08-12), the CLI became a pnpm/nx monorepo and the Go CLI moved to
apps/cli-go, and apps/cli-go/cmd/config.go at tag v2.114.0 STILL registers push alone
(read at the tag, not inferred from the release notes, whose config-adjacent entries are
--project-ref, skip-vault-sync and stack persistence); #5894 remains open with no milestone,
no linked PR and zero comments, so the date moved to 0.11.0. Re-checked again at the 0.11.0
arrival (2026-08-15) against the issue itself: still open, still no milestone, still no
linked PR, so the standing rule moved it to 0.12.0 — a release that was never cut, so the date
arrived at the 1.0.0 cut and was re-checked there (2026-08-16): #5894 still open, still zero
comments, still no milestone, npm latest still 2.114.0, so it moved to 1.1.0. At the 1.1.0
cut (2026-09-29) it arrived again: #5894 still open with no milestone, no linked PR and zero
comments, the CLI reference still documenting config push as the only config subcommand,
and npm latest at 2.118.0, the version the catalog pins exactly, so it moved to 1.2.0.
1.2.0 was never cut, so the date arrived at the 2.0.0 cut (2026-09-30): #5894 still open with
no milestone, no linked PR and no comments, the reference still listing config push alone,
and npm latest at 2.119.0 while the catalog pin stayed at 2.118.0, so it moved to 2.1.0.
The 0.8.0 move licensed itself "once"; the second firing proved the shape recurs, so the rule
is now standing: each arrival with the upstream condition unmet forces the re-check and a
one-release move in a reviewed diff — the discharge happens only when the side-effect-free
subcommand actually ships. The third firing is the one that tested the rule's own premise: the
re-check clause says "at every CLI pin bump", and this is the first bump it has actually met.
Static, <100ms, no Docker, no install: a hand-written TOML reader
(tools/lib/toml.mjs) over the committed file, for the same reason every other gate hand-parses
its subject — the chain must run on a checkout with cold node_modules.
Anti-vacuity: widen jwt_expiry → FAIL naming both values; misspell
enable_refresh_token_rotation → FAIL TWICE (missing reviewed key, unreviewed key present);
enable_anonymous_sign_ins = true → FAIL; add a wildcard redirect → FAIL; delete a reviewed
line → FAIL; point site_url at an http:// host → FAIL; rename [local_smtp] back to
[inbucket] → FAIL both ways; remove tools/auth-posture.json → FAIL closed.
The [auth.mfa] posture and its scoped ramp (0.9.9). Ten reviewed keys across four
sections — max_enrolled_factors, TOTP's two, phone's five, WebAuthn's two — pinned by
value in both directions. Every key is written out including the ones for factors that
are off, and that is the point rather than verbosity: the CLI parses leniently, so a
section whose keys are mostly implicit is a section where a typo reads as a posture. Two
values worth knowing: phone.max_frequency is 5s, and the published documentation says
10s and is wrong (a gate pinned to the documented value would red on every untouched
scaffold, which is how a correct control gets deleted for being noisy); and phone.template
is real but absent from the documentation entirely. Both verified against the CLI's own
embedded config template. [auth.mfa.web_authn] ships explicitly false — it is the
phishing-resistant factor and the only one that would satisfy ASD's phishing-resistance
requirements, but it is undocumented, absent from the Dashboard, experimental in the client
library, and a billable add-on the CLI silently downgrades to false when cost is not
confirmed, so a config line claiming it would be a control that reads as protection and
applies nothing.
The ramp is the interesting half. This policy file is harness-OWNED and
supabase/config.toml is SEEDED, so update arms ten new keys on every install at once and
cannot write the section it now demands — fourteen hard failures on a file the consumer
never touched. [auth.mfa] findings are therefore routed to NOTEs on a pre-0.9.9 install
(deadline 0.10.0; the instruction rides the seededSourceFixes channel into
.harness/pending/), while every other finding in this gate stays hard. A whole-gate
ramp was the easy move and the wrong one: it would have withheld the redirect-allowlist and
session-cookie findings, which have nothing to do with this release and are the ones worth
having. The OK line subtracts the withheld count rather than claiming those values hold.
Anti-vacuity (MFA): a pre-0.9.9 install with no [auth.mfa] → NOTE ×14, exit 0, every
finding printed; the SAME install with a widened jwt_expiry → FAIL (the assertion that
makes the ramp scoped rather than a gate switched off); a fresh 0.9.9 install with no
[auth.mfa] → FAIL; max_frequency = "10s" → FAIL naming both values; flip
web_authn.enroll_enabled to true → FAIL.
The SESSION TRANSPORT half (0.6.0). [auth] above is the posture of the auth server;
this is the posture of the wire. It exists because the seeded web app shipped a sign-in
loop for two releases: apps/web/lib/supabase/client.ts built the browser client with no
storage, so supabase-js persisted the session to localStorage — its documented default —
while proxy.ts, lib/supabase/server.ts and the tRPC cookie branch all read the session out
of the cookie jar. Two disjoint stores. Sign-in succeeded, every protected server render
saw an anonymous caller, and the layout redirected straight back to /sign-in.
Nothing saw it, and the reasons are worth keeping: storage is legitimately optional (a
pure SPA that never server-renders an identity needs none), so the type-checker was right to
be silent; the unit suite tested the cookie codec with options passed in, never the wiring
that passes them; knip was told to ignore the one dependency whose missing import would have
shouted; and every spec in the browser lane is anonymous, so no test in the repository has
ever completed a successful sign-in — while enforcement-tiers.md exempts apps/web/app from
unit coverage on the grounds that a real browser proves it. That is vacuity inside a lane that
ran, which "'exists' is not 'ran'" does not catch.
Three assertions over committed source, all pure text: (1) transport agreement — an app
whose server reads sessions from cookies must construct its browser client with a storage
(the pairing is the defect; neither half is wrong alone, so an SPA with no cookie-reading
server stays clean); (2) every writer — each createServerSupabaseClient call site must
pass cookieOptions naming every requiredCookieAttributes entry, because that client
rewrites the cookie and an attribute one writer omits is one it strips off the value another
set; (3) prose — an attribute listed under unavailableCookieAttributes may be named
only to disclaim it. httpOnly is that attribute here: the browser writes this cookie, and a
user agent ignores HttpOnly on a document.cookie write, so it cannot be set at all. Four
separate comments asserted apps/web set it while every executable control stayed green — a
false hardening claim is worse than a missing one, because it is read as a control by everyone
who reviews the file.
The general lesson, and why this is a gate rather than only a fix: an optional seam between
two halves that must agree will eventually be left unwired, and it fails silently on both
sides. Mobile is structurally immune — createNativeClient takes its storage as a required
positional — so this closes the same hole on the surface where the parameter has to stay
optional.
Anti-vacuity: drop the browser client's storage → FAIL naming the sign-in loop; drop
cookieOptions at either server writer → FAIL naming the silent downgrade; name httpOnly in
a comment without disclaiming it → FAIL quoting the comment. The factory's own definition
must not read as a call site, and that is pinned too.
20. data-flow — node tools/check-data-flow.mjs
Section titled “20. data-flow — node tools/check-data-flow.mjs”What happens to a person's data when they ask to be forgotten, and what a portability
request has to hand back. Both are decided from files this repository already commits: a
delete of auth.users does exactly what the FOREIGN KEY actions say it does, and those
are in the migration history.
docs/adr/20260201-org-scoped-tenancy.md records that after the org re-scope "DSR
completeness is now procedure-backed, not schema-backed… residual rows can no longer be
enumerated back to the subject." That sentence was true, serious, and checkable by nothing:
the procedure lived in one Edge Function's header, the reason for each surviving column lived
in a different SQL comment, and no file listed what actually survives. This gate is that
sentence made mechanical.
Every link out of the subject lands in exactly one bucket:
- ERASED — a chain of
ON DELETE CASCADEreaches it. Nothing to review. - SEVERED —
ON DELETE SET NULL/SET DEFAULT. The row survives and only the link is cut. Needs a reviewed reason intools/data-flow.jsonsevered[], because it is a decision that somebody else is the data controller for that row. - RETAINED — no delete will ever reach it (
audit.events.actor_idhas no foreign key at all, deliberately). Needs a reason and aprocedurenaming a committed file — the gate requires that file to exist, because procedure-backed with no procedure is not backed. - BLOCKING —
RESTRICT,NO ACTION, or noON DELETEclause at all, which PostgreSQL treats asNO ACTION. The delete fails: a GDPR Art. 17 failure and an Apple 5.1.1(v) rejection from a line that looks like every other column definition. Allowed only with a reviewed pre-delete sweep, the waydelete-accountsweeps the personal org first.
The portability half closes export.projection against the schema both ways, and every table
carrying subject data is either projected or excluded with a reason. export.surface
declares how the export is delivered, or states its absence with a dated target — the same
shape store-tunables.json uses for accountDeletion.
It also does the one thing nothing else in the repo does: it compares supabase/schemas/
against supabase/migrations/ on the referential actions. check-migrations.mjs used to
claim that reconciliation ran in "CI's db lane"; no workflow runs supabase db diff, and that
header is corrected in 0.6.0. The gap mattered here more than most, because notes.owner_id
was created ON DELETE CASCADE and demoted to SET NULL by a later ALTER — so a reviewer
reading the declarative file and one reading the creating migration reach opposite conclusions
about whether a note dies with its author.
Reviewed data: tools/data-flow.json (write-guard-protected, git-clean-enforced). Procedure:
docs/runbooks/data-subject-requests.md. Ramped at minVersion 0.6.0, expiring 0.7.0.
The history fold (1.1.0). The column facts are folded through DROP TABLE: a dropped
table's columns leave them and every foreign key into it is cleared, so its links stop
deciding what an erasure does and a reviewed entry that names it goes stale. A finding only
the folded facts produce is a dated NOTE below baseVersion 1.1.0, through
rampNote('data-flow', '1.1.0', 'the SQL history fold (DROP TABLE and ALTER POLICY)', { until: '1.2.0' })
(expired at 2.0.0, so hard for every install since); one both readings produce stays hard. Anti-vacuity: DROP TABLE public.invitations CASCADE → FAIL on the stale severed[] and retained[] entries for it.
21. types-drift — node tools/check-types-drift.mjs
Section titled “21. types-drift — node tools/check-types-drift.mjs”Regenerates the Supabase type mirror (supabase gen types typescript --local) from the
running local stack and compares it with the committed
packages/platform/supabase/src/database.types.ts, normalising only line endings and the
text's trailing whitespace; a mismatch means a migration landed without a pnpm db:types
regen, so the checked-in types describe a schema no database runs. A new Supabase CLI can
also change the generator's output on an unchanged schema (2.117.0 and 2.118.0 both did);
pnpm db:types with the install's own CLI clears that red the same way. Since 1.0.4 the
gate runs that same CLI: the workspace copy in node_modules/.bin when one is installed
(tools/lib/supabase-cli.mjs; never on Windows), PATH otherwise, so on a machine with no
global CLI it runs with the stack up where it used to skip. Before its FAIL
sentence the gate prints each side's line count, the first differing line, and at most
DIFF_LINES lines of each side from there (committed - , generated + ), so a CI log
alone tells a layout change from a schema change. A LIVE-STACK gate: it SKIPS LOUDLY
(exit 0) with no supabase CLI/stack — its fail-closed enforcement is the CI supabase lane
that brings the stack up — and the mirror is opt-in (pnpm db:types writes it), so until
it exists there is nothing to diff. The generic is deliberately NOT in the compile graph
(packages/platform/supabase/src/types.ts): rows are re-parsed against zod at the DAL
exit, so this is a CI drift assertion, never a compile-time licence to skip validation.
Anti-vacuity: edit a committed migration's column and re-run without pnpm db:types →
FAIL "stale", after a diff from the first line that differs; break a migration so
gen types errors while the stack is up → FAIL "failed while the stack is up".
22. migrations — node tools/check-migrations.mjs
Section titled “22. migrations — node tools/check-migrations.mjs”Append-only (no committed migration modified/deleted vs HEAD, or vs the PR base in
CI); no DML without -- harness-allow-dml: <reason>; destructive DDL requires
-- adr: docs/adr/<file> pointing at an existing ADR. Always generate a NEW
migration; follow docs/runbooks/expand-contract.md for destructive phases.
Anti-vacuity: append a comment to an existing migration file (editor) → FAIL
append-only; add DROP TABLE notes; in a new migration without an ADR line → FAIL.
The one escape, and why it exists (0.4.0). Two rules — the ADR requirement on
authorization-destructive DDL and the SET lock_timeout preamble on ACCESS EXCLUSIVE —
were added in 0.2.0 and ramped, because history written before them could not satisfy
them. Both remedies live INSIDE the migration, and rule 1 above reds any edit to a
committed one, so on the day the ramp expired an install carrying either finding in
APPLIED history had a red whose only in-file fix was a different red. Editing is also
wrong on the merits: a lock preamble on a migration that ran months ago governs a lock
already released. So applied history is acknowledged instead, in the reviewed
tools/migrations-allow.json ({"file", "rule": "authz-adr"|"lock-timeout", "reason"}).
Three bounds keep it from becoming a hole: it exempts a (file, rule) pair, never a
file; the migration must already exist at the diff base, so one written today cannot
be exempted at all; and a stale entry reds. Absent by default — creating it is a
widening, so it is in ESCAPE_LISTS and must be committed.
ALTER POLICY is an authorization change (1.1.0). It replaces the USING,
WITH CHECK or TO clause a reviewer approved, and the database never runs the old one
again, so it needs -- adr: naming an existing ADR, as DROP POLICY does. It is judged per
statement: a migration whose only ALTER POLICY statements are RENAME TO needs none, and
one rename does not excuse a rewrite beside it. Its findings ride their own ramp, not the
0.2.0 bucket, through rampNote('migrations', '1.1.0', 'ALTER POLICY as an authorization change', { until: '1.2.0' })
(expired at 2.0.0, so hard for every install since), and an applied migration uses the existing authz-adr entry in
tools/migrations-allow.json. Anti-vacuity: a new migration with
ALTER POLICY notes_select_own ON public.notes USING (true); and no ADR line → FAIL
"removes an authorization control"; the same with RENAME TO → GREEN.
A migration eject deleted (2.0.0). eject removes the worked example's migrations
from an install made with init --with-demo, and records each one's sha256 under
ejectedMigrations in .harness/manifest.json. A deletion of a recorded file whose bytes at
the diff base hash to the record prints a NOTE naming the runbook instead of failing. Every
other deletion still reds, as do an edit to a recorded file and a recorded name whose
deleted bytes differ, and a malformed record excuses nothing. Anti-vacuity: delete a
recorded migration whose bytes were changed first → FAIL append-only.
23. db-limits — node tools/check-db-limits.mjs
Section titled “23. db-limits — node tools/check-db-limits.mjs”The per-role resource ceilings and the per-org quota machinery, judged as data
(tools/db-limits.json). Runs right after migrations because it reads the same
migration text that step just parsed, and because its subject is the same: what the
applied history does to a running database.
The role×knob matrix is folded in statement order, not collected — ALTER ROLE x RESET y after a SET is legal, and the LAST word is what the database holds, so a
gate that only gathered SETs would report a ceiling a later RESET had removed.
Each value must equal the contract's and sit under the contract's ceiling; raising a
ceiling is therefore a reviewed diff rather than a one-line migration edit.
What makes those settings bind is PostgREST, not PostgreSQL, and the gate's scope
is honest about it. ALTER ROLE x SET y writes a pg_db_role_setting row that
PostgreSQL applies when role x starts a session, and SET ROLE does not start
one — verified: as authenticator, SET LOCAL ROLE authenticated left
statement_timeout at the authenticator's value. They bind because PostgREST reads
pg_db_role_setting for the role it impersonates and applies it per request; verified
end to end, a 5-second RPC as anon (2s) was cancelled at 2.03s with SQLSTATE
57014. So these ceilings bound traffic arriving through PostgREST — every
supabase-js call from web and mobile — and do not bound a direct connection, which
gets its own login role's settings. That is why the runtime twin is a client-side
assertion via public.effective_limits() rather than only a pgTAP read of the catalog:
the row proves what PostgREST will read, never that PostgREST applied it.
The inverted half is the one that matters most. temp_file_limit and
CONNECTION LIMIT must NEVER appear, and the gate reds when they do. Both read as
obvious hardening, both were in this release's plan, and both bind nothing here:
temp_file_limit is superuser-only so postgres cannot set it at all, and a
connection limit binds at LOGIN — the only role that logs in is authenticator, which
is reserved. A number that cannot bind is worse than no number, because a reviewer
reads it as a control.
The quota's shape is asserted structurally because both wrong implementations are
one word away and neither fails loudly. FOR EACH ROW serializes every insert behind
the org's single usage tuple; a RESTRICTIVE policy over a STABLE count is hoisted to
one evaluation per statement against the pre-statement count, so a single
multi-row INSERT of any size passes wholesale — it fails open. The gate requires
FOR EACH STATEMENT with REFERENCING NEW TABLE, the AFTER DELETE release twin, no
WHEN clause, and no client write grant on the counter. It also reds if
reconcile_org_usage is ever reassigned to the tenant-scoped writer role: pg_cron has
no JWT, so a scoped owner would read an empty scope, produce an empty truth set, and
set every counter in the database to zero on a schedule.
Pooled-connection discipline is the fifth section, and it walks the source tree
rather than a list of known files — a gate that names tools/mcp/rls-verify-server.mjs
and tests/rls/db-context.ts is green by construction the moment someone adds a third
client. Three shapes red: a postgres() construction without prepare: false (a
prepared statement lives on a backend the next request does not get, so the driver
sends the cached name — an intermittent 26000 no test against a direct connection
reproduces), a timeout GUC set without LOCAL (a pooled backend carries the widened
ceiling into the next tenant's request), and pg_advisory_lock (session-scoped, so an
error path leaks a lock that blocks every later caller of that key and no pool release
clears it). The write-guard denies all three at the moment of the edit
(pg-prepared-statement, pg-session-timeout-set, pg-advisory-session-lock); this
is the half that judges a file the hook never watched being written. The prepare: false search is per-construction within a 600-character window, not per file, so a
compliant client later in the same file cannot clear a non-compliant one above it —
which is exactly what the hook's file-scoped tripwire cannot decide.
Anti-vacuity: drop one ALTER ROLE ... SET → FAIL naming the role and knob;
RESET it later → FAIL; disagree with the contract, or exceed a ceiling → FAIL; add
temp_file_limit or CONNECTION LIMIT 60 → FAIL (the inverted rule); make the quota
trigger FOR EACH ROW, drop its REFERENCING NEW TABLE, delete the release trigger,
or add a RESTRICTIVE counting policy → FAIL, each naming the specific failure mode;
reassign the reconciler's owner, or grant it to authenticated → FAIL; grant a client
UPDATE on org_usage → FAIL; raise [api].max_rows, or set a session-mode pooler →
FAIL; build a postgres() client with prepared statements on, SET statement_timeout
without LOCAL, or take a pg_advisory_lock → FAIL, with SET LOCAL, ALTER ROLE x SET, pg_advisory_xact_lock and a postgres(?:ql)?:// URL regex all proven to stay
green; malformed JSON, an empty role matrix, a knob with no declared ceiling, or an
unavailable entry with a thin reason → FAIL closed.
tests/gates/check-db-limits.test.mjs carries these cases; the runtime twins are the
pg_db_role_setting + quota block in supabase/tests/rls_structure.test.sql and
tests/rls/resource-limits.test.ts, which proves the ceilings are in FORCE through
PostgREST rather than merely present in the catalog.
The history fold (1.1.0). The migration views are folded through DROP TABLE (a
dropped table takes its triggers, columns, grants and policies with it) and ALTER POLICY,
and the RESTRICTIVE counting rule reads the live policies. A metered table dropped and
re-created without its triggers is unenforced; a finding only the fold produces is a dated
NOTE below baseVersion 1.1.0, through
rampNote('db-limits', '1.1.0', 'the SQL history fold (DROP TABLE and ALTER POLICY)', { until: '1.2.0' })
(expired at 2.0.0, so hard for every install since), and one both readings produce stays hard.
24. contracts — node tools/check-contract-drift.mjs
Section titled “24. contracts — node tools/check-contract-drift.mjs”(1) Contract-inventory regen-diff: regenerate the three committed inventories —
tools/generated/action-inventory.json (every tRPC procedure appRouter exposes),
tools/generated/event-catalog.json (every event the platform + vertical catalogs
declare; a vertical is catalogued when its ./client entry exports its catalog as
EVENT_CATALOG, from 1.1.0) and tools/generated/query-shapes.json (every
statement the DALs issue, recorded by driving them through the harness-owned
recording port) — from the LIVE values and diff against the committed copies, so
adding OR removing an action, event or query without pnpm gen reds. Needs an
install (tsx, to walk the runtime router/catalogs/DALs); skips loudly without one,
fails closed in CI.
(2) tsconfig project references mirror the pnpm workspace dependency graph —
parallel topologies desynchronize into confusing type errors otherwise. (3) Bounded
wire strings (G18): every z.string() in @app/contracts carries .max(N), or a
reviewed entry in tools/dto-bounds-allow.json — an unbounded wire string is a
memory-amplification vector.
Anti-vacuity: add a procedure without regenerating → FAIL stale; delete a
references entry from a package tsconfig → FAIL naming the missing ref; add an
unbounded z.string() to a wire DTO → FAIL naming the site.
25. query-shapes — node tools/check-query-shapes.mjs
Section titled “25. query-shapes — node tools/check-query-shapes.mjs”Every statement the DALs actually issue is BOUNDED and SERVED BY AN INDEX — judged
against tools/generated/query-shapes.json, which is written by executing the DALs
rather than by describing them. tools/gen-query-shapes.mjs drives each DAL function
through a harness-owned recording port (tools/lib/query-recorder.mjs) and records the
builder chain it produced; the contracts step immediately before proves that file is
byte-fresh. A hand-authored query manifest would be a tautology — the same turn writes
the DAL and the manifest, and the cheapest repair for a red is to edit the manifest.
The recording port is a Proxy, not a fake with methods: it records every call by
name, including ones no port declares, so .range()/.offset() arrive as extra and
red by name instead of crashing the instrument into being taught to ignore them.
Two closures keep it non-vacuous: generation fails if any exported DAL function has no
probe (the probe module re-exports its DAL as a namespace, so the comparison is against
the functions that exist, not a list), and fails if a probe issues no query at all.
The rules, each catching something the others cannot: bounded (a read with no LIMIT
and no aggregate costs the tenant's whole row count — fine on the day it ships,
forever); no unreviewed builder method (OFFSET pagination re-reads and discards
every skipped row, so page 500 costs 500 pages); served — an index whose leading
columns are the equality set followed by the ORDER BY columns in order and in one scan
direction (PostgreSQL walks a btree backwards, so an all-reversed sort is served too; a
MIXED order is not); cursor/sort agreement (a keyset cursor whose columns disagree
with the ORDER BY skips or repeats rows at every page boundary); tenant-led (on a
tenant table the tenant column must be in the equality set and lead the serving index —
a performance rule with an authorization shadow: the policy filters by org either way,
but without the leading column it filters by SCANNING); and ceiling (no LIMIT above
[api].max_rows, which PostgREST truncates to silently, so the sentinel row a keyset
page uses to detect "has more" never arrives).
rpc() and upsert() (1.1.0). The recording port has db.rpc(name, args), and an rpc
row is op: "rpc" with table: null and one more key, rpc: { name, args }, where args
is the sorted argument names and never a value. upsert is in the reviewed method set, and
an upsert row is op: "upsert" with the payload's keys and one more key, onConflict: the
option split on commas and sorted, or null when absent. A row that uses neither gains no
key, so a manifest that has no such call regenerates byte-identical. Through 1.0.4 a probed
DAL that called .rpc() threw inside pnpm gen, and an upsert recorded as a select whose
extra named it, so it failed with OFFSET advice. Four rules judge the two ops, which skip
served and tenant-led (an rpc has no table, and an upsert's lookup is its conflict
arbiter); bounded never reds a write, and the unreviewed-method and ceiling rules
still apply. rpc resolves: a migration creates public.<name>, the one schema
supabase/config.toml exposes, or PostgREST answers PGRST202. rpc arguments: the call
names every input parameter (IN, INOUT or VARIADIC) that has no DEFAULT, and no parameter
the function lacks, or PostgREST finds no function with that argument list (PGRST202).
upsert arbiter: the conflict columns equal, as a set, the columns of a UNIQUE index or
primary key that still exists; with no onConflict they are the primary key's, which is
what PostgREST targets, and PostgreSQL raises an error when ON CONFLICT inference finds no
such index. Its finding lists the arbiters the table holds, and on a tenant table the target
or index it proposes carries the tenant column, which tenancy requires of every UNIQUE
there. upsert tenant: on a tenant table the payload carries the tenant column, as
an insert's does. The limits, stated in the gate's header too: the parser does not model
DROP FUNCTION, so a dropped function still resolves; resolveFunction matches on the name
alone, so overloads collapse to the last definition; index parsing drops the predicate of a
partial index, so a partial UNIQUE index, which ON CONFLICT cannot infer without an index
predicate, still reads as an arbiter; and a function with an unnamed input parameter
resolves, but its arguments are not judged. The function body an rpc runs is judged by no
rule here. A committed manifest is seeded, so update never rewrites it: a DAL that calls
either method runs pnpm gen again and commits the result.
This is the static half. It cannot prove the planner CHOOSES the index it found — that
is tools/check-db-perf.mjs in the path-filtered db-scale CI lane, against 2M seeded
rows. Neither subsumes the other: this one is decidable from migration text in ~60ms,
and that one needs a real planner, real statistics and real cardinality.
Anti-vacuity: delete notes_org_id_created_at_id_idx → FAIL naming the shape and
printing the exact CREATE INDEX that would serve it; swap the index's sort tail to
(created_at DESC, id ASC) → FAIL on the mixed direction while schema-rls and pgTAP
stay green (both only see the leading column); drop the .limit() from a list DAL →
FAIL unbounded; add .range(0, 20) → FAIL naming .range(); add a DAL function with
no probe → pnpm gen FAILS and contracts reds; empty the manifest → FAIL (an empty
manifest passes every rule above without judging anything); rename the function an rpc
calls in its migration → FAIL naming public.<name> and PGRST202; call it with a wrong or
missing argument name → FAIL naming the parameter; drop the UNIQUE index an upsert's
onConflict names → FAIL naming the conflict columns; drop the tenant column from a tenant
upsert's payload → FAIL; strip rpc from an rpc row or onConflict from an upsert row →
FAIL (the manifest is malformed, not judged).
The history fold (1.1.0). The index lookup is folded through DROP TABLE: a dropped
table takes its indexes with it, so a re-created table is served only by indexes created
after it. A finding only the fold produces is a dated NOTE below baseVersion 1.1.0,
through rampNote('query-shapes', '1.1.0', 'the SQL history fold (DROP TABLE and ALTER POLICY)', { until: '1.2.0' })
(expired at 2.0.0, so hard for every install since), and one both readings produce stays hard. db-perf's static index lookup reads the same
fold, with no ramp: it runs only against a live, scale-seeded database, where a dropped
table's indexes are already gone.
No vertical yet (2.0.0). A default scaffold has no packages/verticals/*, so no DAL
issues a query and the manifest records none. With no vertical directory, an existing
migrations directory, and a manifest that is [] or absent, the step is green and its OK line
names the empty state; a manifest that still records shapes there reds as stale, and one that
does not parse reds. The first vertical's src/data/query-probes.ts arms every rule above.
Anti-vacuity: no vertical and a manifest holding one shape → FAIL.
26. rate-limits — node tools/check-rate-limits.mjs
Section titled “26. rate-limits — node tools/check-rate-limits.mjs”The rate-limit budget as reviewed data (tools/rate-limit-budget.json), closed against
the router the deployment actually exposes. Runs right after contracts, and the order is
load-bearing rather than cosmetic: this gate's whole value rests on
tools/generated/action-inventory.json, and contracts is the step that proves that file
is not stale. Judging a budget against a stale inventory reports full coverage of a router
that no longer exists.
The vacuity it exists to prevent is not "the numbers are wrong." It is a limiter that is wired, tested, and reaches nothing: a new mutation lands, nobody adds it to the policy, and the seam happily limits the five procedures it already knew about while the newest write path runs unbounded — everything green. So the load-bearing rule is a CLOSURE against a GENERATED inventory (walked out of the composed router, never hand-written): every mutation is mapped to a bucket or carries a reasoned exemption, in both directions, and a mapping or exemption naming a procedure the router no longer exposes reds as stale.
The budget is diffed BY VALUE. apps/web/lib/rate-limit.ts is the code that runs; the
JSON is what a human approved. The gate evaluates the module under node's type stripping —
the same technique security-headers uses, and the reason that module has zero value
imports — and compares bucket limits, windows, and both resolvers. A number changed in code
without a reviewed diff reds; so does a number changed in the JSON that the code does not
honour, and so does a documented exemption the code fails to apply.
An unknown name must NOT resolve to null. The resolvers fall back to the write bucket,
so a procedure added without touching the policy is limited (wrongly, in the harmless
direction) for the seconds between writing a router and running the chain. A gate that
allowed null there would make "forgot to map it" and "deliberately unlimited" the same
value.
Both seams are asserted wired, because a policy nothing consults is a policy in name
only: the tRPC host must pass a rateLimit port to createContext (@app/api treats a
missing port as an UNLIMITED router — correct for a worker or a test, silent total loss for
the web host), and every Server Action the budget names must call
enforceActionRateLimit('<name>'). The reverse also reds: an exported *Action with no
budget entry is the same hole as an unmapped mutation, because a Server Action is a public
HTTP endpoint with a generated id.
Anti-vacuity: delete a mutation's bucket → FAIL naming it; map and exempt the same
procedure → FAIL (the two say opposite things and the gate will not choose); name a bucket
buckets does not declare, or declare one nothing spends from → FAIL; exceed the reviewed
ceilings → FAIL (a budget nobody can exceed is not a budget, and neither is a window long
enough never to close); change a limit in code only, or in the JSON only → FAIL with both
numbers printed; return null for an unknown name → FAIL; drop the rateLimit port from
the route, or the guard from an action → FAIL naming the seam; malformed JSON, an empty
bucket set, a bucket with a thin reason, an exemption with a thin reason, or a failOpen
decision that was never recorded → FAIL closed. tests/gates/check-rate-limits.test.mjs
carries the fixture proofs; packages/api/src/ratelimit.test.ts proves the router refuses
BEFORE any handler runs against a database that throws if touched.
Honest loss, stated here because this is where someone will look for it: these budgets
bind the two APPLICATION seams. They do not bind a client that POSTs straight to
/rest/v1/notes with the publishable key and its own JWT, and they do not bind sign-in or
sign-up, which go to GoTrue. The controls that bind every path are the per-org quota
trigger and the per-role statement timeouts (db-limits). The limiter also FAILS OPEN when
its backend is unavailable — an explicit, recorded decision (see
docs/adr/20260204-rate-limiting.md). Since 0.10.0 that no longer means a Redis outage
is a window with no rate limiting at all: the outage rung degrades to the in-process
limiter, so the budget is multiplied by the instance count rather than removed — and on a
serverless platform that discards the process, still effectively removed. A degraded
decision may now be a DENIAL, and counted says whether anything counted.
A surface no row maps: unmapped (2.0.0). Both seams send a procedure or action that
no row maps to a fallback bucket. The budget may declare that bucket as "unmapped"; the
declaration must name a declared bucket, and must be what bucketForProcedure and
bucketForAction actually return for an unmapped name, or the step reds per seam. A bucket
the fallback spends from counts as referenced only when both seams agree. The default
budget declares "unmapped": "write". Anti-vacuity: "unmapped": "nope" → FAIL
undeclared; "unmapped": "read" while the seams fall to write → FAIL naming each seam.
27. parity — node tools/check-mobile-parity.mjs
Section titled “27. parity — node tools/check-mobile-parity.mjs”Two-way surface parity: every action in the contracts-verified inventory
(tools/generated/action-inventory.json) maps to EXACTLY ONE row in the seeded
PARITY.md ledger, naming the web screen and the mobile screen that surface it —
each an existing repo-relative path, or — (exempt) WITH a reason in the Notes
cell. The closure runs both directions: a new action with no row reds (forward), a
row for a deleted/renamed action reds (backward — the fix to the source scanner's
one-way rot). Action names admit digits (billing.v2Invoice), unlike the source
regex that silently dropped them. Runs right after contracts so the inventory is
byte-fresh. Ships soft via rampNote (NOTE-only on installs predating it) and
strict on fresh installs + the template tree; CHECK_MOBILE_PARITY_STRICT=1
forces strict anywhere.
Anti-vacuity: add a procedure without a PARITY.md row → FAIL (strict) naming
the action; leave a row for a removed action → FAIL naming the stale row; set a
surface cell to — with an empty Notes cell → FAIL demanding the reason.
28. dead-code — pnpm exec knip --strict
Section titled “28. dead-code — pnpm exec knip --strict”Unused files, exports, and dependencies, in production mode (test-only reachability
does not keep production code alive). Wire everything you add or delete it.
Deliberate test-facing seam exports carry an explicit @public JSDoc tag — a
visible, greppable claim, reviewed like code. NEVER knip --fix (blocked): it
auto-deletes with false positives.
A SEAM A GATE READS IS A PRODUCTION ENTRY, not an ignore. knip.json has no
comments, so the reasoning lives here. apps/web/lib/i18n/index.ts and
apps/web/lib/routes.generated.ts are declared production entries ("…!"), the same
declaration apps/mobile makes for src/host/env.ts and src/crash/redact.ts. Both
are seams a GATE reads rather than only the app: check-i18n.mjs parses LOCALES out
of the locale module and bans Intl/toLocale*/.toFixed() everywhere else, so
formatNumber/formatDate are the one legal way to format even before a screen needs
one; check-web-routes.mjs regen-diffs the registry. --strict ignores non-production
entries, so a test-only consumer does not keep either alive — and the alternative,
deleting the unused half of a seam, deletes the API the other gate exists to funnel code
into. An ignore entry would silence the file entirely; an entry keeps the rest of the
closure live over it.
Anti-vacuity: add an exported-but-unimported function → FAIL.
29. architecture — pnpm exec depcruise apps packages --config .dependency-cruiser.cjs
Section titled “29. architecture — pnpm exec depcruise apps packages --config .dependency-cruiser.cjs”The dependency law: no cycles; verticals ⊥ verticals; shared ↛ verticals;
platform/* → {errors,events} only; packages/api ↛ next/* (the reversibility wall);
apps/mobile ↛ web-only packages (no next/react-dom/@app/design-system) and
apps/web ↛ react-native; the @supabase/ssr server client stays out of the mobile
graph; expo-secure-store only under src/lib/supabase/**; LLM SDKs only from
packages/eval adapters.
Anti-vacuity: import a server module from a mobile file (editor — the write
guard also denies it in-session) → FAIL with the violation path.
30. build — node tools/build-check.mjs
Section titled “30. build — node tools/build-check.mjs”The app must actually export (expo export --platform android — one canonical
platform keeps the byte accounting deterministic and laptop-fast; the CI device
lanes exercise both platforms for real), and the emitted dist/ must be PURE: no
ORM/server markers, no privileged DSN names, no secret-shaped strings. Bundle
purity is the runtime backstop for gates 4/15 — a transitive leak past static
analysis still shows up in the emitted Hermes bundle. Bytes are gated twice:
absolute gzip budgets (tools/bundle-budget.json, ~3x headroom) and the byte-true
ratchet against the committed tools/perf-baseline.json (measured ≤ baseline ×
ratioCap; re-baseline only via pnpm perf:baseline in a reviewed commit; a
malformed baseline FAILS CLOSED). Stamped — editing the baseline invalidates the
stamp, so a warm validate re-runs the real export.
Anti-vacuity: embed the literal SUPABASE_SERVICE_ROLE_KEY in a mobile constant →
export succeeds, gate FAILs on bundle purity; halve gzip.total in the baseline →
FAIL naming measured vs baseline × ratioCap and the re-baseline ceremony.
31. web-compile — node tools/check-web-build.mjs
Section titled “31. web-compile — node tools/check-web-build.mjs”The web app must COMPILE: pnpm --filter web exec next build --webpack over apps/web,
since 1.1.0. types typechecks and does not bundle, so a Client Component that imports a
server-only module, an import only the bundler fails to resolve, or an RSC boundary
violation all pass it, and build is the mobile export. Until this step a web app that did
not compile passed the whole chain and the static job; only the path-filtered web-build
lane ran next build. It compiles only: the client-bundle purity scan stays in that lane
(build-check.mjs --web), which reads a real build's .next/static.
Stamped, which is what makes a full Next build affordable in a chain every turn ends on.
The inputs are apps/web, packages, tsconfig.base.json, pnpm-workspace.yaml and
pnpm-lock.yaml plus the machinery; .next and dist are never hashed; CI never honours
a stamp, so static builds on every pull request. The binary runs through exec, never a
package.json script an agent could redefine, and --webpack matches the seeded script.
It runs after types, whose tsc -b emits the declarations Next's own type check reads:
run alone on a fresh clone it reds with TS6305 and names that command. For each Supabase key
the build needs that neither the caller nor an apps/web/.env* file sets, it supplies the
web-build job's placeholder, byte for byte, and prints which keys it filled (the stamp
hashes files, not the environment). The build rewrites the committed
apps/web/next-env.d.ts, and the step restores it, so the tree stays clean and a second run
stamp-hits. Skips loudly without apps/web or node_modules; fails closed in CI.
Shipped ramped (minVersion 1.1.0, until 1.2.0): update injects the step into an
existing chain, so below that baseVersion a failed build was a NOTE carrying the output,
recording no stamp. The ramp expired at 2.0.0, so hard for every install since: a web app that does not compile reds
the step on every install.
Anti-vacuity: add import '../lib/auth/session' (it opens with import 'server-only')
under the 'use client' directive of app/providers.tsx → types stays green and
web-compile: FAIL carries Next's You're importing a module that depends on "server-only" error (selftest Canary 35).
32. styleguide — node tools/check-styleguide-manifest.mjs
Section titled “32. styleguide — node tools/check-styleguide-manifest.mjs”The design system is DATA, and the token VALUES are owned by @app/design-tokens
(the TypeScript modules in packages/design-tokens/src, OKLCH). This gate does two
things. (1) Regen-diff the package — pnpm --filter @app/design-tokens run gen:check (tsx packages/design-tokens/scripts/gen.mjs --check): one command
re-asserts BOTH committed adapters byte-for-byte — src/generated/native.ts (the RN
theme apps/mobile consumes via @app/design-tokens/native) and
src/generated/web.css (the Tailwind v4 @theme apps/web imports) — AND the
gamut + WCAG contrast contract (render() throws before emitting a byte), so a hand
edit to a generated file, or a retune that breaks readability, is a red gate. Needs
an install (tsx); skips loudly without one, fails closed in CI. (2) Source-scan
apps/mobile/{src,app} (outside src/theme) for raw values: hex/rgb color literals,
CSS named colors on color props, raw dimension literals, inline style={{…}} numeric
values. tools/styleguide.manifest.json (write-guard-protected) is now the gate
POLICY — accent budget, status surfaces, primitive boundary, motion seam, allow
lists — not token values; the token names it references (accentTokens,
statusSurfaces.tokens) are validated against the committed native.ts palette. The
launch-frame lockstep (splash/adaptive-icon background == the dark canvas token) is
shared with expo-policy, which reads the same native.ts adapter.
The DESIGN-DEPTH sub-checks: motion discipline (motionSeam) — literal
duration:/delay: numerics red anywhere in the walk (the motion vocabulary lives
in @app/design-tokens motion; 0 passes), and raw
Animated./LayoutAnimation./Easing. references red outside the seam file + the
components home with NO allow escape; elevation keys — the shadow*/elevation
style keys are spelled only inside the generated adapter, consumers spread a level
({ ...elevation.raised }); hit-target floor — a home file styling a raw control
must reference minTouchTarget (the 44dp floor @app/design-tokens exports), and
with controlPrimitives.base declared the pressable-class tags may be styled in
exactly ONE home file (the PressableScale touchable base — pressed feedback, the hit
target, and the haptic live there).
Anti-vacuity: hand-edit native.ts/web.css → FAIL regen-diff; retune a token
below a contrast floor → FAIL (render() throws in gen:check); add a hex literal to
a component style → FAIL naming the file; write duration: 250 in a feature → FAIL
naming the literal; call Animated.timing from a screen → FAIL pointing at the seam;
spell shadowOpacity: outside src/theme → FAIL; style a raw <Pressable> in a second
home file → FAIL naming the base; name a non-existent token in accentTokens → FAIL.
33. perf-budget — node tools/check-perf-budget.mjs
Section titled “33. perf-budget — node tools/check-perf-budget.mjs”Median-of-N full react-test-renderer mount time over REAL feature subjects,
asserted against tools/perf-budget.json (write-guard-protected; raising a budget
is a reviewed human decision). A red requires TWO independent over-budget medians
(one automatic re-measure), so scheduler noise cannot fail a turn while a genuine
10× regression still cannot pass. The UPDATE phase (0.1.2): after each timed
mount the harness re-renders the SAME mounted tree with a changed tick prop
and times the reconciliation pass — the re-render cost a mount-only benchmark
never sees, and because props change every update a React.memo wrapper cannot
fake a fast one. The update median is always measured and printed; it is
ASSERTED only when the entry declares medianUpdateBudgetMs (seeded ~10× the
fresh-scaffold update median, same doctrine as the mount budget), and the
updated tree must still carry the scaled markers. One budget shape — subjects: [{ subject, cells, medianBudgetMs, medianUpdateBudgetMs?, expect? }] under one
shared runs — and it arms the
DENSE-FEATURE CLOSURE: every features/* dir importing useKeysetQuery must ship
a perfSubject.tsx declared in subjects[]; declared-but-missing and
present-but-undeclared both red (features/matrix/perfSubject.tsx is the worked
pattern — an island reachable only from tests and this gate). THE REVIEWED EMPTY
STATE (1.0.4): a project with nothing dense to measure declares subjects: []
beside "emptySubjects": { "reason": …, "reviewedOn": "YYYY-MM-DD" }, held to
the vertical-anatomy escape's bar (a reason of at least 40 characters after
trimming; the date is checked for format only, never against the clock). The
gate prints a NOTE, measures nothing, and its OK line names the empty state and
the reason. The row beside a non-empty subjects[] is a stale escape and reds;
subjects absent, or [] without the row, still reds. The leak scan and both
closure directions run unchanged, so the row cannot hide a dense screen or an
undeclared perfSubject.tsx (a kept one needs a reviewed exempt row). This is
the RELATIVE, deterministic canary; absolute startup/UX numbers live in the CI
device lane (mobile-perf), never in the chain.
Anti-vacuity: slow the row render 10× → FAIL twice-measured; slow only the
UPDATE path → FAIL naming the re-render cost; add a features dir importing
useKeysetQuery with no perfSubject → FAIL with the create-FIX line; declare a
subject that does not exist → FAIL naming it; with subjects: [], blank the
emptySubjects reason → FAIL naming emptySubjects, or drop the exempt row of
a dir that still ships perfSubject.tsx → FAIL … exists but is not declared
(the factory's day0-empty-states lane runs both legs).
34. route-manifest — node tools/check-route-manifest.mjs && node tools/check-web-routes.mjs
Section titled “34. route-manifest — node tools/check-route-manifest.mjs && node tools/check-web-routes.mjs”Every screen and every page is REGISTERED. Two scripts, one step — the shape
boundaries has used since 0.1.x — because the two routers share no rule: expo-router
maps a trailing index to its parent path and has no route groups, parallel routes,
intercepting routes or private _folder exclusion; the App Router has all four and no
index convention. Forcing one parser to serve both would mean branching on surface at
every line, which is two parsers with worse names.
The mobile half (check-route-manifest.mjs) — a hand-authored manifest, re-derived.
The web half (check-web-routes.mjs, 0.6.0) — a generated registry.
34a. the mobile half — apps/mobile/src/routes.ts
Section titled “34a. the mobile half — apps/mobile/src/routes.ts”apps/mobile/src/routes.ts ROUTES must be non-empty;
every entry carries id / titleKey (a catalog KEY, so route names are translatable)
/ path / file / states.{loading,empty,error} test ids (the RNTL states sweep
drives each; the Maestro flows and startup budgets iterate the same array); every
route file under apps/mobile/app/ is claimed by EXACTLY ONE entry or allowlisted
chrome in tools/route-allowlist.json (reasons required). Closure runs both ways
— stale manifest/allowlist entries fail too. The file→URL derivation mirrors
expo-router's own rules ((group) segments vanish, index maps to the parent,
[param]/[...param] declared as :param/*param; layouts, +not-found, API
routes are plumbing, not screens), so path cannot silently disagree with the URL
the router serves. Static, <100ms.
Anti-vacuity: add app/reports.tsx without a ROUTES entry → FAIL naming the
orphan; empty the ROUTES array → FAIL ("vacuous pass"); drop states.error → FAIL
naming the entry and key.
34b. the web half — apps/web/lib/routes.generated.ts (0.6.0)
Section titled “34b. the web half — apps/web/lib/routes.generated.ts (0.6.0)”The commitment docs/harness/enforcement-tiers.md dated to 0.6.0, in its own words:
"the App Router has no equivalent registry, so a web page can land with no id, no
title key and no declared loading/empty/error states."
The registry is generated, not authored — tools/gen-web-routes.mjs walks
apps/web/app for page.* files and derives path and file from position, so the
defect the mobile half spends thirty lines catching (a manifest that disagrees with the
URL the router serves) cannot be written on this surface at all. What an author writes is
only what position cannot tell you — id, titleKey, and the three state test ids — in a
page.meta.ts colocated with the page.tsx it describes. (page.meta.ts, not
route.meta.ts: route.ts is the Route Handler convention and this tree ships two.)
Enumeration is a file walk, not a runtime import, which is the opposite choice from
gen-action-inventory.mjs and deliberate: the tRPC router is a value that must be built to
be read, and the App Router's route set IS a file tree. So this generator needs no install,
no next build and no network, and runs inside the chain on cold node_modules.
Covers: every page has a page.meta.ts or is reviewed chrome in
tools/web-route-allowlist.json (reasons required); every titleKey RESOLVES in
apps/web/lib/i18n/catalog.ts; ids and derived URLs are globally unique (two pages under
different route groups at the same position are a build-time conflict, not two routes);
state test ids are globally unique; a null state needs a reviewed unreachableStates
row; the committed registry matches the file tree; app/not-found.tsx exists, because
without it an unmatched URL renders Next's built-in 404 — unbranded, untranslated, outside
every lane. Route groups, [param], [...param] and [[...param]] are derived;
parallel (@slot) and intercepting ((.)/(..)/(...)) routes are REFUSED by name
rather than guessed at, because a page reached at two URLs cannot have one manifest row.
And the one the mobile half gets for free: mobile proves its declared test ids exist
by DRIVING them in the RNTL states sweep. The web half has no such sweep, so the gate
proves it statically — each declared id must be rendered somewhere in the route's own
segment (non-recursively, and never counting page.meta.ts itself, or every declaration
would prove itself). data-testid={meta.states.empty} is the form named in the failure
message because with it the declared id and the rendered id are one expression and cannot
drift apart.
Shipped ramped (minVersion 0.6.0, until 0.7.0): an install created before 0.6.0 has
pages and no page.meta.ts anywhere, and projects grow into gates rather than being
ambushed by them.
And a browser test per route (1.1.0). Mobile closes every route through a Maestro flow
to a startup budget; the web lane was judged on aggregates only, and the seeded suite
rendered one of its three routes (notes was visited only as an anonymous redirect,
security never). Now, for every registered route, some *.spec.* under apps/web/e2e
must name one of its non-null declared state test ids as a quoted literal, read with
comments blanked. State test ids are globally unique, so a literal names exactly one route.
A null state is never demanded; a route whose every state is null has no id a spec could
name, and is chrome to allowlist. The check reads text: it proves a spec names the state,
and the web-e2e lane proves the spec passes. Its findings are a list of their own behind a
ramp of their own (minVersion 1.1.0, until 1.2.0, expired at 2.0.0, so hard for every install since), consulted only once findings exist:
the seeded notes.spec.ts and security.spec.ts are seedOnInitOnly, so an install
created earlier carries a spec for orgs only until it copies them.
Anti-vacuity: add app/(protected)/o/[orgSlug]/settings/page.tsx with no
page.meta.ts → FAIL naming the page and the URL it would be served at; declare a state
test id nothing in the segment renders → FAIL; delete app/not-found.tsx → FAIL; change a
meta's id without regenerating → FAIL ("stale"); allowlist a page that no longer exists
→ FAIL; declare error: null with no reviewed reason → FAIL; remove every literal of an
orgs state test id from apps/web/e2e (today the three 'orgs-empty' lines in
authenticated.spec.ts) → FAIL naming orgs (/o) and both of its non-null ids (selftest
Canary 36).
35. security-headers — node tools/check-security-headers.mjs
Section titled “35. security-headers — node tools/check-security-headers.mjs”The web response posture, asserted BY VALUE. The gate EVALUATES
apps/web/lib/security-headers.ts under node --experimental-strip-types (no
bundler, no tsx, no node_modules, no new dependency) and diffs what the module
actually returns against the reviewed policy in tools/security-headers.json.
The cheaper implementation — grep the source for frame-ancestors 'none' — is
satisfied by a directive that appears in a comment, in a disabled branch, or in a
string that is never joined into the header. Evaluating is what makes this a check
of the value rather than a check of the text, and it is the same reason the
telemetry-redaction gate was cut in design review: a text parse of a value is not a
check of the value.
Covers: every static header by exact value (HSTS, nosniff, referrer-policy,
X-Frame-Options, COOP, CORP); every permissions-policy feature denied by an
EXPLICITLY EMPTY allowlist rather than by omission; the CSP directives that must
hold exact values (default-src, object-src, base-uri, form-action,
frame-ancestors); required and banned CSP tokens; that 'unsafe-inline' in
script-src never appears WITHOUT 'strict-dynamic' (with it, a CSP3 browser
ignores it and it is a CSP2 fallback — without it, it is an open door); that
X-Frame-Options and frame-ancestors AGREE, so the answer does not depend on which
control the browser honours; that the report-only twin carries a report-uri; and
that authenticated responses are private, no-store with a Vary naming the acting
-org selector — same URL, same edge cache key, different tenant's rows is the shape
of a cross-tenant CDN poisoning bug.
Two decisions are RECORDED rather than omitted, and the gate requires each to carry
a non-trivial reason: hstsPreload (close to irreversible — removal from the browser
preload list takes months) and coep (ships UNSET, because require-corp breaks
every third-party embed that does not send its own CORP header, and a gate that
produces a broken app is a gate everyone exempts).
RFC 9116 security.txt (1.0.0). apps/web/public/.well-known/security.txt seeds
at init — its mandatory Expires is YOUR reviewed bound, the {{SECURITY_TXT_EXPIRES}}
init answer (default init+180d), which is why it is seedOnInitOnly and never planted
into an existing install (a planted bound is a date nobody reviewed — the off-switch
shape 0.6.0 removed from framework-floor.json). This gate holds the CLOCKLESS half:
present ⇒ parses against the RFC line grammar, Contact present as an
https/mailto/tel URI (cleartext http reds), Expires present exactly once as an
RFC 3339 date-time. Absence never reds — an install without the file has no
machine-readable disclosure channel to judge, and SECURITY.md still carries the
human-readable policy. The CALENDAR half — an expired bound, or one past the RFC's
one-year recommendation — rides the scheduled floor-review job
(tools/lib/security-txt.mjs is the shared parser; the cc-floor/eol clock split).
Honest limit. It cannot prove the DEPLOYED response carries these headers. A
correct config behind a header-stripping CDN, or a nonce that never reaches Next's
inline bootstrap, is invisible from here. That half is the web-e2e lane's
security-headers.spec.ts, which reads real response.headers(), asserts Next
actually STAMPED the minted nonce onto its bootstrap script, and collects
securitypolicyviolation events so a policy that blanks the app cannot ship green.
check-web-e2e.mjs holds that spec present via its anySecurityHeaders closure, the
same way it holds the axe scan present.
Anti-vacuity: delete the frame-ancestors 'none' entry → FAIL naming the missing
directive AND the framing disagreement; swap 'strict-dynamic' for 'unsafe-eval' →
FAIL on the banned token; remove 'strict-dynamic' leaving 'unsafe-inline' → FAIL;
shorten the HSTS max-age → FAIL; drop camera=() → FAIL; drop x-org-id from
Vary → FAIL; make authenticated responses public, max-age=60 → FAIL; drop the
report-uri → FAIL. Anti-vacuity on the POLICY itself: a tools/security-headers.json
missing a section FAILS rather than silently skipping the checks that section governed,
and a decisions.coep reason shorter than 20 characters FAILS.
36. e2e — node tools/check-e2e.mjs
Section titled “36. e2e — node tools/check-e2e.mjs”The agent-time fast lane: the WHOLE react-native suite in apps/mobile (jest-expo
- React Native Testing Library) — the states sweep over every ROUTES entry (loading/empty/error via the mock network seam), screen flows (notes optimistic write, matrix pagination, actions modal), boot/layout wiring, primitives a11y — with the shipped screens, expo-router navigation, the tRPC client and error translation running for real against the in-process mock server. Seconds, laptop-complete, and exactly what the CI e2e job runs. Runner detection is module resolution (jest-expo resolved FROM apps/mobile), never subprocess vibes: absent → loud local skip with the install command; CI → fail closed. Hard timeout kill; the last output lines surface on failure. An exit-0 run reporting ZERO tests FAILS ("an empty e2e run is a vacuous pass"). The pure suites (routes closure, i18n, kv, sse, fuzzy scorer) run under the root vitest config instead — no test runs under both runners. The ON-DEVICE proof is the CI Maestro lane, deliberately not here (see Honest losses). Anti-vacuity: break a state testID in a screen → the states sweep (and thus the gate) reds; empty the jest suite → FAIL vacuous-pass.
37. docs-sync — node tools/check-docs-sync.mjs && node tools/check-essential-eight.mjs && node tools/check-conformance-map.mjs
Section titled “37. docs-sync — node tools/check-docs-sync.mjs && node tools/check-essential-eight.mjs && node tools/check-conformance-map.mjs”The agent-facing documentation cannot lie about the gate: CLAUDE.md stays a pure
@AGENTS.md include; the AGENTS.md "The N gates, in order: ..." sentence must
match VALIDATE_STEPS exactly (names, order, count — the release-time doc sweep
becomes mechanical); every pnpm <script> command AGENTS.md advertises must exist
in the root package.json scripts; and every VALIDATE_STEPS name has its own
numbered section (### <n>. <name> — ) in THIS catalog — the anti-vacuity record
is part of the gate, so an undocumented step cannot ship. The agent roster is part
of the same surface: every .claude/agents/*.md must parse under the pinned
frontmatter grammar (tools/lib/agent-roster.mjs; a parse failure is a RED, never
a skip) and the reviewers (security-reviewer, web-security-reviewer,
mobile-security-reviewer, accessibility-reviewer, design-reviewer,
architecture-reviewer, torvalds-reviewer, citation-verifier — the roster is
REVIEWER_AGENTS in tools/lib/agent-roster.mjs) may hold ONLY
the read-only allowlist and must disallow Write + Edit — the README's
"read-only by construction" claim, machine-asserted. Since 1.1.0 an agent file
may carry a harnessFallbackModels: a, b list, which Claude Code ignores and
reviewer-verdicts judges a security reviewer's recorded model against: where
present it must name at least one model and each one once, the pin counting as
an entry, or docs-sync reds naming the file (fallbackListProblems; the
harness repository's check-plugin-manifest applies the same rule to the
shipped roster). A file without a list is fine: only its pin counts.
The reviewer severity contract (1.1.0, ramped until 1.2.0, expired at 2.0.0, so hard for every install since). Each reviewer body
states, on lines of their own before its closing verdict paragraph, the severities it
ranks findings at and the ones that make its verdict BLOCK:
Severities: CRITICAL, HIGH, MEDIUM, LOW and Blocking: CRITICAL, HIGH. The gate
reds a body with either line missing or stated twice, a Blocking: line that names a
severity Severities: does not list, and a Blocking: line without the floor,
CRITICAL and HIGH: a narrower line would let a HIGH finding ride a PASS. The lines
are read from the body only, anchored to the start of a line, by
severityContractProblems in tools/lib/agent-roster.mjs, which the gate reaches through
a namespace import: a parked fork of that lib without the judge is one finding naming
it. The SubagentStop hook reads the same Blocking: line (the reviewer-verdicts
section below). Ramped, like the verdict demand's position below and unlike the rest of
the roster check, because since 1.0.2
update parks a locally modified owned body instead of overwriting it, so a body forked
before 1.1.0 got dated NOTEs until 1.2.0, which arrived at 2.0.0; a fresh scaffold is live
from day one.
Reviewer bodies close on the verdict demand (1.1.0, ramped until 1.2.0, expired at 2.0.0, so hard for every install since). Each
reviewer body must ask for VERDICT: PASS or VERDICT: BLOCK, a hard red on every
vintage as it has been since 0.2.0, and that demand must now be its closing instruction.
The body is trimmed, split into paragraphs on blank lines and whitespace-collapsed, and
its last paragraph must be exactly "End with exactly one final line: VERDICT: PASS or
VERDICT: BLOCK.", optionally followed by the rationale sentence every shipped body
carries after it. The rule is verdictDemandProblem() in tools/lib/agent-roster.mjs,
the one definition the harness's own tests also hold the shipped bodies to. It exists
because the SubagentStop hook reads a PASS only as the reply's last line: v1.0.1 shipped
two bodies whose closing paragraph asked for the top 3 fixes after the verdict, the hook
bounced every review that obeyed them, and this gate, which then checked presence only,
passed both. The position rule rides a ramp because update keeps a re-recorded fork of
an owned body (1.0.2) that no earlier release judged for position: below baseVersion
1.1.0 each finding printed as a dated NOTE that expired in 1.2.0, which arrived at 2.0.0, and
a fresh scaffold is live from the start. A fork of tools/lib/agent-roster.mjs that lacks the helper still
loads the gate; presence is judged with the 1.0.x test and one finding, through the same
ramp, names the stale lib. Honest limit: only the last paragraph is judged. An EARLIER
paragraph that asks for text after the verdict line is not caught here, and the hook
still bounces a PASS reply that obeys it.
Agent-surface truth (0.9.5, one ramp until 0.10.0). AGENTS.md's own line-budget
sentence ("Keep under ~N lines") is checked for TRUTH — a claims-check, not a size
cap: no sentence, no check (a fork may unbudget its memory file); present-and-false
reds. And the advertised-command closure extends from AGENTS.md into the BODIES of
.claude/{rules,commands,skills,agents}: every backticked pnpm <script> names a
real script, every backticked node tools|tests|.claude/<path>.mjs names a real
file — the two grammars that cannot false-positive on prose, which is what clears
the open-scanner objection the doctrine-symbols map records. Ramped because
AGENTS.md is seeded (every pre-0.9.5 install keeps its old sentence) and skills may
be consumer-authored.
ADR content shape (0.9.5, ramped until 0.11.0). adr-guard holds ADR presence;
this holds the mechanical slice of content: ## Context / ## Decision /
## Consequences-or-## Honest losses / ## Sources, prefix-matched (multi-part
## Decision 1 — … headings are legitimate authorship), each with ≥ 40 characters
of substance; a **Status:** in the closed vocabulary; every [corpus: <id>]
resolving against tools/mcp/corpus/index.json or tools/mcp/corpus/project.json
(skipped while either file is malformed, which provenance reds); every bare source
URL's host on the tools/lib/citation-domains.mjs allowlist, whose red says to pin the
authority in project.json instead. ## Alternatives Considered stays
advisory on purpose — a shape gate that reds an honest "no alternative existed"
teaches authors to fabricate alternatives. NO escape file: the remedy is always
editing the ADR; an allowlist here would be a place to park unshaped ADRs forever.
Anti-vacuity: falsify the budget sentence → FAIL naming both numbers; advertise
pnpm ghost in a rule body → FAIL naming the file; strip ## Sources from an ADR →
FAIL naming the section; cite an unknown corpus id or an off-allowlist host → FAIL
naming it, and an id in neither corpus file names both (fixtures:
tests/gates/check-docs-sync.test.mjs).
The deferral ledger (0.7.0). The harness's prose makes dated promises —
"Deferred to x.y.z", "out of scope for x.y.z" — and until this release nothing read
them: three sites carried the same stale one into the very release it named, and a
scoping line stayed frozen four versions. Every such sentence now has ONE machine-read
home, tools/deferrals.json (entries { id, file, target, reason ≥ 40 chars, reviewedOn }; file may be an array when one decision is stated at several sites),
closed BOTH ways over a DECLARED surface list: this catalog, the top-level
tools/*.mjs gate scripts, and tools/auth-posture.json. A dated sentence with no
entry for its file reds; an entry whose file dropped the sentence reds as stale; and
an entry whose target the installed harnessVersion has reached reds until the author
ships the check or moves the date — the ledger is git-clean-enforced, so a move is a
reviewed diff, never a flag. The sentence regexes are \s+-joined because markdown
hard-wraps dates onto the next line, which is how a per-line reader missed the stale
one. enforcement-tiers.md is deliberately excluded (its Target column has its own
reader — the surface derivation and the closes: probe), as is every SEEDED file
(a consumer's prose is not the harness's to red; tools/data-flow.json's export
target has its own enforcement in check-data-flow.mjs). Honest limit: the scan
reads the template's OWNED surfaces only. The factory-side reader that limit used to
owe now ships: the harness repo's own dated and conditioned debts live in its
scripts/obligations.json register, read by scripts/check-obligations.mjs — release
targets judged clocklessly against package.json in its per-PR machinery lane, calendar
targets only in its scheduled lane, conditions held to shape + evidence rather than
time, with the deferral census and the future-until ramp fleet closed as unions
against this ledger and the shipped call sites. Version-ramped
(0.7.0, expires 0.8.0): an install whose own tools/*.mjs carry dated prose gets one
release of dated NOTEs to ledger or re-word it.
Anti-vacuity: add a gate to VALIDATE_STEPS without touching AGENTS.md → FAIL
printing documented-vs-actual chains; advertise pnpm ghost → FAIL naming it;
delete a numbered section here → FAIL naming the undocumented gate; grant
security-reviewer Bash → FAIL naming the agent, the grant, and the doctrine;
empty a harnessFallbackModels list, or make it repeat an entry or the pin → FAIL
naming the file and the key;
drop Blocking: from security-reviewer.md, or narrow it to Blocking: HIGH → FAIL
naming the file (a NOTE with its deadline on a pre-1.1.0 install);
append Follow it with the top 3 fixes. to the closing paragraph of
.claude/agents/torvalds-reviewer.md → FAIL naming that file (a dated NOTE below
baseVersion 1.1.0 before harness 1.2.0, RAMP EXPIRED from it, so at 2.0.0); delete a reviewer's verdict
demand → FAIL naming the file on every vintage;
write "Deferred to x.y.z" (a real release number) in any scanned surface with no
ledger entry → FAIL naming file, line and target; delete the sentence an entry
ledgers → FAIL naming the stale entry; plant a manifest at or past an entry's
target → FAIL demanding discharge or a reviewed re-date
(tests/gates/check-docs-sync.test.mjs).
The Essential Eight conformance register (0.9.9) — check-essential-eight.mjs.
The step's second script, folded here rather than injected as a chain step of its own
because it is the same KIND of closure: a claim in a reviewed file must name a control
something actually runs, and it reuses the identical derivation
(tools/lib/live-controls.mjs). tools/essential-eight.json carries all 149
cumulative requirements of ASD's Maturity Level Three, each graded against THIS tree.
Read the register's own header before its numbers: a codebase cannot hold an Essential Eight maturity level and neither can a generator. Maturity attaches to an organisation's system, ASD certifies no products, assessment is all-or-nothing per strategy AND per package, and 46 of the 149 requirements are unreachable by any repository. The register exists so a generated application is never the BLOCKER to its operator's assessment; it never claims the application IS ML3.
Five closures. Census by count — per-strategy totals must equal 13/16/23/29/19/11/27/11;
a naive union of the model's appendices gives 152 and is WRONG, because exactly three
ML1/ML2 requirements are SUPERSEDED at ML3 and are recorded in supersededAtML3[]
rather than deleted (a cut requirement and a forgotten requirement look identical six
months later). Live controls — every effective or alternate-control row names a
step, job, or gate script that exists, and a CONDITIONAL one must say (path-filtered)
or (schedule-gated), because "this control exists" and "this control ran on this
commit" are different claims. Shared-clause dedup — eight logging/incident clauses
repeat across four strategies (32 of the 149 rows on one artefact set), so
sharedClauses[] declares each artefact once and at most one row may claim it.
Deliberately NOT asserted: that the instances share an outcome. The text is identical
but its SUBJECT is the parent strategy's log stream, and those differ — the audit trail
genuinely protects privileged-access events and genuinely has no application-control
events to protect, so forcing equal grades would inflate three rows or deflate one.
Row shape — alternate-control carries assessorMayRefuse (an alternate control is
demonstrated by the system owner, never pre-earned by a generator), not-applicable
carries a written negative proof, and not-implemented names an obligations row so the
register cannot hide a gap. Negative proof — the macro strategy's eleven
not-applicable grades rest on there being no document-parsing surface, which is
decidable, so it is decided: [storage] disabled and no upload route.
What it deliberately does NOT do is red on not-implemented. Rows are honestly unbuilt
and say so; redding on them would create steady pressure to grade generously to get
green, which is the exact failure the register exists to prevent. What reds is a
MALFORMED or INFLATED claim. The simulated-activity evidence tier is judged
factory-side by scripts/check-essential-eight-evidence.mjs, because
tests/canary/injections.json never ships to an install.
Anti-vacuity: delete a row → FAIL naming the strategy and both counts; point an
effective row at a control nothing runs → FAIL naming it; let a second shared-clause
instance claim a control → FAIL naming the double-count; drop an obligation from a
not-implemented row, or assessorMayRefuse from an alternate-control row, or empty
a negativeProof → FAIL naming the row; set [storage] enabled = true → FAIL demanding
the eleven macro grades be revisited; empty requirements[] → FAIL, because an empty
conformance register is not a clean bill of health but a missing one.
The conformance MAP (1.0.0) — check-conformance-map.mjs. The step's third script,
folded here on the identical argument as the second: a claim in a reviewed file must name
a control something actually runs, and it reuses the identical derivation
(tools/lib/live-controls.mjs) widened by one class — a write-guard rule id from
.claude/hooks/lib/guard-rules.mjs is a live control too, because it runs on every
Edit/Write/Bash tool call. tools/conformance-map.json carries every requirement of OWASP
ASVS 5.0.0 (345 rows at tag v5.0.0, across 17 chapters — an earlier planning figure of
369 was wrong and is recorded in the header so nobody re-derives it), OWASP MASVS 2.1
(24 controls) and the EU Cyber Resilience Act Annex I (23 points), text verbatim, each
graded covered | partial | not-covered | not-applicable against THIS tree.
Read the register's own header before its numbers: a mapping is not a verification and no level is claimed. A verification level attaches to a verification of an application performed by an assessor; CRA conformity is a manufacturer's legal act no code tree performs; and no harmonised standard for the CRA had been cited in the Official Journal as of the register's verified date, so the CRA rows carry a shelf life. The map exists so an assessor starts from an honest ledger — which live control bears on which requirement, how far it reaches, and what is left — and so a consumer never mistakes a template for a verification.
Five closures. Census by count — the total, each standard's total, ASVS by chapter AND
by level (a row can change one without the other), MASVS by group, CRA by part; duplicate
ids and non-verbatim texts are census defects too. Live controls — every covered or
partial row names a chain step, a shipped CI job, a gate script a lane invokes, or a
guard-rule id, and a CONDITIONAL one must say (path-filtered), (schedule-gated) or
(event-gated) (the third is the PR-only OSV diff scan, which neither of the first two
describes honestly). Rows with module set are CONDITIONAL on an opt-in module — the
marker is docs/modules/<name>/README.md, which every module ships — and are judged for
liveness ONLY where the module is installed; on a tree without it they produce no finding,
because nothing in that tree can decide them. Row shape — covered may not rest on
the documentation tier; an organisation-boundary row may not be graded covered or
partial; not-covered carries a note of substance; not-applicable carries a written
negative proof and, above the documentation tier, a negativeCanary; a row that claims
nothing names no control, canary or proof. Unmapped controls — every step of
VALIDATE_STEPS ∪ STOP_HOOK_STEPS is either named as some row's control or keyed in
unmappedControls with a written reason, never both and never neither, so a control the
map forgot cannot hide. Regen-diff — docs/compliance/controls-crosswalk.md and
docs/security/threat-model.md are generated from the register, the chain and the
guard-rule tables by tools/gen-conformance-docs.mjs, and this script runs it in
--check mode so a hand-edit to either reds. And through all of it, the claim-sentence
ban: no note, negativeProof or header line may carry an affirmative ASVS/MASVS/CRA
standing claim (tools/lib/standards-claim.mjs, the judgement scripts/hygiene.mjs sweeps
the shipped prose with).
What it deliberately does NOT do is red on not-covered. Those rows are honestly unbuilt
or the consumer's to build and say what would be needed; redding on them would create
steady pressure to grade generously to get green. What reds is a MALFORMED or INFLATED
claim. The canary half — every claim names a registered can-fail proof for its OWN control,
resolved against steps ∪ lanes ∪ hookRules — is judged factory-side by
scripts/check-conformance-evidence.mjs, because tests/canary/injections.json never
ships to an install.
Anti-vacuity: delete a row → FAIL naming the count on every axis it moved; grade a
covered row at the documentation tier → FAIL; point a row at a control nothing runs →
FAIL naming it; strip a conditional lane's marker → FAIL; name a module tools/modules.json
does not list → FAIL; empty a negativeProof → FAIL; give a not-covered row a control →
FAIL; drop a chain step from both the rows and unmappedControls → FAIL naming the step;
write "is ASVS Level 2 compliant" into a note → FAIL quoting it; hand-edit either generated
document → FAIL naming the drifted file; empty requirements[] → FAIL, because an empty
conformance map is not a clean bill of health but a missing one
(tests/gates/check-conformance-map.test.mjs).
Both register scripts are stamped (1.1.0). A warm run over unchanged inputs prints
essential-eight: STAMPED — inputs unchanged since last green run (or the
conformance-map line) and exits 0 without judging; see docs/harness/README.md, Stamped
gates. Each stamp is keyed on what its verdict reads, listed in tools/lib/stamp-inputs.mjs
beside the manifest, the script and the stamp machinery: essential-eight on its register,
tools/harness.config.mjs, .github/workflows and the libraries it imports;
conformance-map on its register, tools/harness.config.mjs, .github/workflows,
.claude/hooks/lib/guard-rules.mjs, tools/modules.json, the docs/modules tree (the
module markers), tools/gen-conformance-docs.mjs and the two documents it compares, and the
libraries both import. Neither is keyed on the evidence paths a row's proof names: neither
script opens them, because that field is judged as text. essential-eight's negative proof
reads supabase/config.toml and the upload-scan roots, which are not stamp inputs. It runs
on every run, before the stamp is consulted, and the script stamps only when the proof finds
nothing, so a storage flip or an upload surface reds the turn it lands, warm stamp or not.
CI always runs both scripts in full: CI=true or HARNESS_REQUIRE_TOOLCHAINS=1 (which the
shipped merge gate and validate --ci-parity set) ignores a stamp, and update and
graduate delete every stamp. The step's first script, check-docs-sync.mjs, is not stamped.
Anti-vacuity: with a stamp in place, edit any one input → the script judges again;
set [storage] enabled = true, or import expo-document-picker under apps/mobile/src →
FAIL on the warm run; CI=true → the full judgement
(tests/gates/check-essential-eight.test.mjs, tests/gates/check-conformance-map.test.mjs).
the validate runner — serial by default, pooled under --report-all
Section titled “the validate runner — serial by default, pooled under --report-all”node tools/validate.mjs runs the chain strictly serially with streamed output,
stopping at the first failure — the fast agent edit-loop (one red, one fix). The
Stop hook instead passes --report-all so an agent sees EVERY red at once; there,
maximal runs of consecutive read-only gates (the PARALLEL_SAFE set: provenance,
expo-policy, native-deps, version-sync, prompts, licenses, schema-rls, migrations,
contracts, styleguide, route-manifest, docs-sync) execute in a small pool, with
output flushed in CANONICAL order. Any step NOT in that set runs exclusive:
build/e2e (subprocess-heavy), perf-budget (a wall-time measurement CPU
contention would flake red), and any consumer-added custom step — an unknown step
is never assumed pool-safe. provenance and migrations share a git resource
key so they never race .git/index.lock.
--ci-parity (1.0.4) gives a local run CI's posture. It sets
HARNESS_REQUIRE_TOOLCHAINS=1 for every step, so a gate whose prerequisite is
missing fails instead of skipping and no stamp is honoured, and it prints that
posture as its first line. A runner that predates the flag ignores it silently,
so a run without that line did not get the posture. Each gate that fails for a
missing prerequisite records it (noteMissingPrerequisite in
tools/lib/gate.mjs: skipOrFail, and the CI branches of the partial legs in
migrations, version-sync and styleguide), and after the summary's total
the runner prints one validate --ci-parity: <step>: <gate> — <reason> line per
record, in step order, or says that none was reported. VALIDATE_TIMINGS stays
the last line. The records live in a temp directory outside the project and
never decide the exit code. The flag composes with --min-floor and
--report-all: node tools/validate.mjs --min-floor --ci-parity is the local
counterpart of CI's static job. With --list it changes nothing, and it
refuses --stop-chain, because the Stop chain has no single CI equivalent and
its reviewer-verdicts step needs a live turn. What it does not cover is in
docs/harness/README.md (skip-local / fail-closed-CI asymmetry).
Stop-hook runtime suites (STOP_HOOK_STEPS)
Section titled “Stop-hook runtime suites (STOP_HOOK_STEPS)”rls-isolation — node tests/rls/run-rls.mjs
Section titled “rls-isolation — node tests/rls/run-rls.mjs”Live cross-user isolation against the running local stack, as it stands: the runner
applies no migration (the first pnpm db:up and pnpm db:reset do). Seeded positive control (a deny-all database must NOT pass), zero-row
cross-user SELECT/UPDATE/DELETE, SQLSTATE 42501 on INSERT smuggling,
pooled-connection GUC-leak detector (pool max=1), and the pg_catalog gate (FORCE
RLS flags, per-op policies, leading-column owner indexes, initPlan-shaped
predicates, patched pgvector, non-BYPASSRLS role). Unreachable database → loud
SKIP locally; in CI with migrations present, unreachable = FAIL. Stamped locally (1.0.4):
with the stack up and its declared inputs, the supabase --version output and the
database's identity (server start time + applied migration versions) all unchanged since
the last green run, it prints rls-isolation: STAMPED and runs neither suite; any non-empty
CI always runs both (see docs/harness/README.md, Stamped gates). Since 1.0.4 every spawn
uses the workspace Supabase CLI in node_modules/.bin when one is installed
(tools/lib/supabase-cli.mjs; PATH otherwise, and always on Windows), the run prints
which CLI it used, and the supabase-js suite gets SUPABASE_DB_URL from
supabase status -o env beside the API URL and keys.
There is no plan probe in THIS suite, and that is a placement decision, not an
omission. A plan is a planner opinion at one statistics snapshot; against the
handful of rows supabase/seed.sql writes it is not merely noisy but WRONG — the
planner correctly reads one page rather than using an index, so a plan assertion
here would either flap or be satisfied for the wrong reason. The probe therefore
lives where the cardinality does: tools/check-db-perf.mjs, in the path-filtered
db-scale CI lane, against supabase/seeds/scale.sql. See db-perf below.
Anti-vacuity: drop one policy in a new migration → catalog gate + isolation
matrix FAIL; break the impersonation helper → the positive control fails, proving
the suite cannot green vacuously.
unit — pnpm exec vitest run --coverage --silent
Section titled “unit — pnpm exec vitest run --coverage --silent”The node-side behavioral net: server (auth clock-skew, envelope bounds,
production+stub boot-fatal, skew middleware, csrf), packages (contracts
drift, importer property tests, eval fixture scorer), and the PURE mobile modules
(i18n incl. the pseudo-locale derivation and RTL direction table, routes closure,
kv, the fuzzy scorer, recents) — pure meaning zero react-native in
the import closure, so Node's runner is honest for them — and, since 1.1.0, the Edge
Function handlers: vitest.config.ts derives from the tree every *.test.ts under
supabase/functions that imports from 'vitest' (a deno test file is never collected)
and measures each top-level directory there that holds one, so an existing untested
helper never weighs on the aggregate. Each function's index.ts shell is excluded; it
cannot load under Node. --coverage enforces the
aggregate thresholds in vitest.config.ts and writes the istanbul map the
diff-coverage step reads.
Anti-vacuity: drop a large untested module → the aggregate threshold reds the
run.
mobile-unit — jest-expo --coverage --silent (via pnpm test:mobile)
Section titled “mobile-unit — jest-expo --coverage --silent (via pnpm test:mobile)”The react-native half of the unit floor: RN components/screens cannot run under
the node runner without a fragile transform pipeline, so apps/mobile runs under
jest-expo (RNTL) — the same suites the e2e gate runs, here with --coverage so
the jest istanbul map lands in apps/mobile/coverage/ for the next step. The
runner split is pinned in jest.config.js (its ignore list mirrors the vitest
include list, so no test ever runs under both runners).
Anti-vacuity: break a screen's reducer → the flow suite reds; delete every
assertion from a suite → test-quality reds it below.
diff-coverage — node tools/check-diff-coverage.mjs
Section titled “diff-coverage — node tools/check-diff-coverage.mjs”Per-file coverage floors on every CHANGED source file under apps/*/src,
packages/*/src or, since 1.1.0, supabase/functions (merge-base diff in CI;
worktree + staged + untracked locally — the brand-new uncommitted feature file is
exactly the case that must not slip), read from the TWO maps the unit steps just
wrote: the vitest map for server/packages/pure-mobile files, the jest map for
apps/mobile/**. Each changed code file must be present in its runner's map
(absent = no test imports it) and clear the per-file floors declared next to that
runner's config. A missing map FAILS CLOSED (the chain was reordered or the
artifact deleted); an empty diff passes with a note.
A changed Edge Function file in a directory with no vitest suite is named as absent,
with the one green path (add the suite); on an install whose baseVersion predates
1.1.0 those findings were diff-coverage: NOTE — (ramp) … lines until 1.2.0, which arrived
at 2.0.0, so they red on every install since.
Anti-vacuity: add an untracked apps/web/src/ file with an exported
function and no test → FAIL naming the file as absent from the coverage map.
duplication — node tools/check-duplication.mjs
Section titled “duplication — node tools/check-duplication.mjs”Copy-paste rot: a zero-dep token clone detector over apps/*/src +
packages/*/src (comments stripped, string/number literals normalized, ≥70-token
AND ≥6-line matching runs reported once with both sites and a stable content
fingerprint). Exact-ish matching, so a red is a genuine paste, not two functions
that rhyme. Reviewed accepted clones live in tools/duplication-allow.json
(write-guard-protected), keyed by fingerprint so an accepted clone stays accepted
after it moves lines.
Anti-vacuity: paste a ≥70-token block across two files → FAIL naming both
sites + fingerprint.
The sweep and --explain (2.1.0, #186): read modes that decide no verdict. The Stop run
is the L0 scan above and nothing else, and it prints no advisory NOTE until the exact rule
goes live (#201). Two read modes sit beside it. node tools/check-duplication.mjs --sweep --json prints one JSON document over one index of the tree: every callable in L0's scope
(the scope list is tools/lib/duplication-scope.mjs, one copy for both readers) and every
SQL function the migration history leaves live (supabase/schemas fills in the rest), read by
tools/lib/shapes.mjs; the importer index (tools/lib/importers.mjs); and home()
(tools/lib/homes.mjs), which judges where one copy of a class may live by the rules the
architecture step, the vertical-anatomy laws and the workspace walls already enforce. Its
families: exact (equal bodies of at least 30 tokens with a legal home, tier owed),
exact-nohome (no legal home, advisory), exact-small (20 to 29 tokens with equal
literals, advisory), near-miss (a pair, with where the two differ), and six complexity
recipes (tools/lib/complexity.mjs). Every record is a closed advisory record (the
recorder's schema), every status is advisory, and the tier the exact rule would give
rides as facts.tier. The document carries one terminator per leg, holds no timestamp,
absolute path or environment value, and is sorted by key, so two machines print the same
bytes; it exits 0 only when every leg completed. --explain '<key12>' prints the record a
NOTE or an issue names, through the closed printers: its members, its home and the move in
a fixed vocabulary, and its facts. A key no record has, or one that is not 12 hex digits,
exits 1. The TypeScript legs read through the project's own typescript; when it cannot
load they are written incomplete, the run exits 1 and records the missing prerequisite, and
the SQL legs still run. A name no closed printer can carry (one with a $, a #private or
quoted member, an over-long name) gets no record, and SQL overloads are told apart by
their argument types. A CREATE OR REPLACE that keeps every input name is read as
replacing that overload, even where PostgreSQL adds one because only a type changed. Only
a callable declared at a file's top level is an export, and the module graph reads
side-effect imports and export type * as dependency-cruiser does. home() reads only the
architecture rules at error severity (cycles and dependency types included), and a rule
condition it does not know refuses the home rather than allow one the step would red. The thresholds are provisional until the
exact rule's gate-proposal freezes them.
Anti-vacuity: paste a 30-token function a package exports into a package that depends
on it → --sweep --json lists an exact record with home import, and --explain on its
key names the exported copy as the target; run it where typescript is not installed → the
TS legs read complete: false and the run exits 1.
i18n — node tools/check-i18n.mjs
Section titled “i18n — node tools/check-i18n.mjs”The locale seam is real and nothing bypasses it: (1) no hardcoded user-facing
string — JSX text under AppText, user-facing props (accessibilityLabel,
accessibilityHint, placeholder, alert/toast copy), and the object literals
that hold copy (route titles, action command titles, matrix column headers) all
resolve through catalog keys; (2) Intl / toLocale* / .toFixed( only inside
src/i18n/ — .toFixed(2) hardcodes the . decimal mark; (3) no dead catalog
key. A static scan, not a type checker: a string assembled at runtime is invisible BY
CONSTRUCTION — the pseudo-locale sweep in the RNTL lane (every en source string
must come back mangled under en-XA; ar-XB is the RTL pass) is the behavioral
other half.
One scan, the syntax tree (1.1.0; alone since 2.0.0). Checks 1 and 2 were regular
expressions over comment-blanked text through 1.0.x, one quote form each, with JSX text that
could not hold =, ;, a backtick or $. 1.1.0 added a walk of the TypeScript syntax tree
(tools/lib/i18n-tree.mjs, one parse per file with the project's own typescript
devDependency) and judged the union for one release, with what only the walk saw ramped
for installs below 1.1.0 until 1.2.0. 2.0.0, the first release at or past that deadline,
retired the expressions: the walk alone reads JSX text, a listed attribute or
label/title/subtitle/description property whose value is a string or a
template literal with no substitutions, bare or inside {…}, and Intl /
toLocale* / toFixed, on every install, with no ramp. Its fixture parity with the
expressions' red and not-copy fixtures (tests/gates/i18n-tree.test.mjs) is what licensed
the retirement. When typescript cannot load, checks 1 and 2 do not run and the output
says so: a loud NOTE locally, a failure in CI; checks 3 and 4 still judge.
The escape is keyed on content. tools/i18n-allow.json entries are
{"key": "<12 hex characters>", "reason": "…"}. The key is a sha256 over the file's
path, the finding kind, the attribute or property name and the text with its
whitespace collapsed; every FAIL line prints the entry ready to paste, and inserting a
line above the string does not move it. A malformed entry, or a key that matches no
finding, FAILS. A 1.0.x {"site": "file:line"} entry muted its line below baseVersion
1.1.0 until 1.2.0; since 2.0.0 it is malformed on every install, and the message says the
FAIL line of the string it muted prints the key that replaces it.
Anti-vacuity: hardcode "Add a note" in a screen → the scan reds it; put
<Text accessibilityLabel={'Close dialog'} /> under apps/mobile/src → only the
tree walk reports it, and the FAIL names "Close dialog" (accessibilityLabel attribute); assemble the string from fragments → the scan stays green and the
pseudo-locale sweep reds it — run both before trusting either.
test-quality — node tools/check-test-quality.mjs
Section titled “test-quality — node tools/check-test-quality.mjs”Assertion PRESENCE — the cheap ~50ms half of the assertion-quality control:
(1) a committed .only is FATAL with no escape (it silently disables every other
test while the suite reports green); (2) a declared-but-never-run test
(.skip/.todo modifier, xit) is reviewable via tools/test-quality-allow.json
(reason mandatory); (3) a test body with no assertion call reds. The runtime
conditional test.skip(condition, reason) is a different construct and stays
green. This gate is GAMEABLE ALONE (expect(true).toBe(true) clears it) and that
is understood: what proves a test would NOTICE a break is the mutation lane, and
that is why both exist.
Anti-vacuity: commit an it.only → FAIL, no allowlist accepted.
mobile-perf — node tools/check-mobile-perf.mjs --closure
Section titled “mobile-perf — node tools/check-mobile-perf.mjs --closure”The CLOSURE half of the mobile performance floor, static and ~10ms: every route in
src/routes.ts must have a Maestro flow (maestro/flows/<id>.yaml) AND a budget
row in tools/startup-budget.json — and stale flows/rows red. This is what makes
the device lane a FLOOR rather than a note about the seed screens: an agent cannot
end a turn having added a screen that no machine check will ever time. The
MEASUREMENT half (check-mobile-perf.mjs without --closure, reading the device
lane's artifacts/perf-results.json: cold-start am start -W TotalTime,
reportFullyDrawn, per-screen budgets) needs an emulator and minutes — CI-only.
Anti-vacuity: register a route with no flow file → FAIL naming the missing
flow and budget row; leave a stale row for a deleted route → FAIL.
reviewer-verdicts — node tools/check-reviewer-verdicts.mjs
Section titled “reviewer-verdicts — node tools/check-reviewer-verdicts.mjs”Stop-chain step 10, and the only check in the harness whose subject is the TURN
rather than the tree: every reviewer whose MUST BE USED trigger patterns
(reviewed data in tools/reviewer-triggers.json) match this turn's diff must
have returned VERDICT: PASS, recorded by the SubagentStop hook
(.claude/hooks/subagent-verdict.mjs) into the session-scoped ledger at
.harness/reviewer-ledger.jsonl. The Stop hook passes the turn's identity down
(HARNESS_SESSION_ID/HARNESS_PROMPT_ID), and this 1.0.x judgement narrows the
ledger to THIS turn, so last turn's PASS satisfies nothing under it (since 2.0.0
the step requires only the session id, and the reviewer ledger v2 keys on the
session and the entry's format: the ledger key, below). Five failure modes: an owed
reviewer that never ran BLOCKS, naming the trigger path that summoned it; a
reviewer that ran and returned VERDICT: BLOCK blocks loudly — that is the
finding it exists to produce, and a turn does not end on a BLOCK (it stands for
the rest of that turn: a same-turn re-run that returns PASS does not clear it,
and the message says so); a PASS whose
path_state binding does not match the tree at Stop time reds as the reviewer
having seen a DIFFERENT TREE than the one the turn is shipping — the hook
records, beside each verdict, a sha256 over the sorted (path, content-sha256)
pairs of the changed files matching that reviewer's triggers (per-reviewer
scope, so follow-up work on non-owed paths sends nothing stale; deleted files
hash as DELETED), the step recomputes it, and a mismatched binding — or a
null/missing one, as every pre-0.7.0 hook entry is — fails toward re-review,
because "a reviewer ran" and "a reviewer reviewed THIS" are different claims
(this class alone rides a fresh 0.7.0 ramp, until 0.8.0: a mid-session upgrade
delivers the new gate into a turn whose earlier PASSes lack the binding); a
security reviewer's PASS on a model that is neither the pin in its agent file
nor on that file's harnessFallbackModels list, or whose model the hook could
not read, reds, while every other verdict off its pin is named (1.1.0, the
model record below); and
on an install whose baseVersion predates 0.6.0 running harness >= 0.7.0, the
formerly NOTE-withheld findings land as hard reds under a RAMP EXPIRED
banner (the 0.6.0 ramp's deadline arrived — sweep the findings, then
graduate), while a 0.6.0-vintage install reds plainly with no banner: its ramp
is inert, not expired. Fail closed in every direction that matters: a missing
tools/reviewer-triggers.json is a broken control, not an empty policy
(FAIL); a missing, unreadable, or unparseable ledger BLOCKS; a missing
session identity skips loudly outside the Stop hook and FAILS in CI, where the
hook that supplies it must have changed. What it deliberately does NOT judge is the
CONTENT of a review: a PASS is an attestation by a read-only agent whose
tools, pinned model, fallback list and body are hashed in
tools/agents.lock.json (its models map records the pin alone; the model a
verdict RAN on is recorded in the ledger and judged, below) — whether it was
a GOOD review is not a property any file can hold.
The reviewer ledger v2 (1.1.0, behind a ramp until 2.1.0). Everything
above is the 1.0.x judgement. On an install whose baseVersion predates 1.1.0
it stays the verdict, and v2's findings print as NOTEs; where v2 is live, v2
decides. v2 judges the BRANCH. Its owed set is reviewChanges() in
tools/lib/git-diff.mjs: the diff from the merge base with the branch's
upstream (the PR base in CI) to the working tree, plus untracked files, with
deletions and both sides of a rename included and .harness/ left out. So a
migration committed before Stop is still owed, and a deleted policy owes
security-reviewer. A push moves an upstream that is the branch's own remote
branch, so there the owed set shrinks to what is not pushed yet; an upstream
set to the branch it will merge into keeps the whole branch owed. The ledger is
read for the whole session. A BLOCK stands until the SAME agent_id returns
PASS at the current digest; a fresh run is a second opinion and retracts
nothing. A PASS counts, in its own prompt or a
later one, only when three digests agree: the one the hook recorded at
SubagentStart (path_state_start, from .harness/reviewer-dispatch.jsonl),
the one it recorded at the verdict (path_state_stop), and the one now. A
review of a moving tree therefore does not count, and a verdict with no start
record reds with a finding that names the SubagentStart wiring, which
wiring cannot see. The wholeTurn class in tools/reviewer-triggers.json
(torvalds-reviewer, citation-verifier) is owed on every non-empty diff, and
its digest covers the whole diff. A PASS from another session still counts for
nothing, and a torn line that claims this turn still fails closed. With no
merge base (a fresh git init, or a branch with no upstream) v2 does not
judge: the step prints a NOTE saying so and the 1.0.x judgement decides. Every
clean-scaffold run in the harness's own CI takes that path. path_state keeps
its 1.0.x meaning.
The ledger key (2.0.0): the session and the format, not the prompt. Under
v2 a PASS is current while its digest pair matches the tree, so prompt_id
only forced re-runs, and it leaves v2's key. The SubagentStop hook stamps every
entry with v, the ledger format (LEDGER_FORMAT in
tools/lib/reviewer-verdicts.mjs, 2.0.0), and v2 reads the session's
entries by session and format (readSessionEntries). An entry in another
format, one a hook from before 2.0.0 wrote (no stamp) or any other stamp, is
returned apart and never counts as a PASS, nor as the PASS that clears a
BLOCK; a BLOCK in any format still stands until the same run passes in the
current format, in ledger order. A reviewer whose entries in the session are
all in another format reds with a finding that names the format and asks for
one re-run, and says that a kept fork of the hook or the lib writes such
entries on every run until the parked copy is merged; it never reads as "did
not run". This is not ramped: it is the 2.0.0 key, and the format finding is a
v2 finding, so it rides v2's ramp. The step requires HARNESS_SESSION_ID
alone. The Stop hook still passes HARNESS_PROMPT_ID and the hook still
records prompt_id: the 1.0.x judgement keeps the prompt in its key (its
path_state leaves deletions out and has no dispatch digest, so an earlier
prompt's PASS is not provably about this tree), a step from 1.1.x still
requires it, and it dates a mis-shaped line. Where the 1.0.x judgement decides
and no prompt id is set, it reds with a finding that says why, in CI and out of
it: a skip would switch the check off. A line of this session that lacks
agent_type or verdict fails closed until the prompt it was written in ends,
the lifetime it always had: re-running the reviewer does not clear it, because
the line stays in the append-only ledger and the reader stops at it, and the
step's remedy says so and says the next prompt does. From then on the line is
skipped with a NOTE and the ledger is judged as if it were absent. With no
prompt id the step cannot date such a line, and fails closed on it. It never
authorizes anything either way. The round budget counts entries of every
format: a round an earlier hook recorded stays spent.
The model a verdict ran on (1.1.0; the security finding behind a ramp until
2.1.0). A reviewer can run off the model its agent file pins: a per-invocation
model, CLAUDE_CODE_SUBAGENT_MODEL (and _FORCE), an availableModels
substitution, a fallbackModel chain (design/CONTROL-PLANE-FACTS.md Fact 16
in the harness repository). The SubagentStop hook records model, read from the
last assistant line of the subagent's own transcript at agent_transcript_path,
and pinned in each ledger entry; what it cannot read is null, and neither the
verdict nor the exit code depends on it. The step judges the model of the entry
each owed reviewer's verdict rests on: the latest entry of the turn under the
1.0.x judgement (the one the stale class judges), the latest counted PASS under
v2. A model counts when it matches the agent file's model pin or an entry of
its harnessFallbackModels list; an alias matches every model ID of its family
(opus any Opus ID, [1m] suffix and provider prefix included), and a full ID
matches only itself (modelMatches in tools/lib/reviewer-verdicts.mjs, data,
never looked up live). Every verdict on another model is NAMED on a
reviewer-verdicts: FALLBACK MODEL — … line, which the Stop hook shows the user
on a green turn too, as a JSON systemMessage (stderr from a hook that exits 0
reaches only the debug log). For security-reviewer, web-security-reviewer
and mobile-security-reviewer (SECURITY_REVIEWERS in
tools/lib/agent-roster.mjs) a model off the list, or null, is also a finding:
that PASS does not count, and the finding names both remedies, running on the pin
or a listed model, or adding the model to the list in a reviewed diff, because a
configuration that forces a model lands a re-run on the same one. An entry with
no model field, as every pre-1.1.0 entry is, is judged exactly as before. On an
install whose baseVersion predates 1.1.0 the finding prints as a NOTE until
2.1.0. A reviewer whose pin cannot run ends before SubagentStop and writes no
entry, so its "did not run" finding, under both judgements, says it may be
dispatched with the Agent tool's model set to a listed model. Where the
transcript holds the model was probed at Claude Code 2.1.285 (Fact 16). Honest
limit: the transcript lives outside the project, so the record catches a
configuration that moves a reviewer, not a session that forges its own
transcript; a value not spelled like a model ID is recorded as null, so a
forged one cannot write lines into the Stop output.
Anti-vacuity: tests/gates/check-reviewer-verdicts.test.mjs — the headline
since 2.0.0: an earlier prompt's current-format PASS counts with the session id
alone at the current digest and reds as returned PASS for a different tree
once the owed file moves; the owed
reviewer that never ran, last turn's PASS refused under the 1.0.x judgement,
the cross-session PASS refused, the BLOCK that blocks, the unparseable ledger
failing closed, the
RAMP EXPIRED branch EXECUTED at harness 0.7.0 (with the 0.6.0-vintage
sibling case pinning the plain red, no banner), the PASS-then-edit stale
binding red (with edit-then-PASS green, the non-owed post-PASS edit staying
green, and the unbound PASS failing toward re-review), and the 0.7.0 binding
ramp's NOTE and RAMP EXPIRED branches both executed; the record half — the
hook appending path_state and the digest MOVING when the owed file moves —
is tests/hooks/subagent-verdict-pathstate.test.mjs. The verdict GRAMMAR (1.0.2)
is asymmetric and proved row by row in the same file's decision table: PASS
only as the exact terminal line with no BLOCK-form line anywhere in the
message, BLOCK wherever a line states it (with its fixes after it, or spelled
FAIL), both forms in one message bounced — so a hedge can never read as a
pass; the bounce record (.harness/verdict-bounces.jsonl: agent, shape, last
line) and "every roster body ENDS with the verdict demand" are pinned there
too, and tests/hooks/hook-contract.test.mjs holds the refusal at exactly exit 2
even when that record cannot be written. That last pin reads the shipped bodies
only; since 1.1.0 docs-sync holds every install's reviewer bodies to the same
position, through the same verdictDemandProblem() (its section above). The
ledger v2 cases (1.1.0) run in a
clone whose branch tracks origin/main: commit-then-Stop still owing
security-reviewer (the headline), a deleted migration, a BLOCK from an
earlier prompt that another agent_id cannot clear and the same one can, the
moving tree, the missing start record, the stale PASS, the unjudged
torvalds-reviewer and the other session's PASS. Each red runs as a NOTE on a
1.0.3 manifest, a plain red on 1.1.0 and RAMP EXPIRED at harness 2.1.0; the
settled PASS and the same-prompt clear are green on 1.1.0 and red on 1.0.3; the
no-upstream path and reviewChanges() are pinned too, with the seeded
.gitignore keeping what supabase start writes out of the owed set. The dispatch record and
path_state_start are proved in tests/hooks/subagent-verdict-pathstate.test.mjs,
and tests/hooks/hook-contract.test.mjs holds a reviewer's SubagentStart to
exit 0 with one dispatch record, no ledger line and no blocked turn outcome.
The ledger key (2.0.0): insert an entry in an older format for an owed reviewer
→ FAIL naming the format and asking for a re-run (a NOTE on a 1.0.3 manifest,
RAMP EXPIRED at harness 2.1.0); an older-format PASS clears no BLOCK, an
older-format BLOCK stands until the same run passes in the current format, a
BLOCK from an earlier prompt stands with the session id alone, and a parked lib
without readSessionEntries is one finding naming it. A mis-shaped line of the
current prompt reds, a re-run after it in the same prompt is still red, the
next prompt is green with a NOTE, and with no prompt id it fails closed; the
step runs with the session id alone, fails closed in CI without it, and the
1.0.x judgement reds without a prompt id instead of skipping. The hook's exact
entry shape carries v: '2.0.0', and tests/hooks/subagent-verdict-pathstate.test.mjs
shows a hook beside a parked lib without LEDGER_FORMAT writing no stamp.
The model record (1.1.0): a transcript fixture in the observed shape on the pin
records pinned: true, one off it, a mid-run fallback (the verdict's model wins),
a failover whose model attachment still names the pin, and a synthetic or torn
last line are pinned, and no readable transcript, or a value not spelled like a
model ID, records model: null at exit 0;
a security reviewer's PASS off its list, and one with model: null, red under
both judgements as a plain red on a fresh or 1.1.0 manifest, a NOTE on 1.0.3 and
RAMP EXPIRED at harness 2.1.0; a PASS on the full ID the pinned alias resolves
to is green and silent, a listed fallback is green and named, another reviewer
off its list is green and named, a re-run on the pin clears an off-list PASS,
and an entry with no model field is judged as before; a reviewer that never
ran is told, under both judgements, that it may run on a listed model. The alias
rule and the other helpers run in-process there, and tests/hooks/hook-contract.test.mjs holds
a green Stop to exit 0 with each FALLBACK MODEL line in a JSON
systemMessage on stdout, and nothing on stdout when there is none.
The severity contract and the round budget (1.1.0). Every reviewer body states
Blocking: CRITICAL, HIGH (docs-sync holds the line's shape), and the SubagentStop
hook reads it from the body of the reviewer that stopped. A PASS whose reply lists a
finding at a blocking severity, a line starting - [HIGH] … or - [CRITICAL] …, is
bounced with exit 2 and the shape pass-with-blocking-finding: the reply contradicts
itself, so the reviewer re-states. A sentence that only mentions a severity is not a
finding, a BLOCK is never bounced on this ground, and neither is a body with no
Blocking: line (a fork, a project's own reviewer), so those keep the 1.0.x behaviour.
Each ledger entry also records blocking, the finding lines at a blocking severity
(each capped at 200 characters, null with no contract), and round, the verdict's
round in its reviewer's review loop in this session, with overBudget past the budget,
where the hook still exits 0. The step judges the budget, behind its own ramp until
2.1.0 (a hook has no NOTE channel to carry one; 2.0.0 moved it from 1.2.0 to the
reviewer ledger v2's deadline, because the budget judges v2's change set by v2's rule). A BLOCK opens a review loop; every
later verdict of that reviewer in the session is its next round, whichever run returns
it; the loop closes when each BLOCK in it is cleared by a PASS from the same run over a
tree that did not move under it. A loop still open after ROUND_BUDGET rounds (3, in
tools/lib/reviewer-verdicts.mjs, the BLOCK's own round included) is spent: a verdict
past the budget never clears it, and the step reds with every recorded blocking
finding and says to stop and hand them to the human. That finding replaces the
reviewer's other findings (its v1 or v2 finding and its model finding), which would say
to run it again. A new session starts every budget afresh, and that is a human's act. An
entry an earlier or parked hook wrote counts as one round, with no recorded findings. The
budget is judged over v2's owed set, the change set. With no merge base there is none, so
the budget does not judge, and the no-merge-base NOTE says so: it closes a loop only on
the same run's PASS, while the 1.0.x judgement that decides there clears a BLOCK with any
run's PASS on a later prompt.
Anti-vacuity (1.1.0): drop Blocking: from security-reviewer.md → docs-sync
FAIL naming the file; send a PASS containing - [HIGH] supabase/migrations/x.sql:3 — …
→ the hook exits 2 with the shape pass-with-blocking-finding
(tests/hooks/hook-contract.test.mjs, beside its exit-0 controls); record three BLOCKs
from one run and then its PASS → reviewer-verdicts FAIL naming the budget and the
findings, as a NOTE on a 1.0.3 manifest (at harness 2.0.0 too) and RAMP EXPIRED at harness 2.1.0
(tests/gates/check-reviewer-verdicts.test.mjs). The pure helpers are table-tested in
tests/gates/severity-contract.test.mjs.
CI-only lanes (outside the chain and the Stop hook)
Section titled “CI-only lanes (outside the chain and the Stop hook)”-
db-perf (
db-scalelane) —node tools/check-db-perf.mjs, aftersupabase/seeds/scale.sqlhas written two million rows and ANALYZEd. It is the live half ofquery-shapesand the only check here that can falsify the claim the tenancy design rests on. For each read shape in the generated manifest it rebuilds the statement, impersonates a real member of the largest tenant (SET LOCAL ROLE authenticated+ a transaction-localrequest.jwt.claims, so RLS is live underneath), and runsEXPLAIN (ANALYZE, BUFFERS, FORMAT JSON). It asserts shape, never milliseconds: the planner chose the index the static gate resolved, noSort/Incremental Sortabove a keyset leaf, no node whose parent relationship isSubPlan(a correlated helper re-evaluated per row), and the returned rows and buffer count insidetools/db-perf-baseline.json. Timings are printed and never compared — a wall-clock threshold on a shared runner is a coin flip, and a flaky perf gate is a deleted perf gate.SET enable_seqscan = offis deliberately not used: with it, the planner will use any index at all, so a table whose only index is useless still yields an Index Scan node and the assertion becomes a statement about the flag. Anti-vacuity, and it is the most important line in this entry: belowminRowsin the baseline the gate SKIPS LOUDLY locally and FAILS in CI. A plan probe against a small table certifies nothing, because the planner correctly ignores an index on one — so the seed's row count is overridable and the floor is what stops that knob from buying a green. The proof it is not redundant (Canary 24, measured): dropnotes_org_id_created_at_id_idxfrom the live database and leave every file alone.schema-rls,tenancy,query-shapesand all 109 pgTAP tests stay green — the index is still in the migration, andnotes_pkeystill satisfies the leading-column assertion — while db-perf reds on all three ordered shapes at once (a Sort node, the planner falling back tonotes_pkey, and 1491 buffers against a 900 budget). Deliberately a different edit from Canary 17, which must drop the primary key too before pgTAP notices anything. (Both canaries run in the factory on aninit --with-demoscaffold, because the index is the worked example's.) No read shape, no lane (2.0.0). On a manifest that records no read shape there is no plan to probe: the shippeddb-scaleadoption step stands down with a notice, and the gate itself skips locally and fails in CI if it is run anyway. A default scaffold is in that state until its first vertical records a shape. -
query-budget (
integration-lane) —node tools/check-query-budget.mjs -- <workload>. It wraps the live-api-proof suite rather than issuing its own requests, resetspg_stat_statements, and reads the delta filtered touserid = 'authenticator'::regrole(so GoTrue/Realtime/Storage traffic, none of which the application controls, is excluded). This is the N+1 detector, and it is the only thing here that can see one:query-shapesanddb-perfare per-statement, and an N+1 is a defect of COUNT — a hundred perfectly-indexed point reads in one request. pg_stat_statements normalizes literals away, so a hundred reads of a hundred ids collapse into one row withcalls = 100, which is the only signature the shape has. Anti-vacuity, both directions: a non-zero count immediately after the reset fails (the reset did not take, so every delta carries an unknown constant), and a ZERO count after the workload fails (the workload never reached PostgREST — a budget met by an instrument that is not connected is not a budget). -
Maestro device lane (
mobile-e2e) — credential-free:expo prebuild -p android→ gradle assemble → install on a GH-hosted emulator → the per-route flows plus the GENERATED route sweep (tools/check-e2e-device.mjs, flows derived fromsrc/routes.tsbytools/lib/maestro-flows.mjs— a new route is swept with zero YAML edits), re-run under a flipped OS theme and font_scale 1.3. Two installs, honestly split: the RELEASE binary (minified Hermes — where Fabric view flattening actually bites) runs the sweeps signed-out; the DEV binary (Metro on the runner) runs what release cannot — the kv-pre-seeded ar-XB/RTL boot (maestro/journeys/i18n-rtl.yaml), the mutation flow (REAL sign-in as an identity the lane mints with its personal org throughtools/ci/mint-device-user.mjs, handed to Maestro as flow variables viacheck-e2e-device.mjs --env→ create note → relaunch → persists, against the real server + Postgres — since 1.0.0; the inherited stub-authority tap of an empty form had been failing nightly, invisibly, since Supabase Auth replaced it), and the perf-harness journey (the dev screen self-measures againsttools/interaction-budget.json; the flow waits for itsperf-runningleaf to clear, then assertsperf-pass, so a breached budget reds at once and a measurement that never ends reds on the wait). The web app is the mutation flow's backend: the job publishes the env its first request parses (the server-onlySUPABASE_SERVICE_ROLE_KEYandSUPABASE_DB_URL, then theNEXT_PUBLIC_trio) before it boots the host, and waits for/api/trpc/system.health. Path-filtered + nightly (emulator cost), and skipped on a pull request while a live surface deferral holds (below); anti-vacuity: a phase that executed zero flows exits red. On every failure the runner prints the ids and text on screen to the log, and the evidence uploads: screenshot, logcat tail, hierarchy, and Maestro's debug output, which sits under a hidden.maestro/directory and so needs the upload'sinclude-hidden-files: true. After a perf-harness pass it prints the values the screen measured, which a re-baseline of the interaction budget starts from. -
Startup measurement lane (
perf-lane) —tools/measure-startup.mjscold-starts every ROUTES entry ×3 on its own quiet emulator (am force-stopam start -Wper deep link;totalTimeMsis the MEDIAN, every roll recorded incoldSamplesMs), then one WARM start per route (HOME + relaunch;warmTotalTimeMs, capped only by rows declaringmaxWarmTotalTimeMs— a declared-but-unreported cap reds), writesartifacts/perf-results.json, andHARNESS_PERF_LANE=1 node tools/check-mobile-perf.mjsenforcestools/startup-budget.json— failing CLOSED if the artifact is missing. Honest limit:fullyDrawnMsstays absent in the managed scaffold (no RN/Expo binding forreportFullyDrawn(); injecting native source would break CNG purity) — the median + warm split is the managed replacement, and the parse stays armed for consumers that add a native binding. Path-filtered + nightly like the Maestro lane, and skipped on a pull request while a live surface deferral holds (below).
-
Surface deferral (
surface-deferral, run bychangesformobile-e2eandperf-lane, and byfloor-review; 1.1.0) — a project that builds its web surface first records the mobile surface as not built yet in the seededtools/surfaces.json:{ "surface": "mobile", "deferredUntil": "YYYY-MM-DD", "reason": "<one line>" }, one row per surface, andmobileis the only surface a row may name. Thechangesjob runsnode tools/ci/surface-deferral.mjs --mode=pr, which prints exactlymobile-deferred=true|falseandmobile-deferral=<until>: <reason>into$GITHUB_OUTPUT(everything else goes to the log), and the two device lanes skip on a pull request while the row is live. Narrow reach: only thepull_requestarm of those two jobs reads the output; scheduled and dispatched runs keep both lanes,nativeand every other job ignore it, andgate-summaryprints the reason beside the two skipped lanes and never counts a skip as a pass. The content tripwire: a row is VOID, and the lanes run, as soon as any filegit ls-fileslists underapps/mobile/differs from the sha256 the installer recorded in.harness/manifest.json, a file is added there or a recorded one is gone, or the manifest is absent. The tree isapps/mobile/alone: a web-first project edits the shared packages on every backend change, and a shared change that forces an edit underapps/mobile/voids the row through that edit. On a retrofit install with no mobile app, nothing is compared and the row is live, and the output says zero files were compared. The clock only tightens:deferredUntilis the last deferred day, and after it a pull request runs the lanes again, so no date can turn a run into a skip. A stale row reds on the schedule instead, never on an unrelated pull request: thefloor-reviewjob runs--mode=review, which exits 1 on an expired, void or malformed row.--mode=prexits 1 only on a malformed register or a corrupt manifest, after printingfalse; an absent or empty register reads nothing else. Adding a row is a reviewed human act: the file is write-guarded (surfaces-register) and in the escape lists, so an uncommitted edit redsgate-integrity, and re-recording anapps/mobile/sha to keep a row live means editing the write-guarded, CODEOWNERS-covered.harness/. Anti-vacuity (tests/gates/surface-deferral.test.mjs): with a live row, one byte appended toapps/mobile/app.config.tsturns the output tomobile-deferred=falseand the log names it (surface-deferral: mobile deferral VOID — apps/mobile/app.config.ts differs from the sha the installer recorded);--mode=reviewwith a--todayafter the row's date exits 1 naming the row, and on its date exits 0. -
Web browser lane (
web-e2e) — the ONLY browser-side accessibility net in the harness (the mobile a11y floor is lint + RNTL; neither renders the DOM).tools/check-web-e2e.mjsfails closed FIRST on a missingplaywright.config, an EMPTYapps/web/e2esuite (Playwright exits 0 on an empty run — the reason the lane runs a runner, not a bareplaywright test), an assertion-free spec, or a spec set with no axe scan; then it runs Playwright, whosewebServerboots a PRODUCTION build against the Supabase local stack (started in the job; its URL- publishable key exported from
supabase statusat runtime — no key is committed). 0.10.0 adds a fourth refusal: an axe scan that passes an EXPLICIT tag list must carry the whole reviewed ladder —wcag2a,wcag2aa,wcag21aa,wcag22aa— becausewithTagsNARROWS axe and a short list is a silent decision not to run the rules outside it. Untagged scans are exempt, deliberately: they run a broader set, so demanding tags would red the stricter spec. Ramped to 0.11.0 (the runner is owned, the specs are seeded). State the coverage as what runs, never as a level: 63 runnable rules over 21 success criteria — the same four tags also select 6 rules axe never runs (defaulttagExcludedropsexperimentalanddeprecated), andtarget-sizeis the only automated WCAG 2.2 AA rule that exists. The seededhome.spec.tsasserts the landing heading renders and that scan is clean. Path-filtered (thewebarm coversapps/web+ the packages it bundles) + nightly, like the device lanes; the Playwright report uploads on failure. Falsifiability:tests/gates/check-web-e2e.test.mjsspawns the runner against a fakepnpmand proves each closure above reds (the browser run itself is CI-only, like the Maestro half ofmobile-e2e). The AUTHENTICATED-RENDER axis (0.6.0). The suite must also contain a spec that mints a real identity, signs in through the form, and then callspage.reload(). Until 0.6.0 every spec in the lane was anonymous — its only sign-in submitted a deliberately wrong password — so no test in the repository had ever completed a successful sign-in, while the seeded app shipped for two releases persisting the browser session tolocalStorageand reading it back from the cookie jar on the server. The lane ran on every relevant PR and was green throughout: vacuity inside a lane that ran, which is past what "'exists' is not 'ran'" catches, andenforcement-tiers.mdnames this lane as the compensating control on nine rows that assume otherwise. Two positive markers and one negative — a real identity, a full reload, and NOTcontext.addCookies(a planted session proves the server reads a cookie and says nothing about what the browser writes, which is the half that broke). Every axis reads code with comments blanked, so a marker named only in prose satisfies nothing. The honest limit is stated in the runner: a static reader cannot tell that the assertion after the reload is about a protected route.
- publishable key exported from
-
mutation —
pnpm mutation(StrykerJS over the critical surface — authorization and transport boundary code), a SET-based ratchet againsttools/mutation-baseline.json: a NEW surviving mutant reds; accepting one is a reviewed human act (empty reason FAILS; the file is write-guard-protected and gate-integrity-hashed). The surface is split since 1.0.0: the FLOOR (tools/lib/mutation-critical.mjs) stays owned and sha-pinned, and YOUR additive roots live in the seededtools/mutation-scope-extra.json({root, why ≥ 40}, union semantics — a row can only ADD surface, never subtract; write-guarded). Deriving the surface from the consumer's inventories was considered and refused — an inventory-derived surface shrinks when a registry row is deleted, and the ratchet's set semantics need a definition only a reviewed diff can change. The scoper's zero-match alarm is the drift tripwire instead: a concrete floor root or ANY extra root matching zero .ts files is a hard red (anti-vacuity, never ramped) — a tree whose structure diverged from the exemplar paths used to silently mutate less than the lane claimed. Never in the Stop chain — minutes vs the chain's seconds budget. 1.1.0 adds the Edge Functions as the STARRED floor rootsupabase/functions/*/(a tree with no functions stays green: starred roots are exempt from the zero-match alarm), with each function'sindex.tscarved out of both encodings — a Deno.serve shell the vitest runner cannot import. Stryker's vitest runner cannot mutate a file no test relates to (with only such files it stops with "No tests were executed" and writes no report), so the scoper judges a changed function file whose directory holds no vitest suite: a FAIL naming the suite it needs. On an install whosebaseVersionpredates 1.1.0 that file is withheld withmutation-scope: NOTE — (ramp) …on stderr, and a new survivor undersupabase/functions/ismutation-ratchet: NOTE — (ramp) …, until 1.2.0, which arrived at 2.0.0, so both red on every install since; a survivor anywhere else is judged as before. -
osv-scan (
osv-scan.yml, its own workflow) — known-vulnerability SCA over every discoveredpnpm-lock.yamlagainst the OSV database. The PR job is DIFF-AWARE (only newly introduced vulns red a PR — the deterministic form of a vulnerability gate: an unchanged tree never reds on an upstream advisory); the DAILY full-tree scan owns time-based discovery; Renovate owns the fix path. Google's official reusable workflows, SHA-pinned. This is whypnpm auditstays out of the validate chain (see Considered and rejected). Daily since 0.10.0: the schedule was weekly for nine releases and the gap was found by GRADING (Essential Eight PA-03 asks for at least daily against online services), not by reading the file — the diff-aware PR half cannot close it, because a tree carrying a vulnerable pin since before the advisory reds on no diff. -
sbom-inventory (
osv-scan.yml, 0.10.0) — the asset inventory, on the same lane and the same clock as the scan it feeds, because asset discovery exists TO SUPPORT vulnerability scanning.pnpm sbom --sbom-format cyclonedx --lockfile-onlyemits it andtools/check-sbom.mjsCONSUMES it: the component set is closed againstpnpm-lock.yamlin BOTH directions, so a resolved package with no component reds (the inventory under-reports the tree) and a component no lockfile entry resolves reds (the artefact describes a DIFFERENT tree — a stale commit, a stray--filter, a generator pointed elsewhere; a too-large inventory reads as thorough, which is why that direction is checked at all). Zero components is a hard failure, never an empty set quietly matching an empty set. The judgement is the whole point:pnpm sbomis one line and cannot go red, so a lane that only emitted would be a green tick over an inventory that had silently lost half the tree. No install —--lockfile-onlyreads two committed files and never the store, which is what makes a daily cadence honest. The artefact uploads only after it passes, so a broken inventory is never the file someone downloads later. -
live-api proof —
__tests__/live-api-proof.test.ts(jest, self-skipping unlessLIVE_PROOF=1;integration-lanepublishes the Supabase URL, keys and service-role key it requires from the local stack, then boots the web host): the one place the mobile client's real tRPC client talks to the real server over real Postgres under FORCE RLS. Every other lane mocks the network — which is exactly how the desktop original once shipped requests with no Authorization header at all while every gate stayed green. Its negative control (an unauthenticated call must fail) keeps it falsifiable, and the harness selftest proves the lane end-to-end (Canary C01: strip the tRPC client's one bearer-attaching line → the suite reds, restore → green). -
backup-evidence (scheduled, in
osv-scan.yml) —node tools/check-backup-posture.mjs. The one control in this harness whose subject is not in the tree. Backups live in the platform's control plane, so the only way to learn their state is to ask over the network — which is exactly why it may not ridepnpm validate, where a third party changing an answer would red an untouched commit overnight. It sits besidefloor-reviewbecause it is the same KIND of job, not the same subject: schedule and dispatch only, never a PR blocker. What makes it more than decoration. It skips on every machine without aSUPABASE_ACCESS_TOKEN, including this harness's own CI, so nothing in the ordinary run of things demonstrates it can go red. The judgement therefore lives intools/lib/backup-posture.mjsas a pure function over recorded API responses, exercised on everypnpm test, andtests/gates/backup-posture.test.mjsspawns the shipped script against a fixture so the lane itself is proven and not just its library. It asserts one thing: that a recovery mechanism exists — PITR enabled, OR at least one completed daily backup — and, only when PITR is off, that the newest daily backup is inside the operator's own tolerance.walg_enabled, the physical-backup flags and the PITR window are REPORTED and never asserted, because their exact semantics are not documented well enough to red a project over. Four shapes that would red a CORRECT project, all avoided by quotation rather than reasoning. (1) Daily-vs-PITR is an OR: "If you enable PITR, we will no longer take Daily Backups." (2) Freshness is not assertable under PITR: an idle database produces no recent WAL backups and its latest recovery point still reflects current state, so a quiet project would red on any recency bound. (3) A project that isRESTORING,PAUSING,RESIZING,COMING_UPorINACTIVEis judged not at all — the sharpest case being a lane that failed a project because it was mid-restore, firing at the exact moment the operator is using what it exists to protect. (4) A project younger than the operator's own tolerance has not missed a backup; that guard reuses their number and invents none.maxDailyBackupAgeHoursships NULL and must be set, because ASD asks for backups "in accordance with business criticality" and that is the operator's determination — a figure invented by a generator would be an obligation with nothing behind it. The vendor publishes an RPO of two minutes for PITR and no RPO at all for daily backups, so there is no number to inherit either. Anti-vacuity: a response missing any of the five required fields, or astatusoutside the published six-value enum, or a non-arraybackups→ FAIL, because "I could not see anything" must never read as "correctly configured". A COMPLETED row whoseinserted_atcannot be read as an unambiguous instant → FAIL as a shape problem: the field is typed as a bare string with no format, and dropping the row instead would empty the newest-completed search and red the project for having no backups at all — the right verdict for entirely the wrong reason. A 402 carryingentitlement_requiredis a plan gate and skips; a 402 without it is a billing-state failure and fails, because an organisation past due on payment is the one about to discover its backups matter. What it cannot do, stated rather than discovered during an assessment: it cannot test a restore. An in-place restore takes the project offline and the non-destructive alternative — restore to a NEW project — is a Dashboard-only flow with no Management API path. So restoration testing is a written, dated record intools/backup-posture.jsonwith a human's name on it, labelleddocumentationtier rather than borrowing the credibility of the checks around it. -
workflow-hardening (
actions-lint.yml, 1.1.0) —node tools/check-workflow-hardening.mjsholds every workflow in.github/workflows/,*.ymland*.yaml, to the three house rules every workflow the harness ships already meets, by the pure rules intools/lib/workflow-hardening.mjs: (1) a workflow-leveldefaults.run.shell: bashabovejobs:, because GitHub runs an un-shelled step asbash -ewithoutpipefail, soproducer | tee filereports tee's status; it is spelled exactlybash, the one spelling GitHub runs with-eo pipefail, so a custom command such asbash -el {0}is a finding (the one workflow that publishes OpenSSF Scorecard results must carry no top-leveldefaultsorenvinstead); (2) a whole-numbertimeout-minuteson every job that can take one, from 1 to 360 on a GitHub-hosted runner and to 7200 on a self-hosted one (the platform's own limits; a job with none inherits 360), and none on a reusable-workflow call; (3)step-security/harden-runneras the FIRST step of every job that is neither a reusable-workflow call nor self-hosted, withegress-policy: auditwhen itsruns-on, or a matrix value it reads, names windows. A finding names<file>or<file>#<job>. It is YAML-shaped, not YAML-parsed: CRLF is normalised, the job indentation is read from the first key underjobs:, a job id is[A-Za-z_][A-Za-z0-9_-]*, and comment lines are skipped. Input it cannot read is a finding, never a pass: no workflow directory or none in it, a file it cannot read, nojobs:block or no job in it, a line underjobs:that is not a job id, a job with no readablesteps:list, a first step with neitheruses:norrun:(a YAML alias, a flow mapping), a ceiling that is not a whole number. The job runs on a pull request or push that touches a workflow,.github/zizmor.yml, the script, its library or.harness/manifest.json, with no install. It is not a chain step, sograduatenever runs it: run it by hand before graduating. Ramped: an install whosebaseVersionpredates 1.1.0 got each finding asworkflow-hardening: NOTE — (ramp) …until 1.2.0, which arrived at 2.0.0; a fresh 1.1.0 scaffold was held to the rules at once, and since 2.0.0 every install is. Besideharden-runner-coverage, not instead of it: that job keeps its id, name and loop, which COUNTS harden-runner lines per*.ymlfile, so two steps in one job cover a bare neighbour, a step aftercheckoutor a comment passes, and a.yamlfile or a four-space indent is never read; this gate checks each of those. The injection (tests/gates/check-workflow-hardening.test.mjs, and by hand in a scaffold): move harden-runner belowcheckoutin any job, delete thedefaults:block of a workflow that has arun:step, or delete a job'stimeout-minutes, and the gate exits 1 withworkflow-hardening: FAILand a line naming.github/workflows/<file>#<job>; the test also runs the counting loop over the same shapes and shows it passing each one. -
edge-functions (
quality-gate.yml, 1.1.0) —node tools/check-edge-functions.mjstypechecks every Supabase Edge Function against the exact dependency versions it deploys with. A function is a directory undersupabase/functionsholding anindex.ts(_-prefixed shared directories excluded). Each needs adeno.jsonwhosejsr:/npm:imports name exact releases — Supabase deploys each function with its owndeno.json, so that is where its versions live — and thedeno.lockdeno writes from it; the gate runsdeno check --frozen --config <fn>/deno.json --lock=<fn>/deno.lock <fn>/index.ts, so a type error anywhere in the shell's import graph (its handler and shared code included) or a lock that no longer matchesdeno.jsonis a finding with deno's own output. No function at all is OK with the count. Nothing Node-side could check this:tsc -bnever reachessupabase/, and a file that imports ajsr:specifier and starts a server as it loads cannot be imported by vitest, coverage or Stryker. deno is a prerequisite, not a dependency: the job installs it withdenoland/setup-denoat an exact version (Renovate proposes bumps; its deno manager bumps each function'sdeno.jsonanddeno.locktogether), with the module cache keyed on the lockfiles. Locally a missing deno SKIPS loudly; in CI it fails closed. The job: path-filtered onsupabase/functions/**, the gate and thetools/libmodules it imports,.harness/manifest.jsonand the workflow, plus the nightly schedule and manual dispatch; gate-summary waits for it. A job and not a chain step, because deno is not a workspace dependency and the chain has a wall-clock budget, sograduatenever runs it: run it by hand before graduating. Measured (2026-09-30, deno 2.9.6, the seeded function): the binary is a 42 MB download (96 MB unpacked), once per run; a cold check fills a 13 MB module cache (supabase-js, its five@supabasenpm packages and what they import) in 2 to 2.5 s; a warm one takes under 0.2 s. Ramped: on an install whosebaseVersionpredates 1.1.0, every finding, and a missing deno,deno.jsonordeno.lock, wasedge-functions: NOTE — (ramp) …until 1.2.0, becauseupdatedelivers the gate and the job but not the seeded split; 1.2.0 arrived at 2.0.0, so it reds on every install since. The injection (tests/gates/check-edge-functions.test.mjs, and Canary 38 on a rendered scaffold): appendconst n: number = 'x'tosupabase/functions/delete-account/index.tsand the gate exits 1 withedge-functions: FAILand deno'sTS2322line; delete that function'sdeno.jsonand it exits 1 naming it.
Opt-in modules
Section titled “Opt-in modules”npx next-expo-supabase-agent-harness@latest enable <module> copies the module's files and
records it in .harness/manifest.json. Tiers: core = none, standard =
ci-provenance + ci-mobile-release + ci-web-deploy, strict = all.
| Module | What it adds | Why not default-on |
|---|---|---|
ci-mobile-release |
the EAS release DAG: store-credentialed builds, submission, signed-artifact checks | needs store credentials and a release cadence |
ci-web-deploy |
tag-triggered rebuild of the web next build + SLSA L2 attestation + in-CI verify of the GitHub-built artifact (the host deploys separately) |
attesting a rebuild is meaningful once you ship to a host and want an independent provenance record; needs the public NEXT_PUBLIC_* build vars set |
device-e2e |
the extended on-device Maestro matrix beyond the base lane | slow emulator runners; the base lane covers the floor |
eas-update |
OTA update channel wiring + staged-rollout runbooks | OTA is a policy decision (runtimeVersion reach, rollback story) |
store-metadata |
store listing metadata as reviewable JSON in-repo (+ iOS privacy manifests) | meaningful once a listing exists |
ci-provenance |
SBOM + build attestation + verification step + NOTICES drift check | meaningful once artifacts ship to a consumer who verifies them |
gate-a11y-deep |
screen-reader checklist + extended a11y assertions beyond the lint/RNTL floor | needs human-in-the-loop passes; the floor already lint/test-enforces |
crash-reporting |
crash/error ingestion wiring, symbol upload, redaction unit test | needs an ingestion endpoint; redaction policy is project-specific |
push-notifications |
push credential wiring + permission-prompt discipline | a product decision with store-policy weight |
eval-live |
GPU-runner live-model eval lane: GBNF/schema pre-validation, exemplar/holdout disjointness check | needs GPU hardware and a served model; the default eval is fixture-scored by design |
observability |
OpenTelemetry wiring for the server + span-per-route test | adds a runtime dependency and an OTLP target decision better made deliberately |
Considered and rejected
Section titled “Considered and rejected”Recorded so the next maintainer doesn't re-litigate (the port-time decisions live in the design record; the enduring ones repeat here):
runtimeVersion.policy = 'fingerprint'— a computed hash is not PR-reviewable;appVersionis. Revisit only if OTA reach across store versions becomes a product requirement.- EAS remote version source / autoIncrement — moves a version surface into a database no gate can diff; breaks the hermetic selftest.
- A styling compile layer (utility-class or native styling runtime) — a compiler between the styleguide manifest and the pixels defeats the tokens-as-data scannability the styleguide gate depends on.
- Maestro-in-the-chain — an emulator boot ahead of every validate would turn a seconds gate into a minutes gate; the closure check keeps the floor while the device lane pays the cost in CI.
- An SSE dependency (
react-native-sse) — rejected in the ancestor's port spec, and the hand-rolled parser that spec designed as the alternative NEVER SHIPPED in this lineage: no SSE client of any kind exists in the scaffold, and no shipped surface streams. (Through 0.11.1 this entry claimed the parser shipped and was "unit-tested at every chunk boundary" — ancestor-port residue, corrected at 1.0.0.) If a streaming seam is ever added it belongs inside the tRPC-client one-door, with its retry/timeout posture declared intools/resilience.jsonlike every other outbound seam. pgTAP— ADOPTED, and this entry was stale ancestor text. The claim it made ("plain-SQL catalog assertions check the same facts without a second toolchain") is false in this tree and had been for some time: pgTAP is shipped and load-bearing.supabase/tests/rls_structure.test.sql,rls_isolation.test.sqlandaudit_immutability.test.sqlallCREATE EXTENSION IF NOT EXISTS pgtap,pnpm db:testruns them, and CI's runtime-rls lane blocks on them. It earns the toolchain because plain SQL cannot do the two things the suite exists for:lives_ok/throws_ok(asserting that a read SUCCEEDS, or that a write raises a specific SQLSTATE, is how the recursion probe and the audit-immutability suite work at all) and a plan count, which is what makes a silently truncated test set a red instead of a shorter green. A rejection record kept after the thing was adopted is worse than no record — it tells the next reader not to look.- ts-prune / lockfile-lint / type-coverage / markdownlint — superseded by
knip --strict, the version-sync committed-lockfile floor + frozen CI installs over it (a frozen install only pins what a committed lockfile records, which is why the gate reds an absent one), the type-aware ESLint bans, and Biome respectively. pnpm auditin the validate chain — non-deterministic in TIME: a new upstream advisory in the Expo dependency tree would red an unchanged tree — and brick every fresh scaffold — and an allowlist only converts each advisory into an emergency edit of a write-protected file. Theosv-scanPR lane is the deterministic form of the same control (diff-aware: only NEWLY introduced vulns red a PR), the daily full scan owns time-based discovery, and Renovate owns the fix path.- react-native-reanimated (0.1.2) — a Babel transform plus a native worklet
runtime between the motion tokens and the pixels — the styling-compiler class,
for motion — when everything this tier needs (press scale, entrance
fade/slide, skeleton pulse) sits inside core
Animated's native-driver whitelist with zero added dependencies. Revisit only for gesture-driven surfaces (sheets/swipes), as an opt-in module. - A runtime memory budget — reaffirmed against the 0.1.2 perf wave: no managed-runtime measurement is honest enough to gate on (a jest heap number measures jest), so leak discipline stays the static effect-cleanup scan plus the emitter-count spec, and unmeasured numbers do not ship.
- max-lines file/function caps — proxy metrics that punish cohesive modules; sonarjs cognitive-complexity ≤ 15 targets the actual failure mode.
- deterministic same-turn test-edit bans — a hook cannot distinguish reward-hacking from legitimate code+tests work; kept as a review-time rule plus the mutation lane, which catches the damage rather than the act.