SyncValsverifier → artifact → classifier → verdict
SyncVals · Trajectory

ddb-outbox-eventbridge-fanout

claude-code claude-opus-4-7 ✓ resolved GOOD_SUCCESS ↑ View task
Solved from the instruction alone, tests/ and solution/ were withheld from the agent's workspace and restored only for grading.
Reward = tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.
Classification , post-hoc; cannot change the reward
GOOD_SUCCESSLegitimate solve, implements the asked-for behavior; tests verify real functionality.
SubtypeLegitimate Solution
EvidenceAll 20 tests passed including: test_08_outbox_stream_is_new_and_old_images, test_15_relay_esm_has_batch_item_failures_on_outbox_stream, test_16_relay_role_is_scoped_not_wildcard, test_17_order_api_role_scoped_to_orders_and_outbox_only, test_18_e2e_order_created_routes_to_created_queue_only, test_20_e2e_idempotent_duplicate_does_not_double_fanout. Agent correctly implemented order-api Lambda with transactional DynamoDB writes and outbox-relay Lambda with idempotent dedup logic using DynamoDB TTL. Result: reward=1.0
Root causeThe agent successfully implemented a complete distributed event fanout system using AWS primitives (DynamoDB transactions, Streams, EventBridge, SQS, IAM, KMS), correctly handling all specified constraints including customer-managed CMK encryption, least-privilege role scoping, idempotent stream processing, and batch failure handling.
RecommendationN/A - task is fine
Trajectory
Tool-by-tool agent trajectory
40 tool calls · 4 tool types · 52 steps
ok the checkout service has been dropping events again. every time the orders table gets a new row, *something* downstream needs to know , pricing, fulfillment, the analytics folks, all of them. right now we're just hoping two sequential putitems stick. they don't. last week we had a row written and no event fired because the lambda crashed between the two writes. nobody noticed for 6 hours. so: outbox pattern. one transaction, two rows, and let the stream do the fanout. build it on localstack , `http://localstack:4566`, creds already exported (`AWS_ACCESS_KEY_ID=test`, same for secret, region `us-east-1`). you've got `aws`, `python3`, `boto3`, `jq`, `zip`. build the whole thing from zero. shape of it: - an "order api" lambda is the only thing that writes orders. it takes `{order_id, kind, ...}` where `kind` is either `OrderCreated` or `OrderCancelled`. it writes the business row **and** the outbox row in one atomic step. no half-states allowed. - the outbox table has a stream , view type `NEW_AND_OLD_IMAGES` (the relay needs to see both the new and old image of the row, not just keys, so downstream subscribers can react on diffs and not just inserts). a relay lambda reads the stream via an event source mapping and republishes onto a custom eventbridge bus. - the relay's event source mapping must use `ReportBatchItemFailures` so a single bad record can fail without retrying the whole batch , the relay returns `{"batchItemFailures": [{"itemIdentifier": "<seq#>"}, ...]}` for the records it couldn't republish. - two rules on that bus route by kind: `OrderCreated` goes to one sqs queue, `OrderCancelled` goes to another. each queue has its own dlq. no shared dlq. - the relay has to be idempotent , if the stream redelivers the same record, we don't want the downstream queue to see the message twice. keep a small ddb table for dedup, ttl it so it doesn't grow forever. - everything is encrypted with a customer-managed kms key. not `aws/dynamodb`, not `aws/sqs`. ours. done looks like this: invoke `order-api` with `{order_id: "o-1", kind: "OrderCreated", ...}`, and within 30s: - `orders-created` sqs queue has exactly 1 message - `orders-cancelled` sqs queue has 0 messages - `processed_events` table has a row for the corresponding outbox event_id then invoke again with `kind: "OrderCancelled"` and a different `order_id`: - `orders-cancelled` picks it up, `orders-created` stays quiet least privilege: every role scoped to its own resources. the outbox-relay role should not be able to touch the orders table's stream; the order-api role should not be able to touch sqs; nobody gets `*` on kms. the verifier reads the policy docs directly, not just "does it work end-to-end". names the verifier grabs by hand, use them exactly: | thing | name | |-|-| | kms alias | `alias/outbox-cmk` | | orders table | `orders` (pk `order_id` / String) | | outbox table | `outbox` (pk `event_id` / String) | | dedup table | `processed_events` (pk `event_id` / String) | | api lambda | `order-api` | | api lambda role | `order-api-role` | | relay lambda | `outbox-relay` | | relay lambda role | `outbox-relay-role` | | event bus | `order-events` | | rule for created | `on-order-created` | | rule for cancelled | `on-order-cancelled` | | created queue | `orders-created` | | cancelled queue | `orders-cancelled` | | created dlq | `orders-created-dlq` | | cancelled dlq | `orders-cancelled-dlq` | eventbridge detail shape: the relay republishes with `source = "outbox.relay"` and `detail-type` matching `kind` exactly , `OrderCreated` or `OrderCancelled`. rule patterns match by those. python3.11 for both lambdas. they run on the shared compose network so `http://localstack:4566` is reachable from inside them. localstack community 4.13.1. outbox rows need an `event_id` so the relay has a stable dedup key; generate one however you like, just make sure it's the same id when the row gets read off the stream.

List app directory contents

shell
ls /app && pwd
output
/app

[stdout]
/app

Check LocalStack health

shell
curl -s http://localstack:4566/_localstack/health | head -50
output
{"services": {"acm": "disabled", "apigateway": "disabled", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "available", "dynamodbstreams": "available", "ec2": "disabled", "es": "disabled", "events": "available", "firehose": "disabled", "iam": "available", "kinesis": "available", "kms": "available", "lambda": "available", "logs": "available", "opensearch": "disabled", "redshift": "disabled", "resource-groups": "disabled", "resourcegroupstaggingapi": "disabled", "route53": "disabled", "route53resolver": "disabled", "s3": "available", "s3control": "disabled", "scheduler": "disabled", "secretsmanager": "disabled", "ses": "disabled", "sns": "disabled", "sqs": "available", "ssm": "disabled", "stepfunctions": "disabled", "sts": "available", "support": "disabled", "swf": "disabled", "transcribe": "disabled"}, "edition": "community", "version": "4.13.1"}

[stdout]
{"services": {"acm": "disabled", "apigateway": "disabled", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "available", "dynamodbstreams": "available", "ec2": "disabled", "es": "disabled", "events": "available", "firehose": "disabled", "iam": "available", "kinesis": "available", "kms": "available", "lambda": "available", "logs": "available", "opensearch": "disabled", "redshift": "disabled", "resource-groups": "disabled", "resourcegroupstaggingapi": "disabled", "route53": "disabled", "route53resolver": "disabled", "s3": "available", "s3control": "disabled", "scheduler": "disabled", "secretsmanager": "disabled", "ses": "disabled", "sns": "disabled", "sqs": "available", "ssm": "disabled", "stepfunctions": "disabled", "sts": "available", "support": "disabled", "swf": "disabled", "transcribe": "disabled"}, "edition": "community", "version": "4.13.1"}
ToolSearch
{
  "query": "select:TodoWrite",
  "max_results": 1
}

, output, 
{"type": "tool_reference", "tool_name": "TodoWrite"}
  • Verify environment and tools available
  • Create KMS CMK with alias outbox-cmk
  • Create DynamoDB tables (orders, outbox with stream, processed_events with TTL)
  • Create SQS queues (orders-created, orders-cancelled) with DLQs
  • Create EventBridge bus, rules, and SQS targets
  • Create least-privilege IAM roles for both lambdas
  • Write and deploy order-api lambda (TransactWriteItems)
  • Write and deploy outbox-relay lambda (stream + dedup + EventBridge)
  • Wire DynamoDB stream to relay with ReportBatchItemFailures
  • End-to-end test: OrderCreated and OrderCancelled flows

Verify tools and environment variables

shell
which aws python3 jq zip && python3 --version && aws --version && echo "---env---" && env | grep -E "AWS_|LOCAL" | sort
output
/usr/local/bin/aws
/opt/venv/bin/python3
/usr/bin/jq
/usr/bin/zip
Python 3.12.3
aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24
---env---
AWS_ACCESS_KEY_ID=test
AWS_DEFAULT_REGION=us-east-1
AWS_ENDPOINT_URL=http://localstack:4566
AWS_PAGER=
AWS_SECRET_ACCESS_KEY=test

[stdout]
/usr/local/bin/aws
/opt/venv/bin/python3
/usr/bin/jq
/usr/bin/zip
Python 3.12.3
aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24
---env---
AWS_ACCESS_KEY_ID=test
AWS_DEFAULT_REGION=us-east-1
AWS_ENDPOINT_URL=http://localstack:4566
AWS_PAGER=
AWS_SECRET_ACCESS_KEY=test

Create build directory structure

shell
mkdir -p /app/build /app/policies /app/lambdas/order-api /app/lambdas/outbox-relay && ls /app
output
build
lambdas
policies

[stdout]
build
lambdas
policies
  • Verify environment and tools available
  • Create KMS CMK with alias outbox-cmk
  • Create DynamoDB tables (orders, outbox with stream, processed_events with TTL)
  • Create SQS queues (orders-created, orders-cancelled) with DLQs
  • Create EventBridge bus, rules, and SQS targets
  • Create least-privilege IAM roles for both lambdas
  • Write and deploy order-api lambda (TransactWriteItems)
  • Write and deploy outbox-relay lambda (stream + dedup + EventBridge)
  • Wire DynamoDB stream to relay with ReportBatchItemFailures
  • End-to-end test: OrderCreated and OrderCancelled flows
Creating KMS CMK first since other resources depend on it.

Get account ID

