Skip to content

Coding rules

Generic engineering principles live in .claude/rules/principles.md; project invariants in CLAUDE.md. This page collects narrower conventions as they are formalised.

Documentation accuracy

Docs are treated as code: every concrete claim traces to a source, and a behaviour change updates its docs in the same pull request. Four rules:

  1. Ground every concrete claim in a source. Commands, paths, endpoints, env vars, field names, UI locations, and numbers are copied from verified code/config, never recalled. An untraceable claim does not ship.
  2. Accuracy is separate from rendering. A rendered page proves it displays, not that it is true. Confirm each checkable claim against HEAD as its own pass; a screenshot is not verification.
  3. Never describe a link target you have not opened. A page is "the reference for X" only if it actually contains X. Otherwise link the real source (code, deploy/README.md, .env.example) or mark it in-progress — never present a stub as complete.
  4. Behaviour change ⇒ doc change, same pull request. Moving a control, renaming a command, changing an endpoint / default / env var, or adding or removing a feature updates the affected page and any diagram with it (quality gate 3).

Broken and missing links fail mkdocs build --strict in CI; the rest is a review gate.

Web query parameters must match OpenAPI

no-web-api-query-drift.ts compares every query-bearing HTTP API call under apps/web/src/** with the matching method and path in the mounted OpenAPI document. JSON requests directly import apiFetch and supply a static path, an explicit literal method, and an inline query object whose property names are statically enumerable. Streamed and downloaded responses build their URL through apiDownloadUrl(path, query), which preserves the shared serializer's repeated-array and omitted-undefined semantics. There is no key allow-list: declare a sent key on the route's Zod query schema or stop sending it.

Raw query strings, helper aliases or namespaces, opaque or spread query objects, dynamic methods or paths, URLSearchParams, direct query-bearing API fetch, and alternate API navigation or download sinks fail closed because they bypass that finite form. WebSocket handshake parameters and SPA navigation search state remain outside the gate because neither uses the HTTP OpenAPI contract. The self-test drives canonical, undeclared, unsupported, narrowed, and vacuous trees before the live scan runs from lint.sh.

Config tables

Every operator-visible config table under docs/_generated/config/ is generated by bun run docs:gen from the live zod schema, and bun run docs:gen --check fails CI when a committed partial no longer matches. Four of the six columns — setting, what it is, accepted values, default — come straight from the schema, so they cannot drift.

The two guidance columns (when to change it, what to expect) are judgement the schema cannot express. They live in scripts/docs/config-notes/<schema>.ts, keyed by the field path the generator emits.

The generator refuses to run when those keys and the schema's leaves disagree. Adding a config field without a note fails; leaving a note behind for a field you removed also fails. So:

  • Adding a setting ⇒ add its note in the same pull request.
  • Renaming or moving a setting ⇒ move its note key with it.
  • Adding a new strategy ⇒ add scripts/docs/config-notes/<name>.ts and register it in STRATEGY_NOTES.

Write the guidance for an operator, not a reviewer: "when" names the situation that should make someone reach for the setting, "expect" names the observable consequence including the downside. A note that only restates the description is worse than none — it passes the gate while teaching nothing.

Environment variables

The env-var reference is generated the same way, from ENV_CATALOGUE in @app/core/env. That module owns the prose; the zod schemas in packages/core/src/env/schema.ts, apps/api/src/env.ts and apps/worker/src/env.ts own the defaults.

Five checks tie them together:

  • bun run docs:gen --check fails when a key declared in .env.example has no catalogue entry, when an entry is missing its description, values, when or expect, when a parsed entry carries a defNote without a defParsed, or when any entry carries a defParsed that nothing reads. An entry whose shippedDefault records an intentional .env.example value must equal the committed value, and at least one such claim must exist. An entry no zod schema parses (parsed: false) — compose- and build-only keys, plus the few application settings read straight off process.env — carries a defNote alone and passes.
  • bun run env-contract --check rebuilds env-contract.json in memory and fails when the committed copy at the repo root differs; it writes nothing. The flagless bun run env-contract is the form that rewrites the file. That file is the published env surface: the helm chart at chrisleekr/helm-charts is synced by version only, so a new variable never reaches its ConfigMap or Secret and nothing fails — an unmirrored variable simply does not render and helm template still succeeds. The file is published so the chart's parity check can fetch it at the release tag and diff the names. The same command also refuses to publish a credential-shaped name classified kind: 'config', and fails if the catalogue yields fewer entries than ENV_CONTRACT_FLOOR. A name is credential-shaped, after upper-casing it and folding - to _, when a whole _-delimited segment is one of CREDENTIAL_SEGMENTS (which includes the connection-string words URL, URI, DSN, CONN, CONNECTION, so DATABASE_URL_REPLICA matches as surely as DATABASE_URL), or when it contains one of CREDENTIAL_SUBSTRINGS anywhere — the motivating case being a spelling with no _ boundary for the segment pass to see, PGPASSWORD. Both lists live in packages/core/src/env/contract.ts; read them there rather than trusting a list copied into prose. NON_SECRET_NAME_ALLOWLIST is the escape hatch and is checked first: an entry there maps the name to the reason its value carries no credential, whatever the name looks like. A build-time variable (consumers: ['build']) is held to a stricter rule, because Vite stamps the value into the web bundle and it reaches every browser whichever chart surface holds it: classifying it secret is rejected, and so is a credential-shaped name regardless of the allowlist, since a waiver there would waive the warning and not the exposure. Only the connection-string words stay waivable for a build variable, because a browser bundle legitimately carries the api origin.
  • env-docs.test.ts in apps/api and apps/worker parses an empty environment through the real loader and asserts every documented default is the default the process actually applies. Changing .default(9100) to .default(9200) fails there. A variable the process documents as required is asserted too: the test drops it from an otherwise-valid environment and requires the parse to fail.
  • no-phantom-env-var.sh checks both directions. Every key in .env.example needs an application reader or an infra-only entry. Every direct, bracket, aliased NodeJS.ProcessEnv, or cast import.meta.env read in application and package TypeScript needs an ENV_CATALOGUE entry or an exact test/tool-only exclusion. Both walks have vacuity floors.
  • no-phantom-env-var.selftest.sh proves undocumented reads, near-miss exclusions, and a vacuous source walk fail while the reviewed exact exclusions pass.

So adding a variable always means adding its catalogue entry, including its kind, then rerunning bun run env-contract in the same pull request. The other two surfaces are decided by who supplies and who reads the value, not by parsed:

  • .env.example whenever an operator or Docker Compose has to supply the value. That includes compose- and build-only keys such as POSTGRES_DB, APP_HTTP_PORT, IMAGE_TAG, WORKER_REPLICAS and VITE_API_BASE_URL, all of which carry parsed: false. Compose interpolates from .env and silently substitutes an empty string for a key it cannot find. The reverse source-read check catches application omissions; compose-only additions still require review because compose interpolation is not TypeScript.
  • A zod schema field only when application code parses the value. A variable read straight off process.env (the *_DB_POOL_MAX pool sizes) has no schema and no .env.example entry, and carries parsed: false.

Where a documented default legitimately differs from the schema's (a value .env.example ships, or one resolved at boot like STUDY_CPU_SHARE), set defNote to say why and defParsed to the exact value an empty environment parses to, or null when the schema yields no value at all. defNote redirects the assertion from def to defParsed; it never removes it. A parsed variable carrying a defNote without a defParsed fails docs:gen --check, and so does any entry carrying a defParsed nothing reads: one with no defNote to redirect the assertion onto it, or one on a variable no app parses. Compose- and build-only variables (parsed: false) have no schema to pin, so they take a defNote alone.

Screenshots

Every screenshot on this site is generated, never taken by hand:

bun run docs:screenshots

That one command creates a dedicated database (the dev one suffixed _docs) and Redis logical db, migrates and seeds them, boots the api and web dev server on their own ports, drives a headless browser over every documented screen, and writes the PNGs straight into docs/assets/screenshots/. Review the diff and commit it. No Binance credentials are involved, because the seeder writes the market data the UI would otherwise read from the exchange.

Prerequisites: Postgres and Redis up, a .env with AUTH_SECRET filled in (bun run setup), node on PATH, and Playwright's Chromium installed once with cd e2e && bunx playwright install chromium.

It never touches the database bun run dev uses. That isolation is the point: the seeder writes wallet balances and prices to the same Redis keys a running worker reads for sizing and exit decisions, so pointing it at a live account would have the worker act on holdings that do not exist. bun run seed:dev therefore refuses outright if the target database holds a live-mode account — only the docs pipeline, against its own disposable database, is allowed one.

What seed:dev does

On an empty database it bootstraps: it creates the operator through Better Auth (so the stored password hash is the one real sign-up would write), adds a placeholder Binance key row so the dashboard stops prompting for one, and creates one enabled profile per registered strategy. Defaults are docs@example.com / docs-screenshot-pw-1234, overridable with SEED_OPERATOR_EMAIL and SEED_OPERATOR_PASSWORD.

On a database that already has an operator it adopts it: existing accounts and profiles are reused and no profile is created, because adding an enabled, symbol-bound profile to someone's account is not adoption — the worker would pick it up and start trading coins nobody chose.

Either way it then seeds orders, positions and the Redis market data. Values are derived from a hash of each symbol, so coins differ from one another while a re-run reproduces the same account. A re-run replaces each profile's orders and positions wholesale.

Closed-trade archive rows are the exception: they are written, and wiped, only on the docs stack. trade_archive is the realised-P/L ledger and the only record of a completed cycle — the fee roll-up and decomposition are computed at archive time and cannot be re-derived from Binance later — and nothing distinguishes a seeded row from a real one afterwards. So the seeder never touches archive history on a dev database, and a dev database gets no realised-P/L data.

Other knobs: SEED_SYMBOLS_PER_PROFILE (default 4) and SEED_NOW_MS (the instant every seeded timestamp is measured back from — the capture freezes the browser clock to the same value, so ages render as a plausible spread instead of all "0s ago").

Two files own the set:

  • e2e/docs-screenshots.manifest.mjs — which screens exist, the route each one lives at, and the file(s) it writes. A screen documented on two pages is one capture with two destinations.
  • e2e/docs-screenshots.mjs — the capture itself: sizing, the frozen clock, and the handful of screens reached by an interaction rather than a URL.

Adding a screenshot means adding a manifest entry and embedding it from a page. scripts/ci/no-stale-screenshot.sh (part of bun run lint) fails if those two lists and the committed PNGs disagree — a page embedding a file nobody captures, a manifest entry nobody committed, or a committed PNG no page uses.

CI does not re-render the screenshots to compare them. PNG bytes are not reproducible across machines (Chromium build, font stack, rasteriser), so a regenerate-and-diff gate would be permanently red. Keeping the images current is a refresh run when the UI they show changes, under rule 4 above.

Money precision in the web UI

.toFixed(n) and toLocaleString with maximumFractionDigits: 2 both impose fixed precision. That is correct for values such as percentages, byte sizes, durations, ratios, and CSS widths, but wrong for a sub-cent quote amount: a BTC-denominated value of 0.0045 becomes 0.00, so real value is reported as nothing. Every direct occurrence under apps/web/src/** is registered with its pattern identity and a factual reason naming the value kind.

no-unreviewed-tofixed.sh (run from lint.sh, self-test first) compares the tree against scripts/ci/tofixed-inventory.json. The pin is relative file path -> { reason, sites }, where each site is pattern-id: normalized matched line; the identifiers are to-fixed and fixed-two. Sites are compared as sorted multisets, never by line number: an edit above a site or a reorder does not churn the pin. The gate refuses on an unregistered file, an added or edited site, a stale entry, a site without pattern identity, a missing or invalid inventory, a blank reason, or a walk that found zero source files. Its fixtures independently prove the all-web scan and multiline option-object match, so narrowing either axis makes the self-test fail.

Two remedies when it fails, and only two: move the value onto a shared formatter in apps/web/src/shared/lib/format.ts (formatMoneyAmount, formatBalanceMoney, formatSignedAmount, formatPercent) if it is money, or register the site with a reason naming the non-money kind if it is not.

The gate covers direct .toFixed( calls and direct fixed-two maximumFractionDigits properties in TypeScript and TSX across the whole web source tree, including properties inside multiline option objects. It does not infer precision hidden behind an alias, variable, or helper call; those abstractions are reviewed at their defining site.

Full-scale decimal fields must reach the DOM through a formatter

Rounding too hard is one half of the precision problem; the other half is not narrowing at all. A money value crosses the wire as a plain decimal string carrying its full stored scale, so 0.000307064092664099 is eighteen significant figures, and a field interpolated straight into JSX paints all eighteen. In a 375px column that wraps, shoves its neighbours out of alignment, and reads as a corrupted number rather than a small one. The exponential half of this defect was closed structurally by the wire encoder (see "Money on the wire" below), but nothing stopped the precision half.

apps/web/__tests__/decimal-formatting-gate.test.ts closes it statically. It parses every apps/web/src/**/*.tsx with the TypeScript compiler API and fails listing each path:line:column where a decimal-typed field is painted into a JSX interpolation slot with no formatter between the field and the DOM. It is a vitest test rather than a shell gate for the same reason the loading-placeholder gate is: a shell version would pass vacuously under BusyBox grep.

  • The field set is derived from the contracts schemas, never listed. The test walks every exported zod root of @app/contracts, follows the links that preserve the wrapped value (.nullable(), .optional(), .default(), effects and pipes, union and intersection arms, z.lazy thunks), and collects the field names whose schema answers to isDecimalStringSchema. A hand-maintained list of "known decimal fields" fails open on every field nobody remembered to add and reads as assurance while doing so; deriving it means a new decimal field in @app/contracts is covered the moment its schema lands. That scope is the honest one and it is not the whole wire: those roots are the only thing walked, so a decimal spelled solely in a strategy package's own payload type is invisible here. apps/web is permitted to import strategy packages for typed event payloads and does, so a field arriving that way needs its formatter checked by review rather than by this gate. Container links are followed when enumerating nested object shapes, because an array of row objects holds decimal fields, but deliberately not when deciding whether the field itself is a decimal: fees: z.record(z.string(), DecimalString) renders as an object, which is a different defect with a different fix.
  • isDecimalStringSchema is backed by a WeakSet, not by .meta(). packages/contracts/src/decimal.ts registers its shared constants and every schema the decimalString(...) factory mints, because the factory returns a fresh object per call that identity comparison alone cannot see. Zod metadata was rejected as the carrier: .meta() is merged into z.toJSONSchema output, so tagging the schemas that way would change the JSON Schema the config-schema and form-builder routes emit. The WeakSet is invisible to parsing, serialisation and the wire.
  • Registration is itself gated, fail-closed. Registering is a step a human takes, so an unregistered new constant would narrow the derived field set while every other assertion here stayed green — the same fail-open, one level down, that deriving the field names exists to eliminate. The test imports decimal.ts by deep path rather than through the package index, so it sees every export of that one module including ones added after it was written, classifies each export behaviourally (a schema that accepts a decimal string and refuses a non-decimal one; exported functions are probed as factories too), and fails if any decimal-producing export is unregistered.
  • A formatter is anything that is not a leaf read. Only a bare identifier and a property read count as offenders, with ?. and ! transparent; a call terminates the search, because a call is where a formatter lives. The scan recurses through the guarded spellings these reads are usually written as — ??, ||, &&, a ternary, string +, template substitutions — since {row.bnbReceived ?? '—'} paints the raw field exactly as {row.bnbReceived} does. A value handed to a component as a prop is not flagged: the callee owns that formatting decision and its own render body is scanned on its own turn.
  • Vacuity guards at every stage. The derivation must yield at least 200 contract roots and 60 field names, and spot-checks assert that a bare field, a .nullable() field and a factory-minted field are all present while a non-decimal field on the same object is absent, so no unwrap link can be removed silently. The TSX walk must collect at least 150 files, find at least 500 interpolation slots, and reach a NAMED anchor file (apps/web/src/features/account/routes/account.dust-transfer.tsx) — the count floors alone catch a walk that broke outright but never one that merely narrowed, and dropping a whole feature directory still leaves well over 150 files coming back from the rest of the tree. A file that fails to parse throws instead of being scanned as offender-free, and a permanent fixture pair proves the classifier still flags a bare interpolation and still clears a formatted one, so neutering it to make the suite green fails there first and names the classifier rather than the tree.

