Field guide · Nº 18
A field guide to version control and CI/CD
This course follows one change — a price-alert toggle in a wine-cellar application — from a keystroke to every customer who will ever use it. Between those two points sits a line of stages: review, continuous integration, an artifact, a staging environment, a canary, a feature flag. Each stage exists to answer the same question, how sure are we this is safe?, and each buys its slice of that certainty at a different price. The whole course is an argument that you can read any team's delivery pipeline as a budget: where confidence is purchased, what each purchase costs, and which expensive late stage is quietly compensating for a cheap early one that nobody built.
The line starts in version control, so we start there — not with commands, but with the four facts about git's storage model that make every confusing thing it does stop being confusing. Git is not a system that tracks changes to files. It is a small content-addressed database of immutable snapshots, with a handful of movable labels stuck onto it. Everything else follows.
A defect has a price, and the price is not fixed. Caught by a unit test on your laptop, the null-threshold bug in this course's running example costs about ninety seconds: read the stack trace, add the guard, re-run. Caught by a reviewer, it costs a comment thread and a day of latency. Caught by a canary, it costs an alert, a decision, and a small number of real users seeing something wrong. Caught by a customer emailing support, it costs the thing you cannot buy back, which is their belief that your software is careful. The same defect, four prices, spanning three orders of magnitude.
That gradient is the entire economic logic of a delivery pipeline. Every stage between your keyboard and your customer is a confidence purchase: a mechanism that answers some portion of is this safe? in exchange for time, money, or exposure. A pipeline is well-designed when each unit of confidence is bought at the cheapest stage capable of supplying it, and pathological when an expensive stage is doing work a cheap one should have done — when the canary is finding null-pointer bugs, or the on-call engineer is finding design errors, or the customer is finding both.
A pipeline is not a sequence of hurdles. It is a portfolio of purchases, and the design question is never should we add a gate? but what confidence are we missing, and what is the cheapest stage that can sell it to us? Every module in this course returns to that question.
Notice what the framing implies about speed. If confidence is bought stage by stage, then a team that ships forty times a week is not skipping stages; it has moved its purchases leftward, where they are cheap and fast, so that the late stages have almost nothing left to find. The evidence bears this out — we will look at it directly in module 8 — but you can already see the mechanism. Speed and safety are not opposites here; they are both downstream of the same engineering.
Almost everyone arrives at git with a folk model inherited from older tools: a repository is a base version plus a stack of patches, and a commit is a diff you saved. It is a reasonable guess, it explains a few observations, and it is wrong in a way that makes half of git's behavior look arbitrary.
What git actually stores is four kinds of object in a key-value database where the key is the hash of the value. A blob is the raw contents of one file. A tree is a directory listing: names paired with the hashes of the blobs and subtrees inside it. A commit is a tiny record naming one root tree, zero or more parent commits, an author, a timestamp, and a message. (Tags are the fourth and matter least here.) Committing does not compute a diff; it writes any new blobs, writes the trees that name them, and writes a commit object pointing at the root tree. The diffs you read in a pull request are computed on demand by comparing two snapshots — they are a view, not the storage.
app.py is stored exactly once and referenced by both snapshots (gold). A full snapshot per commit is therefore cheap: only genuinely new content becomes a new object.The objection people raise here is storage: surely a complete snapshot per commit is wasteful? It is not, for the reason the gold arrows show. Content addressing means the name of an object is a hash of its bytes, so two identical files are one object. A one-line change to a file in a thousand-file repository writes exactly one new blob, one new tree per directory on the path to it, and one commit object. Everything else is shared by reference. (Git additionally packs objects into delta-compressed pack files on disk, but that is a storage optimization underneath the model, not the model itself.)
You already know this construction from evidence integrity and hash-chained audit logs: if an identifier is derived from content, then possessing the identifier is a claim about the content, and altering the content invalidates every downstream reference. A commit hash covers the tree, the message, the author, the timestamp, and the parent hashes — so it transitively covers all of history. That is why you can point an auditor at a commit hash and make a defensible claim about what shipped: rewriting any ancestor changes every descendant identifier, loudly.
Because every commit names its parents, the commit objects form a directed acyclic graph: edges point backward in time, from child to parent, and there are no cycles because a commit's hash covers its parent hashes — a commit cannot name a descendant that does not exist yet. An ordinary commit has one parent. A merge commit has two or more. The very first commit has none.
This has a consequence worth internalizing, because it dissolves the single most common panic in professional git use. History is not a list; it is a graph, and what you can see at any moment is not the graph but the part of it reachable by walking parent edges backward from some starting point. When someone says my commit disappeared, the accurate statement is almost always no ref I am currently looking through can reach my commit. The object is still in the database, addressed by its hash, until garbage collection eventually prunes objects that have been unreachable for weeks. Reachability, not deletion, is the thing that changed.
Git keeps a local, per-clone log of every position HEAD has occupied — the reflog. It is what makes the previous paragraph practically actionable rather than merely true: the hash of your unreachable commit is written down, so recovering it is a matter of pointing a ref at it again. This is also why the reflog is not a backup: it is local, and it expires.
Two properties fall out of the DAG that matter later in this course. First, ancestry is a partial order, not a timeline — two commits on separate branches have no ordering relative to each other, only a common ancestor, which is precisely the fact that makes merging a three-input operation rather than a two-input one. Second, commits are immutable. Nothing you do edits a commit. Operations that appear to (amend, rebase, squash) write new commit objects with different hashes and move a pointer to them, leaving the originals unreferenced. Hold onto that: it is the entire mechanical basis for module 2's argument about rewriting shared history.
Given the graph, the labels are almost anticlimactic. A branch is a file containing one commit hash — forty hexadecimal characters and a newline. That is the whole implementation. Creating price-alert-toggle copies no files, duplicates no objects, and takes constant time regardless of repository size; it writes a forty-one-byte file. Committing on a branch writes a new commit whose parent is the old tip, then updates that file to name the new commit. The branch moves; the commits never do.
HEAD is one more pointer, with one extra ability: it usually points at a branch ("I am on price-alert-toggle"), and then committing advances that branch. But it can also point directly at a commit, and that is a detached HEAD — not an error state, not corruption, just a pointer aimed one level lower than usual. Commit while detached and the new commit is created correctly and fully; HEAD advances to it; no branch does. Switch to another branch and nothing names your work anymore. The commits are fine. Your view of them is not.
HEAD names a branch, the branch names a commit, and committing advances both. Bottom: HEAD names a commit directly, so the commit made there (f2b1) is reachable only from HEAD — switch branches and nothing points at it. The work is not gone; it is unreferenced, and pointing any branch at f2b1 restores it.A teammate checks out a3f81c2 to reproduce a bug, gets absorbed, and commits two fixes. She then runs git switch main and her terminal warns her about leaving commits behind; she does not read it. Her work is now the f2b1 in the lower panel. The recovery is two facts long: the reflog holds the hash of f2b1, and a branch is a file naming a hash — so creating a branch at that hash makes the work reachable again, with no re-typing and no reconstruction. The panic was entirely about the mental model, not about the data.
Merging two branches is not a two-input problem. Git finds the merge base — the nearest commit reachable from both tips — and then performs a three-way comparison: base versus tip A, base versus tip B. For each region of each file, if only one side changed it relative to the base, that side's version wins automatically. If neither changed it, it stays. If both changed the same region to different things, git has no principled way to choose and stops with a merge conflict.
Read that rule twice, because it makes conflicts predictable rather than mystical. A conflict is not caused by two people touching the same file; it is caused by two people changing the same region of a file since their common ancestor. It is a statement about the edits, and git is reporting it rather than guessing.
That last observation is the seed of a much larger argument. If conflict probability rises with the number of regions each side has edited since the base, and edited regions accumulate with time, then divergence time is the hidden variable behind merge pain. A branch alive for four hours conflicts rarely; a branch alive for six weeks conflicts constantly, and — worse — defers every discovery about how it interacts with everyone else's work until the most expensive possible moment. Module 8 turns that observation into a team-scale policy. For now, hold the mechanism.
Symptom: a branch measured in weeks, a merge scheduled as an event, and someone saying we should freeze main while we land this. Why it hurts: conflict surface and integration risk both grow with divergence time, and the big-bang merge concentrates all of that risk at one moment, right before a release. Corrective: integrate to trunk at least daily, hide unfinished behavior behind a flag (module 6) rather than behind a branch.
Module 1 left you with a graph of immutable snapshots and a handful of movable labels. That is the substrate. This module is about the first stage of the factory line built on top of it: the moment your change stops being yours and becomes the team's. Two things happen there, and they are usually conflated. One is scrutiny — another human reads your intent before it becomes everyone's problem. The other is integration — your commits join the trunk, and you choose what shape they take when they arrive.
Both are confidence purchases, and both are routinely mispriced. Review gets credited with catching things it structurally cannot catch, which lets teams under-invest in the stage that actually can. And history strategy gets argued as taste when it is really a question about what you will need to reconstruct, months from now, under pressure — or in front of an auditor.
A pull request is a proposal: here is a set of commits, here is the intent behind them, please integrate them into trunk. Nothing about git requires it. It is a social protocol layered on top of the graph, and its value comes entirely from being the unit at which a second person engages with your work.
Which makes size the dominant variable. Review effectiveness does not degrade linearly with the number of lines under review; it falls off a cliff. Under roughly two hundred lines a competent reviewer holds the whole change in working memory, traces the paths, and asks about the case you did not handle. Past about four hundred, they are sampling. Past a thousand, they are reading for style and typos while telling themselves they are reading for correctness, and the approval they give is a social act rather than an epistemic one. Every team has watched a two-thousand-line pull request accumulate three approvals in under ten minutes.
Symptom: large pull requests approved quickly, review comments that are overwhelmingly about naming and formatting, and a team belief that "everything is reviewed." Why it hurts: the pipeline records a confidence purchase that did not occur, so nobody looks for the missing certainty elsewhere. Corrective: treat pull-request size as a design constraint — split by intent, land refactors separately from behavior changes, and put unfinished work behind a flag (module 6) instead of behind an ever-growing branch.
So the practical discipline is one reviewable intent per pull request. PR #412 in this course's running example adds the price-alert toggle: a new table, an endpoint, and the alert-evaluation path that consumes it. That is one intent, about three hundred lines. The formatting sweep the author was tempted to include — reindenting a neighboring module — would have tripled the diff and hidden the three lines that mattered inside two thousand lines that did not. Small pull requests are not a politeness; they are how you keep this stage's purchase real.
Be precise about what a human reading a diff can and cannot establish, because the gap is where teams lose money.
Review reliably buys three things. Design coherence: is this the right shape, in the right place, consistent with how the rest of the system works? That judgment requires context a machine does not have, and it is the highest-value thing a reviewer produces. Context transfer: after review, at least two people understand this subsystem, which is a hedge against the bus problem and a real accelerant on the next change. Intent scrutiny: does the code do what the description claims it does, and is the claim itself sensible?
Review cannot verify behavior. A human reading source does not execute it, does not enumerate its inputs, and cannot see what happens when alert_price is null on a row where the toggle is on. Reviewers reason about the paths they think to consider, which by construction excludes the paths nobody thought of — which is precisely where defects live. When a team says "it was reviewed" as a reason to trust behavior, they have double-spent: they bought design confidence and are attempting to pay for correctness confidence with the same coin.
| Question | Bought by review? | Who actually answers it |
|---|---|---|
| Is this the right design, in the right place? | Yes — this is review's core product | A reviewer with system context |
| Does anyone else now understand this code? | Yes | Review, as a side effect |
| Does the description match the diff? | Yes | A reviewer reading both |
| Does it behave correctly on the inputs we thought of? | No — humans do not execute code | Automated tests in CI (module 3) |
| Does it behave correctly on real traffic and real data? | No | Canary and progressive rollout (modules 5–6) |
| Does it still work after a rollback? | No | Migration design (module 7) |
Two things converge here. First, AI-generated code sharpens the distinction: a model produces plausible, idiomatic, confidently-wrong code faster than any human, which inflates exactly the signals reviewers use as heuristics for care. Guide Nº 08 treats that at length; the short version is that review of generated code must shift from does this look right to what did the author verify. Second, in change-control terms the pull request is your segregation-of-duties artifact: author and approver are distinct identities, the approval is timestamped, and the record ties an intent to the commit that shipped it. That is genuine audit evidence — and the rubber-stamp anti-pattern is what turns it into a control that exists on paper only, which is exactly how an auditor will characterize it if sampling shows 2,000-line diffs approved in ninety seconds.
Your branch has diverged from trunk and now must rejoin it. Git offers three shapes, and the choice is not about cleanliness — it is about what story your history will tell to the person reading it in eight months, who is almost certainly debugging.
A merge commit creates a new commit with two parents, preserving both lines of development and the true chronology of how work actually interleaved. Nothing is rewritten; every original hash survives. The cost is that trunk becomes a braid, and answering "what changed in this release?" means walking a graph rather than reading a list.
A rebase replays your commits onto the current tip of trunk, producing new commits with new hashes and the same content. History becomes linear and each commit is now expressed as a change against current trunk rather than against a stale base — which is genuinely useful for bisecting. The cost is that the originals are abandoned, and their replacements are, by hash, different commits.
A squash merge collapses the whole branch into one new commit on trunk. Trunk then reads as one intent per commit — e77b019 is "add per-user price alerts," full stop — and the branch's internal chatter ("fix typo," "address review," "actually fix typo") is erased. The cost is that intermediate steps are gone from trunk; reverting is trivial, archaeology inside the change is not.
Squash-merge pull requests into main, so trunk reads as one intent per commit and any change is revertible as a unit. Rebase freely on your own unshared branches, where rewriting costs nothing. Never rewrite history that others may have pulled. The one honest exception: long-running release branches whose true chronology is itself the record — there, merge commits earn their keep, because you need to be able to say what was integrated when, not just what the code became.
The golden rule deserves a derivation rather than an assertion, because module 1 already gave you the mechanism. Rebasing rewrites commits, meaning new objects with new hashes. If a colleague has already pulled the originals, their clone's refs point at commits your rewritten branch does not contain. When they next fetch, git sees two histories with no ancestry relationship and reports divergence; their attempt to reconcile re-applies the old commits alongside the new ones, and the same change lands twice. Nothing was corrupted and nobody did anything malicious — you simply changed the identity of objects other people were already referencing. That is why the rule is about shared history specifically: it is a statement about other people's clones, not about tidiness.
Concretely, then. The author opens PR #412 against main in the cellar-api repository: "Add per-bottle price alerts." One commit, a3f81c2, roughly three hundred lines. It adds an alert_price column to the existing wines table, an endpoint to set it, and a hook in the price-feed consumer that fires a notification when the market price crosses the threshold.
The reviewer's comment is four sentences long and changes the design: alerts are per-user, per-bottle — two people can watch the same wine at different thresholds. wines is shared reference data; putting a user's threshold on it means the second user overwrites the first. The author agrees, and 9d04e7b moves the threshold onto a new user_wine_alerts table keyed by user and wine.
Price the review catch honestly, because it is the clearest illustration of the factory line in the whole course. Caught here, it cost four sentences and about twenty minutes of rework. Caught after the change shipped, it would have cost a data-loss incident (one user's threshold silently clobbering another's), plus a schema migration to split the column into a table, plus a backfill to recover thresholds that had already been overwritten — which is to say, unrecoverable data plus a week. The same defect, two prices, a factor of roughly a hundred apart, and the only difference was which stage found it.
Note also what review did not catch: the null-threshold crash. Both the author and the reviewer read that comparison and neither imagined a row where the toggle is on but no threshold has been set yet — because that state does not exist in either person's mental model of the feature, only in the code's actual state space. A unit test found it in nine minutes. That division of labor is not a failure of the reviewer; it is the pipeline working as designed.
Module 2 ended with a division of labor: humans establish that a change is well-designed, and something that executes code establishes that it behaves. This module is about that something. Continuous integration is the practice of automatically building and checking every commit, and its name is about the word integration: the point is not that a robot runs your tests, it is that your work is combined with everyone else's continuously, so that integration problems surface in minutes instead of accumulating silently for weeks.
In confidence-budget terms, CI is the best deal on the line. It is the last stage where a defect costs machine time rather than human attention, and the first stage that can make claims about behavior. Two things determine whether a team actually collects that value: how fast the pipeline is, and how honestly the team reads its output.
A pipeline is a program that runs on every push and produces one bit: proceed, or stop. What it runs to produce that bit is your team's operational definition of "done" — not the definition in your process document, the one that actually gates code. If your pipeline runs unit tests but no dependency audit, then "done" means "the functions we tested work" and says nothing about a known-vulnerable transitive dependency, whatever your policy claims.
Here is the cellar-api pipeline, stage by stage, with the question each stage answers and its cost. Read it as a shopping list of confidence.
| Stage | Question it answers | Cost | Blocking? |
|---|---|---|---|
| Build (Docker image) | Does the code and its declared dependency set assemble at all, from a clean state? | ~2 min | Yes |
| Unit tests | Do individual functions behave as their tests assert, including on the edge cases we encoded? | ~3 min | Yes |
| Integration tests | Do the pieces work against a real Postgres and a stubbed price feed? | ~4 min | Yes |
| Lint and type check | Does the code obey the conventions and type contracts we have agreed not to re-litigate per review? | ~40 s | Yes |
| Dependency audit | Does anything we ship have a known published vulnerability? | ~30 s | Yes, above a severity threshold |
| Static analysis (SAST) | Does the code contain patterns associated with injection, secret leakage, or unsafe deserialization? | ~90 s | Yes, for new findings only |
| Publish artifact | Is there now exactly one immutable, addressable thing to promote? | ~30 s | n/a |
Each of those is a mechanical reviewer that never gets tired, never has a bad afternoon, and never approves something because it was in a hurry. That is the property that makes them cheap. It is also the property that makes them literal: they check what you encoded, exactly and only.
Pipeline duration is usually discussed as a developer-comfort issue. It is not. It is a behavioral control, and a slow pipeline changes what engineers do in ways that compound.
The chain runs like this. CI takes ninety minutes, so pushing costs an hour and a half of uncertainty. Rational engineers respond by batching: they accumulate more work per push, because the fixed cost is paid per push rather than per change. Larger batches diverge further from trunk, so conflicts get worse (module 1's mechanism, now at team scale). Larger batches also mean that when CI does fail, the failure implicates twenty changes rather than two, so diagnosis takes longer. Longer diagnosis means the trunk stays red longer, which blocks everyone, which encourages people to branch off an older commit to keep working — increasing divergence again. Every arrow in that loop points the same direction.
A defensible target for a service the size of cellar-api is about ten minutes from push to verdict, because that is roughly the boundary at which an engineer will wait for the result instead of context-switching. Hitting it is an engineering exercise, not a wish: run independent jobs in parallel so wall clock is the longest branch rather than the sum; cache dependency installation keyed on the lockfile; run the fast, broad checks first so obvious failures return in ninety seconds; and stage the genuinely slow suites — the twenty-minute end-to-end pack — to run post-merge or on a schedule rather than blocking every push. That last move is a real trade, and it should be made explicitly: you are buying speed by accepting that one class of defect will be caught slightly later.
A green check is a precise and modest claim: nothing we checked failed on this artifact. It is not a claim that the code is correct, that the feature works, or that production will be fine. The strength of the claim is entirely a function of what the checks encode — and it is systematically over-read, because a green check looks the same whether it is backed by four thousand thoughtful tests or by forty that assert the application starts.
Two gaps are worth naming explicitly. First, coverage measures lines executed, not cases considered. A test suite can execute every line of a threshold comparison and never once execute it with a null threshold, because coverage counts whether the line ran, not with what values. That is exactly the shape of PR #412's bug: full line coverage of the comparison, zero coverage of the state that broke it. Second, tests encode what someone imagined. Every test exists because a person thought of a case, which means the tests inherit the blind spots of the people who wrote them — the same blind spots that produced the defect.
Symptom: a test that fails roughly one run in ten with no code change, and a team norm of clicking "re-run failed jobs" and moving on. Bonus symptom: nobody can tell you which tests are flaky, only that "CI is flaky." Why it hurts: a flaky gate is a broken instrument. Once re-running is normal, the team's response to a red build stops being investigation and becomes retry — so a genuine regression gets re-run three times and shipped, and the pipeline's whole value evaporates. Note also that the re-run purchases nothing: it does not diagnose anything, it re-rolls the dice until you like the number. Corrective: quarantine on detection — move the flaky test out of the blocking set immediately so the gate is honest again, file it with an owner and a deadline, then fix the underlying nondeterminism (usually shared state, real clocks, or ordering assumptions) or delete the test. What you cannot do is leave it in the blocking set and tolerate the retries, because that trains everyone to disbelieve the gate.
The same logic applies to security stages, which fail in a distinctive way. A scanner is added, reports four hundred findings on legacy code, blocks everything, and is promptly set to non-blocking so work can continue. It is still in the pipeline. It still emits a report. Nobody reads it, and its findings do not gate anything. In control terms that stage has become a decoration: the control exists on paper, produces evidence of operation, and changes no outcome. The honest fixes are to baseline existing findings and block only on new ones, or to remove the stage and stop claiming the coverage — either is defensible; the status quo is not.
You have seen this exact failure in security operations: an alert that fires constantly and is universally muted has negative value, because it consumes the budget line and the audit answer that would otherwise go to a control that works. "Do you scan dependencies?" has the same relationship to truth as "do you have code review?" — the answer is yes, and the answer is uninformative. The follow-up that matters is what happened the last time it found something.
Return to PR #412. After the design change in 9d04e7b, the pipeline goes red. Here is what the run reports:
FAILED tests/test_alerts.py::test_evaluate_alert_when_threshold_unset
def evaluate(alert, market_price):
> return market_price < alert.alert_price
E TypeError: '<' not supported between instances of
E 'Decimal' and 'NoneType'
cellar/alerts.py:38: TypeError
1 failed, 214 passed in 3m 41sThis is what a valuable CI failure looks like: specific, reproducible, and about a state a human did not model. The new user_wine_alerts row is created when the user flips the toggle on, and alert_price is null until they set a number — a window of anywhere from three seconds to never. Every mental model in the review conversation had "user sets an alert at $80" as a single atomic act. The state machine disagreed.
The fix in c5512aa is four lines: skip evaluation when the threshold is unset. Now price the catch. In CI it cost nine minutes of machine time and about fifteen minutes of the author's attention. Shipped, it would have been an unhandled exception inside the price-feed consumer — not one user's alert failing, but the consumer crashing on the poisoned message, which stalls alert evaluation for everyone until someone is paged and drains the queue. Call it a two-hour incident at an unsociable hour, plus every alert that should have fired during it and did not. Same defect, two prices, four orders of magnitude apart.
The test that caught this did not exist before 9d04e7b; the author wrote it while implementing the new table, reasoning about the states a row could occupy. That is the actual mechanism by which CI gets stronger — not from a coverage mandate, but from each defect and each new state machine adding an assertion that encodes a case someone finally thought of. A pipeline is a ratchet on the team's accumulated imagination, which is also why deleting a flaky test rather than fixing it is a real, if sometimes correct, loss.
Module 3 produced a green check. Ask the precise question: green about what? Not about a branch, not about a commit message, not about your intentions. Green about one specific build output — a set of bytes with a digest. Every test that passed, every scan that found nothing, every hour a change soaked in staging is testimony about that object and nothing else.
Once you take that seriously, most of this module follows mechanically. If confidence attaches to bytes, then changing the bytes discards the confidence, and the entire promotion apparatus exists to move one immutable artifact through environments while varying only what must vary. This module covers the artifact, the version number that describes it to other people, the configuration that surrounds it, and the environment that is most likely to lie to you about it.
A build artifact is the immutable, addressable output of one build: a container image, a jar, a wheel, a static bundle. The addressable part is what matters. cellar-api:2.7.0 is a human-readable tag, and a tag is a movable pointer exactly like a git branch — someone can push different bytes under the same tag tomorrow. The digest sha256:9c4f… is the content address, and it names one specific set of bytes forever. When this course says "the same artifact," it means the same digest.
Now consider what a rebuild changes even when the source is identical. Dependencies resolve at build time, so a transitive package may have published a new patch release in the intervening ten minutes. The base image tag may have moved to pick up OS security updates. The compiler or interpreter version in the runner image may differ. Timestamps, build IDs, and file ordering vary. Two builds from one commit routinely produce different digests, and occasionally produce different behavior — which is precisely the case you cannot rule out by inspection.
Build once, promote everywhere: build one artifact, then move that identical digest through every environment, changing only configuration. Each stage a digest survives adds testimony to the same object, so by the time it reaches production you have a single accumulated case for one thing. Rebuild at any point and you restart the argument from zero while keeping the old verdict's paperwork.
The anti-pattern is easy to fall into because each step looks reasonable. The staging deploy job checks out the branch and builds it. The production deploy job checks out the tag and builds it. Both use the same Dockerfile, so people describe them as "the same build." They are not the same artifact, and the pipeline's central claim quietly becomes false.
This is a chain-of-custody break, and the analogy is exact rather than decorative. You examined exhibit A, documented findings about exhibit A, and then introduced exhibit B at trial on the strength of that examination — while everyone, in good faith, called both "the exhibit." The remedy is the same one evidence handling uses: identify the object by something intrinsic (its hash), record every transfer against that identifier, and treat any step that produces a new object as a break requiring re-examination. "It passed staging" is admissible only if staging tested this digest.
The corrective is small and mostly mechanical. Build in exactly one place — the pipeline, on merge to trunk. Deploy jobs take a digest as input and never a branch name or source path. Reference images by digest rather than by mutable tag in deployment manifests. And make the claim checkable: log the running digest at startup and expose it on a health endpoint, so "is production running the artifact we tested?" is answered by comparison, not by a deployment log narrative.
A version number is not a description of how much work you did. Semantic versioning — MAJOR.MINOR.PATCH — is a communication protocol, and each position is a promise to the people who consume your artifact about whether their existing integration will keep working.
The price-alert release is 2.7.0: a new endpoint and a new table, no change to any existing response or request shape. Any client written against 2.6.x keeps working untouched, which is exactly the claim a MINOR bump makes. The later fix for alerts double-firing on multi-vintage wines ships as 2.7.2 — same contract, fewer bugs.
The failure mode is treating the number as marketing. A team removes a response field, calls it 2.8.0 because "3.0 is reserved for the redesign," and every consumer whose dependency policy auto-accepts minor upgrades — which is the normal policy, and the whole reason the convention has value — takes a breaking change without review. The damage is not the version string; it is that you have taught your consumers your numbers do not mean anything, after which they must review every upgrade by hand and the protocol is dead between you.
An internal service deployed only by you does not have external consumers, so people ask whether semver is theater. Partly — but the number still identifies which promotable artifact you are talking about during an incident, and the discipline of asking "is this breaking?" before assigning it is itself a design review. Keep the discipline; drop the ceremony. And be aware that "we have no consumers" is usually false once a mobile client, an internal dashboard, or a partner integration exists — all of which pin versions somewhere you have not looked.
If one artifact runs everywhere, everything that differs between environments must live outside it. Configuration is that everything: endpoints, connection strings, timeouts, feature-flag service addresses, log levels, resource limits. It is injected at deploy time — environment variables, a mounted config object, a configuration service — and never baked into the image.
| Setting | dev | staging | production |
|---|---|---|---|
DATABASE_URL | local Postgres container | staging RDS, 8 GB, anonymized subset | prod RDS, multi-AZ, 1.4 TB |
PRICE_FEED_URL | local stub, fixed prices | vendor sandbox, replayed feed | vendor production feed |
FLAG_SERVICE_URL | local file provider | staging flag service | production flag service |
LOG_LEVEL | DEBUG | INFO | INFO |
| Image digest | sha256:9c4f… — identical in all three | ||
Secrets are configuration with an additional rule: they belong in neither the artifact nor the repository, and they are fetched at runtime from a secret manager with an identity the workload holds by virtue of where it runs. Baking a database password into an image is not merely a policy violation; it makes the artifact unpromotable by construction, since the credential differs per environment and the image cannot. The two failures are the same failure.
Staging exists to answer a real question — does this artifact start, apply its migrations, and integrate with its dependencies without anything obviously falling over? — and it answers it cheaply. The mistake is asking it a question it cannot answer: will this behave correctly in production? It cannot, and the reasons are systematic rather than incidental, which means they do not go away with more effort.
| How staging lies | What it looks like in cellar-api | Compensating control |
|---|---|---|
| Data volume and shape | 8 GB anonymized subset vs. 1.4 TB; no user owns 400 bottles; no wine has six vintages | Query plans checked against production statistics; canary on real data (module 5) |
| Traffic shape | Synthetic smoke traffic, no diurnal peak, no thundering herd when the price feed publishes | Load test against a recorded production trace; watch saturation metrics at canary |
| Integrations | Vendor price-feed sandbox: different latency, different error rates, never rate-limits you | Explicit failure-injection tests; treat first real-vendor contact as its own risk event |
| Scale and topology | One node; no rolling deploys, so no mixed-version window ever occurs | Contract tests between adjacent versions; canary is where mixed-version issues actually surface |
| Time and accumulation | Reset weekly, so nothing ages: no expired sessions, no year-old rows, no leaked connections | Long-running soak in a pre-production tier, or accept and detect in production |
Symptom: staging has hand-applied fixes nobody remembers, a config that drifted from production two years ago, and a data set someone edited manually to make a demo work. Tell-tale phrase: "it's always like that in staging." Why it hurts: divergence is invisible and monotonic — every manual intervention makes staging predict production slightly less well, and no alarm ever fires when its predictive power reaches zero. Worse, the team keeps paying for it and keeps citing it. Corrective: provision staging from the same infrastructure code as production, forbid manual changes (make it re-creatable and re-create it periodically), and periodically diff the two configurations as a scheduled job rather than a good intention.
The honest posture is not to chase an impossible copy of production. Matching production's data volume and traffic in a second environment costs roughly what production costs, and even then the integrations are still sandboxes and the users are still fake. Instead: keep staging cheap and close, be explicit about what its silence does and does not mean, and buy the residual confidence where it is actually available — on real traffic, at small exposure, with a fast retreat. That is a canary, and it is the subject of module 5. Notice the shape of the argument, which recurs: when a stage cannot supply a kind of confidence at any price, the answer is not to over-invest in it but to identify the cheapest stage that can.
You have one tested artifact and a fleet of machines currently running the previous one. Getting from here to there is the deployment, and there are exactly three shapes it can take, distinguished by how much of the fleet runs the new version at once and what happens to traffic while that is true.
Every argument about deployment strategy that goes badly is an argument in which the axes were never named. Name them and the conversation becomes an engineering comparison with a defensible answer per service.
Three properties determine everything that matters about a deployment strategy.
Blast radius — if this version is bad, how many users touch it before you stop? This is the axis people mean when they say "risk," and it is the one you can most directly buy down.
Rollback speed — from the decision to retreat, how long until no user is served the bad version? Note that this is a different question from blast radius: a strategy can expose everyone instantly and still retreat instantly, or expose almost nobody and retreat slowly.
Cost — infrastructure (do you need a second fleet?) plus operational complexity (does this require traffic-splitting infrastructure, per-version metrics, and someone to read them?).
Everything else — vendor features, house habits, what the last team did — is downstream of these three. And notice the fourth property that the axes deliberately exclude: detection. None of these strategies tells you the new version is bad; they only determine who is affected and how quickly you can undo it once you know. Detection is bought with observability and, at canary, with the deliberate decision to look.
A rolling deployment replaces instances in waves: take some fraction out of the load balancer, start the new version, wait for health checks, put them back, repeat. It is the default nearly everywhere because it needs no extra capacity beyond a small surge headroom and no special traffic infrastructure.
Its blast radius grows monotonically with each wave — a quarter of users, then half, then everyone — and its rollback is a second rolling deploy in the other direction, so retreat takes as long as the advance did. If a bad version takes six minutes to roll out, it takes about six minutes to roll back, during which some users are still being served the bad version.
During every rolling deploy, version N and version N+1 are running simultaneously, serving the same users, against the same database. This is not a rare edge case; it is a property of every rolling deploy you have ever done. Your code must therefore be compatible with its own immediate neighbor in both directions — N+1 must tolerate data written by N, and N must tolerate data written by N+1, because the load balancer will route the same user to both within one session.
A blue-green deployment runs two complete environments. Blue serves all traffic on the current version; green is brought up on the new version and verified while receiving no user traffic; then traffic is switched — a load-balancer target change or a DNS update — in one atomic step. Retreat is the same switch in reverse, and it is the fastest rollback available: seconds, with the previous environment still warm and identical.
The costs are honest and large. You pay for double capacity during the overlap, which for a stateful service can mean doubling the expensive part of your bill. You must keep green genuinely identical to blue, which is the same discipline problem as snowflake staging, one environment further along. And the atomic switch means blast radius goes from zero to everyone with no intermediate step — you cannot expose 5% of users to a blue-green cutover, by construction. What you gain is not reduced exposure; it is the ability to end the exposure almost instantly.
It earns its cost where retreat speed dominates: a cutover during a compliance-sensitive window where the pre-agreed remediation is "revert in under sixty seconds," a release event with an audience, or a service whose state makes gradual replacement genuinely hard. It is waste on a low-traffic internal service where a six-minute rolling rollback would have been fine.
How instances are actually replaced — schedulers, health checks, connection draining, load-balancer target groups — is infrastructure mechanics and belongs to Guide Nº 16. What matters here is the shape of the exposure and the shape of the retreat, both of which are the same whether the fleet is virtual machines, containers on a scheduler, or serverless function versions with a traffic-shifting alias.
A canary deployment routes a small, deliberately chosen slice of real production traffic to the new version, holds it there, and compares that slice's behavior against the rest of the fleet before proceeding. It is the only strategy that buys detection rather than merely governing exposure, and it is the cheapest honest answer to the question staging structurally cannot answer: does this work on real data, real traffic, and real integrations?
Three things must all be true for a canary to be worth its complexity. The slice must see real, representative traffic — a canary that only internal users reach is a staging environment with better branding. Observability must be per-version: if you cannot compare error rate, latency, and the metrics specific to this change between canary and baseline, you have nothing to read. Promotion criteria must be written down before the deploy starts, as comparisons with thresholds and a duration: error rate within 0.2 points of baseline, alert-evaluation p95 within 15%, price-feed consumer lag not growing, over 30 minutes.
Symptom: a canary stage in the pipeline with a fixed wait — "soak for 30 minutes" — followed by automatic promotion, and no dashboard anyone opened. Why it hurts: elapsed time is not evidence. Without a comparison and a threshold, the stage buys nothing while costing real money and real user exposure, and worse, it lets the team believe the detection stage exists. Corrective: pre-commit the metrics, the thresholds, and the halt action, and make promotion conditional on them; if nobody will define the criteria, delete the stage and stop paying for the illusion.
The canary's limit is what it can see, which is why module 6 exists. Infrastructure-level traffic splitting cannot choose which users are in the slice on a business-meaningful basis — you get whichever sessions the load balancer happens to route — and it exposes users to the whole artifact rather than to one feature. Moving that control from the infrastructure layer into the application, where cohorts are chosen and individual behaviors are switched, is what feature flags do.
Module 5 ended at the limit of infrastructure: a canary controls which instances run new code, but it cannot control which users see a new behavior, and it exposes the whole artifact rather than one feature. Both limits dissolve once the switch moves from the fleet into the application.
The move is small in implementation and large in consequence. It separates two events that most teams experience as one, and once separated, launch stops being a moment and becomes a dial you turn while watching.
A deploy is an infrastructure event: new bytes are running on servers. A release is a product event: some user can now do something they could not do before. Teams that couple these — where deploying the code is what makes the feature visible — inherit two problems at once. Every deploy becomes a launch, so deploys acquire ceremony, approval, and fear, which pushes them toward being rarer and larger, which makes each one riskier, which justifies more ceremony. And every launch becomes an infrastructure gamble, so the marketing date and the deploy risk are welded together: if the deploy goes badly on launch morning, both fail simultaneously.
Symptom: a release calendar, a change-freeze window, deploys scheduled for Thursday evenings, and the phrase "we can't deploy, marketing hasn't announced it." Why it hurts: it couples two independent risks and makes both worse — the deploy is now urgent and unrehearsed, and the launch now depends on infrastructure behaving on one specific evening. Corrective: put user-visible behavior behind a flag so code can ship continuously and boringly, and the launch becomes a configuration change made in daylight by whoever owns the product decision.
A feature flag is the decoupling: a runtime conditional whose value comes from configuration outside the artifact, evaluated per request and per user. The code ships; the behavior waits.
if flags.enabled("price_alerts_enabled", user=current_user):
return render_alert_controls(wine, alert)
return render_wine_detail(wine)Notice what this is, in the vocabulary of module 4: configuration. It varies per environment and per user, it is injected from outside, and the artifact is identical whether the flag is on or off. The difference from an environment variable is that it changes without a deploy — which is precisely what makes it the fastest control on the line.
cellar-api:2.7.0 deploys to production with price_alerts_enabled off for everyone. The endpoint exists, the table exists, the consumer hook exists, and no user sees anything at all. That is a dark launch: the riskiest part of shipping — new code in production — has happened and been observed under real traffic, with zero user-visible change and therefore zero product risk. Everything after this is a configuration decision.
Then the dial turns. Staff first: real users with real cellars who will tell you directly. Then 5%, held against criteria. Then 50%. Then everyone.
user_wine_alerts — the flag drops back to staff in seconds while the artifact stays exactly where it is. The fix ships as 2.7.1 through the normal pipeline, and the dial resumes.Cohort design is a sampling problem, and it is where most rollouts quietly fail. The instinct is to start with the friendliest users — staff, beta volunteers, the customers who like you. That cohort is maximally forgiving and minimally informative: staff have small tidy cellars, beta volunteers use the feature the way it was demoed, and neither group contains the account with 400 bottles across six vintages that will break you. Use them for the first rung, where you want direct qualitative feedback, and then make the 5% rung representative: random by account, stratified so that heavy users, large cellars, and every integration path are proportionally present.
This is selection bias with a deploy attached, and you have prosecuted it before: a sample chosen for convenience does not support inferences about the population, no matter how clean the results look. "5% showed no errors" is a claim about the 5%, and it generalizes only to the extent the 5% resembles everyone. The corrective is the same one you would demand of an expert's methodology — say how the sample was drawn, and check that the strata you care about are actually in it.
Rank the retreat mechanisms this course has covered by time-to-safety. A rolling redeploy of the previous version: minutes, and every second of it serves some users the bad version. A blue-green cutover: seconds, but it reverts the entire artifact including the six other changes that shipped with it. A flag flip: sub-second, no deploy, no build, no pipeline, and it reverts exactly one behavior for exactly the population you specify.
That last property is the underrated one. The flag is the only control with surgical blast radius: you can disable price alerts for the 5% cohort while leaving every other change in 2.7.0 running for everyone. Nothing else on the line can do that, which is why the correct first question during an incident is not "can we roll back?" but "is this behavior flag-gated, and can we turn it off?"
The flag only reverts what the flag gates, and only if the off-path still works. Two failures follow. Ungated blast: the migration that shipped alongside the flagged feature is not behind the flag — schema changes never are — so flipping off stops the new behavior and leaves the schema exactly where it is (module 7's whole problem). Decayed off-path: after eight months at 100%, nobody has executed the off-branch since the rollout; the code it calls has been refactored, the template it renders references a variable that no longer exists, and the tests for it were deleted as dead. The flip that was supposed to be your instant retreat throws a 500 for everyone. An untested rollback path is a rollback you do not have.
The corrective for the second failure is unglamorous and works: keep the off-path in the test matrix for as long as the flag exists, and periodically exercise it in a lower environment. If that maintenance is not worth its cost, that is a legitimate answer — and it means the flag should be removed, not left as decoration.
Every flag is a permanent branch in your control flow and a doubling of one dimension of your test matrix. Three live flags in the same code path mean eight combinations, of which you probably test two. That is the interest rate.
Flag debt is the accumulated stock of flags that have finished their job and stayed: at 100% for a year, no owner, no expiry, and a name nobody recognizes. It looks like clutter and it is not. A stale flag is a live control surface attached to unexercised code, editable by anyone with access to the flag console, with no deploy, no review, and — in most setups — considerably less audit trail than a code change. The industry's most-cited trading disaster turned on exactly this shape: a flag repurposed for a new behavior while an old code path that read the same switch remained deployed. One toggle, catastrophic loss, in under an hour. The lesson is not that flags are dangerous; it is that a flag with no owner is a deployment mechanism with no change control.
The discipline that works is boring and must be enforced at creation, because nobody removes flags voluntarily later. Every flag gets an owner (a person, not a team), an expiry date, and a removal ticket filed the day it is created. Flags past expiry appear on a list someone reviews. Removal means deleting the conditional and the dead branch, not setting a default and leaving the code — a flag whose branch remains is still in your test matrix and still a live control surface. And distinguish genuine long-lived switches (kill switches, licensing gates, per-tenant configuration) from release flags: the former are permanent by design and should be named and governed as configuration, so the expiry rule applies cleanly to everything that remains.
price_alerts_enabled was created on the day a3f81c2 was pushed, with an owner, a 60-day expiry, and ticket CELLAR-889 filed at the same time. It reached 100% eleven days later. CELLAR-889 was closed nineteen days after that by a four-line pull request that deleted the conditional, the off-branch, and the two tests that covered the disabled state. Total flag lifetime: thirty days. That is what a repaid loan looks like.
Everything so far has been about not getting here. This module is about being here anyway: production is wrong, users are affected, and someone has to decide what to do in the next ten minutes. The decision has a correct structure, it is knowable in advance, and the single most common way teams get it wrong is assuming that rollback is always available.
It often is not, and the reason is always the same. Code is stateless and swappable. Data is not.
Two options, and a third that beats both when it applies.
Flip the flag — if the bad behavior is flag-gated and the off-path is valid, this is over in seconds with surgical blast radius. Always ask this first; module 6 covers when it fails.
Roll back — redeploy the last-good artifact. Fast (you have the digest, it is already built and tested), well-rehearsed if you deploy often, and strictly limited to what the artifact controls. It reverts code. It does not revert data, schema, messages already published, emails already sent, or anything a third party has already been told.
Roll forward — fix the defect and ship it through the normal pipeline. Slower by the length of your pipeline, and it requires writing correct code under pressure, which is exactly when you are worst at it. But it is the only option when the previous version can no longer run correctly against current state — and, importantly, its cost is a direct function of pipeline speed, which is why module 3's ten-minute target is an incident-response investment and not a comfort feature.
Rolling back code is a solved problem: the previous artifact exists, is tested, and is one deploy away. Rolling back data is not a problem with a solution — it is a problem with a category error inside it. Code has no memory; replacing it restores a previous state exactly. Data has momentum: between the deploy and the rollback, users did things, and those things are now facts.
Three distinct ways a migration blocks retreat, worth separating because they fail differently:
user_wine_alerts lets two users hold different thresholds on the same wine; wines.alert_price has exactly one slot per wine. A rollback here does not crash; it silently shows the wrong threshold to one of them and stops honoring the other's alert entirely.The second is the dangerous one, because nothing tells you it is happening. Errors are a gift; silent wrongness is a liability that compounds until a customer notices, which in a system that sends financial alerts could be weeks.
The schema-design side of this — normalization, keys, what belongs in a shared reference table versus a per-user association table — is Guide Nº 06's territory, and PR #412's review comment was a textbook instance of it. What this module adds is the deployment consequence: a schema decision is also a decision about how reversible your next six releases are. The design review and the rollback plan are the same conversation held at two different times, and holding it once, early, is cheaper.
Expand and contract is the pattern that keeps retreat available for as long as physically possible. Instead of changing the schema in one step, you make the change additive, run both shapes in parallel while the old version might still be needed, and only remove the old shape once you have committed.
Two disciplines make the pattern actually work. Each step is its own deploy, with its own soak — collapsing dual-write and switch-reads into one release destroys the property you were buying, because you never occupy the safe middle. And the backfill must be verified before reads switch: compare row counts and spot-check values, because switching reads onto an incomplete backfill converts "some users lost their alerts" into a defect you will discover by complaint.
Symptom: an incident runbook whose first step is "roll back the last deploy," with no field anywhere recording whether that release was rollback-safe. Why it hurts: the reflex fires exactly when a migration has made it unsafe, and the result is old code silently misreading new data on top of the original incident. Corrective: make rollback safety a required, stated field on every release — safe, or not safe with the reason and the alternative — so the 2 a.m. question is a lookup. On the pipeline side, add a check that runs the previous version's test suite against the migrated schema, which turns the claim into a test.
Here is the whole course, compressed into forty minutes of a Tuesday night.
02:04. An alert fires: notification-send volume is four times baseline. Users on the price-alert feature are receiving duplicate alerts — some the same alert six times. cellar-api:2.7.1 went to 100% at 18:30 the previous evening. It contained the missing index, and it also added multi-vintage support: a wine with several vintages now evaluates each vintage's price separately. The join it introduced produces one row per vintage per alert, and the send loop does not deduplicate.
02:07 — question one: is it flag-gated? Partly, and this is the important subtlety. price_alerts_enabled gates whether a user has alerts at all, so flipping it off does stop the duplicate sends immediately — at the cost of disabling the entire feature for everyone. That is the correct first move to stop the bleeding, and the on-call makes it at 02:09. What the flag cannot do is unsend the 41,000 duplicate notifications already delivered. Stopping and undoing are different verbs.
02:14 — question two: has state moved? Yes, decisively. 2.7.1 was the release that switched reads to user_wine_alerts — step 4 of the migration. Since 18:30, roughly 900 new alert rows have been written, including 60-odd wines where two or more users hold different thresholds. Rolling back to 2.7.0, which reads wines.alert_price, would not crash. It would show one of those users the other's threshold, silently drop the rest, and evaluate everyone's alerts against stale numbers. The rollback would appear to succeed and would be actively wrong.
02:20 — the decision. Roll forward. The fix is a DISTINCT on the alert-send query plus a uniqueness guard keyed on alert and evaluation window — about fifteen lines with a regression test that reproduces the six-vintage case. It goes through the normal pipeline: review at 02:38, CI green at 02:51, canary at 02:56, promoted at 03:26 after the criteria hold. 2.7.2 is at 100% by 03:40, the flag is re-enabled at 03:45, and a correction notice goes out in the morning to the 2,300 affected users — a forward action, because the sends cannot be retracted.
Symptom: "The pipeline takes twenty minutes — I'll just patch the container and restart it." Why it hurts, in three registers. Engineering: the patch is untested, written by someone at 2 a.m., and lives on a machine that will be replaced by the next deploy — so the bug returns at an unpredictable future moment with no connection to any change anyone made. State: production is now running bytes with no digest, no commit, and no build record, which invalidates every claim your pipeline makes about what is running. Change control: an untracked production change with no review, no approval, and no record is a finding in any change-management framework you operate under, and the incident timeline is precisely where an auditor will look. Corrective: roll forward through the pipeline, always — and notice that this is the moment the ten-minute pipeline from module 3 pays for itself. The team that can ship a tested fix in twenty minutes never needs the hotfix; the team whose pipeline takes ninety minutes will reach for it, which is how a twenty-minute incident becomes a three-day one.
The incident-management side of this night — paging, roles, comms, the postmortem, and what SLOs would have said about the error budget — belongs to Guide Nº 09. And the reason the duplicates cannot be unsent is Guide Nº 17's territory: the notification path is at-least-once delivery into a system with real-world side effects, so deduplication has to be an explicit property of the sender, not an assumption. The bug here was precisely that 2.7.1 broke the sender's uniqueness assumption without anyone noticing that an assumption existed.
One more thing to extract from the drill: the canary did not catch this, and the reason is instructive rather than embarrassing. The 5% cohort at the time contained no wine with multiple vintages — a data shape present in about 4% of cellars overall and, by chance, in none of the sampled accounts. That is not a canary failure; it is the honest limit of sampling, and the corrective is the one from module 6: stratify the cohort over the data shapes that change behavior, and add the six-vintage case to the test suite so the next release buys that confidence at the cheapest stage instead of the most expensive one.
Seven modules of mechanism. This one is about judgment: how to look at a whole delivery system — yours, a client's, a company you are evaluating in diligence, a team you have just inherited — and say something true about it within an hour.
Three things make that possible. A structural fact about branching that explains most of what goes wrong upstream. An evidence base that settles the argument about whether speed and safety trade off. And a method: walk the line, price each stage, and find the missing cheap purchase that an expensive stage is currently covering for.
Module 1 established that conflicts are overlapping edits since the merge base, so conflict probability rises with how many regions each side has touched — which rises with time apart. Promote that from a fact about two branches to a fact about a team and you have the argument for trunk-based development: integrate small changes to main continuously, keeping divergence in the range of hours to a couple of days.
The cost of a long-lived branch is not only mechanical conflict. It is deferred discovery. Everything you would have learned about how your change interacts with everyone else's — the shared helper someone refactored, the schema someone altered, the assumption someone inverted — is postponed until the merge, and it all arrives at once, typically the week before a release, usually to the person least equipped to adjudicate it. Both costs compound with time, and the second is the larger.
The obvious objection is that unfinished work cannot go on trunk. It can, and every mechanism you need is now in hand: put the incomplete behavior behind a flag (module 6) so it ships dark; keep the change small enough to review honestly (module 2); rely on a fast pipeline to tell you within minutes whether integration broke something (module 3). Trunk-based development is not a branching policy adopted on its own — it is what becomes possible once those three exist, which is why teams that try to adopt it without them find it unpleasant and conclude it does not work.
Symptom: a branch measured in weeks, a merge treated as a scheduled event with its own risk assessment, and someone proposing a code freeze on main to make it land. Why it hurts: conflict surface and deferred discovery both compound, and the merge concentrates all of it immediately before a release, when the team has the least slack. Corrective: integrate daily behind flags; if a change genuinely cannot be decomposed, that is usually a signal about coupling in the architecture, and it is worth naming as such.
The DORA metrics are four measures, deliberately paired: two describe throughput, two describe stability, and the pairing is the entire methodological point.
| Metric | Measures | What it answers | Gamed by |
|---|---|---|---|
| Deployment frequency | Throughput | How often do changes reach production? | Splitting one release into ten deploys |
| Lead time for changes | Throughput | From commit to running in production, how long? | Measuring from merge instead of from commit |
| Change-failure rate | Stability | What fraction of deployments cause a production failure? | Narrowing the definition of "failure" |
| Time to restore service | Stability | When it breaks, how long until users are healthy? | Starting the clock at acknowledgement |
The headline finding from the research program, replicated across years of survey data, is the one that matters for how you argue: the highest-performing teams are better on all four simultaneously. Throughput and stability correlate positively. They are not endpoints of a slider.
The mechanism is everything this course has covered. Small batches make failures rare and diagnosis fast. Fast pipelines make lead time short and roll-forward viable. Flags decouple exposure from deployment so failures are small and reversible. Automated tests catch defects at the cheapest stage. Every one of those improves both a throughput metric and a stability metric, because they are the same engineering. A team that ships forty times a week has not accepted more risk; it has driven the cost of each unit of confidence low enough that buying it frequently is affordable.
Symptom: a leadership dashboard showing deploys per week and lead time, with change-failure rate absent, unmeasured, or defined so narrowly that only total outages count. Why it hurts: the two throughput metrics can be improved by pure fragmentation — deploy the same work in ten pieces — so a number that only ever goes up gets celebrated while stability degrades in a place nobody is looking. Corrective: never show a throughput metric without its stability partner on the same screen, and define "failure" before you start measuring, in writing, including degradations and rollbacks rather than only outages.
Every anti-pattern in this course, consolidated for recognition. The last column is the diagnostic one: each pathology is a symptom of confidence not purchased at some stage, and it tells you where to look.
| Name | Symptom in the wild | Corrective | Missing purchase |
|---|---|---|---|
| Long-lived branch / big-bang merge | Branches measured in weeks; a merge with its own risk assessment; requests to freeze main | Integrate daily behind flags; decompose, or name the coupling that prevents it | Continuous integration, in the literal sense |
| The rubber stamp | 2,000-line pull requests approved in ten minutes; review comments almost entirely about naming | One intent per pull request; refactors land separately | Design review — recorded as bought, never made |
| Re-run until green | "Just re-run it, that one's flaky"; nobody can name which tests are flaky | Quarantine on detection, with an owner and a deadline; then fix or delete | Behavioral verification — the gate is a dice roll |
| Scan-stage theater | A security stage set to non-blocking with hundreds of unread findings | Baseline existing findings and block on new ones, or remove the stage and stop claiming it | Vulnerability control — evidence of operation without effect |
| Rebuild per environment | Separate build steps in the staging and production deploy jobs; no digest recorded anywhere | Build once; deploy jobs take a digest; expose the running digest | Every earlier stage's testimony, invalidated |
| Snowflake staging | Hand-applied fixes nobody remembers; "it's always like that in staging" | Provision from the same code as production; forbid manual changes; diff on a schedule | Integration confidence — the environment predicts nothing |
| Deploy-as-release | Change-freeze windows; Thursday-evening deploys; "we can't ship, marketing hasn't announced" | Flags: ship code continuously, release by configuration in daylight | Exposure control, which forces ceremony to substitute |
| Flag debt | Dozens of flags at 100% for a year; no owners; nobody dares delete one | Owner, expiry, and removal ticket at creation; removal deletes the branch | Cleanup discipline — an unreviewed control surface remains |
| Prod hotfixing | Patching running containers; deploying from a laptop; "the pipeline is too slow for this" | Flag first, then roll forward through the pipeline; fix pipeline speed on a Wednesday | Pipeline speed, paid for in untracked change |
| Rollback assumed possible | Runbook step one is "roll back"; no release records whether it is safe | Rollback-safety as a required release field; test the previous version against the migrated schema | Migration safety — discovered at 2 a.m. |
| The unmeasured canary | A fixed 30-minute soak, automatic promotion, no dashboard opened | Pre-commit metrics, thresholds, and a halt action; otherwise delete the stage | Detection — the stage exists but buys nothing |
| Deploy-count vanity | Throughput on the leadership dashboard, change-failure rate absent or narrowly defined | Pair every throughput metric with its stability partner; define failure in writing first | Honest measurement — the instrument only points up |
Read down the last column and a pattern emerges that is worth more than the individual rows: nearly every entry is an expensive stage compensating for a cheap one, or a cheap stage that looks purchased and is not. That is the audit method, in embryo.
Here is the payoff, as a method you can run in an hour on a team you have just met.
Step one: walk the line and list the stages. Commit, review, CI, artifact, environments, deployment strategy, flags, rollback. Just get the inventory, without judging it.
Step two: for each stage, ask three questions. What question does this stage answer? What does it cost — in time, money, and human attention? And what would it cost to answer this same question one stage later? The third question is the one people have never been asked, and it produces the most information.
Step three: find the stage that answers no question. Every pipeline has at least one — a stage that runs, emits output, and changes no decision. It is usually a scanner, a soak, or a manual approval that has never once been withheld. Name it, and either give it teeth or remove it.
Step four — the diagnostic move: find the expensive compensation and work backwards to the missing cheap stage. Expensive compensations are loud and easy to spot because people complain about them. A heroic on-call rotation compensates for absent detection — no canary, or one nobody reads. Weekend release parties compensate for absent exposure control — no flags, so every release is an event. A beloved, elaborate staging environment with a manual QA pass often compensates for absent automated tests. A change advisory board that meets weekly compensates for a pipeline that cannot be trusted to gate anything itself. In each case the expensive thing is real work being done by real people, and it is doing the job of a stage that would have cost a fraction as much.
You already know how to do this. A delivery pipeline is a control environment: a set of controls, each with a stated objective, an owner, evidence of operation, and — the part everyone skips — evidence of effectiveness. The questions transfer directly. Is this control designed to address the risk it claims? Does it operate as designed, or is it a report nobody reads? What compensating control is carrying the load where a control is missing? Is the evidence contemporaneous and tied to the object it describes, or reconstructed afterward? That last one is the digest check from module 4, and it is the same question as asking whether an exhibit is the one that was examined. The vocabulary here is different and the discipline is yours.
This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.