shell
aws sts get-caller-identity
output
{
    "UserId": "AKIAIOSFODNN7EXAMPLE",
    "Account": "000000000000",
    "Arn": "arn:aws:iam::000000000000:root"
}

[stdout]
{
    "UserId": "AKIAIOSFODNN7EXAMPLE",
    "Account": "000000000000",
    "Arn": "arn:aws:iam::000000000000:root"
}

Create KMS CMK and alias

shell
KEY_ID=$(aws kms create-key --description "outbox CMK" --key-usage ENCRYPT_DECRYPT --query 'KeyMetadata.KeyId' --output text)
echo "KEY_ID=$KEY_ID"
aws kms create-alias --alias-name alias/outbox-cmk --target-key-id "$KEY_ID"
KEY_ARN=$(aws kms describe-key --key-id alias/outbox-cmk --query 'KeyMetadata.Arn' --output text)
echo "KEY_ARN=$KEY_ARN"
echo "$KEY_ARN" > /app/build/kms_key_arn.txt
echo "$KEY_ID" > /app/build/kms_key_id.txt
output
KEY_ID=a378d809-0047-4032-a731-a97d25d2ceb5
KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5

[stdout]
KEY_ID=a378d809-0047-4032-a731-a97d25d2ceb5
KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5
  • Verify environment and tools available
  • Create KMS CMK with alias outbox-cmk
  • Create DynamoDB tables (orders, outbox with stream, processed_events with TTL)
  • Create SQS queues (orders-created, orders-cancelled) with DLQs
  • Create EventBridge bus, rules, and SQS targets
  • Create least-privilege IAM roles for both lambdas
  • Write and deploy order-api lambda (TransactWriteItems)
  • Write and deploy outbox-relay lambda (stream + dedup + EventBridge)
  • Wire DynamoDB stream to relay with ReportBatchItemFailures
  • End-to-end test: OrderCreated and OrderCancelled flows
Creating the three DynamoDB tables. The outbox table needs a stream with `NEW_AND_OLD_IMAGES`, and `processed_events` needs TTL.

Create three DynamoDB tables with CMK encryption

shell
KEY_ARN=$(cat /app/build/kms_key_arn.txt)

aws dynamodb create-table \
  --table-name orders \
  --attribute-definitions AttributeName=order_id,AttributeType=S \
  --key-schema AttributeName=order_id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --sse-specification "Enabled=true,SSEType=KMS,KMSMasterKeyId=$KEY_ARN" \
  --query 'TableDescription.TableArn' --output text

aws dynamodb create-table \
  --table-name outbox \
  --attribute-definitions AttributeName=event_id,AttributeType=S \
  --key-schema AttributeName=event_id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --stream-specification "StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES" \
  --sse-specification "Enabled=true,SSEType=KMS,KMSMasterKeyId=$KEY_ARN" \
  --query 'TableDescription.TableArn' --output text

aws dynamodb create-table \
  --table-name processed_events \
  --attribute-definitions AttributeName=event_id,AttributeType=S \
  --key-schema AttributeName=event_id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --sse-specification "Enabled=true,SSEType=KMS,KMSMasterKeyId=$KEY_ARN" \
  --query 'TableDescription.TableArn' --output text

aws dynamodb update-time-to-live \
  --table-name processed_events \
  --time-to-live-specification "Enabled=true,AttributeName=ttl" \
  --query 'TimeToLiveSpecification' --output json
output
arn:aws:dynamodb:us-east-1:000000000000:table/orders
arn:aws:dynamodb:us-east-1:000000000000:table/outbox
arn:aws:dynamodb:us-east-1:000000000000:table/processed_events
{
    "Enabled": true,
    "AttributeName": "ttl"
}

[stdout]
arn:aws:dynamodb:us-east-1:000000000000:table/orders
arn:aws:dynamodb:us-east-1:000000000000:table/outbox
arn:aws:dynamodb:us-east-1:000000000000:table/processed_events
{
    "Enabled": true,
    "AttributeName": "ttl"
}

Get outbox stream ARN and verify SSE

shell
STREAM_ARN=$(aws dynamodb describe-table --table-name outbox --query 'Table.LatestStreamArn' --output text)
echo "$STREAM_ARN" > /app/build/outbox_stream_arn.txt
echo "STREAM_ARN=$STREAM_ARN"
# verify SSE settings
aws dynamodb describe-table --table-name orders --query 'Table.SSEDescription'
aws dynamodb describe-table --table-name outbox --query 'Table.SSEDescription'
aws dynamodb describe-table --table-name outbox --query 'Table.StreamSpecification'
output
STREAM_ARN=arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T15:10:08.104
{
    "Status": "ENABLED",
    "SSEType": "KMS",
    "KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5"
}
{
    "Status": "ENABLED",
    "SSEType": "KMS",
    "KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5"
}
{
    "StreamEnabled": true,
    "StreamViewType": "NEW_AND_OLD_IMAGES"
}

[stdout]
STREAM_ARN=arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T15:10:08.104
{
    "Status": "ENABLED",
    "SSEType": "KMS",
    "KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5"
}
{
    "Status": "ENABLED",
    "SSEType": "KMS",
    "KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5"
}
{
    "StreamEnabled": true,
    "StreamViewType": "NEW_AND_OLD_IMAGES"
}
  • Verify environment and tools available
  • Create KMS CMK with alias outbox-cmk
  • Create DynamoDB tables (orders, outbox with stream, processed_events with TTL)
  • Create SQS queues (orders-created, orders-cancelled) with DLQs
  • Create EventBridge bus, rules, and SQS targets
  • Create least-privilege IAM roles for both lambdas
  • Write and deploy order-api lambda (TransactWriteItems)
  • Write and deploy outbox-relay lambda (stream + dedup + EventBridge)
  • Wire DynamoDB stream to relay with ReportBatchItemFailures
  • End-to-end test: OrderCreated and OrderCancelled flows
Creating the SQS queues , DLQs first so the main queues can reference them via redrive policies. Each queue gets KMS encryption with our CMK.

Create DLQs and main SQS queues with KMS and redrive

shell
KEY_ID=$(cat /app/build/kms_key_id.txt)

# DLQs
DLQ_CREATED_URL=$(aws sqs create-queue \
  --queue-name orders-created-dlq \
  --attributes "{\"KmsMasterKeyId\":\"$KEY_ID\"}" \
  --query 'QueueUrl' --output text)
DLQ_CANCELLED_URL=$(aws sqs create-queue \
  --queue-name orders-cancelled-dlq \
  --attributes "{\"KmsMasterKeyId\":\"$KEY_ID\"}" \
  --query 'QueueUrl' --output text)

