How to retire
A removal is the one change you can't have another go at. The feature got read far more than it was ever written, it's depended on by more callers than the author ever knew about, and you're pulling it out in a world that keeps calling the old number long after you hung up. So put the proof of safety into the signals and into reversible steps. Don't lean on the engineer's conviction that nothing uses it. Any dependency you only believe is gone is one some caller will find again the moment the lights go out. This is MANIFESTO.md turned on the far end of the lifecycle, where a removal is itself an issue that runs the whole pipeline, and this guide is how you do the destructive part without getting hurt. The objective hasn't changed, but here it is again for this phase: minimise what a tired engineer must hold in their head to remove this safely, subject to bounding the blast radius of the irreversible deletion and of anything that still depends on it. When two of the moves below pull against each other, you don't settle it with a slogan. You settle it on who controls the input and how wide the blast radius is. None of it is free.
Prove nothing still depends on it before you remove it
Removal is gated on a fact you establish from signals, not a feeling; "I think nothing uses it" is a hostile claim until the telemetry agrees.
The thing you're about to delete has callers you've never met. A cron job in some other team's repo, a dashboard query, a client three releases behind. "Nothing uses this" is a claim about a world you don't control, so treat it as hostile until you've proven otherwise (IV) and make it observable instead of guessing (XVIII). Add a log line or a counter at the entry point, then watch live traffic across a whole business cycle - month-end, the quarterly batch, the seasonal spike - and grep the dependency graph for references, and only once you've done all that is removal a decision backed by a signal rather than a hope. The reference you didn't search for is the page at 2am.
- A code path: an access counter that has read zero across a full cycle, and a graph search that finds no callers vs "I searched the main repo", a sample that misses every other consumer.
- An API endpoint: access logs that name every caller still hitting it vs the assumption that the deprecation email was read and acted on.
- A table: query telemetry and foreign-key references that show it cold vs a schema you eyeballed once and declared unused.
Tension: This pulls against the cost of instrumenting and waiting (XXI, XVII). You can't watch forever, and a rarely-used path may stay quiet for a quarter and then fire. Size the observation window to the real usage cycle, not the calendar you'd prefer. For a genuinely high-traffic surface a week of zero is proof. For a year-end job it isn't.
Ask yourself: What signal, over what window, would convince a stranger that nothing depends on this, and have I actually got it, or am I about to delete on faith?
Deprecate additively, with a window, for consumers you cannot force to upgrade
Mark it deprecated and keep serving the old contract through a published sunset window; the hard cut against clients three versions back is the breakage.
A contract has two sides and you only own one of them. The consumers you can't reach - old mobile builds, partner integrations, the script someone wrote in 2019 - are the ones that break loudest when you pull your side without warning. Name the boundary and version the contract (XV). Announce the deprecation, return a sunset header or a warning, and keep the old behaviour answerable through a window that's long enough for the slowest consumer you have to support to migrate. Optimise the whole thing for reversibility (XX) by making the deprecation a state you can extend rather than a date you detonate. Strict with new callers, tolerant of the old ones still draining away.
- A public API: a
DeprecationandSunsetheader plus a dated removal in the changelog vs a 410 shipped the day you decided, breaking every client that never saw the memo. - A config key: read the old name and the new one through the window, logging each use of the old vs renaming it and letting every unupdated deployment crash on boot.
- A library function: a deprecation annotation and a major-version removal vs deleting the symbol in a patch release that downstream pinned to.
Tension: This trades against the duty to forget and against simplicity (XX, XXI). A sunset window is dead weight you carry, and an indefinite one is just the old thing with an apology attached. Put a real date and an owner on the window, instrument who is still using the old contract, and cut once the telemetry from section I says the stragglers are gone, not a day later.
Ask yourself: Who is the slowest consumer I'm obliged to carry, when does their window actually close, and is the deprecation a state I can extend or a cliff I've already committed to?
Contract in reverse, the mirror of expand-then-contract
Switch reads off it, stop writing to it, let it go cold, then drop; every intermediate state stays runnable and reversible, never the drop-and-rename cliff.
The expansion that brought this thing in was sequenced so you could back out cheaply, and its removal deserves the same discipline (XX). A removal done in one transaction is a bet you've forbidden yourself from losing. If the drop is wrong there's no toggle back, only a restore from backup in the middle of an incident. Run the contraction as steps that each leave a working, revertible system. Divert reads to the replacement and confirm parity, stop new writes and watch the thing stop changing, let it sit cold long enough to trust the silence, and only then drop. Each step is a config change you can undo, not a migration you can only get out of by writing another one.
- A column: stop reading it, then stop writing it, then drop after it has been cold and observed vs a single migration that reads-renames-drops and strands every in-flight transaction.
- A service: drain traffic to the replacement, idle the old fleet but keep it bootable, then decommission vs powering it off and discovering the one caller that mattered.
- A feature flag's losing branch: route 100% to the keep side, bake, then delete the dead branch vs ripping out both branches and the flag in one commit.
Tension: This pulls against simplicity (XXI) and the cost of carrying two states at once. Each intermediate state is clutter, and a half-finished contraction left to rot is its own zombie. Sequence the steps tightly with a named owner driving them to completion, so "cold and observed" is a checked gate with a date, not a project that stalls at step two forever.
Ask yourself: When the drop turns out to be wrong, is the recovery a config toggle or a restore-from-backup, and is every step between here and gone independently revertible?
Separate the decision to delete from the deletion
A reviewable removal plan, what goes, what is checked, the backout, then a second human on the irreversible drop; the fused find-and-delete one-liner erases the wrong thing at 2am.
Drop, truncate, and rm -rf are the catastrophic verbs of this phase, and the danger comes from fusing the decision to the effect (XI). A command that decides what to delete and deletes it in one breath can't be reviewed without paying for the mistake, so the very step that most needs scrutiny gets none. Split them. The removal plan is a pure artefact - the list of exactly what goes, the signals that gate it, the backout procedure - and you can review it to death at zero cost. The execution is the thin, approved step that runs it. Put a second pair of eyes on the irreversible drop (XXV), because it's humans, not the runbook, that create the safety at the edge of the cliff.
- A production table: a reviewed plan naming the table, the backup taken, and the restore command, signed by a second engineer vs a
DROP TABLEtyped live from memory. - A bucket of files: a dry-run that lists what the deletion would touch, checked before the real run vs a glob in a shell that matches one directory too many.
- A batch of records: a generated manifest of ids reviewed before execution vs a
DELETEwhoseWHEREclause nobody read against the data it would actually hit.
Tension: This trades against speed and against simplicity (XXI). Four-eyes on every deletion is friction, and most deletions are trivial anyway. So scale the ceremony to the blast radius: a dead local branch needs no review at all, but dropping the orders table needs a named approver, a dry-run, and a backout. Decide the threshold by what is irreversible and how wide it reaches, not by how busy you happen to feel.
Ask yourself: Could the person running this drop review the decision without being able to cause the damage by reviewing it, and for anything irreversible and wide, who is the second human and what is the backout?
Kill the source of truth, not a copy, and leave nothing orphaned
When you remove the owner of a fact, remove or re-home its derivations and drain the in-flight obligations first; the cache or consumer that outlives its source is the bug.
A fact in a system has one owner, and then a trail of derivations, caches, search indexes, read replicas, materialised views, all of which exist only to serve it (XIV). Delete the owner and leave the derivations behind, and now you have got a confident copy of a thing that no longer exists, answering queries with a ghost. So removal has to trace the trail. Remove or re-home every derivation, and before the source goes dark, drain what it still owes (XII): the queued jobs that will dereference it, the open handles, the requests still in flight mid-transaction (VII). Release what the thing acquired and finish off its obligations before you let it exit, otherwise the orphan becomes the incident.
- A user record: purge or re-key the cache, the search index, and the audit derivations that pointed at it vs deleting the row and serving a stale name from a cache for a month.
- A queue's producer: stop enqueuing, let the consumers drain the backlog to empty, then remove both vs killing the producer and leaving messages no one will ever process.
- A primary store: repoint or tear down the read replicas and the export pipeline that fed off it vs decommissioning the primary and leaving a replica serving reads forever.
Tension: This pulls against the bounded window (VI) and against just getting the thing gone. Draining in-flight obligations can take longer than you want it to, and a drain with no deadline is really a decommission that never finishes. Give the drain a deadline (VI): wait for the backlog to clear inside a bounded window, then escalate the stragglers explicitly rather than blocking the removal on a queue that will not empty.
Ask yourself: What derivations and consumers point at this fact, have I re-homed or removed every one, and has the in-flight work it still owes drained to empty within a deadline?
Honour the duty to forget; destroy the data you must
Soft-delete for recoverability, but data you are obliged to destroy needs a true hard-delete path and an encoded retention policy; "keep forever" is breach surface and legal liability.
Not all removal is meant to be reversible, and confusing the two classes is its own kind of bug. Most deletions want a soft-delete and a grace period so a mistake stays recoverable (XX). But some data you are obliged to destroy, once past a retention limit, or on a deletion right being exercised, or when the lawful basis ends, and for that class "soft-deleted but still on disk" is not deleted at all. It is hostile data you are now liable for (IV), widening the blast radius of any breach and the surface of any subpoena. So encode the retention policy as a TTL the system enforces, hold the minimum that the law and the purpose require (XVI), and build a real hard-delete path that actually reaches the backups and the derived copies, not just the live row.
- An expired record: a TTL the store enforces and a hard-delete that sweeps backups vs a
deleted_atflag that leaves the personal data sitting in every snapshot indefinitely. - A deletion request: a pipeline that erases the row, the derivations, and the exports within the promised window vs a soft-delete that satisfies the UI and breaches the obligation.
- A retired dataset: a classified, time-boxed destruction with the policy in code vs an "archive" that quietly becomes a permanent copy nobody is allowed to forget.
Tension: Hard-delete and recoverability are flatly opposed, and getting the choice wrong either loses data you needed or keeps data you were obliged to destroy. Resolve it by data class, decided up front in the removal plan and not at the keyboard. Recoverable internal state gets a soft-delete and a grace period. Regulated or rights-bearing data gets a hard-delete path and an enforced TTL. Default neither to keep-forever nor to shred-on-sight.
Ask yourself: Which class is this data, recoverable or duty-bound-to-destroy, and for the duty-bound part does the hard-delete actually reach the backups and the derived copies, or just the row the UI shows?
Delete the code, the config, the flag, and the docs together; subtraction is the win
A half-removal that leaves dead code, a zombie flag, or a stale doc is worse than none; the state you do not have cannot be wrong.
The whole point of retiring a thing is to make the system smaller, and the deepest win in the whole manifesto is that the state you remove can never be misconfigured, never drift, never lie (XXI). A removal that deletes the feature but leaves its dead code, its now-meaningless flag, its config keys, and a doc that still describes it has not shrunk the system at all. It has added a trap. The next reader runs into a flag that does nothing, a function no caller reaches, a runbook for a service that is already gone, and a name or a doc describing a thing that no longer exists is worse than silence, because it actively misleads (XXII). So finish the subtraction. Pull the code, the config, the flag, and the documentation in the same change, so nothing left behind names the absence.
- A retired feature: the handler, its flag, its config, its tests, and its doc section all gone in one removal vs a dead code path guarded by a flag that is wired to nothing.
- A removed setting: the key deleted from the schema, the defaults, and the deployment manifests vs a config value every environment still sets and nobody reads.
- A decommissioned service: its runbook, its dashboards, and its on-call rotation retired with it vs a page in the wiki that sends the next incident toward a host that no longer answers.
Tension: This pulls against the reversibility you built into sections II and III. Deleting code, config, and docs in one stroke is the opposite of the cautious cold-then-drop sequence, and doing it too early removes the rollback you might still need. So order it. Keep the seams while the thing is still draining and being watched, and do the full subtraction only once it is provably cold, so the cleanup follows the proof rather than racing it.
Ask yourself: After this change, is there any code, flag, config, or doc left that still names the thing I removed, and would the next reader be misled into thinking it still exists?
Confirm it is gone, and stays gone
Watch for the resurrection: the caller that reappears, the flag re-added, the table a migration recreates; an alert or a test that fails if the retired thing comes back is what keeps it retired.
A thing isn't retired the moment you delete it. It's retired when it stays gone, and the usual fate of something you removed is that it comes back. A revert restores the flag. A copied migration recreates the table. A new feature reintroduces the endpoint because nobody knew it had been killed on purpose. So push the proof past the deletion (XVIII): alert if traffic shows up at the supposedly-dead endpoint, fail the build if the forbidden symbol comes back, and encode the absence as an invariant that a test enforces (XXIV) so the removal can be reproduced and defends itself. A removal nobody can verify stayed done is one that quietly undoes itself.
- A killed endpoint: an alert that fires on any request to the retired route, catching the client or the revert that brought it back vs assuming silence means success.
- A deleted table: a migration test that fails if the schema recreates it vs a teammate's branch quietly reintroducing it three sprints later.
- A removed flag: a lint rule or a test that rejects the flag name reappearing vs the zombie flag springing back in a copied config block.
Tension: This trades against simplicity and the cost of carrying guards forever (XXI). An alert and a test that defend an absence are themselves state you now maintain, and a removal-guard outliving the risk it guarded is exactly the kind of cruft this phase exists to delete. Keep the guard while resurrection is a live risk, give it an owner and a sunset, and retire the guard too once the thing could not plausibly return.
Ask yourself: If this comes back next quarter, by revert or by a migration that recreates it, what fails loudly, and have I encoded the absence as a check rather than trusting that everyone remembers it was deliberate?
The through-line
Every move here is the manifesto's one move dressed up for retirement: push correctness out of human vigilance and into the structure of the system. At the end of the lifecycle that means pushing the proof that this is safe to remove out of "I think nothing uses it" and into the signals and the reversible steps, so the thing goes and stays gone without dragging anything else out with it. Gated on telemetry, deprecated with a window, contracted in reverse, the decision split off from the irreversible drop, no derivation orphaned, the duty to forget honoured, the code and config and flag and docs all subtracted together, and an alert that fires if it ever comes back. The next engineer should be able to trust that this is gone without holding in their head why it was safe to remove. Not because they remembered to check, but because the signals proved it, the steps were reversible until the last one, and the path back was never left lying around to take by accident.
Remove the thing for the engineer who will meet its absence cold, long after you've forgotten it existed. Make the deletion something the signals earned, not something you believed; make the irreversible drop hard to take by accident and impossible to take alone; and leave nothing behind that names the thing you killed or quietly brings it back to life.