Engineering practices that survive the stack

Each technique names the concrete thing to build and the concrete mistake it replaces. They are stated as rules, so they hold across languages, databases and CI vendors, and they apply whether people or agents write the code. The gate is built from local git hooks, so one person can run all of it without leaning on a code host. The few techniques that need a host say when they start to pay.

7
themes
33
techniques
8
to do first
markdown

00index

At a glance

Priorities say where to start. P0 items are the ones a quality gate does not exist without. P1 items are clear wins that take more work. P2 items are worth doing at lower urgency. Pick a row to jump to the detail.

every technique, in reading order

  1. 1.1A hosted CI run as a second opinionP2
  2. 1.2A pre-commit hook that typechecks, lints and testsP0
  3. 1.3Read-only lint in every automated pathP0
  4. 1.4Required status checks, from the day a second person pushesP2
  5. 1.5A host-faithful production build before every deployP1
  6. 1.6A pre-push hook that runs the production buildP0
  7. 2.1A compiler-enforced client/server boundaryP0
  8. 2.2Every committed config has its tool installed and runningP0
  9. 2.3Guides that describe the code that existsP0
  10. 3.1One declared runtime versionP1
  11. 3.2One shared setup step, reading versions from the repositoryP2
  12. 3.3Derived categorizations, generated from one constantP1
  13. 3.4Dependency automation that respects your pinsP1
  14. 4.1Code generation inside the typecheck commandP1
  15. 4.2A proof-of-failure test for every custom checkP0
  16. 4.3An import smoke test over real entry pointsP1
  17. 4.4A fail-safe changed-files gateP2
  18. 4.5Job-level skip conditions, never workflow path filtersP2
  19. 4.6Development overlays off by defaultP2
  20. 5.1Numbered migration files, not create-if-not-existsP1
  21. 5.2A language-neutral migration formatP1
  22. 5.3Harvest the accidental migration log you already haveP1
  23. 5.4Retire the replaced mechanism in the same changeP1
  24. 5.5An advisory lock around the migration runnerP1
  25. 6.1Name the invariant before choosing the vendorP2
  26. 6.2Evaluate tools by failure mode, not feature listP2
  27. 6.3Score bundled benefits one at a timeP2
  28. 6.4A rejected-items section inside the auditP2
  29. 7.1Infrastructure-dependent suites on a schedule, not on the gateP0
  30. 7.2A port and resource registry instead of a documented tableP2
  31. 7.3A shared environment preflight at every entry pointP2
  32. 7.4One living document per subsystem, link-checkedP2
  33. 7.5Remediation ordered by leverage, with time estimatesP1

01gates

Verification has to be structural, not voluntary

A check that someone has to remember to run is a check you do not have. The question is never whether the suite is thorough. It is whether a change can reach the default branch without passing it. When one person does the pushing, git hooks answer that on the machine where the work happens, and the code host is a second opinion.

1.1

A hosted CI run as a second opinion

P2

Your machine has state a fresh checkout does not, so one run on a clean machine after each push tells you what the hooks could not.

do this
One workflow, triggered by a push to the default branch, that installs from the frozen lockfile and runs the same check command and the same build as the hooks. Nothing waits on it.
not this
Building the pull-request pipeline a team needs (a job per check, required statuses, a branch to merge from) for a repository one person pushes to, then waiting on a runner to repeat what the hooks finished a minute ago.

in plain terms

You check your own homework at home, and that is the check that counts. But you check it at your own desk, with your own notes lying open. Now and then it helps to have someone redo the sums from a blank page. If they get a different answer, your desk was helping more than you knew.

why it holds, and what to build it with

The hooks are the gate, and they run on a machine with history: installed packages the lockfile no longer describes, files that exist on disk and were never committed, a local environment file, a warm build cache. A hosted run starts from nothing, so it catches the change that only works where it was written. It runs after the push, it blocks nothing, and a red run means fix forward. Keep it to one workflow that calls the same scripts as the hooks, so there is one definition of passing. If a deploy platform already builds every push from a clean checkout, that build covers much of the same ground, and the hosted run can stay small or wait. The pipeline grows when the team does. Once a second person pushes, split the checks into named jobs, run them on pull requests and make them required, which is technique 1.4. Three workflow details are worth copying at any size: read-only permissions by default, third-party steps pinned to a commit hash, and an install that refuses to change the lockfile.

