How We Ship
Every change takes the same path: branch off main, do one small thing, open a PR, merge back the same day or two. What differs is how much process the change earns on the way, and that is decided by one question, asked once: if this is wrong, can we take it back?
This page is the answer in three parts, in the shape of Rumelt’s kernel: what actually goes wrong, the policy we hold because of it, and the habits that carry the policy out.
The Diagnosis#
Two failure modes. They look like opposites and they have the same cause.
The load-bearing fact: most changes are reversible, a few are not, and we were treating them the same. Uniform process on non-uniform risk is what makes a team slow and unsafe at the same time. If you can revert it, roll it back or turn it off with a flag, review before merge buys delay and nothing else. If you cannot, review after merge buys a record of who was present at the accident.
Three facts about this codebase make it concrete:
- A domain decision gets more expensive every week.
RequestStatusis imported by 24 files today. On the day it was written, getting it wrong cost one file. Now it costs 24, plus a migration, plus deploy ordering. Nothing became irreversible; it became expensive at a rate nobody could see at the time. - CI migrates an empty database. A migration that breaks on populated tables passes every check we have and fails at deploy. The tests cannot see this one, so a person has to.
- GitHub cannot enforce any of this for us. Branch protection and rulesets are not available on this repo’s plan. Every rule here is held by people, which means an unenforced rule is not a weak rule, it is a fiction.
Bad looks like: a 900-line PR titled “roadmap improvements”, opened Thursday, approved Monday with “LGTM”, containing a schema change nobody saw.
Good looks like: four PRs over two days. A 40-line value object, reviewed in five minutes. A migration on its own, reviewed in five minutes. Then the repository and the UI, merged on green CI by the person who wrote them.
The Guiding Policy#
Paint solid lines only where we cannot recover, and say out loud that everything else is dashed.
Three commitments follow, and each one rules something out. A policy that forbids nothing is not one.
1. Reversibility before permission. Given the choice between adding a gate and making the change undoable, make it undoable. A flag, an expand/contract migration, a revert that takes a minute. This rules out the reflex of answering every incident with a new approval step, which is how teams accumulate process that outlives the problem.
2. Gate the smallest version of the change, as early as possible. Guardrails belong on the 40-line domain PR, not the 600-line feature PR that inherits it. This rules out review at the end, when the cost of being right is highest and the reviewer is least able to pay it.
3. Review is for growth. Approval is only for one-way doors. This rules out using review as a status check, and it rules out treating an unreviewed PR as a rule broken.
Coherent Action#
The five solid lines#
These are the changes where a second pair of eyes is required before merge. Each one names the crash, because people follow a rule they understand and route around one they do not.
| Solid line | The crash |
|---|---|
Domain model. A value object, entity, domain error or repository interface in packages/domain | The wrong concept spreads. You get no stack trace, you get a codebase that argues with itself |
| Migrations and deletions | CI migrates an empty database, so the failure appears first in production |
| Authentication and authorization | Silent when wrong. You learn about it from someone else |
| Payments | Money moves and does not move back on its own |
| API contracts other teams or clients depend on | You cannot un-ship a contract once something is calling it |
Everything else is dashed. Open the PR, get CI green, merge it. No approval, no waiting, no apology.
Two named signers per line, not one. Either can clear it, same working day. A line that depends on one person’s calendar becomes a queue, and a queue is how a solid line erodes: people start routing around it, and then it is not a line at all.
Domain first, then outward#
Run the work as a sequence of PRs, in this order. This is the habit that makes small PRs a consequence rather than a discipline.
| Order | PR | Typical size | Line |
|---|---|---|---|
| 1 | Domain: VO, entity, error | ~40 lines | Solid |
| 2 | Schema: one migration | ~20 lines | Solid |
| 3 | Repository and queries | Medium | Dashed |
| 4 | API contract, if others consume | Small | Solid |
| 5 | UI and wiring | The bulk | Dashed |
The gated PRs are the two smallest ones. A value object with its tests has no consumers yet, so reviewing it costs five minutes and reverting it costs nothing. Everything downstream inherits a decision already made and needs no gate. That is the whole trick: the guardrail lands where review is cheap and the work is still soft.
First in the sequence does not mean a blocking prerequisite. Nobody waits for the domain PR to merge before starting the UI. It only has to be small enough to clear the same day, which it will be if it is genuinely only the domain.
Model the states you have today. Domain-first is not an invitation to design for use cases that do not exist, which Keep It Simple argues against. The distinction lives inside the value object: adding a state is dashed, because existing consumers keep working. Renaming or removing one is solid, because all 24 files and the stored rows have to move together.
Make migrations reversible instead of careful. Expand, backfill, switch reads, and drop the old column in a later PR. Each step reverts on its own. Never put a destructive migration in the same PR as the code that depends on it. See Database Migrations.
Branch and commit#
Each branch is one unit of work that starts from main and returns as a single squashed commit. Two in flight is normal; one open for a week is a smell. Never commit straight to main.
Branch names are type/short-description in kebab-case: feat/roadmap-releases, fix/request-back-button, docs/pd-onboarding, design/glide-request-keyboard, chore/bump-drizzle. The context lives in the PR, not the branch name.
Commits follow Conventional Commits, type(scope): subject:
feat(roadmap): add release notes field to initiatives
fix(web): keep the back button on the request detail page
Two rules matter more than the format:
- Small and atomic. One commit is one self-contained change. If you cannot describe it in a line without “and”, it is two commits.
- Green at every commit. Any commit on the branch should check out and work. This is what makes a revert clean, and a clean revert is what lets a line be dashed.
When AI wrote a meaningful part of the change, add the Co-Authored-By: trailer.
The PR#
Small is the whole game. A PR a reviewer can hold in their head gets read carefully and merged fast. A large one gets skimmed, which is where bugs walk in.
- One reason to exist. “Rename this and also add that” is two PRs.
- Reviewable in one sitting. If you cannot review it in ten minutes, neither can anyone else.
- Self-reviewed first. Read your own diff. You will catch the debug log, the stray file, the half-named variable.
- CI green before review. Never hand a red PR to a person.
- AI review addressed. Resolve the real issues and say why you skipped the rest.
We do not set a line-count limit. It is the obvious move and it gets gamed within a month. Small PRs come from the sequence above, not from a cap.
The description is five things by default: what changed, why, before and after, risks, and the way back. The repo template is those five plus one Solid line block you fill in only when you cross one. It exists so risk is stated out loud, not so the PR is long. Here it is filled in for a small flag-guarded feature:
It is short, the risk is named even though it is low, and the way back is one line a stressed on-call can follow. Merge with squash so the branch collapses to one commit on main, and delete the branch.
Make the lines visible#
Since GitHub cannot enforce the solid lines here, CI makes them visible instead. A PR touching packages/domain, a migration directory, auth or payments gets labelled and commented with who should look. Detection, not permission.
The label is the paint. Its absence is the other half of the paint: no label means no reviewer is needed, so merge it. Most teams state the solid lines and never state the dashed ones, and people fill the silence by assuming everything is solid.
The ramp for new engineers#
The line moves with tenure, because the risk is in the mental model, not the change.
- Days 0 to 30. Everything goes to your designated reviewer before merge. Not supervision, pattern transfer. The fastest way to learn what belongs in this codebase is to have someone read your code while you are still building the model of it.
- Days 30 to 60. Merge freely anywhere you have already shipped. Reviewer for the solid lines, and for your first change in an area you have not touched.
- Days 60 to 90. Same rules as everyone else.
Phases are not automatic. Your lead makes the call at the end of each one, in the code walk, and tells you explicitly. If you are not ready at 30 days the phase extends, with no penalty. The timeline is a guide.
The code walk#
This is what replaces gate-based quality, and it is the reason review can stop being a gate without quality falling.
Every engineer does one in their 1:1: once a month, 30 minutes, engineer leads, lead asks questions. Pick one or two representative PRs from the month, not your best work, and share them a day before.
- 0 to 5 min. One-minute summary per PR: the problem, what you built, what shipped.
- 5 to 20 min. Walk the code. The lead asks why it is structured this way, what you would do differently now, how someone new would read it, what you were unsure about.
- 20 to 28 min. Two or three specific observations tied to the actual code.
- 28 to 30 min. One concrete carry-forward. “Write cleaner function names” is not one. “Every function in the payment module should be nameable in under five words” is.
The lead keeps a short running note per engineer: what was walked, the carry-forward, and whether it showed up next month. It is a mentoring record, not a performance file. This is how leadership stays close to quality without standing in anyone’s way.
The self-review bar#
Before you merge anything, gated or not:
- Consistent. Someone new to this module feels at home. It follows the patterns already here.
- Scoped. It does what the definition doc said, no more. Extras belong in the next ticket.
- Instrumented. You can tell it is working from a dashboard, without SSH.
- Readable. A teammate understands it in six months without you in the room.
- Tested. The edge cases from the definition doc have tests. If you would not bet money it works without running it by hand, write more tests.
Repainting#
Watch the crossings, not the compliance. The signal that matters is not whether people fill in the template. It is whether a migration ever landed without a second pair of eyes and nothing happened. One unremarked crossing takes the paint off the whole road, because it proves the line was decoration.
Rules built for five become speed bumps at fifty. Five solid lines is right at this size. Revisit the list at around fifteen engineers, and be as willing to remove a line as to add one. An over-signed road teaches people to ignore signs, including the one that mattered.
Related: Guardrails · Feature Flags · Database Migrations · Testing & TDD · Releasing & Rollback · Rumelt’s Kernel · Freedom Needs Paint