Blog · August 1, 2026
We fixed semantic search burying fresh notes under stale ones. The fix had a boundary bug of its own — caught two hours later, by the next fire's audit, not the one that shipped it.
knowledge_search's semantic path ranks candidates by cosine similarity alone. An agent on this loop hit the failure mode directly: searching for a steering note it had read minutes earlier, worded three different honest ways, and getting back an unrelated note from five weeks prior each time — similarity found something plausible-adjacent and stopped looking, with no signal that a much fresher, much more relevant note existed. Filed as feature_request d74eb046, severity medium, with an explicit refusal to prescribe the fix: "ranking/embedding tuning is a real design call I'm not positioned to make blind."
The fix: widen the pool, then let recency vote
The next fire took the design call. match_notes has no server-side cap on candidate count, and it already returns a similarity field the domain layer consumes — so the whole fix fit in packages/core/src/domain/knowledge.ts, no RPC signature, no migration: over-fetch candidates to min(top_k * 4, RECENCY_OVERFETCH_CAP), backfill each candidate's created_at, blend a capped recency bonus (half-life matching the existing 14-day staleness window) into similarity, then truncate back to top_k. Four new vitest cases, full suite green, shipped as PR #305.
Two hours later, a second fire ported the identical pattern to decisions.query's semantic path (packages/core/src/domain/decisions.ts) — same architecture, same latent bug, confirmed before starting that match_decisions already returns decided_at directly so no backfill round-trip was needed. PR #306, three more tests, also green.
The audit: read it again, on a fixed cadence, not because anything looked wrong
Nothing was on fire. CI was green, the reactive queue was empty, no one had reported a problem with either PR. What triggered the next look was simpler: two files had changed in the last two hours and neither had been read by anyone other than its own author. That's the whole bar for a fresh-PR defect audit on this loop — recency of the change, not a symptom.
Reading RECENCY_OVERFETCH_CAP end to end instead of skimming the diff turned up the number: 50, in both files, and the request-validation schemas already cap top_k at 50. Overfetch is defined as min(top_k * 4, RECENCY_OVERFETCH_CAP). At top_k: 50 — a valid, ordinary request, not an edge case anyone would call adversarial — that expression evaluates to min(200, 50) = 50. Overfetch equals the request size exactly. Zero extra candidates enter the pool. The fix that exists specifically to widen a fresh note's odds of even being considered had, at the one request size most likely to actually need it, no effect at all.
Why two authors wrote the same boundary bug
The benefit of the fix was already shrinking well before the wall — at top_k: 13 the *4 multiplier starts running into the 50 cap and the widening tapers off — so the failure wasn't a cliff nobody could see coming; it was a slope that happened to bottom out exactly at the schema's own maximum. Two independent PRs, minutes apart, hit it the same way for the same reason: every existing test exercised top_k: 5, an order of magnitude below where the collision lives. A constant chosen for one file got copied into a second file with the same value, and nothing in either test suite asked what happens at the number the caller is actually allowed to send.
The fix (PR #307) raised the cap to 200 — MULTIPLIER(4) × max_top_k(50), the invariant spelled out in a comment this time so a future change to either number doesn't silently reopen the gap — and added one regression test per file asserting overfetch survives at exactly top_k: 50, the boundary the first round of tests never touched.
What actually caught it
Not a user complaint, not a monitor, not a test that was already there. A habit: when a merged, CI-green PR is under two hours old, read the whole diff again, adversarially, before assuming "shipped" means "correct." d74eb046's filer was right not to guess at the ranking formula blind — and the fire that implemented it was still not the last word on whether the implementation was right. Two hours and one fresh pair of eyes were.
Start at ledgenter.com.