Skip to content

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.events records 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. SELECT auditing needs pgaudit configuration that is not expressible in a migration, so it stays out of scope until that changes — a permanent scoping CONDITION in the tenancy.json directoryRpc style, 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_id on 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_id is 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 e2e gate and the mobile-unit suite 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-gate mobile-e2e + perf-lane, path-filtered + nightly, and skipped on a pull request while a live tools/surfaces.json row defers the mobile surface; mobile-perf --closure in 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)”

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.

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.)

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 anon or authenticated holds in the default-seeded fold, directly, through PUBLIC or on columns, must be admitted by a PERMISSIVE policy for that operation (or ALL) naming the role, public or no role, whose predicate is not literally false. TRUNCATE, REFERENCES, TRIGGER and MAINTAIN are never admitted — row security does not apply to them — so they are revoked, or listed with a reason in the tolerated-absent tools/grant-bound-allow.json as {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 keeps service_role to 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.sql must equal what tools/gen-grant-assertions.mjs renders from the explicit fold — one is_empty over has_table_privilege(…) IS DISTINCT FROM expected for every table a migration creates, each of the three roles and every table privilege, with its own plan(), 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 CASCADE reaches it. Nothing to review.
  • SEVERED — ON DELETE SET NULL / SET DEFAULT. The row survives and only the link is cut. Needs a reviewed reason in tools/data-flow.json severed[], 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_id has no foreign key at all, deliberately). Needs a reason and a procedure naming a committed file — the gate requires that file to exist, because procedure-backed with no procedure is not backed.
  • BLOCKING — RESTRICT, NO ACTION, or no ON DELETE clause at all, which PostgreSQL treats as NO 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 way delete-account sweeps 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.

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.

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.

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.

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-scale lane) — node tools/check-db-perf.mjs, after supabase/seeds/scale.sql has written two million rows and ANALYZEd. It is the live half of query-shapes and 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-local request.jwt.claims, so RLS is live underneath), and runs EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON). It asserts shape, never milliseconds: the planner chose the index the static gate resolved, no Sort/Incremental Sort above a keyset leaf, no node whose parent relationship is SubPlan (a correlated helper re-evaluated per row), and the returned rows and buffer count inside tools/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 = off is 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: below minRows in 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): drop notes_org_id_created_at_id_idx from the live database and leave every file alone. schema-rls, tenancy, query-shapes and all 109 pgTAP tests stay green — the index is still in the migration, and notes_pkey still satisfies the leading-column assertion — while db-perf reds on all three ordered shapes at once (a Sort node, the planner falling back to notes_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 an init --with-demo scaffold, 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 shipped db-scale adoption 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, resets pg_stat_statements, and reads the delta filtered to userid = '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-shapes and db-perf are 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 with calls = 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 from src/routes.ts by tools/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 through tools/ci/mint-device-user.mjs, handed to Maestro as flow variables via check-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 against tools/interaction-budget.json; the flow waits for its perf-running leaf to clear, then asserts perf-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-only SUPABASE_SERVICE_ROLE_KEY and SUPABASE_DB_URL, then the NEXT_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's include-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.mjs cold-starts every ROUTES entry ×3 on its own quiet emulator (am force-stop

    • am start -W per deep link; totalTimeMs is the MEDIAN, every roll recorded in coldSamplesMs), then one WARM start per route (HOME + relaunch; warmTotalTimeMs, capped only by rows declaring maxWarmTotalTimeMs — a declared-but-unreported cap reds), writes artifacts/perf-results.json, and HARNESS_PERF_LANE=1 node tools/check-mobile-perf.mjs enforces tools/startup-budget.json — failing CLOSED if the artifact is missing. Honest limit: fullyDrawnMs stays absent in the managed scaffold (no RN/Expo binding for reportFullyDrawn(); 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 by changes for mobile-e2e and perf-lane, and by floor-review; 1.1.0) — a project that builds its web surface first records the mobile surface as not built yet in the seeded tools/surfaces.json: { "surface": "mobile", "deferredUntil": "YYYY-MM-DD", "reason": "<one line>" }, one row per surface, and mobile is the only surface a row may name. The changes job runs node tools/ci/surface-deferral.mjs --mode=pr, which prints exactly mobile-deferred=true|false and mobile-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 the pull_request arm of those two jobs reads the output; scheduled and dispatched runs keep both lanes, native and every other job ignore it, and gate-summary prints 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 file git ls-files lists under apps/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 is apps/mobile/ alone: a web-first project edits the shared packages on every backend change, and a shared change that forces an edit under apps/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: deferredUntil is 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: the floor-review job runs --mode=review, which exits 1 on an expired, void or malformed row. --mode=pr exits 1 only on a malformed register or a corrupt manifest, after printing false; 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 reds gate-integrity, and re-recording an apps/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 to apps/mobile/app.config.ts turns the output to mobile-deferred=false and the log names it (surface-deferral: mobile deferral VOID — apps/mobile/app.config.ts differs from the sha the installer recorded); --mode=review with a --today after 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.mjs fails closed FIRST on a missing playwright.config, an EMPTY apps/web/e2e suite (Playwright exits 0 on an empty run — the reason the lane runs a runner, not a bare playwright test), an assertion-free spec, or a spec set with no axe scan; then it runs Playwright, whose webServer boots a PRODUCTION build against the Supabase local stack (started in the job; its URL

    • publishable key exported from supabase status at 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 — because withTags NARROWS 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 (default tagExclude drops experimental and deprecated), and target-size is the only automated WCAG 2.2 AA rule that exists. The seeded home.spec.ts asserts the landing heading renders and that scan is clean. Path-filtered (the web arm covers apps/web + the packages it bundles) + nightly, like the device lanes; the Playwright report uploads on failure. Falsifiability: tests/gates/check-web-e2e.test.mjs spawns the runner against a fake pnpm and proves each closure above reds (the browser run itself is CI-only, like the Maestro half of mobile-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 calls page.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 to localStorage and 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, and enforcement-tiers.md names 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 NOT context.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.
  • mutation — pnpm mutation (StrykerJS over the critical surface — authorization and transport boundary code), a SET-based ratchet against tools/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 seeded tools/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 root supabase/functions/*/ (a tree with no functions stays green: starred roots are exempt from the zero-match alarm), with each function's index.ts carved 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 whose baseVersion predates 1.1.0 that file is withheld with mutation-scope: NOTE — (ramp) … on stderr, and a new survivor under supabase/functions/ is mutation-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 discovered pnpm-lock.yaml against 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 why pnpm audit stays 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-only emits it and tools/check-sbom.mjs CONSUMES it: the component set is closed against pnpm-lock.yaml in 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 sbom is 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-only reads 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 unless LIVE_PROOF=1; integration-lane publishes 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 ride pnpm validate, where a third party changing an answer would red an untouched commit overnight. It sits beside floor-review because 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 a SUPABASE_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 in tools/lib/backup-posture.mjs as a pure function over recorded API responses, exercised on every pnpm test, and tests/gates/backup-posture.test.mjs spawns 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 is RESTORING, PAUSING, RESIZING, COMING_UP or INACTIVE is 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. maxDailyBackupAgeHours ships 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 a status outside the published six-value enum, or a non-array backups → FAIL, because "I could not see anything" must never read as "correctly configured". A COMPLETED row whose inserted_at cannot 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 carrying entitlement_required is 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 in tools/backup-posture.json with a human's name on it, labelled documentation tier rather than borrowing the credibility of the checks around it.

  • workflow-hardening (actions-lint.yml, 1.1.0) — node tools/check-workflow-hardening.mjs holds every workflow in .github/workflows/, *.yml and *.yaml, to the three house rules every workflow the harness ships already meets, by the pure rules in tools/lib/workflow-hardening.mjs: (1) a workflow-level defaults.run.shell: bash above jobs:, because GitHub runs an un-shelled step as bash -e without pipefail, so producer | tee file reports tee's status; it is spelled exactly bash, the one spelling GitHub runs with -eo pipefail, so a custom command such as bash -el {0} is a finding (the one workflow that publishes OpenSSF Scorecard results must carry no top-level defaults or env instead); (2) a whole-number timeout-minutes on 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-runner as the FIRST step of every job that is neither a reusable-workflow call nor self-hosted, with egress-policy: audit when its runs-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 under jobs:, 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, no jobs: block or no job in it, a line under jobs: that is not a job id, a job with no readable steps: list, a first step with neither uses: nor run: (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, so graduate never runs it: run it by hand before graduating. Ramped: an install whose baseVersion predates 1.1.0 got each finding as workflow-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. Beside harden-runner-coverage, not instead of it: that job keeps its id, name and loop, which COUNTS harden-runner lines per *.yml file, so two steps in one job cover a bare neighbour, a step after checkout or a comment passes, and a .yaml file 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 below checkout in any job, delete the defaults: block of a workflow that has a run: step, or delete a job's timeout-minutes, and the gate exits 1 with workflow-hardening: FAIL and 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.mjs typechecks every Supabase Edge Function against the exact dependency versions it deploys with. A function is a directory under supabase/functions holding an index.ts (_-prefixed shared directories excluded). Each needs a deno.json whose jsr:/npm: imports name exact releases — Supabase deploys each function with its own deno.json, so that is where its versions live — and the deno.lock deno writes from it; the gate runs deno 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 matches deno.json is a finding with deno's own output. No function at all is OK with the count. Nothing Node-side could check this: tsc -b never reaches supabase/, and a file that imports a jsr: 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 with denoland/setup-deno at an exact version (Renovate proposes bumps; its deno manager bumps each function's deno.json and deno.lock together), with the module cache keyed on the lockfiles. Locally a missing deno SKIPS loudly; in CI it fails closed. The job: path-filtered on supabase/functions/**, the gate and the tools/lib modules it imports, .harness/manifest.json and 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, so graduate never 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 @supabase npm packages and what they import) in 2 to 2.5 s; a warm one takes under 0.2 s. Ramped: on an install whose baseVersion predates 1.1.0, every finding, and a missing deno, deno.json or deno.lock, was edge-functions: NOTE — (ramp) … until 1.2.0, because update delivers 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): append const n: number = 'x' to supabase/functions/delete-account/index.ts and the gate exits 1 with edge-functions: FAIL and deno's TS2322 line; delete that function's deno.json and it exits 1 naming it.

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

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; appVersion is. 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 in tools/resilience.json like 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.sql and audit_immutability.test.sql all CREATE EXTENSION IF NOT EXISTS pgtap, pnpm db:test runs 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 audit in 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. The osv-scan PR 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.