šŸ—ƒļøResources7
🧱

Engineering Practices That Survive the Stack

32 techniques extrapolated from an audit that compared two codebases, argued for a specific set of changes, and then shipped them — including the three that went wrong. Each one names the concrete thing to build and the concrete mistake it replaces, stated as a rule so it transfers across languages, databases, and CI vendors — and then names the packages, files and commands that actually implemented it, because a portable rule is easy to nod at and hard to act on.

Summary

Every technique as a matched pair — the concrete thing to build, and the concrete mistake it replaces — with a plain-English ELI5, the tooling that implements it, and how urgent it is. Priorities are P0 the gate does not exist without these (9), P1 clear wins, more work (14), and P2 worth doing, lower urgency (9). Click any row to expand the full detail — the rule, why it holds, and the evidence.

#Techniqueāœ“ Do thisāœ— Not thisPriority
🚧 Verification has to be structural, not voluntary
šŸ”’ Encode the rule in the compiler, not in a document
šŸŽÆ Every version and constant gets exactly one home
šŸ”¬ Make checks deterministic, and prove they can fail
šŸ—„ļø Durable state needs a version history
🧭 Adopt techniques, not vendors
šŸ¤ The gate's credibility is a social property

🚧Verification has to be structural, not voluntary

Every check that a developer can decline is a check you do not have. The question is never whether the suite is thorough — it is whether it is possible to merge without it.

1.1

A hosted CI pipeline, not just local hooks

P0

If the only place your checks run is the contributor's machine, you have documentation, not verification.

šŸ§’ Explain like I’m five

Checking your own homework at home is great, but the teacher still checks it at school. If you are the only one who checks, you can skip it on a busy day and nobody would ever know. CI is the teacher who checks every paper, every time, the same way.

āœ“ Do this
A pull-request workflow running typecheck, lint, format check, unit tests and build as separate named jobs on a hosted runner.
āœ— Not this
Keeping every check in a local hook and calling it verification — no pipeline at all, so one bypass flag skips the whole project.
Implemented with
  • GitHub Actions
  • .github/workflows/ci.yml
  • permissions: contents: read
  • concurrency: cancel-in-progress
  • actions/checkout@<sha> # v7.0.1
  • pnpm install --frozen-lockfile

A pre-commit hook is skipped by a single flag, by a machine missing part of the toolchain, or by any push that did not originate from a normal working tree. More importantly, a purely local check produces no artifact — there is no status a branch protection rule can require, so nothing can ever prove a branch was green before it merged. Run the same commands on a runner and the hook becomes a fast local preview of a gate that exists somewhere else. Four details in the workflow are worth copying verbatim: read-only permissions at the workflow level, a concurrency group keyed on the pull request that cancels in progress so a new push kills the stale run, third-party actions pinned to a commit SHA with the version in a trailing comment, and a frozen-lockfile install.

What happened

The audited repo had no CI directory at all. Its full suite ran in a pre-commit hook, meaning one --no-verify, or one machine without the Python toolchain, silently skipped every check in the project.

1.2

A staged-files-only pre-commit hook

P0

A hook slow enough to be annoying will be bypassed, so put only formatting-of-staged-files in it and move the expensive work to CI.

šŸ§’ Explain like I’m five

If brushing your teeth took a whole hour, you would start skipping it. So keep the every-night routine tiny — a quick brush — and save the deep clean for the dentist. The commit hook is the quick brush; CI is the dentist.

āœ“ Do this
One line in the hook — format the staged files and re-stage them. About a second, regardless of how large the repo gets.
āœ— Not this
Running the full suite, plus a second language's dependency sync, on every commit — until people quietly start bypassing the hook.
Implemented with
  • husky
  • lint-staged
  • .husky/pre-commit → pnpm exec lint-staged
  • prettier --write on staged paths
  • pre-push → tsc, not next build

This is the pairing that makes the previous rule safe. Typechecking the whole project, running every test, and syncing a second language's dependencies on every commit costs minutes per commit regardless of what changed. That is exactly the cost profile that trains people to reach for the bypass flag — and a bypassed hook with no CI behind it means zero verification. Shrink the hook until nobody wants to skip it; let the runner absorb the minutes. The mechanism is small: a hook manager installs the hook, and a staged-files runner narrows it to the paths in the index and re-stages whatever it rewrote.

What happened

Moving from a full-suite pre-commit hook to formatting staged files only took commit time from minutes to about a second — 1.2s, measured after the change.

1.3

Read-only lint in every automated path

P0

An auto-fixing linter is a convenience command, never a verification command.

šŸ§’ Explain like I’m five

A checker's job is to say 'this is messy', not to secretly tidy up and announce 'all clean!'. If the robot quietly fixes the mess and then throws the fix away, nobody ever finds out where the mess keeps coming from.

