Your checkout service publishes an OrderRefunded
event to an SNS topic. The publish call returns a MessageId
. 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.
The message wasn't lost. It was filtered. One of the topic's subscriptions has a filter policy that requires an eventType
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.
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.
SNS filter policies live on the subscription, not the topic. A subscriber says "only deliver messages where the eventType
attribute is one of these values":
{
"eventType": ["order.refunded", "order.cancelled"]
}
For that subscription to ever receive anything, the publisher must include the attribute:
await sns.send(
new PublishCommand({
TopicArn: process.env.ORDER_EVENTS_TOPIC,
Message: JSON.stringify(refund),
MessageAttributes: {
eventType: { DataType: 'String', StringValue: 'order.refunded' },
},
}),
);
Omit MessageAttributes
and the publish still succeeds — SNS accepts the message and returns a MessageId
regardless of whether any subscription matches. The message simply isn't delivered to the filtered subscription.
Three things make this failure quiet:
NumberOfNotificationsFilteredOut
CloudWatch metric, but unless you already suspect filtering, nobody looks at it — and by the time you do, you're debugging in production.PublishCommand
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.There is a second variant of the same trap: filter policies can also match against the message body (FilterPolicyScope: MessageBody
) instead of message attributes. Then the required keys must appear in your JSON payload itself. Same silence, different location.
Ask an AI coding assistant to "publish an order refunded event to the order-events topic" and it will produce a perfectly reasonable PublishCommand
call — topic ARN from an environment variable, JSON.stringify
on the payload, maybe a comment. What it will almost never produce is the MessageAttributes
block with exactly the keys your subscriptions filter on.
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.
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.
This is the problem infrawise 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.
cd your-project
npx infrawise start --claude
That analyzes your AWS account and codebase, writes .mcp.json
, 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 FilterPolicy
JSON, and records which attribute keys the policy requires and whether the policy scope is MessageAttributes
or MessageBody
.
The result is exposed through a tool called get_topic_details
. For each topic it returns the subscription count, encryption status, and a filterPolicies
array:
{
"name": "order-events",
"provider": "aws",
"subscriptionCount": 3,
"encrypted": true,
"filterPolicies": [
{
"subscriptionArn": "arn:aws:sns:...:order-events:a1b2...",
"protocol": "sqs",
"requiredAttributes": ["eventType"],
"scope": "MessageAttributes"
}
]
}
Now the workflow changes. When you ask your assistant to write a publish call, it calls get_topic_details
first, sees that a subscription on order-events
filters on eventType
, and writes the MessageAttributes
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.
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 PublishCommand
, PublishBatchCommand
, and SDK publish
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.
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 GetTopicAttributes
, ListSubscriptionsByTopic
, and GetSubscriptionAttributes
, 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.
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.
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: GitHub · npm — npx infrawise start --claude
and ask your assistant what the order-events
topic requires.
MessageId
returned, no DLQ entry, no error. The redrive policy only covers delivery failures, and a filter mismatch isn't one.FilterPolicyScope
: policies can require message get_topic_details
gives your AI assistant that list automatically.NumberOfNotificationsFilteredOut
CloudWatch metric is your post-hoc signal, but the goal is to never need it — catch the missing attribute at coding time.