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_01_cmk_alias_exists_and_enabled, 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_19_e2e_order_cancelled_routes_to_cancelled_queue_only, test_20_e2e_idempotent_duplicate_does_not_double_fanout. Agent successfully created KMS CMK with alias, 3 DynamoDB tables (orders, outbox, processed_events) with proper encryption and stream config, EventBridge bus with custom key, dual SQS queues with DLQs, EventBridge rules with proper routing patterns, and least-privilege IAM roles. Agent trajectory shows systematic execution of all infrastructure setup steps with proper verification.
Root causeAgent correctly understood and implemented a complex AWS serverless architecture pattern (DynamoDB outbox with EventBridge fanout), successfully handling all cross-cutting requirements including customer-managed KMS encryption across services, NEW_AND_OLD_IMAGES stream view type, ReportBatchItemFailures error handling, least-privilege IAM scoping, TTL-enabled deduplication, and end-to-end event routing validation.
RecommendationN/A - task is fine
Trajectory
Tool-by-tool agent trajectory
25 tool calls · 4 tool types · 34 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 working directory contents

shell
ls -la
output
total 8
drwxr-xr-x 2 root root 4096 Apr 21 20:05 .
drwxr-xr-x 1 root root 4096 Apr 28 14:22 ..

[stdout]
total 8
drwxr-xr-x 2 root root 4096 Apr 21 20:05 .
drwxr-xr-x 1 root root 4096 Apr 28 14:22 ..

Check tool versions

shell
aws --version && python3 --version && which jq zip
output
aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24
Python 3.12.3
/usr/bin/jq
/usr/bin/zip

[stdout]
aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24
Python 3.12.3
/usr/bin/jq
/usr/bin/zip

Check localstack health

shell
curl -s http://localstack:4566/_localstack/health | jq .
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"
}

Verify AWS creds and localstack access

shell
echo $AWS_ACCESS_KEY_ID $AWS_DEFAULT_REGION; aws --endpoint-url=http://localstack:4566 sts get-caller-identity
output
test us-east-1
{
    "UserId": "AKIAIOSFODNN7EXAMPLE",
    "Account": "000000000000",
    "Arn": "arn:aws:iam::000000000000:root"
}

[stdout]
test us-east-1
{
    "UserId": "AKIAIOSFODNN7EXAMPLE",
    "Account": "000000000000",
    "Arn": "arn:aws:iam::000000000000:root"
}

Create build directories

shell
mkdir -p /app/build /app/lambdas/order-api /app/lambdas/outbox-relay /app/policies
output
(Bash completed with no output)
Let me start by creating the KMS CMK and the DynamoDB tables.

Create KMS CMK and alias

shell
export AWS_PAGER=""
export ENDPOINT=http://localstack:4566

# Create KMS CMK
KEY_ID=$(aws --endpoint-url=$ENDPOINT kms create-key --description "outbox cmk" --query 'KeyMetadata.KeyId' --output text)
echo "KEY_ID=$KEY_ID"

# Create alias
aws --endpoint-url=$ENDPOINT kms create-alias --alias-name alias/outbox-cmk --target-key-id $KEY_ID
echo "alias created"

# Get full ARN
KEY_ARN=$(aws --endpoint-url=$ENDPOINT kms describe-key --key-id alias/outbox-cmk --query 'KeyMetadata.Arn' --output text)
echo "KEY_ARN=$KEY_ARN"
echo $KEY_ARN > /app/build/key_arn.txt
echo $KEY_ID > /app/build/key_id.txt
output
KEY_ID=a5ae8978-f0fd-4af3-a3fc-80c5235b27fa
alias created
KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/a5ae8978-f0fd-4af3-a3fc-80c5235b27fa

[stdout]
KEY_ID=a5ae8978-f0fd-4af3-a3fc-80c5235b27fa
alias created
KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/a5ae8978-f0fd-4af3-a3fc-80c5235b27fa

Create DynamoDB tables with CMK encryption and stream

shell
export AWS_PAGER=""
export ENDPOINT=http://localstack:4566
KEY_ARN=$(cat /app/build/key_arn.txt)

# Create orders table - encrypted with CMK
aws --endpoint-url=$ENDPOINT 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

# Create outbox table - with stream NEW_AND_OLD_IMAGES, encrypted with CMK
aws --endpoint-url=$ENDPOINT 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

# Create processed_events dedup table - with TTL, encrypted
aws --endpoint-url=$ENDPOINT 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

# Wait for tables to become active
for t in orders outbox processed_events; do
  aws --endpoint-url=$ENDPOINT dynamodb wait table-exists --table-name $t
done

# Enable TTL on processed_events
aws --endpoint-url=$ENDPOINT dynamodb update-time-to-live \
  --table-name processed_events \
  --time-to-live-specification "Enabled=true, AttributeName=ttl"

echo "tables ready"
aws --endpoint-url=$ENDPOINT dynamodb describe-table --table-name outbox --query 'Table.LatestStreamArn' --output text > /app/build/outbox_stream_arn.txt
echo "stream: $(cat /app/build/outbox_stream_arn.txt)"
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
{
    "TimeToLiveSpecification": {
        "Enabled": true,
        "AttributeName": "ttl"
    }
}
tables ready
stream: arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:25:37.799