āœ“ Do this
Two scripts: a checking one wired into the hook and CI, and a fixing one that only a human ever invokes.
āœ— Not this
Wiring the auto-fix variant into a hook or a runner, so violations get repaired, discarded, and never reported.
Implemented with
  • eslint (CI) vs eslint --fix (human)
  • prettier --check vs prettier --write
  • pnpm run ci → "lint", never "lint:fix"

Two distinct failures come from wiring --fix into a gate. In a pre-commit hook, the fixes land on disk but are not re-staged, so the snapshot that gets committed is not the snapshot that passed. On a runner, auto-fix is worse still: it repairs the violation, reports success, and throws the repair away — the gate now hides the exact class of problem it exists to surface. Keep the fixing variant as a manual convenience and point every automated path at the read-only one.

What happened

The audited repo's ci script was tsc && lint:fix && test — an auto-fixer in the one command every commit had to pass.

1.4

Required status checks on the default branch

P0

Building CI and not marking the checks required leaves you with a dashboard, not a gate.

šŸ§’ Explain like I’m five

A red traffic light only works if cars actually have to stop. If the light is just a suggestion, someone will drive through it — probably on the busiest day. Marking checks as required turns the light into a gate arm that physically blocks the road.

āœ“ Do this
Branch protection naming each job explicitly, so a red or missing run blocks the merge button rather than merely annotating it.
āœ— Not this
Shipping the workflows and stopping there, leaving runs that look authoritative, are required by nothing, and merge red.
Implemented with
  • 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

The failure is quiet, because everything looks correct: workflows exist, runs appear on the pull request, badges are green most of the time. But turning on branch protection is usually a repository setting rather than a file in the repo, so it is the one step that cannot be done by the same change that adds the workflow — which makes it exactly the step that gets forgotten. Write the exact list of context names into the issue, because the setting has to be applied by a human in a web form.

What happened

A pull request in the audited repo was merged with a red run, precisely because nothing blocked it. The workflows had shipped; the required-checks setting had not. The repository serving this page repeated the sequence: its first CI run went green on an unprotected main, and only afterward were its three jobs — Types, lint, format Ā· Unit tests Ā· Production build — made required, through the branch-protection API, each bound to the GitHub Actions app so no other integration can satisfy a check by reporting the same name. Pull requests now need all three green and up to date with main to merge. Admins can still bypass the rule, so for a direct push to main the pre-push build is the gate, not the setting.

1.5

A host-faithful production build before every deploy

P1

If the deploy platform is the first place your production build ever runs, you are testing in production; run the build the way the host runs it, before the push that deploys.

šŸ§’ Explain like I’m five

Before you bring a cake to the party, bake a test one at home — using the party's recipe card, not the one in your head. If it is going to collapse, find out in your own kitchen, not in front of the guests. And remember your oven is not their oven: the real test is baking it the way they will.

āœ“ Do this
A required CI job that installs from the frozen lockfile and runs the production build on the host's runtime version with its build-time env — and, until that job exists, the same build in a pre-push hook.
āœ— Not this
Pushing to an auto-deploying branch when typecheck, lint and tests all pass but nothing has run the build — so the platform's build log is where a build-only failure is discovered.
Implemented with
  • next build
  • .husky/pre-push → pnpm run build
  • Production build as a required CI job
  • vercel pull + vercel build for host parity

A typechecker, a linter and a test runner do not exercise the build. Prerendering, build-time environment variables, bundler module resolution, server/client boundary checks and the framework's own route validation only run when the production build does — and those are precisely the failures that turn a green check suite into a red deploy. The staged-files rule above still holds: a build costs far too much for a commit hook, and its proper home is a required CI job. But a push is rarer than a commit, and when the push is the deploy, a pre-push build is the right fallback until CI exists. Keep it honest about what it proves. A local build is a fast preview, not a guarantee: the host installs clean, pins its own runtime version, and injects its own environment, while a local run can pass on a stale output directory or an env file the host never sees. Close the gap by installing from the lockfile, pinning the runtime version the host uses, and — where the host ships one — running its own build CLI against its pulled settings.

What happened

The repository serving this page had no hosted CI and no pre-push hook, and its check script stopped at typecheck, lint, format and tests — so the deploy platform was the first place any change got a production build. It now builds twice before a deploy can surprise anyone: a pre-push hook that takes about seven seconds, and a Production build job in a GitHub Actions workflow on every push and pull request — now a required status check on main.

šŸ”’Encode the rule in the compiler, not in a document

Any invariant that lives only in a style guide is enforced by memory and attention, both of which degrade under deadline. Prefer the version that fails the build.

2.1

A compiler-enforced client/server boundary

P0

If a module must never reach the browser, make importing it from client code a build failure rather than a rule in a contributing guide.

šŸ§’ Explain like I’m five

The house key is secret, so don't just put up a sign saying 'please don't take the key outside' — bolt the key to the wall with a chain. Now taking it outside isn't against the rules; it's impossible. The compiler is the chain.

