<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Siddharth Pandey</title>
    <description>The latest articles on DEV Community by Siddharth Pandey (@siddharth_pandey_27).</description>
    <link>https://dev.to/siddharth_pandey_27</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1878784%2Fc5935d13-014b-42fe-a1ab-a9374ff32cbb.jpg</url>
      <title>DEV Community: Siddharth Pandey</title>
      <link>https://dev.to/siddharth_pandey_27</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/siddharth_pandey_27"/>
    <language>en</language>
    <item>
      <title>Your AI context gets worse the longer you work</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Sun, 06 Sep 2026 17:48:07 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/your-ai-context-gets-worse-the-longer-you-work-5g18</link>
      <guid>https://dev.to/siddharth_pandey_27/your-ai-context-gets-worse-the-longer-you-work-5g18</guid>
      <description>&lt;p&gt;I had my own infrastructure context server running behind my editor, the way I do every day, and noticed it was telling me to re-run the analysis on almost every response. Not occasionally. Almost every request.&lt;/p&gt;

&lt;p&gt;My first guess was that I had set a threshold too low. I had not. The refresh hint was doing exactly what it was configured to do — warn after six hours, with a twenty-four hour cache behind it. Those numbers were fine. What had actually happened was that the session had been running long enough for the context layer to quietly empty itself out, while still answering every question in a well-formed, confident-looking way.&lt;/p&gt;

&lt;p&gt;That bug is worth writing about, not because the fix is interesting, but because I have now shipped the same class of bug four separate times, and I do not think it is specific to my tool. If you give an AI assistant a live connection to your infrastructure, you have not built a data source. You have built a long-running process. Long-running processes fail in ways one-shot fetches never do, and the AI-shaped version of that failure is unusually quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  A context layer is a process, not a fetch
&lt;/h2&gt;

&lt;p&gt;The mental model most people have — mine included, for longer than I would like — is that infrastructure context is a lookup. The assistant needs to know your DynamoDB partition key, something goes and gets it, the answer comes back. Under that model the only interesting question is whether the data is correct.&lt;/p&gt;

&lt;p&gt;That is not what actually runs. What runs is a server that boots once, reads your cloud account once, and then stays alive for as long as your editor does — hours, sometimes days — answering from what it has while incrementally updating parts of itself. It watches your source files. It rebuilds a graph on save. It re-checks its cache before every tool call. All of that incremental machinery exists because the alternative is hitting your AWS account on every question an assistant asks, and assistants ask a lot of questions.&lt;/p&gt;

&lt;p&gt;Every one of those incremental paths is an opportunity for the long-lived state to drift away from what a fresh start would have produced. Here is the same failure, four times, from one repository's history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The analyzer that stopped running.&lt;/strong&gt; An issue filed against the project reads: &lt;code&gt;LambdaMissingTriggerDLQAnalyzer&lt;/code&gt; is in the analyzer list used by the full analysis, and absent from the one used by the file-save refresh. The consequence, quoted from the issue: "After the initial &lt;code&gt;infrawise analyze&lt;/code&gt;, every file-save triggers &lt;code&gt;runCodeRefresh&lt;/code&gt;. From that point forward, all trigger-DLQ findings permanently disappear from the live MCP server's finding set." One missing line in a second list. Save a file once, and a category of finding is gone for the rest of the session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The server that answered from its boot snapshot.&lt;/strong&gt; For a while, running a fresh analysis in a second terminal changed nothing for the editor session already open. The fix commit puts it plainly: "a session served its boot snapshot indefinitely and re-running analyze appeared to do nothing."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The caches that expired at different rates.&lt;/strong&gt; The analysis is stored as several entries — the graph, the findings, the cloud metadata, the record of what was read and when. These used to expire on different clocks: the graph at twenty-four hours, the metadata and provenance at one. A session that ran past an hour served a graph whose companion entries had silently gone null.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The most recent one.&lt;/strong&gt; Same shape, longer fuse. The file-save refresh rewrites the graph but reads no cloud API, so it rewrites three cache entries and not the other two. That resets the clock on three of them. Past the twenty-four hour mark, a session where you keep editing code holds the graph alive indefinitely while the metadata underneath it expires — after which the rebuilt graph contains no cloud facts at all, and the timestamp saying when the cloud was last read goes null. And a null read-time is treated as "cannot vouch for this", which pins the refresh hint to &lt;em&gt;true&lt;/em&gt; on every subsequent response. Which is the behaviour I opened with.&lt;/p&gt;

&lt;p&gt;Four bugs, one shape: &lt;strong&gt;the incremental path diverged from the full path, and nothing noticed because the divergence produced a valid-looking answer.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nobody catches these
&lt;/h2&gt;

&lt;p&gt;Every one of those failures degrades toward a well-formed response.&lt;/p&gt;

&lt;p&gt;A missing analyzer does not throw. It produces a findings array that is shorter. An empty findings array is exactly what a healthy account looks like. Expired metadata does not throw either — it produces a graph with fewer nodes, and a graph with fewer nodes is exactly what a smaller account looks like. A null timestamp does not throw. It produces a response where one field is &lt;code&gt;null&lt;/code&gt;, in a payload where plenty of fields are legitimately null.&lt;/p&gt;

&lt;p&gt;None of that trips an alarm, because from the outside a degraded context layer and a simple environment are byte-for-byte indistinguishable. The tool call succeeded. The JSON parsed. The schema validated.&lt;/p&gt;

&lt;p&gt;And then the failure gets laundered through an LLM, which is where it stops being a data problem and becomes a correctness problem. An assistant handed an empty findings list does not say "this list is suspiciously empty for a repository this size." It says your infrastructure looks clean. Handed a graph with no tables in it, it does not ask where the tables went — it writes the query anyway, guessing at a schema, in exactly the confident register it uses when it &lt;em&gt;does&lt;/em&gt; know. The whole reason to wire real infrastructure into an assistant is to replace guessing with facts. A silently emptied context layer removes the facts and leaves the confidence.&lt;/p&gt;

&lt;p&gt;This is the part that makes it worse than having no integration at all. An assistant with no infrastructure access hedges: &lt;em&gt;I do not have visibility into your tables, you may want to check.&lt;/em&gt; That hedge sends you to the console. An assistant with a broken infrastructure integration does not hedge, because as far as it can tell the integration answered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually check
&lt;/h2&gt;

&lt;p&gt;If you run any long-lived context server — mine, someone else's, or one you wrote — these are the questions worth asking of it. They are the ones I got wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the incremental path produce the same thing as a cold start?&lt;/strong&gt; This is the single highest-value check, and it is testable: build state the slow way, build it the fast way, compare. Two of the four bugs above are exactly this and nothing more. If your refresh path maintains its own list of anything — analyzers, extractors, sources — that list will drift from the authoritative one, because nothing forces someone adding an entry to one to add it to both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does related state expire together?&lt;/strong&gt; If your cached analysis is several pieces, they describe one moment and they must live and die as one. Independent expiry does not produce an error, it produces a chimera: half of Tuesday's read, half of nothing. The fix in my case was to re-stamp the entries the refresh path does not rewrite, so all of them stay on one clock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can it distinguish "nothing there" from "did not look"?&lt;/strong&gt; Every layer between the cloud API and the model needs to preserve this, and by default none of them do — a failed call and an empty account both reduce to &lt;code&gt;[]&lt;/code&gt; the moment someone writes a &lt;code&gt;catch&lt;/code&gt; that returns a default. If the answer reaching the model is an empty array either way, the model will report an empty account. Carry the failure alongside the data, all the way to the response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a response say when its facts were read?&lt;/strong&gt; Not when the object was assembled or when the cache file was written — those move for reasons that have nothing to do with your cloud. Rebuilding a graph from cached metadata produces a brand-new object full of old facts, and dating it by its construction time is a lie in the most convincing possible format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Have you run one session for a full day?&lt;/strong&gt; Every bug in this article requires uptime to reproduce. A test suite starts fresh, does its thing, and exits in milliseconds. None of these would ever appear there. The most recent one needs twenty-four hours of continuous uptime &lt;em&gt;plus&lt;/em&gt; file edits — a combination that occurs constantly in real use and never once in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;Feeding an assistant real infrastructure data raises the ceiling on how good its answers can be. It also raises the floor on how bad a silent failure is, because you have removed the reader's reason to double-check. Every bug above made an assistant more confident and less correct at the same time, and the increase in confidence is the part you cannot see.&lt;/p&gt;

&lt;p&gt;So the interesting engineering in an AI-aware infrastructure layer is not the extraction. Reading DynamoDB key schemas is a solved problem, and any competent developer can wire up the AWS SDK in an afternoon. The interesting engineering is everything that keeps the layer honest about its own state over a workday: making the fast path agree with the slow path, keeping related state on one clock, refusing to let a failure decay into an empty list, and dating every claim by when it was actually read.&lt;/p&gt;

&lt;p&gt;I build this in the open at &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — it ships on &lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt; — and the commit history is a fairly honest record of getting each of these wrong first. If you are building something similar, the four checks above will save you the versions I shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Treat your context layer as a long-running process, not a data source.&lt;/strong&gt; Its worst failures require uptime to reproduce and will never appear in a test suite that starts fresh.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test that the incremental path matches a cold start.&lt;/strong&gt; Any refresh path holding its own list of analyzers, extractors, or sources will drift from the authoritative one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expire related cache entries as one unit.&lt;/strong&gt; Independent TTLs on pieces of a single snapshot produce a half-and-half state that looks valid and is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let a failed read decay into an empty result.&lt;/strong&gt; An empty list and a failed call are the same value; only one of them means "nothing is there."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Date every fact by when it was read, not when it was assembled.&lt;/strong&gt; Rebuilding from cache creates a new object holding old facts, and timestamping it by construction is the most convincing lie available.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>typescript</category>
      <category>ai</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Your agent trusts the first match. Should it?</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Tue, 01 Sep 2026 09:09:03 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/your-agent-trusts-the-first-match-should-it-350k</link>
      <guid>https://dev.to/siddharth_pandey_27/your-agent-trusts-the-first-match-should-it-350k</guid>
      <description>&lt;p&gt;Someone left a comment on one of my posts describing a bug I had not written about, in a tool I did not build.&lt;/p&gt;

&lt;p&gt;They had been working with an MCP codebase-intelligence server. They asked it about a symbol. It answered with a file path, a definition, and a list of references, all well-formed. The answer described a shadow definition sitting in &lt;code&gt;experiments/&lt;/code&gt;, not the real one in &lt;code&gt;src/&lt;/code&gt;. Nothing errored. Nothing was stale. The tool had found two definitions with the same name and returned whichever one it reached first.&lt;/p&gt;

&lt;p&gt;I went and read my own code. &lt;code&gt;analyze_function&lt;/code&gt; is the tool an assistant is supposed to call before writing a handler: it reports which tables that function queries, how it queries them, which permissions its role is missing, and the exact event shape of its trigger. It looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;funcNode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentGraph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;functionName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.find()&lt;/code&gt;. First match wins, silently. That is &lt;a href="https://github.com/Sidd27/infrawise/issues/103" rel="noopener noreferrer"&gt;issue #103&lt;/a&gt;, and the person who caused me to open it was describing someone else's product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode that has no error to report
&lt;/h2&gt;

&lt;p&gt;I had already thought about two ways infrastructure context goes wrong, and shipped fixes for both.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;unread&lt;/strong&gt;. A source fails to extract, the tool returns an empty list, and an empty list reads exactly like "there is nothing there". Ask whether a queue has a dead-letter queue after the SQS extractor threw a permissions error, and "no DLQ configured" is a sentence the tool has no right to say. That is &lt;a href="https://github.com/Sidd27/infrawise/issues/101" rel="noopener noreferrer"&gt;issue #101&lt;/a&gt;, and the fix was to attach a per-source status to every response so a failed read is never mistaken for an absent resource.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;stale&lt;/strong&gt;. The data was read correctly, and then someone ran &lt;code&gt;terraform apply&lt;/code&gt;. The snapshot is internally consistent and describes an account that no longer exists. That is &lt;a href="https://github.com/Sidd27/infrawise/issues/102" rel="noopener noreferrer"&gt;issue #102&lt;/a&gt;: every response now carries when the infrastructure was read and how long ago, so a caller can judge a three-day-old answer against the question it is answering.&lt;/p&gt;

&lt;p&gt;Issue #103 is neither. The extraction succeeded. The data is seconds old. Every field in the response is a real value read from a real source. The response is simply about a different function than the one you asked about, and there is no signal anywhere in it that says so. Freshness metadata does not help. Source status does not help. Both report, correctly, that everything worked.&lt;/p&gt;

&lt;p&gt;This is the part I want to argue about, because it changes what a tool owes its caller. Staleness and read failures are conditions you can attach metadata to. Wrong-candidate resolution is a property of the function signature. A lookup that returns one thing when two things matched has already destroyed the evidence that a choice was made. No amount of metadata bolted onto the response can reconstruct it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the wrong answer is the confident one
&lt;/h2&gt;

&lt;p&gt;Function node IDs in the graph are file-scoped. They are built as &lt;code&gt;function:${op.filePath}:${op.functionName}&lt;/code&gt; (&lt;code&gt;src/graph/index.ts:452&lt;/code&gt;), so &lt;code&gt;getOrder&lt;/code&gt; in &lt;code&gt;src/handler.ts&lt;/code&gt; and &lt;code&gt;getOrder&lt;/code&gt; in &lt;code&gt;experiments/handler.ts&lt;/code&gt; are two genuinely distinct nodes with two distinct sets of outgoing edges.&lt;/p&gt;

&lt;p&gt;The lookup matched on &lt;code&gt;n.name&lt;/code&gt; alone. Which node came back depended on the order the AST scan happened to walk the file tree.&lt;/p&gt;

&lt;p&gt;Now trace what an assistant does with that. It calls &lt;code&gt;analyze_function&lt;/code&gt; for &lt;code&gt;getOrder&lt;/code&gt; because it is about to modify &lt;code&gt;src/handler.ts&lt;/code&gt;. It gets back &lt;code&gt;found: true&lt;/code&gt;, a real file path, a real list of table accesses, real findings. Suppose the scan reached &lt;code&gt;experiments/handler.ts&lt;/code&gt; first: that scratch file queries &lt;code&gt;public.users&lt;/code&gt;, while the real handler scans &lt;code&gt;public.orders&lt;/code&gt; and has a high-severity finding attached to that scan. The assistant now believes the function it is editing touches &lt;code&gt;users&lt;/code&gt;, has no scan problem, and needs no index work. Every downstream decision it makes is coherent, well-reasoned, and built on the wrong file.&lt;/p&gt;

