The Bug That Reverted Every Trade: Three Silent Failures Hiding Behind Each Other
Ballpark Genius is an MLB analytics site — player pages, projections, trade tracking, semantic search over the whole league. In one debugging session this week I chased a single symptom (“Adley Rutschman still shows as an Oriole five days after Baltimore traded him to Boston”) through three unrelated bugs, each of which perfectly hid the next one. None of them threw. None of them logged an error. Every one of them printed a success message while doing nothing, or the wrong thing.
This is the write-up, in the order I actually found them, because the order matters: each fix revealed the next failure instead of resolving the symptom.

Adley Rutschman’s page today — the state all three bugs conspired to prevent.
Bug 1: the cache flush that flushed nothing
The first theory was stale cache. I ran the project’s clear-cache script, expected a clean
slate, and got the same stale data back. Ran it again. Same result. The script printed
✓ cleared every time.
The cause was an environment mismatch that had been there since the caching layer was built:
1 | const CACHE_NAMESPACE = (process.env.CACHE_NAMESPACE || process.env.NODE_ENV || 'dev').trim(); |
The server runs with NODE_ENV=development and writes keys under development:*. The
CLI script, run from a plain shell, has no NODE_ENV set at all — it resolves to dev:,
issues a delete against dev:*, matches zero keys, and reports success anyway, because
delPattern returned void. A zero-match delete was indistinguishable from a real one, at the
type level and in the printed output.
I only found it by going around the tool: redis-cli EXISTS against the development:* keys
directly, before and after a “successful” flush. The keys were still there. That’s when I
noticed the namespace mismatch.
The fix made a no-op impossible to mistake for success:
delPatternnow returns the number of keys it actually deleted, and every call site reports that count —⚠️ 0 entries clearedreads very differently than✓ cleared.- The tool now prints the namespace it’s about to operate on and what Redis actually holds, so a mismatch is visible before you even wait for the delete.
- It detects the mismatch and tells you the fix:
1 | Namespace: dev: (Redis currently holds: development, test, today) |
This had been the standing explanation for a whole class of “why didn’t my fix take effect” reports across the project. The remedy people reached for first had never actually worked.
Bug 2: the importer that outvoted the trade
With caching ruled out, the database itself said Rutschman was still Baltimore’s. A trade sync
job (refresh-player-teams) had correctly written his real club days earlier. Something was
writing it back.
The culprit was a stats importer — a job that pulls a player’s current-season batting or pitching splits and, as an incidental side effect, decided it should also own that player’s team assignment:
1 | // Update player's team if this is the most recent MLB team ... |
The comment claims “most recent.” There is no recency check anywhere in that block — it simply
overwrites with whatever split MLB’s API happens to return next, in whatever order the API
happens to return them. For a player traded mid-season, the old club is often the only one
with any stat rows at all: Rutschman had two Baltimore rows for 2026 and zero for Boston,
because he went straight to Boston’s injured list without playing a game there yet. So every
time his stats got re-synced — including just from a page view triggering a background refresh —
his team flipped back to the club that had traded him away, silently, with a normal-looking
updated_at timestamp as the only trace.
The fix was to make that write fill-only: populate a missing team, never overwrite an existing
one. A stats importer has no business deciding a player’s current club — that’s what the trade
sync and MLB’s own parentOrgId field are for. The importer can still help a player that those
other syncs haven’t reached yet, which is the entire reason the write existed in the first
place; it just can’t outrank them anymore.
Fixing this repaired 51 players who’d been reverted by exactly this path.
Bug 3: the schema that quietly dropped the answer
There was a third bug feeding into the same symptom, found a few days earlier in the same chase:
fix-milb-teams, a job meant to correct minor-league team assignments, saw Rutschman parked on
a non-MLB affiliate, decided that looked broken, and “fixed” it by picking whichever MLB club
he’d played the most games for historically — which, for a player traded five days ago onto an
injured list, is always the old team. Volume-based inference is exactly backwards for someone
who just moved.
MLB’s API does hand back the correct answer directly, as currentTeam.parentOrgId. But our Zod
schema for the player payload didn’t declare that field, and Zod drops unknown keys by default —
so the correct value arrived from MLB and was silently discarded before any of our code ever saw
it. The first draft of this fix read parentOrgId, got undefined, and would have quietly
fallen back to the same broken most-played-games logic while looking like it had been fixed. I
only caught that by checking the parsed output against the raw response, not trusting that
adding a schema field was enough on its own.
With parentOrgId declared and read, fix-milb-teams now asks MLB directly instead of
inferring from history. Repair applied to the 328 players whose team contradicted their most
recent trade: 52 genuinely wrong, corrected; 276 were false positives from the detection query
itself — players like Verlander and Scherzer, whose team correctly differs from an old trade
because they’ve since moved again on their own. The script checks with MLB before touching
anyone, so only the 52 real mismatches changed.
What all three had in common
None of these bugs were exotic. A namespace typo. A missing recency check with a comment that lied about having one. A schema that silently dropped a field. Individually they’re the kind of thing you’d catch in five minutes. What made them dangerous together is that each one’s silence looked exactly like the previous fix working: flush the cache (nothing happens, but it says it worked), so you conclude the data itself must be wrong; fix the importer (the revert stops for a while, until the next stats sync), so you conclude the trade sync must be flaky; add the schema field (still wrong, because the value was never read), so you conclude the API itself must be inconsistent.
The lesson I keep relearning on this project: a tool that can silently do nothing is worse than a tool that fails loudly, because it burns your best hypothesis on every pass. The actual fix, each time, was smaller than the investigation it took to find it.
— James