DLQ_CREATED_ARN=$(aws sqs get-queue-attributes --queue-url "$DLQ_CREATED_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
DLQ_CANCELLED_ARN=$(aws sqs get-queue-attributes --queue-url "$DLQ_CANCELLED_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)

echo "DLQ_CREATED_ARN=$DLQ_CREATED_ARN"
echo "DLQ_CANCELLED_ARN=$DLQ_CANCELLED_ARN"

# main queues with redrive
RP_CREATED=$(jq -n --arg arn "$DLQ_CREATED_ARN" '{deadLetterTargetArn:$arn, maxReceiveCount:"3"} | tostring')
RP_CANCELLED=$(jq -n --arg arn "$DLQ_CANCELLED_ARN" '{deadLetterTargetArn:$arn, maxReceiveCount:"3"} | tostring')

CREATED_ATTRS=$(jq -n --arg key "$KEY_ID" --arg rp "$RP_CREATED" '{KmsMasterKeyId:$key, RedrivePolicy:$rp}')
CANCELLED_ATTRS=$(jq -n --arg key "$KEY_ID" --arg rp "$RP_CANCELLED" '{KmsMasterKeyId:$key, RedrivePolicy:$rp}')

CREATED_URL=$(aws sqs create-queue --queue-name orders-created --attributes "$CREATED_ATTRS" --query 'QueueUrl' --output text)
CANCELLED_URL=$(aws sqs create-queue --queue-name orders-cancelled --attributes "$CANCELLED_ATTRS" --query 'QueueUrl' --output text)

CREATED_ARN=$(aws sqs get-queue-attributes --queue-url "$CREATED_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
CANCELLED_ARN=$(aws sqs get-queue-attributes --queue-url "$CANCELLED_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)

echo "CREATED_URL=$CREATED_URL"
echo "CANCELLED_URL=$CANCELLED_URL"
echo "CREATED_ARN=$CREATED_ARN"
echo "CANCELLED_ARN=$CANCELLED_ARN"

# persist
{
  echo "DLQ_CREATED_URL=$DLQ_CREATED_URL"
  echo "DLQ_CANCELLED_URL=$DLQ_CANCELLED_URL"
  echo "DLQ_CREATED_ARN=$DLQ_CREATED_ARN"
  echo "DLQ_CANCELLED_ARN=$DLQ_CANCELLED_ARN"
  echo "CREATED_URL=$CREATED_URL"
  echo "CANCELLED_URL=$CANCELLED_URL"
  echo "CREATED_ARN=$CREATED_ARN"
  echo "CANCELLED_ARN=$CANCELLED_ARN"
} > /app/build/queues.env
output
DLQ_CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created-dlq
DLQ_CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled-dlq
CREATED_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created
CANCELLED_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled
CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created
CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled

[stdout]
DLQ_CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created-dlq
DLQ_CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled-dlq
CREATED_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created
CANCELLED_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled
CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created
CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled
  • Verify environment and tools available
  • Create KMS CMK with alias outbox-cmk
  • Create DynamoDB tables (orders, outbox with stream, processed_events with TTL)
  • Create SQS queues (orders-created, orders-cancelled) with DLQs
  • Create EventBridge bus, rules, and SQS targets
  • Create least-privilege IAM roles for both lambdas
  • Write and deploy order-api lambda (TransactWriteItems)
  • Write and deploy outbox-relay lambda (stream + dedup + EventBridge)
  • Wire DynamoDB stream to relay with ReportBatchItemFailures
  • End-to-end test: OrderCreated and OrderCancelled flows
Creating the EventBridge bus + rules. The bus uses our CMK; rules match by `source = outbox.relay` and `detail-type`. Each rule needs a queue policy on the target SQS to allow the events service to deliver.

Create event bus and two rules

shell
source /app/build/queues.env
KEY_ARN=$(cat /app/build/kms_key_arn.txt)

# create bus with CMK
BUS_ARN=$(aws events create-event-bus \
  --name order-events \
  --kms-key-identifier "$KEY_ARN" \
  --query 'EventBusArn' --output text)
echo "BUS_ARN=$BUS_ARN"

# Created rule
aws events put-rule \
  --name on-order-created \
  --event-bus-name order-events \
  --event-pattern '{"source":["outbox.relay"],"detail-type":["OrderCreated"]}' \
  --state ENABLED \
  --query 'RuleArn' --output text

# Cancelled rule
aws events put-rule \
  --name on-order-cancelled \
  --event-bus-name order-events \
  --event-pattern '{"source":["outbox.relay"],"detail-type":["OrderCancelled"]}' \
  --state ENABLED \
  --query 'RuleArn' --output text

echo "$BUS_ARN" > /app/build/bus_arn.txt
output
BUS_ARN=arn:aws:events:us-east-1:000000000000:event-bus/order-events
arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created
arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled

[stdout]
BUS_ARN=arn:aws:events:us-east-1:000000000000:event-bus/order-events
arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created
arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled

Attach EventBridge targets and set queue policies

shell
source /app/build/queues.env

# attach SQS targets to rules
aws events put-targets \
  --event-bus-name order-events \
  --rule on-order-created \
  --targets "Id=created-q,Arn=$CREATED_ARN"

aws events put-targets \
  --event-bus-name order-events \
  --rule on-order-cancelled \
  --targets "Id=cancelled-q,Arn=$CANCELLED_ARN"

# allow events.amazonaws.com to deliver to each queue (limited to specific rule)
RULE_CREATED_ARN="arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created"
RULE_CANCELLED_ARN="arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled"

CREATED_POLICY=$(jq -n --arg q "$CREATED_ARN" --arg rule "$RULE_CREATED_ARN" '{
  Version:"2012-10-17",
  Statement:[{
    Sid:"AllowEventBridgeRule",
    Effect:"Allow",
    Principal:{Service:"events.amazonaws.com"},
    Action:"sqs:SendMessage",
    Resource:$q,
    Condition:{ArnEquals:{"aws:SourceArn":$rule}}
  }]
}')

CANCELLED_POLICY=$(jq -n --arg q "$CANCELLED_ARN" --arg rule "$RULE_CANCELLED_ARN" '{
  Version:"2012-10-17",
  Statement:[{
    Sid:"AllowEventBridgeRule",
    Effect:"Allow",
    Principal:{Service:"events.amazonaws.com"},
    Action:"sqs:SendMessage",
    Resource:$q,
    Condition:{ArnEquals:{"aws:SourceArn":$rule}}
  }]
}')

aws sqs set-queue-attributes --queue-url "$CREATED_URL" \
  --attributes "$(jq -n --arg p "$CREATED_POLICY" '{Policy:$p}')"
aws sqs set-queue-attributes --queue-url "$CANCELLED_URL" \
  --attributes "$(jq -n --arg p "$CANCELLED_POLICY" '{Policy:$p}')"

echo "targets attached, queue policies set"
aws events list-targets-by-rule --event-bus-name order-events --rule on-order-created --query 'Targets[].Arn' --output text
aws events list-targets-by-rule --event-bus-name order-events --rule on-order-cancelled --query 'Targets[].Arn' --output text
output
{
    "FailedEntryCount": 0,
    "FailedEntries": []
}
{
    "FailedEntryCount": 0,
    "FailedEntries": []
}

aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'

aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'
targets attached, queue policies set
arn:aws:sqs:us-east-1:000000000000:orders-created
arn:aws:sqs:us-east-1:000000000000:orders-cancelled

[stdout]
{
    "FailedEntryCount": 0,
    "FailedEntries": []
}
{
    "FailedEntryCount": 0,
    "FailedEntries": []
}

aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'

aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'
targets attached, queue policies set
arn:aws:sqs:us-east-1:000000000000:orders-created
arn:aws:sqs:us-east-1:000000000000:orders-cancelled
The set-queue-attributes call hit a LocalStack JSON parsing quirk. Let me use file-based attributes instead.

Set SQS queue policies via file

shell
source /app/build/queues.env

RULE_CREATED_ARN="arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created"
RULE_CANCELLED_ARN="arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled"

# build the inner policy doc
jq -n --arg q "$CREATED_ARN" --arg rule "$RULE_CREATED_ARN" '{
  Version:"2012-10-17",
  Statement:[{
    Sid:"AllowEventBridgeRule",
    Effect:"Allow",
    Principal:{Service:"events.amazonaws.com"},
    Action:"sqs:SendMessage",
    Resource:$q,
    Condition:{ArnEquals:{"aws:SourceArn":$rule}}
  }]
}' > /tmp/created_policy.json

jq -n --arg q "$CANCELLED_ARN" --arg rule "$RULE_CANCELLED_ARN" '{
  Version:"2012-10-17",
  Statement:[{
    Sid:"AllowEventBridgeRule",
    Effect:"Allow",
    Principal:{Service:"events.amazonaws.com"},
    Action:"sqs:SendMessage",
    Resource:$q,
    Condition:{ArnEquals:{"aws:SourceArn":$rule}}
  }]
}' > /tmp/cancelled_policy.json

# build the attributes wrapper using --rawfile to embed the doc as string
jq -n --rawfile p /tmp/created_policy.json '{Policy:$p}' > /tmp/created_attrs.json
jq -n --rawfile p /tmp/cancelled_policy.json '{Policy:$p}' > /tmp/cancelled_attrs.json

aws sqs set-queue-attributes --queue-url "$CREATED_URL" --attributes file:///tmp/created_attrs.json
aws sqs set-queue-attributes --queue-url "$CANCELLED_URL" --attributes file:///tmp/cancelled_attrs.json

aws sqs get-queue-attributes --queue-url "$CREATED_URL" --attribute-names Policy --query 'Attributes.Policy' --output text | jq .
aws sqs get-queue-attributes --queue-url "$CANCELLED_URL" --attribute-names Policy --query 'Attributes.Policy' --output text | jq .
output
aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'

aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowEventBridgeRule",
      "Effect": "Allow",
      "Principal": {
        "Service": "events.amazonaws.com"
      },
      "Action": "sqs:SendMessage",
      "Resource": "arn:aws:sqs:us-east-1:000000000000:orders-created",
      "Condition": {
        "ArnEquals": {
          "aws:SourceArn": "arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created"
        }
      }
    }
  ]
}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowEventBridgeRule",
      "Effect": "Allow",
      "Principal": {
        "Service": "events.amazonaws.com"
      },
      "Action": "sqs:SendMessage",
      "Resource": "arn:aws:sqs:us-east-1:000000000000:orders-cancelled",
      "Condition": {
        "ArnEquals": {
          "aws:SourceArn": "arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled"
        }
      }
    }
  ]
}

[stdout]
aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'

aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowEventBridgeRule",
      "Effect": "Allow",
      "Principal": {
        "Service": "events.amazonaws.com"
      },
      "Action": "sqs:SendMessage",
      "Resource": "arn:aws:sqs:us-east-1:000000000000:orders-created",
      "Condition": {
        "ArnEquals": {
          "aws:SourceArn": "arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created"
        }
      }
    }
  ]
}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowEventBridgeRule",
      "Effect": "Allow",
      "Principal": {
        "Service": "events.amazonaws.com"
      },
      "Action": "sqs:SendMessage",
      "Resource": "arn:aws:sqs:us-east-1:000000000000:orders-cancelled",
      "Condition": {
        "ArnEquals": {
          "aws:SourceArn": "arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled"
        }
      }
    }
  ]
}
Policies are set despite the response-path error , LocalStack returns them back correctly. Moving on to IAM.
  • Verify environment and tools available
  • Create KMS CMK with alias outbox-cmk
  • Create DynamoDB tables (orders, outbox with stream, processed_events with TTL)
  • Create SQS queues (orders-created, orders-cancelled) with DLQs
  • Create EventBridge bus, rules, and SQS targets
  • Create least-privilege IAM roles for both lambdas
  • Write and deploy order-api lambda (TransactWriteItems)
  • Write and deploy outbox-relay lambda (stream + dedup + EventBridge)
  • Wire DynamoDB stream to relay with ReportBatchItemFailures
  • End-to-end test: OrderCreated and OrderCancelled flows