āœ“ Do this
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.
āœ— Not this
Leaving the boundary as a sentence in the contributing guide, where nothing objects until a key is already in a shipped bundle.
Implemented with
  • server-only
  • import "server-only" as line 1
  • declared in dependencies, not framework-aliased
  • eslint no-restricted-imports as a fallback

Most stacks give you some marker — a server-only import, a module boundary, a lint rule with a path restriction — that converts an accidental client import into a compile error. This is the highest-leverage single change in most codebases, because it takes an invariant that was being upheld by discipline across hundreds of files and delegates it to a tool that never gets tired. It also composes with policies you may not want to change: secrets can stay wherever your project keeps them, as long as the compiler guarantees they cannot be bundled for a browser. Declare the package explicitly even if your framework aliases it for you — Next resolves a bare server-only import to its own vendored copy, which works right up until something other than Next resolves it.

What happened

The audited repo's guide said never to reference credentials from client components. That rule was enforced by nothing, in a project with live trading keys, six modules touching private keys, and 129 client components — while the mechanism that would have enforced it was already present in exactly one file out of 715.

2.2

Every committed config has its tool installed and running

P0

Committed configuration for a tool that is not in your dependencies produces the appearance of governance and none of the effect.

šŸ§’ Explain like I’m five

A chore chart on the fridge doesn't clean the room — someone has to actually do the chores. If nobody ever does them, the chart just makes the mess look handled. Either do the chores, or take the chart down so everyone can see the mess.

āœ“ Do this
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.
āœ— Not this
Committing a formatter config without installing the formatter, so the file reads as evidence the concern is handled while the codebase drifts.
Implemented with
  • prettier + .prettierrc
  • @ianvs/prettier-plugin-sort-imports
  • prettier-plugin-tailwindcss
  • format:check in CI
  • .git-blame-ignore-revs

Nobody audits a setting that already exists. The file gets read as evidence that the concern is handled, so drift accumulates unobserved and the eventual cleanup is enormous. Either install the tool and run it in CI, or delete the config so the gap is visible. When you do finally run it, do the mechanical reformat as its own commit and add that commit to your blame-ignore file, so a decade of authorship history is not attributed to the reformat. Two formatter plugins earn their place while you are in there: deterministic import ordering removes a whole category of merge conflict, and canonical class ordering does the same for utility-class markup.

What happened

A formatter config had been committed and honoured by no one — the tool was never in the dependency list. When it was finally installed and run, 517 files disagreed with the settings the repo had claimed to follow.

2.3

Guides that describe the code that exists

P0

A guide that describes an intent the codebase never adopted does not read as out-of-date; it reads as a target to build toward.

šŸ§’ Explain like I’m five

A treasure map showing a bridge that was never built sends every new explorer the wrong way. Worse, they'll trust the map over their own eyes, because maps look official. Redraw the map so it shows the paths that really exist.

āœ“ Do this
Contributor and agent guides naming the data layer, test runner and directory layout actually present, with abandoned directions marked as abandoned.
āœ— Not this
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.
Implemented with
  • CONTRIBUTING.md (human process)
  • AGENTS.md / CLAUDE.md (agent guardrails)
  • .github/pull_request_template.md

Missing documentation makes people go look at the code. Wrong documentation makes them confidently do the wrong thing — and it aims future work, including work done by agents reading that file as authoritative, in a direction the codebase deliberately walked away from. When a stated intent and the implementation diverge, the fix is to amend the document to describe what is actually there, and to note the decision if there was one. It also helps to split the audiences: human process — branch naming, commit titles, when to open an issue — in one file, agent guardrails in another, and a one-line pull-request template that costs nothing.

What happened

The project guide instructed contributors to use a specific ORM and to run its migrations. That ORM had never been a dependency and no migrations directory existed — the codebase had deliberately gone to hand-written SQL across roughly 40 query modules.

šŸŽÆEvery version and constant gets exactly one home

Duplicated facts do not stay equal. The cost is not the duplication; it is that nothing tells you when the copies stop agreeing.

3.1

One declared runtime version

P1

Runtime version, type definitions, and the declared engine range must be one fact expressed once and referenced everywhere else.

šŸ§’ Explain like I’m five

If the three clocks in your house all show different times, you'll miss the bus and won't even know which clock lied to you. Keep one clock, and make every other clock in the house copy it.

āœ“ Do this
A version file, an engines range and runtime type definitions all naming the same major, with dependency automation told not to bump past it.
āœ— Not this
Restating the runtime in three places — version file, type definitions, engines field — and pinning none of them.
Implemented with
  • .nvmrc
  • engines: { node: ">=22" }
  • @types/node held at the runtime major
  • actions/setup-node with node-version-file: .nvmrc

When these drift, the type checker describes a runtime nobody uses, and nothing prevents a contributor or a future runner from picking a different major. The symptom is the worst kind: code that typechecks locally and fails in production, or vice versa, with no error message pointing at the version mismatch. Pin the engine range, keep the version file in sync, and have CI read the version from that file rather than restating it.