&lt;p&gt;Compare that to a tool that fails loudly. A permissions error is annoying and it is honest. You retry, you fix the role, you move on. A well-formed answer about the wrong file is worse than an error, because nothing in your workflow is designed to catch it. You are not going to double-check a response that looks perfectly correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same bug, one layer down
&lt;/h2&gt;

&lt;p&gt;Once I knew the shape, I found it again in the AST scanner. &lt;a href="https://github.com/Sidd27/infrawise/issues/44" rel="noopener noreferrer"&gt;Issue #44&lt;/a&gt;: resolving an identifier to its string value did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;sourceFile&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDescendantsOfKind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SyntaxKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VariableDeclaration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A file-wide search for a variable name, returning the first declaration found, regardless of which scope the call site was in. Two functions in one file, each with its own &lt;code&gt;const tableName&lt;/code&gt;, and every query in the second function got attributed to the first function's table.&lt;/p&gt;

&lt;p&gt;The consequences compound in both directions. Edges land on the wrong table node, so an analyzer flags a missing index on a table that does not need one, and misses a full scan on the table that does. A false finding and a suppressed real finding, from one wrong resolution.&lt;/p&gt;

&lt;p&gt;The fix was to stop searching by name and ask the type checker instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isIdentifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;symbol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSymbol&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decl&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDeclarations&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isVariableDeclaration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decl&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInitializer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;resolveStringValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sourceFile&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getSymbol()&lt;/code&gt; resolves the identifier the way TypeScript itself resolves it, from the call site outward through enclosing scopes. The name-matching search was never resolution. It was a guess that happened to be right most of the time, which is the most dangerous kind of wrong.&lt;/p&gt;

&lt;p&gt;Two different layers of the same codebase, written months apart, both reached for "find the thing with this name" and both got it wrong in the same way. That is not carelessness. &lt;code&gt;.find()&lt;/code&gt; is what the language hands you when you ask for a lookup, and it produces a value rather than a complaint. The API shape pulls you toward the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the codebase was already doing right
&lt;/h2&gt;

&lt;p&gt;The uncomfortable part of issue #103 was that two other resolution paths in the same file already handled ambiguity properly. I had solved this problem, twice, and then not applied it in the third place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short-name table qualification.&lt;/strong&gt; SQL text names tables unqualified (&lt;code&gt;orders&lt;/code&gt;), while extracted nodes are schema-qualified (&lt;code&gt;public.orders&lt;/code&gt;), so code edges have to be resolved against the extracted schema. When a short name maps to more than one qualified table, the map stores an empty string as a poison value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;qualifiedByShortName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;qualifiedByShortName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The empty string is falsy, so &lt;code&gt;qualify()&lt;/code&gt; falls through to a placeholder schema instead of binding to one of the two real tables. A collision produces a node that is visibly unresolved rather than an edge pointing confidently at a coin flip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema lookup.&lt;/strong&gt; &lt;code&gt;get_table_schema&lt;/code&gt; uses &lt;code&gt;filter&lt;/code&gt;, not &lt;code&gt;find&lt;/code&gt;. Ask for &lt;code&gt;orders&lt;/code&gt; and you get every table matching that short name across every database, and the tool contract says so explicitly. When nothing matches, it returns up to five suggestions instead of an empty result that might be read as "no such table".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Lambda linkers.&lt;/strong&gt; Both linkers that connect a deployed Lambda to the source function implementing it contain the same line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The IaC linker reads handler paths out of Terraform or CDK and links only when exactly one source function matches both the file base and the export name; those links are marked &lt;code&gt;confidence: 'proven'&lt;/code&gt;. The heuristic linker normalizes names and links only when exactly one function matches; those are &lt;code&gt;confidence: 'inferred'&lt;/code&gt;. Either way, two candidates means no link at all. The graph would rather have a missing edge than a wrong one.&lt;/p&gt;

&lt;p&gt;So the pattern was established. &lt;code&gt;analyze_function&lt;/code&gt; was the one place that had not adopted it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Return the fork, do not resolve it
&lt;/h2&gt;

&lt;p&gt;The fix is small, which is usually the case once the shape is named:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;funcNodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentGraph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;functionName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;filter&lt;/code&gt; instead of &lt;code&gt;find&lt;/code&gt;. Then per-file detail moves into a &lt;code&gt;matches&lt;/code&gt; array, one entry per source file defining a function with that name, each with its own &lt;code&gt;file&lt;/code&gt;, &lt;code&gt;accesses&lt;/code&gt;, and &lt;code&gt;missingPermissions&lt;/code&gt;, because all three derive from that specific node's edges. When more than one matched, the response carries &lt;code&gt;ambiguous: true&lt;/code&gt;, so the caller sees a fork rather than having to notice that an array got longer.&lt;/p&gt;

&lt;p&gt;The regression test is deliberately literal about the scenario from the comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;analyze_function returns every same-named function, not just the first&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It builds a graph with &lt;code&gt;getOrder&lt;/code&gt; in &lt;code&gt;handler.ts&lt;/code&gt; and &lt;code&gt;getOrder&lt;/code&gt; in &lt;code&gt;experiments/handler.ts&lt;/code&gt;, then asserts &lt;code&gt;ambiguous&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;matches&lt;/code&gt; has length 2, and the second match's access resolves to the table only the shadow file touches. If someone reintroduces a &lt;code&gt;.find()&lt;/code&gt;, that test fails on the length assertion before anything else.&lt;/p&gt;

&lt;p&gt;The same rule applies one level up, where several deployed Lambdas share a handler path. &lt;code&gt;index.handler&lt;/code&gt; repeated across a stack is the ordinary case, not the exotic one, and it means several Lambda nodes legitimately link to a single source function. When that happens the tool returns &lt;code&gt;candidateLambdas&lt;/code&gt; with each Lambda name and its link confidence, and withholds the triggers entirely, because attaching one Lambda's SQS trigger to code shared by five of them is exactly the confident-and-wrong answer this whole exercise is about. When exactly one Lambda links, you get &lt;code&gt;resolvedLambda: { lambda, confidence }&lt;/code&gt; instead, and the triggers come with it.&lt;/p&gt;

&lt;p&gt;Notice what is deliberately absent: there is no &lt;code&gt;file&lt;/code&gt; input for disambiguating the call. Adding one would move the decision back to the caller before the caller knows the fork exists. Returning the candidates lets whoever asked pick using context the tool does not have, which is usually just "the file I currently have open".&lt;/p&gt;

&lt;p&gt;That is the tradeoff I would defend. The response got larger and slightly harder to consume. An assistant now has to read &lt;code&gt;ambiguous&lt;/code&gt; and decide, rather than taking a single answer and running. In exchange, there is no configuration of the repo where the tool asserts something it did not prove.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general rule
&lt;/h2&gt;

&lt;p&gt;If a lookup can match more than one thing, the return type has to be able to say so. A tool that resolves ambiguity internally is not saving its caller work, it is making a decision on the caller's behalf using less information than the caller has, and then hiding that a decision happened at all.&lt;/p&gt;

&lt;p&gt;Three shapes are honest: return every candidate, return nothing and say why, or return one with an explicit confidence marker. What is not honest is returning one of several as if it were the only one. Once you look for it, &lt;code&gt;.find()&lt;/code&gt; on a name shows up everywhere, and each one is a small silent assertion that names are unique when the code plainly says they are not.&lt;/p&gt;

&lt;p&gt;For an AI assistant this matters more than it does for a human reading the same output. A person who gets back &lt;code&gt;experiments/handler.ts&lt;/code&gt; when they asked about &lt;code&gt;src/handler.ts&lt;/code&gt; notices the path. An assistant folds it into context and moves on, and the wrong file is now a premise for everything it writes next.&lt;/p&gt;

&lt;p&gt;Infrawise is on &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and &lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt; if you want to see how the graph, the linkers, and the MCP tools fit together. &lt;code&gt;npx infrawise start&lt;/code&gt; gets you a live analysis and an &lt;code&gt;.mcp.json&lt;/code&gt; without a config file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fresh, successful, and wrong is a distinct failure mode from stale or unread, and neither freshness metadata nor source-status metadata will catch it. It lives in the function signature, not the response body.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.find()&lt;/code&gt; on a name is an assertion that names are unique. In a file-scoped graph, in a scoped language, or across the stacks of a monorepo, that assertion is false more often than it looks.&lt;/li&gt;
&lt;li&gt;A wrong answer that is well-formed is more expensive than an error, because nothing downstream is built to question it.&lt;/li&gt;
&lt;li&gt;If ambiguity is possible, make the return type able to express it: all candidates, or nothing with a reason, or one with a confidence marker. Not one of several, unmarked.&lt;/li&gt;
&lt;li&gt;Refusing to link is a valid outcome. &lt;code&gt;if (matches.length !== 1) continue;&lt;/code&gt; produces a graph with a missing edge instead of a wrong one, and a missing edge is a thing you can notice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a lookup in your codebase matches two things, what does it return today, and would you be able to tell from the output that there was ever a second candidate?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>programming</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Your retry logic is correct and does nothing</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Mon, 24 Aug 2026 18:12:14 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/your-retry-logic-is-correct-and-does-nothing-9oc</link>
      <guid>https://dev.to/siddharth_pandey_27/your-retry-logic-is-correct-and-does-nothing-9oc</guid>
      <description>&lt;p&gt;A comment on one of my posts sat open as an issue for two weeks before I understood how bad the thing it described actually was.&lt;/p&gt;

&lt;p&gt;The post was about an AI assistant missing the SQS trigger on a Lambda. Mads Hansen replied with a sentence I have not been able to unthink since: the trigger shape is the first contract, delivery and retry semantics are the second. Getting &lt;code&gt;event.Records[0].body&lt;/code&gt; right tells you how to read a message. It tells you nothing about what happens when one of ten messages in a batch throws.&lt;/p&gt;

&lt;p&gt;I filed it as &lt;a href="https://github.com/Sidd27/infrawise/issues/87" rel="noopener noreferrer"&gt;issue #87&lt;/a&gt;. The first item on that list is a bug no code reviewer can catch by reading the code, because the code is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The handler that is correct and does nothing
&lt;/h2&gt;

&lt;p&gt;Here is a batch consumer. It is the shape you get if you read the AWS docs and follow them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;processOrders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;Records&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;batchItemFailures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;itemIdentifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;batchItemFailures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;itemIdentifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;batchItemFailures&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine records succeed, one throws, and the handler reports exactly the one that failed. That is the entire point of a partial batch response: the nine that worked are deleted from the queue, the one that failed comes back on its own.&lt;/p&gt;

&lt;p&gt;Except this handler does not do that, because whether anyone listens to that return value is not decided in this file. It is decided by the event source mapping. If &lt;code&gt;FunctionResponseTypes&lt;/code&gt; on the mapping does not contain &lt;code&gt;ReportBatchItemFailures&lt;/code&gt;, Lambda discards the array. The whole batch is marked failed. All ten records are redelivered, including the nine that already ran to completion.&lt;/p&gt;

&lt;p&gt;The failure mode is duplicate processing. Nine orders get handled twice, or twenty times, because one poison message keeps replaying its batch until the queue's retry limit. If your handler is not idempotent, that is double-charged customers, not a log line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nobody catches this in review
&lt;/h2&gt;

&lt;p&gt;Read that handler again with a critical eye. There is nothing wrong with it. It builds the array correctly, it uses &lt;code&gt;messageId&lt;/code&gt; as the identifier, it catches per record rather than around the loop. A unit test that feeds it ten records and asserts one entry in &lt;code&gt;batchItemFailures&lt;/code&gt; passes.&lt;/p&gt;

&lt;p&gt;That is what makes this different from an ordinary bug. The code is not wrong. It is inert. The half of the contract that would make it work lives in a Terraform module in a different repository, or in a CDK stack a different team owns, or in a checkbox someone clicked in the console eighteen months ago. Nothing in your editor, your linter, or your test suite has any visibility into it.&lt;/p&gt;

&lt;p&gt;And an AI assistant, asked to "add partial batch failure handling to this consumer," writes exactly the handler above and reports the job done. It is not hallucinating. It wrote the only half it can see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ends of the same contract, two different findings
&lt;/h2&gt;

&lt;p&gt;When I built the check into &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;Infrawise&lt;/a&gt;, it became clear the mismatch has two directions and they are not equally bad.&lt;/p&gt;

&lt;p&gt;The first is the mapping-side gap. &lt;code&gt;MissingPartialBatchResponseAnalyzer&lt;/code&gt; looks for an &lt;code&gt;sqs&lt;/code&gt;, &lt;code&gt;kinesis&lt;/code&gt;, or &lt;code&gt;dynamodb&lt;/code&gt; trigger with a batch size above 1 and no &lt;code&gt;ReportBatchItemFailures&lt;/code&gt;. A batch of one has nothing to partially fail, so it is skipped. This one is graded &lt;strong&gt;medium&lt;/strong&gt;: it is a missing capability, but the handler may genuinely not need it.&lt;/p&gt;

&lt;p&gt;The second is the mismatch. &lt;code&gt;BatchResponseMismatchAnalyzer&lt;/code&gt; fires when the code builds a &lt;code&gt;batchItemFailures&lt;/code&gt; array and the mapping for that trigger has the setting off. That is graded &lt;strong&gt;high&lt;/strong&gt;, and the description says why:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The code reads as correct and its unit tests pass; only the mapping tells the truth.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A missing setting is an absence. A mismatch is an active false belief — someone wrote the handler on purpose, believing per-record reporting was on. Every downstream decision they made about idempotency rests on that belief.&lt;/p&gt;

&lt;p&gt;Detecting the code side is cheap. Both scanners just match the name: ts-morph looks for a property or shorthand property called &lt;code&gt;batchItemFailures&lt;/code&gt;, and the Python scanner matches the same dict key. There is no control-flow tracing to a return statement, because building an array by that name has no other purpose in a Lambda handler.&lt;/p&gt;

&lt;p&gt;The infrastructure side costs nothing either. &lt;code&gt;FunctionResponseTypes&lt;/code&gt; rides on the &lt;code&gt;ListEventSourceMappings&lt;/code&gt; response that trigger extraction already pages through, so the whole check is a field carried forward plus two analyzers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that keeps it honest
&lt;/h2&gt;

&lt;p&gt;One detail mattered more than the analyzers themselves. Both checks fire only on an explicit &lt;code&gt;false&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only an explicit false is evidence. undefined means the mapping was&lt;/span&gt;
&lt;span class="c1"&gt;// never read, and this finding would be an accusation without evidence.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reportsBatchItemFailures&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a &lt;code&gt;lambda:ListEventSourceMappings&lt;/code&gt; call was denied by IAM, or the page failed halfway, the field is &lt;code&gt;undefined&lt;/code&gt;, not &lt;code&gt;false&lt;/code&gt;. Treating that as "the setting is missing" would produce a high-severity finding about a mapping nothing was ever read from.&lt;/p&gt;