This gate complements no-unreviewed-tofixed. Both cover all of apps/web/src/**, but they answer different questions: the inventory gate asks whether an explicit rounding policy was reviewed, while this semantic TSX gate asks whether a full-scale decimal reaches the DOM without any formatter.

Dependency declaration

A workspace package's dependencies must not re-declare a third-party package it already receives transitively through another @app/* workspace dependency.

The test: if a third-party entry can be removed from a package's dependencies and it still resolves (because a workspace dependency pulls it in) and none of the package's own code — src/, scripts/, and every other entry point — has a direct import … from '<dep>' (import type counts), the entry is redundant; drop it.

Rationale: a re-declared transitive dependency is a second place a version can drift. For example, were Drizzle consumed only through @app/db, drizzle-orm would belong in packages/db alone — but apps/api imports it directly from scripts/reset-password.ts, so it stays declared there.

This rule covers runtime dependencies only. Build-time tooling in devDependencies — bundler plugins, the peers they require to build, and test libraries — is declared wherever the build needs it, regardless of whether the package's own code imports it directly.

Test sources need their own tsc project

tsc -b builds src/ only — every package's tsconfig.json scopes include to it — so nothing under __tests__/ is type-checked by bun run typecheck unless the package wires a second project for it. An unchecked test surface is not a cosmetic gap: a fixture can omit a required field, satisfy Partial<T> at runtime, read undefined where the code branches on null, throw inside a try/catch and pass for the wrong reason while asserting nothing. It is worse where suites gate on HAS_INFRA and skip wholesale without Docker, because a broken test there never even runs to reveal itself.

A package that wants its tests checked adds tsconfig.test.json extending its own config with noEmit, composite: false, declaration / declarationMap / isolatedDeclarations / incremental all off, and include covering src/**/*.ts and __tests__/**/*.ts; its typecheck script then appends tsc -p tsconfig.test.json --noEmit. apps/worker, packages/binance, packages/db, apps/api and packages/contracts carry one. Ordinary tests and type-level tests get separate compilers: where a package also has tsconfig.test-d.json it excludes **/*.test-d.ts here and lets that project own them (below). A package without one carves nothing out, so a .test-d.ts added there is checked by the ordinary project rather than by nothing.

apps/api has one deliberate deviation from that template: its include also covers scripts/**/*.ts, because scripts/reset-password.ts is a real CLI entrypoint that the production src/ project does not compile. The shared Vitest config publishes a declaration beside its JavaScript entrypoint, so consumers keep normal TypeScript module checking without widening their source root or admitting JavaScript into their test projects.

