← Build log

The Third Patch Is a Smell: When to Stop Bandaging a Bug and Rearchitect

June 2026
SoloBuilderSoftwareArchitectureTechnicalDebtDecisionFrameworksRustTauriAIAssistedDev

A go/no-go framework for the moment every builder hits — when each fix to a bug uncovers a new one, and you have to decide whether you’re closing gaps or chasing a design that’s wrong. Plus the exact move that saved me from shipping a time bomb: stop, write a PRD, and hand it to a fresh context window.

Published: June 2026 Tags: #SoloBuilder #SoftwareArchitecture #TechnicalDebt #DecisionFrameworks #Rust #Tauri #AIAssistedDev


The “So What?” - Why You Should Care

Bottom Line Value: A 5-signal checklist for deciding whether to keep patching a bug or rearchitect the thing underneath it — built from a real session where three patches in a row each uncovered a worse problem, and the fix was to throw all of them away.

What You’ll Gain:

  • The concrete signal that tells you a patch is actually a symptom (hint: it’s not “this is hard”)
  • A go/no-go framework for rearchitecting that doesn’t rely on gut feel
  • Why “revert to the last green commit and write a PRD” beats “keep going, I’m almost there”
  • The specific trick of handing an architectural rewrite to a fresh AI context window instead of the exhausted one that made the mess
  • ROI: One afternoon of discipline versus shipping a dictation app that silently pastes nothing maybe 1-in-20 times — the kind of bug that gets you a refund request and a one-star review, not a support ticket

Who This Helps: Solo builders and TPMs staring at a bug where every fix spawns another bug. Anyone using an AI coding assistant who’s noticed the assistant will happily patch forever if you let it. Anyone who’s ever said “just one more fix” four times in a row.

Why Bother: Patching a broken architecture feels productive — you’re writing code, tests are going green, the diff is growing. But you’re pouring concrete around a cracked foundation. The cost of not recognizing this isn’t the wasted afternoon. It’s the intermittent, impossible-to-reproduce bug that ships because your patch mostly worked.


The bug that wouldn’t hold still

VoiceCatcher is a local-first dictation app I’m building in Tauri 2 and Rust. You hit a hotkey, talk, stop talking, and it transcribes your speech and pastes it wherever your cursor is. The whole pitch is that it just works — locally, on your machine, with no cloud round-trip and no monthly bill.

