The cache trap: when a system fails identically every time
May 26–29, 2026Build LogDebuggingStrategy
Two runs of the same prompt produced byte-identical test files — both containing a test that asserted a plus sign would lowercase to False. The system wasn't broken randomly. It was broken reproducibly, which is worse.
Two runs of the exact same prompt produced byte-identical test files. Not similar — identical, down to the byte count. And both contained the same nonsensical test, one that asserted a Python string's .lower() would return False for the character +. Which is impossible. A plus sign has no case.
The system wasn't broken in a random way. It was broken in a perfectly reproducible way. Yun ang nakakatakot (that's the scary part).
What was actually happening
The LLM client had a cache. Reasonable idea: memoize completions so a repeated call doesn't burn a repeated API bill. It keyed on the provider, the prompt, the system message, and the model.
So one day a model rolled badly on a calculator prompt and produced an impossible test. That bad output got cached. And every subsequent calculator run inherited it — because the code-generation stage was deterministic enough to produce near-identical source, which produced identical tester prompts, which hit the cache, which replayed the same garbage. Forever.
The cache was making the system more deterministic in the bad direction. A cached wrong answer doesn't degrade gracefully. It calcifies.
This is worse than a flaky system. A flaky system tells you something's wrong. A system that fails identically every time looks like a real bug in your code, and you'll spend days hunting a phantom in the generator while the actual culprit sits in a dictionary.
The fix isn't "tune the cache"
My first instinct was cache-size, TTL, eviction policy. All wrong. Those just delay the trap.
The real fix is recognizing that caching is a per-call policy decision, not a global setting. Some LLM calls are genuinely idempotent — a router deciding "is this request codegen or debugging?" will always give the same answer for the same input, and caching it is free money. But generative work is the opposite: variance across runs is the entire point. If code generation returns the same thing every time, retrying it is pointless.
So every agent now declares whether its calls are cacheable. The router: yes. Everything that generates — code, tests, architecture, reviews, requirements, debugging: no.
Caches that memoize bad outputs are worse than no caches. The fix is per-call cacheability with sensible per-agent defaults — not a smaller cache. A smaller cache just makes the trap intermittent, which is harder to find.
The bonus bug
While plumbing the cache policy through, I found that a config helper was silently dropping the fallback provider and fallback model fields. Which meant the entire failover system — the thing that's supposed to switch to a backup model when the primary is down — had been a structural no-op since the day it shipped.
It had never once worked. Nobody noticed, because the primary had never gone down during a test. That's the kind of bug that waits patiently for your worst day.
The week's real work was strategic
The code changes above were small and surgical. The bigger move was a set of decisions about what this project is, and what it deliberately isn't.
I split the vision into two committed products and one deferred one:
The greenfield SDLC platform — what this repo is. Generates new code, tests, design, and an audit trail from a prompt. For regulated teams starting new projects under contract.
A read-only knowledge platform — a separate future repo. Local-first retrieval over enterprise knowledge scattered across issue trackers, wikis, code hosts, and QA assets. The one-liner I landed on: a greenfield tool built specifically to make brownfield work tolerable.
A write-enabled sibling — deliberately deferred, design captured, not built.
The architectural decision I'm proudest of
The knowledge platform has one hard rule: read-only by design. Every connector refuses to write. Not "write with a permission flag" — structurally incapable of writing.
The reasoning is a risk-class argument, not a feature argument. The worst case for a read-only tool is "it gave me a wrong answer." The worst case for a write-enabled one is "it silently modified production." Those are not the same category of bad. Regulated buyers can accept the first and cannot accept the second.
I could have made the connectors read-write with a permission system. Instead I made writes impossible. The second version is shorter, safer, and easier to sell.
The architectural decision worth making once is the one that removes a whole risk class — not the one that manages it with a flag. A flag is something a reviewer has to trust. An impossibility is something they can verify.
Why defer the third product
Honest competitive reasoning: brownfield code modification is the most crowded of the three categories. Every major AI coding tool lives there. The wedge for a differentiated entry is real but narrow — audit-defensibility at the top of the regulated curve, where existing tools genuinely fail.
A speculative build into a saturated market, with a small team, is high-risk and expensive. Deferring costs nothing: the design is captured, the shared-library plan supports it later, and no current decision is blocked. If a customer shows up with budget and a pilot commitment, the activation criteria flip and the build is half the size it would be today.
The general principle: starting separate can be unified later. Starting unified can never be cleanly split. Two products keeps the option to add a third open, without spending engineering effort to hold it open.
And why any of this is written down
Most of this week's work was strategic. Strategic work that lives only in a chat window is a year-old TODO nobody remembers deciding.
Conversation memory evaporates; durable docs survive. This entry exists for that reason. The chat that produced these decisions is gone. The doc, the blog entry, and the updated project constitution are the durable form.
The cache fix, the shared library, and the decisions
The implementation detail, the code, and the specific tooling. Same password as the other build-log posts — or request access below.
The cache trap, in detail
The LLM client memoized every completion keyed by (provider, prompt, system, model) and replayed it indefinitely. Two consecutive runs of a CLI-calculator prompt produced byte-identical 6771-byte test files, each containing:
def test_returns_false_when_operation_is_lowercase_plus():
assert "+".lower() is False # impossible: "+" has no case
Once a smaller, faster model rolled badly and produced that test, it was cached. Every later calculator run inherited it, because the stronger codegen model was deterministic enough to produce near-identical source → identical tester prompts → cache hit.
The fix
Added cacheable: bool = True to the per-agent LLM config, plumbed it through the completion path and the failover path, then set cacheable: false on every generative agent in settings: codegen, tester, architect, reviewer, requirements, debugger, and the architecture reviewer. The router stays cacheable: true — its classification is genuinely idempotent.
Note that you must restart the server to clear the in-memory cache before observing the new behavior. Otherwise you're still reading the poisoned entries.
The bonus fix
The for_agent() config helper was silently dropping fallback_provider and fallback_model. Failover had never worked. All three fields now forward correctly.
Also shipped
Presentation deck refresh. The deck had drifted 18 days stale — predating the entire blog. Backed it up, then targeted updates to the seven most-stale slides: agent descriptions (dropped a wrong count), LLM config (added the failover + model-router paragraph), review phases (added the prose-fallback safety-net callout), deployment (workspace-container row + the air-gap principle), by-the-numbers (fixed stats, added the first-clean-run proof point), and the roadmap (rewrote Done and Next against the current queue).
A frontend scoping bug. Alpine's $root resolves to the closest x-data ancestor — which inside a nested welcome component was the local state object, not the outer app state that owned the navigation method. Added a local goto(id) that sets the URL hash, letting the existing hashchange listener do the work.
Standards cleanup on the cache changes. Removed a premature optimization (conditional cache-key computation), removed dead code, updated the docstring to document the new policy flow, verified types and async conventions.
The shared library, deliberately deferred
When the operational pattern justifies it, a shared core library gets extracted: the LLM client (provider abstraction, failover, cache policy), an MCP server framework plus domain MCPs (code, knowledge, issue tracker, wiki, code host, requirements, test runner, audit, diff), RAG primitives (vector stores, embeddings, chunking, retrievers, loaders), agent infrastructure (base, registry, messages, personas, consensus), zero-trust security patterns, an append-only event store, and typed settings with compliance modes.
The insight worth keeping: MCPs are the unit of reuse more than language-level imports are. An issue-tracker MCP written for one product works unchanged in another. That makes the eventual extraction mostly bookkeeping — move directories, update imports — rather than a rewrite.
But the library is deferred on purpose. Each product builds its own version of these primitives first. Extraction happens when two products are running nearly-identical code and the cost of keeping them in sync exceeds the cost of extracting. The target shape is documented so contributors keep the abstractions extraction-friendly from day one, without paying for the abstraction now.
The reviewer model decision
Single reviewer + retry + human-in-the-loop stays the default. A strong cloud model reviewing on its own, with three codegen retries and a human gate, is more honest than three-voter consensus by default.
Consensus stays available as opt-in for two specific scenarios: (1) air-gapped deployments where several weak local models aggregate well enough to approximate one strong reviewer, and (2) audit-defensibility contexts where recorded independent assessments are themselves the deliverable artifact.
State management: adopt the patterns, skip the framework
A duplicate-toast bug (a listener registered twice on hot reload) was the surface symptom. The underlying issue was that explicit subscribe/unsubscribe discipline wasn't enforced anywhere.
Three options considered: a tiny vanilla event-bus + store module (~80 lines), pull in a reactive library, or migrate to a full framework. Chose the vanilla module.
The current stack is deliberately lightweight — server-rendered partials, a small reactivity library, a custom event bus. A framework migration is weeks of work with no clear return today. The bus + store gives the patterns — dedup at registration, explicit subscriptions, central state — without the framework cost. And when a framework eventually is needed, the migration is easier because the data flow is already explicit.
Build log entry from an append-only debugging journal. The blog is the narrative; the session docs are the audit trail.