[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
{
    "TimeToLiveSpecification": {
        "Enabled": true,
        "AttributeName": "ttl"
    }
}
tables ready
stream: arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:25:37.799

Create EventBridge bus and SQS queues with DLQs

shell
export AWS_PAGER=""
export ENDPOINT=http://localstack:4566
KEY_ARN=$(cat /app/build/key_arn.txt)

# Create EventBridge custom bus
aws --endpoint-url=$ENDPOINT events create-event-bus \
  --name order-events \
  --kms-key-identifier $KEY_ARN \
  --query 'EventBusArn' --output text > /app/build/bus_arn.txt
echo "bus: $(cat /app/build/bus_arn.txt)"

# Create DLQs first (with CMK)
for q in orders-created-dlq orders-cancelled-dlq; do
  aws --endpoint-url=$ENDPOINT sqs create-queue \
    --queue-name $q \
    --attributes "{\"KmsMasterKeyId\":\"$KEY_ARN\"}" \
    --query 'QueueUrl' --output text
done

CREATED_DLQ_URL=$(aws --endpoint-url=$ENDPOINT sqs get-queue-url --queue-name orders-created-dlq --query 'QueueUrl' --output text)
CANCELLED_DLQ_URL=$(aws --endpoint-url=$ENDPOINT sqs get-queue-url --queue-name orders-cancelled-dlq --query 'QueueUrl' --output text)
CREATED_DLQ_ARN=$(aws --endpoint-url=$ENDPOINT sqs get-queue-attributes --queue-url $CREATED_DLQ_URL --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
CANCELLED_DLQ_ARN=$(aws --endpoint-url=$ENDPOINT sqs get-queue-attributes --queue-url $CANCELLED_DLQ_URL --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)

echo $CREATED_DLQ_ARN > /app/build/created_dlq_arn.txt
echo $CANCELLED_DLQ_ARN > /app/build/cancelled_dlq_arn.txt

# Create main queues, each with its own redrive policy pointing to its own DLQ
aws --endpoint-url=$ENDPOINT sqs create-queue \
  --queue-name orders-created \
  --attributes "{\"KmsMasterKeyId\":\"$KEY_ARN\",\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$CREATED_DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"5\\\"}\"}" \
  --query 'QueueUrl' --output text

aws --endpoint-url=$ENDPOINT sqs create-queue \
  --queue-name orders-cancelled \
  --attributes "{\"KmsMasterKeyId\":\"$KEY_ARN\",\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$CANCELLED_DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"5\\\"}\"}" \
  --query 'QueueUrl' --output text

CREATED_URL=$(aws --endpoint-url=$ENDPOINT sqs get-queue-url --queue-name orders-created --query 'QueueUrl' --output text)
CANCELLED_URL=$(aws --endpoint-url=$ENDPOINT sqs get-queue-url --queue-name orders-cancelled --query 'QueueUrl' --output text)
CREATED_ARN=$(aws --endpoint-url=$ENDPOINT sqs get-queue-attributes --queue-url $CREATED_URL --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
CANCELLED_ARN=$(aws --endpoint-url=$ENDPOINT sqs get-queue-attributes --queue-url $CANCELLED_URL --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)

echo $CREATED_URL > /app/build/created_url.txt
echo $CANCELLED_URL > /app/build/cancelled_url.txt
echo $CREATED_ARN > /app/build/created_arn.txt
echo $CANCELLED_ARN > /app/build/cancelled_arn.txt

echo "Queues created"
echo "  CREATED ARN: $CREATED_ARN"
echo "  CANCELLED ARN: $CANCELLED_ARN"
output
bus: arn:aws:events:us-east-1:000000000000:event-bus/order-events
http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created-dlq
http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled-dlq
http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created
http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled
Queues created
  CREATED ARN: arn:aws:sqs:us-east-1:000000000000:orders-created
  CANCELLED ARN: arn:aws:sqs:us-east-1:000000000000:orders-cancelled

[stdout]
bus: arn:aws:events:us-east-1:000000000000:event-bus/order-events
http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created-dlq
http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled-dlq
http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created
http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled
Queues created
  CREATED ARN: arn:aws:sqs:us-east-1:000000000000:orders-created
  CANCELLED ARN: arn:aws:sqs:us-east-1:000000000000:orders-cancelled

Apply SQS queue policies allowing EventBridge to send messages

shell
export AWS_PAGER=""
export ENDPOINT=http://localstack:4566
CREATED_URL=$(cat /app/build/created_url.txt)
CANCELLED_URL=$(cat /app/build/cancelled_url.txt)
CREATED_ARN=$(cat /app/build/created_arn.txt)
CANCELLED_ARN=$(cat /app/build/cancelled_arn.txt)
BUS_ARN=$(cat /app/build/bus_arn.txt)

# Build queue policies allowing EventBridge to send
cat > /tmp/created-queue-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowEventBridgeSend",
    "Effect": "Allow",
    "Principal": {"Service": "events.amazonaws.com"},
    "Action": "sqs:SendMessage",
    "Resource": "$CREATED_ARN",
    "Condition": {"ArnEquals": {"aws:SourceArn": "arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created"}}
  }]
}
EOF

cat > /tmp/cancelled-queue-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowEventBridgeSend",
    "Effect": "Allow",
    "Principal": {"Service": "events.amazonaws.com"},
    "Action": "sqs:SendMessage",
    "Resource": "$CANCELLED_ARN",
    "Condition": {"ArnEquals": {"aws:SourceArn": "arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled"}}
  }]
}
EOF

POLICY_CREATED=$(jq -Rs . < /tmp/created-queue-policy.json)
POLICY_CANCELLED=$(jq -Rs . < /tmp/cancelled-queue-policy.json)

aws --endpoint-url=$ENDPOINT sqs set-queue-attributes \
  --queue-url $CREATED_URL \
  --attributes "{\"Policy\": $POLICY_CREATED}"

aws --endpoint-url=$ENDPOINT sqs set-queue-attributes \
  --queue-url $CANCELLED_URL \
  --attributes "{\"Policy\": $POLICY_CANCELLED}"

echo "queue policies set"
output
queue policies set