build it with

  • hosted CI runner, triggered by a push to the default branch
  • the same check and build scripts the hooks call
  • read-only default permissions
  • steps pinned to a commit hash
  • frozen-lockfile install
  • the deploy platform's own clean build

how it shows up when it is missing

A new file is never staged. Every hook passes because the file is on disk, the push goes out, and the first clean checkout fails on a missing import.

1.2

A pre-commit hook that typechecks, lints and tests

P0

Every commit should typecheck, lint clean and pass its tests, so run those three checks in the hook that creates the commit.

do this
In a Next.js project, Husky installs a pre-commit hook that runs the typecheck, the read-only lint and the unit tests, in that order. A commit that fails any of the three is never created.
not this
A commit hook that only formats, so a type error or a failing test is committed and found later, at the push, several commits past the one that caused it.

in plain terms

Before you put a brick in the wall, you check that it is straight, clean and the right size. It takes a moment, and you never have to pull the wall apart later to find the crooked one. The commit is the brick.

why it holds, and what to build it with

A commit is the unit you review, revert and bisect. If any commit can be broken, each of those gets harder, and the failure surfaces far from the change that introduced it. So the hook checks the three things that say whether the code is correct. In a Next.js project that is the route type generation followed by the TypeScript compiler with no emit, the linter in check mode, and the unit test runner. Order them cheapest first so the common failures stop the commit soonest. Husky is the hook manager: its prepare script installs the hooks on every dependency install, so a fresh clone has them without a setup step. The hook has to stay fast enough that nobody reaches for the bypass flag. Keep the suite it runs to unit tests, and leave browser tests and the production build to the pre-push hook. With one person pushing, this hook and the pre-push hook are the gate, so the bypass flag has to be closed off. Write the rule into the agent instruction file, and deny the flag in the agent's permission settings where the tool has them. An agent late in a task is the likeliest thing to reach for it.

build it with

  • git hook manager (Husky for a Next.js project)
  • pre-commit hook running typecheck, lint and unit tests
  • linter in check mode
  • the production build at pre-push
  • a no-bypass rule in the agent instruction file

how it shows up when it is missing

The commit hook only formats. A type error is committed on Monday, three more commits land on top, and the push on Tuesday fails with no hint which commit broke it.

1.3

Read-only lint in every automated path

P0

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

do this
Two scripts: a checking one wired into every hook and runner, and a fixing one that only a human ever runs.
not this
Wiring the auto-fix variant into a hook or a runner, so violations get repaired, discarded, and never reported.

in plain terms

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.

why it holds, and what to build it with

Two distinct failures come from wiring an auto-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 it is worse: the tool 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.

build it with

  • linter in check mode for hooks and runners
  • formatter check mode vs write mode
  • separate lint and lint:fix scripts

how it shows up when it is missing

The one command every commit must pass runs the auto-fixer, so it can never report a violation.

1.4

Required status checks, from the day a second person pushes

P2

Hooks hold while every push comes from a machine you set up. Once someone else can push, the gate has to move to a place neither of you can skip.

do this
Alone: no branch protection, direct pushes to the default branch, hooks as the gate. With a second contributor: pull requests, each hosted job marked required by name, and one deliberately failing pull request, opened and closed, to prove the block is real.
not this
Protecting a branch only you push to, so every change waits on a pull request you open and approve yourself. The opposite mistake is adding a contributor and still trusting hooks on a machine you do not control.

in plain terms

When you live alone, you lock your own door and that is enough. The day you get a housemate, "I always lock it" stops being a plan, because now it depends on two people remembering. That is the day to fit a door that locks itself.

why it holds, and what to build it with

