<?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 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>
    <item>
      <title>Why SNS Silently Drops Your Messages and How to Catch It Before You Ship</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Sat, 11 Jul 2026 08:53:46 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/why-sns-silently-drops-your-messages-and-how-to-catch-it-before-you-ship-1fcm</link>
      <guid>https://dev.to/siddharth_pandey_27/why-sns-silently-drops-your-messages-and-how-to-catch-it-before-you-ship-1fcm</guid>
      <description>&lt;p&gt;Your checkout service publishes an &lt;code&gt;OrderRefunded&lt;/code&gt; event to an SNS topic. The publish call returns a &lt;code&gt;MessageId&lt;/code&gt;. No exception, no retry, nothing in the dead-letter queue. Three days later a customer emails asking where their refund is, and you discover the refund Lambda was never invoked.&lt;/p&gt;

&lt;p&gt;The message wasn't lost. It was filtered. One of the topic's subscriptions has a filter policy that requires an &lt;code&gt;eventType&lt;/code&gt; message attribute, and your publish call didn't include it. SNS did exactly what it was configured to do: it evaluated the filter, found no match, and skipped delivery for that subscription. From the publisher's side, everything looks like success.&lt;/p&gt;

&lt;p&gt;This is one of the nastiest failure modes in event-driven AWS architectures, because every tool you'd normally reach for reports green. This post walks through why it happens, why neither your code review nor your AI coding assistant can catch it from source code alone, and how to make the contract visible at coding time instead of incident time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract nobody wrote down
&lt;/h2&gt;

&lt;p&gt;SNS filter policies live on the &lt;em&gt;subscription&lt;/em&gt;, not the topic. A subscriber says "only deliver messages where the &lt;code&gt;eventType&lt;/code&gt; attribute is one of these values":&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;"eventType"&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;"order.refunded"&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.cancelled"&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;For that subscription to ever receive anything, the publisher must include the attribute:&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;await&lt;/span&gt; &lt;span class="nx"&gt;sns&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;PublishCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;TopicArn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ORDER_EVENTS_TOPIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;MessageAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;String&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;StringValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order.refunded&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Omit &lt;code&gt;MessageAttributes&lt;/code&gt; and the publish still succeeds — SNS accepts the message and returns a &lt;code&gt;MessageId&lt;/code&gt; regardless of whether any subscription matches. The message simply isn't delivered to the filtered subscription.&lt;/p&gt;

&lt;p&gt;Three things make this failure quiet:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No error surface.&lt;/strong&gt; A filter mismatch is not a delivery failure. The subscription's redrive policy never kicks in, so the message never lands in a DLQ. There is nothing to retry and nothing to alert on by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The only trace is a metric.&lt;/strong&gt; SNS increments a &lt;code&gt;NumberOfNotificationsFilteredOut&lt;/code&gt; CloudWatch metric, but unless you already suspect filtering, nobody looks at it — and by the time you do, you're debugging in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests pass.&lt;/strong&gt; Unit tests mock the SNS client and assert that &lt;code&gt;PublishCommand&lt;/code&gt; was called. The mock has no filter policy. The gap between your code and the subscription's configuration is exactly the part the test can't see.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is a second variant of the same trap: filter policies can also match against the message &lt;em&gt;body&lt;/em&gt; (&lt;code&gt;FilterPolicyScope: MessageBody&lt;/code&gt;) instead of message attributes. Then the required keys must appear in your JSON payload itself. Same silence, different location.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your AI assistant writes the broken version
&lt;/h2&gt;

&lt;p&gt;Ask an AI coding assistant to "publish an order refunded event to the order-events topic" and it will produce a perfectly reasonable &lt;code&gt;PublishCommand&lt;/code&gt; call — topic ARN from an environment variable, &lt;code&gt;JSON.stringify&lt;/code&gt; on the payload, maybe a comment. What it will almost never produce is the &lt;code&gt;MessageAttributes&lt;/code&gt; block with exactly the keys your subscriptions filter on.&lt;/p&gt;

&lt;p&gt;It can't. The filter policy is not in your repository. It's a JSON document attached to a subscription in your AWS account, set by whoever wired up the consumer — possibly you, six months ago. The assistant reads your source files, sees other publish calls (which may themselves be missing attributes), and generates code that matches the pattern it found. If the pattern is wrong, the wrongness propagates.&lt;/p&gt;

