The Bug That Emailed the Wrong Person and Never Threw an Exception
The most expensive bugs we've mined from real AI-coding sessions didn't take any systems down. This one sent an email.
An automation system that drafts replies into existing email threads CC'd a real person on a message meant for someone else. Wrong recipient, real name, live conversation. Not a security breach, not an outage — just the kind of credibility damage you can't patch, discovered the way these things always are: by the recipient, not by monitoring.
There was no exception anywhere in the system. Every log line looked normal. The code had worked exactly as written.
One line
thread = find_thread(thread_id) or threads[0]The system looked up an email thread by ID before drafting a reply into it. The lookup pattern was this:
On a hit, correct behavior. On a miss, the fallback quietly substituted the most recent thread in the list — a completely different conversation, with completely different participants.
And here's what made it vicious: everything downstream was flawless. The system drafted a well-written reply, threaded it correctly, and pulled recipients from the thread's participant list — the wrong thread's participant list. Every step after the substitution was internally consistent. The better the downstream code, the more convincing the wrong result.
Silent substitution: the worst of the silent family
We talk a lot about silent failures — the swallowed exception, the .catch() that only logs. Silent substitution is a level nastier.
A swallowed exception hides an error; at least the operation visibly didn't produce a result, and someone eventually asks why. A silent substitution manufactures a plausible wrong answer and hands it downstream with full confidence. Nothing failed, so there is no error to log, no stack trace to find, nothing to grep for. The only evidence the bug exists is the wrong outcome itself, out in the world.
Lookup-miss substitution shows up everywhere once you know to look: the user record fetched by ID "or" the first user, the config entry "or" the default profile, the order "or" the latest order. Each one is a wrong-recipient bug waiting for its moment.
Why AI assistants write it
The or-fallback idiom reads as robustness, and AI models are trained on millions of examples where handling the miss case is what good code does. Assistants are strongly biased against letting code raise or return None — a crash looks like a bug in the diff, a fallback looks like craftsmanship.
So when you ask an assistant to "handle the case where the lookup fails," its favorite patch is a fallback, and the most statistically common fallback in its training data is the first or most recent item. It doesn't model the semantic difference between "a default value" and "a different customer's data."
A human reviewer sees defensive code and approves it. We predicted this exact pattern would be reintroduced by the next AI session that touched the file — that's the thing about AI-characteristic bugs: fixing the instance doesn't fix the generator. A lint rule is the durable guard.
What the safe version looks like
thread = find_thread(thread_id)
if thread is None:
raise LookupError(f"thread {thread_id} not found — refusing to draft")The fix is one of the smallest diffs we've ever pulled from an incident. A miss stops the pipeline and says which ID missed:
Note what the exception buys you that the fallback never can: a name in the error. When this fires, the log tells you exactly which ID missed and the operation halts before any external side effect. The fallback version has no observable moment of failure at all — by the time you know something went wrong, the email is in someone's inbox.
Auditing your own code for this takes ten minutes: search for or immediately after a lookup call, and for [0] on the result of a filtered list. Every hit is a place where a miss becomes a substitution. And don't expect your tests to save you — in tests, the lookup always hits, because the fixture creates the record it looks up. The miss path only executes in production.
What catches it today, and what's coming
The shipped rules cover the swallow half of this family. **QUAL001 (exception-swallowing)** — in the free tier — flags the except-and-continue class: bare excepts, except: pass, except: return None. **ASYNC004 (silent-catch)** flags Promise .catch() handlers that only log and let the failure evaporate.
For the substitution half, we're building **QUAL020 (fallback-to-first-on-id-miss)**: an ID-based lookup whose miss path returns a different record instead of failing. The rule of thumb it encodes is simple — a miss on an ID lookup is never something to "handle" by substituting different data. It's a bug to surface, loudly, before it picks its own recipient.
pip install stablestack
Free with every install. No license key required.
pip install stablestack