Branch protection answers one question: can a change reach the default branch without passing the checks? With one person pushing from one configured machine, the hooks already answer it. A required pull request then adds a wait and a click to every change and catches nothing new. The answer changes the moment someone else can push. Their machine may lack the hooks, the toolchain or the habit, and you cannot see which. That is when the hosted run from technique 1.1 gets promoted to a gate. Three things about the promotion are easy to get wrong. Branch protection is usually a repository setting, so the change that adds the workflow cannot apply it and a person has to. A check name with a typo is required of nothing, and the settings page will not tell you, so prove the rule by opening a pull request that fails one required job on purpose and confirming the merge is refused. Administrators can often bypass the rule, so decide whether it applies to you too. Until that day, write the trigger down (a second person with push access) so the decision is on record.

build it with

  • direct pushes and hooks while one person pushes
  • the trigger written down: a second person with push access
  • branch protection or rulesets after that
  • required checks listed by job name
  • a deliberately failing pull request as the proof

how it shows up when it is missing

A contractor gets push access for a week. Their editor commits with hooks disabled, nothing on the host is required, and a type error lands on the default branch.

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.

do this
The production build runs before every push, on the host's runtime version, from the committed lockfile, with the host's build-time variables present. The pre-push hook in the next technique is where it runs.
not this
Pushing to an auto-deploying branch when typecheck, lint and tests pass but nothing has run the build, so the platform's build log is where a build-only failure is discovered.

in plain terms

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

why it holds, and what to build it with

A typechecker, a linter and a test runner do not exercise the build. Prerendering, build-time environment variables, bundler module resolution and the framework's own route validation only run when the production build does, and those are the failures that turn a green check suite into a red deploy. A build costs too much for a commit hook. A push is rarer than a commit, so the build belongs in a pre-push hook, which the next technique covers. Be honest about what a local build proves. 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 environment file the host never sees. Close as much of that gap as you can on your own machine: pin the runtime to the host's version, keep the build-time variables the host has, and use the host's own build command where it ships one. The first clean build after the push covers what is left, on the deploy platform or in the hosted run from technique 1.1.

build it with

  • production build in the pre-push hook
  • the host's runtime version, pinned
  • the host's own build command where it ships one
  • the first clean build after the push as the backstop

how it shows up when it is missing

The check script stops at typecheck, lint and tests, so the deploy platform is the first place any change gets a production build.

1.6

A pre-push hook that runs the production build

P0

A push publishes, and on an auto-deploying branch it ships, so the production build runs on your machine before the push leaves it.

do this
In a Next.js project, Husky installs a pre-push hook with one line: the production build. A push that fails the build never reaches the remote or the deploy platform.
not this
Hooks that stop at the commit checks, so a build-only failure is found minutes later as a failed deploy, after the commit that caused it is already on the default branch.

in plain terms

Before you mail a letter, you read it one last time at the mailbox. Once it drops in, everyone gets a copy. Writing each sentence is the commit, and you do not reread the whole letter after every sentence. But dropping it in the box is the push, and that is worth thirty seconds.

why it holds, and what to build it with

The two hooks split the work by cost. Typecheck, lint and tests run at every commit, because they are quick and they keep each commit sound. The production build takes longer, so it runs at the push. A push happens a few times a day, it is the moment work reaches the remote and the deploy platform, and a failure after it costs a failed deploy and a fix-forward commit on the default branch. A minute at that boundary is a fair price. The build earns its own hook because it catches what the commit checks cannot: prerendering errors, missing build-time environment variables, bundler module resolution and route validation only run when the production build does. The reverse also holds. A Next.js build does not run your tests or your linter, which is why those stay in the pre-commit hook. The hook has two limits. A local build can pass on a stale output directory, a local environment file or a different runtime version, which the previous technique narrows. A hook can also be skipped, which is why the bypass flag is ruled out in writing. Prove the hook once by pushing a deliberate build error and watching it refuse.

build it with

  • git hook manager (Husky for a Next.js project)
  • pre-push hook running the production build
  • the pre-commit checks in front of it
  • a clean build after the push as the backstop

how it shows up when it is missing

There is no pre-push hook, so a page that fails to prerender passes every commit check and breaks the deploy.

02boundaries

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, not a rule in a contributing guide.

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.

in plain terms

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 is impossible. The compiler is the chain.

why it holds, and what to build it with

