One bug is never one bug
A 401 that didn't redirect was 33 of them. A toggle labeled "verifiable" was three surfaces, not one. The habit that turns a single bug report into a swept class — and the trailer that leaves the receipt.
The problem
You hit a bug. A screen that should bounce a logged-out user to the login page just… shows a confusing error instead. You fix that screen. Ship. Two days later, QA finds the same bug on a different screen.
We learned the lesson the cheap-but-embarrassing way first. A fix patched an off-by-one (bot_count + 1) in one screen’s table-type label; prod QA then found the byte-identical bug on another screen, because nobody had grepped for the second one. The cost that time was small — one extra deploy, ten minutes. But the principle is general and the cost scales: a bug you tripped over is usually one instance of a class your codebase repeats. The fix isn’t to patch the instance. It’s to grep the class and fix all of it in one commit.
The discipline
When a bug is a pattern — a repeated bad helper call, a label that drifted, an off-by-one, a missing guard — the first action isn’t to edit the line. It’s to grep for every sibling, then fix them together. We made it a commit convention: a fix of that kind carries a trailer.
Sibling-grep: redirectOnAuthError applied to all 33 audited auth-gated catch sites
# or, when it genuinely is a one-off:
Sibling-grep: N/A (single-site fix, no antipattern)
The trailer does two jobs. It makes “did you sweep the class?” a question the commit has to answer before you claim done, and it leaves a receipt the next person can check: which grep, what scope, what was exempted.
Example 1: one missing redirect was 33 missing redirects
The reported bug: a logged-out user on the public setup page clicks “create room,” the request 401s, and they get a dead-end error toast instead of the login screen. One screen, one fix? No. “A fetch whose 401 should send you to login but doesn’t” is a pattern, and the client had it everywhere data loads behind auth.
So instead of fixing setup, we wrote one helper — redirectOnAuthError(err, router) — that turns a 401 into clear-the-session-and-go-to-login (and a guest’s registration_required 403 into go-to-register), and returns false for anything else so the caller’s normal error handling still runs. Then we routed every auth-gated catch through it: 33 call sites across 20 files — views and bottom-sheets alike — in one commit.
Two things that sweep taught us, and they’re the actually-useful part:
The exemption list is part of the fix
A blind sweep is its own bug. Four kinds of 401 must not redirect: the boot probe (/me returns “not logged in” as ordinary data), the login and register forms themselves (a 401 there means “invalid credentials,” handled in place), live-table side-fetches (the coach quick-hint — a side-fetch that 401s mid-hand must not yank you out of the hand), and the iOS bottom-sheets (which stay open on an expired session). We wrote those exemptions down right next to the helper — because “where the pattern does not apply” is as much the spec as where it does.
The sweep concentrates the risk into one reviewable surface
Routing all 33 sites through one helper meant the whole change fit on one surface a reviewer could actually hold — and review of that surface caught, before merge, two bugs the first version of the helper introduced. It pushed the user to /login but forgot to clear the stale local session, so the router guard saw “still authenticated” and bounced /login straight back to home — a silent dead-end, worse than the toast we started with. And a stats screen set its loading flag, took the redirect branch, and returned with no finally, so it would have come back stuck-loading. Patch the same 33 catch blocks by hand and both bugs ship; concentrate them in one helper and one review catches both. (The sweep did surface one genuinely pre-existing gap: the router guard bounced guests away from /login and /register, so the new go-to-register redirect would have hit a wall — a hole that predated the sweep and got fixed with it.)
Every hit is triaged by a human trace, not auto-fixed; the trailer records the audited scope — it is a receipt, not machine-verified proof.
Example 2: sibling-grep is for claims, not just code
The habit generalizes past code paths, and this is where it earns its keep. We shipped a “Co-shuffled deal (verifiable)” toggle whose copy promised “an independently verifiable record” of a fair shuffle. In production, that path ran mock crypto — the server itself flagged the transcript as not provably fair. The cards weren’t rigged (it’s still an honest OS-random shuffle), but the claim was: we were advertising a cryptographic guarantee we weren’t delivering.
The amateur fix is a disclaimer next to the toggle. But “the claim” wasn’t one surface. It lived in the toggle’s label, in its sub-text, and — most of all — in the mere existence of an enabled toggle that says “verifiable.” So we swept the claim the way we’d sweep a bug: gate the toggle off in unflagged production builds (which now disable it — greyed out, force-held off, with a “not enabled in this build” hint — rather than promising what they can’t prove), and rewrite every piece of copy to state only what it does — “all players take part in the shuffle; the server can still see the cards” — dropping the verifiability claim entirely. Since then the server closes the wire too: in production any co-shuffle opt-in is pinned back to ordinary server dealing, and the separate engine-blind path is release-caged off in every build (ADR-090) — the claim can’t come back by accident. Same discipline, different class: find every surface making the false claim and fix them at once, not the one you happened to be looking at.
The honest limits: grep is necessary, not sufficient
Sibling-grep is a starting net, not a macro you run blind:
- Grep finds spellings, not meanings. The same text can be correct in one caller and wrong in another — a shared
toCallvalue read identically by the hero path and a bot path can mean different things. A hit isn’t a bug; trace each one before you “fix” it. (And a sibling spelled differently slips past a literal grep entirely — the net has holes.) - Widening a type triggers a sweep, not just an edit. Change a field’s type or meaning and every consumer needs a re-audit even though they all still compile. The build is green; the semantics moved.
- The exemption list is the spec. If you can’t enumerate where the pattern legitimately does not apply, you don’t understand the pattern well enough to sweep it safely.
Recap
- One bug is almost never one bug — it’s one instance of a class your codebase repeats. Fixing only the instance you tripped over ships the rest to your users.
- Grep the class, fix it in one commit, leave a
Sibling-grep:trailer — the trailer makes the sweep answerable and leaves a receipt. - Two payoffs: the sweep enumerates exemptions (where the pattern must not apply — itself part of the spec) and concentrates the change so review of one surface catches what the first version broke (the stale-session dead-end, the stuck-loading screen) before it ships.
- It’s not just for code: a false product claim lives on every surface that makes it — label, sub-copy, the toggle’s very existence — so sweep all of them, not the one you noticed.
- Grep finds spellings, not meanings: trace every hit, and re-audit consumers when a type widens.