tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.Inspect working directory and tools
ls -la /app && echo "---" && which aws python3 jq zip && python3 --version && aws --version
total 8 drwxr-xr-x 2 root root 4096 Apr 21 20:05 . drwxr-xr-x 1 root root 4096 Apr 28 14:37 .. --- /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 [stdout] total 8 drwxr-xr-x 2 root root 4096 Apr 21 20:05 . drwxr-xr-x 1 root root 4096 Apr 28 14:37 .. --- /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
Check localstack availability
curl -s http://localstack:4566/_localstack/health | jq . 2>/dev/null | head -50
{
"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"
}{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
Create build dirs
mkdir -p /app/build /app/lambdas/order_api /app/lambdas/outbox_relay /app/iam && ls /app
build iam lambdas [stdout] build iam lambdas
Create env shim
cat > /app/env.sh <<'EOF' export AWS_ACCESS_KEY_ID=test export AWS_SECRET_ACCESS_KEY=test export AWS_DEFAULT_REGION=us-east-1 export AWS_REGION=us-east-1 export AWS_PAGER="" export EP=http://localstack:4566 alias awsl='aws --endpoint-url $EP' EOF cat /app/env.sh
export AWS_ACCESS_KEY_ID=test export AWS_SECRET_ACCESS_KEY=test export AWS_DEFAULT_REGION=us-east-1 export AWS_REGION=us-east-1 export AWS_PAGER="" export EP=http://localstack:4566 alias awsl='aws --endpoint-url $EP' [stdout] export AWS_ACCESS_KEY_ID=test export AWS_SECRET_ACCESS_KEY=test export AWS_DEFAULT_REGION=us-east-1 export AWS_REGION=us-east-1 export AWS_PAGER="" export EP=http://localstack:4566 alias awsl='aws --endpoint-url $EP'
Verify creds
source /app/env.sh && aws --endpoint-url $EP sts get-caller-identity
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
[stdout]
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EnableRoot",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:root"},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "AllowAwsServices",
"Effect": "Allow",
"Principal": {"Service": ["dynamodb.amazonaws.com", "sqs.amazonaws.com", "events.amazonaws.com", "lambda.amazonaws.com"]},
"Action": ["kms:Encrypt","kms:Decrypt","kms:ReEncrypt*","kms:GenerateDataKey*","kms:DescribeKey","kms:CreateGrant"],
"Resource": "*"
}
]
}
Create CMK + alias
source /app/env.sh KEY_ID=$(aws --endpoint-url $EP kms create-key \ --description "outbox CMK" \ --key-usage ENCRYPT_DECRYPT \ --key-spec SYMMETRIC_DEFAULT \ --policy file:///app/iam/kms-key-policy.json \ | jq -r .KeyMetadata.KeyId) echo "KEY_ID=$KEY_ID" aws --endpoint-url $EP kms create-alias --alias-name alias/outbox-cmk --target-key-id "$KEY_ID" KEY_ARN=$(aws --endpoint-url $EP kms describe-key --key-id alias/outbox-cmk | jq -r .KeyMetadata.Arn) echo "KEY_ARN=$KEY_ARN" echo "$KEY_ID" > /app/build/kms_key_id echo "$KEY_ARN" > /app/build/kms_key_arn
KEY_ID=5ccc6de5-d379-458b-801f-baaeb4c83013 KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/5ccc6de5-d379-458b-801f-baaeb4c83013 [stdout] KEY_ID=5ccc6de5-d379-458b-801f-baaeb4c83013 KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/5ccc6de5-d379-458b-801f-baaeb4c83013
Create DDB tables with CMK + stream + TTL
source /app/env.sh KEY_ARN=$(cat /app/build/kms_key_arn) # orders table aws --endpoint-url $EP 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" \ > /dev/null && echo "orders created" # outbox table with stream aws --endpoint-url $EP 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 \ --sse-specification "Enabled=true,SSEType=KMS,KMSMasterKeyId=$KEY_ARN" \ --stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES \ > /dev/null && echo "outbox created" # processed_events with TTL aws --endpoint-url $EP 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" \ > /dev/null && echo "processed_events created" # Wait for active state then enable TTL for t in orders outbox processed_events; do aws --endpoint-url $EP dynamodb wait table-exists --table-name $t done aws --endpoint-url $EP dynamodb update-time-to-live \ --table-name processed_events \ --time-to-live-specification "Enabled=true,AttributeName=ttl" \ > /dev/null && echo "TTL enabled" OUTBOX_STREAM=$(aws --endpoint-url $EP dynamodb describe-table --table-name outbox | jq -r .Table.LatestStreamArn) echo "OUTBOX_STREAM=$OUTBOX_STREAM" echo "$OUTBOX_STREAM" > /app/build/outbox_stream_arn aws --endpoint-url $EP dynamodb list-tables
orders created
outbox created
processed_events created
TTL enabled
OUTBOX_STREAM=arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:40:17.987
{
"TableNames": [
"orders",
"outbox",
"processed_events"
]
}
[stdout]
orders created
outbox created
processed_events created
TTL enabled
OUTBOX_STREAM=arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:40:17.987
{
"TableNames": [
"orders",
"outbox",
"processed_events"
]
}Create SQS DLQs + main queues with redrive + CMK
source /app/env.sh
KEY_ID=$(cat /app/build/kms_key_id)
KEY_ARN=$(cat /app/build/kms_key_arn)
# DLQs first
for q in orders-created-dlq orders-cancelled-dlq; do
aws --endpoint-url $EP sqs create-queue \
--queue-name $q \
--attributes "{\"KmsMasterKeyId\":\"$KEY_ID\"}" > /dev/null
echo "$q created"
done
CREATED_DLQ_URL=$(aws --endpoint-url $EP sqs get-queue-url --queue-name orders-created-dlq | jq -r .QueueUrl)
CANCELLED_DLQ_URL=$(aws --endpoint-url $EP sqs get-queue-url --queue-name orders-cancelled-dlq | jq -r .QueueUrl)
CREATED_DLQ_ARN=$(aws --endpoint-url $EP sqs get-queue-attributes --queue-url $CREATED_DLQ_URL --attribute-names QueueArn | jq -r .Attributes.QueueArn)
CANCELLED_DLQ_ARN=$(aws --endpoint-url $EP sqs get-queue-attributes --queue-url $CANCELLED_DLQ_URL --attribute-names QueueArn | jq -r .Attributes.QueueArn)
echo "CREATED_DLQ_ARN=$CREATED_DLQ_ARN"
echo "CANCELLED_DLQ_ARN=$CANCELLED_DLQ_ARN"
# Main queues with redrive
CREATED_REDRIVE=$(jq -nc --arg arn "$CREATED_DLQ_ARN" '{deadLetterTargetArn:$arn, maxReceiveCount:"5"}')
CANCELLED_REDRIVE=$(jq -nc --arg arn "$CANCELLED_DLQ_ARN" '{deadLetterTargetArn:$arn, maxReceiveCount:"5"}')
aws --endpoint-url $EP sqs create-queue \
--queue-name orders-created \
--attributes "{\"KmsMasterKeyId\":\"$KEY_ID\",\"RedrivePolicy\":$(echo "$CREATED_REDRIVE" | jq -Rs .)}" > /dev/null
aws --endpoint-url $EP sqs create-queue \
--queue-name orders-cancelled \
--attributes "{\"KmsMasterKeyId\":\"$KEY_ID\",\"RedrivePolicy\":$(echo "$CANCELLED_REDRIVE" | jq -Rs .)}" > /dev/null
CREATED_URL=$(aws --endpoint-url $EP sqs get-queue-url --queue-name orders-created | jq -r .QueueUrl)
CANCELLED_URL=$(aws --endpoint-url $EP sqs get-queue-url --queue-name orders-cancelled | jq -r .QueueUrl)
CREATED_ARN=$(aws --endpoint-url $EP sqs get-queue-attributes --queue-url $CREATED_URL --attribute-names QueueArn | jq -r .Attributes.QueueArn)
CANCELLED_ARN=$(aws --endpoint-url $EP sqs get-queue-attributes --queue-url $CANCELLED_URL --attribute-names QueueArn | jq -r .Attributes.QueueArn)
echo "CREATED_ARN=$CREATED_ARN"
echo "CANCELLED_ARN=$CANCELLED_ARN"
echo "$CREATED_URL" > /app/build/orders_created_url
echo "$CANCELLED_URL" > /app/build/orders_cancelled_url
echo "$CREATED_ARN" > /app/build/orders_created_arn
echo "$CANCELLED_ARN" > /app/build/orders_cancelled_arn
echo "$CREATED_DLQ_ARN" > /app/build/orders_created_dlq_arn
echo "$CANCELLED_DLQ_ARN" > /app/build/orders_cancelled_dlq_arnorders-created-dlq created orders-cancelled-dlq created CREATED_DLQ_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created-dlq CANCELLED_DLQ_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled-dlq CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled [stdout] orders-created-dlq created orders-cancelled-dlq created CREATED_DLQ_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created-dlq CANCELLED_DLQ_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled-dlq CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled
Verify queue attributes
source /app/env.sh # verify redrive + KMS attributes for q in orders-created orders-cancelled; do url=$(aws --endpoint-url $EP sqs get-queue-url --queue-name $q | jq -r .QueueUrl) echo "=== $q ===" aws --endpoint-url $EP sqs get-queue-attributes --queue-url $url --attribute-names KmsMasterKeyId RedrivePolicy done
=== orders-created ===
{
"Attributes": {
"KmsMasterKeyId": "5ccc6de5-d379-458b-801f-baaeb4c83013",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\",\"maxReceiveCount\":\"5\"}\n"
}
}
=== orders-cancelled ===
{
"Attributes": {
"KmsMasterKeyId": "5ccc6de5-d379-458b-801f-baaeb4c83013",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-cancelled-dlq\",\"maxReceiveCount\":\"5\"}\n"
}
}
[stdout]
=== orders-created ===
{
"Attributes": {
"KmsMasterKeyId": "5ccc6de5-d379-458b-801f-baaeb4c83013",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\",\"maxReceiveCount\":\"5\"}\n"
}
}
=== orders-cancelled ===
{
"Attributes": {
"KmsMasterKeyId": "5ccc6de5-d379-458b-801f-baaeb4c83013",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-cancelled-dlq\",\"maxReceiveCount\":\"5\"}\n"
}
}Create bus, rules, targets, queue resource policies
source /app/env.sh
KEY_ID=$(cat /app/build/kms_key_id)
aws --endpoint-url $EP events create-event-bus \
--name order-events \
--kms-key-identifier "$KEY_ID" > /dev/null && echo "bus created"
BUS_ARN=$(aws --endpoint-url $EP events describe-event-bus --name order-events | jq -r .Arn)
echo "BUS_ARN=$BUS_ARN"
echo "$BUS_ARN" > /app/build/bus_arn
# Rules
aws --endpoint-url $EP events put-rule \
--name on-order-created \
--event-bus-name order-events \
--event-pattern '{"source":["outbox.relay"],"detail-type":["OrderCreated"]}' \
> /dev/null && echo "rule on-order-created"
aws --endpoint-url $EP events put-rule \
--name on-order-cancelled \
--event-bus-name order-events \
--event-pattern '{"source":["outbox.relay"],"detail-type":["OrderCancelled"]}' \
> /dev/null && echo "rule on-order-cancelled"
CREATED_ARN=$(cat /app/build/orders_created_arn)
CANCELLED_ARN=$(cat /app/build/orders_cancelled_arn)
CREATED_URL=$(cat /app/build/orders_created_url)
CANCELLED_URL=$(cat /app/build/orders_cancelled_url)
# Targets
aws --endpoint-url $EP events put-targets \
--rule on-order-created --event-bus-name order-events \
--targets "Id=created-q,Arn=$CREATED_ARN" > /dev/null && echo "target -> created"
aws --endpoint-url $EP events put-targets \
--rule on-order-cancelled --event-bus-name order-events \
--targets "Id=cancelled-q,Arn=$CANCELLED_ARN" > /dev/null && echo "target -> cancelled"
# Allow EventBridge to send to each queue
for pair in "$CREATED_URL|$CREATED_ARN" "$CANCELLED_URL|$CANCELLED_ARN"; do
url=${pair%%|*}; arn=${pair##*|}
pol=$(jq -nc --arg arn "$arn" '{Version:"2012-10-17",Statement:[{Sid:"AllowEB",Effect:"Allow",Principal:{Service:"events.amazonaws.com"},Action:"sqs:SendMessage",Resource:$arn}]}')
aws --endpoint-url $EP sqs set-queue-attributes \
--queue-url $url \
--attributes "{\"Policy\":$(echo "$pol" | jq -Rs .)}" > /dev/null
echo "policy set on $url"
donebus created BUS_ARN=arn:aws:events:us-east-1:000000000000:event-bus/order-events rule on-order-created rule on-order-cancelled target -> created target -> cancelled policy set on http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created policy set on http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled [stdout] bus created BUS_ARN=arn:aws:events:us-east-1:000000000000:event-bus/order-events rule on-order-created rule on-order-cancelled target -> created target -> cancelled policy set on http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created policy set on http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
Create IAM roles + scoped policies
source /app/env.sh
KEY_ARN=$(cat /app/build/kms_key_arn)
OUTBOX_STREAM=$(cat /app/build/outbox_stream_arn)
BUS_ARN=$(cat /app/build/bus_arn)
# order-api-role policy
cat > /app/iam/order-api-policy.json <<EOF
{
"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:*"
},
{
"Sid": "TxnWriteOrdersAndOutbox",
"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": "DescribeForCmk",
"Effect": "Allow",
"Action": ["dynamodb:DescribeTable"],
"Resource": [
"arn:aws:dynamodb:us-east-1:000000000000:table/orders",
"arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
]
},
{
"Sid": "UseCmkForDdb",
"Effect": "Allow",
"Action": ["kms:Encrypt","kms:Decrypt","kms:GenerateDataKey","kms:DescribeKey"],
"Resource": "$KEY_ARN",
"Condition": {"StringEquals": {"kms:ViaService": "dynamodb.us-east-1.amazonaws.com"}}
}
]
}
EOF
# outbox-relay-role policy
cat > /app/iam/outbox-relay-policy.json <<EOF
{
"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:*"
},
{
"Sid": "ReadOutboxStream",
"Effect": "Allow",
"Action": [
"dynamodb:DescribeStream",
"dynamodb:GetRecords",
"dynamodb:GetShardIterator",
"dynamodb:ListStreams"
],
"Resource": "$OUTBOX_STREAM"
},
{
"Sid": "DedupTableWrite",
"Effect": "Allow",
"Action": ["dynamodb:PutItem","dynamodb:GetItem","dynamodb:DescribeTable"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/processed_events"
},
{
"Sid": "PutEventsOnBus",
"Effect": "Allow",
"Action": "events:PutEvents",
"Resource": "$BUS_ARN"
},
{
"Sid": "UseCmkForDdb",
"Effect": "Allow",
"Action": ["kms:Decrypt","kms:Encrypt","kms:GenerateDataKey","kms:DescribeKey"],
"Resource": "$KEY_ARN",
"Condition": {"StringEquals": {"kms:ViaService": "dynamodb.us-east-1.amazonaws.com"}}
},
{
"Sid": "UseCmkForEvents",
"Effect": "Allow",
"Action": ["kms:GenerateDataKey","kms:Decrypt"],
"Resource": "$KEY_ARN",
"Condition": {"StringEquals": {"kms:ViaService": "events.us-east-1.amazonaws.com"}}
}
]
}
EOF
# Create roles
aws --endpoint-url $EP iam create-role \
--role-name order-api-role \
--assume-role-policy-document file:///app/iam/lambda-trust.json > /dev/null && echo "order-api-role created"
aws --endpoint-url $EP iam create-role \
--role-name outbox-relay-role \
--assume-role-policy-document file:///app/iam/lambda-trust.json > /dev/null && echo "outbox-relay-role created"
aws --endpoint-url $EP iam put-role-policy \
--role-name order-api-role \
--policy-name order-api-inline \
--policy-document file:///app/iam/order-api-policy.json && echo "order-api policy attached"
aws --endpoint-url $EP iam put-role-policy \
--role-name outbox-relay-role \
--policy-name outbox-relay-inline \
--policy-document file:///app/iam/outbox-relay-policy.json && echo "outbox-relay policy attached"
ORDER_API_ROLE_ARN=$(aws --endpoint-url $EP iam get-role --role-name order-api-role | jq -r .Role.Arn)
RELAY_ROLE_ARN=$(aws --endpoint-url $EP iam get-role --role-name outbox-relay-role | jq -r .Role.Arn)
echo "$ORDER_API_ROLE_ARN" > /app/build/order_api_role_arn
echo "$RELAY_ROLE_ARN" > /app/build/relay_role_arn
echo "ORDER_API_ROLE_ARN=$ORDER_API_ROLE_ARN"
echo "RELAY_ROLE_ARN=$RELAY_ROLE_ARN"order-api-role created outbox-relay-role created order-api policy attached outbox-relay policy attached ORDER_API_ROLE_ARN=arn:aws:iam::000000000000:role/order-api-role RELAY_ROLE_ARN=arn:aws:iam::000000000000:role/outbox-relay-role [stdout] order-api-role created outbox-relay-role created order-api policy attached outbox-relay policy attached ORDER_API_ROLE_ARN=arn:aws:iam::000000000000:role/order-api-role RELAY_ROLE_ARN=arn:aws:iam::000000000000:role/outbox-relay-role
import json
import os
import time
import uuid
import boto3
ENDPOINT = os.environ.get("AWS_ENDPOINT_URL", "http://localstack:4566")
ddb = boto3.client("dynamodb", endpoint_url=ENDPOINT, region_name="us-east-1")
ALLOWED_KINDS = {"OrderCreated", "OrderCancelled"}
def handler(event, _context):
body = event if isinstance(event, dict) else json.loads(event)
order_id = body["order_id"]
kind = body["kind"]
if kind not in ALLOWED_KINDS:
raise ValueError(f"unsupported kind: {kind}")
event_id = str(uuid.uuid4())
now = int(time.time())
order_item = {
"order_id": {"S": order_id},
"kind": {"S": kind},
"updated_at": {"N": str(now)},
"payload": {"S": json.dumps(body)},
}
outbox_item = {
"event_id": {"S": event_id},
"order_id": {"S": order_id},
"kind": {"S": kind},
"created_at": {"N": str(now)},
"payload": {"S": json.dumps(body)},
}
ddb.transact_write_items(
TransactItems=[
{"Put": {"TableName": "orders", "Item": order_item}},
{"Put": {"TableName": "outbox", "Item": outbox_item}},
]
)
return {"order_id": order_id, "event_id": event_id, "kind": kind}
import json
import os
import time
import boto3
from botocore.exceptions import ClientError
ENDPOINT = os.environ.get("AWS_ENDPOINT_URL", "http://localstack:4566")
BUS_NAME = os.environ.get("BUS_NAME", "order-events")
DEDUP_TABLE = os.environ.get("DEDUP_TABLE", "processed_events")
DEDUP_TTL_SECONDS = int(os.environ.get("DEDUP_TTL_SECONDS", "604800")) # 7d
events = boto3.client("events", endpoint_url=ENDPOINT, region_name="us-east-1")
ddb = boto3.client("dynamodb", endpoint_url=ENDPOINT, region_name="us-east-1")
def _img_to_plain(image):
out = {}
for k, v in image.items():
if "S" in v:
out[k] = v["S"]
elif "N" in v:
out[k] = v["N"]
elif "BOOL" in v:
out[k] = v["BOOL"]
else:
out[k] = v
return out
def _claim(event_id):
"""Reserve event_id in dedup table. Returns True if we won the claim."""
try:
ddb.put_item(
TableName=DEDUP_TABLE,
Item={
"event_id": {"S": event_id},
"processed_at": {"N": str(int(time.time()))},
"ttl": {"N": str(int(time.time()) + DEDUP_TTL_SECONDS)},
},
ConditionExpression="attribute_not_exists(event_id)",
)
return True
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
return False
raise
def handler(event, _context):
failures = []
for rec in event.get("Records", []):
seq = rec.get("dynamodb", {}).get("SequenceNumber")
try:
new_image = rec.get("dynamodb", {}).get("NewImage")
if rec.get("eventName") not in ("INSERT", "MODIFY") or not new_image:
# nothing to publish (e.g. REMOVE) , ack and skip
continue
row = _img_to_plain(new_image)
event_id = row.get("event_id")
kind = row.get("kind")
if not event_id or kind not in ("OrderCreated", "OrderCancelled"):
# malformed , skip rather than poison the stream
continue
if not _claim(event_id):
# already processed; skip silently
continue
detail = {
"event_id": event_id,
"order_id": row.get("order_id"),
"kind": kind,
"payload": row.get("payload"),
}
resp = events.put_events(
Entries=[
{
"EventBusName": BUS_NAME,
"Source": "outbox.relay",
"DetailType": kind,
"Detail": json.dumps(detail),
}
]
)
if resp.get("FailedEntryCount", 0) > 0:
raise RuntimeError(f"PutEvents failed: {resp}")
except Exception as exc: # noqa: BLE001
print(f"record {seq} failed: {exc!r}")
if seq:
failures.append({"itemIdentifier": seq})
return {"batchItemFailures": failures}
Zip lambda packages
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/
total 64 drwxr-xr-x 2 root root 4096 Apr 28 14:42 . drwxr-xr-x 1 root root 4096 Apr 28 14:39 .. -rw-r--r-- 1 root root 61 Apr 28 14:41 bus_arn -rw-r--r-- 1 root root 76 Apr 28 14:39 kms_key_arn -rw-r--r-- 1 root root 37 Apr 28 14:39 kms_key_id -rw-r--r-- 1 root root 705 Apr 28 14:42 order-api.zip -rw-r--r-- 1 root root 46 Apr 28 14:41 order_api_role_arn -rw-r--r-- 1 root root 52 Apr 28 14:40 orders_cancelled_arn -rw-r--r-- 1 root root 56 Apr 28 14:40 orders_cancelled_dlq_arn -rw-r--r-- 1 root root 83 Apr 28 14:40 orders_cancelled_url -rw-r--r-- 1 root root 50 Apr 28 14:40 orders_created_arn -rw-r--r-- 1 root root 54 Apr 28 14:40 orders_created_dlq_arn -rw-r--r-- 1 root root 81 Apr 28 14:40 orders_created_url -rw-r--r-- 1 root root 1381 Apr 28 14:42 outbox-relay.zip -rw-r--r-- 1 root root 84 Apr 28 14:40 outbox_stream_arn -rw-r--r-- 1 root root 49 Apr 28 14:41 relay_role_arn [stdout] total 64 drwxr-xr-x 2 root root 4096 Apr 28 14:42 . drwxr-xr-x 1 root root 4096 Apr 28 14:39 .. -rw-r--r-- 1 root root 61 Apr 28 14:41 bus_arn -rw-r--r-- 1 root root 76 Apr 28 14:39 kms_key_arn -rw-r--r-- 1 root root 37 Apr 28 14:39 kms_key_id -rw-r--r-- 1 root root 705 Apr 28 14:42 order-api.zip -rw-r--r-- 1 root root 46 Apr 28 14:41 order_api_role_arn -rw-r--r-- 1 root root 52 Apr 28 14:40 orders_cancelled_arn -rw-r--r-- 1 root root 56 Apr 28 14:40 orders_cancelled_dlq_arn -rw-r--r-- 1 root root 83 Apr 28 14:40 orders_cancelled_url -rw-r--r-- 1 root root 50 Apr 28 14:40 orders_created_arn -rw-r--r-- 1 root root 54 Apr 28 14:40 orders_created_dlq_arn -rw-r--r-- 1 root root 81 Apr 28 14:40 orders_created_url -rw-r--r-- 1 root root 1381 Apr 28 14:42 outbox-relay.zip -rw-r--r-- 1 root root 84 Apr 28 14:40 outbox_stream_arn -rw-r--r-- 1 root root 49 Apr 28 14:41 relay_role_arn
Create both lambdas
source /app/env.sh
ORDER_API_ROLE_ARN=$(cat /app/build/order_api_role_arn)
RELAY_ROLE_ARN=$(cat /app/build/relay_role_arn)
KEY_ARN=$(cat /app/build/kms_key_arn)
aws --endpoint-url $EP lambda create-function \
--function-name order-api \
--runtime python3.11 \
--role "$ORDER_API_ROLE_ARN" \
--handler handler.handler \
--zip-file fileb:///app/build/order-api.zip \
--timeout 30 \
--kms-key-arn "$KEY_ARN" \
--environment "Variables={AWS_ENDPOINT_URL=http://localstack:4566}" > /dev/null && echo "order-api created"
aws --endpoint-url $EP lambda create-function \
--function-name outbox-relay \
--runtime python3.11 \
--role "$RELAY_ROLE_ARN" \
--handler handler.handler \
--zip-file fileb:///app/build/outbox-relay.zip \
--timeout 30 \
--kms-key-arn "$KEY_ARN" \
--environment "Variables={AWS_ENDPOINT_URL=http://localstack:4566,BUS_NAME=order-events,DEDUP_TABLE=processed_events}" > /dev/null && echo "outbox-relay created"
aws --endpoint-url $EP lambda wait function-active-v2 --function-name order-api
aws --endpoint-url $EP lambda wait function-active-v2 --function-name outbox-relay
echo "both active"order-api created outbox-relay created both active [stdout] order-api created outbox-relay created both active
Create ESM with ReportBatchItemFailures
source /app/env.sh OUTBOX_STREAM=$(cat /app/build/outbox_stream_arn) aws --endpoint-url $EP lambda create-event-source-mapping \ --function-name outbox-relay \ --event-source-arn "$OUTBOX_STREAM" \ --starting-position TRIM_HORIZON \ --batch-size 10 \ --maximum-batching-window-in-seconds 1 \ --function-response-types ReportBatchItemFailures \ > /tmp/esm.json cat /tmp/esm.json ESM_UUID=$(jq -r .UUID /tmp/esm.json) echo "ESM_UUID=$ESM_UUID" echo "$ESM_UUID" > /app/build/esm_uuid # wait until enabled for i in $(seq 1 20); do state=$(aws --endpoint-url $EP lambda get-event-source-mapping --uuid $ESM_UUID | jq -r .State) echo "ESM state: $state" [ "$state" = "Enabled" ] && break sleep 1 done
{
"UUID": "7346a8b0-4769-4cfc-a85f-d19510c48511",
"StartingPosition": "TRIM_HORIZON",
"BatchSize": 10,
"MaximumBatchingWindowInSeconds": 1,
"ParallelizationFactor": 1,
"EventSourceArn": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:40:17.987",
"FunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:outbox-relay",
"LastModified": "2026-04-28T14:42:56.240664+00:00",
"LastProcessingResult": "No records processed",
"State": "Creating",
"StateTransitionReason": "User action",
"DestinationConfig": {
"OnFailure": {}
},
"MaximumRecordAgeInSeconds": -1,
"BisectBatchOnFunctionError": false,
"MaximumRetryAttempts": -1,
"TumblingWindowInSeconds": 0,
"FunctionResponseTypes": [
"ReportBatchItemFailures"
],
"EventSourceMappingArn": "arn:aws:lambda:us-east-1:000000000000:event-source-mapping:7346a8b0-4769-4cfc-a85f-d19510c48511"
}
ESM_UUID=7346a8b0-4769-4cfc-a85f-d19510c48511
ESM state: Enabled
[stdout]
{
"UUID": "7346a8b0-4769-4cfc-a85f-d19510c48511",
"StartingPosition": "TRIM_HORIZON",
"BatchSize": 10,
"MaximumBatchingWindowInSeconds": 1,
"ParallelizationFactor": 1,
"EventSourceArn": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:40:17.987",
"FunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:outbox-relay",
"LastModified": "2026-04-28T14:42:56.240664+00:00",
"LastProcessingResult": "No records processed",
"State": "Creating",
"StateTransitionReason": "User action",
"DestinationConfig": {
"OnFailure": {}
},
"MaximumRecordAgeInSeconds": -1,
"BisectBatchOnFunctionError": false,
"MaximumRetryAttempts": -1,
"TumblingWindowInSeconds": 0,
"FunctionResponseTypes": [
"ReportBatchItemFailures"
],
"EventSourceMappingArn": "arn:aws:lambda:us-east-1:000000000000:event-source-mapping:7346a8b0-4769-4cfc-a85f-d19510c48511"
}
ESM_UUID=7346a8b0-4769-4cfc-a85f-d19510c48511
ESM state: EnabledInvoke order-api twice and watch queues
source /app/env.sh
CREATED_URL=$(cat /app/build/orders_created_url)
CANCELLED_URL=$(cat /app/build/orders_cancelled_url)
# Drain any leftover messages
aws --endpoint-url $EP sqs purge-queue --queue-url $CREATED_URL || true
aws --endpoint-url $EP sqs purge-queue --queue-url $CANCELLED_URL || true
sleep 2
# 1) OrderCreated for o-1
PAYLOAD1=$(echo -n '{"order_id":"o-1","kind":"OrderCreated","amount_cents":4999,"sku":"abc"}' | base64 -w0)
aws --endpoint-url $EP lambda invoke \
--function-name order-api \
--payload "$PAYLOAD1" \
--cli-binary-format base64 \
/tmp/order-api-1.json
echo "--- order-api response 1:"
cat /tmp/order-api-1.json; echo
# 2) OrderCancelled for o-2
PAYLOAD2=$(echo -n '{"order_id":"o-2","kind":"OrderCancelled","reason":"buyer changed mind"}' | base64 -w0)
aws --endpoint-url $EP lambda invoke \
--function-name order-api \
--payload "$PAYLOAD2" \
--cli-binary-format base64 \
/tmp/order-api-2.json
echo "--- order-api response 2:"
cat /tmp/order-api-2.json; echo
# Wait for stream to propagate through relay
echo "waiting up to 30s for relay to process..."
for i in $(seq 1 30); do
c=$(aws --endpoint-url $EP sqs get-queue-attributes --queue-url $CREATED_URL --attribute-names ApproximateNumberOfMessages | jq -r .Attributes.ApproximateNumberOfMessages)
x=$(aws --endpoint-url $EP sqs get-queue-attributes --queue-url $CANCELLED_URL --attribute-names ApproximateNumberOfMessages | jq -r .Attributes.ApproximateNumberOfMessages)
echo "t=${i}s created=$c cancelled=$x"
if [ "$c" = "1" ] && [ "$x" = "1" ]; then break; fi
sleep 1
done{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
--- order-api response 1:
{"order_id": "o-1", "event_id": "3c08151f-62a9-47a8-a18e-ce67ce17db0a", "kind": "OrderCreated"}
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
--- order-api response 2:
{"order_id": "o-2", "event_id": "d9a9428b-60a7-4509-bafe-543d0d43e086", "kind": "OrderCancelled"}
waiting up to 30s for relay to process...
t=1s created=1 cancelled=0
t=2s created=1 cancelled=1
[stdout]
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
--- order-api response 1:
{"order_id": "o-1", "event_id": "3c08151f-62a9-47a8-a18e-ce67ce17db0a", "kind": "OrderCreated"}
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
--- order-api response 2:
{"order_id": "o-2", "event_id": "d9a9428b-60a7-4509-bafe-543d0d43e086", "kind": "OrderCancelled"}
waiting up to 30s for relay to process...
t=1s created=1 cancelled=0
t=2s created=1 cancelled=1Verify msgs, dedup, tables, DLQs
source /app/env.sh
CREATED_URL=$(cat /app/build/orders_created_url)
CANCELLED_URL=$(cat /app/build/orders_cancelled_url)
echo "=== orders-created message ==="
aws --endpoint-url $EP sqs receive-message --queue-url $CREATED_URL --max-number-of-messages 10 --wait-time-seconds 1 --visibility-timeout 1 | jq '.Messages[].Body | fromjson | {source, "detail-type", detail: (.detail|fromjson)}'
echo "=== orders-cancelled message ==="
aws --endpoint-url $EP sqs receive-message --queue-url $CANCELLED_URL --max-number-of-messages 10 --wait-time-seconds 1 --visibility-timeout 1 | jq '.Messages[].Body | fromjson | {source, "detail-type", detail: (.detail|fromjson)}'
echo "=== processed_events scan ==="
aws --endpoint-url $EP dynamodb scan --table-name processed_events | jq '.Items[] | {event_id: .event_id.S, ttl: .ttl.N}'
echo "=== outbox scan ==="
aws --endpoint-url $EP dynamodb scan --table-name outbox | jq '.Items[] | {event_id: .event_id.S, order_id: .order_id.S, kind: .kind.S}'
echo "=== orders scan ==="
aws --endpoint-url $EP dynamodb scan --table-name orders | jq '.Items[] | {order_id: .order_id.S, kind: .kind.S}'
echo "=== DLQ depths ==="
for q in orders-created-dlq orders-cancelled-dlq; do
url=$(aws --endpoint-url $EP sqs get-queue-url --queue-name $q | jq -r .QueueUrl)
d=$(aws --endpoint-url $EP sqs get-queue-attributes --queue-url $url --attribute-names ApproximateNumberOfMessages | jq -r .Attributes.ApproximateNumberOfMessages)
echo "$q = $d"
done=== orders-created message ===
jq: error (at <stdin>:10): object ({"event_id"...) only strings can be parsed
=== orders-cancelled message ===
jq: error (at <stdin>:10): object ({"event_id"...) only strings can be parsed
=== processed_events scan ===
{
"event_id": "3c08151f-62a9-47a8-a18e-ce67ce17db0a",
"ttl": "1777992201"
}
{
"event_id": "d9a9428b-60a7-4509-bafe-543d0d43e086",
"ttl": "1777992204"
}
=== outbox scan ===
{
"event_id": "3c08151f-62a9-47a8-a18e-ce67ce17db0a",
"order_id": "o-1",
"kind": "OrderCreated"
}
{
"event_id": "d9a9428b-60a7-4509-bafe-543d0d43e086",
"order_id": "o-2",
"kind": "OrderCancelled"
}
=== orders scan ===
{
"order_id": "o-1",
"kind": "OrderCreated"
}
{
"order_id": "o-2",
"kind": "OrderCancelled"
}
=== DLQ depths ===
orders-created-dlq = 0
orders-cancelled-dlq = 0
[stdout]
=== orders-created message ===
jq: error (at <stdin>:10): object ({"event_id"...) only strings can be parsed
=== orders-cancelled message ===
jq: error (at <stdin>:10): object ({"event_id"...) only strings can be parsed
=== processed_events scan ===
{
"event_id": "3c08151f-62a9-47a8-a18e-ce67ce17db0a",
"ttl": "1777992201"
}
{
"event_id": "d9a9428b-60a7-4509-bafe-543d0d43e086",
"ttl": "1777992204"
}
=== outbox scan ===
{
"event_id": "3c08151f-62a9-47a8-a18e-ce67ce17db0a",
"order_id": "o-1",
"kind": "OrderCreated"
}
{
"event_id": "d9a9428b-60a7-4509-bafe-543d0d43e086",
"order_id": "o-2",
"kind": "OrderCancelled"
}
=== orders scan ===
{
"order_id": "o-1",
"kind": "OrderCreated"
}
{
"order_id": "o-2",
"kind": "OrderCancelled"
}
=== DLQ depths ===
orders-created-dlq = 0
orders-cancelled-dlq = 0Peek SQS message envelopes
source /app/env.sh
CREATED_URL=$(cat /app/build/orders_created_url)
CANCELLED_URL=$(cat /app/build/orders_cancelled_url)
# wait for visibility to expire
sleep 2
echo "=== orders-created message (raw envelope) ==="
aws --endpoint-url $EP sqs receive-message --queue-url $CREATED_URL --max-number-of-messages 10 --wait-time-seconds 1 --visibility-timeout 1 | jq '.Messages[] | .Body | fromjson | {source, "detail-type", detail}'
echo "=== orders-cancelled message (raw envelope) ==="
aws --endpoint-url $EP sqs receive-message --queue-url $CANCELLED_URL --max-number-of-messages 10 --wait-time-seconds 1 --visibility-timeout 1 | jq '.Messages[] | .Body | fromjson | {source, "detail-type", detail}'=== orders-created message (raw envelope) ===
{
"source": "outbox.relay",
"detail-type": "OrderCreated",
"detail": {
"event_id": "3c08151f-62a9-47a8-a18e-ce67ce17db0a",
"order_id": "o-1",
"kind": "OrderCreated",
"payload": "{\"order_id\": \"o-1\", \"kind\": \"OrderCreated\", \"amount_cents\": 4999, \"sku\": \"abc\"}"
}
}
=== orders-cancelled message (raw envelope) ===
{
"source": "outbox.relay",
"detail-type": "OrderCancelled",
"detail": {
"event_id": "d9a9428b-60a7-4509-bafe-543d0d43e086",
"order_id": "o-2",
"kind": "OrderCancelled",
"payload": "{\"order_id\": \"o-2\", \"kind\": \"OrderCancelled\", \"reason\": \"buyer changed mind\"}"
}
}
[stdout]
=== orders-created message (raw envelope) ===
{
"source": "outbox.relay",
"detail-type": "OrderCreated",
"detail": {
"event_id": "3c08151f-62a9-47a8-a18e-ce67ce17db0a",
"order_id": "o-1",
"kind": "OrderCreated",
"payload": "{\"order_id\": \"o-1\", \"kind\": \"OrderCreated\", \"amount_cents\": 4999, \"sku\": \"abc\"}"
}
}
=== orders-cancelled message (raw envelope) ===
{
"source": "outbox.relay",
"detail-type": "OrderCancelled",
"detail": {
"event_id": "d9a9428b-60a7-4509-bafe-543d0d43e086",
"order_id": "o-2",
"kind": "OrderCancelled",
"payload": "{\"order_id\": \"o-2\", \"kind\": \"OrderCancelled\", \"reason\": \"buyer changed mind\"}"
}
}Test idempotency on re-delivery
source /app/env.sh
CREATED_URL=$(cat /app/build/orders_created_url)
# get current depth
before=$(aws --endpoint-url $EP sqs get-queue-attributes --queue-url $CREATED_URL --attribute-names ApproximateNumberOfMessages | jq -r .Attributes.ApproximateNumberOfMessages)
echo "before: $before"
# Build a synthetic stream event re-delivering event_id 3c08151f-62a9-47a8-a18e-ce67ce17db0a
cat > /tmp/replay.json <<'EOF'
{
"Records": [
{
"eventName": "INSERT",
"dynamodb": {
"SequenceNumber": "999999999",
"NewImage": {
"event_id": {"S": "3c08151f-62a9-47a8-a18e-ce67ce17db0a"},
"order_id": {"S": "o-1"},
"kind": {"S": "OrderCreated"},
"created_at": {"N": "1"},
"payload": {"S": "{\"order_id\":\"o-1\",\"kind\":\"OrderCreated\"}"}
}
}
}
]
}
EOF
aws --endpoint-url $EP lambda invoke \
--function-name outbox-relay \
--payload fileb:///tmp/replay.json \
/tmp/replay-resp.json
echo "--- relay response:"
cat /tmp/replay-resp.json; echo
sleep 2
after=$(aws --endpoint-url $EP sqs get-queue-attributes --queue-url $CREATED_URL --attribute-names ApproximateNumberOfMessages,ApproximateNumberOfMessagesNotVisible | jq .Attributes)
echo "after: $after"before: 1
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
--- relay response:
{"batchItemFailures": []}
aws: [ERROR]: An error occurred (InvalidAttributeName) when calling the GetQueueAttributes operation: Unknown Attribute ApproximateNumberOfMessages,ApproximateNumberOfMessagesNotVisible.
after:
[stdout]
before: 1
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
--- relay response:
{"batchItemFailures": []}
aws: [ERROR]: An error occurred (InvalidAttributeName) when calling the GetQueueAttributes operation: Unknown Attribute ApproximateNumberOfMessages,ApproximateNumberOfMessagesNotVisible.
after:Check queue depth after replay
source /app/env.sh CREATED_URL=$(cat /app/build/orders_created_url) aws --endpoint-url $EP sqs get-queue-attributes --queue-url $CREATED_URL --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible | jq .Attributes
{
"ApproximateNumberOfMessages": "1",
"ApproximateNumberOfMessagesNotVisible": "0"
}
[stdout]
{
"ApproximateNumberOfMessages": "1",
"ApproximateNumberOfMessagesNotVisible": "0"
}Dump IAM policy docs
source /app/env.sh echo "=== order-api-role policy ===" aws --endpoint-url $EP iam get-role-policy --role-name order-api-role --policy-name order-api-inline | jq .PolicyDocument echo echo "=== outbox-relay-role policy ===" aws --endpoint-url $EP iam get-role-policy --role-name outbox-relay-role --policy-name outbox-relay-inline | jq .PolicyDocument
=== order-api-role 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:*"
},
{
"Sid": "TxnWriteOrdersAndOutbox",
"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": "DescribeForCmk",
"Effect": "Allow",
"Action": [
"dynamodb:DescribeTable"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:000000000000:table/orders",
"arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
]
},
{
"Sid": "UseCmkForDdb",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/5ccc6de5-d379-458b-801f-baaeb4c83013",
"Condition": {
"StringEquals": {
"kms:ViaService": "dynamodb.us-east-1.amazonaws.com"
}
}
}
]
}
=== outbox-relay-role 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:*"
},
{
"Sid": "ReadOutboxStream",
"Effect": "Allow",
"Action": [
"dynamodb:DescribeStream",
"dynamodb:GetRecords",
"dynamodb:GetShardIterator",
"dynamodb:ListStreams"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:40:17.987"
},
{
"Sid": "DedupTableWrite",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:GetItem",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/processed_events"
},
{
"Sid": "PutEventsOnBus",
"Effect": "Allow",
"Action": "events:PutEvents",
"Resource": "arn:aws:events:us-east-1:000000000000:event-bus/order-events"
},
{
"Sid": "UseCmkForDdb",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:Encrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/5ccc6de5-d379-458b-801f-baaeb4c83013",
"Condition": {
"StringEquals": {
"kms:ViaService": "dynamodb.us-east-1.amazonaws.com"
}
}
},
{
"Sid": "UseCmkForEvents",
"Effect": "Allow",
"Action": [
"kms:GenerateDataKey",
"kms:Decrypt"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/5ccc6de5-d379-458b-801f-baaeb4c83013",
"Condition": {
"StringEquals": {
"kms:ViaService": "events.us-east-1.amazonaws.com"
}
}
}
]
}
[stdout]
=== order-api-role 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:*"
},
{
"Sid": "TxnWriteOrdersAndOutbox",
"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": "DescribeForCmk",
"Effect": "Allow",
"Action": [
"dynamodb:DescribeTable"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:000000000000:table/orders",
"arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
]
},
{
"Sid": "UseCmkForDdb",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/5ccc6de5-d379-458b-801f-baaeb4c83013",
"Condition": {
"StringEquals": {
"kms:ViaService": "dynamodb.us-east-1.amazonaws.com"
}
}
}
]
}
=== outbox-relay-role 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:*"
},
{
"Sid": "ReadOutboxStream",
"Effect": "Allow",
"Action": [
"dynamodb:DescribeStream",
"dynamodb:GetRecords",
"dynamodb:GetShardIterator",
"dynamodb:ListStreams"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:40:17.987"
},
{
"Sid": "DedupTableWrite",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:GetItem",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/processed_events"
},
{
"Sid": "PutEventsOnBus",
"Effect": "Allow",
"Action": "events:PutEvents",
"Resource": "arn:aws:events:us-east-1:000000000000:event-bus/order-events"
},
{
"Sid": "UseCmkForDdb",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:Encrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/5ccc6de5-d379-458b-801f-baaeb4c83013",
"Condition": {
"StringEquals": {
"kms:ViaService": "dynamodb.us-east-1.amazonaws.com"
}
}
},
{
"Sid": "UseCmkForEvents",
"Effect": "Allow",
"Action": [
"kms:GenerateDataKey",
"kms:Decrypt"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/5ccc6de5-d379-458b-801f-baaeb4c83013",
"Condition": {
"StringEquals": {
"kms:ViaService": "events.us-east-1.amazonaws.com"
}
}
}
]
}No file changes, the agent left the workspace untouched.
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.1, pluggy-1.6.0 -- /root/.cache/uv/archive-v0/zF6ciT2hIJpLuGz5p4N3B/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 FAILED [ 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%]
=================================== FAILURES ===================================
_____________ test_14_queue_policies_scope_events_with_source_arn ______________
def test_14_queue_policies_scope_events_with_source_arn():
"""Each main queue's resource policy must allow events.amazonaws.com
principal ONLY when aws:SourceArn matches the rule ARN for that queue.
This is the classic 'confused-deputy' protection that LLMs typically
either omit or wildcard."""
sqs = _client("sqs")
mapping = [
(QUEUE_CREATED, RULE_CREATED),
(QUEUE_CANCELLED, RULE_CANCELLED),
]
for qname, rule_name in mapping:
url = _queue_url(qname)
attrs = sqs.get_queue_attributes(QueueUrl=url, AttributeNames=["Policy"])[
"Attributes"
]
policy_str = attrs.get("Policy")
assert policy_str, f"{qname} missing resource policy"
policy = json.loads(policy_str)
stmts = policy.get("Statement", [])
if isinstance(stmts, dict):
stmts = [stmts]
matched = False
for s in stmts:
if s.get("Effect") != "Allow":
continue
principal = s.get("Principal", {})
svc = principal.get("Service") if isinstance(principal, dict) else None
svc_list = _as_list(svc)
if "events.amazonaws.com" not in svc_list:
continue
actions = _as_list(s.get("Action"))
if not any("SendMessage" in a or a == "sqs:*" for a in actions):
continue
cond = s.get("Condition", {})
arn_like = cond.get("ArnEquals") or cond.get("ArnLike") or {}
src_arn = arn_like.get("aws:SourceArn")
src_list = _as_list(src_arn)
if any(rule_name in v for v in src_list):
matched = True
break
> assert matched, (
f"{qname} resource policy must include an Allow for "
f"events.amazonaws.com scoped by aws:SourceArn containing "
f"rule '{rule_name}' - confused-deputy protection"
)
E AssertionError: orders-created resource policy must include an Allow for events.amazonaws.com scoped by aws:SourceArn containing rule 'on-order-created' - confused-deputy protection
E assert False
/tests/test_outputs.py:395: AssertionError
=============================== warnings summary ===============================
test_outputs.py: 89 warnings
/root/.cache/uv/archive-v0/zF6ciT2hIJpLuGz5p4N3B/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_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
FAILED ../tests/test_outputs.py::test_14_queue_policies_scope_events_with_source_arn
================== 1 failed, 19 passed, 89 warnings in 22.05s ==================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_67cb63173a1c4651. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_67cb63173a1c4651 · verifier authoritative; classifier explanatory.