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 (5)
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.That distinction — stale as "aged" vs placeholder as "never actually read" — is a cleaner split than what I was gesturing at. Filing the per-call age tolerance separately makes sense too; "does X have a DLQ right now" and "what's the general shape of this architecture" really are different consumers of the same graph with different risk tolerance.
The shadow-definition case I mentioned ended up needing something closer to your placeholder idea than a staleness field — the fix wasn't "flag it as old," it was "don't let a definition from experiments/ or tests/ resolve for a src/ symbol at all," which is a scoping problem more than a freshness one. Sounds like your two half-steps already cover both flavors (aged vs never-real) better than I gave credit for in the first comment.
Good luck with #102 — following it.
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.