What happened

One repo gave three different answers: a version file saying 22, type definitions targeting 20, and no declared engine range at all.

3.2

One composite setup step, reading versions from the repo

P1

Pipeline definitions should reference the version files the project already keeps, so upgrading a runtime is one edit rather than a search.

šŸ§’ Explain like I’m five

Writing your address by hand on twenty envelopes means twenty chances for a typo — and if you move, twenty corrections. Make one address stamp and use it everywhere; moving house becomes changing the stamp once.

āœ“ Do this
A single shared setup action doing checkout, runtime, package manager and install — runtime read from the version file, package manager from the manifest.
āœ— Not this
Pasting version literals into pipeline YAML and copy-pasting the install steps into each job, where they drift job by job.
Implemented with
  • .github/actions/setup/action.yml
  • pnpm/action-setup reading packageManager
  • actions/cache keyed on resolved version
  • install-deps still runs on cache hit

The same applies to shared setup steps. Install, cache, and toolchain configuration copy-pasted into each job drifts job by job, and the drift is invisible until one job starts failing for a reason none of the others do. Factor it into a single composite step and call it from each job. Caching belongs in there too, with one caveat worth writing down: a cached browser binary restores without the system libraries it needs, so the cache-hit path still has to run the dependency install step.

3.3

Derived categorizations, generated from one constant

P1

When the same categorization appears in two layers — a query and its labels, a schema and its validator — generate one from the other so they cannot disagree.

šŸ§’ Explain like I’m five

If two friends each keep their own copy of the game rules, the copies slowly drift apart, and one day you're arguing mid-game about who is right. Keep one rulebook and make every other copy a photocopy of it.

āœ“ Do this
The query layer building its bucket expression from the same exported constant the UI renders labels from, so the two are provably identical.
āœ— Not this
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.
Implemented with
  • 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

This is the same principle as version pinning, applied inside the application. Bucket boundaries written once in application code and again by hand in SQL will diverge at the worst possible moment, and the divergence shows up as a subtly wrong chart rather than an error. Building the SQL fragment from the shared constant makes the two provably identical.

What happened

The audited codebase's query layer built a SQL CASE expression from the same lifecycle-bucket constant the UI rendered its labels from, so the two could not drift.

3.4

Dependency automation that respects your pins

P1

Pinning versions creates a maintenance debt, so the bot that pays it down has to understand which pins are deliberate.

šŸ§’ Explain like I’m five

Some of your toys are old because you forgot about them; one is old because it's your favorite and you want it exactly like that. The robot that replaces old toys needs a note saying which ones are old on purpose — or it will 'helpfully' swap your favorite every week.

āœ“ Do this
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.
āœ— Not this
No automation at all, so SHA-pinned actions and a deliberately-held type package quietly become the oldest thing in the repo.
Implemented with
  • .github/dependabot.yml
  • npm minors grouped weekly, majors separate
  • github-actions ecosystem grouped (keeps SHA pins fresh)
  • ignore rules for @types/node and typescript

SHA-pinned actions are a real supply-chain control and a real liability: nothing floats them forward, so without automation they rot in place. Grouping minor and patch bumps into one weekly pull request keeps the noise low enough to actually get reviewed, while splitting majors means a breaking bump arrives with its own diff and its own discussion. The one configuration that matters most is the exclusion list — a type package held at the runtime major, or a compiler held where the rest of the toolchain supports it, must be marked as intentional, or the bot will helpfully undo the decision every week.

What happened

The audited repo had no dependency automation, and the runtime incoherence in the previous item is what that looks like after a year.

šŸ”¬Make checks deterministic, and prove they can fail

The dangerous check is not the one that fails. It is the one that passes for a reason you did not intend, because it is indistinguishable from a real pass until something ships broken.

4.1

Typegen inside the typecheck command

P1

If a check reads generated files, generate them in the same command — otherwise the result depends on what a previous build happened to leave behind.

šŸ§’ Explain like I’m five

Checking yesterday's weather doesn't tell you whether you need an umbrella today. Look out the window right before you leave, every time, as part of leaving — regenerate the files right before you check them, in the same command.

āœ“ Do this
A typecheck script that regenerates route and schema types first, so a clean checkout and a warm working tree give the same answer.
āœ— Not this
Typechecking against whatever generated types the last build happened to leave on disk.
Implemented with
  • next typegen && tsc --noEmit
  • tsconfig includes .next/types/**/*.ts
  • same rule for drizzle-kit generate, prisma generate, graphql-codegen

Typecheckers that consume generated route types, schema types, or client stubs will silently consume a stale copy. The result is a check that passes on a developer's machine and fails on a clean checkout, or the reverse, with no signal that the inputs differed. Usually this is a one-word fix, and it is worth making before wiring anything into CI, so the pipeline is not debugging your build cache.

