| 🚧 Verification has to be structural, not voluntary |
| ▸1.1 | A hosted CI pipeline, not just local hooks GitHub Actions · .github/workflows/ci.yml · permissions: contents: read · concurrency: cancel-in-progress · actions/checkout@<sha> # v7.0.1 · pnpm install --frozen-lockfile | A pull-request workflow running typecheck, lint, format check, unit tests and build as separate named jobs on a hosted runner. | Keeping every check in a local hook and calling it verification — no pipeline at all, so one bypass flag skips the whole project. | P0 |
| ▸1.2 | A staged-files-only pre-commit hook husky · lint-staged · .husky/pre-commit → pnpm exec lint-staged · prettier --write on staged paths · pre-push → tsc, not next build | One line in the hook — format the staged files and re-stage them. About a second, regardless of how large the repo gets. | Running the full suite, plus a second language's dependency sync, on every commit — until people quietly start bypassing the hook. | P0 |
| ▸1.3 | Read-only lint in every automated path eslint (CI) vs eslint --fix (human) · prettier --check vs prettier --write · pnpm run ci → "lint", never "lint:fix" | Two scripts: a checking one wired into the hook and CI, and a fixing one that only a human ever invokes. | Wiring the auto-fix variant into a hook or a runner, so violations get repaired, discarded, and never reported. | P0 |
| ▸1.4 | Required status checks on the default branch GitHub branch protection / rulesets · required contexts by job name · Detect source changes · Types, lint, format · Unit tests · Migrations apply from empty · Production build · Docs links | Branch protection naming each job explicitly, so a red or missing run blocks the merge button rather than merely annotating it. | Shipping the workflows and stopping there, leaving runs that look authoritative, are required by nothing, and merge red. | P0 |
| 🔒 Encode the rule in the compiler, not in a document |
| ▸2.1 | A compiler-enforced client/server boundary server-only · import "server-only" as line 1 · declared in dependencies, not framework-aliased · eslint no-restricted-imports as a fallback | A server-only marker as the first line of every module that reads a credential or opens a database connection, declared as a real dependency. | Leaving the boundary as a sentence in the contributing guide, where nothing objects until a key is already in a shipped bundle. | P0 |
| ▸2.2 | Every committed config has its tool installed and running prettier + .prettierrc · @ianvs/prettier-plugin-sort-imports · prettier-plugin-tailwindcss · format:check in CI · .git-blame-ignore-revs | Each config file maps to a declared dependency and a CI job — plus a one-off mechanical reformat landed as its own commit and recorded in the blame-ignore file. | Committing a formatter config without installing the formatter, so the file reads as evidence the concern is handled while the codebase drifts. | P0 |
| ▸2.3 | Guides that describe the code that exists CONTRIBUTING.md (human process) · AGENTS.md / CLAUDE.md (agent guardrails) · .github/pull_request_template.md | Contributor and agent guides naming the data layer, test runner and directory layout actually present, with abandoned directions marked as abandoned. | Leaving a guide that mandates a library which is not a dependency, so new work — including agent work — aims at an architecture you already rejected. | P0 |
| 🎯 Every version and constant gets exactly one home |
| ▸3.1 | One declared runtime version .nvmrc · engines: { node: ">=22" } · @types/node held at the runtime major · actions/setup-node with node-version-file: .nvmrc | A version file, an engines range and runtime type definitions all naming the same major, with dependency automation told not to bump past it. | Restating the runtime in three places — version file, type definitions, engines field — and pinning none of them. | P1 |
| ▸3.2 | One composite setup step, reading versions from the repo .github/actions/setup/action.yml · pnpm/action-setup reading packageManager · actions/cache keyed on resolved version · install-deps still runs on cache hit | A single shared setup action doing checkout, runtime, package manager and install — runtime read from the version file, package manager from the manifest. | Pasting version literals into pipeline YAML and copy-pasting the install steps into each job, where they drift job by job. | P1 |
| ▸3.3 | Derived categorizations, generated from one constant one exported const as the source · SQL CASE built from it, not typed twice · Zod schema and DB column from the same union · test-runner path aliases read from tsconfig, not restated as regexes | The query layer building its bucket expression from the same exported constant the UI renders labels from, so the two are provably identical. | Hand-maintaining the same boundaries in application code and again in SQL, then finding out via a chart that is subtly wrong rather than broken. | P1 |
| ▸3.4 | Dependency automation that respects your pins .github/dependabot.yml · npm minors grouped weekly, majors separate · github-actions ecosystem grouped (keeps SHA pins fresh) · ignore rules for @types/node and typescript | Grouped weekly updates for minors and patches, majors split into their own reviewable pull requests, and an explicit hold on anything pinned to match the runtime. | No automation at all, so SHA-pinned actions and a deliberately-held type package quietly become the oldest thing in the repo. | P1 |
| 🔬 Make checks deterministic, and prove they can fail |
| ▸4.1 | Typegen inside the typecheck command next typegen && tsc --noEmit · tsconfig includes .next/types/**/*.ts · same rule for drizzle-kit generate, prisma generate, graphql-codegen | A typecheck script that regenerates route and schema types first, so a clean checkout and a warm working tree give the same answer. | Typechecking against whatever generated types the last build happened to leave on disk. | P1 |
| ▸4.2 | A proof-of-failure test for every custom check a negative fixture per validator · avoid git ls-files — it omits untracked and newly added files · assert the exact status, not status < 400 | Each hand-written validator shipping with a fixture that makes it fail, run in CI beside it — so green means it looked and found nothing. | Shipping a bespoke checker without ever watching it fail, so it can enumerate the wrong set of files and report success forever. | P0 |
| ▸4.3 | An import smoke test over real entry points scripts/smoke-imports.ts · tsx, the same launcher production uses · react-server vs default export conditions · runner module stubs hide this: vitest alias, jest moduleNameMapper | A CI script that imports the module graph of every CLI, daemon and scheduled job the way that process actually starts it. | Treating a green build and a green test suite as proof the program starts. | P1 |
| ▸4.4 | A fail-safe changed-files gate .github/actions/changed-source/action.yml · gh pr diff --name-only · allowlist: **/*.md, docs/**, .gitignore · any failure or non-PR event ⇒ source_changed=true | A gate that reports source-changed on an unknown path, a failed diff or a non-pull-request event, and skips only on an explicit docs-only allowlist. | Skip logic that defaults to skipping, where a failed diff quietly produces a run that did nothing and looks exactly like one that passed. | P2 |
| ▸4.5 | Job-level skip conditions, never workflow path filters jobs.<id>.if: needs.detect.outputs.source_changed == 'true' · never on.pull_request.paths | Every job starting and then deciding immediately to do nothing, so the required context still reports a result. | Filtering the workflow itself by path, which leaves the required check pending forever and the pull request permanently unmergeable. | P2 |
| 🗄️ Durable state needs a version history |
| ▸5.1 | Numbered migration files, not create-if-not-exists migrations/0001_baseline.sql · schema_migrations applied-log table · CI job applying from empty against a postgres service container · drizzle-kit if you want generated types with it | An ordered migrations directory, an applied-migrations table, today's schema captured as migration 0001, and a CI job applying all of it to an empty database. | Building the schema from idempotent DDL at startup, then applying the first column addition by hand and out of band. | P1 |
| ▸5.2 | A language-neutral migration format plain .sql files, one runner per runtime · one shared applied-migrations table · the failure mode of drizzle-kit push / alembic autogenerate | Plain SQL files plus a small runner per runtime, all reading and writing one shared applied-migrations table. | Pointing a model-diffing generator at a database another runtime also writes to, where every table it cannot see reads as one to drop. | P1 |
| ▸5.3 | Harvest the accidental migration log you already have ALTER TABLE ... ADD COLUMN IF NOT EXISTS blocks · split in written order into 0002, 0003, ... · no schema redesign in the same change | The existing conditional ALTER block split into ordered files, in the order it was written — a mechanical move, not a redesign. | Scoping the conversion as a schema redesign, when the block of conditional ALTERs running at every startup already is the history. | P1 |
| ▸5.4 | Retire the replaced mechanism in the same change delete the ensureSchema() entry point · delete its package script · one writer: pnpm run db:migrate | The change that adds the migration runner also deleting the old setup entry point and its script, leaving exactly one writer. | Landing the new runner and leaving the old path in place for now, since it still works — which is how the drift gets created. | P1 |
| ▸5.5 | An advisory lock around the migration runner pg_advisory_lock(<constant>) before the first statement · pg_advisory_unlock in a finally block · each migration in its own transaction | The runner taking a database-level advisory lock before its first statement, so a concurrent restart waits instead of interleaving. | Relying on a rule about who may apply migrations — enforced by nobody, at three in the morning, mid-deploy. | P1 |
| 🧭 Adopt techniques, not vendors |
| ▸6.1 | Name the invariant before choosing the vendor versioned migrations ≠ supabase/migrations · schema invariant tests ≠ pgTAP · background jobs ≠ Inngest | Each adopted item written as the invariant — versioned migrations, tests that assert access-control rules — with the tool picked in a separate step. | Rejecting a sound practice wholesale because the hosted product that implemented it elsewhere does not fit this stack. | P2 |
| ▸6.2 | Evaluate tools by failure mode, not feature list a destructive-default column in the comparison · would you notice? as a scored row · model-diffing generators drop what they cannot see | An evaluation that records each candidate's destructive default and whether it is detectable, alongside the capability comparison. | Choosing on the feature table, and meeting the destructive default for the first time in production. | P2 |
| ▸6.3 | Score bundled benefits one at a time score the query builder, the types, the migrations and the studio separately · mark the ones you would route around | A per-benefit table for each integrated tool, marking which of its linked features this codebase would use and which it would route around. | Paying the full migration, learning and failure-mode cost of five linked benefits in order to get one of them. | P2 |
| ▸6.4 | A rejected-items section inside the audit an Explicitly NOT recommended heading · the reasoning, and the context that would flip it | Declined recommendations living in the same document as the adopted ones, each with its reasoning and the context in which it would be right. | Recording only what you adopted, so the same already-considered proposal returns every six months with its reasoning gone. | P2 |
| 🤝 The gate's credibility is a social property |
| ▸7.1 | Infra-dependent suites on a schedule, not on the gate on: schedule + workflow_dispatch · actions/upload-artifact with if: !cancelled() · the suite's HTML report retained · promote to required only once the database is disposable | The infra-dependent suite running nightly and on manual dispatch with its report kept as an artifact; only jobs that are hermetic on a fresh runner are required. | Making a suite that needs live shared infrastructure a required check, and teaching the team that red means try again. | P0 |
| ▸7.2 | A port and resource registry instead of a documented table .locks/ claim-and-release directory · a host-global lock so two worktrees cannot collide · shifted port ranges per workspace | Sessions claiming a port from a lock directory at startup and releasing it on exit, so no document has to assign numbers by hand. | Assigning ports to worktrees in a table in the guide, then adding a standing rule never to kill a running server. | P2 |
| ▸7.3 | A shared environment preflight at every entry point assertEnv([...]) called at every entry point · scripts/check-dev-env.mjs before pnpm dev · the error names the variables and links the doc | One assertion helper called at the top of each script, daemon and scheduled unit, naming the missing variables and the doc that explains them. | Letting a missing variable surface as a stack trace deep in a library, naming everything except the setting that is absent. | P2 |
| ▸7.4 | One living document per subsystem, link-checked in CI docs/ — one document per subsystem, plus an ownership table · ai/YYYY-MM-DD-*.md stays append-only · pnpm docs:check as its own CI job · markdown AST link + heading-anchor validation | Dated notes staying append-only, plus a small set of current-state documents per subsystem, validated by a link-and-anchor checker in CI. | Relying on the dated archive alone, so answering how something works today means replaying it in order and guessing what was superseded. | P2 |
| ▸7.5 | Remediation ordered by leverage, with time estimates a Suggested order section with minutes/hours/days attached · a priority column: P0 / P1 / P2 · a status and pull-request column, updated as it lands | The audit shipping as an ordered list: several-minute fixes first with minutes attached, half-day items next, decision-shaped items last and named as such. | Delivering the audit as one undifferentiated list, which reads as a project and gets deferred as one. | P1 |
🤝The gate's credibility is a social property
A pipeline's real output is trust. Once people learn that a red check usually means nothing, you have lost the signal even though every job still runs.
Infra-dependent suites on a schedule, not on the gate
P0A required check that goes red because of network flakiness or shared infrastructure teaches the team to ignore red, which costs you every other check too.
This is the most expensive mistake in the list, because the damage is not confined to the flaky job. Suites that need live external services, real data volumes, or long timeouts belong on a schedule and on manual trigger, where a failure is investigated rather than retried. Promote them to required status only once they can run against disposable infrastructure that the pipeline itself controls. Keep the HTML report as an artifact with a not-cancelled condition, so the overnight failure is still debuggable in the morning.
A suite added as a merge gate read a live database over a public proxy; from a hosted runner the first request exceeded a 60-second timeout on all three attempts. It was moved to nightly plus manual dispatch.
A port and resource registry instead of a documented table
P2When your documentation assigns each developer or worktree a distinct port, number, or directory by hand, that is an unautomated lock.
Conventions like this are a reliable signal that a structural fix is missing, and they fail exactly when the project is busiest — the moment two sessions run at once. A lock file, a port registry, or a workspace that allocates its own resources removes the class of problem instead of documenting it. The full hermetic-environment version is a large project, but the coordination primitive underneath it usually is not.
The audited guide assigned each worktree its own dev-server port by hand and added a standing rule never to kill a running server — two manual workarounds for one missing lock.
A shared environment preflight at every entry point
P2Check required configuration at startup and exit with the exact list of what is missing, plus where to get it.
Systems with many entry points — scripts, daemons, scheduled units — fail deep inside a call stack when a variable is absent, and the stack trace names a library rather than the missing setting. A shared assertion helper called at the top of each entry point converts that into a one-line failure that says what to do next. It is cheap, and it matters most for the unattended processes nobody is watching.
One living document per subsystem, link-checked in CI
P2Dated, append-only notes are true as of their date; you also need one living document per subsystem that is true now.
Both forms are valuable and they are not substitutes. The journal preserves why a decision was made and what was known at the time; the living document answers what the pipeline does today, without requiring a reader to replay the archive in order and guess which parts were superseded. Once the living documents exist, a link-and-anchor checker is a small script that keeps them from rotting quietly — roughly a hundred lines that parses each markdown file and resolves every relative link and heading anchor.
Remediation ordered by leverage, with time estimates
P1Order remediation so the one-line fixes land before the ones that need a design decision, and attach a time estimate to each.
An audit that arrives as an undifferentiated list gets deferred as a project. The same audit ordered by effort — with the several-minute items first, the half-day items next, and the ones requiring a decision last and named as such — gets started, and the early wins make the rest easier to justify. The estimate is also the honest way to surface that most of the value is usually in the first afternoon.
In the source audit, the first seven items totalled roughly half a day and were what converted 'the hooks probably ran' into 'the main branch is provably green'.