write · /app/policies/lambda-trust.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {"Service": "lambda.amazonaws.com"},
      "Action": "sts:AssumeRole"
    }
  ]
}
write · /app/policies/order-api-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Logs",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": [
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api",
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api:*"
      ]
    },
    {
      "Sid": "OrdersOutboxTransact",
      "Effect": "Allow",
      "Action": [
        "dynamodb:PutItem",
        "dynamodb:UpdateItem",
        "dynamodb:TransactWriteItems"
      ],
      "Resource": [
        "arn:aws:dynamodb:us-east-1:000000000000:table/orders",
        "arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
      ]
    },
    {
      "Sid": "CmkForDdbSse",
      "Effect": "Allow",
      "Action": [
        "kms:Encrypt",
        "kms:Decrypt",
        "kms:GenerateDataKey",
        "kms:DescribeKey"
      ],
      "Resource": "__KEY_ARN__",
      "Condition": {
        "StringEquals": {
          "kms:ViaService": "dynamodb.us-east-1.amazonaws.com"
        }
      }
    }
  ]
}
write · /app/policies/outbox-relay-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Logs",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": [
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay",
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay:*"
      ]
    },
    {
      "Sid": "OutboxStreamRead",
      "Effect": "Allow",
      "Action": [
        "dynamodb:DescribeStream",
        "dynamodb:GetRecords",
        "dynamodb:GetShardIterator",
        "dynamodb:ListStreams"
      ],
      "Resource": "__OUTBOX_STREAM_ARN__"
    },
    {
      "Sid": "DedupTable",
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem"
      ],
      "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/processed_events"
    },
    {
      "Sid": "PublishToBus",
      "Effect": "Allow",
      "Action": "events:PutEvents",
      "Resource": "arn:aws:events:us-east-1:000000000000:event-bus/order-events"
    },
    {
      "Sid": "CmkForDdbAndEventsSse",
      "Effect": "Allow",
      "Action": [
        "kms:Decrypt",
        "kms:GenerateDataKey",
        "kms:DescribeKey"
      ],
      "Resource": "__KEY_ARN__",
      "Condition": {
        "StringEquals": {
          "kms:ViaService": [
            "dynamodb.us-east-1.amazonaws.com",
            "events.us-east-1.amazonaws.com"
          ]
        }
      }
    }
  ]
}

Create IAM roles and inline policies

shell
KEY_ARN=$(cat /app/build/kms_key_arn.txt)
STREAM_ARN=$(cat /app/build/outbox_stream_arn.txt)

# substitute placeholders
sed "s|__KEY_ARN__|$KEY_ARN|g" /app/policies/order-api-policy.json > /app/build/order-api-policy.json
sed -e "s|__KEY_ARN__|$KEY_ARN|g" -e "s|__OUTBOX_STREAM_ARN__|$STREAM_ARN|g" /app/policies/outbox-relay-policy.json > /app/build/outbox-relay-policy.json

# create roles
aws iam create-role --role-name order-api-role \
  --assume-role-policy-document file:///app/policies/lambda-trust.json \
  --query 'Role.Arn' --output text > /app/build/order-api-role.arn
aws iam create-role --role-name outbox-relay-role \
  --assume-role-policy-document file:///app/policies/lambda-trust.json \
  --query 'Role.Arn' --output text > /app/build/outbox-relay-role.arn

cat /app/build/order-api-role.arn /app/build/outbox-relay-role.arn

# attach inline policies
aws iam put-role-policy --role-name order-api-role \
  --policy-name order-api-inline \
  --policy-document file:///app/build/order-api-policy.json
aws iam put-role-policy --role-name outbox-relay-role \
  --policy-name outbox-relay-inline \
  --policy-document file:///app/build/outbox-relay-policy.json

echo "--- order-api inline policy ---"
aws iam get-role-policy --role-name order-api-role --policy-name order-api-inline --query 'PolicyDocument' --output json
echo "--- outbox-relay inline policy ---"
aws iam get-role-policy --role-name outbox-relay-role --policy-name outbox-relay-inline --query 'PolicyDocument' --output json
output
arn:aws:iam::000000000000:role/order-api-role
arn:aws:iam::000000000000:role/outbox-relay-role
--- order-api inline policy ---
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Logs",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api",
                "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api:*"
            ]
        },
        {
            "Sid": "OrdersOutboxTransact",
            "Effect": "Allow",
            "Action": [
                "dynamodb:PutItem",
                "dynamodb:UpdateItem",
                "dynamodb:TransactWriteItems"
            ],
            "Resource": [
                "arn:aws:dynamodb:us-east-1:000000000000:table/orders",
                "arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
            ]
        },
        {
            "Sid": "CmkForDdbSse",
            "Effect": "Allow",
            "Action": [
                "kms:Encrypt",
                "kms:Decrypt",
                "kms:GenerateDataKey",
                "kms:DescribeKey"
            ],
            "Resource": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5",
            "Condition": {
                "StringEquals": {
                    "kms:ViaService": "dynamodb.us-east-1.amazonaws.com"
                }
            }
        }
    ]
}
--- outbox-relay inline policy ---
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Logs",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay",
                "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay:*"
            ]
        },
        {
            "Sid": "OutboxStreamRead",
            "Effect": "Allow",
            "Action": [
                "dynamodb:DescribeStream",
                "dynamodb:GetRecords",
                "dynamodb:GetShardIterator",
                "dynamodb:ListStreams"
            ],
            "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T15:10:08.104"
        },
        {
            "Sid": "DedupTable",
            "Effect": "Allow",
            "Action": [
                "dynamodb:GetItem",
                "dynamodb:PutItem"
            ],
            "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/processed_events"
        },
        {
            "Sid": "PublishToBus",
            "Effect": "Allow",
            "Action": "events:PutEvents",
            "Resource": "arn:aws:events:us-east-1:000000000000:event-bus/order-events"
        },
        {
            "Sid": "CmkForDdbAndEventsSse",
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt",
                "kms:GenerateDataKey",
                "kms:DescribeKey"
            ],
            "Resource": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5",
            "Condition": {
                "StringEquals": {
                    "kms:ViaService": [
                        "dynamodb.us-east-1.amazonaws.com",
                        "events.us-east-1.amazonaws.com"
                    ]
                }
            }
        }
    ]
}

[stdout]
arn:aws:iam::000000000000:role/order-api-role
arn:aws:iam::000000000000:role/outbox-relay-role
--- order-api inline policy ---
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Logs",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api",
                "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api:*"
            ]
        },
        {
            "Sid": "OrdersOutboxTransact",
            "Effect": "Allow",
            "Action": [
                "dynamodb:PutItem",
                "dynamodb:UpdateItem",
                "dynamodb:TransactWriteItems"
            ],
            "Resource": [
                "arn:aws:dynamodb:us-east-1:000000000000:table/orders",
                "arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
            ]
        },
        {
            "Sid": "CmkForDdbSse",
            "Effect": "Allow",
            "Action": [
                "kms:Encrypt",
                "kms:Decrypt",
                "kms:GenerateDataKey",
                "kms:DescribeKey"
            ],
            "Resource": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5",
            "Condition": {
                "StringEquals": {
                    "kms:ViaService": "dynamodb.us-east-1.amazonaws.com"
                }
            }
        }
    ]
}
--- outbox-relay inline policy ---
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Logs",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay",
                "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay:*"
            ]
        },
        {
            "Sid": "OutboxStreamRead",
            "Effect": "Allow",
            "Action": [
                "dynamodb:DescribeStream",
                "dynamodb:GetRecords",
                "dynamodb:GetShardIterator",
                "dynamodb:ListStreams"
            ],
            "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T15:10:08.104"
        },
        {
            "Sid": "DedupTable",
            "Effect": "Allow",
            "Action": [
                "dynamodb:GetItem",
                "dynamodb:PutItem"
            ],
            "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/processed_events"
        },
        {
            "Sid": "PublishToBus",
            "Effect": "Allow",
            "Action": "events:PutEvents",
            "Resource": "arn:aws:events:us-east-1:000000000000:event-bus/order-events"
        },
        {
            "Sid": "CmkForDdbAndEventsSse",
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt",
                "kms:GenerateDataKey",
                "kms:DescribeKey"
            ],
            "Resource": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5",
            "Condition": {
                "StringEquals": {
                    "kms:ViaService": [
                        "dynamodb.us-east-1.amazonaws.com",
                        "events.us-east-1.amazonaws.com"
                    ]
                }
            }
        }
    ]
}
  • Verify environment and tools available
  • Create KMS CMK with alias outbox-cmk
  • Create DynamoDB tables (orders, outbox with stream, processed_events with TTL)
  • Create SQS queues (orders-created, orders-cancelled) with DLQs
  • Create EventBridge bus, rules, and SQS targets
  • Create least-privilege IAM roles for both lambdas
  • Write and deploy order-api lambda (TransactWriteItems)
  • Write and deploy outbox-relay lambda (stream + dedup + EventBridge)
  • Wire DynamoDB stream to relay with ReportBatchItemFailures
  • End-to-end test: OrderCreated and OrderCancelled flows
write · /app/lambdas/order-api/handler.py
import json
import os
import uuid
from datetime import datetime, timezone

import boto3
from botocore.exceptions import ClientError

ORDERS_TABLE = os.environ["ORDERS_TABLE"]
OUTBOX_TABLE = os.environ["OUTBOX_TABLE"]
VALID_KINDS = {"OrderCreated", "OrderCancelled"}

_ddb = boto3.client("dynamodb")


def _to_attr(value):
    if value is None:
        return {"NULL": True}
    if isinstance(value, bool):
        return {"BOOL": value}
    if isinstance(value, (int, float)):
        return {"N": str(value)}
    if isinstance(value, str):
        return {"S": value}
    if isinstance(value, list):
        return {"L": [_to_attr(v) for v in value]}
    if isinstance(value, dict):
        return {"M": {k: _to_attr(v) for k, v in value.items()}}
    return {"S": str(value)}


