# 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. Canonical copy: https://www.expeditionlabs.co/resources/engineering-practices/llms.txt

33 techniques in 7 themes. Priorities say where to start:

- **P0:** The gate does not exist without these (8)
- **P1:** Clear wins, more work (12)
- **P2:** Worth doing, lower urgency (13)

## 1. 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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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

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

## 2. 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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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

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

## 3. 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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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)

**Rule:** 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:** 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)

**Rule:** 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:** 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

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

## 4. 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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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)

**Rule:** 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:** 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)

**Rule:** 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:** 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

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

## 5. 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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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

## 6. 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)

**Rule:** 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:** 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)

**Rule:** 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:** 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)

**Rule:** 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:** 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)

**Rule:** 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:** 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

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

## 7. 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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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

**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)

**Rule:** 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:** 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)

**Rule:** 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:** 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)

**Rule:** 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:** 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