&lt;p&gt;The manual fix is miserable: open the AWS console, navigate to the topic, open each subscription one by one, read each filter policy, and paste the required attribute names into your prompt. Every session. For every topic. This is the copy-paste loop that breaks flow — and if you skip it once, you ship the silent drop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pulling the contract into the coding session
&lt;/h2&gt;

&lt;p&gt;This is the problem &lt;a href="https://github.com/Sidd27/infrawise" rel="noopener noreferrer"&gt;infrawise&lt;/a&gt; is built for: it extracts your live infrastructure into a graph and serves it to your AI assistant over MCP, so the assistant queries facts instead of guessing.&lt;br&gt;
&lt;/p&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 analyzes your AWS account and codebase, writes &lt;code&gt;.mcp.json&lt;/code&gt;, and opens Claude Code with 21 MCP tools connected. For SNS specifically, the extraction is read-only and direct: infrawise lists your topics, then for every confirmed subscription fetches its attributes, parses the &lt;code&gt;FilterPolicy&lt;/code&gt; JSON, and records which attribute keys the policy requires and whether the policy scope is &lt;code&gt;MessageAttributes&lt;/code&gt; or &lt;code&gt;MessageBody&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The result is exposed through a tool called &lt;code&gt;get_topic_details&lt;/code&gt;. For each topic it returns the subscription count, encryption status, and a &lt;code&gt;filterPolicies&lt;/code&gt; array:&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;"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;"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;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aws"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subscriptionCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"encrypted"&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;"filterPolicies"&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;"subscriptionArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:sns:...:order-events:a1b2..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"protocol"&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;"requiredAttributes"&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;"eventType"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MessageAttributes"&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;Now the workflow changes. When you ask your assistant to write a publish call, it calls &lt;code&gt;get_topic_details&lt;/code&gt; first, sees that a subscription on &lt;code&gt;order-events&lt;/code&gt; filters on &lt;code&gt;eventType&lt;/code&gt;, and writes the &lt;code&gt;MessageAttributes&lt;/code&gt; block into the code on the first attempt. The contract that used to live invisibly in a subscription's configuration is now part of the context every generated publish call is checked against.&lt;/p&gt;

&lt;p&gt;The tool description itself tells the assistant when to use it — before writing any SNS publish code — so you don't have to remember to prompt for it. And because infrawise also scans your application code (an AST pass that recognizes &lt;code&gt;PublishCommand&lt;/code&gt;, &lt;code&gt;PublishBatchCommand&lt;/code&gt;, and SDK &lt;code&gt;publish&lt;/code&gt; calls and resolves their target topic ARNs), the graph knows which of your functions publish to which topics. Reviewing an existing publisher works the same way: the assistant can cross-reference what the function sends against what the topic's subscriptions require.&lt;/p&gt;

&lt;p&gt;A few boundaries worth stating, because tools that read your AWS account should be explicit about them. Infrawise is read-only — the SNS extraction uses &lt;code&gt;GetTopicAttributes&lt;/code&gt;, &lt;code&gt;ListSubscriptionsByTopic&lt;/code&gt;, and &lt;code&gt;GetSubscriptionAttributes&lt;/code&gt;, nothing that writes. It never reads message contents, secret values, or parameter values. And the analysis is deterministic: AST parsing and API introspection, no LLM deciding what your infrastructure looks like. The LLM is only a consumer of the extracted context.&lt;/p&gt;

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

&lt;p&gt;Silent message drops are a configuration-versus-code mismatch, and those never show up in the layer you're staring at. The filter policy is correct. The publish code is correct. Only the combination is broken, and the combination exists nowhere in your repository — until you put it there.&lt;/p&gt;

