Skip to content

Quality gates and commands — the catalogue ​

One section per script in package.json (check: · test: · deploy: and the daily commands), each with what it cannot see — because a gate is only trustworthy when its blind spots are written next to it (QRS-246: a standard documented for months and implemented by nothing). npm run check:claims rule C3 asserts that every check:/test:/ deploy: script has an H2 here with a non-empty Cannot see line, so a new gate cannot stay invisible.

How to read an entry

Command is the exact invocation. Cannot see is the load-bearing line — read it before reading a green run as proof of anything. The narrative under each entry is the operating manual's own commentary, moved here verbatim on 2026-09-23 (QRS-1288): the incidents that produced the gate, what it measures, and how it was mutation-tested.

Reading a gate's output — four checks, five failures ​

READ A GATE'S OUTPUT PROPERLY — this has now failed FIVE ways [ENFORCED]. A truncated view of a gate is worse than no gate, because it leaves a number behind that looks like evidence. Check the summary line, the EXIT CODE, and the test COUNT — all three. QRS-240: npm run e2e | tail -8 reported "506 passed" while cutting off 29 failures, because Playwright prints failures BEFORE the summary. QRS-245: a run killed by memory printed only ok lines and no summary at all, so every visible line said pass; only exit=1 plus "138 of 712" exposed it. Never pipe a gate through tail/head; redirect to a file and grep for the summary, or use the JSON reporter.

⚠ AND NEVER READ $? AFTER PIPING A GATE INTO grep — YOU GET GREP'S EXIT CODE, NOT THE GATE'S [fourth variant, 2026-08-18]. npm run type-check 2>&1 | grep -cE "error TS"; echo "exit=$?" prints 0 matches and then exit=1, because grep exits 1 when it finds nothing. So a clean type-check reports failure, and the obvious next move is to go hunting for a build error that does not exist. This is the same family as the three above — a reading apparatus that silently reports on something other than the thing you asked about — and it is the most tempting one, because piping into grep -c is exactly how you keep a long gate's output short. Redirect to a file, read the command's own exit status on its own line, then grep the file.

⚠ apps/mobile's test script is jest --passWithNoTests, so "0 tests" reports GREEN — the third variant of this same failure, and structurally the nastiest, because there is no truncation and no crash to notice: a broken testMatch, a bad path filter or a renamed __tests__/ directory produces a clean pass with an exit code of 0. The COUNT is the only signal that distinguishes it from success, which is exactly why the rule above names all three checks rather than just the summary and the exit code. Read the number.

⚠⚠ FIFTH VARIANT, AND THE ONLY ONE WITH NOTHING TO READ AT ALL: A GATE THAT PRODUCES NO OUTPUT BECAUSE ITS DRIVER NEVER RAN [2026-09-07, QRS-1155]. npm run check:state exited 0 with zero bytes of stdout for its entire life on Windows — its import.meta.url guard hand-built a file:// URL with two slashes where Node uses three, so main() was never called. Every other variant above leaves something misleading behind (a truncated number, a wrong exit code, a green zero-count); this one leaves the shell with one bit of information, and that bit says pass. The three checks the rule names cannot help: there is no summary line, no count, and the exit code is genuinely 0. So the rule needs a fourth check for any gate you have not seen speak: DID IT PRINT ANYTHING? Every gate in this repo prints a summary on success (check:screens its counts, check:portal-nav its page total, check:naming its rule list) — silence is not success, it is absence, and the two are distinguishable in one glance if you look for the line rather than the code. Its nine mutation tests were green throughout because all of them imported the pure function; a test that imports the function cannot see that the file never calls it. Spawn the CLI and assert on its output.

Gate layers, cheapest first — and the hooks ​

Gate layers, cheapest first. Husky pre-commit = check:readmes + check:parity + lint-staged (sub-second by design — commits are frequent; lint-staged runs the untyped eslint.fast.mjs). pre-push = check:parity again (a bypassed commit is the one worth catching) + test:hooks + project-wide type-check. CI is where the full type-aware npm run lint and the sonar job run — so CI is the first layer that can see a type-aware finding at all, and the only one that is authoritative on Sonar. Claude Code hooks (.claude/settings.json → tools/hooks/) block the bypasses that have caused incidents and run the parity gate the moment a systemic file is edited — EIGHT hooks are wired (read .claude/settings.json, never this list, if they disagree): PreToolUse/Bash — guard-bash.mjs · guard-preview.mjs. PostToolUse/Edit|Write|MultiEdit — on-systemic-edit.mjs · on-naming-surface-edit.mjs · on-core-entity-edit.mjs (QRS-871 — fires the instant a live migration structurally changes users/workspaces/workspace_members/organizations/ setu_cards/feature_grants/orders/conversations/messages/audit_log with no proposal declaring it; advisory by design and it exits 0 always, because a core-entity migration is often exactly the right thing to write and a hook that blocks a legitimate action is one that gets switched off. It fires only on CREATE/ALTER/DROP TABLE — a COMMENT ON, an index, a grant or a policy is silent, which is the anti-noise rule that keeps it worth reading). Stop — session-parity-summary.mjs · session-docs-impact.mjs · session-preview-staleness.mjs. They are unit-tested (npm run test:hooks, 50/50 green as of 2026-08-24; it read "32/32 as of 2026-08-17" until then) because an untested control is a belief. ⚠ Read the COUNT off a run, never off this line — the suite grows with each new hook, and an understated count is the drift direction that hides delivered work. ⚠ This paragraph named FOUR of the seven until 2026-08-17, and session-preview-staleness.mjs was named NOWHERE in this file — a wired Stop hook, in the same operating manual that carries a five-paragraph incident about the owner reviewing a stale preview. The three it omitted are each documented in their own topic section (naming, docs-impact) or not at all, so a reader looking for "what controls run automatically" got a list that was silently 43% short. The generalisable form: an inventory kept in prose next to the thing it inventories still rots — ls tools/hooks/ and .claude/settings.json are the sources of truth, and this list is a convenience that must be re-measured, not trusted.

⚠ guard-preview.mjs EXISTS BECAUSE THE PRODUCT OWNER REVIEWED A TWO-DAY-OLD BUILD (2026-08-14, QRS-666), and the mechanism is worth knowing because nothing in this repo could see it. npx serve dist -l 8080 does not fail when its port is taken — it prints Accepting connections at http://localhost:<random> and exits 0. In a background task that line is never read, so every session that ended by "serving the preview on 8080" bound nothing, looked successful, and left another orphan; port 8080 stayed owned by the first server ever started. Measured at the moment the owner asked: nine orphaned processes. They then spent a review pass describing drift that was partly an artifact of the server. The claim "the new build is on 8080" was true of the command that had been run and false of the world — CLAUDE.md's third rule exactly. The hook now sweeps preview servers before any web:export or preview, and BLOCKS raw serve; npm run -w @qrsetu/mobile preview is fatal on EADDRINUSE, serves no-store, prints the entry-bundle hash, and warns when dist/ is older than src/ — which is the check that catches staleness from the other direction. Two defects were found while building it, both by its own tests, and both are the general kind: (1) the first patterns used \S* between npm run and the script name, which cannot cross the spaces in npm run -w @qrsetu/mobile preview — it would have passed every test written from the short command and done nothing on the command this repo actually uses (a green no-op, QRS-013). (2)guard-bash.mjs ran process.exit(main()) unconditionally at module scope, which was harmless for two months because nothing imported it; the moment guard-preview imported its quote-blanking helpers rather than copying them, main() read a stdin that never arrived and hung the test runner with no output at all. A module with a side effect at import is a landmine for its first importer, and the first importer is precisely who cannot diagnose it.

CI lives in .github/workflows/. The workflow set and its count are in the measured inventory at the top of this file; what each one does is under "Environments, CI/CD & promotion". This sentence used to name four of them, which read as the whole set.

