The rewrite cut over in 12 days. The cut list took four more weeks.
I replaced a 62-day-old Python service with Go. Cutover in 12 days, old repo dead in 20. Then almost every feature I swore I'd cut came back, one scoped slice at a time. The real math of a rewrite, with receipts.

12 days. First commit of the Go rewrite to production cutover. 2026-05-20 to v0.1.0 on 2026-06-01. 105 commits.
20 days. Until the old Python repo took its final commit, ever. The Go repo was already ~220 commits deep by then.
62 days. The age of the service being rewritten. Two months, total.
I know the dogma. Joel Spolsky called the ground-up rewrite the single worst strategic mistake a software company can make. I've quoted that line at my own teams. Then I did one anyway, on my own voice-to-text pipeline, and it worked, and the git history explains exactly why.
But the 12-day number flatters me, so here's the honest version. The heavy daily commits ran from 2026-05-20 to about 2026-06-27. Call it five and a half weeks. Twelve days of that was the cutover. The other four weeks went to rebuilding almost everything the rewrite design swore it would cut. Both halves are the lesson.
A 62-day-old legacy system
The old service was transcript-optimizer. Python: FastAPI plus arq workers plus Redis plus Postgres with pgvector, all on a single GCE VM. 83 Python files, 12,218 lines in src/, another 14K lines of tests, and a ~3.5K-line React dashboard on the side.
Its commit histogram is the diagnosis. About 145 commits in the first 13 days. Then a trickle: 4 commits in all of April, and after mid-April, nothing. I had stopped touching my own two-month-old service because I was afraid of it.
Then two incidents in one week.
2026-05-06. I deleted the Cloud SQL instance the service depended on. With its backups. Nobody noticed for about five days, until I finally saw the polls failing. The damage entry, in the old repo's own decision log:
"Accept full history loss: ~3099 historical recordings, embeddings, correction logs... gone. Recreating from migrations is fine because the service is a personal playground."
(transcript-optimizer, docs/decisions.md, 2026-05-11)
2026-05-11. The firefight day. The same log names the failure mode precisely:
"a single sync HTTP download (the inner SSL path is
http.client, not async httpx) blocked arq's event loop for the entire 5-15 minute fetch, freezing all cron polls and producing the 41-minute 'hang then SIGSEGV via glibc heap corruption' failure mode we saw today."
The patch that day: 6 worker replicas, each pinned to max_jobs=1. WhisperX disabled. The LLM pipeline collapsed to a single provider. It held. It was also the moment the workaround got named a workaround, in writing.
Nine days later I wrote the rewrite design.
The test: is the failure language-structural?
The design names four confirmed failures: monolith coupling, operational fragility, hard to extend, and Python itself. Only the last one justifies a rewrite, so it deserves the scrutiny. Verbatim:
"Sync IO inside the async event loop produced a 41-minute hang → SIGSEGV failure mode; Zoom token refresh silently broke;
BrokenPipeErrorfrom connection pooling. The 6-replicamax_jobs=1scale-out is a workaround for a language-level problem."
(docs/superpowers/specs/2026-05-20-voice-to-text-rewrite-design.md, §3)
Here's the mechanism, because this is where most rewrite arguments go soft. In asyncio, the event loop is a shared, invisible resource. Every dependency in your tree is one synchronous call away from starving it. The offending code wasn't even mine. It was an inner SSL path inside a library, quietly using http.client instead of async httpx. One blocking download froze every cron poll in the process for the length of a 5-15 minute fetch. You can fix that instance. You cannot fix the class, because the class is the concurrency model. Auditing every transitive dependency for hidden sync IO, forever, is not engineering. It's archaeology on a treadmill.