&lt;p&gt;You can do that manually every session by reading subscriptions in the console, or you can have it extracted once and served to your assistant automatically. If you're on the second option: &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;code&gt;npx infrawise start --claude&lt;/code&gt; and ask your assistant what the &lt;code&gt;order-events&lt;/code&gt; topic requires.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;An SNS publish that misses a subscription's filter policy succeeds silently: &lt;code&gt;MessageId&lt;/code&gt; returned, no DLQ entry, no error. The redrive policy only covers delivery failures, and a filter mismatch isn't one.&lt;/li&gt;
&lt;li&gt;The publisher's contract lives on the subscription, in AWS, not in your code — so tests with mocked SNS clients and AI assistants reading source files both miss it.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;FilterPolicyScope&lt;/code&gt;: policies can require message &lt;em&gt;attributes&lt;/em&gt; or keys in the message &lt;em&gt;body&lt;/em&gt;. The fix is different for each.&lt;/li&gt;
&lt;li&gt;Before writing any publish call, enumerate the target topic's filter policies and include every required attribute. With infrawise, &lt;code&gt;get_topic_details&lt;/code&gt; gives your AI assistant that list automatically.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;NumberOfNotificationsFilteredOut&lt;/code&gt; CloudWatch metric is your post-hoc signal, but the goal is to never need it — catch the missing attribute at coding time.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>typescript</category>
      <category>aws</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Stop Sending Your AI Assistant 40 Tables When It Only Needs 3</title>
      <dc:creator>Siddharth Pandey</dc:creator>
      <pubDate>Tue, 07 Jul 2026 08:35:15 +0000</pubDate>
      <link>https://dev.to/siddharth_pandey_27/stop-sending-your-ai-assistant-40-tables-when-it-only-needs-3-19b7</link>
      <guid>https://dev.to/siddharth_pandey_27/stop-sending-your-ai-assistant-40-tables-when-it-only-needs-3-19b7</guid>
      <description>&lt;p&gt;Say your service has 40 tables. You ask Claude Code to fix a bug in checkout — a function that touches exactly three of them: &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;payments&lt;/code&gt;, &lt;code&gt;inventory_reservations&lt;/code&gt;. If your MCP server hands the model your whole schema graph on every call just to answer that, you've spent a few thousand tokens of context on 37 tables nobody asked about, before the model has written a single line of code.&lt;/p&gt;

&lt;p&gt;Multiply that by every tool call, every session, every developer on the team, and "just give it the schema" turns into a real line item — slower responses, a noisier context window, and a model more likely to get distracted by a &lt;code&gt;campaigns&lt;/code&gt; table that has nothing to do with the bug you're fixing.&lt;/p&gt;

&lt;p&gt;Infrawise's MCP tools are built around a specific answer to this: never send more schema than the task needs, and give the agent an explicit way to ask for exactly what it's missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two bad defaults
&lt;/h2&gt;

&lt;p&gt;Without something like this, an AI coding assistant reading your codebase has two options, and both are wrong in a different direction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option one: no schema at all.&lt;/strong&gt; The model reads your source files, sees a &lt;code&gt;DocumentClient.scan()&lt;/code&gt; call, and has no way to know that table has 50 million rows and a GSI it isn't using. It writes code that compiles and looks reasonable and is wrong the moment it touches production data — because "wrong" here isn't a syntax problem, it's a missing fact about your infrastructure that isn't in any file it can read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option two: dump everything, every time.&lt;/strong&gt; Paste the full schema — every table, every column, every foreign key, every DynamoDB GSI — into the prompt so the model definitely has what it needs. This works, in the sense that the model now has the fact it's missing. It also means every single request pays for the full graph whether the task touches one table or fifteen. Context windows aren't free, and neither is the model's attention: the more irrelevant schema it has to read past, the more likely it latches onto the wrong table or a stale column name from a service you don't even own.&lt;/p&gt;

&lt;p&gt;Infrawise's MCP server is designed around a third option: give the agent a cheap way to see what exists, then let it ask for detail only on the things it's actually going to touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the lookup actually works
&lt;/h2&gt;

&lt;p&gt;The server exposes 21 tools, but three of them define the whole pattern.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;get_infra_overview&lt;/code&gt; is the entry point. It returns a compact snapshot — every table and collection by name and database type, queue and topic names, secret names, Lambda names, high-severity findings — no columns, no foreign keys, no indexes. It's meant to answer "what exists here" in a few hundred tokens, not "give me everything about it."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;get_table_schema&lt;/code&gt; is where the actual detail lives, and it's scoped on purpose: it takes a list of 1 to 20 table or collection names and returns, per name, the columns with data types and nullability, primary keys, foreign keys (so an agent building a join knows the path without guessing), indexes, DynamoDB partition/sort keys, or a MongoDB estimated document count. Row data is never included — this is schema, not a data dump. Names are matched case-insensitively and by suffix, so asking for &lt;code&gt;orders&lt;/code&gt; matches &lt;code&gt;public.orders&lt;/code&gt; without the agent needing to know your schema prefix in advance. Ask for a table that doesn't exist and instead of a bare failure, you get up to five closest name matches back — useful when the agent guessed &lt;code&gt;order&lt;/code&gt; instead of &lt;code&gt;orders&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;get_graph_summary&lt;/code&gt; is still there, and it still returns everything — every node, every edge, every finding, no filtering. It's the tool description that makes the intent explicit: it exists as the tool to reach for when you genuinely need the full picture across services, not the one an agent should reach for to answer "what does the orders table look like." The default path is &lt;code&gt;get_infra_overview&lt;/code&gt; for orientation, &lt;code&gt;get_table_schema&lt;/code&gt; for the two or three tables actually in scope, and &lt;code&gt;get_graph_summary&lt;/code&gt; only when the task is broad enough to need it — reviewing an entire service, tracing relationships across five different tables and functions at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like on a real task
&lt;/h2&gt;