Most stacks give you some marker that converts an accidental client import into a compile error: a server-only import, a module boundary, a lint rule with a path restriction. This is the highest-leverage single change in most codebases, because it takes an invariant upheld by discipline across hundreds of files and hands 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 marker package explicitly even if your framework aliases it for you. A framework alias works right up until something other than the framework resolves the import.

build it with

  • server-only marker module
  • the marker as the first import
  • declared dependency, not a framework alias
  • restricted-import lint rule as a fallback

how it shows up when it is missing

The guide says never to reference credentials from client components. Nothing enforces it, across dozens of modules that touch secrets and hundreds of client components.

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.

do this
Each config file maps to a declared dependency and a step in the check command the hooks run. The one-off mechanical reformat lands as its own commit and is 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.

in plain terms

A chore chart on the fridge doesn't clean the room. Someone has to 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.

why it holds, and what to build it with

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

build it with

  • formatter plus its config file
  • import-ordering plugin
  • class-ordering plugin
  • format check in the check command
  • blame-ignore file for the reformat commit

how it shows up when it is missing

A formatter config is committed and the formatter is not in the dependency list. When it is finally installed, hundreds of files disagree with the settings the repository 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.

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.

in plain terms

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

why it holds, and what to build it with

Missing documentation makes people go and look at the code. Wrong documentation makes them confidently do the wrong thing, and it aims future work, including work done by agents that read the file as authoritative, in a direction the codebase deliberately left. When a stated intent and the implementation diverge, amend the document to describe what is there, and note the decision if there was one. It also helps to split the audiences: human process in one file and agent guardrails in another.

build it with

  • contributing guide for human process
  • agent instruction file for guardrails

how it shows up when it is missing

The project guide tells contributors to use a data library that was never a dependency, while the codebase has deliberately gone another way.

03one home

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.

do this
A version file, an engine 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, engine range) and pinning none of them.

in plain terms

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.

why it holds, and what to build it with

When these drift, the type checker describes a runtime nobody uses, and nothing stops 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 the reverse, with no error pointing at the version mismatch. Pin the engine range, keep the version file in sync, and have every runner and host read the version from that file instead of restating it.

build it with

  • runtime version file
  • engine range in the manifest
  • runtime type definitions held at the same major
  • runners and hosts reading the version file

how it shows up when it is missing

One repository gives three answers: a version file naming one major, type definitions targeting another, and no declared engine range.

3.2

One shared setup step, reading versions from the repository

P2

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

do this
A single shared setup step doing checkout, runtime, package manager and install, with the runtime read from the version file and the package manager from the manifest.
not this
Pasting version literals into pipeline config and copying the install steps into each job, where they drift job by job.

in plain terms

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.

why it holds, and what to build it with

This starts to pay at the second job. A single push-triggered run has nothing to share yet, though it should still read its versions from the repository. Install, cache and toolchain configuration copied 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 reusable 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 system dependency install.

build it with

  • reusable setup step
  • package manager version read from the manifest
  • cache keyed on the resolved version
  • system dependency install on cache hit too
3.3

Derived categorizations, generated from one constant

P1

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

do this
The query layer building its bucket expression from the same exported constant the interface renders labels from.
not this
Hand-maintaining the same boundaries in application code and again in a query, then finding out through a chart that is subtly wrong and not broken.

in plain terms

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.

why it holds, and what to build it with

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

build it with

  • one exported constant as the source
  • query fragment generated from it
  • validator and column type from the same union
  • path aliases read from the compiler config, not restated
3.4

Dependency automation that respects your pins

P1

Pinning versions creates a maintenance debt, so whatever pays it down has to know which pins are deliberate.

do this
One grouped update a week for minors and patches, each major as its own change, and an explicit hold on anything pinned to match the runtime.
not this
No routine at all, so hash-pinned steps and a deliberately held type package quietly become the oldest thing in the repository.

in plain terms

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 swap your favorite every week.

why it holds, and what to build it with

