tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.Check environment
ls /app && echo "---" && which aws python3 jq zip && echo "---" && aws --version && python3 --version
--- /usr/local/bin/aws /opt/venv/bin/python3 /usr/bin/jq /usr/bin/zip --- aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24 Python 3.12.3 [stdout] --- /usr/local/bin/aws /opt/venv/bin/python3 /usr/bin/jq /usr/bin/zip --- aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24 Python 3.12.3
Verify localstack connectivity
export AWS_ENDPOINT_URL=http://localstack:4566 && aws --endpoint-url=http://localstack:4566 sts get-caller-identity && echo "---" && aws --endpoint-url=http://localstack:4566 dynamodb list-tables
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
---
{
"TableNames": []
}
[stdout]
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
---
{
"TableNames": []
}Check existing resources
aws --endpoint-url=http://localstack:4566 stepfunctions list-state-machines && echo "---" && aws --endpoint-url=http://localstack:4566 lambda list-functions --query 'Functions[].FunctionName'
{
"stateMachines": []
}
---
[]
[stdout]
{
"stateMachines": []
}
---
[]Set up directory structure
mkdir -p /app/lambdas /app/policies /app/build && ls /app
build lambdas policies [stdout] build lambdas policies
import time
import boto3
from botocore.exceptions import ClientError
ddb = boto3.client("dynamodb")
def handler(event, context):
sku = event["sku"]
qty = int(event["qty"])
key = event["idempotency_key"]
op_key = f"{key}#reserve"
try:
ddb.put_item(
TableName="saga-idempotency",
Item={
"idempotency_key": {"S": op_key},
"ttl": {"N": str(int(time.time()) + 86400)},
},
ConditionExpression="attribute_not_exists(idempotency_key)",
)
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
print(f"reserve-inventory: already done for {op_key}, skipping")
return {**event, "reserved": True, "qty_reserved": qty}
raise
ddb.update_item(
TableName="inventory",
Key={"sku": {"S": sku}},
UpdateExpression="ADD reserved_qty :q",
ExpressionAttributeValues={":q": {"N": str(qty)}},
)
print(f"reserve-inventory: reserved {qty} of {sku}")
return {**event, "reserved": True, "qty_reserved": qty}
import time
import boto3
from botocore.exceptions import ClientError
ddb = boto3.client("dynamodb")
sm = boto3.client("secretsmanager")
def handler(event, context):
if event.get("force_failure"):
raise RuntimeError("forced failure for compensation test")
order_id = event["order_id"]
amount = event["amount"]
key = event["idempotency_key"]
op_key = f"{key}#charge"
payment_id = f"pay-{order_id}"
try:
ddb.put_item(
TableName="saga-idempotency",
Item={
"idempotency_key": {"S": op_key},
"ttl": {"N": str(int(time.time()) + 86400)},
},
ConditionExpression="attribute_not_exists(idempotency_key)",
)
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
print(f"charge-payment: already done for {op_key}, skipping")
return {**event, "payment_id": payment_id, "charged": True}
raise
secret = sm.get_secret_value(SecretId="payment/gateway-apikey")
api_key = secret["SecretString"]
print(f"charge-payment: read gateway api key (len={len(api_key)}) and called fake gateway")
ddb.put_item(
TableName="payments",
Item={
"payment_id": {"S": payment_id},
"order_id": {"S": order_id},
"amount": {"N": str(amount)},
"status": {"S": "CAPTURED"},
},
)
print(f"charge-payment: captured {amount} for {payment_id}")
return {**event, "payment_id": payment_id, "charged": True}
import time
import boto3
from botocore.exceptions import ClientError
ddb = boto3.client("dynamodb")
def handler(event, context):
order_id = event["order_id"]
key = event["idempotency_key"]
op_key = f"{key}#ship"
shipment_id = f"ship-{order_id}"
try:
ddb.put_item(
TableName="saga-idempotency",
Item={
"idempotency_key": {"S": op_key},
"ttl": {"N": str(int(time.time()) + 86400)},
},
ConditionExpression="attribute_not_exists(idempotency_key)",
)
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
print(f"create-shipment: already done for {op_key}, skipping")
return {**event, "shipment_id": shipment_id, "shipped": True}
raise
ddb.put_item(
TableName="shipments",
Item={
"shipment_id": {"S": shipment_id},
"order_id": {"S": order_id},
"status": {"S": "CREATED"},
},
)
print(f"create-shipment: created {shipment_id}")
return {**event, "shipment_id": shipment_id, "shipped": True}
import time
import boto3
from botocore.exceptions import ClientError
ddb = boto3.client("dynamodb")
def handler(event, context):
sku = event["sku"]
qty = int(event["qty"])
key = event["idempotency_key"]
op_key = f"{key}#release"
try:
ddb.put_item(
TableName="saga-idempotency",
Item={
"idempotency_key": {"S": op_key},
"ttl": {"N": str(int(time.time()) + 86400)},
},
ConditionExpression="attribute_not_exists(idempotency_key)",
)
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
print(f"release-inventory: already done for {op_key}, skipping")
return {**event, "released": True}
raise
ddb.update_item(
TableName="inventory",
Key={"sku": {"S": sku}},
UpdateExpression="ADD reserved_qty :q",
ExpressionAttributeValues={":q": {"N": str(-qty)}},
)
print(f"release-inventory: released {qty} of {sku}")
return {**event, "released": True}
import time
import boto3
from botocore.exceptions import ClientError
ddb = boto3.client("dynamodb")
def handler(event, context):
order_id = event["order_id"]
key = event["idempotency_key"]
op_key = f"{key}#refund"
payment_id = f"pay-{order_id}"
try:
ddb.put_item(
TableName="saga-idempotency",
Item={
"idempotency_key": {"S": op_key},
"ttl": {"N": str(int(time.time()) + 86400)},
},
ConditionExpression="attribute_not_exists(idempotency_key)",
)
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
print(f"refund-payment: already done for {op_key}, skipping")
return {**event, "refunded": True}
raise
try:
ddb.update_item(
TableName="payments",
Key={"payment_id": {"S": payment_id}},
UpdateExpression="SET #s = :r",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={":r": {"S": "REFUNDED"}},
ConditionExpression="attribute_exists(payment_id)",
)
print(f"refund-payment: refunded {payment_id}")
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
print(f"refund-payment: no payment row for {payment_id} (no-op, charge never wrote)")
else:
raise
return {**event, "refunded": True}
{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
def handler(event, context):
detail_type = event.get("detail-type", "Unknown")
detail = event.get("detail", {})
print(f"saga-terminal:{detail_type} order_id={detail.get('order_id')} status={detail.get('status')}")
return {"ok": True}
{
"Comment": "Order saga: reserve inventory -> charge payment -> create shipment, with compensations in reverse order on failure.",
"StartAt": "ReserveInventory",
"States": {
"ReserveInventory": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "reserve-inventory",
"Payload.$": "$"
},
"ResultSelector": {"out.$": "$.Payload"},
"ResultPath": "$.reserve",
"Catch": [
{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.error",
"Next": "PublishFailedNoComp"
}
],
"Next": "ChargePayment"
},
"ChargePayment": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "charge-payment",
"Payload.$": "$"
},
"ResultSelector": {"out.$": "$.Payload"},
"ResultPath": "$.charge",
"Catch": [
{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.error",
"Next": "RefundAfterChargeFail"
}
],
"Next": "CreateShipment"
},
"CreateShipment": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "create-shipment",
"Payload.$": "$"
},
"ResultSelector": {"out.$": "$.Payload"},
"ResultPath": "$.ship",
"Catch": [
{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.error",
"Next": "RefundAfterShipFail"
}
],
"Next": "PublishCompleted"
},
"RefundAfterShipFail": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "refund-payment",
"Payload.$": "$"
},
"ResultPath": "$.refundComp",
"Next": "ReleaseAfterShipFail"
},
"ReleaseAfterShipFail": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "release-inventory",
"Payload.$": "$"
},
"ResultPath": "$.releaseComp",
"Next": "PublishFailed"
},
"RefundAfterChargeFail": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "refund-payment",
"Payload.$": "$"
},
"ResultPath": "$.refundComp",
"Next": "ReleaseAfterChargeFail"
},
"ReleaseAfterChargeFail": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "release-inventory",
"Payload.$": "$"
},
"ResultPath": "$.releaseComp",
"Next": "PublishFailed"
},
"PublishCompleted": {
"Type": "Task",
"Resource": "arn:aws:states:::events:putEvents",
"Parameters": {
"Entries": [
{
"Source": "order.saga",
"DetailType": "OrderCompleted",
"EventBusName": "saga-events",
"Detail": {
"order_id.$": "$.order_id",
"status": "completed"
}
}
]
},
"End": true
},
"PublishFailed": {
"Type": "Task",
"Resource": "arn:aws:states:::events:putEvents",
"Parameters": {
"Entries": [
{
"Source": "order.saga",
"DetailType": "OrderFailed",
"EventBusName": "saga-events",
"Detail": {
"order_id.$": "$.order_id",
"status": "failed-compensated"
}
}
]
},
"End": true
},
"PublishFailedNoComp": {
"Type": "Task",
"Resource": "arn:aws:states:::events:putEvents",
"Parameters": {
"Entries": [
{
"Source": "order.saga",
"DetailType": "OrderFailed",
"EventBusName": "saga-events",
"Detail": {
"order_id.$": "$.order_id",
"status": "failed-no-comp"
}
}
]
},
"End": true
}
}
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "states.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "InventoryWrite",
"Effect": "Allow",
"Action": ["dynamodb:UpdateItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/inventory"
},
{
"Sid": "IdempotencyWrite",
"Effect": "Allow",
"Action": ["dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/saga-idempotency"
},
{
"Sid": "Logs",
"Effect": "Allow",
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:us-east-1:000000000000:*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "InventoryWrite",
"Effect": "Allow",
"Action": ["dynamodb:UpdateItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/inventory"
},
{
"Sid": "IdempotencyWrite",
"Effect": "Allow",
"Action": ["dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/saga-idempotency"
},
{
"Sid": "Logs",
"Effect": "Allow",
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:us-east-1:000000000000:*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PaymentsWrite",
"Effect": "Allow",
"Action": ["dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/payments"
},
{
"Sid": "IdempotencyWrite",
"Effect": "Allow",
"Action": ["dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/saga-idempotency"
},
{
"Sid": "ReadGatewaySecret",
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "__SECRET_ARN__"
},
{
"Sid": "DecryptWithCmk",
"Effect": "Allow",
"Action": ["kms:Decrypt"],
"Resource": "__KMS_KEY_ARN__"
},
{
"Sid": "Logs",
"Effect": "Allow",
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:us-east-1:000000000000:*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PaymentsUpdate",
"Effect": "Allow",
"Action": ["dynamodb:UpdateItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/payments"
},
{
"Sid": "IdempotencyWrite",
"Effect": "Allow",
"Action": ["dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/saga-idempotency"
},
{
"Sid": "Logs",
"Effect": "Allow",
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:us-east-1:000000000000:*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ShipmentsWrite",
"Effect": "Allow",
"Action": ["dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/shipments"
},
{
"Sid": "IdempotencyWrite",
"Effect": "Allow",
"Action": ["dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/saga-idempotency"
},
{
"Sid": "Logs",
"Effect": "Allow",
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:us-east-1:000000000000:*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:us-east-1:000000000000:*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "InvokeSagaSteps",
"Effect": "Allow",
"Action": ["lambda:InvokeFunction"],
"Resource": [
"arn:aws:lambda:us-east-1:000000000000:function:reserve-inventory",
"arn:aws:lambda:us-east-1:000000000000:function:charge-payment",
"arn:aws:lambda:us-east-1:000000000000:function:create-shipment",
"arn:aws:lambda:us-east-1:000000000000:function:release-inventory",
"arn:aws:lambda:us-east-1:000000000000:function:refund-payment"
]
},
{
"Sid": "PublishSagaEvents",
"Effect": "Allow",
"Action": ["events:PutEvents"],
"Resource": "arn:aws:events:us-east-1:000000000000:event-bus/saga-events"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "OnlyChargePaymentReadsSecret",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:role/charge-payment-role"},
"Action": "secretsmanager:GetSecretValue",
"Resource": "*"
}
]
}
#!/usr/bin/env bash
set -euo pipefail
ENDPOINT="http://localstack:4566"
AWS="aws --endpoint-url=$ENDPOINT --region us-east-1"
ACCOUNT="000000000000"
REGION="us-east-1"
cd /app
echo "==> 1. KMS customer-managed key + alias"
KEY_META=$($AWS kms list-aliases --query "Aliases[?AliasName=='alias/saga-cmk'].TargetKeyId" --output text)
if [ -z "$KEY_META" ] || [ "$KEY_META" = "None" ]; then
KEY_ID=$($AWS kms create-key --description "Saga CMK for payment gateway secret" --query 'KeyMetadata.KeyId' --output text)
$AWS kms create-alias --alias-name alias/saga-cmk --target-key-id "$KEY_ID"
else
KEY_ID="$KEY_META"
fi
KEY_ARN="arn:aws:kms:${REGION}:${ACCOUNT}:key/${KEY_ID}"
echo " key_arn=$KEY_ARN"
echo "==> 2. Secret encrypted with CMK"
if ! $AWS secretsmanager describe-secret --secret-id payment/gateway-apikey >/dev/null 2>&1; then
$AWS secretsmanager create-secret \
--name payment/gateway-apikey \
--secret-string "fake-gateway-key-$(date +%s)" \
--kms-key-id "alias/saga-cmk" >/dev/null
else
$AWS secretsmanager update-secret \
--secret-id payment/gateway-apikey \
--kms-key-id "alias/saga-cmk" >/dev/null
fi
SECRET_ARN=$($AWS secretsmanager describe-secret --secret-id payment/gateway-apikey --query ARN --output text)
echo " secret_arn=$SECRET_ARN"
echo "==> 3. DynamoDB tables"
create_table() {
local name="$1" pk="$2"
if ! $AWS dynamodb describe-table --table-name "$name" >/dev/null 2>&1; then
$AWS dynamodb create-table \
--table-name "$name" \
--attribute-definitions AttributeName=$pk,AttributeType=S \
--key-schema AttributeName=$pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
echo " created $name"
else
echo " $name already exists"
fi
}
create_table inventory sku
create_table payments payment_id
create_table shipments shipment_id
create_table saga-idempotency idempotency_key
# Enable TTL on idempotency
TTL_STATUS=$($AWS dynamodb describe-time-to-live --table-name saga-idempotency --query 'TimeToLiveDescription.TimeToLiveStatus' --output text 2>/dev/null || echo "DISABLED")
if [ "$TTL_STATUS" != "ENABLED" ]; then
$AWS dynamodb update-time-to-live \
--table-name saga-idempotency \
--time-to-live-specification "Enabled=true,AttributeName=ttl" >/dev/null
echo " enabled TTL on saga-idempotency"
fi
echo "==> 4. IAM roles + inline policies"
ensure_role() {
local role="$1" trust_file="$2"
if ! $AWS iam get-role --role-name "$role" >/dev/null 2>&1; then
$AWS iam create-role --role-name "$role" --assume-role-policy-document "file://$trust_file" >/dev/null
echo " created role $role"
fi
}
attach_inline() {
local role="$1" policy_name="$2" policy_file="$3"
$AWS iam put-role-policy --role-name "$role" --policy-name "$policy_name" --policy-document "file://$policy_file" >/dev/null
}
# Render charge-payment policy with secret + kms ARNs
mkdir -p /app/build
sed -e "s|__SECRET_ARN__|${SECRET_ARN}|g" \
-e "s|__KMS_KEY_ARN__|${KEY_ARN}|g" \
/app/policies/charge-payment-policy.json > /app/build/charge-payment-policy.rendered.json
ensure_role reserve-inventory-role /app/policies/trust-lambda.json
ensure_role charge-payment-role /app/policies/trust-lambda.json
ensure_role create-shipment-role /app/policies/trust-lambda.json
ensure_role release-inventory-role /app/policies/trust-lambda.json
ensure_role refund-payment-role /app/policies/trust-lambda.json
ensure_role saga-notifier-role /app/policies/trust-lambda.json
ensure_role order-saga-role /app/policies/trust-states.json
attach_inline reserve-inventory-role reserve-inventory-policy /app/policies/reserve-inventory-policy.json
attach_inline charge-payment-role charge-payment-policy /app/build/charge-payment-policy.rendered.json
attach_inline create-shipment-role create-shipment-policy /app/policies/create-shipment-policy.json
attach_inline release-inventory-role release-inventory-policy /app/policies/release-inventory-policy.json
attach_inline refund-payment-role refund-payment-policy /app/policies/refund-payment-policy.json
attach_inline saga-notifier-role saga-notifier-policy /app/policies/saga-notifier-policy.json
attach_inline order-saga-role order-saga-policy /app/policies/order-saga-policy.json
echo "==> 5. Resource policy on secret (only charge-payment-role can read)"
$AWS secretsmanager put-resource-policy \
--secret-id payment/gateway-apikey \
--resource-policy "file:///app/policies/secret-policy.json" >/dev/null
echo "==> 6. Package + create lambdas"
package_lambda() {
local src="$1" zip="$2"
( cd /app/lambdas && zip -q -j "/app/build/${zip}" "${src}" )
}
package_lambda reserve_inventory.py reserve_inventory.zip
package_lambda charge_payment.py charge_payment.zip
package_lambda create_shipment.py create_shipment.zip
package_lambda release_inventory.py release_inventory.zip
package_lambda refund_payment.py refund_payment.zip
package_lambda saga_notifier.py saga_notifier.zip
create_or_update_lambda() {
local name="$1" handler="$2" role="$3" zip="$4"
local role_arn="arn:aws:iam::${ACCOUNT}:role/${role}"
if $AWS lambda get-function --function-name "$name" >/dev/null 2>&1; then
$AWS lambda update-function-code --function-name "$name" --zip-file "fileb:///app/build/${zip}" >/dev/null
else
$AWS lambda create-function \
--function-name "$name" \
--runtime python3.11 \
--handler "$handler" \
--role "$role_arn" \
--zip-file "fileb:///app/build/${zip}" \
--timeout 30 >/dev/null
echo " created lambda $name"
fi
# wait for active
for _ in $(seq 1 30); do
state=$($AWS lambda get-function --function-name "$name" --query 'Configuration.State' --output text 2>/dev/null || echo "Pending")
[ "$state" = "Active" ] && break
sleep 1
done
}
create_or_update_lambda reserve-inventory reserve_inventory.handler reserve-inventory-role reserve_inventory.zip
create_or_update_lambda charge-payment charge_payment.handler charge-payment-role charge_payment.zip
create_or_update_lambda create-shipment create_shipment.handler create-shipment-role create_shipment.zip
create_or_update_lambda release-inventory release_inventory.handler release-inventory-role release_inventory.zip
create_or_update_lambda refund-payment refund_payment.handler refund-payment-role refund_payment.zip
create_or_update_lambda saga-notifier saga_notifier.handler saga-notifier-role saga_notifier.zip
echo "==> 7. EventBridge custom bus + rule + target"
if ! $AWS events describe-event-bus --name saga-events >/dev/null 2>&1; then
$AWS events create-event-bus --name saga-events >/dev/null
echo " created event bus saga-events"
fi
$AWS events put-rule \
--name on-saga-terminal \
--event-bus-name saga-events \
--event-pattern '{"source":["order.saga"],"detail-type":["OrderCompleted","OrderFailed"]}' \
--state ENABLED >/dev/null
RULE_ARN=$($AWS events describe-rule --name on-saga-terminal --event-bus-name saga-events --query Arn --output text)
NOTIFIER_ARN="arn:aws:lambda:${REGION}:${ACCOUNT}:function:saga-notifier"
# Allow EventBridge to invoke the notifier
$AWS lambda remove-permission --function-name saga-notifier --statement-id allow-eventbridge >/dev/null 2>&1 || true
$AWS lambda add-permission \
--function-name saga-notifier \
--statement-id allow-eventbridge \
--action lambda:InvokeFunction \
--principal events.amazonaws.com \
--source-arn "$RULE_ARN" >/dev/null
$AWS events put-targets \
--rule on-saga-terminal \
--event-bus-name saga-events \
--targets "Id=1,Arn=${NOTIFIER_ARN}" >/dev/null
echo "==> 8. State machine"
SM_ROLE_ARN="arn:aws:iam::${ACCOUNT}:role/order-saga-role"
SM_ARN="arn:aws:states:${REGION}:${ACCOUNT}:stateMachine:order-saga"
if $AWS stepfunctions describe-state-machine --state-machine-arn "$SM_ARN" >/dev/null 2>&1; then
$AWS stepfunctions update-state-machine \
--state-machine-arn "$SM_ARN" \
--definition "file:///app/state-machine.json" \
--role-arn "$SM_ROLE_ARN" >/dev/null
else
$AWS stepfunctions create-state-machine \
--name order-saga \
--type STANDARD \
--definition "file:///app/state-machine.json" \
--role-arn "$SM_ROLE_ARN" >/dev/null
echo " created state machine order-saga"
fi
echo "==> deploy complete"
Run deploy script
chmod +x /app/deploy.sh && bash /app/deploy.sh
==> 1. KMS customer-managed key + alias
key_arn=arn:aws:kms:us-east-1:000000000000:key/b8faf67c-f83a-494a-b022-93f6e5812215
==> 2. Secret encrypted with CMK
secret_arn=arn:aws:secretsmanager:us-east-1:000000000000:secret:payment/gateway-apikey-Aquutq
==> 3. DynamoDB tables
created inventory
created payments
created shipments
created saga-idempotency
enabled TTL on saga-idempotency
==> 4. IAM roles + inline policies
created role reserve-inventory-role
created role charge-payment-role
created role create-shipment-role
created role release-inventory-role
created role refund-payment-role
created role saga-notifier-role
created role order-saga-role
==> 5. Resource policy on secret (only charge-payment-role can read)
==> 6. Package + create lambdas
created lambda reserve-inventory
created lambda charge-payment
created lambda create-shipment
created lambda release-inventory
created lambda refund-payment
created lambda saga-notifier
==> 7. EventBridge custom bus + rule + target
created event bus saga-events
==> 8. State machine
created state machine order-saga
==> deploy complete
[stdout]
==> 1. KMS customer-managed key + alias
key_arn=arn:aws:kms:us-east-1:000000000000:key/b8faf67c-f83a-494a-b022-93f6e5812215
==> 2. Secret encrypted with CMK
secret_arn=arn:aws:secretsmanager:us-east-1:000000000000:secret:payment/gateway-apikey-Aquutq
==> 3. DynamoDB tables
created inventory
created payments
created shipments
created saga-idempotency
enabled TTL on saga-idempotency
==> 4. IAM roles + inline policies
created role reserve-inventory-role
created role charge-payment-role
created role create-shipment-role
created role release-inventory-role
created role refund-payment-role
created role saga-notifier-role
created role order-saga-role
==> 5. Resource policy on secret (only charge-payment-role can read)
==> 6. Package + create lambdas
created lambda reserve-inventory
created lambda charge-payment
created lambda create-shipment
created lambda release-inventory
created lambda refund-payment
created lambda saga-notifier
==> 7. EventBridge custom bus + rule + target
created event bus saga-events
==> 8. State machine
created state machine order-saga
==> deploy completeStart happy path execution and wait
AWS="aws --endpoint-url=http://localstack:4566 --region us-east-1" && \
EXEC_ARN=$($AWS stepfunctions start-execution \
--state-machine-arn arn:aws:states:us-east-1:000000000000:stateMachine:order-saga \
--name happy-1-$(date +%s) \
--input '{"order_id":"o-1","sku":"x","qty":2,"amount":100,"idempotency_key":"k-1"}' \
--query executionArn --output text) && echo "exec=$EXEC_ARN" && \
for i in $(seq 1 30); do
STATUS=$($AWS stepfunctions describe-execution --execution-arn "$EXEC_ARN" --query status --output text)
echo " iter=$i status=$STATUS"
[ "$STATUS" != "RUNNING" ] && break
sleep 2
done && \
$AWS stepfunctions describe-execution --execution-arn "$EXEC_ARN" --query 'status' --output textexec=arn:aws:states:us-east-1:000000000000:execution:order-saga:happy-1-1776914590 iter=1 status=RUNNING iter=2 status=RUNNING iter=3 status=SUCCEEDED SUCCEEDED [stdout] exec=arn:aws:states:us-east-1:000000000000:execution:order-saga:happy-1-1776914590 iter=1 status=RUNNING iter=2 status=RUNNING iter=3 status=SUCCEEDED SUCCEEDED
Inspect DDB rows after happy path
AWS="aws --endpoint-url=http://localstack:4566 --region us-east-1" && \
echo "--- inventory[x]" && $AWS dynamodb get-item --table-name inventory --key '{"sku":{"S":"x"}}' && \
echo "--- payments[pay-o-1]" && $AWS dynamodb get-item --table-name payments --key '{"payment_id":{"S":"pay-o-1"}}' && \
echo "--- shipments[ship-o-1]" && $AWS dynamodb get-item --table-name shipments --key '{"shipment_id":{"S":"ship-o-1"}}'--- inventory[x]
{
"Item": {
"sku": {
"S": "x"
},
"reserved_qty": {
"N": "2"
}
}
}
--- payments[pay-o-1]
{
"Item": {
"amount": {
"N": "100"
},
"order_id": {
"S": "o-1"
},
"payment_id": {
"S": "pay-o-1"
},
"status": {
"S": "CAPTURED"
}
}
}
--- shipments[ship-o-1]
{
"Item": {
"shipment_id": {
"S": "ship-o-1"
},
"order_id": {
"S": "o-1"
},
"status": {
"S": "CREATED"
}
}
}
[stdout]
--- inventory[x]
{
"Item": {
"sku": {
"S": "x"
},
"reserved_qty": {
"N": "2"
}
}
}
--- payments[pay-o-1]
{
"Item": {
"amount": {
"N": "100"
},
"order_id": {
"S": "o-1"
},
"payment_id": {
"S": "pay-o-1"
},
"status": {
"S": "CAPTURED"
}
}
}
--- shipments[ship-o-1]
{
"Item": {
"shipment_id": {
"S": "ship-o-1"
},
"order_id": {
"S": "o-1"
},
"status": {
"S": "CREATED"
}
}
}Check notifier logs for saga-terminal
AWS="aws --endpoint-url=http://localstack:4566 --region us-east-1" && \ LOG_GROUP="/aws/lambda/saga-notifier" && \ sleep 3 && \ STREAMS=$($AWS logs describe-log-streams --log-group-name "$LOG_GROUP" --order-by LastEventTime --descending --max-items 5 --query 'logStreams[].logStreamName' --output text 2>/dev/null) && \ echo "streams: $STREAMS" && \ for s in $STREAMS; do echo "--- $s" $AWS logs get-log-events --log-group-name "$LOG_GROUP" --log-stream-name "$s" --query 'events[].message' --output text done
streams: 2026/04/23/[$LATEST]64187fb6544ebe61c0d957bf3e8e17f8 --- 2026/04/23/[$LATEST]64187fb6544ebe61c0d957bf3e8e17f8 START RequestId: 2186f5c2-d223-4fd5-a647-339407aca54b Version: $LATEST saga-terminal:OrderCompleted order_id=o-1 status=completed END RequestId: 2186f5c2-d223-4fd5-a647-339407aca54b REPORT RequestId: 2186f5c2-d223-4fd5-a647-339407aca54b Duration: 9.75 ms Billed Duration: 10 ms Memory Size: 128 MB Max Memory Used: 128 MB [stdout] streams: 2026/04/23/[$LATEST]64187fb6544ebe61c0d957bf3e8e17f8 --- 2026/04/23/[$LATEST]64187fb6544ebe61c0d957bf3e8e17f8 START RequestId: 2186f5c2-d223-4fd5-a647-339407aca54b Version: $LATEST saga-terminal:OrderCompleted order_id=o-1 status=completed END RequestId: 2186f5c2-d223-4fd5-a647-339407aca54b REPORT RequestId: 2186f5c2-d223-4fd5-a647-339407aca54b Duration: 9.75 ms Billed Duration: 10 ms Memory Size: 128 MB Max Memory Used: 128 MB
Failure path execution + DDB inspection
AWS="aws --endpoint-url=http://localstack:4566 --region us-east-1" && \
EXEC_ARN=$($AWS stepfunctions start-execution \
--state-machine-arn arn:aws:states:us-east-1:000000000000:stateMachine:order-saga \
--name fail-1-$(date +%s) \
--input '{"order_id":"o-2","sku":"x","qty":2,"amount":100,"idempotency_key":"k-2","force_failure":true}' \
--query executionArn --output text) && echo "exec=$EXEC_ARN" && \
for i in $(seq 1 30); do
STATUS=$($AWS stepfunctions describe-execution --execution-arn "$EXEC_ARN" --query status --output text)
echo " iter=$i status=$STATUS"
[ "$STATUS" != "RUNNING" ] && break
sleep 2
done && \
echo "--- final status:" && \
$AWS stepfunctions describe-execution --execution-arn "$EXEC_ARN" --query 'status' --output text && \
echo "--- inventory[x]" && $AWS dynamodb get-item --table-name inventory --key '{"sku":{"S":"x"}}' && \
echo "--- payments[pay-o-2]" && $AWS dynamodb get-item --table-name payments --key '{"payment_id":{"S":"pay-o-2"}}' && \
echo "--- shipments[ship-o-2]" && $AWS dynamodb get-item --table-name shipments --key '{"shipment_id":{"S":"ship-o-2"}}'exec=arn:aws:states:us-east-1:000000000000:execution:order-saga:fail-1-1776914635
iter=1 status=RUNNING
iter=2 status=RUNNING
iter=3 status=SUCCEEDED
--- final status:
SUCCEEDED
--- inventory[x]
{
"Item": {
"sku": {
"S": "x"
},
"reserved_qty": {
"N": "2"
}
}
}
--- payments[pay-o-2]
--- shipments[ship-o-2]
[stdout]
exec=arn:aws:states:us-east-1:000000000000:execution:order-saga:fail-1-1776914635
iter=1 status=RUNNING
iter=2 status=RUNNING
iter=3 status=SUCCEEDED
--- final status:
SUCCEEDED
--- inventory[x]
{
"Item": {
"sku": {
"S": "x"
},
"reserved_qty": {
"N": "2"
}
}
}
--- payments[pay-o-2]
--- shipments[ship-o-2]Confirm compensations + notifier OrderFailed
AWS="aws --endpoint-url=http://localstack:4566 --region us-east-1" && \ echo "--- saga-idempotency rows (compensation evidence)" && \ $AWS dynamodb scan --table-name saga-idempotency --query 'Items[].idempotency_key.S' --output text | tr '\t' '\n' | sort && \ echo "--- notifier log (most recent stream)" && \ sleep 2 && \ LATEST=$($AWS logs describe-log-streams --log-group-name /aws/lambda/saga-notifier --order-by LastEventTime --descending --max-items 1 --query 'logStreams[0].logStreamName' --output text) && \ echo "stream=$LATEST" && \ $AWS logs get-log-events --log-group-name /aws/lambda/saga-notifier --log-stream-name "$LATEST" --query 'events[].message' --output text
Exit code 254 --- saga-idempotency rows (compensation evidence) k-1#charge k-1#reserve k-1#ship k-2#refund k-2#release k-2#reserve --- notifier log (most recent stream) stream=2026/04/23/[$LATEST]08c5a4a80de9e3c75f4bbf1c9bba74e1 None aws: [ERROR]: An error occurred (ResourceNotFoundException) when calling the GetLogEvents operation: The specified log stream does not exist. [error] tool reported failure
Filter all saga-terminal log lines
AWS="aws --endpoint-url=http://localstack:4566 --region us-east-1" && \ $AWS logs filter-log-events --log-group-name /aws/lambda/saga-notifier --filter-pattern '"saga-terminal"' --query 'events[].message' --output text
START RequestId: 2186f5c2-d223-4fd5-a647-339407aca54b Version: $LATEST saga-terminal:OrderCompleted order_id=o-1 status=completed END RequestId: 2186f5c2-d223-4fd5-a647-339407aca54b REPORT RequestId: 2186f5c2-d223-4fd5-a647-339407aca54b Duration: 9.75 ms Billed Duration: 10 ms Memory Size: 128 MB Max Memory Used: 128 MB START RequestId: 36adb276-3bf0-4338-8640-a004fbba6548 Version: $LATEST saga-terminal:OrderFailed order_id=o-2 status=failed-compensated END RequestId: 36adb276-3bf0-4338-8640-a004fbba6548 REPORT RequestId: 36adb276-3bf0-4338-8640-a004fbba6548 Duration: 8.23 ms Billed Duration: 9 ms Memory Size: 128 MB Max Memory Used: 128 MB [stdout] START RequestId: 2186f5c2-d223-4fd5-a647-339407aca54b Version: $LATEST saga-terminal:OrderCompleted order_id=o-1 status=completed END RequestId: 2186f5c2-d223-4fd5-a647-339407aca54b REPORT RequestId: 2186f5c2-d223-4fd5-a647-339407aca54b Duration: 9.75 ms Billed Duration: 10 ms Memory Size: 128 MB Max Memory Used: 128 MB START RequestId: 36adb276-3bf0-4338-8640-a004fbba6548 Version: $LATEST saga-terminal:OrderFailed order_id=o-2 status=failed-compensated END RequestId: 36adb276-3bf0-4338-8640-a004fbba6548 REPORT RequestId: 36adb276-3bf0-4338-8640-a004fbba6548 Duration: 8.23 ms Billed Duration: 9 ms Memory Size: 128 MB Max Memory Used: 128 MB
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/i98WC4T_QG83EuHR8KJzn/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_and_secret_exist PASSED [ 5%]
../tests/test_outputs.py::test_02_all_four_tables_exist PASSED [ 10%]
../tests/test_outputs.py::test_03_all_six_lambdas_exist PASSED [ 15%]
../tests/test_outputs.py::test_04_state_machine_and_bus_exist PASSED [ 20%]
../tests/test_outputs.py::test_05_terminal_rule_exists_on_saga_bus PASSED [ 25%]
../tests/test_outputs.py::test_06_every_forward_state_has_catch_to_compensation PASSED [ 30%]
../tests/test_outputs.py::test_07_compensations_run_in_lifo_order PASSED [ 35%]
../tests/test_outputs.py::test_08_compensation_states_preserve_error_via_result_path PASSED [ 40%]
../tests/test_outputs.py::test_09_idempotency_table_has_ttl_and_correct_pk PASSED [ 45%]
../tests/test_outputs.py::test_10_each_lambda_role_scoped_to_its_own_table PASSED [ 50%]
../tests/test_outputs.py::test_11_only_charge_role_reads_the_payment_secret PASSED [ 55%]
../tests/test_outputs.py::test_12_saga_role_lambda_invoke_not_wildcarded PASSED [ 60%]
../tests/test_outputs.py::test_13_rule_pattern_matches_both_terminal_types_with_array_wrap PASSED [ 65%]
../tests/test_outputs.py::test_14_charge_role_kms_decrypt_scoped_not_wildcard PASSED [ 70%]
../tests/test_outputs.py::test_15_rule_targets_notifier_lambda PASSED [ 75%]
../tests/test_outputs.py::test_16_notifier_role_is_least_privilege_no_ddb_kms_secrets PASSED [ 80%]
../tests/test_outputs.py::test_17_notifier_lambda_resource_policy_allows_events_with_source_arn PASSED [ 85%]
../tests/test_outputs.py::test_18_happy_path_completes_and_writes_all_three_tables PASSED [ 90%]
../tests/test_outputs.py::test_19_failure_path_triggers_compensation_and_rolls_back_inventory PASSED [ 95%]
../tests/test_outputs.py::test_20_idempotency_prevents_double_reservation PASSED [100%]
=============================== warnings summary ===============================
test_outputs.py: 132 warnings
/root/.cache/uv/archive-v0/i98WC4T_QG83EuHR8KJzn/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_and_secret_exist
PASSED ../tests/test_outputs.py::test_02_all_four_tables_exist
PASSED ../tests/test_outputs.py::test_03_all_six_lambdas_exist
PASSED ../tests/test_outputs.py::test_04_state_machine_and_bus_exist
PASSED ../tests/test_outputs.py::test_05_terminal_rule_exists_on_saga_bus
PASSED ../tests/test_outputs.py::test_06_every_forward_state_has_catch_to_compensation
PASSED ../tests/test_outputs.py::test_07_compensations_run_in_lifo_order
PASSED ../tests/test_outputs.py::test_08_compensation_states_preserve_error_via_result_path
PASSED ../tests/test_outputs.py::test_09_idempotency_table_has_ttl_and_correct_pk
PASSED ../tests/test_outputs.py::test_10_each_lambda_role_scoped_to_its_own_table
PASSED ../tests/test_outputs.py::test_11_only_charge_role_reads_the_payment_secret
PASSED ../tests/test_outputs.py::test_12_saga_role_lambda_invoke_not_wildcarded
PASSED ../tests/test_outputs.py::test_13_rule_pattern_matches_both_terminal_types_with_array_wrap
PASSED ../tests/test_outputs.py::test_14_charge_role_kms_decrypt_scoped_not_wildcard
PASSED ../tests/test_outputs.py::test_15_rule_targets_notifier_lambda
PASSED ../tests/test_outputs.py::test_16_notifier_role_is_least_privilege_no_ddb_kms_secrets
PASSED ../tests/test_outputs.py::test_17_notifier_lambda_resource_policy_allows_events_with_source_arn
PASSED ../tests/test_outputs.py::test_18_happy_path_completes_and_writes_all_three_tables
PASSED ../tests/test_outputs.py::test_19_failure_path_triggers_compensation_and_rolls_back_inventory
PASSED ../tests/test_outputs.py::test_20_idempotency_prevents_double_reservation
====================== 20 passed, 132 warnings in 21.82s =======================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_c00e2ff3ace84860. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_c00e2ff3ace84860 · verifier authoritative; classifier explanatory.