The bug that isn't a bug
On Tuesday you attach a dead-letter queue to orders-queue. On Wednesday a batch of messages disappears and you ask Claude Code what happened. It answers immediately: orders-queue has no DLQ configured, so failed messages are dropped after the maximum receive count.
That answer is wrong, and it is also not a hallucination. The assistant read a real snapshot of your AWS account. The snapshot was taken Monday.
This is the failure mode that shows up once you give an AI assistant deterministic infrastructure context instead of letting it guess. Guessing produces answers that feel uncertain, and you treat them accordingly. A stale snapshot produces answers that feel authoritative, with real table names, real queue names, real ARNs. Nothing in the response signals that the underlying facts expired.
Infrawise extracts your DynamoDB tables, Lambda configs, queue settings, database schemas, and code-to-table access patterns into a graph, then serves that graph to AI editors over MCP. Everything below is about the part nobody asks for in a feature list: what happens to that graph when it gets old.
Why the context has to be cached at all
The obvious fix is to never cache. Answer every question from a live account read.
That does not survive contact with an actual session. A full infrawise analyze walks every enabled service, paginating through DynamoDB DescribeTable, Lambda configurations and their event source mappings, SQS queue attributes, SNS subscriptions and filter policies, Secrets Manager rotation state, S3 versioning and public-access configuration, ElastiCache clusters, CloudWatch log groups, plus schema introspection against Postgres, MySQL, or MongoDB, plus a local IaC parse, plus an AST scan of the repository. Every extractor is dispatched through a single Promise.all, so wall-clock time is bounded by the slowest one rather than their sum, but it is still seconds, not milliseconds.
An assistant calls get_infra_overview at the start of a task, analyze_function when it opens a handler, get_table_schema before writing a query. Re-extracting the account on each of those calls would make the tools unusable, and it would hammer AWS APIs with describe calls on every keystroke-adjacent action.
So the graph is cached. Which means the graph goes stale. The only real question is whether the tool bounds that staleness and reports it, or lets it drift silently.
What actually expires, and when
The cache is a directory of JSON files under .infrawise/cache, next to your infrawise.yaml. Each entry stores three things: the data, the timestamp it was written, and a cache version.
Reads are TTL-checked, and the check is deliberately blunt:
export function readCache<T>(key: string, maxAgeMs = 3600000): T | null {
const entry = readEntry<T>(key);
if (!entry) return null;
if (entry.version !== CACHE_VERSION) return null;
if (Date.now() - entry.timestamp > maxAgeMs) return null;
return entry.data;
}
An expired entry does not return old data with a warning attached. It returns null, which every caller treats as "no cache" and handles by re-analyzing. There is no code path that serves data past its TTL, because a warning is something a caller can ignore and a null is not.
The graph, the findings, and the raw AWS/DB metadata all use the same 24-hour TTL. That number is not arbitrary, and getting there took one bad bug. The metadata cache originally used the function's 1-hour default while the graph used 24 hours. In a long-running serve session, that mismatch meant every graph rebuilt after the first hour came back with an empty metadata half: no table schemas, no Lambda configs, no queue attributes. Findings that depend on that metadata silently stopped being generated. Not an error, not a warning, just fewer findings than an hour ago. The comment in runCodeRefresh still records why the TTLs are now unified:
// Same 24h TTL as the graph cache — a shorter TTL here silently dropped all
// AWS/DB metadata from refreshed graphs once a serve/stdio session passed 1h.
const cached = readCache<CachedMeta>('meta', 24 * 60 * 60 * 1000);
The general lesson is worth stating plainly: when two caches feed one derived result, different TTLs produce a partially-empty result rather than an error. Partial results are the worst kind, because they look like a correct answer to a smaller question.
Refresh happens at the boundary you already have
Both transports share one bootstrap, and it tries the cache first. Running infrawise serve over HTTP, that looks like:
✓ Config loaded infrawise.yaml
✓ Cached analysis loaded 42 nodes · 18 edges · 7 finding(s)
If the entries are missing or older than 24 hours, readCache returns null, the bootstrap warns No cache found — running analysis now..., and it re-analyzes before serving a single tool call. You never run a refresh command. Session start is the refresh trigger, because session start is the moment you were already going to wait a few seconds.
When your editor launches infrawise serve --stdio from .mcp.json instead, the same bootstrap runs with its success channel silenced and warnings routed to stderr with an infrawise: prefix — stdout belongs to MCP JSON-RPC, and a stray status line there corrupts the protocol stream.
Inside a session, file saves take a cheaper path. The watcher debounces for 2 seconds, ignores anything outside .ts, .tsx, .js, .jsx, .mjs, and .cjs, and then calls runCodeRefresh, which re-runs the AST scan and the local IaC parse and rebuilds the graph on top of the cached AWS and database metadata. No AWS calls. This is the right trade: the thing that changed when you hit save is your code, not your account. Add a .scan() call to a handler and the scan edge is in the graph on the next debounce tick, without a single describe call leaving your machine. The infrastructure half of that graph is still whatever was cached, bounded by the same 24 hours.
Making age visible instead of silent
Bounding staleness is half the job. The other half is telling the consumer how old the facts are, so it can decide.
get_infra_overview returns a freshness object alongside the actual data:
{
"analyzedAt": "2026-08-07T09:14:22.019Z",
"ageSeconds": 98400,
"stale": true,
"hint": "Analysis is stale — run `infrawise analyze` to refresh."
}
analyzedAt comes from readCacheTimestamp, a separate read that deliberately ignores the TTL — its whole job is to report age, so applying an expiry to it would defeat the purpose. The stale flag flips past 24 hours, matching the TTL that drives auto-refresh, and the hint field only appears when stale is true.
This exists because the assistant is the one deciding whether to trust the answer. If it is about to tell you a queue has no DLQ, the difference between a 40-second-old graph and a two-day-old one matters, and only the tool knows which one it is holding. Handing over the timestamp costs one field and removes the entire class of confidently-wrong answers described at the top of this post.
When the server boots with no analysis at all, analyzedAt is null and stale is false. Unknown age is reported as unknown rather than as fresh — a null timestamp defaulting to "current" would be exactly the silent lie the field exists to prevent.
Conclusion
Caching infrastructure context is not optional; extraction is far too expensive to run per question. What is optional is whether the staleness that caching creates stays silent. Every design decision here points the same direction: expired reads return null instead of stale data, the two caches that feed one graph share a TTL so they cannot go half-empty, refresh is attached to session start rather than a command you must remember, and the age of the loaded analysis ships as a field in the response so the consumer can weigh it.
If you want the same behavior in your editor, npx infrawise start --claude writes .mcp.json and hands your assistant the graph — GitHub · npm.
Key Takeaways
- A stale cache is more dangerous than an empty one, because it produces specific, confident, wrong answers instead of visibly uncertain ones.
- Return
nullpast the TTL rather than stale-with-a-warning. Callers ignore warnings; they cannot ignore anull. - When several caches feed one derived result, give them the same TTL. Mismatched TTLs produce silently partial results, which look like correct answers to smaller questions.
- Tie refresh to a boundary the user already pauses at — session start — instead of a command they have to remember to run.
- Ship the age of your data as a field in the response. The consumer, not the cache, should decide whether 26 hours old is good enough.
Top comments (15)
The "expired → null, never stale-with-a-warning" call is the right one, and I'd generalize it further: this isn't just a caching problem, it's a "does the tool know what it doesn't know" problem.
I hit the same failure shape from a different angle building an MCP codebase-intelligence server — a symbol lookup would silently resolve to a shadow definition in a different part of the repo, and the wrong answer looked exactly as confident as a right one (same schema, same format, no signal anything was off). No TTL involved, but the root cause is identical to your metadata/graph TTL mismatch: two sources of truth that can silently diverge, and nothing downstream can tell.
The
freshnessobject is a good pattern precisely because it turns an invisible failure mode into a visible field the caller can act on. Curious if you've thought about the same idea for provenance rather than just age — e.g. flagging when a graph node was reconstructed from a stale sub-source vs a fresh one, not just "the whole graph is N seconds old."Does the tool know what it doesn't know" is the better framing, and the shadow-definition case is the nastier version of it because there's no timestamp to hang a warning on at all. Infrawise has two half-steps toward the per-node provenance you're describing: CDK-sourced stack outputs carry their own
stale: trueplus astaleReasonwhen the template they came from is an orphan the manifest no longer references, and resources that exist only as an unresolvable code reference (QueueUrl: process.env.QUEUE_URL) stay in the graph markedplaceholder: trueand are excluded from findings entirely, so it won't claim "this queue has no DLQ" about a queue whose config it never read. Both are the same instinct as the freshness object: make the gap a field instead of an omission. What I haven't done is generalize that into a per-node source watermark, so a node reconstructed from a stale sub-source is currently indistinguishable from a fresh one once it's in the graph. Filed it as github.com/Sidd27/infrawise/issues... along with letting a caller state an age tolerance per call, since "what does this architecture look like" and "does queue X have a DLQ right now" clearly shouldn't share one staleness budget. Thanks for the push on this one.Good to see this turned into #101 and #102 — and now that both are up, it looks like there might be a third bucket worth naming alongside "stale" and "failed extraction": wrong-source. I hit that version building an MCP codebase-intelligence server — a symbol lookup would successfully resolve, with no error and no missing data, just to the wrong definition (a same-named shadow in experiments/ instead of the real one in src/). Not absent, not aged, just resolved against the wrong evidence.
Doesn't block #101/#102 landing in order — just flagging it in case the per-source records from #101 end up being a natural place to also record "which of N candidate matches did this actually resolve to," not just "did the source succeed."
Wrong-source is the right third bucket, and your comment sent me to check my own resolution paths — one of them has exactly the bug you're describing. Two of the three refuse to guess already: short-name table qualification detects when "orders" maps to two schema-qualified tables and deliberately declines to bind, falling back to a placeholder rather than picking one, and
get_table_schemareturns every match instead of choosing. Butanalyze_functionlooks up by name with a plain.find()and takes the first hit, even though function node IDs are file-scoped, so a repo withhandlerin bothsrc/andexperiments/gets whichever one the AST scan reached first, with no signal that a second candidate existed. Successfully resolved, no error, no missing data, wrong definition. So you're right that this doesn't fit either bucket: #101 records whether a source could be read and #102 records how old it was, and neither would have caught a lookup that read a fresh, complete source and picked the wrong row out of it. The per-source records do look like the natural place to hang "N candidates, resolved to this one, here's why" — with the honest default being that N > 1 without a tiebreaker returns all of them rather than the first. Filing it separately, and thanks for making me go look.The "N > 1 without a tiebreaker returns all" default is the right call — it shifts the burden of ambiguity to the caller, where it belongs, instead of hiding it in a silent first-match.
I ended up at the same place with the symbol resolver: when there are multiple candidates, returning all of them with their source paths forces the caller to either pick explicitly or surface the ambiguity. The cases where .find() feels safe are exactly the cases where a second candidate shows up six months later and nobody notices.
A second candidate shows up six months later and nobody notices" is exactly what made me go looking rather than just patching the one case. analyze_function now returns every match with its file plus ambiguous: true when more than one hit, shipped in 0.23.1. The sweep afterwards was the useful part, though it turned up a different bucket than wrong-source: get_table_schema had no source mapping at all, so a failed postgres extraction still answered found: false for every postgres table — an explicit "no such table" about a database that was never read — and get_stream_details only checked whether Kinesis had been read, so an MSK permission error returned kafkaClusters: [] with nothing to indicate half the tool went blind. Neither is a wrong-source resolution, they're the unread-source case, but the shape is the same one you named: a confident answer whose evidence nobody checked. Both are fixed, and the fail-closed work from #101 landed too — an unreadable source now answers with the source name and the error instead of an empty list. The thing I did not anticipate is that "disabled in config" needed handling as carefully as "failed": it's the far more common reason a list comes back empty, and it was equally silent. Thanks for the nudge, it was worth more than the one bug it started with.
"disabled in config" needing the same treatment as "failed" is a case I keep hitting too — it's the most common reason a list is empty and the easiest one to silently mislabel as clean. Good to see it handled the same way.
Strong framing. I’d make freshness part of each tool call’s contract, not only overview metadata. Different questions tolerate different ages: an architecture overview may accept 24 hours, while “does queue X have a DLQ right now?” should request a much smaller
maxAgeSecondsor force a live read. Return per-source watermarks and completeness together with account, region, and effective principal; a fresh snapshot taken with narrower permissions can look exactly like a missing resource. For negative claims, fail closed unless every required source is complete within the requested age. Useful tests: skewed source timestamps, an omitted region, expired credentials, and a resource changing mid-refresh.The per-call
maxAgeSecondspoint lands, and the permissions one sent me back to the code where it turned out worse than I'd have guessed. Infrawise runs every extractor through a helper that catches adapter failures, logs a warning to the terminal, and returns undefined so one bad service never aborts the run. Which means an AccessDenied onsqs:ListQueuesproduces a graph with zero queues, identical in every respect to an account that genuinely has none, and the DLQ analyzer then finds nothing to flag. The absence of a finding reads as a clean bill of health. Nothing records the effective principal either, so there's no way to detect it after the fact. That's a bug rather than a missing feature and I've filed it as github.com/Sidd27/infrawise/issues... — record per-source outcomes and caller identity, then fail closed on negative claims so an unreadable source answers "unknown, the SQS adapter failed" instead of silence. Your test list went in verbatim; the mid-refresh mutation and omitted-region cases are the ones I wouldn't have thought to write. Appreciate it.Thanks for sharing this, very informative
This is a really good point, especially the part about stale data looking more trustworthy than missing data. I’ve been thinking about similar problems while working on CodeCan.net—sometimes having old information available can be more confusing than having no information at all. Making the data age visible is a simple idea, but it can make a big difference when an AI is making decisions based on that context.
The DLQ example is the exact failure mode that makes me distrust grounded agent answers more than vague ones. A stale ARN with real table names is worse than a guess, because the confidence is real even when the facts expired. Returning null past TTL instead of a soft warning is the right call. Callers ignore warnings under deadline pressure, they cannot ignore a missing tool result. The part I would push further is putting the snapshot age in the agent reply itself, so the human sees as of Monday next to the claim before they act on it.
Actually I m also thinking on the same line but I thought maybe its overkill but now I see as more people are inline with this tough I might surface the age directly in tool response with hint so that if needed agent can surface that.
Making freshness explicit instead of silently serving stale context feels like an important design choice. I’m curious whether you’ve considered letting downstream decision logic declare its own freshness and completeness requirements, so stale or unavailable evidence produces an unresolved decision rather than simply being consumed as context.
Half of that shipped this week; the other half turned out to hinge on who the downstream is. A caller can now declare its own freshness requirement per call — maxAgeSeconds — and the response reports whether the data met it. What I deliberately didn't do is refuse: for a coding assistant a labelled answer beats an unresolved one, since a twenty-hour-old snapshot is fine for an architecture question, and refusing moves a judgement into the tool that only the caller can make. Decision logic is different, and there it behaves as you describe — analyzers fire only on explicit evidence, never on absence, so a flag reading false means the setting is off while undefined means it was never read, and only the first produces a finding. Where your framing lands hardest is the CI gate: completeness is arguably already declared, since enabling a service is the statement that you require it, yet a run where an enabled source failed still exits zero. It warns, but doesn't refuse to certify. That's the unresolved-decision case sitting in plain sight, and it needs no new configuration — just the honesty to say a green build on infrastructure that was never read isn't green.