Go's answer is structural. Blocking IO inside a goroutine is normal and free. There is no event loop to starve. The design puts the consequence in one line: worker concurrency becomes a config integer. The thing I ran six replicas to fake became a number in a config file.
That's my rewrite test, and I hold teams to it: rewrite when the failure mode is a property of the runtime model, and you can quote your own incident log proving it. "The code is ugly" doesn't clear the bar. "Nobody understands it anymore" doesn't either. The bar is a named failure class, with receipts, that the target language deletes by construction.
The design's net line:
"Net: 84 Python files / 12 tables / api + 6 workers + Redis → one Go binary, 8 tables, one container."
(same design doc, §13)
The 84 there is the design's estimate; my measured count is 83. Hold on to the "8 tables," though. It comes back.
Cutover math: dual-run or don't
Twelve days from empty repo to production is only responsible because the cutover was designed to be boring.
The old service was never stopped until the flip. Dual-run the whole way. Rollback was "don't flip." That single property turns a rewrite from a bet into a slice.
Budgets lived in types, not dashboards. The design shipped hard LLM spend caps behind a typed BudgetExceededError. On cutover day both got raised live: per-recording cap to $10, daily cap from $10 to $50. Two one-line commits while real traffic flowed. That is what a cap is for: tuning a limit live beats discovering a bill at month end.
Don't-pay-twice lives in the schema. ADR 001's transcription protocol writes durable queued → submitting → polling markers so queue retries can never double-pay a BatchRecognize call. The ADR is explicit about the ugly corner:
"A crash while
transcribe_state='submitting'with an emptytranscribe_opis genuinely ambiguous. The worker never auto-resubmits"
(docs/adr/001-transcription-core-architecture.md)
Correctness under retry lives in the schema, where a 3am worker restart can't argue with it.
The database moved separately. Day 13, blue/green: old voicetotext-db to a fresh CMEK-encrypted stark-vtt-db. Its own slice, after the code cutover, never during.
And the risk register got honored in the cheap direction. The design flagged Google's BatchRecognize limits as a risk to verify later. On 2026-06-04 the risk materialized and was handled the same day: chunk audio to under 20 minutes. YAGNI until it bites, then fix it the day it bites.
Then the cut list came back
Now the embarrassing half, which is the half worth writing down.
Section 2 of the design reads like a manifesto: "Not a parity rewrite." Cut: WhisperX, agreement scoring, voice profiles, multi-provider LLM fan-out, the React dashboard, app-layer encryption, DB-backed runtime config. Declared: "Not a product. Single user." Locked: "GCE VM kept."
Git shows what happened next:
| Cut on 2026-05-20 | Back by | As |
|---|---|---|
| React dashboard | Day 10 | Next.js monitoring UI. Today: 64K lines of TS. |
| "GCE VM kept" | Day 9 | Cloud Run (ADR 002): two services, three jobs, one on a GPU |
| WhisperX | Day 11 | Opt-in WhisperX (ivrit.ai) transcription path |
| Multi-provider LLM | Day 12 | Provider-pluggable client, connectors, budget gates |
| App-layer encryption | Day 13 | Tink envelope encryption, then client-held keys, CMEK |
| "Single user" | Day 10 | Multi-user ownership ADR; RBAC ADR on 2026-06-11, slices live in prod the next day |
Nearly every cut reversed. Most within two weeks. And the design's "8 tables" promise deserves its own line: by week 8 the schema stood at 107 migrations and 59 CREATE TABLE statements.
The totals mock the trim, too. The old system was roughly 30K lines all-in. The new one is 146,238 lines of Go (78,585 non-test, 67,653 test, 13,930 of it sqlc-generated) plus 64,128 lines of TypeScript in the web UI. Around 211K lines. It does far more: multi-user, RBAC, encryption, Zoom connectors, a recorder bot. But "smaller sharp core" did not survive contact with usage.
Even Python didn't die. Two Python files run in production inside the Go system today: the pyannote diarization container and the WhisperX GPU transcription container. The ML ecosystem is Python, so the GPU math stays Python, whatever the rewrite manifesto claims.
So was the cut list a lie? No. And that "no" is the concept I want you to walk away with.
The cut list is a queue, not a graveyard