Pinned versions are a real supply-chain control and a real liability: nothing moves them forward, so without a routine they rot in place. The routine does not need a bot. One command a week that updates minors and patches together, passes the hooks and lands as a single commit is enough for one person. A major gets its own commit, so a breaking bump has its own diff and its own revert. A hosted update bot earns its place once pull requests are how changes land, and the same grouping applies there. Either way, the configuration that matters most is the exclusion list. A type package held at the runtime major must be marked as intentional, or the next update will undo the decision.

build it with

  • a weekly update command, or a dependency update bot
  • minors and patches grouped into one change
  • each major as its own change
  • ignore rules for deliberate pins

how it shows up when it is missing

Nothing runs on a schedule, and a year later the runtime, its type definitions and the pinned pipeline steps all disagree.

04honest checks

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 looks exactly like a real pass until something ships broken.

4.1

Code generation 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.

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 left on disk.

in plain terms

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.

why it holds, and what to build it with

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. This is usually a one-word fix, and it is worth making before wiring the typecheck into a hook or a runner, so neither ends up debugging your build cache.

build it with

  • generate, then typecheck, in one script
  • generated directories included in the compiler config
  • the same rule for schema and API client generators

how it shows up when it is missing

The typecheck script runs the compiler alone, while the compiler config includes 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.

do this
Each hand-written validator ships with a fixture that makes it fail, run in the same check command, 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.

in plain terms

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

why it holds, and what to build it with

Verification code is code, and it has bugs. 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.

build it with

  • a negative fixture per validator
  • file enumeration that includes untracked and new files
  • assertions on the exact status, not a range

how it shows up when it is missing

A link checker lists only tracked files, so it skips every file the change adding it created and reports success while checking nothing. An auth setup step asserts a status below 400, and a rejected session returns a redirect, so it passes on the failure it exists 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.

do this
A script in the check command that imports the module graph of every command-line script, daemon and scheduled job the way that process starts it.
not this
Treating a green build and a green test suite as proof the program starts.

in plain terms

The car starting in the warm garage doesn't prove it starts in the snow. If the snow is where you 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.

why it holds, and what to build it with

Build systems, test runners and standalone scripts resolve modules under different conditions, and a guard that is inert in one can throw in another. 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 and runs with the other checks. It has to run them the way production does, not through the test runner that stubs the module in question.

build it with

  • import smoke script
  • the same launcher production uses
  • awareness of per-environment export conditions
  • no test-runner module stubs on this path

how it shows up when it is missing

A server-only guard is added to shared modules. The build uses the one condition where the guard is inert and the test runner stubs it, so every check is green while every command-line script dies 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.

do this
A gate that reports source-changed on an unknown path, a failed diff or an unexpected event, and skips only on an explicit docs-only allowlist.
not this
Skip logic that defaults to skipping, where a failed diff produces a run that did nothing and looks exactly like one that passed.

in plain terms

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.

why it holds, and what to build it with

Skipping the slow checks when only documentation changed is a real saving, in a hook as much as on a runner, especially in repositories with large 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.

build it with

  • changed-files detection step
  • explicit docs-only allowlist
  • any failure or unknown event means run everything
4.5

Job-level skip conditions, never workflow path filters

P2

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

do this
Every job starts and then decides immediately to do nothing, so the required check still reports a result.
not this
Filtering the workflow itself by path, which leaves the required check pending and the pull request unmergeable.

in plain terms

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

why it holds, and what to build it with

This applies only after technique 1.4 has made checks required. 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. The failure looks like a platform outage, not a configuration mistake. Let the job start, then have it decide immediately to do nothing.

build it with

  • job-level conditions
  • no path filters on the workflow trigger
4.6

Development overlays off by default

P2

A floating development indicator sits on top of the interface you are trying to verify, so it is off by default and turned on by the person who wants it.

do this
The framework's development indicator is disabled in the committed configuration. Screenshots, visual diffs and browser-driven checks see the same pixels a user will.
not this
Leaving the framework's default badge in a corner of every development page, where it shows up in screenshots, covers a control, and takes the click an automated check meant for the page.

in plain terms

If you want to check how a painting looks, take the sticky note off the glass first. Otherwise every photo of the painting has a sticky note in it, and sooner or later someone tries to dust the note instead of the frame.

why it holds, and what to build it with

