EP 17

The Bugs That Fail Without an Error


August 2026·7 min read·#debugging#silent-failure

In EP 04 I wrote:

"Getting an error without knowing it — that was the real fear. Not the error itself."

That fear became real three times over the summer. All three bugs share one thing. No error. It just comes out wrong.


One — the vanishing emails

The Brief tab's "N analyzed" was off. Dozens of emails come in a day, but it caught only 1 or 2.

There was no error. The briefing rendered fine. The number was just small.

I dug through the production DB directly. The culprit was the OAuth token for the hyungbin.kim@gmail.com sub-account. Its oauth_tokens.issued_at was frozen at June 8th — a value that should update and write back every day. Meaning refresh had been failing since then, and every Gmail query for that account was dying with 401.

The problem was that the code was swallowing that death.

// getUserGoogleTokens
if (!refreshed.access_token) continue   // silently skip the failed account

// fetchGmailMessages
catch (e) { return }                    // silently 0 emails on failure

continue and return. An entire account could die without a single error, and only "N analyzed" quietly shrank. The low-traffic account caught 1 email; the actual busy gmail.com contributed 0.

The fix wasn't a code change — it was reconnecting. Re-attaching the account in the app forces a new refresh_token via prompt=consent. After reconnecting: 12 emails, "13 analyzed." But the real defect remains. Silent failure. When an account dies, someone should know.


Two — the late messages

Trying to fix load speed, I added caching (EP 16) — and now messages other people sent didn't show up right away, or showed up late. Especially when I sat in the background and tapped a push to come in: the screen was still the old state.

This one had three causes, all in different layers.

  1. setAuth() was called once, at mount → when the access token (1 hour) expired, re-joining the channel was refused by RLS (CHANNEL_ERROR). Retrying with the expired JWT gets refused again. → the "disconnected" banner case.
  2. postgres_changes has no replay → messages from the window when the socket dropped in the background never arrive, even after reconnecting. → the case with no banner but missing messages.
  3. The REST delta that should fill that gap prioritized the token frozen at mount time over getSession() → 401 → if (!res.ok) return, another silent failure.

The reason it reproduced so erratically is elegant: it depended on whether the background stay exceeded the token expiry (1 hour). In particular, the Family room's ChatView, whose segments are a display toggle (EP 16), never re-mounts for the app's entire run — so it holds the first token to the end and broke the worst.

The fix was seven changes, but one thing runs through them — keep the token always fresh (propagate to all channels via onAuthStateChange, wire AppState ↔ autoRefresh), fill the gap on foreground return with a re-subscribe + REST delta backfill, and expose failure with setConnected(false). Turning silent failure into audible failure.


Three — "You never saved that"

The most recent, and the most infuriating, bug.

"Pepper, tell me the wine I saved""It's not in the Vault."

The data is there. The embedding is there. But it always says there's nothing.

I verified every layer. triage classification? Fine — it catches recall at 0.95. Embedding? Fine — 100% filled across all types. Ranking? Fine — wine ranks on top for a wine query. All healthy, and yet zero results.

The culprit was one filter.

vault-recall.ts  SIMILARITY_THRESHOLD = 0.65

I gave the RPC similarity_threshold: -1 to dump the full similarity distribution — and with gemini-embedding-001 (768-dim) measuring an asymmetric Korean query (short question vs. long document), the similarity ceiling was ~0.66. The 0.65 cut was slicing off all the correct answers. Classification, embedding, ranking all correct, and one last filter number turned everything into zero.

0.65 → 0.45,  limit 5 → 8

Wine/recipe/restaurant recall came back to life. The embedding layer I proudly introduced in EP 09 had been dead for two months over a 0.2 difference in a threshold.


A good system fails loudly

None of the three bugs could be caught without logs and diagnostic queries. The dead token was revealed by the frozen issued_at in the DB, the lost messages by the channel state logs, the zero-result recall by the similarity distribution dump. The reason I called pepper_logs "Pepper's black box" in EP 04, the reason I couldn't find the cause in EP 06 until Claude Code wrote a diagnostic query — I felt all of it three more times here.

And at the root of all three was the same anti-pattern. continue, return, .catch(() => null), if (!res.ok) return. Code that fails quietly. That's the most dangerous code for a non-developer. Throw an error and at least it's visible; swallow it quietly and you don't know for two months.

Building this taught me something. A good system doesn't hide failure. It makes failure loud. That's the only way someone like me finds out.