What happened

The audited typecheck script was tsc --noEmit alone, while tsconfig.json included two directories that only exist after a build.

4.2

A proof-of-failure test for every custom check

P0

A check is not trustworthy until you have watched it fail on the exact condition it was written to detect.

šŸ§’ Explain like I’m five

A smoke alarm that has never made a sound might be quiet because there's no fire — or because the battery is dead. From the couch, those look exactly the same. Press the test button once, so you know it can actually scream.

āœ“ Do this
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.
āœ— Not this
Shipping a bespoke checker without ever watching it fail, so it can enumerate the wrong set of files and report success forever.
Implemented with
  • a negative fixture per validator
  • avoid git ls-files — it omits untracked and newly added files
  • assert the exact status, not status < 400

Verification code is code, and it has bugs — but its bugs are unusually well camouflaged, because the failure mode is a green result. Two patterns account for most of them: enumerating the wrong universe of inputs, and asserting a condition looser than the one that matters. Both produce a check that runs, reports success, and inspects nothing.

What happened

A docs link checker enumerated files with a command that lists only tracked files — so it skipped every file the pull request adding it had created, and reported success while checking nothing. Separately, an auth setup step asserted a response status below 400, but a rejected session returns a 307 redirect, so it passed on precisely the failure it existed to catch.

4.3

An import smoke test over real entry points

P1

Smoke-test your real entry points, because module resolution differs between the environments your gates run in and the ones your code runs in.

šŸ§’ Explain like I’m five

The car starting in the warm garage doesn't prove it starts in the snow. If the snow is where you actually drive it, the snow is where you need to turn the key. Start each program the way it really starts, not the way the test lab starts it.

āœ“ Do this
A CI script that imports the module graph of every CLI, daemon and scheduled job the way that process actually starts it.
āœ— Not this
Treating a green build and a green test suite as proof the program starts.
Implemented with
  • 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

Build systems, test runners, and standalone scripts resolve modules under different conditions, and a guard that is inert in one can throw in another. That means a production build, a full test suite, and a local run of the whole check suite can all be green while an entire class of entry point is dead on import. The cheap insurance is a script that imports each real entry point's module graph and runs it in CI — and it has to run them the way production does, not through the test runner that stubs the very module in question.

What happened

Adding a server-only guard to 53 modules broke every command-line script in the project. The build used the one export condition where the guard is inert, and the test runner stubbed the module — so CI, the production build, and a full local suite were all green while every script died at import.

4.4

A fail-safe changed-files gate

P2

Any optimization that skips work should treat every uncertainty — an unrecognized path, a failed query, an unexpected event type — as a reason to run everything.

šŸ§’ Explain like I’m five

If the shortcut sign is blurry and you're not sure it's really a shortcut, take the long road you know. Being slow and right beats being fast and lost — especially when nobody is watching to catch the mistake.