&lt;p&gt;Take the checkout bug from the top. An agent working through it calls &lt;code&gt;get_infra_overview&lt;/code&gt; once and learns there are 40 tables across Postgres and DynamoDB, plus a queue and a couple of Lambdas — cheap, one call, no column data yet. It's now looking at &lt;code&gt;orders.ts&lt;/code&gt;, sees a query joining on &lt;code&gt;payment_id&lt;/code&gt;, and calls &lt;code&gt;get_table_schema&lt;/code&gt; with &lt;code&gt;["orders", "payments"]&lt;/code&gt;. Back comes exactly two schemas: column types, the foreign key from &lt;code&gt;orders.payment_id&lt;/code&gt; to &lt;code&gt;payments.id&lt;/code&gt;, and the indexes on both tables. That's the entire fetched context for the task — two tables, not forty.&lt;/p&gt;

&lt;p&gt;If the bug turns out to touch &lt;code&gt;inventory_reservations&lt;/code&gt; too, the agent just adds it to the next &lt;code&gt;get_table_schema&lt;/code&gt; call. It never had to have asked for it upfront, and it never had to eat the cost of the other 37 tables it was never going to look at.&lt;/p&gt;

&lt;p&gt;Compare that to the alternative most teams reach for without a tool like this: pasting the schema export once, keeping it in context, and hoping it doesn't drift as the schema changes. Infrawise's tables come from a fresh analysis (&lt;code&gt;get_infra_overview&lt;/code&gt; even reports a &lt;code&gt;freshness&lt;/code&gt; field with an age and a &lt;code&gt;stale&lt;/code&gt; flag once the cached analysis passes 24 hours), so the agent knows when to ask for a re-run instead of working off of something that quietly went out of date three deploys ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters beyond the token count
&lt;/h2&gt;

&lt;p&gt;The token savings are the visible part, but the more important effect is on accuracy. A model given forty tables' worth of columns has to do implicit filtering — figure out which of them are relevant, hold the rest as noise. Give it three tables that are actually in scope, and there's no filtering step: everything in context is something it's going to use. That's a smaller, sharper problem than "here's a schema, find what you need," and models are measurably better at smaller, sharper problems.&lt;/p&gt;

&lt;p&gt;It also composes with what AGENTS.md calls out directly for large-database use cases — a text-to-SQL or query-writing agent should call &lt;code&gt;get_infra_overview&lt;/code&gt; once per session for the table inventory, then &lt;code&gt;get_table_schema&lt;/code&gt; only for the tables the current query touches, and treat &lt;code&gt;get_graph_summary&lt;/code&gt; as the tool of last resort, not the default. That's the same pattern the checkout example walks through, just named as the recommended path for exactly the case where dumping everything hurts most — a database with hundreds of tables where "just paste the schema" was never realistic in the first place.&lt;/p&gt;

&lt;p&gt;None of this requires the developer to think about it. You don't decide when to call &lt;code&gt;get_table_schema&lt;/code&gt; versus &lt;code&gt;get_graph_summary&lt;/code&gt; — the agent does, because the tool descriptions say when to reach for each one. The developer experience is just: ask Claude Code to fix the bug, and it already knows which two tables matter.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Two bad defaults exist for schema context: give an AI assistant nothing (it guesses and gets it wrong) or give it everything (it pays for and gets distracted by tables it will never touch).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_infra_overview&lt;/code&gt; is a compact, column-free snapshot meant for orientation — names and types, not detail.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_table_schema&lt;/code&gt; fetches full column, key, and index detail for up to 20 named tables at a time, matched case-insensitively by short name, with fuzzy suggestions on a miss.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_graph_summary&lt;/code&gt; still returns the full graph — it's the explicit escape hatch for cross-service work, not the tool an agent should reach for to answer a question about two tables.&lt;/li&gt;
&lt;li&gt;The pattern scales down naturally to large databases: fetch the inventory once per session, then pull schemas only for the tables the current task actually touches.&lt;/li&gt;
&lt;/ul&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;

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