Once the test surface is part of a tsc program its imports have to resolve like any other. vitest and @app/strategy-trailing-trade were imported by apps/api tests while declared nowhere in apps/api/package.json; both are now devDependencies there, which is what the rule above already asks for — test libraries are declared wherever the build needs them.

Type-level guards

Type-level assertions live in <pkg>/__tests__/*.test-d.ts — their @ts-expect-error lines assert that a wrong-shape call fails to compile. Such a file protects nothing unless a tsc pass actually compiles it, and the default per-package tsconfig.json excludes __tests__ (or scopes include to src/), so an unwired guard is silently dead.

Each package holding a guard file needs a tsconfig.test-d.json extending the package config with include covering __tests__/**/*.test-d.ts (and noEmit, non-composite, so it only typechecks), and its typecheck script must append tsc -p tsconfig.test-d.json --noEmit. The no-unwired-test-d CI gate fails the build if any .test-d.ts is missing either wiring.

Loading branches must reserve height

apps/web/__tests__/loading-placeholder-gate.test.ts parses every apps/web/src/**/*.tsx with the TypeScript compiler API and fails listing each path:line:column that renders a bare-text loading state instead of a placeholder from @/shared/components/page-skeleton. It is a vitest test, not a shell gate: CI runs on bun:alpine, whose BusyBox grep silently no-ops GNU flags, so a shell version of this check would pass vacuously.