[stdout]
queue policies set
ToolSearch
{
  "query": "select:TodoWrite",
  "max_results": 1
}

, output, 
{"type": "tool_reference", "tool_name": "TodoWrite"}
  • Create KMS CMK and DDB tables
  • Create EventBridge bus and SQS queues with DLQs
  • Create EventBridge rules routing to queues
  • Create least-privilege IAM roles for both lambdas
  • Build and deploy order-api lambda with TransactWrite
  • Build and deploy outbox-relay lambda with idempotency and ReportBatchItemFailures
  • Verify end-to-end with both kinds

Create EventBridge rules

shell
export AWS_PAGER=""
export ENDPOINT=http://localstack:4566
CREATED_ARN=$(cat /app/build/created_arn.txt)
CANCELLED_ARN=$(cat /app/build/cancelled_arn.txt)

# Rule for OrderCreated
aws --endpoint-url=$ENDPOINT 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

aws --endpoint-url=$ENDPOINT events put-targets \
  --rule on-order-created \
  --event-bus-name order-events \
  --targets "Id=created-q,Arn=$CREATED_ARN"

# Rule for OrderCancelled
aws --endpoint-url=$ENDPOINT 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

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

echo "rules created"
aws --endpoint-url=$ENDPOINT events list-rules --event-bus-name order-events --query 'Rules[].[Name,State]' --output text
output
arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created
{
    "FailedEntryCount": 0,
    "FailedEntries": []
}
arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled
{
    "FailedEntryCount": 0,
    "FailedEntries": []
}
rules created
on-order-created	ENABLED
on-order-cancelled	ENABLED

[stdout]
arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created
{
    "FailedEntryCount": 0,
    "FailedEntries": []
}
arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled
{
    "FailedEntryCount": 0,
    "FailedEntries": []
}
rules created
on-order-created	ENABLED
on-order-cancelled	ENABLED
Now let me build the IAM roles with least-privilege policies.

Create IAM roles with least-privilege inline policies

shell
export AWS_PAGER=""
export ENDPOINT=http://localstack:4566
KEY_ARN=$(cat /app/build/key_arn.txt)
BUS_ARN=$(cat /app/build/bus_arn.txt)
STREAM_ARN=$(cat /app/build/outbox_stream_arn.txt)
ORDERS_ARN="arn:aws:dynamodb:us-east-1:000000000000:table/orders"
OUTBOX_ARN="arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
DEDUP_ARN="arn:aws:dynamodb:us-east-1:000000000000:table/processed_events"

# Trust policy for lambda
cat > /app/policies/lambda-trust.json <<'EOF'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"Service": "lambda.amazonaws.com"},
    "Action": "sts:AssumeRole"
  }]
}
EOF

# order-api policy: only orders + outbox tables (TransactWriteItems), only its CMK for those tables, only basic logs
cat > /app/policies/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": "TransactionalWrite",
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:GetItem"],
      "Resource": ["$ORDERS_ARN", "$OUTBOX_ARN"]
    },
    {
      "Sid": "Transact",
      "Effect": "Allow",
      "Action": ["dynamodb:TransactWriteItems"],
      "Resource": ["$ORDERS_ARN", "$OUTBOX_ARN"]
    },
    {
      "Sid": "KmsForOrdersAndOutbox",
      "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 policy: read outbox stream, write dedup table, putevents to bus, kms for those, logs
cat > /app/policies/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": "$STREAM_ARN"
    },
    {
      "Sid": "DedupTable",
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
      "Resource": "$DEDUP_ARN"
    },
    {
      "Sid": "PublishToBus",
      "Effect": "Allow",
      "Action": "events:PutEvents",
      "Resource": "$BUS_ARN"
    },
    {
      "Sid": "KmsForRelay",
      "Effect": "Allow",
      "Action": ["kms:Encrypt", "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"
          ]
        }
      }
    }
  ]
}
EOF

# Create roles
aws --endpoint-url=$ENDPOINT 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.txt

aws --endpoint-url=$ENDPOINT 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.txt

# Attach inline policies
aws --endpoint-url=$ENDPOINT iam put-role-policy \
  --role-name order-api-role \
  --policy-name order-api-inline \
  --policy-document file:///app/policies/order-api-policy.json

aws --endpoint-url=$ENDPOINT iam put-role-policy \
  --role-name outbox-relay-role \
  --policy-name outbox-relay-inline \
  --policy-document file:///app/policies/outbox-relay-policy.json

echo "order-api-role: $(cat /app/build/order_api_role_arn.txt)"
echo "outbox-relay-role: $(cat /app/build/outbox_relay_role_arn.txt)"
output
order-api-role: arn:aws:iam::000000000000:role/order-api-role
outbox-relay-role: arn:aws:iam::000000000000:role/outbox-relay-role

[stdout]
order-api-role: arn:aws:iam::000000000000:role/order-api-role
outbox-relay-role: arn:aws:iam::000000000000:role/outbox-relay-role
Now I need to add a KMS key policy that allows the lambda roles to use the CMK. Note that the IAM policies grant the lambdas permission to call KMS, but the key resource policy must also allow it.

Apply KMS key policy

shell
export AWS_PAGER=""
export ENDPOINT=http://localstack:4566
KEY_ID=$(cat /app/build/key_id.txt)

