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.
The contract nobody wrote down
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:
- No error surface. 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.
-
The only trace is a metric. SNS increments a
NumberOfNotificationsFilteredOutCloudWatch metric, but unless you already suspect filtering, nobody looks at it — and by the time you do, you're debugging in production. -
Tests pass. Unit tests mock the SNS client and assert that
PublishCommandwas 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.
Why your AI assistant writes the broken version
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.
Pulling the contract into the coding session
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.
Conclusion
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.
Key Takeaways
- An SNS publish that misses a subscription's filter policy succeeds silently:
MessageIdreturned, no DLQ entry, no error. The redrive policy only covers delivery failures, and a filter mismatch isn't one. - 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.
- Check
FilterPolicyScope: policies can require message attributes or keys in the message body. The fix is different for each. - Before writing any publish call, enumerate the target topic's filter policies and include every required attribute. With infrawise,
get_topic_detailsgives your AI assistant that list automatically. - The
NumberOfNotificationsFilteredOutCloudWatch metric is your post-hoc signal, but the goal is to never need it — catch the missing attribute at coding time.
Top comments (0)