Since 2026-09-23 four more hooks are wired for the context architecture (read .claude/settings.json, never this list): on-bash-read.mjs (PostToolUse Bash — loads a path-scoped rule when a matching file is read through Bash, which Claude Code does not do on its own), gate-edit-on-read.mjs (PreToolUse Edit|Write|MultiEdit and Bash — refuses a write under a gate: block rule, including sed -i, redirects and tee, until the agent's OWN transcript shows the mandatory pages were printed; a subagent is checked against its own transcript), on-prompt-route.mjs (UserPromptSubmit — a pointer to the domain index for the task shape) and on-claude-md-edit.mjs (PostToolUse on CLAUDE.md — the placement policy and the ceiling headroom).

⚠ Hook output above ~10,000 characters never reaches the model (measured 2026-09-24, QRS-1289): Claude Code saves it to a file and injects a 2 KB preview, and says so to no one. session-state.mjs printed the whole 135 KB state record for its entire life and every session received its first 2 KB. Every hook budgets to 9,000 characters through tools/hooks/hook-output-budget.mjs: the SessionStart hook prints an extract (each open thread's opening lines and line range, then the section index); on-bash-read.mjs sends whole rules up to the budget, gate: block first, and points at the rest without marking them sent. Cannot see: whether an extract's opening lines are the right ones. Gated by test:hooks, which spawns the SessionStart hook on the REAL record and fails above 9,000 characters or when an open thread is missing from what it prints.

Test runners and their boundaries ​

THREE unit-test runners, split by workspace (this said "two" until 2026-08-12, before apps/web existed): apps/mobile runs jest-expo; apps/web runs vitest; packages/* run the Node test runner (@qrsetu/domain = node --test "src/**/*.test.ts", no jest, no bundler — that is why its relative imports carry .ts extensions). Root npm run test fans out to the six workspaces that define test: mobile, web, analytics, domain, schemas, tokens. Single test: mobile — npm run -w @qrsetu/mobile test -- <path-or-pattern> (or npx jest <path> from apps/mobile); web — npm run -w @qrsetu/web test -- <path>; package — node --test <file>; Playwright — npm run e2e -- e2e/layout-invariants.spec.ts (add --project=phone-small to pin one viewport, which is exactly what e2e:quick does).

Runner boundaries that bite. apps/mobile/e2e/ holds Playwright .spec.ts files, which also match jest's default testMatch; jest ignores e2e/ via testPathIgnorePatterns and Playwright only looks in e2e/ — keep both halves of that split intact or npm test breaks (throwIfRunningInsideJest). Playwright runs against the apps/mobile/dist/ export, which is GITIGNORED and therefore absent on a fresh clone (CI runs web:export itself before e2e; locally you must too, or Playwright fails with dist does not exist). Re-export after any source change or you are testing the previous build. visual.spec.ts screenshot baselines are Linux-only by design (font rasterisation) and skip elsewhere. ⚠ apps/mobile/e2e/visual.spec.ts DOES NOT EXIST — verified 2026-08-06, QRS-364. This sentence described it as live for months; the folder holds only layout-invariants.spec.ts, parity-probe.spec.ts, theme-consistency.spec.ts. apps/web/e2e/visual.spec.ts (M14) is the first real implementation of the Linux-only-skip pattern in this repo.

The full Playwright matrix does not complete on the 16 GB Windows box (QRS-245). Two attempts died on memory, one of them at --workers=2, and check:disk reports C: below its 15 GB floor with nothing reclaimable. So locally workers is capped at 2, npm run e2e:quick (99 tests / ~2.4 min) is the pre-push web gate, and the full 712-test matrix belongs to CI, which starts from a clean runner. Do not interpret a local inability to finish as a test failure.

Known gate blind spots — do not read green as parity. npm run e2e is the web bundle only; jest mocks Reanimated's createAnimatedComponent to identity, so native-only wrapper behaviour does not exist under test. For anything touching packages/tokens/**, apps/*/src/ui/**, theme plumbing or press/gesture handling, the native builds are the gate — see "Cross-platform feature parity".

⚠ AND THE WEB GATE MEASURES THE WRONG MOMENT — e2e/layout-invariants.spec.ts ASSERTS BEFORE HYDRATION. Its settle heuristic is "the same non-zero innerText length on two consecutive samples" (line ~55), which is satisfied by any momentarily stable tree — a prerendered shell (/settings = 8 characters) or a loading skeleton (/dashboard = 21 characters, 4 skeleton blocks). Hydration then replaces the tree. The spec contains zero references to skeleton or -loading (grepped 2026-08-17), so it never waits for either, and KNOWN_SMALL_TARGETS is {} — so the touch-target assertion compares the post-shell state against an empty list and passes. The run-mobile skill measured the settled state with the gate's own selectors and found 9 real sub-44px violations on /dashboard and /settings (incl. 32×32 Notifications and Profile buttons, 32-tall INR/English/System pills, a 48×28 switch) while the gate was green. Its "renders content" assertion also passes at 8 characters. Read a green e2e:quick as "the shell has no overflow", never as "the screen is correct", and use the skill's check command for the truth.

The scripts ​

Grouped as package.json groups them; each H2 is one script.

check:context ​

Command: npm run check:context [-- --write | --range <base>..<head>]Group: root: gates Cannot see: whether a mandatory page was UNDERSTOOD (only that it was opened, via the hooks); whether a rule's invariant is TRUE; anything visual. It decides size, shape and reference resolution.

THE CONTEXT ARCHITECTURE GATE (QRS-1288, 2026-09-23). CLAUDE.md was 3,677 lines and 121.3K tokens, loaded on every session, and the fixed per-session load was ~178K tokens before the first prompt. This gate keeps the root file a constitution and makes the "read this before changing that" layer real. Rules: X1 CLAUDE.md under its line and character ceiling (a ratchet in .claude/context-ceiling.json; raising it needs a per-commit Context-Ceiling: <reason> trailer at pre-push) · X2 every .claude/rules/*.md has paths:, every glob matches ≥ 1 file, the four blocks are present, ≤ 60 lines, and the worst-case overlapping load per churned file is bounded · X3 every path a rule, the routing table or a Read-before block cites RESOLVES · X4 every portal section index has a Read-before-changing block, scanned for retired vocabulary with no exemption · X5 churn coverage — files changed in 90 days matched against all rule globs, uncovered count ratcheted, and every parity-contract implementation path covered by the screens rule · X6 no @import in CLAUDE.md or a rule · X7 the state record under its ceiling · X8 zero correction-shaped lines ("until 2026-", "this file said") in CLAUDE.md or a rule — the growth vector itself · X9 nested CLAUDE.md (under apps/, packages/, supabase/) ≤ 120 lines, skill descriptions ≤ 560 chars each and 7,000 in total, the inventory block ≤ 40 lines · X10 every R-nn cited resolves to architecture/operating-rules.md, and every quoted CLAUDE.md heading in tools/hooks/agents/workflows still exists. --write regenerates the two GENERATED blocks in CLAUDE.md — the rules (from the operating-rules page) and the routing table (from .claude/context-routing.json) — so neither can drift from its source. Full design and the measured incidents: Claude context architecture.

lint ​

Command: npm run lint / lint:fixGroup: root: gates Cannot see: anything only the type-aware program can judge when run through eslint.fast.mjs (pre-commit is untyped on purpose); Sonar-only rules such as S7781 that eslint-plugin-sonarjs does not implement.

ONE central eslint . --max-warnings=0 (never per-workspace). TYPE-AWARE (~217 sonarjs rules + security). ~53s whole tree.

format ​

Command: npm run format / format:checkGroup: root: gates Cannot see: whether the formatting is readable — only whether Prettier would change it.

Prettier write / gate

type-check ​

Command: npm run type-checkGroup: root: gates Cannot see: a type that is any; runtime behaviour; anything in legacy/ (not a workspace).

tsc --noEmit in ALL 10 TS workspaces (QRS-015 closed the opt-in hole)

test ​

Command: npm run testGroup: root: gates Cannot see: native-only behaviour (Reanimated is mocked to identity); a zero-test run in apps/mobile (--passWithNoTests reports green) — read the COUNT.

jest-expo (apps/mobile) + node --test (packages) via workspaces

check:readmes ​

Command: npm run check:readmesGroup: root: gates Cannot see: whether a README is CURRENT or good — presence only; freshness is a review item.

README-everywhere presence gate (ADR-0012)

check:parity ​

Command: npm run check:parityGroup: root: gates Cannot see: any incident it has not yet encoded (it only ever encodes yesterday's defects); native gesture and press behaviour; anything at runtime.

static cross-platform-parity rules (ADR-0017) — pre-commit/push + CI. It PRINTS its own rule count; do not restate one here (QRS-713).

check:naming ​

Command: npm run check:namingGroup: root: gates Cannot see: whether a scoped name is the RIGHT scope; column names and the archive are exempt by design.

feature-scoped naming gate (QRS-436) — N1 package exports · N2 directories · N3 npm scripts · N4 SQL tables/functions. COLUMNS ARE EXEMPT (the table carries the disambiguation), and so is the archive. Incident-driven root list: card · template · plan · primitive · archetype, each naming its own collision. Mutation-tested both directions (check-naming.test.mjs, 10). Also runs as a Claude Code hook the moment a boundary-crossing file is edited (tools/hooks/on-naming-surface-edit.mjs).

check:state ​

Command: npm run check:stateGroup: root: gates Cannot see: whether a sentence in the state record is TRUE — presence and recency only (and, after QRS-1288, size).

PROJECT-STATE FRESHNESS. documentation/portal/dev-tracker/ project-state.md is the COMPACTION-SURVIVABLE HAND-OFF, and this gate is what stops it becoming a one-time snapshot. Fails when its stateAt: front-matter is more than 10 commits behind HEAD, absent, malformed, or names a commit that is not in this branch's history (rebased away or mistyped). ⚠ PRE-PUSH, never pre-commit: commits are frequent and intermediate, and a gate that fires on every commit is one people learn to bypass. A ceiling of 10 rather than "must equal HEAD", for the same reason. ⚠ merge-base --is-ancestor runs BEFORE rev-list, because a sha that is not an ancestor still yields a NUMBER and a meaningless count reads exactly like a real one. ⚠ NO GIT = "unknown", reported and PASSING. A gate that fails where it cannot measure gets disabled, and then it measures nowhere. The same module backs the SessionStart and PreCompact hooks (tools/hooks/project-state-lib.mjs) so the three can never disagree about whether the record is current. ⚠ Decides PRESENCE and RECENCY only — never whether a sentence in the record is TRUE. Claiming otherwise would repeat QRS-246. Mutation-tested 11 ways against REAL throwaway git repos (in npm run test:hooks), which is what caught a real bug in its own library: the git calls ignored the root argument and measured the wrong repository. A mocked execFileSync would have agreed with itself. ⚠⚠ AND IT NEVER RAN AT ALL ON WINDOWS UNTIL 2026-09-07 (QRS-1155) — THE GATE OVER THE COMPACTION HAND-OFF WAS A GREEN NO-OP FOR ITS WHOLE LIFE. Its driver guard compared import.meta.url against a hand-built file:// + argv path (TWO slashes) while Node renders a Windows drive path as file:///D:/… (THREE), so main() was never once called and node exited 0 having written NOTHING. 🔎 Two things hid it, and both generalise. (1) EXIT 0 WITH NO OUTPUT IS WHAT A PASSING RUN ALSO LOOKS LIKE — see the fifth variant under "READ A GATE'S OUTPUT PROPERLY" below. (2) The SessionStart/PreCompact hooks kept reporting staleness CORRECTLY, because they import project-state-lib.mjs directly — so the VISIBLE half of the system worked while the ENFORCING half did not, which is why nobody went looking. ⚠ Its nine mutation tests were green throughout, because every one imports evaluate(). They proved the ARITHMETIC and never the WIRING. A test that imports the function cannot see that the file never calls it — QRS-013's green no-op moved from a gate's logic to its ENTRY POINT. Two of the 11 cases now SPAWN the CLI and assert on its STDOUT, in both directions; asserting the exit code alone passes against the bug. ⚠ NEVER HAND-ASSEMBLE A file:// URL. Use import.meta.url === pathToFileURL(process.argv[1]).href (as check-i18n-keys.js and tools/release/manifest-hash.js do) or resolve(…) === resolve(fileURLToPath(…)) (four more gates). All 44 import.meta.url sites in tools/ were swept on 2026-09-07: this was the only broken one.

check:docs-impact ​

Command: npm run check:docs-impactGroup: root: gates Cannot see: whether the prose is correct or current — correlation only; a one-word edit satisfies it.

documentation-impact gate (QRS-437) — DIFF-AWARE, so it needs a base ref (--base, else $GITHUB_BASE_REF, else origin/develop); exits 2 rather than passing silently if it cannot find one. Tier A (new package/feature/EF, or ANY migration) also demands a tracker row + delivery-log entry; Tier B only the local doc. Waive with a Docs-Impact: <reason> COMMIT TRAILER, which the gate echoes — never with --no-verify. PRE-PUSH + CI, never pre-commit. --worktree diffs the working tree (the Stop hook uses it, since session-end doc edits are usually uncommitted).

check:design ​

Command: npm run check:designGroup: root: gates Cannot see: whether a drift-ledger row's judgement is right; visual fidelity of anything.

design-drift ledger gate (ADR-0015), fails closed

check:design-prompt ​

Command: npm run check:design-promptGroup: root: gates Cannot see: whether the product context named is SUFFICIENT or the modules cited are the right ones — presence and citation resolution only.

DESIGN-PROMPT PRODUCT CONTEXT (QRS-913). ~170ms, pre-commit + ci.yml docs job. A design prompt carrying FEATURE context but NOT EXISTING-PRODUCT context produces a PARALLEL PRODUCT, every time. ⚠ MEASURED: the Marriage Biodata round-2 prompt gave excellent feature context (14 answered questions, every state enumerated) and named prototype/consumer/ ZERO times, named not one reusable module, and never said "extend". It asked for "THE FIVE SCREENS, IN YOUR ORDER" and got a mini-app at prototype/my-qrsetu/ with its own ds-base.js/icons.js/support.js, a biodata-core.js importing nothing, no navigation to ConsumerHome, and no entry in qr-registry.js — whose own header says a new code type is a DATA ENTRY there rather than a screen change. 🔎 ROUND 1 HAD IT RIGHT and round 2 discarded the frame by replacing it with a screen list. A LATER PROMPT CAN LOSE CONTEXT AN EARLIER ONE ESTABLISHED, so each round restates the product frame. D1 a prompt page must DECLARE its target (extends · greenfield · template · legacy) — silence is a failure, never a default · D2 an extends page must name the surface IN THE PROMPT and cite >=3 real files in it · D3 every cited design-project path must RESOLVE (an invented path is worse than an omission, it reads as verified) · D4 inventory age, REPORTED not fatal — a gate that reddens with time gets ignored. The inventory is TRANSCRIBED, never fetched (design-system/.design-project-inventory.json), for the reason check:desktop-parity gives: a gate needing the network fails when the design project is unreachable and then gets switched off. Stale-by-default after a design round. Allowances PRINT on every run — a silent allowance is a hole. ⚠ Decides PRESENCE and CITATION RESOLUTION only. It cannot judge whether the context named is SUFFICIENT or the modules are the RIGHT ones. Claiming otherwise would repeat QRS-246. Presence is what was missing though: round 2 cited zero. Mutation-tested 29 ways, both directions (QRS-013), including the ACTUAL round-2 shape as a fixture. Its own tests caught a real bug on first run: the path regex swallowed sentence-ending full stops.

check:docs ​

Command: npm run check:docsGroup: root: gates Cannot see: whether prose is correct; a SWAP (one legitimate warning removed, one live mention added); source code — it never scans supabase/** or apps/**.

retired-vocabulary gate over documentation/portal AND the two ROOT DOCS (QRS-416, extended 2026-08-24 by QRS-567). 13 term groups; ADRs/tracker/releases/screen-reviews are EXEMPT (they are logs — superseded by a BANNER, never an edit), and ::: containers + > blockquotes are exempt structurally so a warning may name what it warns about. Ratchet baseline with a REASON per file in portal/.docs-vocabulary-baseline.json; also fails on a STALE ALLOWANCE. ⚠⚠ IT DID NOT SCAN CLAUDE.md OR README.md UNTIL 2026-08-24, AND BOTH HAD DRIFTED — on 2026-08-12 this file's own opening section listed the RETIRED PRODUCT NAME — term group #1 in that very script — as one of three live products, eleven days after tables were dropped. The two most-read documents in the repo were the two nobody gated (QRS-567, now CLOSED). Keyed <root>/CLAUDE.md and <root>/README.md so a root file can never collide with a portal-relative path, and COUNTED SEPARATELY in the summary so the portal figure stays comparable with check:claims' own independently-walked count — two numbers agreeing is what makes either trustworthy. Baselined at 38 / 1. The terms are named in the baseline's own why field, not here — every one sits inside an explicit correction. ⚠ Unlike the portal these files have NO ::: containers, so most warnings sit in plain prose and cannot be exempted structurally — the > blockquote is their only aside. ⚠ THE RATCHET STILL BITES (proven twice: one added retired-name line took README to 2 > 1 and failed, and this very paragraph was itself caught and reworded). ⚠ It cannot catch a SWAP — one warning removed, one live mention added. Inherent to a count. ⚠ AND "Mutation-tested 3 ways" WAS ASSERTED ON THIS LINE AND WAS FALSE: there was NO test file anywhere in the repo, measured by enumerating every *.test.mjs outside node_modules. QRS-246 exactly, inside the one gate whose job is catching docs that claim what the repo does not do. NOW genuinely mutation-tested 19 ways (check-docs-vocabulary.test.mjs), both directions, including that an aside is exempt while the SAME sentence outside one is a violation. ⚠ It also RAN ITS DRIVER AT IMPORT until then, so it was not importable and therefore not testable — the guard-bash.mjs landmine again, and much of why no test ever existed.

check:setu-card-templates ​

Command: npm run check:setu-card-templatesGroup: root: gates Cannot see: whether a template looks right or renders correctly — manifest shape, palette contrast and version registry only.

card-template manifest + palette gate (QRS-346, M5) — T1/T2/T3/T4/T7/T9/T12. Imports the REAL Zod schemas (packages/schemas) rather than re-implementing shape checks — the one tools/*.js gate with a workspace-package dependency, deliberately (avoids a second validator that could disagree with the first). Mutation-tested: node --test tools/check-setu-card-templates.test.mjs (20 cases, each proven to fail AND pass on the same fixture, per QRS-013).

check:db-health ​

Command: npm run check:db-healthGroup: root: gates Cannot see: whether a hot query is legitimate — it reads call RATE only; it cannot reach a project without a token (reports unknown, passing).

IS ANY QUERY RUNNING AT A PATHOLOGICAL RATE RIGHT NOW? node tools/check-db-health.mjs [--project dev|prod] [--seconds 20] [--max-rate 100] [--json] ⚠⚠ IT EXISTS BECAUSE 1,378,564,796 ABORTED TRANSACTIONS ACCUMULATED OVER SEVEN DAYS AND NOTHING NOTICED (QRS-1277). payments-watchdog.yml watches money; nothing watched the database, and the incident was found only because the owner opened a dashboard. ⚠ THE METRIC WENT THROUGH THREE VERSIONS AND THE FIRST TWO WERE WRONG, which is why the third is shaped as it is. (1) POSTGRES LOG COUNTS — rejected: the stream is HARD-CAPPED at 6,000/min (measured: three consecutive minutes read 6000/6000/6001 while the true rate was 631/sec). A capped source understates BY DESIGN and can never measure volume. (2) pg_stat_database.xact_rollback — rejected: a controlled experiment moved it by THREE while 4,312 executions occurred, because a retry reusing one transaction rolls back once. It caught the 7-day loop and would have MISSED a shorter one. (3) pg_stat_statements CALL RATE, which this uses. At the incident ONE query held 96.2% of all execution time with 1,379,867,031 calls against 99,584 for the runner-up. ⚠ THE DELTA, NEVER THE CUMULATIVE. pg_stat_statements counts since the last reset (52 days on Dev), so a poisoned instance reads ~96% for weeks AFTER the fix. Two samples seconds apart give a RATE, which is what "is it happening now" means — and it makes the tool stateless, so no stored baseline. ⚠ THE THRESHOLD IS MEASURED: the incident ran ~2,600 calls/sec on one query for 7 days; ordinary traffic on the same database is ~0.02/sec. The default sits orders of magnitude from both. ⚠⚠ IT REPORTED A FALSE 4,539 calls/sec ON ITS FIRST TWO REAL RUNS, from TWO separate bugs, and both are worth knowing because they are properties of the DATA SOURCE: · inspect db calls returns only the TOP TEN, so "absent from sample A" means "not in the top ten", NOT "did not exist" — the first version read a query's whole 52-day cumulative as one window's delta. Now intersected. · IT RETURNS TWO ROWS WITH AN IDENTICAL QUERY STRING and no queryid to separate them, so a Map was last-write-wins and diffed two unrelated statements (99,584 − 8,800 = 90,784, exactly the false number). Now SUMMED by key. 🔎 A unit test had ASSERTED the first wrong rule, so the bug shipped with a green suite describing it as intended. Only the live database disagreed. A watchdog that cries wolf gets ignored — the same outcome as not having one. ⚠ NOT WIRED INTO ANY HOOK OR WORKFLOW, DELIBERATELY — the check:ef-drift precedent, same reasons: it needs the network and a project token, so pre-commit and pre-push are both wrong (a gate that fails on a plane gets bypassed), and the Actions quota is a standing constraint. Run it after a deploy, before a promotion, and whenever a database is suspected. ⚠ CANNOT MEASURE ⇒ "unknown", REPORTED AND PASSING. Only a MEASURED runaway exits 1. ⚠ It reads call RATE, so it cannot tell a legitimate hot query from a runaway — a bulk import looks identical. It says "one query dominates", never "this is wrong". Judging the query it names is a human's job (QRS-246). 14 cases, both directions, against the REAL incident numbers. ⚠ Detection is proven by those unit tests, NOT end-to-end against a live runaway — creating one on a shared Dev was declined, correctly. That gap is real and stated.

check:biodata-parity ​

Command: npm run check:biodata-parity (after cd apps/web && npm run build) Group: root: gates (network-free, needs a BUILT Worker; wired into no hook and no workflow) Cannot see: pixels, spacing, typography and colour; content hidden by CSS; anything the run-web driver's single sample record cannot vary (one subject, one theme, one family layout, no private-tier field); the SQL projection itself (pgTAP owns it); a stale build; a wrong resolver, which makes page and expectation agree on a wrong reading.

THE DESIGN'S OWN PARITY METHOD, PORTED (prototype/my-qrsetu/BiodataParityCheck.dc.html, round 55). It does NOT compare view models to each other — the resolver is template-agnostic, so that comparison is identical by construction and proves nothing. It serves every catalogued biodata design (BIODATA_TEMPLATES) from the built Worker through the run-web driver, at basic and released, and reads the DOCUMENT back: COVERAGE (one separable value per filled slot, in text or link targets) · LEAKAGE (at basic, the mock's released projection minus its basic projection must be absent, text and tel: / wa.me targets included) · FIDELITY (photo count by URL, inline --bio-accent, [data-family-layout], data-slot order via biodataOrderOk) · IDENTITY (data-design different from the requested design is SUBSTITUTED, never a pass; data-tier must match). A reading counts only after three consecutive identical seconds; <script>, <style>, <template> and the hydration payload are excluded; a value that also occurs in other slots or in catalog copy is reported NOT SEPARABLE rather than passed. Exit 1 only on a MEASURED failure (or any substitute under --require-registered); cannot measure ⇒ unknown, exit 0. Ports 4185/4186 (RUN_WEB_APP_PORT / RUN_WEB_MOCK_PORT); --fixture <file.json> evaluates saved readings offline. 32 tests, 5 spawn the CLI, 13 of 13 code mutants killed (tools/check-biodata-parity.test.mjs). First real run (2026-09-26): 6 readings owed, 6 in parity, 0 substituted — content and disclosure only, never visual parity.

check:biodata-presentation ​

Command: npm run check:biodata-presentation (after cd apps/web && npm run build; needs a design root, default D:/DevCache/design-mirror, and the network for the artboard's React runtime) Group: root: gates (wired into no hook and no workflow; run on request before any biodata parity claim) Cannot see: spacing, colour, typography and block TREATMENT (a card and a timeline with the same words read the same); anything outside innerText (photographs, aria-labels, shadow-DOM captions); closed, pending and 404 states; data shapes the design's seed does not carry (an education with no institution was invisible to it, QRS-1353); the app, which embeds this page (ADR-0033).

THE PAGE AGAINST ITS ARTBOARD, SAME PERSON, LINE BY LINE (QRS-1357). Built after the owner found the in-app Preview and the shared link materially different while every other check passed (QRS-1354): check:biodata-parity proves content and disclosure, never presentation. Both sides render the design's OWN seed record (biodata-core.js, Sanika), the page through the run-web mock (RUN_WEB_BIODATA_FIXTURE, private fields dropped as the SQL drops them), so words can be compared and not only layout. Per design × tier × language (18 cells), the artboard's page root and the page's [data-design] are read as normalised lines and diffed: MISSING · EXTRA · MOVED (blocks) · CHANGED ("27 years" against "27"); a line split differently on each side is not a difference. Every difference is APPROVED (an owner decision), TRACKED (a known gap with a QRS id, NOT done) or UNEXPLAINED, from parity-contracts/allowlists/biodata-presentation.json; an entry may name catalog keys, resolved per language, so it never quotes the fixture's data. Exit 1 on any DIVERGED cell or any STALE allowance; --strict also fails TRACKED (the Definition of Done); cannot measure ⇒ UNKNOWN, exit 0. ⚠ Known limit, safe direction: when two blocks swap, the LONGER stays in place, so an approved section that is the longer leaves the other reported as unexplained (tested). --save / --fixture store and re-evaluate readings offline. 17 tests, 3 spawn the CLI; mutation checked on the real readings both ways (the D2 entry removed ⇒ 6 DIVERGED; the pre-fix page ⇒ 14 DIVERGED and 2 stale). Baseline 2026-09-27: 1 of 18 cells in parity; after the fixes, 4 PARITY, 14 TRACKED, 0 DIVERGED.

check:sql ​

Command: npm run check:sqlGroup: root: gates Cannot see: whether a COMMENT ON is good; a genuine serialization failure versus a deterministic refusal (that is intent, and no regex has it); pre-fix migrations are exempt and printed.

THREE tools: RPC/grant least-privilege lint (ADR-0014) · table/function COMMENT ON coverage · and since 2026-09-22 the RETRYABLE-SQLSTATE gate (QRS-1276, tools/check-sql-errcodes.js). ⚠⚠ THE THIRD EXISTS BECAUSE ONE errcode LITERAL COST 1,378,564,796 ABORTED TRANSACTIONS. Seven biodata write-API functions raised DETERMINISTIC refusals — a withdrawn share, a concluded profile, a stale version — with errcode = '40001', the SQL standard's serialization_failure, i.e. "this transaction failed for concurrency reasons, RETRY IT". None can succeed on retry, so the retry never stopped: four pooled connections spinning from 15 Sep, CPU at 100% for seven days, against ZERO HTTP requests for the RPCs (QRS-1277). MEASURED BY CONTROLLED EXPERIMENT through the real PostgREST — three functions, identical bodies, differing ONLY in SQLSTATE, each called once: 40001 NEVER RETURNED and produced 9,215 executions over 96s; QRS09, QRS10 and P0002 each produced EXACTLY 1. 🔎 The retry OUTLIVES the request (client gave up at 08:03:36, last execution 08:03:52), which is how one request poisons a pooled connection for a week with nothing calling it. ⚠ IT KEYS ON THE CLASS, NOT THE NUMBER — class 40 (transaction rollback) and class 08 (connection exception) both read as TRANSIENT below the application. A gate matching only 40001 would miss 40P01 and every 08xxx. ⚠ PRE-FIX MIGRATIONS ARE EXEMPT BY DESIGN and the count PRINTS: a merged migration is never edited, so those nine 40001 literals are PERMANENT TEXT, and 20260922090000 supersedes the functions anyway. Gating them would make this permanently red, and a permanently red gate gets bypassed (the check:rpc failure mode). Same exemption check-sql-comments takes. ⚠ It CANNOT tell a genuine serialization failure from a deterministic refusal — that is intent, and no regex has it. So it REFUSES BY DEFAULT and needs an allowance with a written reason; allowances PRINT, and a STALE one fails. Claiming more would repeat QRS-246. Mutation-tested 11 ways, both directions, and TWO cases SPAWN THE CLI and assert on stdout — check:state passed nine mutation tests while being a total no-op on Windows because every one imported the pure function (QRS-1155). Proven end to end: a throwaway migration reintroducing 40001 is caught by file:line and exits 1.

check:disk ​

Command: npm run check:diskGroup: root: gates Cannot see: anything stored outside an env-configurable path that is not in its UNMANAGED list; whether a cache is worth keeping.

dev-drive hygiene invariants (QRS-205)

check:release ​

Command: npm run check:releaseGroup: root: gates Cannot see: the nine change classes invisible to every script (secrets, storage, cron, auth settings, Cloudflare, third-party, the three app classes); whether evidence is TRUE.

release records + EVERY production change declared (QRS-288), PLUS the G-D discovery gate (QRS-475): a scope item carrying vertical: "<slug>" needs verticals/<slug>/discovery.md to EXIST at scoped and to be APPROVED at scope_frozen. Two-stage on purpose — a single wall gets switched off. 13 of the suite's 54 cases cover it, both directions (QRS-013).

check:version ​

Command: npm run check:versionGroup: root: gates Cannot see: whether a build was actually produced or submitted — integrity of the recorded numbers only.

app version/build integrity, per platform (QRS-289)

version:set ​

Command: npm run version:set -- 26.0.2Group: root: gates Cannot see: it is a writer, not a gate — it cannot tell you whether the new version is the right one.

stamp a version (--build-android N / --build-ios M diverge)

check:portal-nav ​

Command: npm run check:portal-navGroup: root: gates Cannot see: whether a page is worth navigating to — orphan pages and dead sidebar links only.

orphaned portal page OR dead sidebar link (QRS-448). ~40ms, pre-commit + CI. A spec that is not in config.mjs's sidebar is invisible in the portal, which is how two orphans shipped.

check:screens ​

Command: npm run check:screensGroup: root: gates Cannot see: fidelity — presence and ledger honesty only; whether the design changed upstream (the ledger is transcribed, not fetched).

SCREEN CONFORMANCE — every APPROVED design screen is accounted for (QRS-626). The ledger is documentation/portal/design-system/screen-conformance.json, transcribed from the Claude Design PROTOTYPE project's own SCREENS.md (which PrototypeHub.dc.html generates itself from). ⚠ NOT 1:1 with it, and this line said "1:1" until 2026-08-28: the ledger scopes to TWO surfaces of the registry's nine, and carries five rows SCREENS.md does not have at all (QRS-1024). Six BIDIRECTIONAL rules: S2 a built screen whose folder moved · S3 a screen implemented while the ledger still reads missing — the direction that makes the printed count a FACT rather than an estimate · S4 a stale row not naming its design round · S5 a deferral with no reason · S6 two rows laundering one path. Prints the unimplemented count on EVERY run, green or red, so it can only come down through real work. Answers PRESENCE, never FIDELITY. ~30ms, pre-commit + CI, mutation-tested 18 ways. ⚠⚠ S3 SWEPT tiers/user ONLY UNTIL 2026-09-04, so for every section: "consumer" row — eleven of them since 2026-08-13 — the rule that makes the count a fact was INERT. Now derived from a TIERS array, like guardrails.js (QRS-1025). ⚠ Update a row in the SAME change that moves the code.

design:spec ​

Command: npm run design:spec -- <artboard.dc.html> <anchor> [--end <anchor>]Group: root: gates Cannot see: it is an extractor, not a differ: it cannot compare HTML against React Native or decide they agree; a token with a fallback prints raw.

EXTRACT A DESIGN SPEC (QRS-1101) — the missing FIRST step of the design-to-code workflow. Prints every styled element in a .dc.html section with its CSS declarations, radius tokens resolved to numbers, plus every {{ binding }} the parity contract must enumerate. ⚠⚠ IT EXISTS BECAUSE THE WORKFLOW WAS implement -> owner reviews -> fix, and the implement step was READING the artboard and typing what was remembered. Measured on ONE screen (Consumer Home, one session): 7 commits, 12 tracker rows, and SEVEN of eleven visual defects were plainly "the design says X, the code says Y" — radius 24 vs 40, e1 vs e2, a 72px QR vs 64, no letter-spacing where the design sets -.035em, the address in the UI face vs mono, a CTA missing its icon, and a note line the design renders NOWHERE. Every one type-checked, linted and passed check:design-parity. 🔎 The one time the artboard was extracted MECHANICALLY, ten defects fell out in a single pass. This makes that the default rather than the recovery. ⚠ IT IS NOT A DIFFER AND CANNOT BECOME ONE — the design is HTML + a view model, the code is React Native, and nothing can diff those and decide they agree (QRS-246). It removes the TRANSCRIPTION step; the comparison stays human. ⚠ Fetch the artboard FRESH via DesignSync into the scratchpad. A committed copy is a cache and may be stale. ⚠ A token WITH a fallback (var(--a, var(--b))) prints raw: resolving it needs the first token's value, and guessing would launder a fabricated number through a tool that looks authoritative.

check:i18n-keys ​

Command: npm run check:i18n-keysGroup: root: gates Cannot see: whether the sentence is right, or whether hi/mr are good translations; keys BUILT AT RUNTIME (counted and printed, not resolved).

I18N KEY RESOLUTION (QRS-1068) - every LITERAL translation key an app asks for must resolve to a STRING in the en catalog. ~380ms, pre-commit + ci.yml's workspace job. The rules: I1 tr('a.b') / tr('en','a.b') / t(lang,'a.b'); I2 the *Key: 'a.b' INDIRECTION (labelKey, titleKey, ...), which I1 cannot see because the call site reads tr(meta.labelKey). IT EXISTS BECAUSE t() RETURNS THE KEY WHEN IT CANNOT RESOLVE ONE, so a namespace inserted into the wrong object renders consumerMakers.downloadImage where a button label belongs, and on 2026-09-05 FOUR namespaces did exactly that: they landed under onboarding.setup because an insertion anchor matched a NESTED common before the root one. NOTHING ELSE COULD SEE IT, and that is the whole argument for the gate. type-check cannot, because t(key: string) accepts any string. i18n-catalogs.test.ts cannot, because it asserts the three languages agree WITH EACH OTHER and all three were wrong identically. And jest cannot, because a test that computes its expected text from the same key matches the unresolved key and PASSES. QRS-1070 is that shape at its sharpest, found by this gate's first run: OrderCodeScreen's test asserted getByText(tr('orders.status.ready')), a key in NO catalog, against a screen rendering that same key because the stub behind it invented a second status vocabulary. The buyer saw orders.status.ready where "Ready" belonged, and the test passed for exactly as long as the defect existed. A key BUILT AT RUNTIME cannot be resolved statically. Those are COUNTED AND PRINTED on every run, because a silent cap reads as full coverage (QRS-013). Decides RESOLUTION only. Never whether the sentence is right, nor whether hi/mr are good translations (QRS-924, human). Mutation-tested 11 ways, both directions, against real files on disk. The driver runs only when the file is executed directly, so the module is importable and therefore testable - the guard-bash.mjs landmine, not repeated.

check:env ​

Command: npm run check:envGroup: root: gates Cannot see: the npx expo run:ios path (npm pre* hooks do not fire for it, QRS-669); values — it never prints one.

MOBILE ENV PREFLIGHT (QRS-668) — asserts apps/mobile/.env.<NODE_ENV> EXISTS and defines EXPO_PUBLIC_SUPABASE_URL + _PUBLISHABLE_KEY, then PRINTS which Supabase project the build will talk to. Never prints a value. check:env:prod is the same check against .env.production (qr-setu-PROD) and WARNS in red, because that is the surface real merchants use. ⚠ It runs AUTOMATICALLY as prestart/preios/preandroid/preweb, so it is not a gate you remember — it is one you cannot skip via npm. npm pre* hooks do NOT fire for npx expo run:ios, which is the command the incident was reported from, so that path is still uncovered (QRS-669). It exists because .env.* is GIT-IGNORED, so git pull can never deliver it and every new machine — notably the Mac for iOS — builds and launches fine, then fails at the first auth call with getSupabaseClient: not initialized. That message names a client that was never created rather than a file that was never copied, which is the whole cost. ⚠ THE GUARD ASYMMETRY IS THE LESSON: build-android.mjs and web-export.mjs had verified the env file and printed the project ref since QRS-605; npm start/ios/android were raw expo calls with NO check. The only unguarded paths were the DEV ones, which is exactly where a new machine starts — the hardened paths had had incidents and the dev paths had not, so nobody had built their guard yet. Resolves through @expo/env's OWN getEnvFiles/parseEnvFiles, never a second dotenv parser — a resolver that can disagree with the bundler is worse than none, because it passes while the build fails. ⚠ "Mutation-tested 3 ways" WAS ASSERTED HERE AND IS FALSE: there is no test file for apps/mobile/scripts/check-env.mjs anywhere in the repo (measured 2026-08-24 by enumerating every *.test.mjs outside node_modules). QRS-873.

check:env-drift ​

Command: npm run check:env-driftGroup: root: gates Cannot see: what it actually compares needs re-establishing (QRS-670: it never reads .env.example); a console-only secret change until someone runs it (no cron since 2026-09-23).

.env.example vs what the EFs actually read; also env-drift.yml runs it on push (migrations/functions) and on MANUAL dispatch. ⚠ Its daily cron was REMOVED 2026-09-23 (no scheduled runs in any workflow), so a console-only secret change needs a manual run. ⚠ THAT DESCRIPTION IS WRONG AND HAS BEEN FOR SOME TIME: tools/check-env-drift.js never reads .env.example at all (grepped 2026-08-14 — its only readFileSync is env-drift/collect.sql). Re-establish what it actually compares before citing it as the env-documentation gate. QRS-670.

check:ef-drift ​

Command: npm run check:ef-driftGroup: root: gates Cannot see: whether a function WORKS (it never executes anything); secrets, storage, cron, auth settings, Cloudflare, third-party config and the app classes; only ONE project per run.

WHAT IS DEPLOYED vs WHAT IS IN THE REPO (QRS-1128) — the first artifact here that reads a LIVE PROJECT rather than the repo. node tools/check-ef-drift.mjs [--project dev|prod] [--json]. ⚠⚠ IT EXISTS BECAUSE THE SIXTH RULE'S OTHER HALF WAS NEVER BUILT: "the release system governs what is DECLARED. Nothing measures what is DEPLOYED." Every other gate reads the repo, so a repo/project divergence is invisible BY CONSTRUCTION — QRS-693 (a migration never applied), QRS-694 (four archived functions still ACTIVE, two on the money path), and QRS-1128 itself. 🔎 THE DEFECT IT TARGETS IS A CLAIM, NOT A BUG. Four functions were reported to the owner as "stale by 2-4 days, so redeploying ships those changes too", and the redeploy offered as their call. That was an INFERENCE OFF COMMIT DATES. Measured, all four were byte-identical to the repo: the trade-off did not exist, and its cost would have been a known money-path defect left live because redeploying FELT risky. HOW: supabase functions download <fn> --use-api returns the DEPLOYED source, including the _shared snapshot that bundle was built from. ⚠ The download MUST land outside the repo — without --workdir the CLI writes into supabase/functions/, so a read-only check would overwrite the working tree with whatever is deployed. The temp workdir is asserted to be outside the repo before a single download runs. ⚠⚠ LINE ENDINGS ARE NORMALISED, AND THAT IS LOAD-BEARING. A deployed bundle is always LF; a Windows checkout is CRLF (core.autocrlf=true, LF in the object store — normal, not a defect). A byte diff reported reconcile-payments as 404 CHANGED LINES IN A 404-LINE FILE, on the money path, and it was nothing. A gate that reports total drift on every file on one machine is one that gets switched off in a week. E1 a deployed function whose own source differs · E2 its bundled _shared differs · E3 ACTIVE on the target, absent from the repo (QRS-694: worse than absent, a deployed function reads as a working feature) · E4 in the repo, never deployed (a 404 the client shows as a generic failure) · E5 deployed verify_jwt disagreeing with config.toml (QRS-643, where the posture was an ACCIDENT not a decision). E2 is scoped to the files the bundle CARRIES — the repo has more, and one unrelated shared edit would otherwise redden all 15. ⚠ NOT WIRED INTO ANY HOOK OR WORKFLOW, DELIBERATELY. It needs the network and a project token, so pre-commit and pre-push are both wrong (a gate that fails on a plane gets bypassed), and the Actions quota is a standing constraint. Run it before a promotion, after any deploy, and before any claim about deployed state. ~7 min for 15 functions. ⚠ CANNOT MEASURE ⇒ "unknown", REPORTED AND PASSING (the check:state precedent). Only MEASURED drift exits 1. ⚠ That fail-open design once HID A REAL BUG IN ITS OWN READER — --output json emits a pretty ARRAY while the bare command emits one-line {"functions":[…]}, so the parser threw and the broken run reported success. Both shapes are now handled and tested; a fail-open gate needs its reader tested HARDER. ⚠ Decides SOURCE EQUALITY on ONE project. It never executes anything, so it cannot tell you a function WORKS, and it is blind to secrets, storage, cron, auth settings, Cloudflare, third-party config and all three app classes — nine of the sixth rule's seventeen change classes. Mutation-tested 28 ways, both directions (node --test tools/check-ef-drift.test.mjs), incl. that CRLF is NOT drift and that a real one-character change still IS.

check:qa-suite ​

Command: npm run check:qa-suiteGroup: root: gates Cannot see: whether a case is GOOD, an expectation CORRECT, whether the suite COVERS the product, or whether a state still matches the build.

THE QA CASE LIBRARY IS VALID AND THE WORKBOOK IS NOT STALE (QRS-1130). tools/qa/cases-*.mjs is the SSOT; the Excel workbook is a BUILD OUTPUT of npm run qa:workbook. ⚠⚠ IT EXISTS BECAUSE THE PREVIOUS SUITE LIVED IN A CHAT MESSAGE. 52 of its 66 cases were never transcribed anywhere, so a context reset destroyed them and the owner's own device findings had to be recovered from a SCREENSHOT to be analysed. Q1 a duplicate case id (QRS-249 duplicate-identity, applied to QA) · Q2 a malformed case — no preconditions, no steps, or a step with an action and NO EXPECTED RESULT, which is the rule that makes "verify login works" unrepresentable; a blocked-* or known-defect case additionally REQUIRES tester guidance, because without it a tester files defects against features that were never built · Q3 an id prefix mapping to no sheet group, which would vanish from the workbook and its totals · Q4 the workbook is STALE against the library. ⚠ Q4 IS THE ONE WITH TEETH. A stale build output is worse than a missing one: the team executes last week's suite against this week's build while every file in the repo looks current. The stamp hashes the tester-visible DATA, never the source text, so a comment reflow does not demand a pointless regeneration. ⚠ The hash has ONE implementation (tools/qa/hash.mjs). It was briefly written twice, and that fails in the worst way — the gate reports STALE right after a successful regeneration. ⚠ Decides SHAPE, UNIQUENESS and FRESHNESS only. It cannot judge whether a case is GOOD, whether an expectation is CORRECT, whether the suite COVERS the product, or whether a state still matches the build. Claiming otherwise would repeat QRS-246. Mutation-tested 20 ways, both directions.

qa:workbook ​

Command: npm run qa:workbookGroup: root: gates Cannot see: it is a generator — it cannot see hand edits in columns A–L (the next run discards them).

GENERATE THE QA WORKBOOK + PORTAL PAGE from the case library. Writes documentation/portal/qa/qr-setu-qa-workbook.xlsx (one sheet per feature, frozen header, autofilter, Status/Retest/Final dropdowns, a README tab and a formula Summary tab) plus qa/test-suite.md and the .suite-hash stamp. ⚠ ZERO-DEPENDENCY XLSX WRITER (tools/qa/xlsx.mjs): an .xlsx is a ZIP of XML and Node ships zlib, so the workbook regenerates on ANY machine with no install. Adding exceljs would need an owner callout under the dependency policy, and a generated artifact that needs a missing dep to rebuild is one nobody rebuilds. ⚠ NEVER HAND-EDIT A DEFINITION COLUMN IN THE SPREADSHEET — the next regeneration discards it. Columns A-L are generated and engineering-owned; M onwards are QA-owned and never written here.

check:fn-config ​

Command: npm run check:fn-configGroup: root: gates Cannot see: nothing, as wired: it exits with USAGE without --project <ref>, so it runs as a gate nowhere (QRS-643).

every supabase/functions/* has a config.toml verify_jwt entry. ⚠ REQUIRES --project <ref> AND EXITS WITH USAGE WITHOUT IT, so it is NOT running as a gate anywhere (QRS-643). That is how manage-reminder came to have a function folder and no config entry at all. check:claims now reports that specific gap on every run, but the gate itself still needs fixing.

check:rpc ​

Command: npm run check:rpcGroup: root: gates Cannot see: argument NAMES (PostgREST binds by name — QRS-640; arg-name checking is QRS-641); whether an RPC body is right; a stub seam that never calls its constant.

RPC CONTRACT (QRS-638) — every RPC name the client calls must be defined in a NON-ARCHIVED migration. R1 dead name · R2 the sharper case: defined ONLY under _archive_pre_v2/, reported distinctly because "it exists" is the wrong conclusion from a grep that does not exclude the archive · R3 advisory inline .rpc('literal') instead of a *_RPC constant · R4 stale allowance. Allowances carry a REASON + a kind (forward-declared vs known-live-defect) and PRINT on every run — a silent allowance is a hole in the gate (QRS-570). Found 3 real defects on its first run, incl. QRS-637. ⚠ CHECKS NAMES, NOT ARGUMENT NAMES — and QRS-640 proved that is a real gap: is_slug_reserved was called with check_slug for (p_slug), PostgREST binds by NAME, and this gate was green throughout. Arg-name checking is QRS-641. ✅ WIRED 2026-08-24 (QRS-742 CLOSED). It appeared ONLY as a package.json script until then — not in .husky/, not in any workflow — so it ran only when somebody typed it, which is part of how QRS-636 and QRS-640 reached Dev. Now pre-push + ci.yml's workspace job. ⚠ PRE-PUSH, NOT PRE-COMMIT, and the reason is MEASURED rather than aesthetic: 2134ms against 69 migrations plus the whole client tree. Pre-commit is sub-second by design and a gate that slows every commit is one people learn to bypass. An RPC name cannot drift between commit and push in any way that matters. ⚠ The four pre-existing findings were the reason it COULD NOT be wired while red — a permanently failing hook gets bypassed and a bypassed gate still reads as coverage (QRS-013). Each was MEASURED stub-only (no service.supabase.ts on disk, the barrel binds createStub*Service, the name is a *_RPC constant nothing invokes) and carries a forward-declared allowance with its reason: get_my_collectable_orders · get_my_consumer_activity · get_payment_ledger · get_setu_card_activity. Six allowances now, all printed on EVERY run. ⚠ get_app_release_policy stays known-live-defect, NOT forward-declared, and conflating the two kinds would be worse than having no allowance: releaseService IS Supabase-wired, so that one is a real broken read (QRS-637).

check:claims ​

Command: npm run check:claims [-- --write]Group: root: gates Cannot see: whether prose is correct, current or well-reasoned — arithmetic and existence only.

DOC ARITHMETIC (QRS-642) — measures the repo and compares against the GENERATED MEASURED-INVENTORY block at the top of this file: EF set + which lack a config entry, migrations, real-vs- stub data seams, gates, workflows, ADRs, features, primitives, portal pages, and the Ganapati screen ledger. --write regenerates; CI compares and fails on drift. ALSO asserts every check:* script is DESCRIBED in prose here (C3) — it found check:rpc, built the same morning and already invisible. ⚠ Decides ARITHMETIC and EXISTENCE only. It cannot judge whether prose is correct, current, or well-reasoned. Claiming otherwise would repeat QRS-246 exactly.

check:arch-proposal ​

Command: npm run check:arch-proposalGroup: root: gates Cannot see: whether the evidence is RELEVANT, whether a safe verdict is TRUE, whether the cited line SAYS what the claim says, or whether the enumeration of dependents is COMPLETE.

ARCHITECTURE CHANGE PROTOCOL (QRS-871) - an architectural change to a CORE ENTITY may not be RECOMMENDED unless the assessment behind it is complete and its citations RESOLVE. ⚠ IT EXISTS BECAUSE I PUBLISHED A RECOMMENDATION THAT WAS IMPOSSIBLE TO EXECUTE, WITH ITS OWN REFUTATION ON THE SAME PAGE: architecture/user-lifecycle.md MEASURED that conversations.consumer_user_id is NOT NULL, quoted it, and then recommended ON DELETE SET NULL. Rule L9 generalised that same instruction across 11 FKs having checked the nullability of NONE of them. A second recommendation - a re-pointable 302 "touchpoint" QR redirect - was described as a refinement of existing behaviour with NO redirect layer ever inspected. 🔎 The generalisable defect: A RECOMMENDATION WAS ASSERTED WHERE AN ASSESSMENT WAS OWED - the third rule's shape applied to architecture instead of to readiness. And the harm is the REGISTER: measured and unmeasured claims were written identically, so a reader could not tell them apart. Proposals live in documentation/portal/architecture/proposals/*.md with a machine-readable 10-step front-matter block; the process page is architecture/architecture-change-protocol.md. Eight rules: P1 a missing step · P2 a step with NO VERDICT ("not checked" and "fine" must never look the same) · P3 a citation that does not resolve - a path that is absent, or a file:line whose LINE IS PAST THE END OF THE FILE · P4 a customer type left unassessed (all FOUR by name: consumer · solo_smb · dealership · enterprise) · P5 high/severe risk with no migration strategy, or neither a rollback nor a forward_fix · P7 a core entity discussed in the body but undeclared · P8 a safe verdict resting on inferred: evidence ALONE. ⚠⚠ P6 IS THE ONE WITH TEETH: any insufficient_evidence step or customer type FORCES final_recommendation: do_not_implement. That converts the owner's instruction - "explicitly say architecture evidence is insufficient" - from a request into a condition, because an open question and a green recommendation cannot coexist in one document. The template ships in that honest state and PASSES, so saying so is always the cheap path. P7's matcher is deliberately NARROW - public.x, backticked x, x.column, or after a SQL keyword - because four of the ten core entities are ordinary English words and \borders\b fires on "orders of magnitude". A gate with false positives is a gate that gets bypassed. Mutation-tested 24 ways, both directions (QRS-013), including that plain prose does NOT trip P7 and that refusing a change never needs a measured citation. ⚠ Decides PRESENCE, CITATION RESOLUTION and INTERNAL CONSISTENCY only. It cannot judge whether the evidence is RELEVANT, whether a safe verdict is TRUE, whether the cited line SAYS what the claim says, or whether the enumeration of dependents is COMPLETE. Claiming otherwise would be QRS-246 exactly. The enumeration stays human work; the gate's whole contribution is that an unbacked recommendation is LOUD rather than indistinguishable from a backed one. Pre-commit + ci.yml's docs job. Paired with the on-core-entity-edit.mjs hook, which covers the other direction - a core-entity MIGRATION written with no proposal at all, which a gate that only reads proposals can never see.

check:design-parity ​

Command: npm run check:design-parityGroup: root: gates Cannot see: whether a pass verdict is TRUE, whether a contract enumerates the design COMPLETELY, whether the pixels match, or whether the design changed upstream; a row that declares no reachableFrom route is not checked for reachability (P7 is optional by design).

THE MACHINE HALF OF THE FOURTH RULE (R-04, QRS-683). Iterates every parity contract under documentation/portal/design-system/parity-contracts/*.json: a screen with no contract · a row with no verdict ("not checked" and "fine" must never look the same) · a pass row whose evidence testID does not exist in the source · a contract whose design round is older than the ledger's · P7: a row whose declared reachableFrom route does not mount it — added after a green contract covered an UNREACHABLE PIN gate for a month (QRS-1001/1002): the testID existed in a component while entry="open" had zero product call sites. Evidence in a COMPONENT proves the behaviour was written; a token in a ROUTE proves something renders it. Prints the route-verified count on every run (a ratchet, like check:screens), because requiring reachableFrom everywhere today would make the gate permanently red and a permanently red gate gets bypassed. Pre-commit + CI. The enumeration is human work; the gate makes an omission LOUD instead of silent.

check:env:prod ​

Command: npm run -w @qrsetu/mobile check:env:prodGroup: mobile app Cannot see: the npx expo run:ios path (npm pre* hooks do not fire for it, QRS-669); the values themselves — it never prints one.

The same preflight as check:env, run against apps/mobile/.env.production (qr-setu-PROD, ref ikkwqowfnbhdasfejojg). It asserts the file EXISTS and defines EXPO_PUBLIC_SUPABASE_URL + _PUBLISHABLE_KEY, then prints WHICH Supabase project the build will talk to — and WARNS in red, because that is the surface real merchants use. It exists because a release APK once pointed at LIVE PROD while the build script printed "development" (raw gradlew bypassed the env wrapper, and the guarded rebuild reused the bad bundle as UP-TO-DATE); reading the project ref off the artifact, not the build line, is the whole point. Resolves through @expo/env's own parser, never a second dotenv reader. ⚠ "Mutation-tested 3 ways" was once asserted for this script and was false — there is no test file for apps/mobile/scripts/check-env.mjs (QRS-873).

check:desktop-parity ​

Command: npm run check:desktop-parityGroup: root: gates Cannot see: whether a pass is true or a contract enumerates the design completely; upstream design changes (the inventory is transcribed).

DESKTOP DESIGN-SIDE DENOMINATOR (QRS-815) - the 16 designed merchant-console screens and the 4 marketplace screens measured against routes + parity contracts. ⚠ IT EXISTS BECAUSE NOTHING ELSE COULD ANSWER "how much of the desktop console is built". check:screens reads screen-conformance.json, which is transcribed from the design's MOBILE sections, so a desktop screen appears in no row at all; check:design-parity iterates the contracts that EXIST, so it can never report a designed screen nobody wrote a contract for. Both are correct and neither holds the design-side denominator. Prints per screen: NOT BUILT / built-no-contract / the verdict tally. Currently 6 of 20 designed desktop screens have a route and 5 have a contract. FAILS on an UNASSESSED row or a stale component path only. "not built" and "built, no contract" are REPORTED, because failing on a project state makes a gate permanently red and permanently red gates get bypassed (the check:rpc failure mode), and because check:design-parity already tracks the no-contract backlog as advisory - two gates disagreeing about one fact is worse than one gate. ⚠ The design inventory is TRANSCRIBED, not fetched: a gate that needs the network fails when the design project is unreachable and then gets switched off. Stale-by-default after a design round; re-measure with list_files. ⚠ Decides PRESENCE and ENUMERATION HONESTY only. It cannot judge whether a pass is true or whether a contract enumerates the design completely.

test:hooks ​

Command: npm run test:hooksGroup: root: gates Cannot see: the wiring in .claude/settings.json (a test imports the function; only a spawned-CLI case sees a driver that never runs); the real Claude Code process.

unit tests for the Claude Code hooks in tools/hooks/

test:portal-theme ​

Command: npm run test:portal-themeGroup: root: gates Cannot see: whether the portal looks good or a diagram is legible — contrast and wiring only.

DEV-PORTAL THEME GATE (QRS-749/750) — 8 assertions over the portal's QR Setu theme. It exists because the portal shipped STOCK VITEPRESS INDIGO until 2026-08-18, i.e. the docs for a platform whose own standard is "zero hard-coded colours, one shared token package" were the one surface not consuming those tokens. T1 the generated palette exists and is COMPLETE (a partial one silently falls back to indigo) · T2 every colour mapped to a TEXT role meets WCAG AA on its own surface, in both schemes · T3 the trap, asserted as a measurement: saffron at brand strength is 1.45:1 on white, so links are CORAL (700 light / 300 dark) and saffron is a BACKGROUND with navy ink — the app's own accent/accent-contrast pairing · T4 no prefers-color-scheme block (the QRS-201 invariant) · T5 every theme file the entry imports exists, and portal.css's citation of this test is not a dangling reference · T6 mermaid's fontFamily contains NO webfont. ⚠ T6 is the mermaid CLIPPING fix, and it is not a style choice: mermaid sizes each node by MEASURING its label, so a webfont arriving after that measurement widens the glyphs and not the boxes, and labels are cut off mid-word. Same source renders fine on mermaid.live, which loads no webfont — that difference IS the diagnosis. A second, INDEPENDENT cause was vertical: .vp-doc's 1.72 line-height made multi-line labels paint taller than the measured box, so portal.css forces line-height: normal inside diagrams. Fixing one and not the other still clips. ⚠ Decides CONTRAST and WIRING only. It cannot judge whether the portal looks good or a diagram is legible — that stays a screenshot and a human.

sb ​

Command: npm run sb -- <dev|prod> <args>Group: root: gates Cannot see: it is a wrapper — it cannot tell you whether the command you asked it to run is the right one; it only guarantees which PROJECT receives it.

RUN THE SUPABASE CLI AS A NAMED PROJECT, without touching the stored login. tools/supabase-as.mjs. ⚠ IT EXISTS BECAUSE A PAT IS SCOPED TO A USER, so supabase login --token is LAST-WRITE-WINS and the other account's projects vanish entirely. That surfaces as a 403 that reads like a permissions problem and is actually a wrong-account problem. It has now cost real time on 2026-08-18 AND 2026-08-20, both times with the PROD account stored, so a session that believed it was on Dev could not reach Dev at all and the only QRSETU project it COULD reach was production. SUPABASE_ACCESS_TOKEN overrides the stored login for ONE invocation, so each command carries its own identity and supabase login never needs running again. ⚠ THE PREFLIGHT IS THE POINT, not a nicety: it calls projects list FIRST and refuses unless the token can actually see the intended REF. A wrong token does not error, it shows a DIFFERENT project set, and every command downstream would then target whatever it can see. Keyed by REF, never by name: two projects have held the name qr-setu-prod (QRS-249 applied to environments, where a stale name points a live write at the wrong project). prod additionally requires --i-know-this-is-production, because that account also holds nefoxx-prod. Token from, in order: the environment · the Windows USER env (setx SUPABASE_ACCESS_TOKEN "sbp_...", read from the REGISTRY so an already-open shell does not report it missing) · .env.supabase-tokens (gitignored by the existing .env.* rule). Never printed, never in a command line, env-only, and masked if it appears in child output.

deploy:manual ​

Command: npm run deploy:manual <dev|uat|production>Group: root: gates Cannot see: whether the deploy SHOULD happen — it is a live hostname change under an owner authorisation with an EXPIRES date in the script.

⚠ CHANGES A LIVE HOSTNAME. apps/web/scripts/deploy-manual.mjs. Owner-authorised 2026-08-20 as a QUOTA MEASURE ONLY, and it REFUSES TO RUN after 2026-08-31 rather than trusting anyone to remember: an authorisation that outlives its reason is just an ungoverned deploy path. After the quota resets, push and let deploy-web.yml deploy. It mirrors that workflow exactly - same GitHub Environment variables (they are vars, not secrets, so it cannot drift from the pipeline), same gates in the same order, then the RNW /app export + mount, then a smoke test of the live hostname. ⚠ THE FAILURE IT PREVENTS: wrangler deploy REPLACES the Worker's vars, so a hand-typed deploy that omits --var SUPABASE_URL publishes fine and then throws on every card request. The required vars are asserted before deploying, and GA is asserted present on production alone. ⚠ Its smoke test RETRIES: the first version reported two 404s on a good deploy because Cloudflare's asset layer had not finished propagating. A false-failing check either sends someone debugging a non-problem or teaches them to ignore it.

deploy:reminders ​

Command: npm run deploy:remindersGroup: root: gates Cannot see: anything beyond the reminders migration pair and its function; the other project's state.

⚠ CHANGES A LIVE SUPABASE PROJECT (QRS-564). Pushes the reminders migrations + EF to a project ref and PROVES it landed: preflight (can the CLI even SEE the project — the 403 that made this script necessary) -> link -> DRY RUN that stops if the reminders pair is not pending -> push -> read-back. It never reads, prints or sets a token: supabase login --token is the owner's action and stays outside this script. It exists because a documented-but-not-executable procedure gets performed differently every time, and the differences are where the incidents live (QRS-267's orphan migration versions). ⚠ It went UNDOCUMENTED here until 2026-08-13, found by widening check:claims rule C3 from check:* to deploy:* — an undocumented deploy path is worse than an undocumented gate.

There is NO npm run sonar, and that is deliberate, not an omission (QRS-252). The SonarQube CE engine runs ONLY in ci.yml's sonar job: locally it crashed Docker Desktop on the 16 GB Windows box (embedded Elasticsearch + a 3 GB scanner heap + indexing a bind mount over node_modules), the same family of limit as QRS-245. Locally, the VS Code SonarLint extension is the substitute — and it is strictly BROADER than our ESLint layer (it reports rules eslint-plugin-sonarjs does not implement, e.g. S7781 — ⚠ S4036 is NOT such an example: eslint-plugin-sonarjs DOES implement it as sonarjs/no-os-command-from-path), so treat a disagreement between them as normal, not a bug.

The two lint speeds, because it matters which one you are looking at: npm run lint typed, whole tree, ~53s <- CI + the real gate eslint --config eslint.fast.mjs … untyped, ~65% faster on a small set <- what lint-staged runs eslint.fast.mjs exists because typed linting costs +65% on a 3-file staged set and pre-commit is meant to stay fast. Accepted consequence, stated so nobody debugs it later: a TYPE-AWARE finding (e.g. S6759 prefer-read-only-props) can pass pre-commit and fail CI. It cannot reach develop.

DO NOT re-enable reportUnusedDisableDirectives for the fast config (QRS-255). It defaults to warn in ESLint 9 and --fix DELETES directives it thinks are unused — and an untyped pass thinks every eslint-disable-next-line sonarjs/deprecation is unused, because it never runs that rule. It stripped two documented suppressions out of expoPort.ts during git commit, and CI then failed on the code they protected. The rule that generalises: only a pass that actually RUNS a rule may decide a suppression of that rule is dead. It stays ON in the typed config, which can judge.

Note the shape of that bug, because no local gate can catch its class: the damage happens DURING git commit, after every pre-commit check has already run, so a hook cannot observe its own side effect. If a commit's diff contains changes you did not make, suspect lint-staged --fix first.

android ​

Command: npm run android / iosGroup: mobile app (cd apps/mobile, or npm run -w @qrsetu/mobile <script>) Cannot see: a stale bundle on the device (prove it: verify-device-bundle skill); a second Metro on 8081.

expo run:android / run:ios — native dev builds

build:android ​

Command: npm run build:androidGroup: mobile app (cd apps/mobile, or npm run -w @qrsetu/mobile <script>) Cannot see: whether the APK points at the intended Supabase project unless you read the env line it prints; anything on iOS.

GUARDED release APK (arm64) — use this, not raw gradlew (OOM, QRS-012)

build:android:emulator ​

Command: npm run build:android:emulatorGroup: mobile app (cd apps/mobile, or npm run -w @qrsetu/mobile <script>) Cannot see: the arm64 device build (this is the x86_64 variant).

x86_64 variant for the emulator

web:export ​

Command: npm run web:exportGroup: mobile app (cd apps/mobile, or npm run -w @qrsetu/mobile <script>) Cannot see: whether dist/ is newer than src/ at serve time — the preview command checks that, this one does not.

generate web icons + expo export -p web → apps/mobile/dist/ npm test — jest-expo

type-check ​

Command: npm run type-checkGroup: mobile app (cd apps/mobile, or npm run -w @qrsetu/mobile <script>) Cannot see: a type that is any; runtime behaviour; anything in legacy/ (not a workspace).

tsc --noEmit

── E2E (web surface only — RNW export). ROOT-ONLY scripts: npm run e2e from apps/mobile fails with Missing script: "e2e", which is easy to misread as a broken config.

e2e ​

Command: npm run e2eGroup: mobile app (cd apps/mobile, or npm run -w @qrsetu/mobile <script>) Cannot see: anything native; the hydrated screen (layout-invariants asserts BEFORE hydration); a run killed by memory that printed only ok lines (read the exit code and the count).

full matrix, 712 tests — CI's job; see the memory note below

e2e:quick ​

Command: npm run e2e:quickGroup: mobile app (cd apps/mobile, or npm run -w @qrsetu/mobile <script>) Cannot see: anything beyond "the phone-small shell has no overflow" — never read it as "the screen is correct".

PRE-PUSH gate: layout-invariants @ phone-small, 99 tests / ~2.4 min

e2e:ui ​

Command: npm run e2e:ui / e2e:reportGroup: mobile app (cd apps/mobile, or npm run -w @qrsetu/mobile <script>) Cannot see: nothing more than e2e — it is the same suite in UI mode.

Playwright UI mode / last HTML report

dev ​

Command: npm run devGroup: web app (cd apps/web, or npm run -w @qrsetu/web <script>) — Stack 1, React Router v8 SSR Cannot see: the Worker runtime — react-router dev runs on Node, and workerd has no Node stream APIs.

react-router dev (Vite dev server, SSR)

build ​

Command: npm run buildGroup: web app (cd apps/web, or npm run -w @qrsetu/web <script>) — Stack 1, React Router v8 SSR Cannot see: the runtime target: a green build is not evidence it targets workerd; boot the server or run the driver.

react-router build → apps/web/build/ (gitignored)

start ​

Command: npm run startGroup: web app (cd apps/web, or npm run -w @qrsetu/web <script>) — Stack 1, React Router v8 SSR Cannot see: a missing Worker var — wrangler dev starts fine and the card route throws on request.

wrangler dev — ⚠ NOT react-router-serve since the 2026-08-20 Workers conversion; it cannot execute a Worker module. npm test — vitest run — no jsdom; renderToStaticMarkup smoke tests

type-check ​

Command: npm run type-checkGroup: web app (cd apps/web, or npm run -w @qrsetu/web <script>) — Stack 1, React Router v8 SSR Cannot see: a type that is any; runtime behaviour; anything in legacy/ (not a workspace).

react-router typegen && tsc --noEmit Local dev needs SUPABASE_URL + SUPABASE_PUBLISHABLE_KEY in .env.local (see .env.example) — getSetuCardServerEnv() throws immediately if either is unset, no production-fallback default.

apps/web's OWN Playwright suite (M14) — layout-invariants + axe-core + visual regression against a static fixture (no live Supabase route reachable in this environment; see apps/web/e2e/README.md). Separate from the root e2e/e2e:quick scripts, which point at apps/mobile's config and never touch this app.

build ​

Command: npm run build && npm run e2e:fixture && npm run e2eGroup: web app (cd apps/web, or npm run -w @qrsetu/web <script>) — Stack 1, React Router v8 SSR Cannot see: the runtime target: a green build is not evidence it targets workerd; boot the server or run the driver.

rebuild CSS → regenerate fixture → test

⚠ NEITHER npm test NOR e2e/ EVER BOOTS THE SERVER, and that blind spot hid two live bugs for days (QRS-568/569). vitest renders components directly; e2e/ tests a renderToStaticMarkup STATIC FIXTURE by design (its README: "there is no local Supabase stack reachable"). So the CARD ROUTE — loader, headers, template resolver, SSR pipeline — was covered by nothing.

⚠ AND THE CARD IS NOT AT /:slug — measured 2026-08-24, and this file said /:slug in four places. apps/web/src/app/routes.ts declares route(':slug/setu-card', ...) for the CARD, and route(':slug', 'routes/setu-card-redirect.tsx') is a 301-only module with no default export whose loader always throw redirect(/${slug}/setu-card, 301). So /:slug is a permanent redirect, not the card. ⚠ Consequence worth knowing before debugging either: a nav /<slug> in the run-web driver lands on the card only AFTER following a 301, so headers may report the hop rather than the card. Navigate to /<slug>/setu-card directly when you are measuring cache headers or the Cache-Tag.

.claude/skills/run-web/driver.mjs closes it: starts a local mock answering the two public RPCs, boots the REAL built server against it, drives it with Playwright over stdin commands (nav, headers, blocks, screenshots, viewport/theme, 404 + unknown-template branches). Nothing in the app is stubbed; only the Postgres endpoint is substituted. cd apps/web && npm run build && printf 'nav /ganesh-idols-pune\nheaders\nss card\nquit\n' \| node .claude/skills/run-web/driver.mjs

⚠ TWO apps/web LANDMINES, both load-bearing, both found only by booting the server:

  1. vite.config.ts's ssr.noExternal: ['react-router'] is NOT boilerplate — without it EVERY SSR route 500s (Cannot read properties of null (reading 'useContext')). apps/mobile pins react to EXACTLY 19.2.3 (Expo SDK 57 constraint) while apps/web asks ^19.2.7, so npm hoists 19.2.3 to the root and nests 19.2.8 here; react-router hoists to the root and binds the ROOT react while react-dom/server renders with the NESTED one — two hook dispatchers. resolve.dedupe DOES NOT FIX IT (measured): dedupe governs only what Vite BUNDLES, and an externalized dep is resolved by Node at runtime. The root cause (two React copies) is untouched and is a dependency-policy call.
  2. A route's loader headers DO NOT reach a document response without a headers export. React Router applies data(..., { headers }) to the .data response itself, but a document response is assembled from every matched route so it will not guess whose headers win. Cost while broken: the public card served with no Cache-Control AND no Cache-Tag — edge caching and the entire ADR-0027 purge-by-tag design, both silently inert.

test:ef ​

Command: npm run test:efGroup: backend Cannot see: an Edge Function that is not deployed (404 on Dev); the live database; _archive_pre_v2/ (excluded).

deno test --allow-env --allow-net (from supabase/functions)

test:db ​

Command: npm run test:dbGroup: backend Cannot see: anything without a running local stack; a table with no test; a citext that passes locally and fails on Dev.

supabase test db (pgTAP; needs supabase db start)

functions:deploy ​

Command: npm run functions:deploy -- --project-ref <ref> [--only fn-a,fn-b] [--dry-run]Group: backend Cannot see: whether the project ref is the one you meant — the CLI login is volatile; run projects list first.

docs:gen ​

Command: npm run docs:gen / docs:dev / docs:buildGroup: docs & maintenance Cannot see: whether the EF "Type" it infers is right — verify against source.

portal (documentation/portal/, its own package.json)

clean:dev ​

Command: npm run clean:dev[:apply|:deep]Group: docs & maintenance Cannot see: whether a regenerable artifact is warm and about to be needed — it follows the published retention schedule.

regenerable-artifact retention sweep

setup:disk-automation ​

Command: npm run setup:disk-automationGroup: docs & maintenance Cannot see: whether the scheduled task actually ran — check:disk asserts the sweep is installed.

install the daily scheduled sweep (per-user, no admin)

check:docs — the portal's own freshness gate (operating-manual text) ​

Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)

This is the verbatim text of CLAUDE.md § "The portal's own freshness is gated — check:docs" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.

The portal's own freshness is gated — check:docs [ENFORCED, 2026-08-08, QRS-416] ​

A documentation audit on 2026-08-08 found the portal describing an architecture this repo has not had since ADR-0011/0012, and found it by READING — no gate could see any of it. The worst cases: architecture/tiers.md documented the retired Vite SPA's src/tiers/ + ProtectedRoute.jsx with no warning of any kind; overview/platform.md opened "QRSETU is a React SPA" and listed BioLink as a live pillar; overview/glossary.md defined "Pillar" as one of three products; overview/target-end-users.md mapped every segment onto "live capabilities" drawn from BioLink/Studio/Digital Menu and contradicted overview/industry-scope.md about the flagship vertical; and design-system/pdpr-prompt.md — a prompt sent to Claude Design — described BioLink as something to design for, which is how a retired product gets re-designed into existence.

npm run check:docs (tools/check-docs-vocabulary.js) scans the portal for 13 retired-vocabulary groups (a live run reports 192 portal pages + 2 root docs, 162 baselined mentions, 0 new — ⚠ this said 124/123 until 2026-08-28; the gate prints all four numbers, so read them off a run rather than off this paragraph). Three design points, each load-bearing:

⚠ IT DID NOT SCAN CLAUDE.md OR README.md UNTIL 2026-08-24 (QRS-567, now CLOSED), AND BOTH HAD DRIFTED [measured 2026-08-12]. It scans both now — ROOT_DOCS = ['CLAUDE.md', 'README.md']. At the time, the gate's roots were documentation/portal/ only. On 2026-08-12 this file's own opening section still listed BioLink — which is retired term group #1 in that very script — as one of three live products, and README.md did the same. The two most-read documents in the repo were the two nobody gated, for eleven days after the tables were dropped. Both are fixed above; extending the gate's roots to cover them is the actual remedy and has not been done (it needs its own baseline entries, so it is a change with a decision in it, not a one-line edit) — QRS-567.

⚠ AND IT DOES NOT SCAN SOURCE CODE, which produced the same failure in the OPPOSITE DIRECTION [2026-08-12, QRS-573]. _shared/cardCache.ts's header comment named public_page_ops_cache_operations as its audit trail four days after the table was dropped. Writing backend/shared-kit.md I copied that sentence out of the code, and only then did check:docs reject it — which is how the stale code comment was discovered at all, and it turned out to be a live defect, not a comment problem (the function really was inserting into a dropped table on every card write). So the gate caught a real bug by accident, at its own boundary, one hop after the point where it mattered. Both halves of QRS-567's lesson now have evidence: a gate's blind spot is invisible from inside the gate, and a vocabulary gate over supabase/** would have caught this at the source instead of downstream in prose.

  • Historical records are exempt, and that is not a loophole. architecture/adr/**, dev-tracker/**, releases/** and design-system/screen-reviews/** are logs: they legitimately say "we decided BioLink". An ADR is superseded by a BANNER, never by an edit — rewriting one destroys the record of why the decision was taken.
  • Banner text is exempt structurally — lines inside a ::: container or a > blockquote are skipped, because naming a retired term in order to warn about it is the correct thing to do. Without this the fix and the gate would fight, which is exactly how a gate becomes a permanent ignore.
  • Ratchet, with a reason per file. documentation/portal/.docs-vocabulary-baseline.json carries a measured ceiling per file (125 mentions across 30 files at introduction) and every allowance states why. A count may only go DOWN; the gate also fails on a STALE ALLOWANCE (a ceiling higher than the file now needs), because a ratchet that never tightens is not a ratchet. Mutation-tested in all three directions per QRS-013.

⚠ It scans the two ROOT DOCS as well since 2026-08-24 (QRS-567) — CLAUDE.md and README.md, which were the two nobody gated and both of which had drifted. And the "mutation-tested in all three directions" claim just above was false until the same date; it is now true at 19 cases.

Wired into pre-commit (~100ms) and ci.yml — and unlike check:design it is not PR-only, because it reads absolute file contents rather than a diff, and a direct push to develop is exactly how stale docs historically landed.

Mermaid diagrams are not covered by any gate, and one had never rendered. npm run docs:build fails only on a malformed fence — vitepress-plugin-mermaid renders client-side, so invalid diagram syntax ships silently. Parsing all 66 portal diagrams with the real Mermaid parser (jsdom is already in the repo root for jest-expo; without it every flowchart fails on DOMPurify.addHook, which is an environment error and must not be read as a syntax error) found architecture/data-access.md's read-path diagram broken by a ( inside a pipe label — so the portal's self-described "single most important rule" page showed an error box where the diagram belonged. Parse diagrams after editing them; do not trust docs:build to catch it.