Two orthogonal detectors run and their results are unioned, deduped by source position. A is condition-side — a ?: / if / && whose condition is a loading test (.isLoading / .isPending / .isFetching, an identifier matching /^(is)?\w*[Ll]oading$/, or a || of those) and whose branch renders text with no placeholder tag under it (/(Skeleton|LoadingRows)$/, so LoadingRows clears it too). Negations and compound conditions are deliberately not loading tests. B is content-side — any element carrying loading-shaped copy (/^Loading\b/i, an ellipsis-terminated "loading"/"fetching" string, or a t('….loading') key) with no skeleton descendant, whatever guards it. B is what catches a placeholder behind a data test (data ? … : <p>Loading…</p>), which A structurally cannot see. A third rule flags a Skeleton/*Skeleton element whose static className carries no height token (h-, min-h-, aspect-, size-) — the right tag name is not the invariant, the reserved box is.

Exemptions are structural, never a path allow-list (an allow-list fails open the moment a file moves): a control tag as the nearest enclosing element, an sr-only element, and a value that cannot render. A render prop is not exempt — aside={<Panel/>} and pendingComponent={() => <p/>} are painted as page content.

Vacuity guards, because a gate that scans nothing is worse than no gate: the source root must exist, the walk must collect ≥150 files and find ≥30 loading branches (the denominator, not the offender count), parseDiagnostics is read off each SourceFile and throws rather than letting a truncated tree report clean, and each detector has a fixture only it can reach so neither can be neutered without a test going red.

Deadline-bounded writes take a thunk

A best-effort write whose reply you do not read — one you want attempted, not confirmed — goes through raceDeadline (apps/worker/src/lib/race-deadline.ts), which bounds it by a deadline and routes a stall, a rejection and a synchronous throw to a callback instead of propagating. Pass the write as a thunk (() => redis.set(...)), never as an already-created promise.

A bounded call whose reply is load-bearing wants the opposite shape and rejects instead, so the caller's error path gets a verdict it can act on; rejectOnDeadline in apps/worker/src/tick/override-settlement.ts is that counterpart. A bounded read that treats a fault and a stall identically has no use for the distinction either helper draws, and races inline.

The reason is where the argument is evaluated. A promise argument is built by the caller, so a client that throws before it can return one — a closed connection, an argument the command builder refuses — throws outside the helper. On the tick path that unwinds a tick which may have already placed or cancelled orders, and at a fire-and-forget call site it becomes an unhandled rejection. Passing a thunk moves the payload build and the call itself inside the guard, so the helper's "resolves, never rejects" contract holds by construction rather than by every caller remembering a .catch. The thunk must return the call: async () => { dep(); } compiles but resolves immediately, which makes the deadline vacuous.

apps/worker/__tests__/lib/race-deadline.test-d.ts is the compiler guard that keeps the signature honest — it asserts a bare promise argument fails to compile.

Error handling and logging

Log the raw caught binding under the err key: logger.error({ err }, '...'). pino's err serializer emits the error's type, message, and stack, and narrows non-Error throws safely. Passing a pre-stringified value under err (a .message, an instanceof-Error ternary, String(err), or a helper call like errorMessage(err)) fires before the serializer and strips the stack, so a log that should carry a trace carries only a line of text.

errorMessage(x) from @app/core/error is for operator-facing message strings only — return values, reason: fields, notification bodies, DB receipts — never under the err log key. Two CI gates hold the line: no-stripped-err-log fails on a stringified value under err, and no-error-cast fails on an (err as Error) cast.

The no-error-cast matcher

no-error-cast.sh walks apps/ and packages/ for .ts and .tsx with bun's fs rather than a recursive grep, because CI runs on bun:alpine whose BusyBox grep silently no-ops GNU flags. It began as a copy of the sibling no-decimal-tostring-cast gate below, inherited its comment stripper and both of that stripper's defects, and has since gone one step further than it on vacuity, so five properties are worth knowing before you edit either:

  • It blanks comments in place, and only comments that START a line. Comments have to survive the strip rather than be reported as violations, because this rule is stated verbatim as prose inside packages/core/src/error/error-message.ts. Blanking rather than deleting keeps the line numbers truthful — dropping the newlines inside a block comment shifts every later line, and a violation sitting directly under a JSDoc block was then reported above the code that is actually wrong. Anchoring the /* to line start is what keeps it from blanking code: an unclosed /* inside a string literal (const u = "https://example.com/*") otherwise swallows everything up to the next */ anywhere in the file, taking any violation in between with it, and the gate exits 0. A glob like "src/**/*.ts" is harmless — it self-closes at /**/.
  • The anchor leaves a residual in each direction, and both are accepted rather than fixed. Fail-open: a multi-line template literal whose continuation line begins with /* genuinely is at line start, so its body is blanked as if it were a comment, and an unclosed opener there swallows real code below it. The known-gap-template-literal fixture pins that as current behaviour so it is observed rather than assumed; turning it into a rejecting case is how a fix would announce itself. It is pinned as a PAIR, because an accepting fixture on its own proves nothing — delete the hidden cast and the case stays green forever as a second copy of pass. known-gap-template-literal-control carries the identical cast with the template literal removed and is rejected on its exact file:line, so the two jointly say "this cast is a violation, and the template literal is what hides it" and the control goes red the moment the cast is edited out of either tree. Fail-closed: a block comment that does not start its own line and spans lines is no longer stripped at all, so prose in its body can be reported as a violation — noisy, never silent, which is why that is the safe direction to leave. Neither is closable with a regex; a sound fix needs a tokeniser that tracks string, template and comment state.
  • Matched over the whole file, not line by line. A prettier reflow can split (err as Error).message across three lines, which a per-line matcher never sees whole. The regex is global over the stripped text and the line is derived from the match index, reported on the line the cast starts on — where the value is read, not where .message landed. The operand stays a bare identifier, and the reason is worth stating exactly, because "it would only catch test files" is not it. Widening to member expressions catches three casts in the tree today, and one of them is shipping production code: apps/web/src/features/technicals/components/technicals-health-pill.tsx reads (q.error as Error).message behind an if (q.error), which with TanStack Query's Error | null typing yields undefined rather than a throw for a non-Error thrown by the query function — mildly guarded, not exempt. The other two are genuine tests (packages/core/__tests__/fan-out/fan-out.test.ts, apps/web/__tests__/use-preview-model.test.tsx). So the widening needs both a decision on whether test files are exempt AND a fix for that production read, not just a longer regex.
  • Vacuity stops are PER ROOT, which is where it diverges from the sibling. Each of apps/ and packages/ must contribute files and must contain every file that is always in scope — apps/api/src/index.ts and apps/web/src/main.tsx under apps/, packages/core/src/error/error-message.ts under packages/. The .tsx anchor pins the extension widening itself: with only .ts anchors, reverting the .endsWith(".tsx") clause (or losing apps/web) leaves both file floors and both anchors satisfied while the gate examines zero .tsx files, which is the same wrong-collection shape the per-root argument below is about. reject-missing-tsx-anchor drives it. A union walk with one shared floor and one shared anchor is fail-open in the direction that matters most: apps/ going dark (a rename, a re-layout, a skip-list entry that grew) still returns hundreds of files from packages/, still finds the single anchor, and prints a confident count having never examined apps/api, apps/worker or apps/web. The reject-half-walk fixture is exactly that tree. The sibling gate still uses the shared form and carries the same latent gap.
  • It has a self-test, and it earned one. no-error-cast.selftest.sh runs from lint.sh immediately before the gate and drives the real matcher over fixture trees under scripts/ci/__fixtures__/error-cast/, through a GUARD_ROOT override so what is proven is the shipping script and not a copy of it. Both stripper defects above are invisible from a green run — one reported the right violation at the wrong line, the other exited 0 over a live one — which is the whole argument: a gate whose stops have never been driven over a tree they must reject is not yet evidence of anything. Each rejecting case asserts its OWN diagnostic, and the violation case asserts the reported file:line, because the gate exits 1 for a violation, for an empty walk and for a narrowed walk alike; a bare non-zero check would read a moved fixture as a successful catch.

Money on the wire

Every price, quantity, amount, balance and P/L crosses the wire as a DecimalString — a branded string, never a JSON number. The brand carries a formatting promise as well as a type: the value is spelled in plain decimal notation. Decimal#toString() does not keep that promise. decimal.js switches to exponential outside its toExpNeg/toExpPos thresholds (-7 and 21), so a stored 0.00000036 commission leaves as 3.6e-7. Every consumer interpolates the field verbatim — the SPA prints it into a table cell, a notifier drops it into a message — and an exponent in a column of fixed-decimal numbers reads as a corrupted value, not as a small one.

Mint a DecimalString only through asDecimalString, decimalAdd, decimalSub or decimalMul from @app/contracts. All four format with toFixed(), which has no threshold and is exact at any magnitude. Anything else is a hand-written cast, and a cast is invisible to tsc — the brand type-checks identically whether or not the string honours it.

no-decimal-tostring-cast.sh, run from lint.sh, fails the build on x.toString() as DecimalString and on String(x) as DecimalString. It matches across whitespace, so a prettier reflow that moves as DecimalString onto its own line does not slip past. It walks apps/ and packages/ with bun's fs rather than a recursive grep, because CI runs on bun:alpine whose BusyBox grep silently no-ops GNU flags.

Three properties are worth knowing before you edit it:

  • It blanks comments in place, and only comments that START a line. Blanking rather than deleting keeps the line numbers truthful — dropping the newlines inside a block comment shifts every later line, and the first real violation the gate found sat directly under a JSDoc block. Anchoring to line start is what keeps it from blanking code: an unclosed /* inside a string literal (const u = 'https://example.com/*') otherwise swallows everything up to the next */ anywhere in the file, hiding any violation in between and exiting 0. A glob like 'src/**/*.ts' is harmless — it self-closes at /**/. The cost is that a trailing // comment mid-line is not stripped at all; that direction fails closed (a noisy false positive), and widening it would risk blanking a https:// URL.
  • Two vacuity stops, not one. A zero-file walk fails, and so does a walk that no longer reaches packages/contracts/src/decimal.ts. A count-only floor catches an empty walk but not a narrowed one: a skip-list entry that grew to match a real source directory would still scan hundreds of files and print a confident count over a subset.
  • It has a self-test, and it earned one. no-decimal-tostring-cast.selftest.sh drives the real matcher over fixture trees and asserts each rejection by its own diagnostic. Two of its stops were fail-open until they were driven that way — the unclosed-/* case above, and a String(...) argument match that could not cross a nested call — and neither was visible from reading the regex. A gate whose stops have never been driven over a tree they must reject is not yet evidence of anything.

The holes it does not close, stated plainly. It matches two spellings of the cast, not the act of branding: a helper that returns DecimalString from an unformatted string, an as unknown as DecimalString, or a .toString() assigned to a variable that is cast on a later line all ship. The String(...) pattern reads one level of call nesting, so String(new Decimal(x)) is caught but String(fmt(new Decimal(x))) is not. Widening the match to every as DecimalString is not an option — most of the casts in apps/api and packages/db re-brand a value a producer already formatted, and a gate that flagged those would be switched off within a week. What backstops the holes is the schema: DecimalString and PositiveDecimalString normalise on parse, yielding exactly what asDecimalString would, so any value crossing a zod boundary is re-spelled however it was built. The gate exists for the routes that hand a body straight to c.json without parsing it. discovery.ts is the one such route today, and it carries more than one unparsed money value: the exposure-gauge cap, plus the holdings' quantity and avgEntryPrice. The cap is the one that has actually been exponential — its percent arm multiplies, and its amount arm reads a string out of untyped profile-config JSONB whose own validator admits scientific notation, so neither arm is a passthrough and both format through asDecimalString. The holdings come straight off a Postgres numeric, whose text output is always plain, so their bare casts are re-brands rather than latent exponents — true today, and not a guarantee the type system carries.

Both branded schemas also bound the inbound value at MAX_WIRE_EXPONENT = 308 (packages/contracts/src/decimal.ts) — a base-10 exponent magnitude read off Decimal#e before anything formats the value. The two sides of that bound rest on different arguments, and only the upper one is about representability.

  • Above +308: the neighbourhood where a DecimalString stops surviving the JS double every consumer eventually narrows it through. This is a coarse stop, not a representability guarantee: the check reads only the exponent, so 9e308 and 1.8e308 both report e === 308, both are accepted, and both come back Infinity from Number() (Number.MAX_VALUE is 1.7976931348623157e308). That is deliberate — a mantissa-exact cut would buy nothing, since no exchange quotes a price, quantity or balance within thirty orders of magnitude of it, and the expansion argument below carries the bound on its own. Read it as "past this, it is no longer money".
  • Below -308: the mirror argument does not hold — subnormals run down to Number.MIN_VALUE (5e-324), so 1e-320 really does survive a double. This half is bounded purely by expansion cost: an accepted value is immediately spelled out by toFixed(), so a twelve-byte 1e-10000000 becomes a ten-million-character string on routes that carry no requireNotDemo. The bound is symmetric because 308 fraction digits is already far past any exchange's precision, not because 1e-309 is unrepresentable.

A value past the bound is a normal failed parse, which the request validator turns into a 400 rather than a throw. Two shapes need care and both are covered. A base-prefixed literal (0x…, 0b…, 0o…) is rejected by the decimal-format regex before the Decimal is constructed, because decimal.js converts a non-decimal base with a quadratic convertBase — 4,000 hex digits measured 8.9ms, 8,000 35.7ms, 16,000 149.5ms, so a one-megabyte body is minutes of blocked event loop from one anonymous request. And a value more extreme than the bound is laundered by decimal.js itself: anything under minE (-9e15) clamps to an exact zero whose e is 0, so 1e-9000000000000001 would sail through an exponent check that correctly stops 1e-10000000. A zero result is therefore only accepted when the input string's mantissa is genuinely zero. Do not replace any of this with a max-length cap: plain-decimal parsing is linear (10k digits 0.3ms, 1M digits 10.5ms) so length is not the hazard, and an accepted value's toFixed() output can be longer than its input (1e-308 is 6 characters in, 310 out). A cap sized on the input would therefore reject a body the route itself just produced, turning a 200 into a 500 on any route that re-parses its own response — account-health, status, exchange-info, dust-transfer and ops-notify do that today. Treat that as a property to check, not a count to trust; the last census of it in this document was wrong.

The decimalString(message, bounds) factory is deliberately not bounded that way. It produces a plain string for strategy config fields, never a wire brand, and it accepts sub-double magnitudes on purpose so an operator's 1e-330 knob is judged on its Decimal value rather than on what a double can hold.

Metric names

Every metric the worker emits is declared in apps/worker/src/metrics/catalog.ts: a member of the MetricName union, plus an entry in CATALOG giving its kind (counter, gauge, or histogram), its labelNames, and — for a histogram — its bucket bounds.

At every call site the rule is enforced by the type system rather than by a CI script. MetricsSink.record and MetricsSink.forget take name: MetricName, so an uncatalogued name is a compile error and shows up on bun run typecheck. Against a given sink there is no way around it: a non-null assertion, bracket access to the method, an aliased receiver, or a name computed into a string variable all fail the same way.

The escape is one level up — declaring a different sink — and it needs a CI gate, below.

That strength is the point, because the runtime behaviour is silent. The prom-client adapter looks the name up and returns when it misses — no series, no log, no error. An uncatalogued metric would read zero forever, and a degrade counter stuck at zero is indistinguishable from a path that has never degraded, so the operator is reassured by a number that was never collected.

MetricName is spelled out as a union rather than derived with keyof typeof CATALOG, because isolatedDeclarations rejects an exported declaration whose type depends on an object literal. CATALOG is then annotated Readonly<Record<MetricName, MetricSpec>>, which keeps the two in step in both directions: a union member with no entry fails to compile, and an entry with no union member fails too. Do not relax that annotation to Record<string, MetricSpec> — it would widen MetricName to string and silently un-check every call site in the worker.

apps/worker/__tests__/metrics/catalog.test-d.ts pins this. It asserts the union is not string (a directive-free assertion, so it cannot drift), and carries a @ts-expect-error for each evasion above; no-unwired-test-d.sh fails the build if any of those directives stops suppressing a real error.

Pick the kind and labels from what the call site means, not from the name:

  • A level that is re-read each pass — a queue depth, a consumer backlog, a rolling usage window — is a gauge. Accumulating one reports a total that never existed.
  • A per-event tally is a counter.
  • A distribution of a per-pass magnitude is a histogram, with bounds chosen for that magnitude. Reusing the latency bounds for an entry count wastes most of them above the range the metric actually occupies.
  • A call site that passes no tags declares labelNames: []. Declaring a label the caller never supplies stamps it unknown on every series and invents a dimension the metric does not have.

Names are checked; labels are not. record() resolves each declared label as tags?.[key] ?? 'unknown', so renaming a tag key at a call site (streamstreamKey) still compiles: the metric exports audit_stream_length{stream="unknown"} and every profile collapses into one series. Nothing catches that today — check the labels by hand when you edit a call site.

Only the catalogue may declare the metrics sink

apps/worker/src/metrics/catalog.ts is the one module allowed to declare the sink. A second declaration typed on stringrecord(name: string, …) — compiles, satisfies every consumer of the real sink, and accepts a name no catalogue lists. The metric is then emitted, the generated reference never lists it, and an alert written against it evaluates empty forever.

TypeScript cannot catch this one, which is why it is a gate and not a type. The wider type is a supertype, so assignment works in the direction that hurts; and because the sink is declared with method syntax, its parameters stay bivariant even under strictFunctionTypes, so the narrowing is not checked at all. Rewriting the declaration in property syntax would make strictFunctionTypes bite, but only for that one shape — a widened method-syntax declaration elsewhere still assigns cleanly. The invariant is inherently about where a declaration may appear, which is a lint question rather than a type question.

no-wider-metrics-sink.sh enforces it, run from lint.sh. Three properties are worth knowing before you edit it:

  • It matches the parameter name, not just the type. record(clientOrderId: string, …) is a legitimate unrelated method — the placement dedup ledger has one — and a gate that flagged it would be switched off within a week. The cost is a hole the gate cannot close and does not pretend to: a second sink spelling the parameter something else, record(metric: string, …), is not a candidate and ships. Matching on the type alone is what it would take to catch that, and it would flag the dedup ledger on day one. What the gate does guarantee is that the widening cannot be hidden in the catalogue's own declaration — a MetricsSink member whose first parameter is not name fails the gate loudly rather than being skipped, so the escape stays a deliberate act in a new file rather than a rename in the file everyone edits.
  • It reads the guarded method names off the MetricsSink declaration instead of naming them. A hardcoded list is stale the day a method is added, and it fails open precisely on the new method: forget was added after record, and a gate naming only record would have shipped already blind to half the interface.
  • It walks apps/ and packages/ only, and it reads the first parameter's whole annotation, so name: MetricName | string is caught alongside a bare string — a union accepts every string just the same, and pinning one spelling would only catch the authors who were not trying. The consequence to carry: adding a third top-level source root widens the hole in silence, because the empty-scan stop fires only when both existing roots come back empty.

The gate stops with its own diagnostic rather than reporting OK when it can no longer see what it guards: an empty scan, a moved catalogue, a renamed MetricsSink, a declaration with no name-first method, or a MetricsSink member the parameter-name match would silently skip. no-wider-metrics-sink.selftest.sh drives the real gate over fixture trees and asserts each of those messages by name, so a fixture that moves trips a different branch instead of reading as a successful catch.

Alert rules may only use series we emit

Every top-level .yml or .yaml file under deploy/observability/ is classified as a Prometheus rules file or non-rule configuration. At least one rules file must exist. Every metric named in an expr: must be a series this repo emits, and every selector label key must be emitted with that metric. no-phantom-alert-metric.sh enforces those properties, and lint.sh runs it immediately before promtool-lint.sh.

The two gates catch different faults and the order matters. promtool check rules validates PromQL syntax; it has no view of which series exist, so a rule over a metric nothing writes passes it, then evaluates to an empty vector forever. Such a rule never fires and never errors, which looks exactly like a healthy rule that has not tripped — the operator believes they are covered and is not. Name existence therefore has to fail first, or the author reads a syntax verdict and stops.

The allowed set is assembled from the code rather than a list in the gate, so a new sink is covered the day it lands:

  • the worker catalogue — the MetricName union and the CATALOG keys, parsed separately and diffed against each other, since TypeScript already couples them and a divergence means the parser regressed;
  • every new Counter/Gauge/Histogram/Summary({ name: … }) under apps/** and packages/**, which picks up the api HTTP sink and the @app/observability registry without either path being named in the gate. The scan window closes at that call's own }), never at a character count: a fixed window can run past the call and adopt an unrelated name: literal as a declared metric, which is fail-open and would stop a real phantom being reported;
  • _count / _sum / _bucket off a name declared kind: 'histogram', and _count / _sum off a summary, because Prometheus derives those and only for those kinds. A counter or gauge derives nothing, so decision_count_sum is still a phantom even though decision_count is real;
  • two exact-match sets for series no repo code declares: Prometheus' synthesised up, and the process_* / nodejs_* metrics collectDefaultMetrics registers. Both match exactly and never by prefix, so up_wrong is still reported. nodejs_gc_duration_seconds carries a kind alongside its name because it is the one default metric prom-client registers as a Histogram, so its derived series are real.

Only expr: values are scanned — annotations legitimately carry {{ $labels.profileId }} and prose. Selector bodies are scanned first for an optional metric and label keys, while respecting quoted and escaped braces in values. Label keys are checked against catalogue/constructor labels, registry-wide service/version, scrape job/instance, default-metric labels, and the derived histogram le label. Arbitrary label values are not checked because values such as scrape job names can live outside this repository. The remaining expression is tokenised subtractively (strip quoted strings, range selectors, grouping label lists, offset/@ modifiers, then numeric literals, and read what survives), so a parse miss over-reports and fails the build instead of skipping a name.

Two YAML shapes are handled rather than skipped, because in both the subtractive order would erase the name before it was read and the rule would pass as if it named nothing: a fully quoted expr: scalar (unwrapped first, '' and \" escapes included) and a {__name__="x"} matcher (harvested before the brace strip). A regex or negated __name__ matcher cannot be resolved to a concrete series at all, so it is a hard error rather than a silent skip. Flow-style rule entries (- {alert: …, expr: …}) are rejected the same way: they match neither key pattern while keeping the head and expr counts balanced, so the consistency assert would not notice them.

Five vacuity floors fail the gate rather than pass it: zero catalogue names, zero constructed names, zero rules, zero exprs, and any single expr that names no metric. That last one is per-expr deliberately — a global count is satisfied by one healthy rule and lets a sibling parsed down to nothing through, which is the silent pass the gate exists to stop. A sixth assert requires every rule entry to have yielded an expr, so a block-scalar walk that over-consumed fails red instead of quietly checking less.

A rule that names no metric on purpose — a dead-man switch watchdog is expr: vector(1) by construction — opts out with a # names-no-metric: <reason> comment on the line directly above its expr: key. The opt-out is per-rule and has to be typed, so the floor stays fail-closed: an expression that silently parsed to nothing is still an error.

Rules are read as entries, starting at the dash that introduces each one, and sibling keys are matched at exactly that entry's key column. That is what makes key order inside a rule irrelevant (expr: may lead) while keeping a block-scalar annotation that happens to contain expr: or alert: from being read as a rule key.

no-phantom-alert-metric.selftest.sh runs the gate over focused fixture trees under scripts/ci/__fixtures__/alert-metric/, and lint.sh runs it before the gate itself: set -e stops at the first failure, and on the run where both would fail it is the self-test that says whether the rules file is wrong or the parser regressed. Every rejecting case asserts its own diagnostic string, never a bare non-zero exit: the floors and the hard parse errors all exit 1, so a moved fixture would trip one of them and a non-zero-means-caught check would read that as a successful catch. The fixtures cover accepted syntax, phantom names, invalid and valid selector labels, multiple rules files, explicit non-rule siblings, malformed siblings, and vacuous discovery.

If a rule you want has no series behind it, emit the metric or drop the rule. Do not leave it in place: the file is operator-facing, and a rule structurally incapable of firing is worse than a documented gap. Record the gap in alerts.yml instead, as the "no alert coverage today" block does.

A lint rule carrying an invariant must stay armed

Some oxlint rules are not style — they are the only thing standing between the repo and a defect class. react/no-unstable-nested-components is one: a component declared inside another's render body is a new type every render, so React tears its subtree down instead of updating it, and on WebKit that clamps scrollTop and drags a reader off their place on every poll.

oxlint hard-fails on a misspelled rule or an unknown plugin prefix, so those need no gate. Two drift shapes are silent, and both exit 0:

  • A plugin removed from plugins. Setting that array overwrites oxlint's defaults, and react is not among them, so trimming the list makes every react/* rule vanish from the resolved config with no diagnostic.
  • A severity downgraded to warn. The rule still runs, but lint.sh invokes bunx oxlint without --deny-warnings, so a warning can never fail the build.

no-dropped-lint-rule.sh asserts against oxlint --print-config — the resolved config, not the source file — that each listed rule is present at the expected severity, and lint.sh runs it before bunx oxlint so a disarmed rule is reported before the lint pass reports clean. Add a rule to required_rules when its absence would retire an invariant rather than relax a preference.

no-dropped-lint-rule.selftest.sh drives the gate over mutated copies via the OXLINT_CONFIG seam, so the tracked .oxlintrc.json is never edited and a killed run cannot leave a disarmed rule behind. It fixtures only the two silent shapes: a fixture for a misspelling would prove nothing, because oxlint exits 1 before the gate can read anything. Each rejecting case asserts its own diagnostic string, and each fixture asserts that its own sed actually applied — a substitution that silently stopped matching would otherwise leave the case passing against an unmodified config.

A shipped migration is immutable

_app_migrations keys on the migration's file name and stores its body's SHA-256. The two ways to break that key fail in opposite directions, and only one of them is loud.

Editing a file that has already been applied — a comment is enough to move the digest — makes the runner throw already applied with a different checksum. Refusing to mutate history. against every database that already holds the old value. Loud, and fatal.

Renaming, renumbering, or deleting one is worse, because the runner cannot see it at all. The lookup is by name, so a renamed file matches no ledger row, is treated as brand new, and its body is applied a second time; the row for the old name is left behind pointing at a file that no longer exists. On a migration written if not exists that succeeds in silence and the ledger is permanently wrong. Nothing in the runner rejects this, which is why the CI manifest gate has to. That remains true of the runner, which has no notion of ordering at all — it is no-backfilled-migration.sh that makes the specific case of renumbering downward loud, by rejecting the shape of the directory before such a file can be added. See A migration number is never reused and never backfilled.

Two properties make this worse than an ordinary failed gate. It throws at that file, so every later migration is blocked behind it, including ones the running code now depends on. And the checksum map is read once, before the apply loop, so a repair migration can never execute itself out of the hole no matter how it is numbered — recovery means restoring the original bytes or hand-editing the ledger on every deployment. To change what a shipped migration did, write a new one.

No suite can see this against the real migrations/ tree, which is the point of the gate. Every suite migrates a fresh database, where a mutated file is indistinguishable from a correct one; the drift only exists relative to a database that already recorded the old checksum. no-mutated-applied-migration.sh supplies that missing oracle from packages/db/migrations/checksums.json. It imports loadMigrations and its digest from migrate.ts rather than re-implementing them: a hand-copy would make "the manifest pins what the runner computes" a comment rather than a fact, and a manifest pinning a digest the runner does not compute would pin nothing. Adding a migration means adding its manifest line, so every file is covered and an unpinned addition cannot hide a later edit.

The runner-side half — that a moved checksum actually throws, and throws before any later migration runs — is pinned separately by packages/db/__tests__/migrate-immutability.test.ts, over a synthetic tree it deliberately re-migrates. Without it the four lines in migrate.ts that enforce the invariant could be deleted with this gate still reporting the repo clean. That test also characterises the case the runner cannot catch: a rename orphans the old ledger row and silently re-applies the identical body under the new name, which is the gap the digest manifest exists to close.

The oracle is the manifest rather than a diff against the base branch for two reasons. The durable one: re-pinning a digest leaves a changed hex string in the diff, which a reviewer reads as someone is rewriting history, whereas a merge-base diff leaves no artifact at all once satisfied. The mechanical one: both CI providers clone shallow here, so a merge-base would resolve to nothing on exactly the pipelines that matter — do not "fix" that by setting fetch-depth: 0, because the first reason is the one that holds. For the same reason the gate fails, rather than passes, when it scans zero migrations. The cost this pays: a migration still being iterated on is re-pinned on every edit, because the manifest cannot distinguish editing a file that shipped from editing one that only exists on this branch.

no-mutated-applied-migration.selftest.sh drives the real gate over a generated fixture through the MIGRATIONS_DIR seam and asserts each rejection's own diagnostic string: an edited body, a renamed file, an unpinned addition, a deleted manifest, and an empty directory, plus a pristine case proving the gate can still pass. A bare non-zero assertion would be satisfied by the vacuity guard alone, leaving a gate whose scan path had broken looking green.

A migration number is never reused and never backfilled

migrate() applies files in name order and skips any name already in _app_migrations. A file inserted below the high-water mark therefore runs in its sorted position on a fresh database, but runs last on every database that already migrated past it — after everything numbered above it. Schema statements written if not exists survive that reordering. One-shot data statements do not, and neither does a repair whose correctness depended on running before some later file.

So: a new migration takes the next unused number. The sequence starts at 0001, and the only legal addition is max + 1 — never a letter suffix, never a hole left open for someone else, never a number below a file that has shipped, and never below the floor. scripts/ci/no-backfilled-migration.sh enforces the shape that makes this checkable — a dense, gap-free, letter-suffix-free sequence has exactly one legal insertion point, and it is the top. As sets: the used numbers must be a subset of {1..max}, and used ∪ retired must equal {1..max}. Both halves are needed, and they catch different things — a 0000_*.sql file sorts ahead of every shipped migration while leaving no gap behind it, so only the subset half can see it. The gate reports six separate diagnostics — malformed filenames, duplicate numbers, reuse of a retired number, a number below the floor, gaps, and a grandfathered entry that no longer names a file — because the remedies differ: a retired number must be abandoned, a below-floor number renumbered upward to the top, and a gap closed by taking the first free number — which is usually renumbering down into it, since the file holding the gap open sits above it. The exception is a gap left by a migration that was removed after shipping: that number carries a ledger row on every deployed database and must be retired, not reused. The removal itself is what no-mutated-applied-migration.sh catches.

The directory listing is the only oracle available. checksums.json gains the new file's line in the same commit as the file, so it cannot tell a shipped migration from one added on this branch; and both CI providers clone shallow, so a merge-base diff resolves to nothing on exactly the pipelines that matter. The gate's three exception sets are hardcoded rather than read from the environment for the same reason the manifest is not env-tunable: a reviewed grandfather list any caller can widen is a bypass, not an exception.

Three exceptions exist, and all three are historical:

  • Retired numbers 0002 and 0078. Claimed and released before shipping. They stay holes forever — reusing one would place a new file below everything already applied above it.
  • The duplicate 0070 pair (0070_drop_technicals_recommendations.sql, 0070_first_class_accounts.sql). Benign, because the order they were committed in matches the order they sort in, so neither has ever run after the other. It is not a precedent: a second file at an existing number is otherwise unordered with respect to its twin.
  • 0075a_action_logs_root_heap_drain.sql — the one deliberate backfill, and the documented precedent for the escape hatch. 0076_log_retention_config.sql shipped on 2026-08-07 and its set not null was already failing in environments carrying stranded hypertable root-heap rows. The apply loop rolls back and rethrows at the first failing file, so nothing numbered above 0076 can ever be reached while 0076 fails: a forward-numbered repair is unreachable by construction. Sorting the drain below 0076 was the only repair that runs at all.

That is the bar for the escape hatch, and the escape path is explicit: a repair that genuinely must sort below a shipped migration adds its filename to GRANDFATHERED_FILES inside the gate, in the same merge request as the migration. Reviewed, and visible in the diff, rather than inferred from a filename nobody questioned.

no-backfilled-migration.selftest.sh drives the real gate over a generated 79-file miniature through the MIGRATIONS_DIR seam — malformed name, new letter suffix, duplicate number, retired-number reuse, a below-floor 0000, an open gap, an empty directory, a directory whose files carry no numbers, a stale grandfather entry, a missing directory, and a path that is not a directory — each asserting its own diagnostic string, plus a pristine case that passes and prints its count. The fixture is synthesized from literals and never reads packages/db/migrations, so what the pristine case pins is that the constants still describe the shape they claim to — a grandfathered name that stops matching a file in the fixture, or a retired number that stops matching a hole in it, turns the pristine case red. Staleness against the real directory is a separate branch: a grandfathered entry naming no file is itself reported, because it whitelists that filename forever. A bare non-zero assertion would be satisfied by the vacuity guard alone, leaving a gate whose scan path had broken looking green.

A migration's data statements must be replay-safe

The numbering gate removes the ordinary way a migration gets replayed out of order. It does not make one safe to replay, and the two are separate obligations: a migration is written as if it may be applied twice, or after work that logically follows it.

Schema statements get this for free — if not exists / if exists on every create, add column, and drop constraint. Data statements have to earn it, and the trap is not idempotence in the arithmetic sense. It is a statement whose predicate encodes a meaning that the migration itself abolishes.

0088_profile_symbols_pinned.sql:16 is the worked example:

update profile_symbols set pinned = true where source = 'manual';

Correct exactly once. Migration 0088 is precisely what made manual AND NOT pinned a legal state: before it, every manual binding was reap-exempt by virtue of being manual, so the backfill is a faithful translation of the old model into the new column. After it, manual AND NOT pinned means the operator deliberately released that coin — and a replay silently re-pins every one of them. The predicate reads the same; what it means inverted underneath it.

Its post-flight block (lines 30-35) cannot catch that either. It raises when a manual row is still unpinned, which is an incomplete backfill — the failure mode of applying the statement too few times. There is no assertion that can see an over-applied one, because the over-applied state is indistinguishable from the correct one at the moment it is written.

Write data statements so a second application is a no-op against the state the first one produced. Where that is impossible — where the correct set is only knowable at the moment the migration runs — say so in the migration's header, and prefer a narrower predicate (an explicit id list, a where updated_at < <migration timestamp> bound) over one whose meaning the migration is itself changing.

Every Bun pin must move together

The Bun runtime is pinned in nine places, spread across .tool-versions, package.json, both GitHub workflows, .gitlab-ci.yml, and apps/server/Dockerfile. A partial bump validates CI on one Bun and ships another. Renovate is not the safety net: its coverage is split across several managers and misses packageManager entirely, so a bump can arrive as a Renovate MR and still be partial. no-bun-version-skew.sh's header carries the per-site breakdown, next to the patterns it describes.

no-bun-version-skew.sh reads all nine plus the advisory engines.bun floor and fails unless they agree. Reading counts are exact, not floors: if one site's pattern stops matching, the sites still visible keep agreeing and the gate reports OK forever over a quietly narrowed set. A count mismatch means re-register the moved pin site, never relax the gate.

That expected total is a hand-counted literal rather than a sum over the site registry, and the distinction is the whole guard: an expectation derived from the list it validates shrinks in lockstep with it, so deleting a pin site's registry row leaves every surviving site agreeing against a smaller target and the gate reports OK over a set it silently stopped enforcing. A second check compares the registry's declared sum against that literal, so a registry edit is reported as one — the collected-count message alone reads as a pin gone missing from the repo, and a lowered per-site expect trips the per-site check, whose remedy tells the editor to re-register a site that never moved. Adding or retiring a pin site means moving the literal by hand, in the same diff.

no-bun-version-skew.selftest.sh drives the real gate over a fixture tree through a GUARD_ROOT seam: nine skews (one per site), a pin deleted outright, and six shape breakages — a loose @types/bun range, a drifted engines.bun floor, and four malformed package.json fields. Each must turn the gate red with a diagnostic naming that exact reading, file:line plus the site's label, so a fixture edit that shifted two probes onto one site fails rather than silently leaving a site unprobed. The deletion probe is the one an "all sites agree" check cannot catch, since the survivors still agree.

Five further probes perturb the gate instead of the fixture and run it against the pristine pass tree, because a narrowed gate is green over a good tree by construction and no fixture edit can reach that: a deleted SITES row, a per-site expect lowered below what the file publishes, a pattern edited so it stops matching, a site that throws when it is read, and a reading counted but never collected. Each names the branch it pins, since the gate's checks overlap and an assertion that only demanded red would be satisfied by whichever one happened to fire. The first is the mutation that passed before the expected total was hand-counted; the last is what keeps the collected-count branch — the detector — from being deleted outright with the self-test still reporting OK.

A scan gate's count is evidence only if the walk still reaches the rule's module

Most scripts/ci gates answer their invariant by walking a source tree and counting matches. Every one of them refuses a walk that returned nothing, and that catches only the walk that collapses outright. The walk that merely narrows — a skip-list entry that grew to match a real source directory, a renamed root, a re-layout, a dropped .tsx clause — still returns hundreds of files, so the gate prints a confident count over a subset that no longer contains the code the rule exists to protect, and the build is green over an invariant nobody is checking.

scripts/ci/lib/walk.mjs is therefore the single walk, and collectOrExit refuses a root that declares no anchor. A gate cannot take the zero-file floor from it without also taking the anchor stop, which is what makes "routes through the helper" mean "carries both stops" — something no syntactic check could establish on its own. Anchors are per root and must live under the root they anchor: a shared floor plus one anchor is fail-open exactly where it matters, since apps going dark still leaves packages to satisfy both. Where a gate scans .tsx as well as .ts and the rule reaches apps/web, one anchor is a .tsx file, so reverting the extension clause cannot leave every floor and anchor satisfied over zero components.

no-blind-walk.sh enforces the routing, with a pinned manifest as its vacuity floor, asserted in both directions so neither a new walk gate nor a retired one can drift in silence. The gate prints the live count itself, so this page names no number: a count repeated here is one more thing that can stop matching what it describes, which is the failure the section is about. Its scan admits every depth-one executable candidate except registered non-gate data, including *.selftest.sh, rather than trusting a filename suffix or a list of gate extensions. It derives the walker set from executable source and classifies the two exact fixture-only walkers, no-mutated-applied-migration.selftest.sh and walk-lib.selftest.sh, separately; any other self-test that starts walking remains subject to the production manifest and routing rules. Routing and the override seam have one spelling per language: a shell gate reaches the helper through CI_WALK_LIB and parameterises its root as GUARD_ROOT="${GUARD_ROOT:-$root}", a TypeScript one imports lib/walk.mjs and reads process.env['GUARD_ROOT']. A file whose extension has neither spelling registered, but which walks by any spelling the gate does know, stops the build under its own diagnostic — where an extension allow-list in the scan would have filtered out precisely that file, leaving the refusal unreachable and the tree reading as fully routed over a walk the gate had never seen. The seam is required because a stop that no self-test can point at a broken fixture tree is a stop nobody has ever watched fire, and lint.sh clears the variable before the real run so an ambient value cannot redirect every gate at a tree of someone else's choosing — a substitute tree carrying the anchors would satisfy both stops silently. That clearing is itself checked, and by position and content rather than by a substring: it has to be a live line, name every override the gates read, and run above the first gate, because a sweep that is commented out, names only one variable, or sits below the gates reads identically to a correct one while clearing nothing. A gate may still list a directory itself only where the listing is not a verdict — today only no-stale-screenshot.sh, whose committed PNG set is cross-referenced against the capture manifest and the docs embeds in three directions, so no way of narrowing it produces a confident OK — and only with the reason written at the call site and the name registered in the gate. merge-coverage.ts and workspaces.ts are registered separately as walk libraries: they export a root-parameterised walk and decide nothing, and each refuses a short result itself before returning one — at declared workspace has no package.json, missing complete-suite lcov for: and no lcov source records found. Each migrated gate's self-test drives both stops over its own fixture trees and asserts their two textually distinct sentences, because both exit 1 and an exit code alone cannot say which fired. One walk is exempt and says so at its call site: the API-route walk in no-web-api-query-drift.ts is rooted at the repo root rather than at GUARD_ROOT, because the operation parse imports the real API modules by repo-root URL and a fixture root would desync the two halves, so its stops are proven by the real run only — weaker than the bar every other migrated walk meets.

Coverage evidence

Vitest coverage uses src/**/*.{ts,tsx} as the denominator, so an unimported source file still counts. packages/config/vitest/coverage-policy.js accounts for every workspace and binds each live threshold to the CI lane that runs that workspace's complete suite. The unit, integration, worker-integration, and db-isolation jobs write separate artifacts; coverage-merge rewrites their workspace-relative source paths and retains one deterministic coverage/lcov.info artifact.