&lt;p&gt;That failure would be worse than staying silent. A tool that occasionally accuses you of a bug you do not have gets muted, and then it is not there on the day it is right. So the extractor raises a partial-extraction error carrying whatever it did page through: the data survives, the source is recorded as &lt;code&gt;partial&lt;/code&gt;, and the tools report it as unread rather than reporting a clean bill of health.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed in the loop
&lt;/h2&gt;

&lt;p&gt;Two things, both small.&lt;/p&gt;

&lt;p&gt;Before writing a batch consumer, &lt;code&gt;analyze_function&lt;/code&gt; now returns the trigger with &lt;code&gt;batchSize&lt;/code&gt; and &lt;code&gt;reportsBatchItemFailures&lt;/code&gt; alongside the event shape, and &lt;code&gt;get_lambda_overview&lt;/code&gt; carries the same fields for every function. So the assistant knows, before it writes a line, whether the partial-batch response it is about to build will be read or discarded. If it is going to be discarded, the honest answer is either "set &lt;code&gt;FunctionResponseTypes&lt;/code&gt; first" or "make this handler idempotent," and now that answer is available at coding time.&lt;/p&gt;

&lt;p&gt;And because the mismatch is graded high, &lt;code&gt;infrawise check&lt;/code&gt; fails the build on it at the default &lt;code&gt;--fail-on high&lt;/code&gt;. The mapping-side gap at medium does not — it is a suggestion, not a broken promise. A handler that reports failures nobody reads is a broken promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A partial batch response has two ends.&lt;/strong&gt; The handler returns &lt;code&gt;batchItemFailures&lt;/code&gt;; the event source mapping must set &lt;code&gt;FunctionResponseTypes: ["ReportBatchItemFailures"]&lt;/code&gt;. One without the other is not half a feature, it is zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correct code can be inert.&lt;/strong&gt; The bugs that survive review are the ones where the file is right and the thing it depends on lives somewhere your tooling never looks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grade the mismatch above the gap.&lt;/strong&gt; A missing setting is an absence. A handler written to use a setting that is off is a false belief the rest of the design rests on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never infer a setting is off from a read that failed.&lt;/strong&gt; &lt;code&gt;undefined&lt;/code&gt; and &lt;code&gt;false&lt;/code&gt; are different answers. A tool that conflates them will eventually be wrong loudly, and after that nobody reads it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Until both ends are in place, the handler must be idempotent.&lt;/strong&gt; That is not a workaround, it is the only thing standing between a poison message and processing the same order ten times.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Infrawise is open source and exposes this through MCP, so your assistant reads the mapping's real configuration instead of assuming. &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>typescript</category>
      <category>aws</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Your monorepo remembers infrastructure you deleted</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Mon, 17 Aug 2026 13:18:05 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/your-monorepo-remembers-infrastructure-you-deleted-24lp</link>
      <guid>https://dev.to/siddharth_pandey_27/your-monorepo-remembers-infrastructure-you-deleted-24lp</guid>
      <description>&lt;h2&gt;
  
  
  A tool told someone to deploy a table from a stack they had deleted
&lt;/h2&gt;

&lt;p&gt;Three bug reports arrived from one person running my analyzer against a CDK monorepo fronted by a single CloudFront distribution. The commit that fixed them describes the worst one in a line: stale &lt;code&gt;cdk.out&lt;/code&gt; templates were being reported as live infrastructure.&lt;/p&gt;

&lt;p&gt;That sounds mild. Here is what it actually produces. The shape is now a checked-in regression fixture, so I can show it with the fixture's names rather than anyone's real ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  1.  MED    IaC drift: DynamoDB table "LegacyInvoicesTable" defined in IaC but not deployed
       "LegacyInvoicesTable" is in cdk.out/LegacyBillingStack.template.json but not found
       in AWS. It may be undeployed or deleted manually.
       → Run `terraform apply` / deploy your stack, or remove the definition from IaC.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That table is not missing. &lt;code&gt;LegacyBillingStack&lt;/code&gt; was deleted from the app. The table is correctly absent from AWS, and the tool is recommending its return.&lt;/p&gt;

&lt;p&gt;Worse than the wrong answer is the wrong answer's provenance. "It is in your IaC, at this file path" is the kind of statement that ends an argument. An assistant reading that finding has no reason to doubt it, and every reason to write code against a table that does not exist or to helpfully re-add the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a deleted stack keeps its template
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cdk synth&lt;/code&gt; writes one CloudFormation template per stack into &lt;code&gt;cdk.out&lt;/code&gt;, plus a &lt;code&gt;manifest.json&lt;/code&gt; describing the app. Delete a stack from the app, or rename it, and the next synth writes the new set of templates. The template from the deleted stack is still sitting there from the last synth that included it.&lt;/p&gt;

&lt;p&gt;Two properties of a monorepo turn that leftover file into a real problem.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cdk.out&lt;/code&gt; is a build directory, and the &lt;code&gt;.gitignore&lt;/code&gt; that &lt;code&gt;cdk init&lt;/code&gt; generates lists it. Nothing in &lt;code&gt;git status&lt;/code&gt;, nothing in a code review diff, nothing in CI will ever show you a stale file in there. It exists only on developer machines and build agents, which is exactly where analysis tools run.&lt;/p&gt;

&lt;p&gt;And in a monorepo you cannot see it by looking. On a single-stack app, an extra &lt;code&gt;.template.json&lt;/code&gt; next to your one real one is noticeable. With a dozen stacks in one repo, and stacks getting split, merged, and renamed as the repo grows, one extra file among thirteen is invisible. The repo with the most stack churn is the repo where leftovers are hardest to spot.&lt;/p&gt;

&lt;p&gt;The file itself gives you no help either. An orphaned template is valid CloudFormation. Parse it and you get a table name, a key schema, a billing mode, an SQS queue, an output with an &lt;code&gt;Export.Name&lt;/code&gt;, all structurally identical to the live ones. Nothing inside the file says the stack that produced it no longer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why everything downstream believes it
&lt;/h2&gt;

&lt;p&gt;Follow the orphan forward and every consumer inherits the error.&lt;/p&gt;

&lt;p&gt;The drift analyzer compares what is declared in IaC against what was extracted from AWS. Every resource in the dead stack lands on the declared side and nothing on the deployed side, so each one becomes a "defined but not deployed" finding. A deleted stack does not produce one wrong finding, it produces one per resource, all medium severity, all confidently sourced to a real file path on disk.&lt;/p&gt;

&lt;p&gt;The graph gets the same resources as nodes, so a question like "which tables exist in this service" is answered with a table that was decommissioned two quarters ago.&lt;/p&gt;

&lt;p&gt;Cross-stack exports are the sharpest edge. The orphan's &lt;code&gt;Outputs&lt;/code&gt; section still carries &lt;code&gt;Export.Name: demo-legacy-invoices-table-arn&lt;/code&gt;, which reads as an export you can &lt;code&gt;Fn::ImportValue&lt;/code&gt; against. That import compiles, synths, and fails at deploy time, because the export was removed from CloudFormation along with the stack.&lt;/p&gt;

&lt;p&gt;And notice what the naive fix breaks. If you simply drop the orphan's outputs along with its resources, a dead cross-stack export becomes silently missing rather than visibly wrong, and the next person to go looking for &lt;code&gt;demo-legacy-invoices-table-arn&lt;/code&gt; gets nothing back with no explanation. Those two cases need opposite treatment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file the build rewrites every run
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cdk.out/manifest.json&lt;/code&gt; is the one artifact that knows what the app looks like today. &lt;code&gt;cdk synth&lt;/code&gt; rewrites it every run, and it lists exactly the stacks the app instantiates, as &lt;code&gt;aws:cloudformation:stack&lt;/code&gt; artifacts each pointing at their &lt;code&gt;templateFile&lt;/code&gt;. That is true whatever language the CDK app is written in, because the manifest is the assembly contract, not a TypeScript detail.&lt;/p&gt;

&lt;p&gt;So a &lt;code&gt;*.template.json&lt;/code&gt; that no manifest artifact points at is an orphan from a deleted or renamed stack. That gives three grades of confidence, and they get three different treatments:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not in the manifest.&lt;/strong&gt; Its resources are excluded entirely: they never reach the graph, so they never reach the drift analyzer, and nothing downstream can act on them. Its outputs are kept and marked &lt;code&gt;stale: true&lt;/code&gt; with the reason, so a dead export stays visible instead of vanishing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the manifest, but the file's mtime lags the newest template by more than an hour.&lt;/strong&gt; Flagged, resources kept. Synth rewrites every template each run, so a lagging timestamp is real evidence of something, but a weaker kind, and excluding resources on it would throw away live infrastructure over a clock skew.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No readable manifest.&lt;/strong&gt; No cross-check is possible, so nothing is flagged and nothing is excluded. Absence of evidence is not evidence.&lt;/p&gt;

&lt;p&gt;That last rule is not a nicety. A tool that flags resources on a guess trains you to skim past its findings, and then it does not matter what it detects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exclusion that did not hold
&lt;/h2&gt;

&lt;p&gt;Excluding orphans in the CDK parser was not enough, and the bug that followed is the kind you only find by testing the whole pipeline instead of the unit.&lt;/p&gt;

&lt;p&gt;There is a second parser for hand-written CloudFormation templates, and it walks the repo recursively. It walked straight into &lt;code&gt;cdk.out&lt;/code&gt; and re-admitted the orphaned stacks under a &lt;code&gt;cloudformation&lt;/code&gt; source, so the resources the CDK path had just excluded came back in through the side door. The exclusion looked correct in isolation and did nothing in practice.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cdk.out&lt;/code&gt; is now on the recursive scan's excluded-directories list, alongside &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;.git&lt;/code&gt;, and &lt;code&gt;dist&lt;/code&gt;, with a comment saying why: that directory belongs to the CDK parser, which cross-checks it. The regression test asserts on both entry points, the CloudFormation extractor and the combined one, because only the combined one catches this class of bug.&lt;/p&gt;

&lt;p&gt;The remaining tests are the negative controls: resources from an unlisted template stay out, its outputs stay in with the flag, a lagging mtime raises the flag while keeping the resources, and with no manifest present every template is treated as current.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the loop
&lt;/h2&gt;

&lt;p&gt;The drift report stopped inventing work. &lt;code&gt;get_stack_outputs&lt;/code&gt; returns &lt;code&gt;stale&lt;/code&gt; and &lt;code&gt;staleReason&lt;/code&gt; per output, and the tool description tells the assistant in as many words not to rely on a stale export without re-synthesizing, so the model does not have to infer what the flag means.&lt;/p&gt;

&lt;p&gt;The demo carries the broken shape on purpose as a checked-in fixture: a &lt;code&gt;manifest.json&lt;/code&gt; listing one stack, and an orphan template next to it whose table and queue must not appear anywhere while its export must appear flagged. Delete the manifest and both stacks are treated as current again, which is the third rule made visible.&lt;/p&gt;

&lt;p&gt;If you run a CDK monorepo, the check you can do right now without any tooling: list &lt;code&gt;cdk.out/*.template.json&lt;/code&gt;, list the &lt;code&gt;templateFile&lt;/code&gt; values in &lt;code&gt;cdk.out/manifest.json&lt;/code&gt;, and diff them. Anything in the first list and not the second is a stack your app no longer builds, still describing infrastructure to anything that reads that directory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build output outlives source.&lt;/strong&gt; A deleted or renamed CDK stack leaves its synthesized template in &lt;code&gt;cdk.out&lt;/code&gt;, and because that directory is gitignored, no review or CI step will ever show it to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ground "what exists" in the file the build rewrites every run.&lt;/strong&gt; &lt;code&gt;cdk.out/manifest.json&lt;/code&gt; lists the stacks the app instantiates today. The templates beside it only record what it once did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude dead resources, keep dead exports, flag them.&lt;/strong&gt; They are opposite failures: a resource that no longer exists must not be acted on; an export that no longer exists must not disappear silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Absence of evidence is not evidence.&lt;/strong&gt; With no manifest to check against, the honest output is nothing at all, not a guess in either direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the pipeline, not the unit.&lt;/strong&gt; A correct exclusion in one parser was undone by a second parser recursing into the same directory. Only an end-to-end assertion caught it.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>typescript</category>
      <category>aws</category>
      <category>cdk</category>
    </item>
    <item>
      <title>An empty result is not an all clear</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Thu, 13 Aug 2026 17:04:57 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/an-empty-result-is-not-an-all-clear-4998</link>
      <guid>https://dev.to/siddharth_pandey_27/an-empty-result-is-not-an-all-clear-4998</guid>
      <description>&lt;p&gt;An infrastructure audit runs across your account and comes back with two findings on S3. You fix both, close the ticket, and move on.&lt;/p&gt;

&lt;p&gt;What the report did not tell you is that the role running it lacks &lt;code&gt;s3:GetEncryptionConfiguration&lt;/code&gt;. Fourteen buckets returned &lt;code&gt;AccessDenied&lt;/code&gt; on that call. The tool caught the rejection, wrote &lt;code&gt;encrypted: false&lt;/code&gt;, and moved on to the next bucket. Some of those fourteen are encrypted. Some are not. The report cannot tell you which, because the value it printed was not read from AWS — it was the default that got assigned when the read failed.&lt;/p&gt;

&lt;p&gt;This is the bug I spent a release fixing across &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;Infrawise&lt;/a&gt;, and it is not really an S3 bug. It is a shape-of-data bug that almost every infrastructure scanner has somewhere: a boolean field with two states being asked to carry three.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;false&lt;/code&gt; is a claim, and a failed call has no claim to make
&lt;/h2&gt;

&lt;p&gt;The S3 extractor issues four calls per bucket — notifications, versioning, encryption, public access block — through &lt;code&gt;Promise.allSettled&lt;/code&gt;, so one rejection never takes down the other three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;notifResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;versionResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;encryptResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pabResult&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetBucketNotificationConfigurationCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetBucketVersioningCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetBucketEncryptionCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetPublicAccessBlockCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;allSettled&lt;/code&gt; is the right primitive. The mistake was what happened next. Reading a rejected result as &lt;code&gt;false&lt;/code&gt; collapses two completely different situations into one byte:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;We read the bucket and versioning is off.&lt;/strong&gt; An observation. Worth a finding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The call was denied, throttled, or timed out.&lt;/strong&gt; Not an observation. Worth an error message and nothing else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Downstream, nothing can separate them again. The analyzer receives &lt;code&gt;versioned: false&lt;/code&gt; and emits a medium-severity finding recommending you turn versioning on for a bucket whose versioning state was never read. The finding looks exactly like a real one. It has a bucket name, a severity, a recommendation. It is indistinguishable from evidence, and it is not evidence.&lt;/p&gt;

&lt;p&gt;The fix is that the field admits a third state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;versioned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;versionResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fulfilled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;versionResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enabled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And every analyzer that consumes it tests for the observation, not for falsiness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;versioned&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* finding */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;null&lt;/code&gt; falls through. No finding, because there is nothing to find — only something unread.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two exceptions are the interesting part
&lt;/h2&gt;