Frameworks ship a small floating badge or toolbar in development builds. It is useful when you want it and noise the rest of the time, and the noise is not harmless. A visual check compares pixels the user will never see. A screenshot attached to a review has a logo sitting on the layout. A browser-driven test, or an agent driving the page, clicks whatever is at a coordinate, and sometimes that is the badge. The overlay is absent in production, so the development page and the shipped page differ in exactly the place a check is looking. Turn it off in the committed configuration so that every contributor, runner and agent gets the clean page without being told to. Compile and runtime errors still surface when the indicator is hidden, so nothing that matters is lost.

build it with

  • the framework's development-indicator setting, set to off in committed config
  • a per-developer opt-in for anyone who wants it back
  • visual checks run against the same page with no overlay

how it shows up when it is missing

A screenshot taken to review a layout has a framework badge in the bottom corner, on top of the element under review.

05state

Durable state needs a version history

Application code has history for free. Anything living outside the repository (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.

do this
An ordered migrations directory, an applied-migrations table, today's schema captured as the first migration, and a check that applies all of it to an empty database.
not this
Building the schema from idempotent statements at startup, then applying the first column addition by hand and out of band.

in plain terms

A photo of your block 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.

why it holds, and what to build it with

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. At that moment the definition committed to your repository stops describing production, with nothing to detect the gap and nothing to roll back.

build it with

  • ordered migrations directory
  • applied-migrations table
  • baseline migration of the current schema
  • a check applying from empty to a disposable database

how it shows up when it is missing

The schema is a set of create-if-not-exists statements applied at startup: no history, no rollback, and no way to check the live database against the repository.

5.2

A language-neutral migration format

P1

If more than one language changes 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.

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.

in plain terms

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.

why it holds, and what to build it with

Multi-language schema ownership is common and easy to miss, because each side looks self-contained from the inside. A tool that treats one language's model as the single source of truth will not merely be unhelpful. It will 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.

build it with

  • plain SQL files
  • one small runner per runtime
  • one shared applied-migrations table

how it shows up when it is missing

One runtime defines most tables and a second creates a few more in the same database. A diff-based generator proposes dropping every table it cannot see.

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. It is unordered, unversioned and re-run forever.

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.

in plain terms

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.

why it holds, and what to build it with

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

build it with

  • conditional alter statements split in written order
  • no schema redesign in the same 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.

do this
The change that adds the migration runner also deletes 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. That is how the drift gets created.

in plain terms

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 was not them.

why it holds, and what to build it with

The transitional period where both exist is the one where drift is created, and it lasts 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, not an additional opinion.

build it with

  • delete the old setup entry point
  • delete its script
  • one writer: the migration command

how it shows up when it is missing

The runner lands, the startup schema function stays, and retiring it becomes a follow-up that stays open.

5.5

An advisory lock around the migration runner

P1

If daemons or scheduled jobs can restart during a deploy, the migration runner needs a lock, not a convention about who runs it.

do this
The runner takes 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.

in plain terms

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

why it holds, and what to build it with

Convention works right up until an unattended restart lands during a deploy, and the 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.

build it with

  • database advisory lock before the first statement
  • release in a finally block
  • each migration in its own transaction

06adoption

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 elsewhere, name the invariant it provides, then choose your own mechanism for it.

do this
Each adopted item is 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.

in plain terms

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

why it holds, and what to build it with

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

build it with

  • the invariant written before the tool
  • tool selection as its own step
  • one implementation noted as one of several
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 only what it does when it is right.

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.

in plain terms

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 toys are gone. What a helper breaks matters more than what it helps with.

why it holds, and what to build it with

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 acceptable in a project where its model is complete, and disqualifying in one where it is not. No feature table shows that distinction.

build it with

  • a destructive-default column in the comparison
  • "would you notice?" as a scored row
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.

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.

in plain terms

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.

why it holds, and what to build it with

An integrated tool typically offers four or five linked benefits. If your data layer is hand-written and good, and you do not intend to rewrite it, you may be buying one of those benefits while paying the full cost of all of them. That is not an argument against integrated tools. It is an argument for doing the count first.

build it with

  • a per-benefit scoring table
  • the parts you would route around, marked
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.

do this
Declined recommendations live 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 with its reasoning gone.

in plain terms

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.

why it holds, and what to build it with

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 does not apply here. That record is more useful than silence, and it survives the departure of everyone who was in the room.

build it with

  • an explicitly-not-recommended heading
  • the reasoning, and the context that would flip it

how it shows up when it is missing

Per-pull-request preview environments get proposed again every few months for a single-operator system with no reviewer audience, because nobody wrote down why they were declined.

07trust

The gate's credibility is a social property

A gate's real output is trust. Once a red check usually means nothing, the signal is gone even though every check still runs. That holds for one person and their agents as much as for a team.

7.1

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

P0

A gate that goes red because of network flakiness or shared infrastructure teaches you to ignore red, or to reach for the bypass flag, which costs you every other check too.

do this
The infrastructure-dependent suite runs nightly and on manual trigger, with its report kept. Only hermetic checks (no network, no shared database) run in the hooks.
not this
Putting a suite that needs live shared infrastructure in a hook or a required check, and learning that red means try again.

in plain terms

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.

why it holds, and what to build it with

This is the most expensive mistake in the list, because the damage is not confined to the flaky suite. 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 and not retried. A scheduled job on your own machine works as well as a scheduled hosted workflow. Promote a suite to the gate only once it can run against disposable infrastructure that the check itself starts. Keep the report even when the run fails, so the overnight failure is still debuggable in the morning.

build it with

  • scheduled and manually triggered run, local or hosted
  • report kept unless the run was cancelled
  • promotion to the gate only on disposable infrastructure

how it shows up when it is missing

A suite added to the pre-push hook reads a live database over a public network path. The first push from a slow connection times out, and the bypass flag gets its first use.

7.2

A port and resource registry instead of a documented table

P2

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

do this
Sessions claim a port from a lock directory at startup and release it on exit, so no document assigns numbers by hand.
not this
Assigning ports to working copies in a table in the guide, then adding a standing rule never to kill a running server.

in plain terms

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.

why it holds, and what to build it with

Conventions like this are a reliable signal that a structural fix is missing, and they fail 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. The coordination step underneath it usually is not.

build it with

  • claim-and-release lock directory
  • a machine-wide lock so two working copies cannot collide
  • shifted port ranges per workspace

how it shows up when it is missing

The guide assigns each working copy its own dev-server port by hand and adds a 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.

do this
One assertion helper called at the top of each script, daemon and scheduled unit, naming the missing variables and the document 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.

in plain terms

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 'something, somewhere, is missing'.

why it holds, and what to build it with

Systems with many entry points fail deep inside a call stack when a variable is absent, and the stack trace names a library, not 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.

build it with

  • one environment assertion helper
  • a preflight before the dev server starts
  • an error that names the variables and links the doc
7.4

One living document per subsystem, link-checked

P2

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

do this
Dated notes stay append-only, and a small set of current-state documents per subsystem is validated by a link-and-anchor checker in the check command.
not this
Relying on the dated archive alone, so answering how something works today means replaying it in order and guessing what was superseded.

in plain terms

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.

why it holds, and what to build it with

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 system does today, without requiring a reader to replay the archive. Once the living documents exist, a link-and-anchor checker is a small script that keeps them from rotting quietly.

build it with

  • one document per subsystem, plus an ownership table
  • dated notes kept append-only
  • a docs check as its own step in the check command
  • link and heading-anchor validation
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.

do this
The audit ships 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.

in plain terms

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 is hard. A giant unsorted pile of chores just gets ignored.

why it holds, and what to build it with

An audit that arrives as an undifferentiated list gets deferred as a project. The same audit ordered by effort gets started, and the early wins make the rest easier to justify. The estimate is also the honest way to show that most of the value is usually in the first afternoon.

build it with

  • a suggested-order section with minutes, hours or days attached
  • a priority column
  • a status column, updated as items land

get in touch

Let's scope your project.

Tell us what you need and where things are stuck. You'll hear back within a day with a plain-language plan and a price. We skip the pitch deck and the discovery retainer.