@app/testcontainers is exempt from a live threshold. The integration lane reuses externally provided Postgres and Redis services, but the deadline and retry composition is now covered Docker-free against mocked container classes, while the real-daemon smoke test in wrapper.test.ts remains skipped in that lane. That partial execution is not complete-suite coverage evidence.

Codecov is not active. Enabling its upload or badge requires the repository owner to provision a project or global CODECOV_TOKEN first. Until that human prerequisite is complete, the retained lcov artifact is the coverage evidence and the repository must not publish a Codecov status or badge.

Browser bootstrap and app e2e

The browser-bootstrap Playwright job remains stack-free. It starts all four configured browser projects and reports every skipped application execution, but its passing data-URL smoke proves browser startup only.

The separate required app-e2e job runs bun run test:e2e:app. The common harness owns disposable Postgres and Redis state, migrations, an offline normal-auth seed, deterministic Binance endpoints, the same-origin ROLE=all app on http://localhost:53000, readiness at 127.0.0.1:9100/readyz, and cleanup. Its app-only Playwright config runs one serial P0 journey in the same four projects. check-playwright-honesty.ts --mode=app-required fails the lane if any declared execution is skipped, and the harness fails it again if the Binance fixture recorded traffic it does not answer: a REST call with no fixture, or a stream upgrade, which the seeded-disabled profiles mean should never happen.

Binance endpoint overrides exist only inside that harness. NODE_ENV=test and APP_E2E=1 must both be set, every REST and WebSocket URL must be supplied, and every URL must target loopback. Normal runtime endpoint selection stays fixed to Binance production and testnet hosts.

Application journey backlog

  • P0, manual order: place and observe a manual order through its terminal state.
  • P1, safety and diagnosis: engage and release the kill switch, prove configuration durability, and complete the profile diagnosis flow.
  • P2, product breadth: cover onboarding, market and symbol views, backtest, history, orphan-order handling, dust transfer, and scroll stability.