# Update the KMS key policy: allow root + the two lambda roles + dynamodb / events services
cat > /app/policies/key-policy.json <<'EOF'
{
  "Version": "2012-10-17",
  "Id": "outbox-cmk",
  "Statement": [
    {
      "Sid": "EnableRootAccount",
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::000000000000:root"},
      "Action": "kms:*",
      "Resource": "*"
    },
    {
      "Sid": "AllowOrderApiUse",
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::000000000000:role/order-api-role"},
      "Action": ["kms:Encrypt","kms:Decrypt","kms:GenerateDataKey","kms:DescribeKey"],
      "Resource": "*"
    },
    {
      "Sid": "AllowRelayUse",
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::000000000000:role/outbox-relay-role"},
      "Action": ["kms:Encrypt","kms:Decrypt","kms:GenerateDataKey","kms:DescribeKey"],
      "Resource": "*"
    },
    {
      "Sid": "AllowAwsServices",
      "Effect": "Allow",
      "Principal": {"Service": ["dynamodb.amazonaws.com","events.amazonaws.com","sqs.amazonaws.com","lambda.amazonaws.com"]},
      "Action": ["kms:Encrypt","kms:Decrypt","kms:GenerateDataKey","kms:DescribeKey","kms:CreateGrant"],
      "Resource": "*"
    }
  ]
}
EOF

aws --endpoint-url=$ENDPOINT kms put-key-policy \
  --key-id $KEY_ID \
  --policy-name default \
  --policy file:///app/policies/key-policy.json
echo "kms key policy applied"
output
kms key policy applied

[stdout]
kms key policy applied
Now the Lambda code. Let me write the order-api lambda first.
write · /app/lambdas/order-api/handler.py
import json
import os
import time
import uuid

import boto3

ENDPOINT = os.environ.get("AWS_ENDPOINT_URL") or os.environ.get("LOCALSTACK_ENDPOINT")
ddb = boto3.client("dynamodb", endpoint_url=ENDPOINT)

ALLOWED_KINDS = {"OrderCreated", "OrderCancelled"}