āœ“ Do this
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.
āœ— Not this
Skip logic that defaults to skipping, where a failed diff quietly produces a run that did nothing and looks exactly like one that passed.
Implemented with
  • .github/actions/changed-source/action.yml
  • gh pr diff --name-only
  • allowlist: **/*.md, docs/**, .gitignore
  • any failure or non-PR event ⇒ source_changed=true

Skipping CI when only documentation changed is a real saving, especially in repos with large research or notes directories. But the logic that decides is itself untested code running outside the gate, so its default has to be the expensive-and-correct branch. A gate that can skip by accident is not a gate.

4.5

Job-level skip conditions, never workflow path filters

P2

Use a job-level condition to skip work, because a workflow filtered out entirely never reports its status and leaves required checks pending forever.

šŸ§’ Explain like I’m five

If the referee stays home, the game can never officially end — nobody is there to say 'nothing to call today'. The referee should always show up, look around, and then say out loud 'nothing for me to do'.

āœ“ Do this
Every job starting and then deciding immediately to do nothing, so the required context still reports a result.
āœ— Not this
Filtering the workflow itself by path, which leaves the required check pending forever and the pull request permanently unmergeable.
Implemented with
  • jobs.<id>.if: needs.detect.outputs.source_changed == 'true'
  • never on.pull_request.paths

This is a specific trap with a general shape: the mechanism that prevents a workflow from running also prevents it from reporting that it did not need to run. The required check is then permanently pending and the pull request can never merge — a failure that looks like a platform outage rather than a configuration mistake. Let the job start, then have it decide immediately to do nothing.

šŸ—„ļøDurable state needs a version history

Application code has history for free. Anything living outside the repo — schemas, buckets, queues, indexes — only has the history you deliberately build for it.

5.1

Numbered migration files, not create-if-not-exists

P1

Create-if-not-exists describes the current shape and can never express a change to an existing one.

šŸ§’ Explain like I’m five

A photo of your LEGO castle shows what it looks like, but not the steps that built it. When you want to add a tower, the photo can't help — you need the numbered instruction booklet. Migrations are the booklet; keep every page.

āœ“ Do this
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.
āœ— Not this
Building the schema from idempotent DDL at startup, then applying the first column addition by hand and out of band.
Implemented with
  • 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

It is an appealing starting point: simple, safe to re-run, no ordering to maintain. The wall arrives the first time you need to add a column, widen a type, or add a constraint, because none of those are expressible. Those changes then get applied by hand, out of band — and at that moment the definition committed to your repository stops describing production, with nothing to detect the gap and nothing to roll back.

What happened

The audited schema was arrays of CREATE TABLE IF NOT EXISTS strings across 10 modules and roughly 41 tables, applied at startup — no history, no rollback, and no way to check the live database against HEAD.

5.2

A language-neutral migration format

P1

If more than one language issues DDL against the same database, the migration mechanism has to be legible to all of them — which usually means plain SQL files and a shared applied-migrations table.

šŸ§’ Explain like I’m five

If two people share a room, the room rules have to be written in a language both of them can read. And a robot butler that only knows one person's belongings will throw out the other person's things as clutter.

āœ“ Do this
Plain SQL files plus a small runner per runtime, all reading and writing one shared applied-migrations table.
āœ— Not this
Pointing a model-diffing generator at a database another runtime also writes to, where every table it cannot see reads as one to drop.
Implemented with
  • plain .sql files, one runner per runtime
  • one shared applied-migrations table
  • the failure mode of drizzle-kit push / alembic autogenerate

Multi-language schema ownership is common and easy to miss, because each side looks self-contained from the inside. Any tool that treats one language's model as the single source of truth will not merely be unhelpful; it will actively propose destroying the tables it does not know about. A directory of numbered SQL files with a small runner is language-neutral by construction, and both runtimes read the same history.

What happened

The audited repo's TypeScript modules defined roughly 41 tables, but its Python package independently created six more against the same database. A diff-based migration generator would have proposed dropping all six — including the table holding the system's primary output.

5.3

Harvest the accidental migration log you already have

P1

A pile of add-column-if-not-exists statements re-executed at startup is already a migration history — just unordered, unversioned, and re-run forever.

šŸ§’ Explain like I’m five

Those sticky-note fixes you re-apply every single morning? That pile is already your instruction list — it's just unnumbered and stuck to the fridge. Number the notes and put them in a binder, in the order you wrote them.

āœ“ Do this
The existing conditional ALTER block split into ordered files, in the order it was written — a mechanical move, not a redesign.
āœ— Not this
Scoping the conversion as a schema redesign, when the block of conditional ALTERs running at every startup already is the history.
Implemented with
  • ALTER TABLE ... ADD COLUMN IF NOT EXISTS blocks
  • split in written order into 0002, 0003, ...
  • no schema redesign in the same change

Finding it is good news, because it means the conversion is mechanical rather than a redesign: the statements exist and are known correct, they simply need to be split into ordered files. It is also the clearest possible evidence that the idempotent approach has already hit its ceiling, since those statements only exist because create-if-not-exists could not express the change.

5.4

Retire the replaced mechanism in the same change

P1

Introducing a new mechanism without removing the one it replaces leaves two writers and no way to know which one produced the current state.

šŸ§’ Explain like I’m five

When you put a new lock on the door, take the old lock off. With two locks and two keys, you never know which door was left open — and everyone swears it wasn't them.

āœ“ Do this
The change that adds the migration runner also deleting the old setup entry point and its script, leaving exactly one writer.
āœ— Not this
Landing the new runner and leaving the old path in place for now, since it still works — which is how the drift gets created.
Implemented with
  • delete the ensureSchema() entry point
  • delete its package script
  • one writer: pnpm run db:migrate

The transitional period where both exist is the one where drift is created, and it tends to last much longer than planned because the old path still works. Retiring it in the same change is what makes the new mechanism the single source of truth rather than an additional opinion.

What happened

Retiring the audited repo's ensurePostgresSchema() in favour of the runner did not land with the migrations, and is still an open follow-up — exactly the two-writer window this rule exists to close.

5.5

An advisory lock around the migration runner

P1

If daemons or scheduled jobs can restart concurrently with a deploy, the migration runner needs an advisory lock rather than a convention about who runs it.

šŸ§’ Explain like I’m five

One bathroom needs one lock on the door. 'We agreed to take turns' works right up until two people are in a hurry at the same time. The lock isn't rude; the lock is how taking turns actually happens.

āœ“ Do this
The runner taking a database-level advisory lock before its first statement, so a concurrent restart waits instead of interleaving.
āœ— Not this
Relying on a rule about who may apply migrations — enforced by nobody, at three in the morning, mid-deploy.
Implemented with
  • pg_advisory_lock(<constant>) before the first statement
  • pg_advisory_unlock in a finally block
  • each migration in its own transaction

Convention works right up until an unattended restart lands during a deploy, and the resulting half-applied schema is discovered by whatever queries next. The lock is a few lines. The alternative is a rule that migrations may only be applied from one place, enforced by nobody at three in the morning.

🧭Adopt techniques, not vendors

Copying practices between codebases works. Copying the stack that happened to implement them does not, and the two are easy to confuse because they arrive together.

6.1

Name the invariant before choosing the vendor

P2

When importing a practice from another project, name the invariant it provides, then choose your own mechanism for it.

šŸ§’ Explain like I’m five

You don't need your friend's exact bike to learn what they learned — you need a bike. Copy the skill, not the brand.

āœ“ Do this
Each adopted item written as the invariant — versioned migrations, tests that assert access-control rules — with the tool picked in a separate step.
āœ— Not this
Rejecting a sound practice wholesale because the hosted product that implemented it elsewhere does not fit this stack.
Implemented with
  • versioned migrations ≠ supabase/migrations
  • schema invariant tests ≠ pgTAP
  • background jobs ≠ Inngest

Versioned migrations is the technique; a particular hosted database's migration folder is one implementation. Tests that assert schema and access-control invariants is the technique; a particular in-database test framework is one implementation. Stating the invariant first is what lets you keep the value when the vendor does not transfer — and it makes the eventual write-up useful to the next project too.

6.2

Evaluate tools by failure mode, not feature list

P2

Ask what this tool does when it is wrong, and whether you would notice — not just what it does when it is right.

šŸ§’ Explain like I’m five

Don't just ask what the robot vacuum picks up — ask what it eats that it shouldn't, and whether you'd notice before your LEGO is gone. What a helper breaks matters more than what it helps with.

āœ“ Do this
An evaluation that records each candidate's destructive default and whether it is detectable, alongside the capability comparison.
āœ— Not this
Choosing on the feature table, and meeting the destructive default for the first time in production.
Implemented with
  • a destructive-default column in the comparison
  • would you notice? as a scored row
  • model-diffing generators drop what they cannot see

Feature comparisons are symmetric and cheap to produce; failure-mode comparisons are neither, which is why they get skipped. A generator that computes changes by diffing its own model against reality has a specific and severe failure mode: anything outside its model reads as something to remove. That may be perfectly acceptable in a project where its model is complete, and disqualifying in one where it is not — a distinction no feature table will show you.

6.3

Score bundled benefits one at a time

P2

Tools sell as a bundle; score each part separately against what your codebase will really do, and be honest about the parts you will bypass.

šŸ§’ Explain like I’m five

A meal deal is only a deal if you were going to eat more than the fries. Price each item on its own before you buy the combo — sometimes you're paying for four things to get one.

āœ“ Do this
A per-benefit table for each integrated tool, marking which of its linked features this codebase would use and which it would route around.
āœ— Not this
Paying the full migration, learning and failure-mode cost of five linked benefits in order to get one of them.
Implemented with
  • score the query builder, the types, the migrations and the studio separately
  • mark the ones you would route around

An integrated tool typically offers four or five linked benefits. If your data layer is hand-written and good, and you have no intention of rewriting it, you may be buying one of those benefits while paying the full migration, learning, and failure-mode cost of all of them. That is not an argument against integrated tools — it is an argument for doing the count before, rather than discovering it during.

6.4

A rejected-items section inside the audit

P2

An audit's rejected items are worth as much as its adopted ones, because otherwise the same suggestion returns every six months.

šŸ§’ Explain like I’m five

Write down why you said 'no thanks' to the toy, or you'll have the same argument again every birthday. 'We thought about it, and here's why not' is the note that ends the rerun.

āœ“ Do this
Declined recommendations living in the same document as the adopted ones, each with its reasoning and the context in which it would be right.
āœ— Not this
Recording only what you adopted, so the same already-considered proposal returns every six months with its reasoning gone.
Implemented with
  • an Explicitly NOT recommended heading
  • the reasoning, and the context that would flip it

Write the rejection next to the recommendation, in the same document, with the reasoning that produced it. This is also the honest place to note that a practice was excellent in its original context and simply does not apply here — which is a much more useful record than silence, and it survives the departure of everyone who was in the room.

What happened

The audit explicitly declined per-pull-request preview environments — a well-built scaffold in the source repo, but dormant even there, and pointless for a single-operator system with no reviewer audience.

šŸ¤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.

7.1

Infra-dependent suites on a schedule, not on the gate

P0

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

šŸ§’ Explain like I’m five

If the fire alarm goes off every time someone makes toast, people stop looking up when it rings — and then they miss the real fire. Only alarms that really mean it get to be alarms; the toast-sensitive one gets checked on a schedule instead.

āœ“ Do this
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.
āœ— Not this
Making a suite that needs live shared infrastructure a required check, and teaching the team that red means try again.
Implemented with
  • 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

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.

What happened

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.

7.2

A port and resource registry instead of a documented table

P2

When your documentation assigns each developer or worktree a distinct port, number, or directory by hand, that is an unautomated lock.

šŸ§’ Explain like I’m five

Instead of a list on the wall saying which kid gets which swing, let whoever arrives take a ticket from the machine. Nobody argues, nobody has to update the list, and the list can never go stale.

āœ“ Do this
Sessions claiming a port from a lock directory at startup and releasing it on exit, so no document has to assign numbers by hand.
āœ— Not this
Assigning ports to worktrees in a table in the guide, then adding a standing rule never to kill a running server.
Implemented with
  • .locks/ claim-and-release directory
  • a host-global lock so two worktrees cannot collide
  • shifted port ranges per workspace

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.

What happened

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.

7.3

A shared environment preflight at every entry point

P2

Check required configuration at startup and exit with the exact list of what is missing, plus where to get it.

šŸ§’ Explain like I’m five

Check for your shoes, lunch and homework at the front door — not when you're already on the bus. And the reminder should say 'you forgot your lunch', not just 'something, somewhere, is missing'.

āœ“ Do this
One assertion helper called at the top of each script, daemon and scheduled unit, naming the missing variables and the doc that explains them.
āœ— Not this
Letting a missing variable surface as a stack trace deep in a library, naming everything except the setting that is absent.
Implemented with
  • assertEnv([...]) called at every entry point
  • scripts/check-dev-env.mjs before pnpm dev
  • the error names the variables and links the doc

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.

7.4

One living document per subsystem, link-checked in CI

P2

Dated, append-only notes are true as of their date; you also need one living document per subsystem that is true now.

šŸ§’ Explain like I’m five

A diary tells you what happened each day; a map tells you where things are right now. You need both — and someone should walk the streets once in a while to make sure the map still matches them.

āœ“ Do this
Dated notes staying append-only, plus a small set of current-state documents per subsystem, validated by a link-and-anchor checker in CI.
āœ— Not this
Relying on the dated archive alone, so answering how something works today means replaying it in order and guessing what was superseded.
Implemented with
  • 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

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.

7.5

Remediation ordered by leverage, with time estimates

P1

Order remediation so the one-line fixes land before the ones that need a design decision, and attach a time estimate to each.

šŸ§’ Explain like I’m five

Faced with a messy room, start with the toys you can put away in one minute — the room looks better right away, and now you can see what's actually hard. A giant unsorted pile of chores just gets ignored.

āœ“ Do this
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.
āœ— Not this
Delivering the audit as one undifferentiated list, which reads as a project and gets deferred as one.
Implemented with
  • 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

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.

What happened

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

7.6

A pull request only when it buys a review

P1

Open a pull request when a person will review it or the change comes from outside the repo; otherwise push straight to the gated branch, because a pull request nobody reads costs time and tokens and buys nothing.

šŸ§’ Explain like I’m five

If you're the only one in the kitchen, you don't write a note asking yourself to taste the soup, wait for yourself to read it, and then sign off. You taste it and serve it. You write the note when someone else is going to taste it.

āœ“ Do this
A pre-push hook and push-triggered CI as the gate, routine changes pushed directly to the default branch, and pull requests reserved for reviewed work, external contributions and bot-authored dependency bumps — written down where agents read it.
āœ— Not this
Opening a branch and a pull request for every one-line change on a solo repo, then spending more tokens creating, polling, describing and merging it than the change itself took.
Implemented with
  • .husky/pre-push — pnpm run ci and the production build
  • on: push to main, plus pull_request, in the CI workflow
  • gh pr create reserved for reviewed changes
  • one line in the agent guardrails: push directly; no pull request unless asked

The gate is the pre-push hook plus the workflow that runs on every push. A pull request adds exactly two things on top of that: a reviewer and a merge button. When nobody is going to review, the branch, the description, the wait for checks that already ran locally, the merge and the branch deletion are ceremony, and for an agent each step is a round trip that burns context on the same green result. Required status checks stay in place for the day a pull request does exist; they are not a reason to create one. The rule has to live in the guardrails file, because an agent left to its defaults will open a pull request for a typo.

What happened

The repository serving this page has no merge commits: every change, including the one that added this technique, went to main through the pre-push build and the push-triggered workflow. The pull-request path exists for its dependency bot and for anyone outside the repo.

Where this came from. A read-only audit comparing engineering practice between two Next.js / pnpm / TypeScript repositories, which produced 15 recommendations, 14 adoptions, one explicit rejection, and three post-merge failures worth recording. Every Implemented with list above is that stack’s answer, not the requirement — the rule is what transfers, and husky, lint-staged, server-only and pg_advisory_lock are what it looked like once someone finished doing it. The rest of the specifics — table names, file counts, the pull requests each item shipped in — are in the original issue ↗.

Applied to this site. The same list, audited against the repository serving this page — including a formatter config nothing had ever run, credentials with no compiler guard, and a documented verification command that turned out to delete node_modules — is tracked in masao.site#1 ↗.