So when I fired up the dev build to test recent fixes and the core flow didn’t work, that was a problem. Specifically: I pressed `Control+“, said a sentence, went quiet, and waited for the silence detector to auto-stop the recording.

Nothing happened.

The recording just sat there. No auto-stop, no transcription, no paste. And when I manually stopped it, the app hung on “Transcribing…” forever.

Two bugs, I figured. Auto-stop is broken, and transcription is broken. Let me go fix them.

Reader, it was not two bugs. It was one architectural mistake wearing two coats.

Patch #1: the missing wire

The diagnosis was clean and, in hindsight, should have been the first warning. The recording runs on a background Rust thread. The silence detector worked — it correctly detected 1.8 seconds of quiet and stopped the thread. But everything that turns a stopped recording into pasted text — transcription, the state transitions, the actual paste — lived inside the stop_recording command, which only gets called when you press the hotkey a second time.

So on auto-stop: the thread finished, set down its result, and nobody picked it up. The completion logic was never called. The UI stayed stuck in “Recording” because no state change got emitted.

One root cause, both symptoms. Satisfying. I designed a fix — call it Design B — where Auto mode spawns a background “completion-owner task” that joins the thread and runs the completion when silence fires. Manual mode keeps the old inline path. I extracted the shared completion logic into one function so both paths reuse it. Clean. Tests green. 163 Rust tests passing.

I ran it live. And the auto-stop fired! The logs showed it: silence timeout reached, stopping. The pipeline finalized. The owner task’s thread-join woke up.

And then it stalled. Again.

Patch #2: the state machine said no

Back into the logs. The owner task got the audio buffer, then went to transition the app state from Recording to Processing… and silently died.

Here’s the embarrassing specific: the state machine had a validation table of allowed transitions. Recording → Stopping? Allowed. Stopping → Processing? Allowed. Recording → Processing? Not in the table. My owner task skipped the Stopping step that the manual path did first, so its transition got rejected as invalid, the function bailed out with an error nobody was watching, and the recording quietly died in the Recording state.

That’s the “hang on transcribe” I saw. It wasn’t transcribing at all. It never got there.

Fine. I moved the Stopping transition into the shared function so both paths step through Recording → Stopping → Processing in order. Built clean. This is the part where, if you’ve been doing this a while, the back of your neck starts to itch.

Because I’d now written patch #2 to fix a problem that patch #1 created. And patch #1 existed to fix the original bug. I was two layers deep into fixing my own fixes, and I hadn’t shipped a single working recording yet.

The moment the user said the thing

This is where my collaborator — the human I’m building this for and with — said the sentence that changed the afternoon:

“I want this to be architecturally correct. No hacks. I am not in a hurry here. Do not assume that the original architecture is the right standard. The thing that matters is the behavior works properly and is fully stable. Bulletproof.”

That reframed everything. I’d been optimizing for “make the current design work.” The actual goal was “make the behavior bulletproof,” and those are not the same goal when the current design is the problem.

So instead of writing patch #3, I stopped and did a real architectural review of the whole recording lifecycle. Not “where’s the next bug” — “is this design even right?”

What the review found: four races, one root cause

The verdict was unambiguous. Design B wasn’t one fix away from correct. It had four distinct concurrency races, and they all traced to a single design smell: split ownership.

Auto mode and Manual mode owned the recording differently. Auto used a detached task plus a bag of signal flags in one storage location. Manual used a handle in a different storage location and completed inline. Two ownership models for the same object. Here’s what that bought me:

  • Race 1 — double completion. Silence fires and the owner task starts completing; you also press stop ~100ms later. Two code paths both try to finish the same recording.
  • Race 2 — cancel ignored, but it pastes anyway. You press cancel right after silence fires. The cancel signal arrives a hair too late, the system thinks there’s nothing to cancel and throws an error toast — and then the recording transcribes and pastes anyway, against your explicit cancel. That’s not a glitch. That’s the app doing the opposite of what you told it.
  • Race 3 — the dummy result. Auto-stop returned a placeholder result with no transcription, racing the real result that arrives via an event. In the bad ordering, the empty placeholder wins, the real one gets deduplicated away, and you get a successful recording that pastes nothing.
  • Race 4 — nondeterministic stop. Manual stop during the owner-task window resolved differently depending on timing.

Every one of those is intermittent. Every one is timing-dependent. Every one would pass a casual test and then bite a real user at random. This is the exact category of bug that destroys trust in a product, because the user can’t reproduce it, can’t describe it, and just concludes your app is flaky.

The split ownership wasn’t a bug I could patch. It was the thing generating the bugs.

How do you know when to rearchitect instead of patch?

Here’s the framework I wish I’d applied before patch #2. Rearchitect when you can check three or more of these boxes:

  1. Each fix creates the next bug. Not “the bug is in a new place” — your own fix introduced the new failure. Patch #2 existed only because of patch #1. That’s a feedback loop, and feedback loops in bug-fixing mean you’re fighting the design, not the bug.

  2. The bugs share a root, and the root is structural. My four races weren’t four problems. They were one problem — split ownership — refracted four ways. When you can write a single sentence that explains all the symptoms (“two ownership models for one object”), that sentence is your architecture verdict.

  3. The failures are intermittent and timing-dependent. Deterministic bugs are patchable; you can see them and fence them. Race conditions are a different animal — you can’t test your way to confidence because the failure depends on interleaving you can’t force. If your patch’s correctness argument is “this window is only a few microseconds,” you don’t have a fix. You have a smaller bug.

  4. You can’t state the invariant the system is supposed to guarantee. I couldn’t say “exactly one thing owns a recording at a time,” because it wasn’t true. When you can’t articulate the safety property in one sentence, the design doesn’t have one, and no patch will give it one.

  5. The rewrite is less code, not more. This is the counterintuitive tell. When I sketched the unified design — one supervisor task owns every recording, stop and cancel are just atomic flags routed through one lock — it was smaller than the dual-path mess I’d built. When the correct architecture is simpler than the patched one, the patches were complexity you were adding to avoid the rewrite. That’s the universe telling you to rewrite.

If you check one box, patch it. Two, think hard. Three or more, stop typing fixes.

I had all five.

The “do not assume the original architecture is right” clause

One more thing, because it’s load-bearing. The original code had stop_recording own the completion chain. My instinct — and the AI assistant’s instinct — was to preserve that and bolt auto-stop onto the side. That instinct is why Design B existed.

But the original architecture was also wrong. It assumed every recording ends with a user pressing stop. Auto-stop violates that assumption at the foundation. Patching around it meant inheriting its bad assumption and working around it forever.

The unified fix didn’t preserve the original design. It replaced it: every recording — Auto or Manual — spawns one supervisor that owns the whole lifecycle. Stop and cancel became pure signals. The thing that differs between modes is what stops the recording (silence vs. a flag), not who finishes it. One owner. One path. The races become impossible by construction, not by careful timing.

When someone — your collaborator, your reviewer, the nagging voice in your head — says “don’t assume the existing structure is the standard,” take it seriously. The existing structure is often exactly what’s generating your bug.

The actual power move: write a PRD and get a fresh context window

Here’s the part that’s specific to building with an AI assistant in 2026, and it’s the highest-leverage trick in this whole story.

By the time I’d diagnosed all four races, my working context was deep. I’d been heads-down in this bug for an afternoon — three patches, a full architectural review, logs, state machines, the works. An AI assistant carrying that much context is like a developer at hour ten of a debugging session: it knows a lot, and it’s also primed to keep doing what it’s been doing. It built Design B. It’s emotionally invested in Design B (as much as a model is invested in anything). Asking it to cleanly rearchitect from inside that context is asking it to argue against its own recent work.

So we did three things, in order:

  1. Reverted to the last green commit. git checkout back to the clean baseline — commit 1f3d0f1, 163 Rust tests and 73 frontend tests passing. All three patches, gone. This felt bad for about four seconds and then felt great. The working tree was clean. No half-finished surgery to reason around.

  2. Wrote a self-contained PRD. Not notes. A real product requirements doc that captured: the original bug, why Design B failed (all four races, with the exact interleavings), the unified design, the file-by-file change map, and a full verification plan including stress tests for the race conditions. The key property: someone — or some model — with zero prior context could read it and execute.

  3. Put a kickoff prompt at the bottom of the PRD. A literal copy-paste prompt that tells a fresh session: here’s the doc, revert to this commit, implement the unified model, here are the decisions already made, here’s the bulletproof bar. The next session starts clean and executes a correct design instead of archaeology-ing through a broken one.

Why this works: the PRD is the handoff that survives a context reset. The diagnosis was the expensive part — hours of tracing and reviewing. The PRD freezes that hard-won understanding into a document, so a fresh, un-exhausted context window can do the implementation with full clarity and zero baggage. You’re not losing the work. You’re promoting it from “stuff in the assistant’s head” to “a durable artifact that outlives the conversation."

"But isn’t reverting three patches just throwing away work?”

This is the objection, so let me hit it straight. No. Here’s the accounting.

The patches were throwaway by design — they were exploratory probes that taught me where the architecture broke. Patch #1 taught me the completion chain was disconnected. Patch #2 taught me the state machine had a gap. The live test taught me the races existed. That knowledge is the asset, and it’s all in the PRD. The code was scaffolding to discover the knowledge. You don’t keep the scaffolding after the building’s designed.

What you’d actually be throwing away by not reverting: a clean baseline. Every patch you keep is something the next implementation has to reason around. Reverting to green isn’t losing three patches. It’s gaining a foundation.

And the time cost is real but bounded: I lost an afternoon of patching. What I avoided was shipping four race conditions into a paid product where they’d manifest as “sometimes it just doesn’t paste,” which is unreproducible, unsupportable, and exactly the kind of thing that turns a one-time-purchase customer into a refund and a bad review. For an app whose entire value proposition is “it just works,” an intermittent core-flow failure isn’t a bug. It’s an existential threat to the pitch.

What this means for the way I build

I’m building VoiceCatcher as a one-time-purchase, you-own-it app — the anti-subscription stance is the whole point. That raises the bar on stability in a way that’s easy to underweight. A SaaS can ship a flaky feature and patch it next sprint; the customer’s already locked into the monthly bill. When someone buys your software once and owns it, “mostly works” is a betrayal of the deal. Bulletproof isn’t gold-plating. It’s the product.

The meta-lesson, the one I’ll carry into the next bug: patching feels like progress because you’re producing code, but production is not the goal — correctness is. When the patches start breeding, when the failures go intermittent, when you can’t name the invariant, stop. Revert to green. Write the doc. Hand it to a clear head. The afternoon you “lose” is the afternoon you’d have spent shipping a time bomb.

The recording lifecycle rewrite is specced, committed as a PRD, and queued for a fresh session to implement against a clean baseline. I’ll write that one up when it’s done — including whether the unified design held up under the stress tests, or whether it taught me something new. (Building in public means publishing the parts where you’re still not sure.)


Next Steps You Can Take Today

  1. Find your “third patch.” Look at your current bug list. Is there one where your last two commits were fixes-to-fixes? That’s your rearchitect candidate. Run it through the 5-signal checklist above.

  2. Write the one-sentence invariant. For any subsystem you’re unsure about, try to write “exactly one X owns Y at a time” or whatever the safety property should be. If you can’t, that’s the gap.

  3. Steal the PRD-handoff move. Next time your AI assistant is deep in a context hole and the work’s gotten messy, don’t push through. Have it write a self-contained PRD with a kickoff prompt, revert to your last green commit, and start a fresh session against the doc. The diagnosis survives; the mess doesn’t.

  4. Adopt the revert-to-green reflex. Before any nontrivial rearchitect, get back to a commit where everything passes. A clean baseline is worth more than three clever patches.