def lambda_handler(event, context):
    body = event
    if isinstance(event, dict) and "body" in event and isinstance(event["body"], str):
        body = json.loads(event["body"])

    order_id = body["order_id"]
    kind = body["kind"]
    if kind not in ALLOWED_KINDS:
        return {"statusCode": 400, "body": json.dumps({"error": f"unknown kind {kind}"})}

    payload = {k: v for k, v in body.items() if k not in ("order_id", "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(payload)},
    }

    outbox_item = {
        "event_id": {"S": event_id},
        "order_id": {"S": order_id},
        "kind": {"S": kind},
        "created_at": {"N": str(now)},
        "detail": {"S": json.dumps({"order_id": order_id, "kind": kind, **payload})},
    }

    ddb.transact_write_items(
        TransactItems=[
            {"Put": {"TableName": "orders", "Item": order_item}},
            {"Put": {"TableName": "outbox", "Item": outbox_item}},
        ]
    )

    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 os
import time

import boto3
from botocore.exceptions import ClientError

ENDPOINT = os.environ.get("AWS_ENDPOINT_URL") or os.environ.get("LOCALSTACK_ENDPOINT")
events = boto3.client("events", endpoint_url=ENDPOINT)
ddb = boto3.client("dynamodb", endpoint_url=ENDPOINT)

BUS_NAME = os.environ["BUS_NAME"]
DEDUP_TABLE = os.environ["DEDUP_TABLE"]
DEDUP_TTL_SECONDS = int(os.environ.get("DEDUP_TTL_SECONDS", "604800"))


def _claim(event_id):
    ttl = int(time.time()) + DEDUP_TTL_SECONDS
    try:
        ddb.put_item(
            TableName=DEDUP_TABLE,
            Item={"event_id": {"S": event_id}, "ttl": {"N": str(ttl)}},
            ConditionExpression="attribute_not_exists(event_id)",
        )
        return True
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return False
        raise


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

    for r in records:
        seq = r.get("dynamodb", {}).get("SequenceNumber")
        try:
            if r.get("eventName") != "INSERT":
                continue

            new_image = r["dynamodb"].get("NewImage") or {}
            event_id = new_image.get("event_id", {}).get("S")
            kind = new_image.get("kind", {}).get("S")
            detail_s = new_image.get("detail", {}).get("S", "{}")

            if not event_id or kind not in ("OrderCreated", "OrderCancelled"):
                continue

            if not _claim(event_id):
                continue

            events.put_events(
                Entries=[
                    {
                        "EventBusName": BUS_NAME,
                        "Source": "outbox.relay",
                        "DetailType": kind,
                        "Detail": detail_s,
                    }
                ]
            )
        except Exception as exc:  # noqa: BLE001
            print(f"failed record seq={seq}: {exc}")
            if seq:
                failures.append({"itemIdentifier": seq})

    return {"batchItemFailures": failures}

Package lambda zips

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/
output
total 64
drwxr-xr-x 2 root root 4096 Apr 28 14:27 .
drwxr-xr-x 1 root root 4096 Apr 28 14:25 ..
-rw-r--r-- 1 root root   61 Apr 28 14:26 bus_arn.txt
-rw-r--r-- 1 root root   52 Apr 28 14:26 cancelled_arn.txt
-rw-r--r-- 1 root root   56 Apr 28 14:26 cancelled_dlq_arn.txt
-rw-r--r-- 1 root root   83 Apr 28 14:26 cancelled_url.txt
-rw-r--r-- 1 root root   50 Apr 28 14:26 created_arn.txt
-rw-r--r-- 1 root root   54 Apr 28 14:26 created_dlq_arn.txt
-rw-r--r-- 1 root root   81 Apr 28 14:26 created_url.txt
-rw-r--r-- 1 root root   76 Apr 28 14:25 key_arn.txt
-rw-r--r-- 1 root root   37 Apr 28 14:25 key_id.txt
-rw-r--r-- 1 root root  790 Apr 28 14:27 order-api.zip
-rw-r--r-- 1 root root   46 Apr 28 14:27 order_api_role_arn.txt
-rw-r--r-- 1 root root 1018 Apr 28 14:27 outbox-relay.zip
-rw-r--r-- 1 root root   49 Apr 28 14:27 outbox_relay_role_arn.txt
-rw-r--r-- 1 root root   84 Apr 28 14:25 outbox_stream_arn.txt

[stdout]
total 64
drwxr-xr-x 2 root root 4096 Apr 28 14:27 .
drwxr-xr-x 1 root root 4096 Apr 28 14:25 ..
-rw-r--r-- 1 root root   61 Apr 28 14:26 bus_arn.txt
-rw-r--r-- 1 root root   52 Apr 28 14:26 cancelled_arn.txt
-rw-r--r-- 1 root root   56 Apr 28 14:26 cancelled_dlq_arn.txt
-rw-r--r-- 1 root root   83 Apr 28 14:26 cancelled_url.txt
-rw-r--r-- 1 root root   50 Apr 28 14:26 created_arn.txt
-rw-r--r-- 1 root root   54 Apr 28 14:26 created_dlq_arn.txt
-rw-r--r-- 1 root root   81 Apr 28 14:26 created_url.txt
-rw-r--r-- 1 root root   76 Apr 28 14:25 key_arn.txt
-rw-r--r-- 1 root root   37 Apr 28 14:25 key_id.txt
-rw-r--r-- 1 root root  790 Apr 28 14:27 order-api.zip
-rw-r--r-- 1 root root   46 Apr 28 14:27 order_api_role_arn.txt
-rw-r--r-- 1 root root 1018 Apr 28 14:27 outbox-relay.zip
-rw-r--r-- 1 root root   49 Apr 28 14:27 outbox_relay_role_arn.txt
-rw-r--r-- 1 root root   84 Apr 28 14:25 outbox_stream_arn.txt

Deploy both lambdas

shell
export AWS_PAGER=""
export ENDPOINT=http://localstack:4566
ORDER_API_ROLE=$(cat /app/build/order_api_role_arn.txt)
RELAY_ROLE=$(cat /app/build/outbox_relay_role_arn.txt)

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

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

# Wait for both
aws --endpoint-url=$ENDPOINT lambda wait function-active --function-name order-api
aws --endpoint-url=$ENDPOINT lambda wait function-active --function-name outbox-relay
echo "lambdas active"
output
arn:aws:lambda:us-east-1:000000000000:function:order-api
arn:aws:lambda:us-east-1:000000000000:function:outbox-relay
lambdas active

[stdout]
arn:aws:lambda:us-east-1:000000000000:function:order-api
arn:aws:lambda:us-east-1:000000000000:function:outbox-relay
lambdas active
  • Create KMS CMK and DDB tables
  • Create EventBridge bus and SQS queues with DLQs
  • Create EventBridge rules routing to queues
  • Create least-privilege IAM roles for both lambdas
  • Build and deploy order-api lambda with TransactWrite
  • Build and deploy outbox-relay lambda with idempotency and ReportBatchItemFailures
  • Wire event source mapping with ReportBatchItemFailures
  • Verify end-to-end with both kinds

Create event source mapping with ReportBatchItemFailures

shell
export AWS_PAGER=""
export ENDPOINT=http://localstack:4566
STREAM_ARN=$(cat /app/build/outbox_stream_arn.txt)

# Create event source mapping with FunctionResponseTypes for ReportBatchItemFailures
aws --endpoint-url=$ENDPOINT lambda create-event-source-mapping \
  --function-name outbox-relay \
  --event-source-arn $STREAM_ARN \
  --starting-position TRIM_HORIZON \
  --batch-size 10 \
  --function-response-types ReportBatchItemFailures \
  --maximum-retry-attempts 3 \
  --query '{UUID:UUID,FunctionResponseTypes:FunctionResponseTypes}'

# Wait for it to come up
sleep 3
aws --endpoint-url=$ENDPOINT lambda list-event-source-mappings \
  --function-name outbox-relay \
  --query 'EventSourceMappings[].[State,FunctionResponseTypes]' --output text
output
{
    "UUID": "d5229896-7237-49d2-a579-871fca9aa7bc",
    "FunctionResponseTypes": [
        "ReportBatchItemFailures"
    ]
}
Enabled
ReportBatchItemFailures

[stdout]
{
    "UUID": "d5229896-7237-49d2-a579-871fca9aa7bc",
    "FunctionResponseTypes": [
        "ReportBatchItemFailures"
    ]
}
Enabled
ReportBatchItemFailures
Now let me run an end-to-end verification.
  • Create KMS CMK and DDB tables
  • Create EventBridge bus and SQS queues with DLQs
  • Create EventBridge rules routing to queues
  • Create least-privilege IAM roles for both lambdas
  • Build and deploy order-api lambda with TransactWrite
  • Build and deploy outbox-relay lambda with idempotency and ReportBatchItemFailures
  • Wire event source mapping with ReportBatchItemFailures
  • Verify end-to-end with both kinds

Invoke order-api and verify queues

shell
export AWS_PAGER=""
export ENDPOINT=http://localstack:4566
CREATED_URL=$(cat /app/build/created_url.txt)
CANCELLED_URL=$(cat /app/build/cancelled_url.txt)

# Invoke order-api with OrderCreated for o-1
aws --endpoint-url=$ENDPOINT lambda invoke \
  --function-name order-api \
  --cli-binary-format raw-in-base64-out \
  --payload '{"order_id":"o-1","kind":"OrderCreated","amount":42,"currency":"USD"}' \
  /tmp/inv1.json
cat /tmp/inv1.json; echo

# Wait for stream + relay + eventbridge + sqs delivery
echo "waiting 12s for event propagation..."
sleep 12

# Check both queues
echo "== orders-created =="
aws --endpoint-url=$ENDPOINT sqs get-queue-attributes --queue-url $CREATED_URL \
  --attribute-names ApproximateNumberOfMessages --query 'Attributes' --output json

echo "== orders-cancelled =="
aws --endpoint-url=$ENDPOINT sqs get-queue-attributes --queue-url $CANCELLED_URL \
  --attribute-names ApproximateNumberOfMessages --query 'Attributes' --output json
output
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
{"statusCode": 200, "body": "{\"order_id\": \"o-1\", \"event_id\": \"51ca6d2a-d77d-45e4-87bf-4129be6ece96\", \"kind\": \"OrderCreated\"}"}
waiting 12s for event propagation...
== orders-created ==
{
    "ApproximateNumberOfMessages": "1"
}
== orders-cancelled ==
{
    "ApproximateNumberOfMessages": "0"
}

[stdout]
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
{"statusCode": 200, "body": "{\"order_id\": \"o-1\", \"event_id\": \"51ca6d2a-d77d-45e4-87bf-4129be6ece96\", \"kind\": \"OrderCreated\"}"}
waiting 12s for event propagation...
== orders-created ==
{
    "ApproximateNumberOfMessages": "1"
}
== orders-cancelled ==
{
    "ApproximateNumberOfMessages": "0"
}
OrderCreated routed correctly. Let me check the message body and verify the dedup row, then test OrderCancelled.

Verify OrderCreated message and test OrderCancelled

shell
export AWS_PAGER=""
export ENDPOINT=http://localstack:4566
CREATED_URL=$(cat /app/build/created_url.txt)
CANCELLED_URL=$(cat /app/build/cancelled_url.txt)

# Peek at the queued message (don't delete)
echo "== msg in orders-created =="
aws --endpoint-url=$ENDPOINT sqs receive-message --queue-url $CREATED_URL --visibility-timeout 0 --max-number-of-messages 1 --query 'Messages[].Body' --output text | jq .

echo "== processed_events row for o-1 event =="
EVENT_ID=$(echo '51ca6d2a-d77d-45e4-87bf-4129be6ece96')
aws --endpoint-url=$ENDPOINT dynamodb get-item \
  --table-name processed_events \
  --key "{\"event_id\":{\"S\":\"$EVENT_ID\"}}" --query 'Item' --output json

# Now invoke OrderCancelled with a different order_id
aws --endpoint-url=$ENDPOINT lambda invoke \
  --function-name order-api \
  --cli-binary-format raw-in-base64-out \
  --payload '{"order_id":"o-2","kind":"OrderCancelled","reason":"customer requested"}' \
  /tmp/inv2.json
cat /tmp/inv2.json; echo

sleep 12

echo "== orders-created =="
aws --endpoint-url=$ENDPOINT sqs get-queue-attributes --queue-url $CREATED_URL \
  --attribute-names ApproximateNumberOfMessages --query 'Attributes' --output json
echo "== orders-cancelled =="
aws --endpoint-url=$ENDPOINT sqs get-queue-attributes --queue-url $CANCELLED_URL \
  --attribute-names ApproximateNumberOfMessages --query 'Attributes' --output json

echo "== msg in orders-cancelled =="
aws --endpoint-url=$ENDPOINT sqs receive-message --queue-url $CANCELLED_URL --visibility-timeout 0 --max-number-of-messages 1 --query 'Messages[].Body' --output text | jq .
output
== msg in orders-created ==
{
  "version": "0",
  "id": "22a09908-db69-4e6d-a665-c3fd83dcc7e5",
  "detail-type": "OrderCreated",
  "source": "outbox.relay",
  "account": "000000000000",
  "time": "2026-04-28T14:28:36Z",
  "region": "us-east-1",
  "resources": [],
  "detail": {
    "order_id": "o-1",
    "kind": "OrderCreated",
    "amount": 42,
    "currency": "USD"
  }
}
== processed_events row for o-1 event ==
{
    "ttl": {
        "N": "1777991316"
    },
    "event_id": {
        "S": "51ca6d2a-d77d-45e4-87bf-4129be6ece96"
    }
}
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
{"statusCode": 200, "body": "{\"order_id\": \"o-2\", \"event_id\": \"1ddf7e89-a471-4d89-99b2-a70c0d1c5ab0\", \"kind\": \"OrderCancelled\"}"}
== orders-created ==
{
    "ApproximateNumberOfMessages": "1"
}
== orders-cancelled ==
{
    "ApproximateNumberOfMessages": "1"
}
== msg in orders-cancelled ==
{
  "version": "0",
  "id": "c6c4abed-dc32-4328-96b0-4ce1839ae515",
  "detail-type": "OrderCancelled",
  "source": "outbox.relay",
  "account": "000000000000",
  "time": "2026-04-28T14:29:03Z",
  "region": "us-east-1",
  "resources": [],
  "detail": {
    "order_id": "o-2",
    "kind": "OrderCancelled",
    "reason": "customer requested"
  }
}

[stdout]
== msg in orders-created ==
{
  "version": "0",
  "id": "22a09908-db69-4e6d-a665-c3fd83dcc7e5",
  "detail-type": "OrderCreated",
  "source": "outbox.relay",
  "account": "000000000000",
  "time": "2026-04-28T14:28:36Z",
  "region": "us-east-1",
  "resources": [],
  "detail": {
    "order_id": "o-1",
    "kind": "OrderCreated",
    "amount": 42,
    "currency": "USD"
  }
}
== processed_events row for o-1 event ==
{
    "ttl": {
        "N": "1777991316"
    },
    "event_id": {
        "S": "51ca6d2a-d77d-45e4-87bf-4129be6ece96"
    }
}
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
{"statusCode": 200, "body": "{\"order_id\": \"o-2\", \"event_id\": \"1ddf7e89-a471-4d89-99b2-a70c0d1c5ab0\", \"kind\": \"OrderCancelled\"}"}
== orders-created ==
{
    "ApproximateNumberOfMessages": "1"
}
== orders-cancelled ==
{
    "ApproximateNumberOfMessages": "1"
}
== msg in orders-cancelled ==
{
  "version": "0",
  "id": "c6c4abed-dc32-4328-96b0-4ce1839ae515",
  "detail-type": "OrderCancelled",
  "source": "outbox.relay",
  "account": "000000000000",
  "time": "2026-04-28T14:29:03Z",
  "region": "us-east-1",
  "resources": [],
  "detail": {
    "order_id": "o-2",
    "kind": "OrderCancelled",
    "reason": "customer requested"
  }
}
End-to-end works. Let me print a final summary of the deployed resources and policy shape.

Print final resource summary and policy docs

shell
export AWS_PAGER=""
export ENDPOINT=http://localstack:4566

echo "======= RESOURCES ======="
aws --endpoint-url=$ENDPOINT kms list-aliases --query "Aliases[?AliasName=='alias/outbox-cmk'].[AliasName,TargetKeyId]" --output text
aws --endpoint-url=$ENDPOINT dynamodb list-tables --query 'TableNames' --output text
aws --endpoint-url=$ENDPOINT events list-event-buses --query "EventBuses[?Name=='order-events'].Name" --output text
aws --endpoint-url=$ENDPOINT events list-rules --event-bus-name order-events --query 'Rules[].Name' --output text
aws --endpoint-url=$ENDPOINT sqs list-queues --query 'QueueUrls' --output text
aws --endpoint-url=$ENDPOINT lambda list-functions --query 'Functions[].FunctionName' --output text
aws --endpoint-url=$ENDPOINT iam list-roles --query "Roles[?starts_with(RoleName,'order-') || starts_with(RoleName,'outbox-')].RoleName" --output text

echo "======= STREAM SETTINGS ======="
aws --endpoint-url=$ENDPOINT dynamodb describe-table --table-name outbox \
  --query 'Table.StreamSpecification' --output json

echo "======= ESM SETTINGS ======="
aws --endpoint-url=$ENDPOINT lambda list-event-source-mappings --function-name outbox-relay \
  --query 'EventSourceMappings[].{State:State,FunctionResponseTypes:FunctionResponseTypes,EventSource:EventSourceArn}' --output json

echo "======= ORDER-API POLICY ======="
aws --endpoint-url=$ENDPOINT iam get-role-policy --role-name order-api-role --policy-name order-api-inline --query 'PolicyDocument' --output json

echo "======= RELAY POLICY ======="
aws --endpoint-url=$ENDPOINT iam get-role-policy --role-name outbox-relay-role --policy-name outbox-relay-inline --query 'PolicyDocument' --output json
output
======= RESOURCES =======
alias/outbox-cmk	a5ae8978-f0fd-4af3-a3fc-80c5235b27fa
orders	outbox	processed_events
order-events
on-order-created	on-order-cancelled
http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created-dlq	http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled-dlq	http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created	http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled
order-api	outbox-relay
outbox-relay-role	order-api-role
======= STREAM SETTINGS =======
{
    "StreamEnabled": true,
    "StreamViewType": "NEW_AND_OLD_IMAGES"
}
======= ESM SETTINGS =======
[
    {
        "State": "Enabled",
        "FunctionResponseTypes": [
            "ReportBatchItemFailures"
        ],
        "EventSource": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:25:37.799"
    }
]
======= ORDER-API 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": "TransactionalWrite",
            "Effect": "Allow",
            "Action": [
                "dynamodb:PutItem",
                "dynamodb:UpdateItem",
                "dynamodb:GetItem"
            ],
            "Resource": [
                "arn:aws:dynamodb:us-east-1:000000000000:table/orders",
                "arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
            ]
        },
        {
            "Sid": "Transact",
            "Effect": "Allow",
            "Action": [
                "dynamodb:TransactWriteItems"
            ],
            "Resource": [
                "arn:aws:dynamodb:us-east-1:000000000000:table/orders",
                "arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
            ]
        },
        {
            "Sid": "KmsForOrdersAndOutbox",
            "Effect": "Allow",
            "Action": [
                "kms:Encrypt",
                "kms:Decrypt",
                "kms:GenerateDataKey",
                "kms:DescribeKey"
            ],
            "Resource": "arn:aws:kms:us-east-1:000000000000:key/a5ae8978-f0fd-4af3-a3fc-80c5235b27fa",
            "Condition": {
                "StringEquals": {
                    "kms:ViaService": "dynamodb.us-east-1.amazonaws.com"
                }
            }
        }
    ]
}
======= RELAY 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:25:37.799"
        },
        {
            "Sid": "DedupTable",
            "Effect": "Allow",
            "Action": [
                "dynamodb:PutItem",
                "dynamodb:GetItem"
            ],
            "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": "KmsForRelay",
            "Effect": "Allow",
            "Action": [
                "kms:Encrypt",
                "kms:Decrypt",
                "kms:GenerateDataKey",
                "kms:DescribeKey"
            ],
            "Resource": "arn:aws:kms:us-east-1:000000000000:key/a5ae8978-f0fd-4af3-a3fc-80c5235b27fa",
            "Condition": {
                "StringEquals": {
                    "kms:ViaService": [
                        "dynamodb.us-east-1.amazonaws.com",
                        "events.us-east-1.amazonaws.com"
                    ]
                }
            }
        }
    ]
}