Here's what the summary numbers hide and the git log shows: every returning feature came back as its own project. Its own spec, plan, deployable slices, its own eval. The dashboard came back as a scoped monitoring UI. Encryption came back as an envelope-encryption slice sequence. And WhisperX's fancy arbitration merge got evaluated against real transcripts and shelved, because the eval said no.
That's the difference between scope discipline and scope denial. When you cut a feature from a rewrite, you're answering exactly one question: does this block the cutover? Everything that doesn't block it goes behind the flip. The cut hands the feature a queue position, nothing more.
This dissolves the classic rewrite dilemma. Chase parity and you cut over never; the old system is a moving target and you're rewriting a river. Cut to the bone and mean it forever, and you ship a worse product; the users revolt within a month, even when the only user is you. The third door: cut ruthlessly to make the flip small, then re-admit features one scoped project at a time, each earning its way back with a design and an eval. The dashboard earned its return. The arbitration merge didn't, and stayed shelved. A graveyard can't tell those two apart. A queue can.
If your rewrite plan doesn't say which cuts are dead and which are deferred, what you have is a wish list.
Immortal identifiers

One more discipline artifact, because nobody writes about this one. Six weeks after cutover, the project got renamed to kotodama. The rename ADR carries a constraint block, locked by owner:
"Live GCP identifiers are NOT renamed casually. Where a rename is stateful or auth-critical (SQL instance, CMEK, WIF, service accounts), prefer add-alias / dual-run over in-place rename, and only cut over behind a tested slice."
(docs/adr/009-rename-to-kotodama.md)
Executed as slices, same as everything else. Slice 1: the repo rename, id-stable, so the GitHub repo id never changed and workload identity federation was rename-transparent; 442 import sites rewritten, CI green from cold. Slice 2: the CLI binaries, but the in-image binary path stayed /voicetotext so deploy wiring didn't move in the same change. Slice 3, the public ingress rename: investigated, found blocked on a Shared VPC networking change, and explicitly DEFERRED with the finding written into the plan. Slice 5, renaming the SQL instance and CMEK keys: SKIP. The plan's words: renaming buys nothing.
So the live system, proudly named kotodama, still runs on a service account called voicetotext-sa, WIF selectors called vtt_deploy, env vars prefixed VTT_*, a host at voice.evinced.rocks, and a database named for the previous name:
SQL_INSTANCE ?= stark-vtt-db
(Makefile, line 109)
I call these immortal identifiers. A name bound to state or auth doesn't get renamed. It gets aliased, dual-run, or outlived. The name in your README and the name in your IAM bindings are different objects with different costs of change. Confusing them is how renames take down production for zero user value.
Even handled this carefully, old names bite. On 2026-07-10 a Makefile default was still pointing at the long-decommissioned voicetotext-db and needed a fix commit. Immortality has interest payments. Pay them. They're smaller than the principal on a stateful rename.
What to take
Six rules, all paid for by the history above:
- Rewrite on receipts. The trigger must be quotable from your own incident log. Mine was a 41-minute hang ending in SIGSEGV, written down the day it happened.
- The bar is a language-structural failure class. A failure the runtime model guarantees and the target language deletes by construction. Ugly code doesn't clear the bar. Event-loop starvation does.
- Dual-run or don't. The old service never stops until the flip. Rollback must cost nothing.
- The cut list is a queue, not a graveyard. Cut everything that doesn't block cutover. Re-admit features as scoped projects with their own evals. Let some stay shelved.
- Identifiers bound to state or auth are immortal. Alias, dual-run, or outlive. Never rename in place for aesthetics.
- Rewrites don't fix upstream. Six weeks after cutover, a vendor speech API deterministically returned corrupt, non-monotonic word timelines: 45 to 150 seconds re-decoded twice with broken offsets. Reproduced byte-identical, own code ruled out with chunk manifests and logs, fixed by sanitizing the stream. New language, same vendors.
Fear is the real legacy system
Forget the 12 days. The pair that stays with me: the old repo shipped 175 commits and 3 release tags in its entire life. The new one shipped 468 commits and 155 tags in its first eight weeks. Same problem. Same single-digit user count.
That's not Go beating Python, whatever the comment section says this week. That's what it looks like when deploys stop being scary. When touching the code costs nothing, you touch it. When it might starve an event loop somewhere in a dependency you've never read, you don't. The commit histogram was measuring fear the whole time.
The service I rewrote was 62 days old. Age had nothing to do with it. When you flinch before touching your own code, the rewrite has already been decided. The only open question is whether you'll do it with receipts, a dual-run, a queue instead of a graveyard, and names you're humble enough to leave alone.
Fear is the real legacy system. The rest is math, and the math says 12 days.