&lt;p&gt;Not every rejection is an absence of information. &lt;code&gt;GetBucketEncryption&lt;/code&gt; answers "this bucket has no encryption configuration" by throwing &lt;code&gt;ServerSideEncryptionConfigurationNotFoundError&lt;/code&gt; rather than returning an empty body. &lt;code&gt;GetPublicAccessBlock&lt;/code&gt; does the same with &lt;code&gt;NoSuchPublicAccessBlockConfiguration&lt;/code&gt;. For those two specific error names, the rejection &lt;em&gt;is&lt;/em&gt; the fact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encrypted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;encryptResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fulfilled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encryptResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ServerSideEncryptionConfiguration&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;Rules&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;errorName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encryptResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ServerSideEncryptionConfigurationNotFoundError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the part you cannot skip by writing a generic "catch everything, return null" wrapper. Two of the four calls encode a real answer in an exception. Collapse all rejections to &lt;code&gt;null&lt;/code&gt; and you stop reporting genuinely unencrypted buckets, which is a false negative traded for the false positive you just removed. The error name has to be inspected. There is no way around reading the API's documented behavior call by call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways absence gets promoted to fact
&lt;/h2&gt;

&lt;p&gt;Once you start looking for this pattern, it shows up everywhere a scan touches an API it does not fully control:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A capped listing.&lt;/strong&gt; &lt;code&gt;ListBuckets&lt;/code&gt; paginates. The old code took the first page and stopped, so accounts past 200 buckets got a partial inventory rendered as a complete one. Nothing downstream can detect a missing bucket — an absent entry reads as "does not exist" to every consumer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A synthesized node.&lt;/strong&gt; When code says &lt;code&gt;QueueUrl: process.env.QUEUE_URL&lt;/code&gt;, there is no queue name to resolve. A graph node still gets created so the function's edges have somewhere to point, and it was created with &lt;code&gt;hasDLQ: false&lt;/code&gt;. The DLQ analyzer read that default as an observation and produced a high-severity finding: a queue literally named &lt;code&gt;unknown&lt;/code&gt; had no dead-letter queue. Those nodes now carry &lt;code&gt;placeholder: true&lt;/code&gt;, and every analyzer whose evidence is absent configuration skips them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;queue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A failed service.&lt;/strong&gt; One extractor throwing should not kill an analysis, so each one is wrapped and its outcome recorded rather than only logged. A warning printed to a terminal nobody read is not a signal. The status is one of four values — &lt;code&gt;ok&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;, &lt;code&gt;partial&lt;/code&gt;, &lt;code&gt;disabled&lt;/code&gt; — and it rides along with the results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A partial extraction.&lt;/strong&gt; Sometimes an extractor gets most of what it needed and loses one piece. Throwing away the whole service costs more than it protects; keeping it silently is the exact false negative all of this exists to prevent. &lt;code&gt;PartialExtractionError&lt;/code&gt; carries the usable data &lt;em&gt;and&lt;/em&gt; the gap, and the source is marked &lt;code&gt;partial&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this got urgent
&lt;/h2&gt;

&lt;p&gt;An infrastructure report with a phantom finding wastes an engineer twenty minutes. They open the console, see versioning is already on, shrug, and move on. Annoying, self-correcting.&lt;/p&gt;

&lt;p&gt;That was before the reader stopped being human.&lt;/p&gt;

&lt;p&gt;Infrawise serves this data to AI coding assistants over MCP, and a model does not shrug. Ask it "which of my buckets are unencrypted" and it gets a JSON array. If the array is empty because the S3 read failed, the model does not infer a failed read. It answers "all your buckets are encrypted" in exactly the tone it uses for things it verified, and then it writes your bucket policy on that assumption. Empty is the most dangerous possible response, because empty is what "everything is fine" also looks like.&lt;/p&gt;

&lt;p&gt;So every tool response carries a &lt;code&gt;dataHealth&lt;/code&gt; block, and the part that matters here is the source list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"sources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AccessDenied"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every key is always present. &lt;code&gt;error&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; rather than omitted, so nothing has to be inferred from a missing field. &lt;code&gt;get_graph_summary&lt;/code&gt; goes further and stamps each node with its own &lt;code&gt;source&lt;/code&gt; and &lt;code&gt;sourceStatus&lt;/code&gt;, so a node from a degraded source is distinguishable from a clean one without cross-referencing anything.&lt;/p&gt;

&lt;p&gt;The rule this gives an assistant is a single sentence, stated in the description of the first tool it is told to call: &lt;strong&gt;a source that is not &lt;code&gt;ok&lt;/code&gt; means an empty result is "not read", not "none exist"&lt;/strong&gt;. &lt;code&gt;get_table_schema&lt;/code&gt; is the sharpest case — with a database listed as &lt;code&gt;failed&lt;/code&gt;, &lt;code&gt;found: false&lt;/code&gt; means "not looked for", not "no such table". That is the difference between an assistant writing a query against the wrong schema and an assistant telling you to re-run &lt;code&gt;infrawise analyze&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;If you build anything that reports on infrastructure you do not own — a scanner, a linter, a dashboard, an MCP server — the check is quick. Take any boolean in your output and ask what it holds when the call that fills it fails. If the answer is &lt;code&gt;false&lt;/code&gt;, you are shipping a claim you never verified. If the answer is "the whole field is missing", you are asking every consumer downstream to guess, and at least one of them will guess wrong in the direction of "fine".&lt;/p&gt;

&lt;p&gt;Three states, always. Observed true, observed false, not observed. The third one costs a nullable type and a few &lt;code&gt;=== false&lt;/code&gt; comparisons, and it is the entire difference between a report that is trustworthy and a report that is merely quiet.&lt;/p&gt;

&lt;p&gt;Infrawise is MIT-licensed and runs locally against your own account: &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A boolean that defaults to &lt;code&gt;false&lt;/code&gt; on a failed read is asserting something nobody verified. Make the field nullable and have consumers test &lt;code&gt;=== false&lt;/code&gt;, not falsiness.&lt;/li&gt;
&lt;li&gt;Do not wrap every rejection into &lt;code&gt;null&lt;/code&gt; blindly. Some APIs answer "not configured" with an exception — &lt;code&gt;GetBucketEncryption&lt;/code&gt; and &lt;code&gt;GetPublicAccessBlock&lt;/code&gt; both do — and for those, the error name is the fact.&lt;/li&gt;
&lt;li&gt;Unpaginated list calls silently shrink your inventory, and a missing item reads as a non-existent item to everything downstream.&lt;/li&gt;
&lt;li&gt;Objects synthesized to hold a reference together (from an env var, an ARN, a code path) must be marked as such, or their default values get audited as if they were read from the provider.&lt;/li&gt;
&lt;li&gt;If an LLM consumes your output, ship per-source status alongside the data. An empty array with no health signal will be reported to the user as an all clear.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>typescript</category>
      <category>aws</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stale infrastructure context is worse than none</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Sat, 08 Aug 2026 15:06:55 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/stale-infrastructure-context-is-worse-than-none-2npa</link>
      <guid>https://dev.to/siddharth_pandey_27/stale-infrastructure-context-is-worse-than-none-2npa</guid>
      <description>&lt;h2&gt;
  
  
  The bug that isn't a bug
&lt;/h2&gt;

&lt;p&gt;On Tuesday you attach a dead-letter queue to &lt;code&gt;orders-queue&lt;/code&gt;. On Wednesday a batch of messages disappears and you ask Claude Code what happened. It answers immediately: &lt;code&gt;orders-queue&lt;/code&gt; has no DLQ configured, so failed messages are dropped after the maximum receive count.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;Infrawise&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the context has to be cached at all
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to never cache. Answer every question from a live account read.&lt;/p&gt;

&lt;p&gt;That does not survive contact with an actual session. A full &lt;code&gt;infrawise analyze&lt;/code&gt; walks every enabled service, paginating through DynamoDB &lt;code&gt;DescribeTable&lt;/code&gt;, 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 &lt;code&gt;Promise.all&lt;/code&gt;, so wall-clock time is bounded by the slowest one rather than their sum, but it is still seconds, not milliseconds.&lt;/p&gt;

&lt;p&gt;An assistant calls &lt;code&gt;get_infra_overview&lt;/code&gt; at the start of a task, &lt;code&gt;analyze_function&lt;/code&gt; when it opens a handler, &lt;code&gt;get_table_schema&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually expires, and when
&lt;/h2&gt;

&lt;p&gt;The cache is a directory of JSON files under &lt;code&gt;.infrawise/cache&lt;/code&gt;, next to your &lt;code&gt;infrawise.yaml&lt;/code&gt;. Each entry stores three things: the data, the timestamp it was written, and a cache version.&lt;/p&gt;

&lt;p&gt;Reads are TTL-checked, and the check is deliberately blunt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readCache&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxAgeMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;readEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;CACHE_VERSION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;maxAgeMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An expired entry does not return old data with a warning attached. It returns &lt;code&gt;null&lt;/code&gt;, 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 &lt;code&gt;null&lt;/code&gt; is not.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;serve&lt;/code&gt; 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 &lt;code&gt;runCodeRefresh&lt;/code&gt; still records why the TTLs are now unified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Same 24h TTL as the graph cache — a shorter TTL here silently dropped all&lt;/span&gt;
&lt;span class="c1"&gt;// AWS/DB metadata from refreshed graphs once a serve/stdio session passed 1h.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;readCache&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CachedMeta&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;meta&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refresh happens at the boundary you already have
&lt;/h2&gt;