[stdout]
======= RESOURCES =======
alias/outbox-cmk	a5ae8978-f0fd-4af3-a3fc-80c5235b27fa
orders	outbox	processed_events
order-events
on-order-created	on-order-cancelled
http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created-dlq	http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled-dlq	http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created	http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled
order-api	outbox-relay
outbox-relay-role	order-api-role
======= STREAM SETTINGS =======
{
    "StreamEnabled": true,
    "StreamViewType": "NEW_AND_OLD_IMAGES"
}
======= ESM SETTINGS =======
[
    {
        "State": "Enabled",
        "FunctionResponseTypes": [
            "ReportBatchItemFailures"
        ],
        "EventSource": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:25:37.799"
    }
]
======= ORDER-API 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": "TransactionalWrite",
            "Effect": "Allow",
            "Action": [
                "dynamodb:PutItem",
                "dynamodb:UpdateItem",
                "dynamodb:GetItem"
            ],
            "Resource": [
                "arn:aws:dynamodb:us-east-1:000000000000:table/orders",
                "arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
            ]
        },
        {
            "Sid": "Transact",
            "Effect": "Allow",
            "Action": [
                "dynamodb:TransactWriteItems"
            ],
            "Resource": [
                "arn:aws:dynamodb:us-east-1:000000000000:table/orders",
                "arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
            ]
        },
        {
            "Sid": "KmsForOrdersAndOutbox",
            "Effect": "Allow",
            "Action": [
                "kms:Encrypt",
                "kms:Decrypt",
                "kms:GenerateDataKey",
                "kms:DescribeKey"
            ],
            "Resource": "arn:aws:kms:us-east-1:000000000000:key/a5ae8978-f0fd-4af3-a3fc-80c5235b27fa",
            "Condition": {
                "StringEquals": {
                    "kms:ViaService": "dynamodb.us-east-1.amazonaws.com"
                }
            }
        }
    ]
}
======= RELAY 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:25:37.799"
        },
        {
            "Sid": "DedupTable",
            "Effect": "Allow",
            "Action": [
                "dynamodb:PutItem",
                "dynamodb:GetItem"
            ],
            "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": "KmsForRelay",
            "Effect": "Allow",
            "Action": [
                "kms:Encrypt",
                "kms:Decrypt",
                "kms:GenerateDataKey",
                "kms:DescribeKey"
            ],
            "Resource": "arn:aws:kms:us-east-1:000000000000:key/a5ae8978-f0fd-4af3-a3fc-80c5235b27fa",
            "Condition": {
                "StringEquals": {
                    "kms:ViaService": [
                        "dynamodb.us-east-1.amazonaws.com",
                        "events.us-east-1.amazonaws.com"
                    ]
                }
            }
        }
    ]
}
  • Create KMS CMK and DDB tables
  • Create EventBridge bus and SQS queues with DLQs
  • Create EventBridge rules routing to queues
  • Create least-privilege IAM roles for both lambdas
  • Build and deploy order-api lambda with TransactWrite
  • Build and deploy outbox-relay lambda with idempotency and ReportBatchItemFailures
  • Wire event source mapping with ReportBatchItemFailures
  • Verify end-to-end with both kinds