def handler(event, context):
    if not isinstance(event, dict):
        return {"statusCode": 400, "body": "event must be an object"}

    order_id = event.get("order_id")
    kind = event.get("kind")
    if not order_id or not isinstance(order_id, str):
        return {"statusCode": 400, "body": "order_id required"}
    if kind not in VALID_KINDS:
        return {"statusCode": 400, "body": f"kind must be one of {sorted(VALID_KINDS)}"}

    event_id = str(uuid.uuid4())
    now = datetime.now(timezone.utc).isoformat()

    payload = {k: v for k, v in event.items() if k not in ("order_id", "kind")}

    order_item = {
        "order_id": {"S": order_id},
        "kind": {"S": kind},
        "updated_at": {"S": now},
    }
    for k, v in payload.items():
        order_item[k] = _to_attr(v)

    outbox_item = {
        "event_id": {"S": event_id},
        "order_id": {"S": order_id},
        "kind": {"S": kind},
        "created_at": {"S": now},
        "payload": _to_attr({"order_id": order_id, "kind": kind, **payload}),
    }

    try:
        _ddb.transact_write_items(
            TransactItems=[
                {"Put": {"TableName": ORDERS_TABLE, "Item": order_item}},
                {"Put": {"TableName": OUTBOX_TABLE, "Item": outbox_item}},
            ]
        )
    except ClientError as e:
        return {
            "statusCode": 500,
            "body": json.dumps({"error": e.response["Error"]["Code"], "message": str(e)}),
        }

    return {
        "statusCode": 200,
        "body": json.dumps({"order_id": order_id, "event_id": event_id, "kind": kind}),
    }
write · /app/lambdas/outbox-relay/handler.py
import json
import logging
import os
import time

import boto3
from botocore.exceptions import ClientError

logger = logging.getLogger()
logger.setLevel(logging.INFO)

DEDUP_TABLE = os.environ["DEDUP_TABLE"]
EVENT_BUS = os.environ["EVENT_BUS"]
DEDUP_TTL_SECONDS = int(os.environ.get("DEDUP_TTL_SECONDS", "86400"))
EVENT_SOURCE = "outbox.relay"

_ddb = boto3.client("dynamodb")
_events = boto3.client("events")


def _from_attr(attr):
    if "S" in attr:
        return attr["S"]
    if "N" in attr:
        n = attr["N"]
        return int(n) if "." not in n else float(n)
    if "BOOL" in attr:
        return attr["BOOL"]
    if "NULL" in attr:
        return None
    if "L" in attr:
        return [_from_attr(x) for x in attr["L"]]
    if "M" in attr:
        return {k: _from_attr(v) for k, v in attr["M"].items()}
    if "SS" in attr:
        return list(attr["SS"])
    if "NS" in attr:
        return [int(x) if "." not in x else float(x) for x in attr["NS"]]
    return None


def _image_to_dict(image):
    return {k: _from_attr(v) for k, v in image.items()}


def _already_processed(event_id):
    resp = _ddb.get_item(
        TableName=DEDUP_TABLE,
        Key={"event_id": {"S": event_id}},
        ConsistentRead=True,
    )
    return "Item" in resp


def _mark_processed(event_id):
    ttl = int(time.time()) + DEDUP_TTL_SECONDS
    _ddb.put_item(
        TableName=DEDUP_TABLE,
        Item={
            "event_id": {"S": event_id},
            "ttl": {"N": str(ttl)},
            "processed_at": {"N": str(int(time.time()))},
        },
    )


def handler(event, context):
    failures = []
    records = event.get("Records", [])

    for record in records:
        seq = record.get("dynamodb", {}).get("SequenceNumber") or record.get("eventID")
        try:
            ddb = record.get("dynamodb", {})
            new_image = ddb.get("NewImage")
            if not new_image:
                logger.info("skipping record with no NewImage (event=%s)", record.get("eventName"))
                continue

            row = _image_to_dict(new_image)
            event_id = row.get("event_id")
            kind = row.get("kind")

            if not event_id or not kind:
                logger.warning("record missing event_id/kind: %s", row)
                continue

            if _already_processed(event_id):
                logger.info("dedup hit for event_id=%s; skipping", event_id)
                continue

            detail = {
                "event_id": event_id,
                "order_id": row.get("order_id"),
                "kind": kind,
                "created_at": row.get("created_at"),
                "payload": row.get("payload"),
            }

            resp = _events.put_events(
                Entries=[
                    {
                        "EventBusName": EVENT_BUS,
                        "Source": EVENT_SOURCE,
                        "DetailType": kind,
                        "Detail": json.dumps(detail, default=str),
                    }
                ]
            )
            failed = resp.get("FailedEntryCount", 0)
            if failed:
                logger.error("PutEvents reported failures: %s", resp.get("Entries"))
                failures.append({"itemIdentifier": seq})
                continue

            _mark_processed(event_id)
            logger.info("relayed event_id=%s kind=%s", event_id, kind)

        except ClientError as e:
            logger.exception("ClientError handling record seq=%s: %s", seq, e)
            failures.append({"itemIdentifier": seq})
        except Exception as e:
            logger.exception("Unhandled error for record seq=%s: %s", seq, e)
            failures.append({"itemIdentifier": seq})

    return {"batchItemFailures": failures}

Package lambdas as zip files