&lt;p&gt;Both transports share one bootstrap, and it tries the cache first. Running &lt;code&gt;infrawise serve&lt;/code&gt; over HTTP, that looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ✓ Config loaded  infrawise.yaml
  ✓ Cached analysis loaded  42 nodes · 18 edges · 7 finding(s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the entries are missing or older than 24 hours, &lt;code&gt;readCache&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt;, the bootstrap warns &lt;code&gt;No cache found — running analysis now...&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;When your editor launches &lt;code&gt;infrawise serve --stdio&lt;/code&gt; from &lt;code&gt;.mcp.json&lt;/code&gt; instead, the same bootstrap runs with its success channel silenced and warnings routed to stderr with an &lt;code&gt;infrawise:&lt;/code&gt; prefix — stdout belongs to MCP JSON-RPC, and a stray status line there corrupts the protocol stream.&lt;/p&gt;

&lt;p&gt;Inside a session, file saves take a cheaper path. The watcher debounces for 2 seconds, ignores anything outside &lt;code&gt;.ts&lt;/code&gt;, &lt;code&gt;.tsx&lt;/code&gt;, &lt;code&gt;.js&lt;/code&gt;, &lt;code&gt;.jsx&lt;/code&gt;, &lt;code&gt;.mjs&lt;/code&gt;, and &lt;code&gt;.cjs&lt;/code&gt;, and then calls &lt;code&gt;runCodeRefresh&lt;/code&gt;, which re-runs the AST scan and the local IaC parse and rebuilds the graph on top of the &lt;em&gt;cached&lt;/em&gt; 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 &lt;code&gt;.scan()&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making age visible instead of silent
&lt;/h2&gt;

&lt;p&gt;Bounding staleness is half the job. The other half is telling the consumer how old the facts are, so it can decide.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;get_infra_overview&lt;/code&gt; returns a &lt;code&gt;freshness&lt;/code&gt; object alongside the actual data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"analyzedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-07T09:14:22.019Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ageSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;98400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Analysis is stale — run `infrawise analyze` to refresh."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;analyzedAt&lt;/code&gt; comes from &lt;code&gt;readCacheTimestamp&lt;/code&gt;, 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 &lt;code&gt;stale&lt;/code&gt; flag flips past 24 hours, matching the TTL that drives auto-refresh, and the &lt;code&gt;hint&lt;/code&gt; field only appears when &lt;code&gt;stale&lt;/code&gt; is true.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;When the server boots with no analysis at all, &lt;code&gt;analyzedAt&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; and &lt;code&gt;stale&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;null&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;If you want the same behavior in your editor, &lt;code&gt;npx infrawise start --claude&lt;/code&gt; writes &lt;code&gt;.mcp.json&lt;/code&gt; and hands your assistant the graph — &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A stale cache is more dangerous than an empty one, because it produces specific, confident, wrong answers instead of visibly uncertain ones.&lt;/li&gt;
&lt;li&gt;Return &lt;code&gt;null&lt;/code&gt; past the TTL rather than stale-with-a-warning. Callers ignore warnings; they cannot ignore a &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Tie refresh to a boundary the user already pauses at — session start — instead of a command they have to remember to run.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>typescript</category>
      <category>aws</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Your 12-Minute Frontend Build Is Now Your AI Agent's Bottleneck</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Wed, 05 Aug 2026 07:30:12 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/your-12-minute-frontend-build-is-now-your-ai-agents-bottleneck-2ilb</link>
      <guid>https://dev.to/siddharth_pandey_27/your-12-minute-frontend-build-is-now-your-ai-agents-bottleneck-2ilb</guid>
      <description>&lt;p&gt;Your coding agent just opened five PRs in a morning. Each one changes a handful of files in one product area. Each one triggers a twelve-minute build of the entire frontend: search, account, admin, marketing pages, all of it. That is an hour of CI to validate maybe two hundred lines, and the review queue is now the slowest part of a workflow you adopted to go faster.&lt;/p&gt;

&lt;p&gt;The same twelve minutes used to be an annoyance you absorbed a few times a day. At agent throughput it is the bottleneck.&lt;/p&gt;

&lt;p&gt;It gets worse before it gets better. You ask the agent "where does checkout read the pricing config from?" and it opens forty files across four product areas before answering, because as far as the tooling is concerned there is exactly one application and it is enormous. Then it changes six words of copy in the checkout confirmation screen, and every user on the site redownloads a main bundle whose hash moved for reasons that have nothing to do with them.&lt;/p&gt;

&lt;p&gt;One build unit, one bundle graph, one context blob. Micro frontends in a monorepo split all three, and the monorepo part is what keeps it from turning into a distributed systems problem you did not sign up for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four taxes you are paying
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Build fan-out.&lt;/strong&gt; Your CI has no idea that checkout and search do not touch each other. Any commit invalidates the whole build. Build time is a function of repo size, not change size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bundle coupling.&lt;/strong&gt; Webpack and Vite chunk splitting help with what gets loaded, but not with what gets invalidated. If checkout and search compile into one build output, a checkout change can shift chunk hashes across the app and evict cached bytes users already had.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delivery coupling.&lt;/strong&gt; Two teams shipping to the same artifact means every deploy is a merge queue negotiation. The team that is ready waits on the team that is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context cost.&lt;/strong&gt; This is the newest one and the least discussed. An AI coding agent working in a repo has a finite attention budget. Give it one 1,200-file application with no enforced internal boundaries and it will read broadly, guess about ownership, and occasionally reach into a module it had no business touching. Give it &lt;code&gt;apps/checkout&lt;/code&gt; plus three shared libs and it reads less, guesses less, and produces a diff you can actually review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Splitting without splitting the repo
&lt;/h2&gt;

&lt;p&gt;The mistake people make is equating micro frontends with repo-per-team. That buys you deploy independence and charges you version drift, six duplicate CI configs, and cross-repo refactors that nobody ever does.&lt;/p&gt;

&lt;p&gt;Keep one repo. Split the build.&lt;/p&gt;

&lt;p&gt;With Nx, scaffolding the pieces is one command. Nx 23 renamed the generators: the app that loads federated modules is a &lt;strong&gt;consumer&lt;/strong&gt; (previously &lt;code&gt;host&lt;/code&gt;), the app that exposes them is a &lt;strong&gt;provider&lt;/strong&gt; (previously &lt;code&gt;remote&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nx g @nx/react:consumer apps/shell &lt;span class="nt"&gt;--bundler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rspack &lt;span class="nt"&gt;--providerNames&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;checkout,search
nx g @nx/react:provider apps/account &lt;span class="nt"&gt;--consumer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;shell
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old &lt;code&gt;@nx/react:host&lt;/code&gt; and &lt;code&gt;@nx/react:remote&lt;/code&gt; generators still ship, so existing workspaces keep working, but new work should use consumer/provider. With the new generators, providers are registered at runtime from an inline list in the consumer's &lt;code&gt;src/mf.ts&lt;/code&gt; rather than being frozen into build config.&lt;/p&gt;

&lt;p&gt;Each provider gets its own build target and its own deployable output. Underneath, the Module Federation build plugin config looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createModuleFederationConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@module-federation/enhanced/rspack&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;createModuleFederationConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shell&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;remotes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout@https://cdn.example.com/checkout/mf-manifest.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;shared&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;react&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;singleton&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-dom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;singleton&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;singleton: true&lt;/code&gt; on React is not optional. Without it you ship two React copies, hooks break across the boundary, and you spend an afternoon on an error message about invalid hook calls that has nothing to do with your hooks.&lt;/p&gt;

&lt;p&gt;For remotes that are not known at build time (a plugin surface, a tenant-specific module, an A/B variant), the runtime API takes over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loadRemote&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@module-federation/runtime&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shell&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;remotes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;promo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://cdn.example.com/promo/remoteEntry.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Promo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadRemote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;promo/Banner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The performance case, stated honestly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cache invalidation gets a boundary.&lt;/strong&gt; Each remote publishes its own entry and its own chunks. A copy change in checkout produces new checkout bytes. Search bytes keep their hashes and stay in the user's cache. On a large app this is the difference between shipping a small delta and making every user re-download a vendor bundle because one hash cascaded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loading follows navigation.&lt;/strong&gt; A remote is fetched when the route that needs it is entered. Users who never open admin never pay for admin. You can approximate this with route-level lazy imports in a monolith, but you cannot approximate the invalidation boundary, and lazy imports quietly re-couple every time someone adds a top-level import for a type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local dev stops rebuilding what you are not editing.&lt;/strong&gt; &lt;code&gt;nx serve shell&lt;/code&gt; starts the whole composed app with the remotes built and served statically. Only the remote you are actively editing needs a real dev server, and since Nx 21 you get that by serving the remote itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nx serve checkout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Angular, or on webpack Module Federation without inferred tasks, the equivalent is &lt;code&gt;nx serve shell --devRemotes=checkout&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now be honest about the costs, because they are real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each remote costs a manifest fetch plus an entry fetch. On a route that pulls three remotes, that is latency you did not have before. Preload the manifests for likely-next routes.&lt;/li&gt;
&lt;li&gt;Shared singletons only work if versions are compatible. A remote built against React 18 and a host on React 19 fails at runtime rather than at build time, which is the worst place to find out. This is the single biggest operational hazard of the pattern, and it is exactly the failure a monorepo prevents: one lockfile, one version, enforced by the repo rather than by a wiki page.&lt;/li&gt;
&lt;li&gt;Below a certain size this is all overhead. One team, a 90-second build, three routes? Do not do this. The pattern earns its place when multiple teams contend for one pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the AI speedup actually comes from
&lt;/h2&gt;

&lt;p&gt;Two mechanisms, both boring, both effective.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Affected-only work.&lt;/strong&gt; Nx computes a project graph from real imports, so it knows checkout and search are unrelated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nx affected &lt;span class="nt"&gt;-t&lt;/span&gt; build
nx affected &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nb"&gt;test &lt;/span&gt;lint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent that opens a PR touching only &lt;code&gt;apps/checkout&lt;/code&gt; triggers a build of checkout and its dependents. Not the repo. Those five PRs from the opening now cost five checkout builds instead of an hour of full-frontend CI, and they run in parallel because they no longer contend for the same build. The twelve minutes did not get optimized away, it stopped being charged for work that never changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boundaries the agent cannot argue with.&lt;/strong&gt; Tag every project and let ESLint enforce who may import whom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@nx/enforce-module-boundaries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="na"&gt;depConstraints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sourceTag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scope:shared&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onlyDependOnLibsWithTags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scope:shared&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sourceTag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scope:checkout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onlyDependOnLibsWithTags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scope:shared&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scope:checkout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sourceTag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scope:search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onlyDependOnLibsWithTags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scope:shared&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scope:search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the part that changes how it feels to work with an agent. A prompt that says "do not import from other teams' code" is a suggestion the model may or may not follow. A lint rule that fails CI is a fact. When an agent takes the shortcut of reaching into &lt;code&gt;apps/search/src/internal/pricing.ts&lt;/code&gt; from checkout, the build tells it no, and it fixes the mistake in the same session instead of you finding it in review three days later.&lt;/p&gt;

&lt;p&gt;The context effect compounds. Scoping an agent to one remote plus the shared libs means the files it reads are the files that matter. Smaller context, fewer distractor files, more of the budget spent on the actual change. The same architecture decision that gave you independent deploys gave you a natural unit of work for an agent, which is not a coincidence: both are asking for the same thing, which is a piece of the system you can reason about without loading the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration path that does not stall
&lt;/h2&gt;

&lt;p&gt;Do not carve up the whole app. Pick the boundary that hurts most, usually the one where two teams collide in the merge queue, and extract exactly that one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the consumer, keep the existing app as the first provider. Nothing changes for users.&lt;/li&gt;
&lt;li&gt;Extract the contended area into a second provider. Shared code goes to &lt;code&gt;libs/shared&lt;/code&gt;, tagged &lt;code&gt;scope:shared&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Turn on &lt;code&gt;enforce-module-boundaries&lt;/code&gt; in warn mode, fix the violations it finds, then flip it to error.&lt;/li&gt;
&lt;li&gt;Switch CI to &lt;code&gt;nx affected&lt;/code&gt;. Measure the build time delta before extracting a third remote.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 4 does not show a meaningful improvement, stop. You have a dependency-graph problem, not an architecture problem, and adding remotes will not fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Agent throughput turns a tolerable build time into a hard bottleneck. Five PRs a day made twelve minutes annoying; five PRs an hour makes it the constraint on everything.&lt;/li&gt;
&lt;li&gt;Micro frontends buy independent build, cache, and deploy boundaries. A monorepo keeps the single lockfile and single-commit refactors. You want both, not one or the other.&lt;/li&gt;
&lt;li&gt;Mark framework packages &lt;code&gt;singleton: true&lt;/code&gt; in &lt;code&gt;shared&lt;/code&gt;, or hooks break across the remote boundary at runtime.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nx affected -t build&lt;/code&gt; turns build time into a function of change size instead of repo size, which is what makes multi-PR agent work practical rather than theoretical.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@nx/enforce-module-boundaries&lt;/code&gt; with tags converts your architecture from a convention into a CI failure, and an AI agent respects a failing build far more reliably than a prompt.&lt;/li&gt;
&lt;li&gt;Extract one boundary, measure, then decide. Below a couple of teams and a couple of minutes of build time, this pattern costs more than it returns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs worth reading before you start: &lt;a href="https://nx.dev/docs/technologies/module-federation/concepts/micro-frontend-architecture" rel="noopener noreferrer"&gt;Nx Module Federation&lt;/a&gt; and &lt;a href="https://module-federation.io/" rel="noopener noreferrer"&gt;Module Federation Core&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>react</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your Cognito Login Code Fails Because the App Client Never Allowed That Auth Flow</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Sun, 02 Aug 2026 20:39:22 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/your-cognito-login-code-fails-because-the-app-client-never-allowed-that-auth-flow-1hmf</link>
      <guid>https://dev.to/siddharth_pandey_27/your-cognito-login-code-fails-because-the-app-client-never-allowed-that-auth-flow-1hmf</guid>
      <description>&lt;p&gt;Your login screen works locally. You point it at the staging user pool and every sign-in comes back as an error before it ever reaches a password check.&lt;/p&gt;

&lt;p&gt;The code your AI assistant wrote calls &lt;code&gt;InitiateAuth&lt;/code&gt; with &lt;code&gt;AuthFlow: 'USER_PASSWORD_AUTH'&lt;/code&gt;. Reasonable guess: it is the flow in most Cognito tutorials. But the app client in staging was created by a Terraform module that set &lt;code&gt;explicit_auth_flows = ["ALLOW_USER_SRP_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"]&lt;/code&gt;, and that client also has a secret. So the call fails twice over: the flow is not enabled for this client, and the request is missing &lt;code&gt;SECRET_HASH&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;None of that is visible in your source files. The assistant read your repo, found no answer, and produced the most statistically common Cognito snippet on the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The app client is the config, and it is not in your repo
&lt;/h2&gt;

&lt;p&gt;Cognito's failure modes are almost all per app client settings, not per user pool settings. Four of them break generated code immediately:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allowed auth flows.&lt;/strong&gt; &lt;code&gt;ExplicitAuthFlows&lt;/code&gt; is a whitelist. If &lt;code&gt;ALLOW_USER_PASSWORD_AUTH&lt;/code&gt; is not on it, &lt;code&gt;USER_PASSWORD_AUTH&lt;/code&gt; is rejected regardless of whether the username and password are correct. Client A in dev may allow it while client B in prod does not, and the same handler code then works in one environment and not the other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client secret.&lt;/strong&gt; A client with &lt;code&gt;GenerateSecret: true&lt;/code&gt; requires every auth call to carry &lt;code&gt;SECRET_HASH&lt;/code&gt;, a base64 HMAC-SHA256 of &lt;code&gt;username + clientId&lt;/code&gt; keyed with the client secret. An assistant that does not know a secret exists will never emit that field, and the call fails on secret verification rather than on credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MFA.&lt;/strong&gt; With MFA set to &lt;code&gt;ON&lt;/code&gt;, &lt;code&gt;InitiateAuth&lt;/code&gt; typically returns a &lt;code&gt;ChallengeName&lt;/code&gt; and a session instead of tokens. Code written against the happy path reads &lt;code&gt;response.AuthenticationResult.IdToken&lt;/code&gt;, gets &lt;code&gt;undefined&lt;/code&gt;, and throws somewhere three functions away from the actual cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token validity units.&lt;/strong&gt; &lt;code&gt;AccessTokenValidity: 60&lt;/code&gt; means nothing on its own. With &lt;code&gt;TokenValidityUnits.AccessToken = 'minutes'&lt;/code&gt; it is one hour. With &lt;code&gt;'days'&lt;/code&gt; it is two months. Refresh logic built on the wrong unit either hammers the token endpoint or lets sessions die.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth settings.&lt;/strong&gt; If you are using the hosted UI instead of direct auth, the client carries its own &lt;code&gt;AllowedOAuthFlows&lt;/code&gt;, &lt;code&gt;AllowedOAuthScopes&lt;/code&gt;, and &lt;code&gt;CallbackURLs&lt;/code&gt;. Build a redirect against a URL that is not in the callback list, or request a scope the client does not allow, and Cognito refuses the request at the authorize endpoint before your app sees anything. An assistant writing the redirect has no way to know the staging client only registered &lt;code&gt;https://staging.example.com/callback&lt;/code&gt; while your local dev URL was never added.&lt;/p&gt;

&lt;p&gt;Every one of those lives in AWS, not in your repository. Even when the pool is defined in Terraform, the assistant would have to find the right module, resolve the variables, and know which client the running service actually uses. In practice it does not, so it guesses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the real client config
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;Infrawise&lt;/a&gt; extracts this and hands it to your assistant over MCP. The Cognito extractor in &lt;code&gt;src/adapters/aws/services.ts&lt;/code&gt; walks four read-only calls: &lt;code&gt;ListUserPools&lt;/code&gt; for every pool, then per pool &lt;code&gt;DescribeUserPool&lt;/code&gt;, then &lt;code&gt;ListUserPoolClients&lt;/code&gt;, then &lt;code&gt;DescribeUserPoolClient&lt;/code&gt; for each client. Both listings are paginated with &lt;code&gt;NextToken&lt;/code&gt;, so a pool with 80 app clients does not get silently truncated at the first page.&lt;/p&gt;

&lt;p&gt;For each client it keeps exactly the fields that change how you write the call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;clientName, clientId
authFlows          &amp;lt;- ExplicitAuthFlows
oauthFlows         &amp;lt;- AllowedOAuthFlows
oauthScopes        &amp;lt;- AllowedOAuthScopes
callbackUrls       &amp;lt;- CallbackURLs
generatesSecret    &amp;lt;- !!ClientSecret
accessTokenValidity, idTokenValidity, refreshTokenValidity
tokenValidityUnits &amp;lt;- { accessToken, idToken, refreshToken }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;generatesSecret&lt;/code&gt;. &lt;code&gt;DescribeUserPoolClient&lt;/code&gt; does return the secret value, and infrawise converts it to a boolean at the point of extraction. The value is never stored in the graph, never cached, and never returned by any tool. Your assistant learns that a secret exists and that &lt;code&gt;SECRET_HASH&lt;/code&gt; is mandatory, without ever seeing the secret. Same for users: infrawise never calls any user API. Sign-in code is what it helps you write, not a directory it reads.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;get_cognito_overview&lt;/code&gt; MCP tool returns the whole thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Client secret values and user data are never included."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"userPools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"app-users-staging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ap-south-1_XXXXXXXXX"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"mfaConfiguration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OPTIONAL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"clients"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"clientName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"web-spa"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"clientId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4h1..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"authFlows"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ALLOW_USER_SRP_AUTH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ALLOW_REFRESH_TOKEN_AUTH"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"oauthFlows"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"oauthScopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"openid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"callbackUrls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://staging.example.com/callback"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"generatesSecret"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"accessTokenValidity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"tokenValidityUnits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"accessToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"minutes"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the difference between an assistant guessing &lt;code&gt;USER_PASSWORD_AUTH&lt;/code&gt; and an assistant writing SRP with a &lt;code&gt;SECRET_HASH&lt;/code&gt;, because the whitelist and the secret flag are sitting right there in its context.&lt;/p&gt;

&lt;p&gt;The tool description registered in &lt;code&gt;src/server/index.ts&lt;/code&gt; tells the model when to reach for it and when not to: call it before writing any sign-in, sign-up, or token-refresh code; do not call it to look up users or tokens. That last clause matters more than it looks. Tool descriptions are the only thing steering which tool an agent picks, and a tool that sounds like a user directory will get called for the wrong reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning it on
&lt;/h2&gt;

&lt;p&gt;Cognito is off by default. &lt;code&gt;infrawise start&lt;/code&gt; writes an &lt;code&gt;infrawise.yaml&lt;/code&gt; with &lt;code&gt;cognito: { enabled: false }&lt;/code&gt;, because most repos have no Cognito and there is no reason to make an API call for them. Auth work means flipping one key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;cognito&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The IAM policy is four read actions, and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;cognito-idp:ListUserPools&lt;/span&gt;
&lt;span class="s"&gt;cognito-idp:DescribeUserPool&lt;/span&gt;
&lt;span class="s"&gt;cognito-idp:ListUserPoolClients&lt;/span&gt;
&lt;span class="s"&gt;cognito-idp:DescribeUserPoolClient&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;infrawise start &lt;span class="nt"&gt;--claude&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That probes your environment, runs the analysis, writes &lt;code&gt;.mcp.json&lt;/code&gt; so your editor reconnects on every future launch, and opens Claude Code with all 21 tools available. From then on you just run &lt;code&gt;claude&lt;/code&gt;. Results are cached for 24 hours, and &lt;code&gt;get_infra_overview&lt;/code&gt; reports a &lt;code&gt;freshness&lt;/code&gt; object with the analysis age and a &lt;code&gt;stale&lt;/code&gt; flag so the assistant can tell when it is looking at yesterday's picture.&lt;/p&gt;

&lt;p&gt;Ask "write me a sign-in handler for the staging pool" and the flow is no longer a guess. The assistant calls &lt;code&gt;get_cognito_overview&lt;/code&gt;, sees &lt;code&gt;ALLOW_USER_SRP_AUTH&lt;/code&gt; and &lt;code&gt;generatesSecret: true&lt;/code&gt;, and writes SRP with a secret hash the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually buys you
&lt;/h2&gt;

&lt;p&gt;The bug class here is boring, which is why it eats so much time. Nothing crashes at build time. Types are fine. Tests that mock the Cognito client pass. The failure only shows up against a real user pool, as an exception whose message is about a flow name rather than about the app client that disallowed it, and the fix is a config value you have to go read in a console tab.&lt;/p&gt;

&lt;p&gt;Cognito is one instance of the general pattern. The information needed to write correct code is split between your repo and your cloud account, and the assistant only has half. Infrawise closes that gap deterministically: no LLM in the extraction path, just SDK calls, AST parsing, and rule-based analyzers producing a graph that MCP tools read from.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; or &lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cognito failures are per app client, not per user pool. The same code succeeds against one client and fails against another in the same pool.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;callbackUrls&lt;/code&gt; and &lt;code&gt;oauthScopes&lt;/code&gt; before building a hosted UI redirect. A URL that is not registered is rejected at the authorize endpoint.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;generatesSecret&lt;/code&gt; is true, every auth call needs &lt;code&gt;SECRET_HASH&lt;/code&gt;. An assistant that does not know a secret exists will never emit it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AccessTokenValidity&lt;/code&gt; is meaningless without &lt;code&gt;TokenValidityUnits&lt;/code&gt;. 60 is an hour or two months depending on the unit.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;cognito: enabled: true&lt;/code&gt; in &lt;code&gt;infrawise.yaml&lt;/code&gt; (it defaults to false) and grant the four &lt;code&gt;cognito-idp&lt;/code&gt; read actions.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;get_cognito_overview&lt;/code&gt; before writing sign-in, sign-up, or refresh code. It never returns client secret values or user data.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>typescript</category>
      <category>aws</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your Lambda Passes Tests Then Throws AccessDeniedException in Production</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Mon, 27 Jul 2026 06:12:56 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/your-lambda-passes-tests-then-throws-accessdeniedexception-in-production-28bc</link>
      <guid>https://dev.to/siddharth_pandey_27/your-lambda-passes-tests-then-throws-accessdeniedexception-in-production-28bc</guid>
      <description>&lt;p&gt;You asked Claude Code to add a step to your order handler: pull the Stripe key out of Secrets Manager, then publish an &lt;code&gt;order.settled&lt;/code&gt; event to SNS. It wrote clean code. Your unit tests mock both SDK clients, so they pass. CI is green. You deploy.&lt;/p&gt;

&lt;p&gt;Then the first real invocation logs this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/order-processor-role/order-processor
is not authorized to perform: sns:Publish on resource: arn:aws:sns:us-east-1:123456789012:order-events
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing was wrong with the code. The execution role was written months ago, when the function only touched DynamoDB. The code grew a new dependency and the policy did not follow.&lt;/p&gt;

&lt;p&gt;This is one of the most annoying classes of AWS bug because it is invisible to every tool in the loop. The type checker sees a valid &lt;code&gt;PublishCommand&lt;/code&gt;. The linter sees valid TypeScript. The AI assistant reads your handler and has no idea what &lt;code&gt;order-processor-role&lt;/code&gt; allows, because the policy lives in AWS (or in a Terraform file three directories away), not in the file it is editing.&lt;/p&gt;

&lt;p&gt;Infrawise closes that gap by comparing the two directly. &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the assistant cannot see this on its own
&lt;/h2&gt;

&lt;p&gt;An AI coding assistant works from the files you give it. When it writes a Lambda handler, it can see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the handler source&lt;/li&gt;
&lt;li&gt;imports, so it knows which AWS SDK clients you use&lt;/li&gt;
&lt;li&gt;maybe a nearby test file&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it cannot see is the runtime identity that code will execute as. The link between "this handler" and "this IAM role" is a &lt;code&gt;Role&lt;/code&gt; property on the deployed function, resolved through an ARN, and the policy behind that ARN is a set of JSON documents attached to a role name. Three lookups away from anything in your repo.&lt;/p&gt;

&lt;p&gt;So the assistant does the only thing it can: it assumes the permissions exist. Ninety percent of the time they do, which is exactly what makes the other ten percent expensive. The failure surfaces at runtime, in an environment where you are reading CloudWatch instead of your editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What infrawise actually compares
&lt;/h2&gt;

&lt;p&gt;Infrawise already builds a graph of your infrastructure and your code. The AST scan produces typed edges from functions to the things they touch: a &lt;code&gt;query&lt;/code&gt; or &lt;code&gt;scan&lt;/code&gt; edge to a DynamoDB table, a &lt;code&gt;reads_secret&lt;/code&gt; edge to a Secrets Manager secret, a &lt;code&gt;reads_parameter&lt;/code&gt; edge to an SSM parameter, a &lt;code&gt;publishes_to&lt;/code&gt; edge to an SQS queue or an SNS topic.&lt;/p&gt;

&lt;p&gt;Separately, the Lambda extractor pulls &lt;code&gt;roleArn&lt;/code&gt; off every function, then fetches what that role permits. It batches the fetch per unique role ARN, so ten functions sharing one role cost one IAM round trip, not ten. For each role it walks both attached managed policies and inline policies, decodes the policy documents, and reduces every &lt;code&gt;Allow&lt;/code&gt; statement to the service prefix of its actions. &lt;code&gt;sns:Publish&lt;/code&gt; becomes &lt;code&gt;sns&lt;/code&gt;. &lt;code&gt;dynamodb:GetItem&lt;/code&gt; becomes &lt;code&gt;dynamodb&lt;/code&gt;. A bare &lt;code&gt;"*"&lt;/code&gt; action becomes &lt;code&gt;*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The result is &lt;code&gt;allowedServices&lt;/code&gt;, a flat list like &lt;code&gt;["logs", "dynamodb"]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now the comparison is trivial. Walk the function's outgoing edges, map each to the service prefix it requires, and subtract &lt;code&gt;allowedServices&lt;/code&gt;. What is left is what the code needs and the role does not grant.&lt;/p&gt;

&lt;p&gt;The mapping is deliberately narrow. Five edge types map to five services:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Edge&lt;/th&gt;
&lt;th&gt;Required service&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;query&lt;/code&gt; or &lt;code&gt;scan&lt;/code&gt; on a DynamoDB table&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dynamodb&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reads_secret&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;secretsmanager&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reads_parameter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ssm&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;publishes_to&lt;/code&gt; a queue&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sqs&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;publishes_to&lt;/code&gt; a topic&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sns&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two cases produce no finding at all, on purpose:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No IAM data.&lt;/strong&gt; If the IAM read fails (missing permission, SCP, whatever), &lt;code&gt;allowedServices&lt;/code&gt; comes back undefined and the analyzer skips the function entirely. Infrawise will not tell you a permission is missing when it could not read the policy. Silence beats a false alarm you learn to ignore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wildcard.&lt;/strong&gt; If the role has &lt;code&gt;"Action": "*"&lt;/code&gt; anywhere, the service list contains &lt;code&gt;*&lt;/code&gt; and the function is skipped. AdministratorAccess is its own problem, but it is not a missing-permission problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where you see it
&lt;/h2&gt;

&lt;p&gt;Two places, and they cover the two moments where it matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;While you are writing the handler.&lt;/strong&gt; &lt;code&gt;analyze_function&lt;/code&gt; returns &lt;code&gt;missingPermissions&lt;/code&gt; alongside everything else it knows about the function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"function"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"order-processor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"found"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src/handlers/order-processor.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"triggers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sqs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"eventShape"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"event.Records[0].body"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accesses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"targetId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"table:dynamo:Orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"edgeType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"targetName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"targetType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"table"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"targetId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"secret:aws:stripe/live"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"edgeType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reads_secret"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"targetName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stripe/live"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"targetType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"secret"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"targetId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"topic:aws:order-events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"edgeType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"publishes_to"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"targetName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"order-events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"targetType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"topic"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"missingPermissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"secretsmanager"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sns"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issues"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recommendations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole point. The assistant asked one question before writing code and got back the trigger event shape &lt;em&gt;and&lt;/em&gt; the fact that two of the three services this function talks to are not in its role. It can tell you to fix the policy in the same breath as it writes the handler.&lt;/p&gt;

&lt;p&gt;Note the field is omitted entirely when IAM data was unavailable, rather than returned as an empty array. An empty &lt;code&gt;missingPermissions&lt;/code&gt; means "checked, nothing missing." An absent one means "not checked." Those are different answers and conflating them is how you get misplaced confidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you deploy.&lt;/strong&gt; The same comparison runs as a high-severity analyzer in &lt;code&gt;infrawise analyze&lt;/code&gt; and &lt;code&gt;infrawise check&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  1.  HIGH   Lambda "order-processor" accesses sns but execution role has no sns permissions
       "order-processor" calls sns in code but its IAM execution role (arn:aws:iam::123456789012:role/order-processor-role) has no sns:* permissions. This will cause AccessDeniedException at runtime — code passes tests but fails in AWS.
       → Add sns permissions to the execution role for "order-processor". Minimum required: sns:Publish.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The recommendation names actual actions, not &lt;code&gt;sns:*&lt;/code&gt;. Each service has a minimal action set baked in: &lt;code&gt;secretsmanager:GetSecretValue&lt;/code&gt; for secrets, &lt;code&gt;ssm:GetParameter, ssm:GetParameters, ssm:GetParametersByPath&lt;/code&gt; for parameters, &lt;code&gt;sqs:SendMessage, sqs:ReceiveMessage, sqs:DeleteMessage, sqs:GetQueueAttributes&lt;/code&gt; for queues, and the CRUD-plus-query set for DynamoDB. You can paste the list into the policy without thinking about whether you just granted &lt;code&gt;dynamodb:DeleteTable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Because it is a high-severity finding, &lt;code&gt;infrawise check&lt;/code&gt; fails the build on it by default. That is the whole CI story: &lt;code&gt;check&lt;/code&gt; runs a fresh analysis (never a cached graph, since CI gating on stale data is worse than no gating) and exits non-zero when anything at or above &lt;code&gt;--fail-on&lt;/code&gt; shows up. Default threshold is &lt;code&gt;high&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;infrawise check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two things to know before you trust it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Names have to line up.&lt;/strong&gt; The check joins a code function node to a Lambda node by exact name match. If your handler is exported as &lt;code&gt;handler&lt;/code&gt; in &lt;code&gt;src/orders.ts&lt;/code&gt; but deployed as &lt;code&gt;order-processor&lt;/code&gt;, infrawise finds no code node for that Lambda and produces no finding. It fails closed, which is the right direction, but it means the check is only as good as your naming discipline. If you use a framework that derives function names from the deployed name, you get this for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Granularity stops at the service prefix.&lt;/strong&gt; Infrawise compares &lt;code&gt;sns&lt;/code&gt; against &lt;code&gt;sns&lt;/code&gt;, not &lt;code&gt;arn:aws:sns:us-east-1:123456789012:order-events&lt;/code&gt; against the resource in your &lt;code&gt;PublishCommand&lt;/code&gt;. A role scoped to one topic passes the check even if your code publishes to a different topic. So this catches "the policy has no SNS at all," which is the common case, not "the policy has the wrong SNS resource." Resource-level checking is a different problem and pretending otherwise would make the finding untrustworthy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It needs read access to IAM.&lt;/strong&gt; Five actions: &lt;code&gt;iam:ListAttachedRolePolicies&lt;/code&gt;, &lt;code&gt;iam:ListRolePolicies&lt;/code&gt;, &lt;code&gt;iam:GetRolePolicy&lt;/code&gt;, &lt;code&gt;iam:GetPolicy&lt;/code&gt;, &lt;code&gt;iam:GetPolicyVersion&lt;/code&gt;. They are optional in the infrawise IAM policy. Drop them and everything else still works, you just lose this check. Infrawise is read-only across the board, so nothing here writes to your account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;your-project
npx infrawise start &lt;span class="nt"&gt;--claude&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That probes your environment, writes &lt;code&gt;infrawise.yaml&lt;/code&gt; and &lt;code&gt;.mcp.json&lt;/code&gt;, and opens Claude Code with all 21 tools connected. Ask it to write a Lambda that reads a secret, and watch it check the role before it writes the code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AccessDeniedException&lt;/code&gt; in a Lambda is almost never a code bug. It is a drift bug: the code grew a dependency, the execution role did not follow.&lt;/li&gt;
&lt;li&gt;Your AI assistant cannot catch this from source files alone, because the role that code runs as is three lookups away from anything in your repo.&lt;/li&gt;
&lt;li&gt;Infrawise compares the function's actual outgoing edges (DynamoDB query, secret read, SSM read, SQS send, SNS publish) against the service prefixes its IAM role permits, then reports the difference.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;analyze_function&lt;/code&gt; before writing a handler to get &lt;code&gt;missingPermissions&lt;/code&gt; alongside the trigger event shape. An absent field means IAM was not readable; an empty array means it was checked and clean.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;infrawise check&lt;/code&gt; in CI. Missing permissions are high severity, so the default &lt;code&gt;--fail-on high&lt;/code&gt; blocks the deploy.&lt;/li&gt;
&lt;li&gt;The check is service-level, not resource-level, and joins code to Lambda by exact function name. Know both limits so you know what a clean result actually proves.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>typescript</category>
      <category>aws</category>
      <category>serverless</category>
    </item>
    <item>
      <title>How I Solved Claude Code Silently Missing the SQS Trigger on My Lambda</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Wed, 22 Jul 2026 19:50:35 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/how-i-solved-claude-code-silently-missing-the-sqs-trigger-on-my-lambda-5a</link>
      <guid>https://dev.to/siddharth_pandey_27/how-i-solved-claude-code-silently-missing-the-sqs-trigger-on-my-lambda-5a</guid>
      <description>&lt;p&gt;I asked Claude Code to add a handler for &lt;code&gt;processOrders&lt;/code&gt;, a Lambda wired up to consume from &lt;code&gt;orders-queue&lt;/code&gt; and read the matching row out of the &lt;code&gt;Orders&lt;/code&gt; DynamoDB table. It wrote one immediately — confidently, in one shot, the way it writes most things.&lt;/p&gt;

&lt;p&gt;It also parsed the event body wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: Claude Code didn't know the Lambda had an SQS trigger
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;processOrders&lt;/code&gt; is invoked by &lt;code&gt;orders-queue&lt;/code&gt; through an event source mapping — that's not in &lt;code&gt;processOrders&lt;/code&gt;'s source file, it's in the AWS account. Claude Code, reading only the handler's code, had no way to see that mapping. So it guessed at the event shape instead of knowing it, and reached for &lt;code&gt;event.body&lt;/code&gt; — the shape you'd expect from an API Gateway proxy integration, not from SQS. The actual shape for an SQS-triggered Lambda is &lt;code&gt;event.Records[0].body&lt;/code&gt;. First message in, first field access, and the handler throws.&lt;/p&gt;

&lt;p&gt;That wasn't the only thing invisible from source alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;orders-queue&lt;/code&gt; has no dead-letter queue. If the handler throws, SQS retries up to &lt;code&gt;maxReceiveCount&lt;/code&gt; times and then discards the message — no alert, no queue-depth spike, nothing in the logs pointing at what happened. A failed order just vanishes.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;Orders&lt;/code&gt; table has one partition key (&lt;code&gt;orderId&lt;/code&gt;) and zero GSIs. Any lookup by anything else becomes a full table scan the moment the handler ships.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is visible from reading &lt;code&gt;processOrders&lt;/code&gt;'s current code. It's the state of the AWS account, and an AI assistant reading only source files fills the gap with a plausible guess instead of a fact — starting with which trigger the function even has.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: infrawise's MCP tools in the loop, in order
&lt;/h2&gt;

&lt;p&gt;Here's what the same session looked like once I wired &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;infrawise&lt;/a&gt; into Claude Code as an MCP server and asked it to redo the handler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start wide: &lt;code&gt;get_infra_overview&lt;/code&gt;.&lt;/strong&gt; No arguments, just a snapshot — table names, queue names, lambda count, high-severity findings. It comes back showing the &lt;code&gt;Orders&lt;/code&gt; table, &lt;code&gt;orders-queue&lt;/code&gt;, and &lt;code&gt;processOrders&lt;/code&gt; already in the graph, plus a high-severity finding already attached to that Lambda. That finding surfaces before the handler is written, not after the deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Narrow to the function: &lt;code&gt;analyze_function&lt;/code&gt;.&lt;/strong&gt; Called with &lt;code&gt;function: "processOrders"&lt;/code&gt;, this is the tool built for "I'm about to write or review this handler," and it returns four things Claude Code would otherwise guess at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;The trigger event shape.&lt;/em&gt; &lt;code&gt;processOrders&lt;/code&gt; is triggered by SQS, so the correct shape is &lt;code&gt;event.Records[0].body&lt;/code&gt; — not &lt;code&gt;event.body&lt;/code&gt;. Infrawise keeps a fixed map per trigger type (SNS is &lt;code&gt;event.Records[0].Sns.Message&lt;/code&gt;, S3 is &lt;code&gt;event.Records[0].s3.object.key&lt;/code&gt;, DynamoDB Streams is &lt;code&gt;event.Records[0].dynamodb.NewImage&lt;/code&gt;), so the entry point is right on the first draft.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;The DLQ finding, made concrete.&lt;/em&gt; Infrawise's &lt;code&gt;LambdaMissingTriggerDLQAnalyzer&lt;/code&gt; walks every Lambda's SQS/Kinesis/DynamoDB-stream triggers, checks whether the source queue has a DLQ, and raises high severity when it doesn't: &lt;em&gt;"processOrders is triggered by orders-queue which has no DLQ — if the Lambda handler fails, messages will be retried and eventually discarded with no failure record."&lt;/em&gt; Same problem named in the previous section, now attached to the exact function and queue instead of being a fact nobody checked.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Missing IAM permissions.&lt;/em&gt; When execution-role data is available, &lt;code&gt;analyze_function&lt;/code&gt; diffs what the function's code actually calls against what its role allows, and returns the gap — catching an &lt;code&gt;AccessDeniedException&lt;/code&gt; before it happens in prod instead of after.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Deduplicated recommendations,&lt;/em&gt; collapsed into one list instead of several warnings to reconcile by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Confirm the queue directly: &lt;code&gt;get_queue_details&lt;/code&gt;.&lt;/strong&gt; The handler's retry behavior depends on more than DLQ presence, so Claude Code checks the queue itself: &lt;code&gt;hasDLQ: false&lt;/code&gt; confirmed, plus visibility timeout, encryption, and &lt;code&gt;isFifo&lt;/code&gt;. That last field matters more than it looks — if the queue were FIFO, every &lt;code&gt;SendMessage&lt;/code&gt; call in the handler would need a &lt;code&gt;MessageGroupId&lt;/code&gt;, and skipping it isn't a lint warning, it's a runtime error the first time the handler publishes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get the schema right: &lt;code&gt;get_table_schema&lt;/code&gt;.&lt;/strong&gt; Called with &lt;code&gt;tables: ["Orders"]&lt;/code&gt;, this returns the partition key (&lt;code&gt;orderId&lt;/code&gt;), billing mode (&lt;code&gt;PAY_PER_REQUEST&lt;/code&gt;), and the fact that there are no GSIs. If the handler needs to look up an order by anything other than &lt;code&gt;orderId&lt;/code&gt;, that's a &lt;code&gt;Scan&lt;/code&gt;, and infrawise's scan analyzer flags it the moment it lands in the code graph — so Claude Code reaches for &lt;code&gt;suggest_gsi&lt;/code&gt; up front instead of shipping the scan and fixing it in the next PR. Row data is never included — column names, types, keys, and index definitions only, and only for the tables this handler actually touches, not the whole account's schema dumped into the prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped instead
&lt;/h2&gt;

&lt;p&gt;With all four calls back, the rewritten handler:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Parses &lt;code&gt;event.Records[0].body&lt;/code&gt; — the correct shape for this trigger, not a guess&lt;/li&gt;
&lt;li&gt;Queries &lt;code&gt;Orders&lt;/code&gt; by &lt;code&gt;orderId&lt;/code&gt; — no scan, because the schema call already ruled that out&lt;/li&gt;
&lt;li&gt;Ships with an explicit callout that &lt;code&gt;orders-queue&lt;/code&gt; needs a DLQ before this goes to production&lt;/li&gt;
&lt;li&gt;Doesn't publish anywhere without checking &lt;code&gt;isFifo&lt;/code&gt; first&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those four things was either wrong or invisible in the first version — the one Claude Code wrote from source files alone. None of it required me to open the AWS console mid-task or remember which table has which GSI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a repeatable pattern, not a one-off
&lt;/h2&gt;

&lt;p&gt;The four tools compose in a fixed order because each answers a narrower question than the last: &lt;code&gt;get_infra_overview&lt;/code&gt; says what exists, &lt;code&gt;analyze_function&lt;/code&gt; says what's wrong with the one thing being touched, &lt;code&gt;get_queue_details&lt;/code&gt; and &lt;code&gt;get_table_schema&lt;/code&gt; fill in the operational and schema detail neither of the first two carries at full resolution. That's the same shape as infrawise's own "reviewing an entire service" and "before writing a query" patterns — it holds for any handler, not just this one.&lt;/p&gt;

&lt;p&gt;None of this runs an LLM against the infrastructure. Extraction and analysis are deterministic — AST parsing, live AWS reads, rule-based analyzers, graph correlation. Claude Code only consumes that graph through MCP, the same way it consumes source files. The difference is the graph doesn't guess.&lt;/p&gt;

&lt;p&gt;Try it in your own project — &lt;code&gt;npx infrawise start --claude&lt;/code&gt; writes the &lt;code&gt;.mcp.json&lt;/code&gt; and opens Claude Code with all 21 tools wired in, no server to babysit. &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Call &lt;code&gt;analyze_function&lt;/code&gt; before writing or reviewing any Lambda handler — it returns the correct trigger event shape, missing IAM permissions, and any DLQ/trigger findings in one call.&lt;/li&gt;
&lt;li&gt;A queue with a live SQS trigger and no DLQ is a high-severity finding for a reason: failed messages are retried up to &lt;code&gt;maxReceiveCount&lt;/code&gt; times, then discarded with no record.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;isFifo&lt;/code&gt; on any queue before writing &lt;code&gt;SendMessage&lt;/code&gt; calls — a missing &lt;code&gt;MessageGroupId&lt;/code&gt; on a FIFO queue is a runtime error, not a style issue.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;get_table_schema&lt;/code&gt; with just the tables a handler touches instead of dumping the whole schema into the prompt — it also tells you whether the GSI your query needs already exists.&lt;/li&gt;
&lt;li&gt;None of this requires an LLM to analyze your infrastructure — the graph is deterministic; Claude Code only reads it.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>typescript</category>
      <category>aws</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Your Lambda List Was Silently Truncated at 200 — And Your AI Assistant Never Knew</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:01:08 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/your-infrastructure-tool-silently-drops-functions-past-200-and-so-might-yours-1eke</link>
      <guid>https://dev.to/siddharth_pandey_27/your-infrastructure-tool-silently-drops-functions-past-200-and-so-might-yours-1eke</guid>
      <description>&lt;h2&gt;
  
  
  Hook
&lt;/h2&gt;

&lt;p&gt;An AWS account with 240 Lambda functions runs &lt;code&gt;infrawise analyze&lt;/code&gt;. The tool prints a clean summary: services detected, findings generated, no errors. A developer asks their AI assistant, wired up through MCP, "does this function have a dead-letter queue?" for a function that happens to be function #217 in the account. The assistant calls &lt;code&gt;get_lambda_overview&lt;/code&gt;, gets back a list, doesn't find the function, and confidently tells the developer it doesn't exist — or worse, silently skips it and reasons about a different function with a similar name. No error. No warning. Just a wrong answer delivered with total confidence, because 40 functions never made it into the graph in the first place.&lt;/p&gt;

&lt;p&gt;That was &lt;a href="https://github.com/Sidd27/infrawise/issues/40" rel="noopener noreferrer"&gt;Infrawise issue #40&lt;/a&gt;, and it's a useful case study in how a tool whose entire pitch is "stop AI assistants from guessing" can quietly start guessing itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 200-function wall
&lt;/h2&gt;

&lt;p&gt;Infrawise builds an in-memory graph of a project's infrastructure — tables, functions, queues, indexes — and exposes it to AI coding assistants through 21 MCP tools. &lt;code&gt;extractLambdaMetadata&lt;/code&gt; in &lt;code&gt;src/adapters/aws/services.ts&lt;/code&gt; is the function responsible for pulling every Lambda function into that graph. It calls AWS's &lt;code&gt;ListFunctionsCommand&lt;/code&gt; in pages of 50 and loops until AWS says there's nothing left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ListFunctionsCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;Marker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;MaxItems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="c1"&gt;// ...push each function into the array...&lt;/span&gt;
  &lt;span class="nx"&gt;marker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NextMarker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;marker&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That trailing &lt;code&gt;&amp;amp;&amp;amp; functions.length &amp;lt; 200&lt;/code&gt; is the whole bug. Once the running total hit 200 — four pages in — the loop stopped, even though AWS was still handing back a &lt;code&gt;NextMarker&lt;/code&gt; and had more functions to give. There was no error, no thrown exception, no log line. The function just returned early with a partial list and reported success.&lt;/p&gt;

&lt;p&gt;For a small project this never surfaces. Most side projects don't have 200 Lambda functions. But "common in larger orgs," as the issue itself puts it, is exactly the audience infrawise is trying to win — teams with enough infrastructure that pasting schemas into a prompt every session actually hurts. Those are the accounts most likely to hit the cap, and the ones with the least tolerance for a tool that quietly drops data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why silent is worse than wrong
&lt;/h2&gt;

&lt;p&gt;Infrawise's own docs describe two pillars: &lt;em&gt;Context&lt;/em&gt; — give the AI assistant your real schema instead of making the developer paste it — and &lt;em&gt;Guard&lt;/em&gt; — catch expensive infrastructure mistakes before they ship. The 200-function cap broke both, and it broke them without telling anyone.&lt;/p&gt;

&lt;p&gt;Every function past the cap disappeared from the graph entirely — no node, no edges, nothing for the graph engine to attach event-source mappings, IAM roles, or trigger metadata to. Three consumer paths inherited that gap directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;LambdaMissingTriggerDLQAnalyzer&lt;/code&gt; — the analyzer that flags Lambda triggers with no dead-letter queue — silently produced zero findings for those functions. Not "skipped, see warning." Zero findings, same as a function that's perfectly configured.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;analyze_function&lt;/code&gt;, the MCP tool an AI assistant calls before writing or reviewing a handler, would report no issues for a function it never saw, rather than saying it didn't have data.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_lambda_overview&lt;/code&gt;, the tool that lists every function's runtime, memory, timeout, and triggers, just wouldn't include the function in the list at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these paths distinguish "checked and found nothing wrong" from "never checked." That distinction is the entire value proposition of a deterministic tool. Infrawise's pitch, explicit in its own docs, is that it doesn't use an LLM to analyze infrastructure — everything is AST parsing, schema introspection, and rule-based analyzers, specifically so an AI assistant gets ground truth instead of a guess. A silent truncation at 200 functions turns that ground truth into a guess anyway, just one dressed up as certainty.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pattern, not a one-off
&lt;/h2&gt;

&lt;p&gt;The 200-function cap wasn't an isolated mistake — it's one of several bugs in Infrawise's own issue tracker that share the same shape: a limit or a stale value fails quietly instead of loudly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Sidd27/infrawise/issues/45" rel="noopener noreferrer"&gt;Issue #45&lt;/a&gt; found that &lt;code&gt;CACHE_DIR&lt;/code&gt; was computed from &lt;code&gt;process.cwd()&lt;/code&gt; at module load time, so running &lt;code&gt;infrawise analyze&lt;/code&gt; from one directory and the MCP server from another meant the second command silently read a cache built for a different invocation path — no mismatch warning, just answers that looked current and weren't. &lt;a href="https://github.com/Sidd27/infrawise/issues/38" rel="noopener noreferrer"&gt;Issue #38&lt;/a&gt; found that &lt;code&gt;--no-cache&lt;/code&gt; was accepted by the CLI parser but never actually read by &lt;code&gt;runAnalyze&lt;/code&gt;, so a developer explicitly asking for a fresh scan got a cached one anyway, with no indication the flag had been ignored. And &lt;a href="https://github.com/Sidd27/infrawise/issues/39" rel="noopener noreferrer"&gt;issue #39&lt;/a&gt; found that &lt;code&gt;LambdaMissingTriggerDLQAnalyzer&lt;/code&gt; was wired into the one-time &lt;code&gt;infrawise analyze&lt;/code&gt; path but missing from &lt;code&gt;runCodeRefresh&lt;/code&gt;, the path the file watcher calls on every save — so DLQ findings that showed up on the first run quietly vanished after the first edit, with the developer none the wiser.&lt;/p&gt;

&lt;p&gt;Different subsystems, same failure mode: nothing crashed, nothing logged, the tool just answered with less than it should have and gave no sign of it. For a tool that stakes its value on being more trustworthy than an LLM's guess, "wrong but confident" is the one failure mode it can't afford — and it's the one that shipped three separate times before anyone caught it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix for #40 was a one-line diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- } while (marker &amp;amp;&amp;amp; functions.length &amp;lt; 200);
&lt;/span&gt;&lt;span class="gi"&gt;+ } while (marker);
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pagination now runs until AWS says there's nothing left, full stop. The fix direction noted in the issue also called for a warning log if pagination ever gets cut short for another reason — worth keeping in mind if you're building a similar extraction loop: an arbitrary safety cap on a paginated API call is rarely wrong to have, but silence when it triggers always is. A &lt;code&gt;logger.warn&lt;/code&gt; costs one line and turns a silent data gap into a debuggable one.&lt;/p&gt;

&lt;p&gt;If you're pointing an AI assistant at your own AWS account and it has more than 200 Lambda functions, this fix landed in the same release cycle as the other three — worth confirming you're on a current version.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A pagination cap with no warning is a silent data-loss bug waiting for an account large enough to trigger it — 200 felt generous until "larger orgs" turned out to be the target audience.&lt;/li&gt;
&lt;li&gt;Distinguish "checked and found nothing" from "never checked" in any analyzer or query result — collapsing them into the same empty response is the failure mode that erodes trust fastest.&lt;/li&gt;
&lt;li&gt;Audit every loop bounded by both a natural termination condition (a pagination marker) and an arbitrary one (a hardcoded count) — the arbitrary one is usually the one that fails silently.&lt;/li&gt;
&lt;li&gt;Cache keys derived from &lt;code&gt;process.cwd()&lt;/code&gt; or other invocation-time state can go stale in ways a static cache TTL never catches — tie cache identity to something stable, not to where the process happened to start.&lt;/li&gt;
&lt;li&gt;If a CLI flag exists, verify the code path actually reads it. &lt;code&gt;--no-cache&lt;/code&gt; sitting in the parser but unused is exactly the kind of gap that only turns up when someone reads the issue tracker, not the tests.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>typescript</category>
      <category>aws</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The Right Way to Start Claude Code on an AWS Project</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Tue, 14 Jul 2026 09:40:36 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/the-right-way-to-start-claude-code-on-an-aws-project-ipf</link>
      <guid>https://dev.to/siddharth_pandey_27/the-right-way-to-start-claude-code-on-an-aws-project-ipf</guid>
      <description>&lt;p&gt;You know the drill for adding an MCP server to a project: dig the exact command string out of the docs, hand-write a &lt;code&gt;.mcp.json&lt;/code&gt; with an absolute path you'll typo once, restart the editor, and discover no tools showed up because the server expected a config file you haven't created yet. Plenty of MCP servers lose their would-be users somewhere inside that loop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;Infrawise&lt;/a&gt; collapses the whole loop into one command. It's an open-source tool (&lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt;) that statically analyzes your codebase, AWS infrastructure, and database schemas, then exposes that context to AI coding assistants over MCP — so Claude Code knows your actual partition keys, GSIs, and indexes instead of guessing from source files. This post is about the part that usually kills tools like this before they deliver any value: setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 1: One command, four steps
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; infrawise   &lt;span class="c"&gt;# or skip install and use npx&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;your-project
infrawise start &lt;span class="nt"&gt;--claude&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;start&lt;/code&gt; does four things, in order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Probes your environment.&lt;/strong&gt; If there's no &lt;code&gt;infrawise.yaml&lt;/code&gt; in the project, it generates one. It reads &lt;code&gt;AWS_PROFILE&lt;/code&gt; if set; otherwise it looks at your configured AWS profiles — one profile means zero questions, several means one prompt asking which to use. That's the entire interview. (If you want the full guided wizard instead, &lt;code&gt;infrawise start --interactive&lt;/code&gt; runs it.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Runs the analysis.&lt;/strong&gt; It scans your AWS services, database schemas, and codebase, builds a graph of services, tables, indexes, and query patterns, and runs rule-based analyzers over it. No LLM is involved in this step — extraction and analysis are deterministic, so the same infrastructure always produces the same graph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Writes &lt;code&gt;.mcp.json&lt;/code&gt; to your project root.&lt;/strong&gt; This is the file you'd otherwise write by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"infrawise"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"infrawise"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"serve"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--stdio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--config"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"/absolute/path/to/infrawise.yaml"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Opens Claude Code.&lt;/strong&gt; Claude Code reads &lt;code&gt;.mcp.json&lt;/code&gt; automatically and starts the session with all 21 infrawise tools available — schema lookups, per-function analysis, GSI suggestions, queue and topic details, the lot.&lt;/p&gt;

&lt;p&gt;If the &lt;code&gt;claude&lt;/code&gt; CLI isn't installed, &lt;code&gt;start&lt;/code&gt; tells you exactly that and where to get it, instead of failing silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 2: Day two — you never type &lt;code&gt;infrawise&lt;/code&gt; again
&lt;/h2&gt;

&lt;p&gt;This is the part that matters more than the first run. &lt;code&gt;.mcp.json&lt;/code&gt; doesn't point at a long-running server you have to remember to start. It tells the editor how to spawn one: every time you launch Claude Code in that project, the editor itself runs &lt;code&gt;infrawise serve --stdio&lt;/code&gt; as a child process. There is no port to keep free, no background daemon to babysit, no "is the server running?" debugging session.&lt;/p&gt;

&lt;p&gt;So the second-day workflow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No infrawise command anywhere.&lt;/p&gt;

&lt;p&gt;Two mechanisms keep the context from going stale underneath you:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A 24-hour analysis cache.&lt;/strong&gt; The analysis from &lt;code&gt;start&lt;/code&gt; is cached. When the editor spawns the server and the cache is fresh, the session begins instantly with the existing graph. Once the cache is older than 24 hours, the next session start refreshes it. The freshness is also visible to the assistant itself: &lt;code&gt;get_infra_overview&lt;/code&gt; returns an &lt;code&gt;analyzedAt&lt;/code&gt; timestamp, an &lt;code&gt;ageSeconds&lt;/code&gt; value, and a &lt;code&gt;stale&lt;/code&gt; flag, so Claude can tell you — or decide on its own — when the picture it has is old. (This came out of a reader question on an earlier post about deterministic analysis; it shipped as a proper feature.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A file watcher inside the session.&lt;/strong&gt; While the server is running, it watches the repository for file changes and re-runs code analysis on save. Write a new function that calls &lt;code&gt;DynamoDB.scan()&lt;/code&gt; mid-session, and the code graph reflects it — you don't need to restart anything for the assistant to see what you just wrote.&lt;/p&gt;

&lt;p&gt;Infrastructure changes are the one thing that won't propagate automatically mid-session — if you just added a table or changed a GSI in AWS, run &lt;code&gt;infrawise analyze&lt;/code&gt; to force a full re-scan rather than waiting out the cache. And if the config itself has drifted from reality (new AWS account, different profile), &lt;code&gt;infrawise start --rediscover&lt;/code&gt; deletes &lt;code&gt;infrawise.yaml&lt;/code&gt; and the &lt;code&gt;.infrawise/&lt;/code&gt; cache directory and rebuilds both from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 3: Not a Claude Code shop? Same command, different flag
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;--claude&lt;/code&gt; flag is one of three editor targets, and none of them changes what gets analyzed — only where the MCP config lands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;infrawise start --cursor&lt;/code&gt; writes &lt;code&gt;.cursor/mcp.json&lt;/code&gt; and opens Cursor.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;infrawise start --vscode&lt;/code&gt; writes into &lt;code&gt;.vscode/mcp.json&lt;/code&gt; and opens VS Code. This one &lt;strong&gt;merges&lt;/strong&gt; rather than overwrites: if you already have other MCP servers registered in that file, they survive. Infrawise adds its own entry alongside them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;infrawise start&lt;/code&gt; with no flag writes &lt;code&gt;.mcp.json&lt;/code&gt;, prints the launch command for each editor, and exits — for any other MCP-capable editor, point it at &lt;code&gt;infrawise serve --stdio --config /path/to/infrawise.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And for the teammates who don't use an AI editor at all, the same analysis runs as a CI gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;infrawise check &lt;span class="nt"&gt;--fail-on&lt;/span&gt; high
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;check&lt;/code&gt; runs a fresh analysis and exits non-zero when any finding reaches the threshold severity (&lt;code&gt;high&lt;/code&gt; is the default; &lt;code&gt;medium&lt;/code&gt; and &lt;code&gt;low&lt;/code&gt; tighten it). A full-table scan on a production DynamoDB table fails the build whether or not anyone on the team has ever opened Claude Code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 4: What the assistant actually does with it
&lt;/h2&gt;

&lt;p&gt;The payoff for the setup being this short is that the context is there before you need it. Without it, an AI assistant reading only your source files will happily suggest a &lt;code&gt;.scan()&lt;/code&gt; on a table with 50 million rows, or recommend adding a GSI you already have. With the MCP tools connected, it can call &lt;code&gt;get_table_schema&lt;/code&gt; for the exact columns and keys, &lt;code&gt;analyze_function&lt;/code&gt; for a Lambda's real trigger event shape, or &lt;code&gt;suggest_gsi&lt;/code&gt; for a ready-to-use index definition matched to your table's billing mode — before writing the query, not after you've reviewed it.&lt;/p&gt;

&lt;p&gt;I've written about those analysis capabilities in earlier posts; the point of this one is narrower. A context tool only works if it's actually connected, and "actually connected" has to cost less than the problem it solves. Pasting a schema into a prompt costs thirty seconds, every session, forever. &lt;code&gt;infrawise start --claude&lt;/code&gt; costs one command, once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The gap between "this MCP server exists" and "this MCP server is running in my editor" is where most infrastructure context tools die. Infrawise's answer is to make the editor own the server lifecycle: one &lt;code&gt;start&lt;/code&gt; command probes, analyzes, writes the editor config, and launches; from then on the editor spawns &lt;code&gt;infrawise serve --stdio&lt;/code&gt; itself, a 24-hour cache keeps session starts instant, and a file watcher keeps the code graph current while you work.&lt;/p&gt;

&lt;p&gt;Try it on a real project — the first &lt;code&gt;start&lt;/code&gt; on an actual AWS account is where it gets interesting: &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/infrawise" rel="noopener noreferrer"&gt;npm&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;infrawise start --claude&lt;/code&gt; is the entire setup: probe environment, generate &lt;code&gt;infrawise.yaml&lt;/code&gt;, analyze, write &lt;code&gt;.mcp.json&lt;/code&gt;, open Claude Code with 21 MCP tools.&lt;/li&gt;
&lt;li&gt;After the first run, just launch your editor — it spawns &lt;code&gt;infrawise serve --stdio&lt;/code&gt; from &lt;code&gt;.mcp.json&lt;/code&gt; on its own. No daemon, no port, no second command.&lt;/li&gt;
&lt;li&gt;Analysis is cached for 24 hours and refreshed at session start when stale; the assistant can check freshness itself via &lt;code&gt;get_infra_overview&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;File changes are picked up mid-session by a watcher; infrastructure changes need &lt;code&gt;infrawise analyze&lt;/code&gt;, and &lt;code&gt;--rediscover&lt;/code&gt; rebuilds config from scratch.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--cursor&lt;/code&gt; and &lt;code&gt;--vscode&lt;/code&gt; target other editors (the VS Code writer merges with existing MCP servers), and &lt;code&gt;infrawise check --fail-on high&lt;/code&gt; gates CI with the same analysis.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>typescript</category>
      <category>mcp</category>
      <category>aws</category>
    </item>
  </channel>
</rss>