Done. Outbox is live on LocalStack and verified end-to-end. **End-to-end run:** - `order-api({order_id:"o-1", kind:"OrderCreated", ...})` → `orders-created` got 1 message, `orders-cancelled` got 0, `processed_events` has the event_id row with TTL. - `order-api({order_id:"o-2", kind:"OrderCancelled", ...})` → `orders-cancelled` got 1 message, `orders-created` stayed at 1 (no spillover). **How the pieces fit:** - `order-api` writes orders + outbox in a single `TransactWriteItems` , half-state is impossible. Generates `event_id` (UUID) and stores the EventBridge `detail` JSON pre-serialized on the outbox row, so the relay reads the same id off the stream. - `outbox` table stream is `NEW_AND_OLD_IMAGES`. Event source mapping has `FunctionResponseTypes=ReportBatchItemFailures`; relay returns `{"batchItemFailures":[{"itemIdentifier": <SequenceNumber>}, ...]}` only for records it failed to republish. - Relay idempotency: conditional `PutItem` on `processed_events` with `attribute_not_exists(event_id)`. Already-claimed → skip. TTL attribute `ttl` set to 7d so the table self-prunes. - Two rules on `order-events` filter by `source=outbox.relay` + `detail-type=OrderCreated|OrderCancelled` and target their dedicated queue. Each main queue has its own DLQ via `RedrivePolicy` (no shared DLQ). **Least-privilege shape (verifier-readable):** - `order-api-role`: DDB on `orders` + `outbox` only (incl. `TransactWriteItems`); KMS scoped to the CMK ARN with `kms:ViaService = dynamodb.us-east-1.amazonaws.com`. No SQS, no events, no stream perms. - `outbox-relay-role`: stream perms scoped to the **outbox** stream ARN only; `events:PutEvents` scoped to the `order-events` bus ARN; DDB on `processed_events` only; KMS scoped to the CMK with `kms:ViaService` limited to dynamodb + events. No access to the `orders` table, no SQS, no `*` on KMS. - KMS key resource policy explicitly grants the two role ARNs, plus AWS service principals (`dynamodb`, `events`, `sqs`, `lambda`) needed for transparent encryption. Everything uses the customer-managed `alias/outbox-cmk` (orders/outbox/processed_events SSE, both queues + both DLQs, and the event bus).
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/5n5j6otN-n7Sh_-4AwrjP/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/5n5j6otN-n7Sh_-4AwrjP/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 21.99s =======================

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

Trial trial_ce7b35ada36b4c4a · verifier authoritative; classifier explanatory.