The 3-Line Diff That's Harder to Undo Than Your Rewrite
One-way door decisions in software, Hyrum's Law, and a two-question test for what becomes permanent

Two pull requests are waiting for your review.
PR #1 rewrites the pricing module. 600 lines changed, same public interface, full test coverage.
PR #2 adds one field to a public API response. Three lines.
Which one deserves more of your attention?
Most review habits say PR #1. It's bigger, it looks riskier, and it touches money. But if PR #1 goes wrong, you revert it. If PR #2 goes wrong, there may be nothing to revert to, because by the time you notice, mobile apps, partner integrations, and another team's dashboards are already reading that field.
This post is about that gap: why the hardest decisions to undo in software are usually small and visible, and a practical test for spotting them before they lock.
Key Takeaways
Size is a poor predictor of permanence. Large internal changes are often easy to revert. Small, visible details often aren't.
Reversibility is a count, not a category. Per Hyrum's Law, every observable behavior eventually gets depended on, and the number of dependents only goes up after you ship.
Two questions predict permanence: Who can see it? and What does it forget?
Lossy writes are one-way on day one, even with zero users. You can migrate a schema, not information you never stored.
You can keep doors open on purpose: expose slowly, shrink the visible surface, store facts, use expand and contract, and version every boundary. Each has a cost.
Why "one-way vs two-way doors" isn't enough for code
The framework comes from Jeff Bezos's Amazon shareholder letters. One-way doors are decisions that are hard or impossible to reverse, so they deserve slow, careful thought. Two-way doors are cheap to undo, so they should be made fast.
It's useful, and it fixes a real problem: teams that treat every choice as irreversible move at committee speed. But applied to software, it usually has two blind spots.
It labels decisions at decision time. Database engine: one-way. Logging library: two-way. But reversibility changes after you ship.
It only covers things people recognize as decisions. The ones that make it into a design doc. The field name in PR #2 never gets that treatment.
Hyrum's Law: reversibility is a count of observers
Hyrum Wright, an engineer at Google, put words to something every API maintainer learns the hard way. Once a system has enough users, your documented contract stops mattering much, because every behavior users can observe ends up depended on by someone. Google's book on software engineering treats this as a central lesson about maintaining code over time.
Apply that to decisions and the conclusion is direct: every observable behavior is a door that starts closing the moment someone can see it. Nobody has to decide to make it permanent. Usage does that for you.
The two-question permanence test
Question 1: Who can see it?
Think of it as a ladder. The higher the rung, the more people you'd have to coordinate with to change it, and at the top, you can't even find them.
| Rung | Who depends on it | What changing it costs |
|---|---|---|
| Just you | A branch, a local experiment | A commit |
| Your codebase | Merged code other modules call | A refactor and a review |
| Other teams | Internal consumers, shared tables, event streams | A migration plan and a deadline they agree to |
| Strangers | Public APIs, SDKs, installed apps, file formats, URLs | A new version, and usually keeping the old one alive |
The direction matters most: after you ship, things only climb. An internal field gets picked up by the data team. A temporary endpoint gets a partner integration. Re-ask the question whenever the audience is about to grow.
Question 2: What does it forget?
If a change destroys information, it's one-way at every rung, including the bottom one. That's the door to slow down for, even on a side project with no users. More on this below.
Notice what isn't part of the test: line count, how long the meeting was, or how "architectural" the change sounds.
Where one-way doors hide in a typical API
Here's the kind of response that gets approved in a minute:
// GET /subscriptions/48213
{
"id": 48213,
"status": "active",
"renews_at": "2026-11-01 09:00:00"
}
Run the first question against each field:
idis a sequential integer. Clients will store it in URLs, logs, tickets, and analytics. They can also guess the next one. Changing how IDs are generated now touches systems you can't see.statusis an undocumented string, so some client is doingstatus == "active". When you add"paused", every installed copy of that app decides on its own what paused means.renews_athas no offset. Every client guesses the time zone, and those guesses are now part of your contract.
A version that keeps more doors open:
// GET /v1/subscriptions/sub_7Hk2pQ
{
"id": "sub_7Hk2pQ",
"status": "active",
"renews_at": "2026-11-01T09:00:00Z"
}
The ID is opaque: clients still store it, but they can't do arithmetic on it, so you keep control over how IDs are generated. The timestamp is unambiguous. The version lives in the path. And status should be documented as an enum that may grow, with clients told to handle unknown values. That last part is a promise in your docs, not in the JSON, which is exactly why it has to be written down.
The same pattern shows up in places that don't look like API design at all:
Error message text that clients match with string comparisons.
Result order from a query with no
ORDER BY. It's whatever the query plan produces, and plans change when indexes do.Default page sizes, which clients silently assume.
Timing. A synchronous endpoint that callers now depend on finishing before they continue.
Lossy writes: the one-way door with zero users
Everything above gets more permanent as more people can see it. Lossy writes don't wait for an audience.
Take local timestamps. In places that observe daylight saving time, clocks fall back once a year, and an hour happens twice. In the US in 2026, that's the early hours of November 1:
-- Lossy: which 01:30? The first one, or the one an hour later?
placed_at TIMESTAMP -- '2026-11-01 01:30:00'
-- Lossless: the instant, plus where it happened
placed_at_utc TIMESTAMPTZ, -- the exact instant (PostgreSQL)
placed_tz TEXT -- 'America/New_York'
A row written with the first schema during that hour is ambiguous forever. No later migration can recover which hour it meant, because the information was discarded at write time.
Other lossy writes that look harmless:
A status column updated in place. You'll never know when it changed or what it was before.
A stored total without its line items. When a pricing bug surfaces, you can't recompute who was affected.
An email normalized on write with the original discarded.
You can migrate a schema. You can't migrate information you never kept.
Five ways to keep a door open (and what each costs)
1. Decide fast, expose slow. Make internal calls quickly, but delay the moment strangers can see them: feature flags, internal-only endpoints, one early-access client with an explicit "this will change" agreement. Cost: slower feedback from real usage.
2. Shrink what can be seen. Return only what clients need. Give errors stable codes and treat messages as human text. Use opaque pagination cursors. You can even make unpromised behavior visibly unreliable: Go 1 defined map iteration order as unpredictable, even across repeated loops over the same map, so no program could quietly depend on it. Cost: more design upfront, slightly less convenience for consumers.
3. Store what happened, derive the rest. UTC instant plus original zone. An append-only status history next to the current status. Raw input next to the normalized value. Cost: storage and some complexity.
4. Add before you remove. Use expand and contract (also called parallel change). Never rename something visible in a single deploy:
-- PostgreSQL. Each step ships as its own deploy.
-- 1. Expand: add the new column, nullable
ALTER TABLE orders ADD COLUMN placed_at_utc TIMESTAMPTZ;
-- 2. Dual-write: app code now fills both columns
-- 3. Backfill old rows in batches. Rows from the repeated
-- DST hour get flagged for review, not guessed.
-- 4. Switch every read to placed_at_utc. Wait a release cycle.
-- 5. Contract: stop writing the old column, then drop it
ALTER TABLE orders DROP COLUMN placed_at;
Cost: several deploys instead of one, and a temporarily messier codebase. The real risk is never shipping step 5. Ticket it with a date.
5. Put a version on every boundary. In URLs, in a schema_version field on events, in file formats. Cost: versions invite you to support several at once, so decide up front how long old ones live.
It happens to the best: doors that closed, and one held open
Make's tab character (1976). Stuart Feldman chose tabs to mark command lines while building Make at Bell Labs, and soon knew it was a poor choice. Within a few weeks he had about a dozen users, mostly friends, and didn't want to break their files. GNU Make added an opt-in alternative,
.RECIPEPREFIX, in 2010. The default is still a tab.Excel's leap year. Excel treats 1900 as a leap year to stay compatible with Lotus 1-2-3. Microsoft's support documentation says fixing it would shift almost every date in existing workbooks by one day.
Referer. The HTTP header is misspelled in the spec and always will be. The newerReferrer-Policyheader is spelled correctly, so both spellings live on.Go's map order, held open on purpose. By defining iteration order as unpredictable in Go 1, the language designers made sure an order nobody promised could never become a dependency.
Add this to your PR template
### Permanence check
- **Permanent:** anything here that someone outside this codebase
can see, or that forgets information
- **Reversible:** everything else
Most of the time the first line is empty and you can merge with confidence. When it isn't, you've found the part of the change worth slowing down for, and your reviewer knows where to look.
FAQ
What is a one-way door decision in software engineering?
A one-way door decision is a technical choice that is expensive or impossible to reverse once made. In software, the most common one-way doors aren't big architecture choices but small details that other people start depending on, such as public field names, error formats, ID schemes, and data written in a lossy format.
What is Hyrum's Law?
Hyrum's Law, named after Google engineer Hyrum Wright, says that with enough users of an API, every observable behavior of your system will be depended on by someone, regardless of what your documentation promises. Response ordering, error text, timing, and even bugs can become implicit contracts.
How do you know if a technical decision is reversible?
Ask two questions. Who can see it: just you, your codebase, other teams, or external users? And does it forget information? The more people who can observe it, the harder it is to change. If it destroys information, it's irreversible even with no users at all.
Is choosing a database a one-way door decision?
It's often treated as the textbook example, and switching databases is genuinely expensive. But the database engine sits behind your code, so with a clean data-access layer it can be migrated. Public field names, lossy data formats, and ID schemes that clients store are frequently harder to change, because the dependents are outside your control.
What is the expand and contract pattern?
Expand and contract, also called parallel change, is a way to make breaking changes safely. You add the new structure alongside the old one, write to both, backfill, move all reads to the new structure, and only then remove the old one. Each step ships separately, so every step stays reversible.
How do you avoid breaking changes in a public API?
Expose as little as possible, use opaque IDs and cursors, give errors stable codes, document enums as open to new values, version the API from the start, and evolve it additively with expand and contract. Most importantly, decide what's permanent before release, because after release your users decide for you.
Related reading
Your Worker Read the Past. Then It Saved It.: why wrong data, once written downstream, never heals on its own.
You Added an Index. Your Query Is Still Slow.: how the query planner makes the choices that also decide your unordered result order.
AI Won't Take Your Coding Job. It Will Change It.: why judgment about what's safe to ship is becoming the job.
Your Best Work Erases Its Own Evidence: how to make good engineering judgment visible before it disappears.
The bottom line
Nothing in software is permanent on the day you write it. Permanence is something other people give your code, one dependency at a time. The only exception is forgetting, which is permanent the moment it happens.
Stop asking how big a decision is. Ask who can see it, and what it forgets.
Adam Jaber is a software engineer who writes Simply Explained: complex topics, made simple. No jargon, no hype.