shell
cd /app/lambdas/order-api && zip -q -r /app/build/order-api.zip handler.py
cd /app/lambdas/outbox-relay && zip -q -r /app/build/outbox-relay.zip handler.py
ls -la /app/build/*.zip
output
-rw-r--r-- 1 root root 1000 Apr 28 15:13 /app/build/order-api.zip
-rw-r--r-- 1 root root 1385 Apr 28 15:13 /app/build/outbox-relay.zip

[stdout]
-rw-r--r-- 1 root root 1000 Apr 28 15:13 /app/build/order-api.zip
-rw-r--r-- 1 root root 1385 Apr 28 15:13 /app/build/outbox-relay.zip

Deploy both lambdas and wait for Active

shell
ORDER_API_ROLE=$(cat /app/build/order-api-role.arn)
RELAY_ROLE=$(cat /app/build/outbox-relay-role.arn)

# create order-api lambda
aws lambda create-function \
  --function-name order-api \
  --runtime python3.11 \
  --handler handler.handler \
  --role "$ORDER_API_ROLE" \
  --zip-file fileb:///app/build/order-api.zip \
  --timeout 15 \
  --environment "Variables={ORDERS_TABLE=orders,OUTBOX_TABLE=outbox,AWS_ENDPOINT_URL=http://localstack:4566}" \
  --query 'FunctionArn' --output text

# create outbox-relay lambda
aws lambda create-function \
  --function-name outbox-relay \
  --runtime python3.11 \
  --handler handler.handler \
  --role "$RELAY_ROLE" \
  --zip-file fileb:///app/build/outbox-relay.zip \
  --timeout 30 \
  --environment "Variables={DEDUP_TABLE=processed_events,EVENT_BUS=order-events,AWS_ENDPOINT_URL=http://localstack:4566}" \
  --query 'FunctionArn' --output text

echo "--- waiting for both to be Active ---"
aws lambda wait function-active --function-name order-api
aws lambda wait function-active --function-name outbox-relay
aws lambda get-function --function-name order-api --query 'Configuration.[State,LastUpdateStatus]' --output text
aws lambda get-function --function-name outbox-relay --query 'Configuration.[State,LastUpdateStatus]' --output text
output
arn:aws:lambda:us-east-1:000000000000:function:order-api
arn:aws:lambda:us-east-1:000000000000:function:outbox-relay
--- waiting for both to be Active ---
Active	Successful
Active	Successful

[stdout]
arn:aws:lambda:us-east-1:000000000000:function:order-api
arn:aws:lambda:us-east-1:000000000000:function:outbox-relay
--- waiting for both to be Active ---
Active	Successful
Active	Successful
  • Verify environment and tools available
  • Create KMS CMK with alias outbox-cmk
  • Create DynamoDB tables (orders, outbox with stream, processed_events with TTL)
  • Create SQS queues (orders-created, orders-cancelled) with DLQs
  • Create EventBridge bus, rules, and SQS targets
  • Create least-privilege IAM roles for both lambdas
  • Write and deploy order-api lambda (TransactWriteItems)
  • Write and deploy outbox-relay lambda (stream + dedup + EventBridge)
  • Wire DynamoDB stream to relay with ReportBatchItemFailures
  • End-to-end test: OrderCreated and OrderCancelled flows

Wire stream to relay with ReportBatchItemFailures

shell
STREAM_ARN=$(cat /app/build/outbox_stream_arn.txt)

aws lambda create-event-source-mapping \
  --function-name outbox-relay \
  --event-source-arn "$STREAM_ARN" \
  --starting-position TRIM_HORIZON \
  --batch-size 10 \
  --maximum-batching-window-in-seconds 1 \
  --function-response-types ReportBatchItemFailures \
  --query '{UUID:UUID,State:State,FunctionResponseTypes:FunctionResponseTypes}' --output json

aws lambda list-event-source-mappings --function-name outbox-relay --query 'EventSourceMappings[].{State:State,FunctionResponseTypes:FunctionResponseTypes,EventSourceArn:EventSourceArn}' --output json
output
{
    "UUID": "d49652bd-49cd-4da3-8d1e-85227868cdb3",
    "State": "Creating",
    "FunctionResponseTypes": [
        "ReportBatchItemFailures"
    ]
}
[
    {
        "State": "Enabled",
        "FunctionResponseTypes": [
            "ReportBatchItemFailures"
        ],
        "EventSourceArn": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T15:10:08.104"
    }
]

[stdout]
{
    "UUID": "d49652bd-49cd-4da3-8d1e-85227868cdb3",
    "State": "Creating",
    "FunctionResponseTypes": [
        "ReportBatchItemFailures"
    ]
}
[
    {
        "State": "Enabled",
        "FunctionResponseTypes": [
            "ReportBatchItemFailures"
        ],
        "EventSourceArn": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T15:10:08.104"
    }
]
  • Verify environment and tools available
  • Create KMS CMK with alias outbox-cmk
  • Create DynamoDB tables (orders, outbox with stream, processed_events with TTL)
  • Create SQS queues (orders-created, orders-cancelled) with DLQs
  • Create EventBridge bus, rules, and SQS targets
  • Create least-privilege IAM roles for both lambdas
  • Write and deploy order-api lambda (TransactWriteItems)
  • Write and deploy outbox-relay lambda (stream + dedup + EventBridge)
  • Wire DynamoDB stream to relay with ReportBatchItemFailures
  • End-to-end test: OrderCreated and OrderCancelled flows
End-to-end test. Invoke `order-api` for `OrderCreated`, then `OrderCancelled`, then check the queues and dedup table.

Invoke order-api with OrderCreated

shell
source /app/build/queues.env

# invoke OrderCreated
PAYLOAD1=$(jq -c -n '{order_id:"o-1", kind:"OrderCreated", customer_id:"c-42", total_cents:1999, currency:"USD"}')
aws lambda invoke \
  --function-name order-api \
  --cli-binary-format raw-in-base64-out \
  --payload "$PAYLOAD1" \
  /tmp/resp1.json --query 'StatusCode' --output text
cat /tmp/resp1.json
echo
output
200
{"statusCode": 200, "body": "{\"order_id\": \"o-1\", \"event_id\": \"4f74c998-6158-4393-a5b1-05664d6ad1d8\", \"kind\": \"OrderCreated\"}"}

[stdout]
200
{"statusCode": 200, "body": "{\"order_id\": \"o-1\", \"event_id\": \"4f74c998-6158-4393-a5b1-05664d6ad1d8\", \"kind\": \"OrderCreated\"}"}

Verify OrderCreated flow

shell
source /app/build/queues.env

# wait for stream → relay → eventbridge → sqs propagation
sleep 8

echo "--- orders-created ApproxNumberOfMessages ---"
aws sqs get-queue-attributes --queue-url "$CREATED_URL" \
  --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \
  --query 'Attributes' --output json

echo "--- orders-cancelled ApproxNumberOfMessages ---"
aws sqs get-queue-attributes --queue-url "$CANCELLED_URL" \
  --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \
  --query 'Attributes' --output json

echo "--- receive from orders-created ---"
aws sqs receive-message --queue-url "$CREATED_URL" --max-number-of-messages 10 --wait-time-seconds 2 --query 'Messages[].Body' --output json | jq '.[] | fromjson'

echo "--- processed_events scan ---"
aws dynamodb scan --table-name processed_events --query 'Items' --output json
output
--- orders-created ApproxNumberOfMessages ---
{
    "ApproximateNumberOfMessages": "1",
    "ApproximateNumberOfMessagesNotVisible": "0"
}
--- orders-cancelled ApproxNumberOfMessages ---
{
    "ApproximateNumberOfMessages": "0",
    "ApproximateNumberOfMessagesNotVisible": "0"
}
--- receive from orders-created ---

aws: [ERROR]: An error occurred (InternalError) when calling the ReceiveMessage operation (reached max retries: 2): exception while calling sqs.ReceiveMessage: string indices must be integers, not 'str'
--- processed_events scan ---
[
    {
        "ttl": {
            "N": "1777475668"
        },
        "processed_at": {
            "N": "1777389268"
        },
        "event_id": {
            "S": "4f74c998-6158-4393-a5b1-05664d6ad1d8"
        }
    }
]

[stdout]
--- orders-created ApproxNumberOfMessages ---
{
    "ApproximateNumberOfMessages": "1",
    "ApproximateNumberOfMessagesNotVisible": "0"
}
--- orders-cancelled ApproxNumberOfMessages ---
{
    "ApproximateNumberOfMessages": "0",
    "ApproximateNumberOfMessagesNotVisible": "0"
}
--- receive from orders-created ---

aws: [ERROR]: An error occurred (InternalError) when calling the ReceiveMessage operation (reached max retries: 2): exception while calling sqs.ReceiveMessage: string indices must be integers, not 'str'
--- processed_events scan ---
[
    {
        "ttl": {
            "N": "1777475668"
        },
        "processed_at": {
            "N": "1777389268"
        },
        "event_id": {
            "S": "4f74c998-6158-4393-a5b1-05664d6ad1d8"
        }
    }
]
Queue counts and dedup table look correct. The ReceiveMessage error is a LocalStack response-serialization quirk , let me retry without the query filter to see the raw payload.

Receive message and inspect body

shell
source /app/build/queues.env

# look at one message body without --query to avoid the cli serialization issue
aws sqs receive-message --queue-url "$CREATED_URL" --max-number-of-messages 1 --visibility-timeout 0 > /tmp/msg.json 2>&1 || true
cat /tmp/msg.json
echo "---"
# extract body content if present
jq '.Messages[0].Body | fromjson' /tmp/msg.json 2>&1 || true
output
aws: [ERROR]: An error occurred (InternalError) when calling the ReceiveMessage operation (reached max retries: 2): exception while calling sqs.ReceiveMessage: string indices must be integers, not 'str'
---
jq: parse error: Invalid numeric literal at line 2, column 4

[stdout]
aws: [ERROR]: An error occurred (InternalError) when calling the ReceiveMessage operation (reached max retries: 2): exception while calling sqs.ReceiveMessage: string indices must be integers, not 'str'
---
jq: parse error: Invalid numeric literal at line 2, column 4

Receive message via boto3 to inspect body

shell
source /app/build/queues.env
python3 - <<EOF
import boto3, json, os
os.environ.setdefault("AWS_ENDPOINT_URL", "http://localstack:4566")
sqs = boto3.client("sqs")
url = "$CREATED_URL"
resp = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10, WaitTimeSeconds=1, VisibilityTimeout=0)
msgs = resp.get("Messages", [])
print("count:", len(msgs))
for m in msgs:
    body = json.loads(m["Body"])
    print("--- body ---")
    print(json.dumps(body, indent=2))
EOF
output
Exit code 1
Traceback (most recent call last):
  File "<stdin>", line 5, in <module>
  File "/opt/venv/lib/python3.12/site-packages/botocore/client.py", line 569, in _api_call
    return self._make_api_call(operation_name, kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/venv/lib/python3.12/site-packages/botocore/client.py", line 1023, in _make_api_call
    raise error_class(parsed_response, operation_name)
botocore.exceptions.ClientError: An error occurred (InternalError) when calling the ReceiveMessage operation (reached max retries: 4): exception while calling sqs.ReceiveMessage: string indices must be integers, not 'str'

[error] tool reported failure

Inspect queue attributes and test DLQ receive

shell
source /app/build/queues.env

# check queue attrs 
echo "--- main queue attrs ---"
aws sqs get-queue-attributes --queue-url "$CREATED_URL" --attribute-names All

echo "--- try receive on the DLQ (no Policy/Redrive set) ---"
aws sqs receive-message --queue-url "$DLQ_CREATED_URL" --max-number-of-messages 1 --wait-time-seconds 1 || echo "(empty)"
output
--- main queue attrs ---
{
    "Attributes": {
        "CreatedTimestamp": "1777389043",
        "DelaySeconds": "0",
        "LastModifiedTimestamp": "1777389043",
        "MaximumMessageSize": "1048576",
        "MessageRetentionPeriod": "345600",
        "QueueArn": "arn:aws:sqs:us-east-1:000000000000:orders-created",
        "ReceiveMessageWaitTimeSeconds": "0",
        "VisibilityTimeout": "30",
        "SqsManagedSseEnabled": "true",
        "KmsMasterKeyId": "a378d809-0047-4032-a731-a97d25d2ceb5",
        "RedrivePolicy": "\"{\\\"deadLetterTargetArn\\\":\\\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"",
        "Policy": "{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Sid\": \"AllowEventBridgeRule\",\n      \"Effect\": \"Allow\",\n      \"Principal\": {\n        \"Service\": \"events.amazonaws.com\"\n      },\n      \"Action\": \"sqs:SendMessage\",\n      \"Resource\": \"arn:aws:sqs:us-east-1:000000000000:orders-created\",\n      \"Condition\": {\n        \"ArnEquals\": {\n          \"aws:SourceArn\": \"arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created\"\n        }\n      }\n    }\n  ]\n}\n",
        "ApproximateNumberOfMessages": "1",
        "ApproximateNumberOfMessagesDelayed": "0",
        "ApproximateNumberOfMessagesNotVisible": "0"
    }
}
--- try receive on the DLQ (no Policy/Redrive set) ---

[stdout]
--- main queue attrs ---
{
    "Attributes": {
        "CreatedTimestamp": "1777389043",
        "DelaySeconds": "0",
        "LastModifiedTimestamp": "1777389043",
        "MaximumMessageSize": "1048576",
        "MessageRetentionPeriod": "345600",
        "QueueArn": "arn:aws:sqs:us-east-1:000000000000:orders-created",
        "ReceiveMessageWaitTimeSeconds": "0",
        "VisibilityTimeout": "30",
        "SqsManagedSseEnabled": "true",
        "KmsMasterKeyId": "a378d809-0047-4032-a731-a97d25d2ceb5",
        "RedrivePolicy": "\"{\\\"deadLetterTargetArn\\\":\\\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"",
        "Policy": "{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Sid\": \"AllowEventBridgeRule\",\n      \"Effect\": \"Allow\",\n      \"Principal\": {\n        \"Service\": \"events.amazonaws.com\"\n      },\n      \"Action\": \"sqs:SendMessage\",\n      \"Resource\": \"arn:aws:sqs:us-east-1:000000000000:orders-created\",\n      \"Condition\": {\n        \"ArnEquals\": {\n          \"aws:SourceArn\": \"arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created\"\n        }\n      }\n    }\n  ]\n}\n",
        "ApproximateNumberOfMessages": "1",
        "ApproximateNumberOfMessagesDelayed": "0",
        "ApproximateNumberOfMessagesNotVisible": "0"
    }
}
--- try receive on the DLQ (no Policy/Redrive set) ---
The `RedrivePolicy` is double-encoded , `jq | tostring` produced a JSON-string-of-a-JSON-string. Let me fix by re-setting it cleanly via file input.

Fix RedrivePolicy double-encoding

shell
source /app/build/queues.env

# write redrive policy as a string value within an attributes file
jq -n --arg arn "$DLQ_CREATED_ARN" '{RedrivePolicy: ({deadLetterTargetArn:$arn, maxReceiveCount:"3"} | tostring)}' > /tmp/created_redrive.json
jq -n --arg arn "$DLQ_CANCELLED_ARN" '{RedrivePolicy: ({deadLetterTargetArn:$arn, maxReceiveCount:"3"} | tostring)}' > /tmp/cancelled_redrive.json

cat /tmp/created_redrive.json
echo "---"

aws sqs set-queue-attributes --queue-url "$CREATED_URL" --attributes file:///tmp/created_redrive.json
aws sqs set-queue-attributes --queue-url "$CANCELLED_URL" --attributes file:///tmp/cancelled_redrive.json

echo "--- post-fix attrs ---"
aws sqs get-queue-attributes --queue-url "$CREATED_URL" --attribute-names RedrivePolicy
output
{
  "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\",\"maxReceiveCount\":\"3\"}"
}
---
--- post-fix attrs ---
{
    "Attributes": {
        "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\",\"maxReceiveCount\":\"3\"}"
    }
}

[stdout]
{
  "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\",\"maxReceiveCount\":\"3\"}"
}
---
--- post-fix attrs ---
{
    "Attributes": {
        "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\",\"maxReceiveCount\":\"3\"}"
    }
}

Retry ReceiveMessage after RedrivePolicy fix

shell
source /app/build/queues.env
aws sqs receive-message --queue-url "$CREATED_URL" --max-number-of-messages 5 --visibility-timeout 0 --wait-time-seconds 2
output
{
    "Messages": [
        {
            "MessageId": "2fc7d9db-60ff-4ea9-9e0a-f9d5ef359f17",
            "ReceiptHandle": "NGM0Njc0NjMtMDJiNi00YzhhLThmNWQtNTQ2NmE3MWUwODhmIGFybjphd3M6c3FzOnVzLWVhc3QtMTowMDAwMDAwMDAwMDA6b3JkZXJzLWNyZWF0ZWQgMmZjN2Q5ZGItNjBmZi00ZWE5LTllMGEtZjlkNWVmMzU5ZjE3IDE3NzczODkzNTYuMjE0NTY3NA==",
            "MD5OfBody": "b698a0b3f56c16a49a96372d3536ebc9",
            "Body": "{\"version\":\"0\",\"id\":\"6ba4c9d5-d08a-423b-8d4f-67b82241d2ab\",\"detail-type\":\"OrderCreated\",\"source\":\"outbox.relay\",\"account\":\"000000000000\",\"time\":\"2026-04-28T15:14:28Z\",\"region\":\"us-east-1\",\"resources\":[],\"detail\":{\"event_id\":\"4f74c998-6158-4393-a5b1-05664d6ad1d8\",\"order_id\":\"o-1\",\"kind\":\"OrderCreated\",\"created_at\":\"2026-04-28T15:14:26.861028+00:00\",\"payload\":{\"order_id\":\"o-1\",\"kind\":\"OrderCreated\",\"customer_id\":\"c-42\",\"total_cents\":1999,\"currency\":\"USD\"}}}"
        }
    ]
}

[stdout]
{
    "Messages": [
        {
            "MessageId": "2fc7d9db-60ff-4ea9-9e0a-f9d5ef359f17",
            "ReceiptHandle": "NGM0Njc0NjMtMDJiNi00YzhhLThmNWQtNTQ2NmE3MWUwODhmIGFybjphd3M6c3FzOnVzLWVhc3QtMTowMDAwMDAwMDAwMDA6b3JkZXJzLWNyZWF0ZWQgMmZjN2Q5ZGItNjBmZi00ZWE5LTllMGEtZjlkNWVmMzU5ZjE3IDE3NzczODkzNTYuMjE0NTY3NA==",
            "MD5OfBody": "b698a0b3f56c16a49a96372d3536ebc9",
            "Body": "{\"version\":\"0\",\"id\":\"6ba4c9d5-d08a-423b-8d4f-67b82241d2ab\",\"detail-type\":\"OrderCreated\",\"source\":\"outbox.relay\",\"account\":\"000000000000\",\"time\":\"2026-04-28T15:14:28Z\",\"region\":\"us-east-1\",\"resources\":[],\"detail\":{\"event_id\":\"4f74c998-6158-4393-a5b1-05664d6ad1d8\",\"order_id\":\"o-1\",\"kind\":\"OrderCreated\",\"created_at\":\"2026-04-28T15:14:26.861028+00:00\",\"payload\":{\"order_id\":\"o-1\",\"kind\":\"OrderCreated\",\"customer_id\":\"c-42\",\"total_cents\":1999,\"currency\":\"USD\"}}}"
        }
    ]
}
ReceiveMessage works now. Body has the EventBridge envelope with `source: outbox.relay`, `detail-type: OrderCreated`, full detail. Now testing OrderCancelled.

Invoke OrderCancelled and verify split routing

shell
source /app/build/queues.env

PAYLOAD2=$(jq -c -n '{order_id:"o-2", kind:"OrderCancelled", customer_id:"c-42", reason:"changed_mind"}')
aws lambda invoke \
  --function-name order-api \
  --cli-binary-format raw-in-base64-out \
  --payload "$PAYLOAD2" \
  /tmp/resp2.json --query 'StatusCode' --output text
cat /tmp/resp2.json
echo

sleep 8

echo "--- queue counts ---"
aws sqs get-queue-attributes --queue-url "$CREATED_URL" \
  --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \
  --query 'Attributes' --output json
aws sqs get-queue-attributes --queue-url "$CANCELLED_URL" \
  --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \
  --query 'Attributes' --output json

echo "--- orders-cancelled message ---"
aws sqs receive-message --queue-url "$CANCELLED_URL" --max-number-of-messages 5 --visibility-timeout 0 --wait-time-seconds 2 --query 'Messages[].Body' --output json | jq '.[] | fromjson'

echo "--- processed_events ---"
aws dynamodb scan --table-name processed_events --query 'Items[].event_id.S' --output json

echo "--- outbox rows ---"
aws dynamodb scan --table-name outbox --query 'Items[].{event_id: event_id.S, kind: kind.S, order_id: order_id.S}' --output json

echo "--- orders rows ---"
aws dynamodb scan --table-name orders --query 'Items[].{order_id: order_id.S, kind: kind.S}' --output json
output
200
{"statusCode": 200, "body": "{\"order_id\": \"o-2\", \"event_id\": \"f43d0333-be8f-4ce2-a51a-ebf8bb386388\", \"kind\": \"OrderCancelled\"}"}
--- queue counts ---
{
    "ApproximateNumberOfMessages": "1",
    "ApproximateNumberOfMessagesNotVisible": "0"
}
{
    "ApproximateNumberOfMessages": "1",
    "ApproximateNumberOfMessagesNotVisible": "0"
}
--- orders-cancelled message ---
{
  "version": "0",
  "id": "f3fc2426-edb1-4d3f-bdcb-301b270f2d1b",
  "detail-type": "OrderCancelled",
  "source": "outbox.relay",
  "account": "000000000000",
  "time": "2026-04-28T15:16:14Z",
  "region": "us-east-1",
  "resources": [],
  "detail": {
    "event_id": "f43d0333-be8f-4ce2-a51a-ebf8bb386388",
    "order_id": "o-2",
    "kind": "OrderCancelled",
    "created_at": "2026-04-28T15:16:12.233895+00:00",
    "payload": {
      "order_id": "o-2",
      "kind": "OrderCancelled",
      "customer_id": "c-42",
      "reason": "changed_mind"
    }
  }
}
--- processed_events ---
[
    "4f74c998-6158-4393-a5b1-05664d6ad1d8",
    "f43d0333-be8f-4ce2-a51a-ebf8bb386388"
]
--- outbox rows ---
[
    {
        "event_id": "4f74c998-6158-4393-a5b1-05664d6ad1d8",
        "kind": "OrderCreated",
        "order_id": "o-1"
    },
    {
        "event_id": "f43d0333-be8f-4ce2-a51a-ebf8bb386388",
        "kind": "OrderCancelled",
        "order_id": "o-2"
    }
]
--- orders rows ---
[
    {
        "order_id": "o-1",
        "kind": "OrderCreated"
    },
    {
        "order_id": "o-2",
        "kind": "OrderCancelled"
    }
]

[stdout]
200
{"statusCode": 200, "body": "{\"order_id\": \"o-2\", \"event_id\": \"f43d0333-be8f-4ce2-a51a-ebf8bb386388\", \"kind\": \"OrderCancelled\"}"}
--- queue counts ---
{
    "ApproximateNumberOfMessages": "1",
    "ApproximateNumberOfMessagesNotVisible": "0"
}
{
    "ApproximateNumberOfMessages": "1",
    "ApproximateNumberOfMessagesNotVisible": "0"
}
--- orders-cancelled message ---
{
  "version": "0",
  "id": "f3fc2426-edb1-4d3f-bdcb-301b270f2d1b",
  "detail-type": "OrderCancelled",
  "source": "outbox.relay",
  "account": "000000000000",
  "time": "2026-04-28T15:16:14Z",
  "region": "us-east-1",
  "resources": [],
  "detail": {
    "event_id": "f43d0333-be8f-4ce2-a51a-ebf8bb386388",
    "order_id": "o-2",
    "kind": "OrderCancelled",
    "created_at": "2026-04-28T15:16:12.233895+00:00",
    "payload": {
      "order_id": "o-2",
      "kind": "OrderCancelled",
      "customer_id": "c-42",
      "reason": "changed_mind"
    }
  }
}
--- processed_events ---
[
    "4f74c998-6158-4393-a5b1-05664d6ad1d8",
    "f43d0333-be8f-4ce2-a51a-ebf8bb386388"
]
--- outbox rows ---
[
    {
        "event_id": "4f74c998-6158-4393-a5b1-05664d6ad1d8",
        "kind": "OrderCreated",
        "order_id": "o-1"
    },
    {
        "event_id": "f43d0333-be8f-4ce2-a51a-ebf8bb386388",
        "kind": "OrderCancelled",
        "order_id": "o-2"
    }
]
--- orders rows ---
[
    {
        "order_id": "o-1",
        "kind": "OrderCreated"
    },
    {
        "order_id": "o-2",
        "kind": "OrderCancelled"
    }
]
  • Verify environment and tools available
  • Create KMS CMK with alias outbox-cmk
  • Create DynamoDB tables (orders, outbox with stream, processed_events with TTL)
  • Create SQS queues (orders-created, orders-cancelled) with DLQs
  • Create EventBridge bus, rules, and SQS targets
  • Create least-privilege IAM roles for both lambdas
  • Write and deploy order-api lambda (TransactWriteItems)
  • Write and deploy outbox-relay lambda (stream + dedup + EventBridge)
  • Wire DynamoDB stream to relay with ReportBatchItemFailures
  • End-to-end test: OrderCreated and OrderCancelled flows
Built and live. End-to-end works: **Two invokes, two routes** - `order-api {order_id: o-1, kind: OrderCreated}` → `orders-created` has 1 msg, `orders-cancelled` 0 - `order-api {order_id: o-2, kind: OrderCancelled}` → `orders-cancelled` 1 msg, `orders-created` still 1 - `processed_events` has both event_ids, `outbox` and `orders` each have both rows - EventBridge envelope confirmed: `source: outbox.relay`, `detail-type` matches `kind` exactly **How it's wired** - `order-api` writes orders + outbox in one `TransactWriteItems`. UUID4 `event_id` lives in the outbox row, so the same id flows to the stream. - `outbox` has a `NEW_AND_OLD_IMAGES` stream. Event source mapping → `outbox-relay` with `FunctionResponseTypes: [ReportBatchItemFailures]`. The handler returns `{"batchItemFailures": [{"itemIdentifier": <SequenceNumber>}, …]}` for any record it can't republish. - Relay deduplicates via `processed_events` (GetItem before publish, PutItem after), TTL field set to now+1 day so the table self-cleans. - Bus `order-events` has two rules matching `source: outbox.relay` + `detail-type: OrderCreated|OrderCancelled`. Queue policies on both SQS targets allow `events.amazonaws.com` only when `aws:SourceArn` equals the specific rule. - Both queues have their own DLQ (`orders-created-dlq`, `orders-cancelled-dlq`) via `RedrivePolicy maxReceiveCount=3`. No shared DLQ. - Everything KMS-encrypted with `alias/outbox-cmk` (DDB tables, SQS queues, EventBridge bus). No `aws/*` keys anywhere. **Least privilege (inline policies, readable via `iam get-role-policy`)** - `order-api-role`: `TransactWriteItems`/`PutItem`/`UpdateItem` on `orders` and `outbox` only; `kms:Encrypt/Decrypt/GenerateDataKey/DescribeKey` on the CMK gated by `kms:ViaService = dynamodb.us-east-1.amazonaws.com`; logs scoped to `/aws/lambda/order-api`. No SQS, no events, no streams, no `*` on KMS. - `outbox-relay-role`: stream read actions scoped to the outbox stream ARN exactly (cannot reach the orders table or any other stream); GetItem/PutItem on `processed_events` only; `events:PutEvents` on the `order-events` bus only; KMS decrypt/GenerateDataKey on the CMK gated by `ViaService` for dynamodb + events; logs scoped to `/aws/lambda/outbox-relay`. **One LocalStack quirk worth flagging**: ReceiveMessage 500'd on the main queues until I rewrote the `RedrivePolicy` value cleanly (initial `jq | tostring` had double-encoded the JSON string). That's fixed now , receives return messages correctly. Files: lambda sources at `/app/lambdas/{order-api,outbox-relay}/handler.py`, packaged zips at `/app/build/*.zip`, IAM policies at `/app/policies/` (templates) and `/app/build/*-policy.json` (rendered with the actual KMS/stream ARNs).
Final diff , what the agent changed

No file changes, the agent left the workspace untouched.

Verifier output , the proof behind the reward
tests/test.sh
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.1, pluggy-1.6.0 -- /root/.cache/uv/archive-v0/J_14WEX8_2SORwXI6D9ID/bin/python
cachedir: .pytest_cache
rootdir: /tests
plugins: json-ctrf-0.3.5
collecting ... collected 20 items

../tests/test_outputs.py::test_01_cmk_alias_exists_and_enabled PASSED    [  5%]
../tests/test_outputs.py::test_02_orders_table_exists PASSED             [ 10%]
../tests/test_outputs.py::test_03_outbox_table_exists PASSED             [ 15%]
../tests/test_outputs.py::test_04_processed_events_table_exists PASSED   [ 20%]
../tests/test_outputs.py::test_05_custom_event_bus_exists PASSED         [ 25%]
../tests/test_outputs.py::test_06_orders_table_encrypted_with_cmk PASSED [ 30%]
../tests/test_outputs.py::test_07_outbox_table_encrypted_with_cmk PASSED [ 35%]
../tests/test_outputs.py::test_08_outbox_stream_is_new_and_old_images PASSED [ 40%]
../tests/test_outputs.py::test_09_processed_events_ttl_enabled PASSED    [ 45%]
../tests/test_outputs.py::test_10_both_rule_patterns_use_array_wrap_and_disjoint_types PASSED [ 50%]
../tests/test_outputs.py::test_11_each_rule_targets_only_its_intended_queue PASSED [ 55%]
../tests/test_outputs.py::test_12_both_main_queues_encrypted_with_cmk PASSED [ 60%]
../tests/test_outputs.py::test_13_main_queues_have_redrive_to_distinct_dlqs PASSED [ 65%]
../tests/test_outputs.py::test_14_queue_policies_scope_events_with_source_arn PASSED [ 70%]
../tests/test_outputs.py::test_15_relay_esm_has_batch_item_failures_on_outbox_stream PASSED [ 75%]
../tests/test_outputs.py::test_16_relay_role_is_scoped_not_wildcard PASSED [ 80%]
../tests/test_outputs.py::test_17_order_api_role_scoped_to_orders_and_outbox_only PASSED [ 85%]
../tests/test_outputs.py::test_18_e2e_order_created_routes_to_created_queue_only PASSED [ 90%]
../tests/test_outputs.py::test_19_e2e_order_cancelled_routes_to_cancelled_queue_only PASSED [ 95%]
../tests/test_outputs.py::test_20_e2e_idempotent_duplicate_does_not_double_fanout PASSED [100%]

=============================== warnings summary ===============================
test_outputs.py: 90 warnings
  /root/.cache/uv/archive-v0/J_14WEX8_2SORwXI6D9ID/lib/python3.12/site-packages/botocore/auth.py:424: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
    datetime_now = datetime.datetime.utcnow()

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED ../tests/test_outputs.py::test_01_cmk_alias_exists_and_enabled
PASSED ../tests/test_outputs.py::test_02_orders_table_exists
PASSED ../tests/test_outputs.py::test_03_outbox_table_exists
PASSED ../tests/test_outputs.py::test_04_processed_events_table_exists
PASSED ../tests/test_outputs.py::test_05_custom_event_bus_exists
PASSED ../tests/test_outputs.py::test_06_orders_table_encrypted_with_cmk
PASSED ../tests/test_outputs.py::test_07_outbox_table_encrypted_with_cmk
PASSED ../tests/test_outputs.py::test_08_outbox_stream_is_new_and_old_images
PASSED ../tests/test_outputs.py::test_09_processed_events_ttl_enabled
PASSED ../tests/test_outputs.py::test_10_both_rule_patterns_use_array_wrap_and_disjoint_types
PASSED ../tests/test_outputs.py::test_11_each_rule_targets_only_its_intended_queue
PASSED ../tests/test_outputs.py::test_12_both_main_queues_encrypted_with_cmk
PASSED ../tests/test_outputs.py::test_13_main_queues_have_redrive_to_distinct_dlqs
PASSED ../tests/test_outputs.py::test_14_queue_policies_scope_events_with_source_arn
PASSED ../tests/test_outputs.py::test_15_relay_esm_has_batch_item_failures_on_outbox_stream
PASSED ../tests/test_outputs.py::test_16_relay_role_is_scoped_not_wildcard
PASSED ../tests/test_outputs.py::test_17_order_api_role_scoped_to_orders_and_outbox_only
PASSED ../tests/test_outputs.py::test_18_e2e_order_created_routes_to_created_queue_only
PASSED ../tests/test_outputs.py::test_19_e2e_order_cancelled_routes_to_cancelled_queue_only
PASSED ../tests/test_outputs.py::test_20_e2e_idempotent_duplicate_does_not_double_fanout
======================= 20 passed, 90 warnings in 23.09s =======================

Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_7c4b7ca31b2c4dea. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.

Trial trial_7c4b7ca31b2c4dea · verifier authoritative; classifier explanatory.