SyncValsverifier → artifact → classifier → verdict
SyncVals · Trajectory

sfn-saga-compensation-orchestrator

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: test_01-test_20 cover CMK/secret existence, all 4 tables/6 lambdas/state machine present, every forward state has Catch to compensation, compensations run in LIFO order, ResultPath preservation, idempotency table TTL enabled, per-lambda role scoping (no wildcard DDB/KMS), saga role lambda:Invoke not wildcarded, EventBridge rule pattern with array-wrapped detail-type, notifier role least-privilege, resource policy allows events source, happy path creates all 3 tables with correct data, failure path compensation rolls back inventory, idempotency prevents double reservation. Agent trajectory shows methodical implementation: lambdas with idempotent condition-expressions, IAM policies with specific table/key permissions, state machine definition with error handling and EventBridge integration.
Root causeAgent successfully implemented a complete, production-grade AWS Step Functions saga pattern with proper idempotency, compensation orchestration, security scoping, and event-driven notifications, meeting all explicit and implicit requirements from the instruction.md specification.
RecommendationN/A - task is fine
Trajectory
Tool-by-tool agent trajectory
36 tool calls · 5 tool types · 50 steps
so the order service keeps leaving garbage rows. inventory gets reserved, payment call times out, we retry, now inventory is double-reserved because the retry worked the second time. same deal when shipping fails after payment , we'd just charge the customer and leave them with no shipment. the ops slack is basically me apologizing at this point. we need a real saga. if any step fails, **undo** the earlier steps in reverse order. no half-states. step functions runs it, localstack , `http://localstack:4566`, creds already exported (`AWS_ACCESS_KEY_ID=test`, same for secret, region `us-east-1`). `aws`, `python3`, `boto3`, `jq`, `zip`. build from zero. shape of it: - a state machine runs three forward steps: **reserve inventory**, **charge payment**, **create shipment**. each step is its own lambda. each step writes to its own ddb table. - if any step fails, the saga fires compensations **in reverse order** of what actually ran. if reserve succeeded but charge failed, you refund (no-op, nothing was charged) and release. if charge succeeded but shipment failed, you refund payment and release inventory. if reserve failed, nothing to undo. - compensations are their own lambdas too. they must be idempotent , if state-function retries a compensation, running it twice should not corrupt the table. - each forward step gets an idempotency key from the input so a lambda retry inside a single step doesn't double-decrement inventory. - payment calls a "gateway" that needs an api key. store the key in secrets manager , customer-managed kms key encrypts the secret. the payment lambda reads the secret, nobody else should be able to. - on terminal state (completed or failed-compensated), the saga publishes an event to a custom event bus. a notifier lambda sits on that bus and writes one log line starting with `saga-terminal:`. to actually prove compensations work, the charge-payment lambda accepts a `force_failure` flag in its input , when true, it raises. the verifier uses this to drive a failure run and assert the state snaps back. done looks like this. two executions: **happy path** , `start-execution` with `{"order_id": "o-1", "sku": "x", "qty": 2, "amount": 100, "idempotency_key": "k-1"}`: - sm reaches `SUCCEEDED` within 60s - inventory row for sku `x` reflects `reserved_qty = 2` - payments row exists with status `CAPTURED` - shipments row exists - notifier log has `saga-terminal:OrderCompleted` **failure path** , same input but `"force_failure": true, "order_id": "o-2", "idempotency_key": "k-2"`: - sm still reaches `SUCCEEDED` (saga handles it gracefully, not a hard abort) - inventory reserved_qty back to 0 (compensation ran) - payments row is absent or `REFUNDED` (nothing was charged since charge failed, so a no-op refund is also ok) - shipments row does **not** exist - notifier log has `saga-terminal:OrderFailed` least privilege. the verifier reads policy docs directly. each lambda role only gets its own table. saga role can't wildcard on lambda invoke. payment role is the only one that reads the secret. notifier role has nothing but eventbridge + logs. no `*` on kms anywhere. names the verifier grabs by hand, use them exactly: | thing | name | |-|-| | kms alias | `alias/saga-cmk` | | secret | `payment/gateway-apikey` | | inventory table | `inventory` (pk `sku` / String) | | payments table | `payments` (pk `payment_id` / String) | | shipments table | `shipments` (pk `shipment_id` / String) | | idempotency table | `saga-idempotency` (pk `idempotency_key` / String, ttl enabled) | | reserve lambda | `reserve-inventory` | | charge lambda | `charge-payment` | | ship lambda | `create-shipment` | | release compensation | `release-inventory` | | refund compensation | `refund-payment` | | notifier lambda | `saga-notifier` | | reserve role | `reserve-inventory-role` | | charge role | `charge-payment-role` | | ship role | `create-shipment-role` | | release role | `release-inventory-role` | | refund role | `refund-payment-role` | | notifier role | `saga-notifier-role` | | state machine | `order-saga` | | state machine role | `order-saga-role` | | event bus | `saga-events` | | event rule | `on-saga-terminal` | eventbridge detail shape: source `order.saga`, detail-type is one of `OrderCompleted` or `OrderFailed`. the rule matches both and routes to the notifier lambda. python3.11 for all lambdas. localstack community 4.13.1. the "payment gateway" is fake , charge-payment reads the secret, logs that it did, and marks the payments row CAPTURED. no actual network call.

Inspect app directory and AWS env

shell
ls /app && echo "---" && env | grep -E "AWS|LOCALSTACK" | sort
output
---
AWS_ACCESS_KEY_ID=test
AWS_DEFAULT_REGION=us-east-1
AWS_ENDPOINT_URL=http://localstack:4566
AWS_PAGER=
AWS_SECRET_ACCESS_KEY=test

[stdout]
---
AWS_ACCESS_KEY_ID=test
AWS_DEFAULT_REGION=us-east-1
AWS_ENDPOINT_URL=http://localstack:4566
AWS_PAGER=
AWS_SECRET_ACCESS_KEY=test
ToolSearch
{
  "query": "select:TodoWrite",
  "max_results": 1
}

, output, 
{"type": "tool_reference", "tool_name": "TodoWrite"}

Check working dir and tools

shell
ls -la /app/ 2>&1; which aws python3 zip jq; aws --version; python3 --version
output
total 8
drwxr-xr-x 2 root root 4096 Apr 21 20:05 .
drwxr-xr-x 1 root root 4096 Apr 23 04:25 ..
/usr/local/bin/aws
/opt/venv/bin/python3
/usr/bin/zip
/usr/bin/jq
aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24
Python 3.12.3

[stdout]
total 8
drwxr-xr-x 2 root root 4096 Apr 21 20:05 .
drwxr-xr-x 1 root root 4096 Apr 23 04:25 ..
/usr/local/bin/aws
/opt/venv/bin/python3
/usr/bin/zip
/usr/bin/jq
aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24
Python 3.12.3

Verify localstack reachability

shell
aws --endpoint-url=$AWS_ENDPOINT_URL sts get-caller-identity; echo "---"; curl -s http://localstack:4566/_localstack/health | jq .
output
{
    "UserId": "AKIAIOSFODNN7EXAMPLE",
    "Account": "000000000000",
    "Arn": "arn:aws:iam::000000000000:root"
}
---
{
  "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": "available",
    "ses": "disabled",
    "sns": "disabled",
    "sqs": "available",
    "ssm": "disabled",
    "stepfunctions": "available",
    "sts": "running",
    "support": "disabled",
    "swf": "disabled",
    "transcribe": "disabled"
  },
  "edition": "community",
  "version": "4.13.1"
}

[stdout]
{
    "UserId": "AKIAIOSFODNN7EXAMPLE",
    "Account": "000000000000",
    "Arn": "arn:aws:iam::000000000000:root"
}
---
{
  "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": "available",
    "ses": "disabled",
    "sns": "disabled",
    "sqs": "available",
    "ssm": "disabled",
    "stepfunctions": "available",
    "sts": "running",
    "support": "disabled",
    "swf": "disabled",
    "transcribe": "disabled"
  },
  "edition": "community",
  "version": "4.13.1"
}
I'll build this step by step. Let me plan the pieces and start writing.
  • Create KMS CMK + alias + secret + DDB tables
  • Write 6 lambda handlers (idempotent)
  • Write least-privilege IAM policies + roles
  • Package and deploy lambdas
  • Event bus + rule + notifier wiring
  • State machine with saga compensations
  • Run happy path + failure path, verify

Create work directories

shell
mkdir -p /app/lambdas /app/policies /app/sm /app/build
output
(Bash completed with no output)
Writing lambda handlers first. Each forward step is guarded by the saga-idempotency table (condition-expression insert), and compensations are idempotent by construction.
write · /app/lambdas/reserve_inventory.py
import os, json, time, boto3
from botocore.exceptions import ClientError

ddb = boto3.resource("dynamodb")
inv = ddb.Table("inventory")
idem = ddb.Table("saga-idempotency")


def _idem_key(event):
    return f"{event['idempotency_key']}:reserve"


def handler(event, _ctx):
    key = _idem_key(event)
    try:
        idem.put_item(
            Item={"idempotency_key": key, "ttl": int(time.time()) + 86400, "status": "DONE"},
            ConditionExpression="attribute_not_exists(idempotency_key)",
        )
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {**event, "reserve": {"already_done": True}}
        raise

    inv.update_item(
        Key={"sku": event["sku"]},
        UpdateExpression="SET reserved_qty = if_not_exists(reserved_qty, :z) + :q",
        ExpressionAttributeValues={":q": event["qty"], ":z": 0},
    )
    return {**event, "reserve": {"sku": event["sku"], "qty": event["qty"]}}
write · /app/lambdas/charge_payment.py
import os, json, time, boto3
from botocore.exceptions import ClientError

ddb = boto3.resource("dynamodb")
payments = ddb.Table("payments")
idem = ddb.Table("saga-idempotency")
sm = boto3.client("secretsmanager")


def _idem_key(event):
    return f"{event['idempotency_key']}:charge"


def handler(event, _ctx):
    resp = sm.get_secret_value(SecretId="payment/gateway-apikey")
    api_key = resp["SecretString"]
    print(f"charge-payment: loaded gateway api key len={len(api_key)}")

    if event.get("force_failure"):
        raise RuntimeError("forced payment failure")

    key = _idem_key(event)
    try:
        idem.put_item(
            Item={"idempotency_key": key, "ttl": int(time.time()) + 86400, "status": "DONE"},
            ConditionExpression="attribute_not_exists(idempotency_key)",
        )
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {**event, "charge": {"already_done": True, "payment_id": f"pay-{event['order_id']}"}}
        raise

    payment_id = f"pay-{event['order_id']}"
    payments.put_item(
        Item={
            "payment_id": payment_id,
            "order_id": event["order_id"],
            "amount": event["amount"],
            "status": "CAPTURED",
        }
    )
    return {**event, "charge": {"payment_id": payment_id, "status": "CAPTURED"}}
write · /app/lambdas/create_shipment.py
import os, json, time, boto3
from botocore.exceptions import ClientError

ddb = boto3.resource("dynamodb")
shipments = ddb.Table("shipments")
idem = ddb.Table("saga-idempotency")


def _idem_key(event):
    return f"{event['idempotency_key']}:ship"


def handler(event, _ctx):
    key = _idem_key(event)
    try:
        idem.put_item(
            Item={"idempotency_key": key, "ttl": int(time.time()) + 86400, "status": "DONE"},
            ConditionExpression="attribute_not_exists(idempotency_key)",
        )
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {**event, "ship": {"already_done": True, "shipment_id": f"ship-{event['order_id']}"}}
        raise

    shipment_id = f"ship-{event['order_id']}"
    shipments.put_item(
        Item={
            "shipment_id": shipment_id,
            "order_id": event["order_id"],
            "sku": event["sku"],
            "qty": event["qty"],
            "status": "CREATED",
        }
    )
    return {**event, "ship": {"shipment_id": shipment_id, "status": "CREATED"}}
write · /app/lambdas/release_inventory.py
import os, json, time, boto3
from botocore.exceptions import ClientError

ddb = boto3.resource("dynamodb")
inv = ddb.Table("inventory")
idem = ddb.Table("saga-idempotency")


def _idem_key(event):
    return f"{event['idempotency_key']}:release"


def handler(event, _ctx):
    key = _idem_key(event)
    try:
        idem.put_item(
            Item={"idempotency_key": key, "ttl": int(time.time()) + 86400, "status": "DONE"},
            ConditionExpression="attribute_not_exists(idempotency_key)",
        )
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {**event, "release": {"already_done": True}}
        raise

    if not event.get("reserve") or event.get("reserve", {}).get("already_done"):
        pass

    inv.update_item(
        Key={"sku": event["sku"]},
        UpdateExpression="SET reserved_qty = if_not_exists(reserved_qty, :z) - :q",
        ExpressionAttributeValues={":q": event["qty"], ":z": 0},
    )
    return {**event, "release": {"sku": event["sku"], "qty": event["qty"]}}
write · /app/lambdas/refund_payment.py
import os, json, time, boto3
from botocore.exceptions import ClientError

ddb = boto3.resource("dynamodb")
payments = ddb.Table("payments")
idem = ddb.Table("saga-idempotency")


def _idem_key(event):
    return f"{event['idempotency_key']}:refund"


def handler(event, _ctx):
    key = _idem_key(event)
    try:
        idem.put_item(
            Item={"idempotency_key": key, "ttl": int(time.time()) + 86400, "status": "DONE"},
            ConditionExpression="attribute_not_exists(idempotency_key)",
        )
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {**event, "refund": {"already_done": True}}
        raise

    payment_id = f"pay-{event['order_id']}"
    try:
        payments.update_item(
            Key={"payment_id": payment_id},
            UpdateExpression="SET #s = :r",
            ConditionExpression="attribute_exists(payment_id)",
            ExpressionAttributeNames={"#s": "status"},
            ExpressionAttributeValues={":r": "REFUNDED"},
        )
        return {**event, "refund": {"payment_id": payment_id, "status": "REFUNDED"}}
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {**event, "refund": {"payment_id": payment_id, "noop": True}}
        raise
write · /app/lambdas/saga_notifier.py
import json


def handler(event, _ctx):
    detail_type = event.get("detail-type", "Unknown")
    print(f"saga-terminal:{detail_type} detail={json.dumps(event.get('detail', {}))}")
    return {"ok": True}
Now the IAM policies , each one scoped tight, no wildcards on kms or lambda invoke.
write · /app/policies/lambda-trust.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {"Service": "lambda.amazonaws.com"},
      "Action": "sts:AssumeRole"
    }
  ]
}
  • Create KMS CMK + alias + secret + DDB tables
  • Write 6 lambda handlers (idempotent)
  • Write least-privilege IAM policies + roles
  • Package and deploy lambdas
  • Event bus + rule + notifier wiring
  • State machine with saga compensations
  • Run happy path + failure path, verify
Policies use KMS ARN and Secret ARN which are generated at runtime , I'll write them as templates with `{{...}}` placeholders and substitute during deploy. That keeps the final policy docs on disk so the verifier can read them.
write · /app/policies/reserve-inventory-role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Logs",
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": [
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/reserve-inventory",
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/reserve-inventory:*"
      ]
    },
    {
      "Sid": "InventoryTable",
      "Effect": "Allow",
      "Action": ["dynamodb:UpdateItem", "dynamodb:GetItem", "dynamodb:PutItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/inventory"
    },
    {
      "Sid": "IdempotencyTable",
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/saga-idempotency"
    }
  ]
}
write · /app/policies/charge-payment-role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Logs",
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": [
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/charge-payment",
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/charge-payment:*"
      ]
    },
    {
      "Sid": "PaymentsTable",
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:UpdateItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/payments"
    },
    {
      "Sid": "IdempotencyTable",
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/saga-idempotency"
    },
    {
      "Sid": "ReadGatewaySecret",
      "Effect": "Allow",
      "Action": ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"],
      "Resource": "{{SECRET_ARN}}"
    },
    {
      "Sid": "DecryptSecretWithCmk",
      "Effect": "Allow",
      "Action": ["kms:Decrypt"],
      "Resource": "{{KMS_ARN}}"
    }
  ]
}
write · /app/policies/create-shipment-role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Logs",
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": [
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/create-shipment",
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/create-shipment:*"
      ]
    },
    {
      "Sid": "ShipmentsTable",
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/shipments"
    },
    {
      "Sid": "IdempotencyTable",
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/saga-idempotency"
    }
  ]
}
write · /app/policies/release-inventory-role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Logs",
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": [
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/release-inventory",
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/release-inventory:*"
      ]
    },
    {
      "Sid": "InventoryTable",
      "Effect": "Allow",
      "Action": ["dynamodb:UpdateItem", "dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/inventory"
    },
    {
      "Sid": "IdempotencyTable",
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/saga-idempotency"
    }
  ]
}
write · /app/policies/refund-payment-role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Logs",
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": [
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/refund-payment",
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/refund-payment:*"
      ]
    },
    {
      "Sid": "PaymentsTable",
      "Effect": "Allow",
      "Action": ["dynamodb:UpdateItem", "dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/payments"
    },
    {
      "Sid": "IdempotencyTable",
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/saga-idempotency"
    }
  ]
}
write · /app/policies/saga-notifier-role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Logs",
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": [
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/saga-notifier",
        "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/saga-notifier:*"
      ]
    }
  ]
}
write · /app/policies/order-saga-role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "InvokeSagaLambdas",
      "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": "PublishToSagaBus",
      "Effect": "Allow",
      "Action": "events:PutEvents",
      "Resource": "arn:aws:events:us-east-1:000000000000:event-bus/saga-events"
    }
  ]
}
write · /app/policies/states-trust.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {"Service": "states.amazonaws.com"},
      "Action": "sts:AssumeRole"
    }
  ]
}
Now the state machine. Forward steps use `Catch` to branch to the correct compensation starting point. Compensations run in reverse order.
write · /app/sm/order-saga.json
{
  "Comment": "Order saga: reserve -> charge -> ship. On failure, compensate in reverse.",
  "StartAt": "ReserveInventory",
  "States": {
    "ReserveInventory": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:000000000000:function:reserve-inventory",
      "ResultPath": "$.reserve_result",
      "OutputPath": "$.reserve_result",
      "Retry": [
        {
          "ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException"],
          "IntervalSeconds": 1,
          "MaxAttempts": 2,
          "BackoffRate": 2.0
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "ResultPath": "$.error",
          "Next": "PublishFailed"
        }
      ],
      "Next": "ChargePayment"
    },
    "ChargePayment": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:000000000000:function:charge-payment",
      "ResultPath": "$.charge_result",
      "OutputPath": "$.charge_result",
      "Retry": [
        {
          "ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException"],
          "IntervalSeconds": 1,
          "MaxAttempts": 2,
          "BackoffRate": 2.0
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "ResultPath": "$.error",
          "Next": "ReleaseInventory"
        }
      ],
      "Next": "CreateShipment"
    },
    "CreateShipment": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:000000000000:function:create-shipment",
      "ResultPath": "$.ship_result",
      "OutputPath": "$.ship_result",
      "Retry": [
        {
          "ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException"],
          "IntervalSeconds": 1,
          "MaxAttempts": 2,
          "BackoffRate": 2.0
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "ResultPath": "$.error",
          "Next": "RefundPayment"
        }
      ],
      "Next": "PublishCompleted"
    },
    "RefundPayment": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:000000000000:function:refund-payment",
      "ResultPath": "$.refund_result",
      "OutputPath": "$.refund_result",
      "Next": "ReleaseInventory"
    },
    "ReleaseInventory": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:000000000000:function:release-inventory",
      "ResultPath": "$.release_result",
      "OutputPath": "$.release_result",
      "Next": "PublishFailed"
    },
    "PublishCompleted": {
      "Type": "Task",
      "Resource": "arn:aws:states:::events:putEvents",
      "Parameters": {
        "Entries": [
          {
            "EventBusName": "saga-events",
            "Source": "order.saga",
            "DetailType": "OrderCompleted",
            "Detail": {
              "order_id.$": "$.order_id",
              "outcome": "completed"
            }
          }
        ]
      },
      "End": true
    },
    "PublishFailed": {
      "Type": "Task",
      "Resource": "arn:aws:states:::events:putEvents",
      "Parameters": {
        "Entries": [
          {
            "EventBusName": "saga-events",
            "Source": "order.saga",
            "DetailType": "OrderFailed",
            "Detail": {
              "order_id.$": "$.order_id",
              "outcome": "failed-compensated"
            }
          }
        ]
      },
      "End": true
    }
  }
}
  • Write lambda handlers, policies, state machine def
  • Write setup.sh and provision everything
  • Run happy path + failure path, verify
write · /app/setup.sh
#!/usr/bin/env bash
set -euo pipefail

AWSX="aws --endpoint-url=${AWS_ENDPOINT_URL}"
REGION="us-east-1"
ACCOUNT="000000000000"
ARN_PREFIX_LAMBDA="arn:aws:lambda:${REGION}:${ACCOUNT}:function"
BUS_NAME="saga-events"
BUS_ARN="arn:aws:events:${REGION}:${ACCOUNT}:event-bus/${BUS_NAME}"

cd /app
mkdir -p build/policies build/zips

echo "== KMS key + alias =="
if ! $AWSX kms describe-key --key-id alias/saga-cmk >/dev/null 2>&1; then
  KMS_KEY_ID=$($AWSX kms create-key --description "saga customer managed key" --query 'KeyMetadata.KeyId' --output text)
  $AWSX kms create-alias --alias-name alias/saga-cmk --target-key-id "$KMS_KEY_ID"
else
  KMS_KEY_ID=$($AWSX kms describe-key --key-id alias/saga-cmk --query 'KeyMetadata.KeyId' --output text)
fi
KMS_ARN="arn:aws:kms:${REGION}:${ACCOUNT}:key/${KMS_KEY_ID}"
echo "kms key: $KMS_ARN"

echo "== Secret =="
if ! $AWSX secretsmanager describe-secret --secret-id payment/gateway-apikey >/dev/null 2>&1; then
  $AWSX secretsmanager create-secret \
    --name payment/gateway-apikey \
    --kms-key-id "$KMS_ARN" \
    --secret-string "sk_test_FAKEGATEWAYKEY_ABC123" >/dev/null
fi
SECRET_ARN=$($AWSX secretsmanager describe-secret --secret-id payment/gateway-apikey --query 'ARN' --output text)
echo "secret: $SECRET_ARN"

echo "== DynamoDB tables =="
create_table() {
  local name="$1" pk="$2"
  if ! $AWSX dynamodb describe-table --table-name "$name" >/dev/null 2>&1; then
    $AWSX 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
  fi
}
create_table inventory sku
create_table payments payment_id
create_table shipments shipment_id
create_table saga-idempotency idempotency_key
$AWSX dynamodb update-time-to-live \
  --table-name saga-idempotency \
  --time-to-live-specification "Enabled=true, AttributeName=ttl" >/dev/null 2>&1 || true

echo "== Event bus =="
$AWSX events create-event-bus --name "$BUS_NAME" >/dev/null 2>&1 || true

echo "== Substitute policy templates =="
for f in policies/*.json; do
  out="build/$(basename "$f" .json).json"
  # skip trust policies
  case "$(basename "$f")" in
    lambda-trust.json|states-trust.json)
      cp "$f" "build/$(basename "$f")"; continue;;
  esac
  sed -e "s|{{KMS_ARN}}|${KMS_ARN}|g" -e "s|{{SECRET_ARN}}|${SECRET_ARN}|g" "$f" > "build/policies/$(basename "$f")"
done
cp policies/lambda-trust.json build/lambda-trust.json
cp policies/states-trust.json build/states-trust.json

echo "== IAM roles =="
create_role() {
  local role="$1" trust="$2" policy_doc="$3" policy_name="${role}-policy"
  if ! $AWSX iam get-role --role-name "$role" >/dev/null 2>&1; then
    $AWSX iam create-role --role-name "$role" --assume-role-policy-document "file://$trust" >/dev/null
  fi
  $AWSX iam put-role-policy --role-name "$role" --policy-name "$policy_name" --policy-document "file://$policy_doc" >/dev/null
}
create_role reserve-inventory-role  build/lambda-trust.json build/policies/reserve-inventory-role.json
create_role charge-payment-role     build/lambda-trust.json build/policies/charge-payment-role.json
create_role create-shipment-role    build/lambda-trust.json build/policies/create-shipment-role.json
create_role release-inventory-role  build/lambda-trust.json build/policies/release-inventory-role.json
create_role refund-payment-role     build/lambda-trust.json build/policies/refund-payment-role.json
create_role saga-notifier-role      build/lambda-trust.json build/policies/saga-notifier-role.json
create_role order-saga-role         build/states-trust.json build/policies/order-saga-role.json

role_arn() { echo "arn:aws:iam::${ACCOUNT}:role/$1"; }

echo "== Package lambdas =="
zip_lambda() {
  local src="$1" zipname="$2"
  (cd lambdas && zip -q -j "/app/build/zips/${zipname}.zip" "${src}")
}
zip_lambda reserve_inventory.py reserve-inventory
zip_lambda charge_payment.py    charge-payment
zip_lambda create_shipment.py   create-shipment
zip_lambda release_inventory.py release-inventory
zip_lambda refund_payment.py    refund-payment
zip_lambda saga_notifier.py     saga-notifier

echo "== Create lambdas =="
upsert_lambda() {
  local name="$1" handler="$2" role="$3" zip="$4"
  if $AWSX lambda get-function --function-name "$name" >/dev/null 2>&1; then
    $AWSX lambda update-function-code --function-name "$name" --zip-file "fileb://$zip" >/dev/null
  else
    $AWSX lambda create-function \
      --function-name "$name" \
      --runtime python3.11 \
      --role "$(role_arn "$role")" \
      --handler "$handler" \
      --zip-file "fileb://$zip" \
      --timeout 30 >/dev/null
  fi
  # wait until Active
  for i in 1 2 3 4 5 6 7 8 9 10; do
    state=$($AWSX lambda get-function --function-name "$name" --query 'Configuration.State' --output text 2>/dev/null || echo Pending)
    [ "$state" = "Active" ] && break
    sleep 1
  done
}
upsert_lambda reserve-inventory  reserve_inventory.handler reserve-inventory-role  build/zips/reserve-inventory.zip
upsert_lambda charge-payment     charge_payment.handler    charge-payment-role     build/zips/charge-payment.zip
upsert_lambda create-shipment    create_shipment.handler   create-shipment-role    build/zips/create-shipment.zip
upsert_lambda release-inventory  release_inventory.handler release-inventory-role  build/zips/release-inventory.zip
upsert_lambda refund-payment     refund_payment.handler    refund-payment-role     build/zips/refund-payment.zip
upsert_lambda saga-notifier      saga_notifier.handler     saga-notifier-role      build/zips/saga-notifier.zip

echo "== EventBridge rule -> notifier =="
$AWSX events put-rule \
  --name on-saga-terminal \
  --event-bus-name "$BUS_NAME" \
  --event-pattern '{"source":["order.saga"],"detail-type":["OrderCompleted","OrderFailed"]}' >/dev/null

$AWSX lambda add-permission \
  --function-name saga-notifier \
  --statement-id allow-eventbridge-invoke \
  --action lambda:InvokeFunction \
  --principal events.amazonaws.com \
  --source-arn "arn:aws:events:${REGION}:${ACCOUNT}:rule/${BUS_NAME}/on-saga-terminal" >/dev/null 2>&1 || true

$AWSX events put-targets \
  --rule on-saga-terminal \
  --event-bus-name "$BUS_NAME" \
  --targets "Id=notifier,Arn=${ARN_PREFIX_LAMBDA}:saga-notifier" >/dev/null

echo "== State machine =="
SM_NAME="order-saga"
SM_ROLE_ARN=$(role_arn order-saga-role)
SM_DEF=$(cat sm/order-saga.json)
EXISTING_SM_ARN=$($AWSX stepfunctions list-state-machines --query "stateMachines[?name=='${SM_NAME}'].stateMachineArn | [0]" --output text)
if [ "$EXISTING_SM_ARN" = "None" ] || [ -z "$EXISTING_SM_ARN" ]; then
  SM_ARN=$($AWSX stepfunctions create-state-machine \
    --name "$SM_NAME" \
    --role-arn "$SM_ROLE_ARN" \
    --definition "$SM_DEF" \
    --query 'stateMachineArn' --output text)
else
  SM_ARN="$EXISTING_SM_ARN"
  $AWSX stepfunctions update-state-machine \
    --state-machine-arn "$SM_ARN" \
    --definition "$SM_DEF" \
    --role-arn "$SM_ROLE_ARN" >/dev/null
fi
echo "state machine: $SM_ARN"

printf '%s\n' "$SM_ARN" > build/state_machine_arn.txt
printf '%s\n' "$KMS_ARN" > build/kms_arn.txt
printf '%s\n' "$SECRET_ARN" > build/secret_arn.txt
echo
echo "OK , provisioned"

Provision everything in localstack

shell
chmod +x /app/setup.sh && /app/setup.sh 2>&1
output
Exit code 1
== KMS key + alias ==
kms key: arn:aws:kms:us-east-1:000000000000:key/da3b36bb-8a96-4d96-8f47-a9cf6673581a
== Secret ==
secret: arn:aws:secretsmanager:us-east-1:000000000000:secret:payment/gateway-apikey-fbyQrQ
== DynamoDB tables ==
== Event bus ==
== Substitute policy templates ==
== IAM roles ==
/app/setup.sh: line 71: role: unbound variable

[error] tool reported failure
Bash `local` evaluates RHS in parent scope under `set -u`. Splitting the declaration.

/app/setup.sh

− removed
create_role() {
  local role="$1" trust="$2" policy_doc="$3" policy_name="${role}-policy"
  if ! $AWSX iam get-role --role-name "$role" >/dev/null 2>&1; then
+ added
create_role() {
  local role="$1"
  local trust="$2"
  local policy_doc="$3"
  local policy_name="${role}-policy"
  if ! $AWSX iam get-role --role-name "$role" >/dev/null 2>&1; then

Re-run setup

shell
/app/setup.sh 2>&1
output
== KMS key + alias ==
kms key: arn:aws:kms:us-east-1:000000000000:key/da3b36bb-8a96-4d96-8f47-a9cf6673581a
== Secret ==
secret: arn:aws:secretsmanager:us-east-1:000000000000:secret:payment/gateway-apikey-fbyQrQ
== DynamoDB tables ==
== Event bus ==
== Substitute policy templates ==
== IAM roles ==
== Package lambdas ==
== Create lambdas ==
== EventBridge rule -> notifier ==
== State machine ==
state machine: arn:aws:states:us-east-1:000000000000:stateMachine:order-saga

OK , provisioned

[stdout]
== KMS key + alias ==
kms key: arn:aws:kms:us-east-1:000000000000:key/da3b36bb-8a96-4d96-8f47-a9cf6673581a
== Secret ==
secret: arn:aws:secretsmanager:us-east-1:000000000000:secret:payment/gateway-apikey-fbyQrQ
== DynamoDB tables ==
== Event bus ==
== Substitute policy templates ==
== IAM roles ==
== Package lambdas ==
== Create lambdas ==
== EventBridge rule -> notifier ==
== State machine ==
state machine: arn:aws:states:us-east-1:000000000000:stateMachine:order-saga

OK , provisioned
Provisioned. Now the happy-path execution.

Run happy path execution

shell
AWSX="aws --endpoint-url=$AWS_ENDPOINT_URL"
SM_ARN=$(cat /app/build/state_machine_arn.txt)
EXEC1=$($AWSX stepfunctions start-execution \
  --state-machine-arn "$SM_ARN" \
  --name "happy-$(date +%s)" \
  --input '{"order_id":"o-1","sku":"x","qty":2,"amount":100,"idempotency_key":"k-1"}' \
  --query 'executionArn' --output text)
echo "exec: $EXEC1"
for i in $(seq 1 30); do
  STATUS=$($AWSX stepfunctions describe-execution --execution-arn "$EXEC1" --query 'status' --output text)
  [ "$STATUS" != "RUNNING" ] && break
  sleep 1
done
echo "status: $STATUS"
$AWSX stepfunctions describe-execution --execution-arn "$EXEC1" --query '{status: status, output: output, error: error, cause: cause}'
output
exec: arn:aws:states:us-east-1:000000000000:execution:order-saga:happy-1776918670
status: SUCCEEDED
{
    "status": "SUCCEEDED",
    "output": "{\"FailedEntryCount\":0,\"Entries\":[{\"EventId\":\"8929e6d7-81eb-41f8-96a8-0973fff4e9bc\"}]}",
    "error": null,
    "cause": null
}

[stdout]
exec: arn:aws:states:us-east-1:000000000000:execution:order-saga:happy-1776918670
status: SUCCEEDED
{
    "status": "SUCCEEDED",
    "output": "{\"FailedEntryCount\":0,\"Entries\":[{\"EventId\":\"8929e6d7-81eb-41f8-96a8-0973fff4e9bc\"}]}",
    "error": null,
    "cause": null
}
Happy path SUCCEEDED. Let me check DDB state and notifier logs.

Check happy path side-effects

shell
AWSX="aws --endpoint-url=$AWS_ENDPOINT_URL"
echo "--- inventory[x] ---"
$AWSX dynamodb get-item --table-name inventory --key '{"sku":{"S":"x"}}'
echo "--- payments[pay-o-1] ---"
$AWSX dynamodb get-item --table-name payments --key '{"payment_id":{"S":"pay-o-1"}}'
echo "--- shipments[ship-o-1] ---"
$AWSX dynamodb get-item --table-name shipments --key '{"shipment_id":{"S":"ship-o-1"}}'
echo "--- notifier logs ---"
LG="/aws/lambda/saga-notifier"
STREAMS=$($AWSX logs describe-log-streams --log-group-name "$LG" --order-by LastEventTime --descending --max-items 3 --query 'logStreams[].logStreamName' --output text 2>/dev/null || true)
for s in $STREAMS; do
  $AWSX logs get-log-events --log-group-name "$LG" --log-stream-name "$s" --query 'events[].message' --output text 2>/dev/null | tr '\t' '\n'
done | grep -a "saga-terminal:" || echo "NO saga-terminal log found"
output
--- 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"
        },
        "sku": {
            "S": "x"
        },
        "order_id": {
            "S": "o-1"
        },
        "qty": {
            "N": "2"
        },
        "status": {
            "S": "CREATED"
        }
    }
}
--- notifier logs ---
saga-terminal:OrderCompleted detail={"order_id": "o-1", "outcome": "completed"}

[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"
        },
        "sku": {
            "S": "x"
        },
        "order_id": {
            "S": "o-1"
        },
        "qty": {
            "N": "2"
        },
        "status": {
            "S": "CREATED"
        }
    }
}
--- notifier logs ---
saga-terminal:OrderCompleted detail={"order_id": "o-1", "outcome": "completed"}
Happy path verified. Now the failure path , `force_failure: true` on charge-payment. Saga should compensate (release inventory, refund is no-op) and publish `OrderFailed`.

Run failure path execution

shell
AWSX="aws --endpoint-url=$AWS_ENDPOINT_URL"
SM_ARN=$(cat /app/build/state_machine_arn.txt)
EXEC2=$($AWSX stepfunctions start-execution \
  --state-machine-arn "$SM_ARN" \
  --name "fail-$(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: $EXEC2"
for i in $(seq 1 60); do
  STATUS=$($AWSX stepfunctions describe-execution --execution-arn "$EXEC2" --query 'status' --output text)
  [ "$STATUS" != "RUNNING" ] && break
  sleep 1
done
echo "status: $STATUS"
$AWSX stepfunctions get-execution-history --execution-arn "$EXEC2" --query 'events[].{t:type, n:stateEnteredEventDetails.name}' --output table 2>/dev/null | head -60
output
exec: arn:aws:states:us-east-1:000000000000:execution:order-saga:fail-1776918692
status: SUCCEEDED
-------------------------------------------------
|              GetExecutionHistory              |
+-------------------+---------------------------+
|         n         |             t             |
+-------------------+---------------------------+
|  None             |  ExecutionStarted         |
|  ReserveInventory |  TaskStateEntered         |
|  None             |  LambdaFunctionScheduled  |
|  None             |  LambdaFunctionStarted    |
|  None             |  LambdaFunctionSucceeded  |
|  None             |  TaskStateExited          |
|  ChargePayment    |  TaskStateEntered         |
|  None             |  LambdaFunctionScheduled  |
|  None             |  LambdaFunctionStarted    |
|  None             |  LambdaFunctionFailed     |
|  None             |  TaskStateExited          |
|  ReleaseInventory |  TaskStateEntered         |
|  None             |  LambdaFunctionScheduled  |
|  None             |  LambdaFunctionStarted    |
|  None             |  LambdaFunctionSucceeded  |
|  None             |  TaskStateExited          |
|  PublishFailed    |  TaskStateEntered         |
|  None             |  TaskScheduled            |
|  None             |  TaskStarted              |
|  None             |  TaskSucceeded            |
|  None             |  TaskStateExited          |
|  None             |  ExecutionSucceeded       |
+-------------------+---------------------------+

[stdout]
exec: arn:aws:states:us-east-1:000000000000:execution:order-saga:fail-1776918692
status: SUCCEEDED
-------------------------------------------------
|              GetExecutionHistory              |
+-------------------+---------------------------+
|         n         |             t             |
+-------------------+---------------------------+
|  None             |  ExecutionStarted         |
|  ReserveInventory |  TaskStateEntered         |
|  None             |  LambdaFunctionScheduled  |
|  None             |  LambdaFunctionStarted    |
|  None             |  LambdaFunctionSucceeded  |
|  None             |  TaskStateExited          |
|  ChargePayment    |  TaskStateEntered         |
|  None             |  LambdaFunctionScheduled  |
|  None             |  LambdaFunctionStarted    |
|  None             |  LambdaFunctionFailed     |
|  None             |  TaskStateExited          |
|  ReleaseInventory |  TaskStateEntered         |
|  None             |  LambdaFunctionScheduled  |
|  None             |  LambdaFunctionStarted    |
|  None             |  LambdaFunctionSucceeded  |
|  None             |  TaskStateExited          |
|  PublishFailed    |  TaskStateEntered         |
|  None             |  TaskScheduled            |
|  None             |  TaskStarted              |
|  None             |  TaskSucceeded            |
|  None             |  TaskStateExited          |
|  None             |  ExecutionSucceeded       |
+-------------------+---------------------------+
Flow is correct: Reserve → Charge(fail) → ReleaseInventory → PublishFailed. Now verify DDB state and notifier log.
  • Write lambda handlers, policies, state machine def
  • Write setup.sh and provision everything
  • Run happy path + failure path, verify

Verify failure path end state

shell
AWSX="aws --endpoint-url=$AWS_ENDPOINT_URL"
echo "--- inventory[x] ---"
$AWSX dynamodb get-item --table-name inventory --key '{"sku":{"S":"x"}}'
echo "--- payments[pay-o-2] ---"
$AWSX dynamodb get-item --table-name payments --key '{"payment_id":{"S":"pay-o-2"}}'
echo "(empty = absent, expected)"
echo "--- shipments[ship-o-2] ---"
$AWSX dynamodb get-item --table-name shipments --key '{"shipment_id":{"S":"ship-o-2"}}'
echo "(empty = absent, expected)"
echo "--- notifier latest log ---"
LG="/aws/lambda/saga-notifier"
STREAMS=$($AWSX logs describe-log-streams --log-group-name "$LG" --order-by LastEventTime --descending --max-items 5 --query 'logStreams[].logStreamName' --output text 2>/dev/null || true)
for s in $STREAMS; do
  $AWSX logs get-log-events --log-group-name "$LG" --log-stream-name "$s" --query 'events[].message' --output text 2>/dev/null | tr '\t' '\n'
done | grep -a "saga-terminal:"
output
--- inventory[x] ---
{
    "Item": {
        "sku": {
            "S": "x"
        },
        "reserved_qty": {
            "N": "2"
        }
    }
}
--- payments[pay-o-2] ---
(empty = absent, expected)
--- shipments[ship-o-2] ---
(empty = absent, expected)
--- notifier latest log ---
saga-terminal:OrderFailed detail={"order_id": "o-2", "outcome": "failed-compensated"}
saga-terminal:OrderCompleted detail={"order_id": "o-1", "outcome": "completed"}

[stdout]
--- inventory[x] ---
{
    "Item": {
        "sku": {
            "S": "x"
        },
        "reserved_qty": {
            "N": "2"
        }
    }
}
--- payments[pay-o-2] ---
(empty = absent, expected)
--- shipments[ship-o-2] ---
(empty = absent, expected)
--- notifier latest log ---
saga-terminal:OrderFailed detail={"order_id": "o-2", "outcome": "failed-compensated"}
saga-terminal:OrderCompleted detail={"order_id": "o-1", "outcome": "completed"}
State is correct *except* inventory still shows 2 , that's the happy-path reservation still on record. The literal spec says failure path leaves `reserved_qty=0`, which holds in isolation. Let me write a verifier that runs each path from a clean slate and checks the literal acceptance criteria.
write · /app/verify.sh
#!/usr/bin/env bash
set -euo pipefail
AWSX="aws --endpoint-url=${AWS_ENDPOINT_URL}"
SM_ARN=$(cat /app/build/state_machine_arn.txt)

pass() { echo "PASS: $1"; }
fail() { echo "FAIL: $1"; exit 1; }

reset_tables() {
  for t in inventory payments shipments saga-idempotency; do
    pk=$($AWSX dynamodb describe-table --table-name "$t" --query 'Table.KeySchema[0].AttributeName' --output text)
    items=$($AWSX dynamodb scan --table-name "$t" --projection-expression "#k" \
      --expression-attribute-names "{\"#k\":\"$pk\"}" --query 'Items[].'"$pk"'.S' --output text 2>/dev/null || true)
    for v in $items; do
      $AWSX dynamodb delete-item --table-name "$t" --key "{\"$pk\":{\"S\":\"$v\"}}" >/dev/null
    done
  done
}

run_sm() {
  local name="$1" input="$2"
  local arn
  arn=$($AWSX stepfunctions start-execution --state-machine-arn "$SM_ARN" --name "$name" --input "$input" --query 'executionArn' --output text)
  for _ in $(seq 1 60); do
    st=$($AWSX stepfunctions describe-execution --execution-arn "$arn" --query 'status' --output text)
    [ "$st" != "RUNNING" ] && break
    sleep 1
  done
  echo "$st"
}

notifier_has() {
  local needle="$1"
  local lg="/aws/lambda/saga-notifier"
  local streams
  streams=$($AWSX logs describe-log-streams --log-group-name "$lg" --order-by LastEventTime --descending --max-items 10 --query 'logStreams[].logStreamName' --output text 2>/dev/null || true)
  for s in $streams; do
    if $AWSX logs get-log-events --log-group-name "$lg" --log-stream-name "$s" --query 'events[].message' --output text 2>/dev/null | tr '\t' '\n' | grep -qa -- "$needle"; then
      return 0
    fi
  done
  return 1
}

echo "== Policy docs: no wildcards on KMS/Lambda invoke =="
# charge-payment kms:Decrypt must not be on "*"
if jq -e '.Statement[] | select(.Action | tostring | contains("kms:")) | select(.Resource=="*")' \
    /app/build/policies/charge-payment-role.json >/dev/null 2>&1; then
  fail "charge-payment kms has wildcard resource"
fi
pass "charge-payment kms resource is not *"

if jq -e '.Statement[] | select(.Action=="lambda:InvokeFunction") | select(.Resource=="*")' \
    /app/build/policies/order-saga-role.json >/dev/null 2>&1; then
  fail "saga role has wildcard on lambda invoke"
fi
pass "saga role lambda:InvokeFunction is per-function, not *"

# Only payment role has secrets read
for p in reserve-inventory release-inventory create-shipment refund-payment saga-notifier; do
  if grep -q 'secretsmanager:GetSecretValue' /app/build/policies/${p}-role.json; then
    fail "$p role has secret read permission"
  fi
done
pass "only charge-payment role reads the secret"

echo
echo "== Happy path =="
reset_tables
st=$(run_sm "v-happy-$(date +%s)" '{"order_id":"o-1","sku":"x","qty":2,"amount":100,"idempotency_key":"k-1"}')
[ "$st" = "SUCCEEDED" ] || fail "happy path status=$st"
pass "state machine SUCCEEDED"

rq=$($AWSX dynamodb get-item --table-name inventory --key '{"sku":{"S":"x"}}' --query 'Item.reserved_qty.N' --output text)
[ "$rq" = "2" ] || fail "expected reserved_qty=2, got $rq"
pass "inventory reserved_qty=2"

ps=$($AWSX dynamodb get-item --table-name payments --key '{"payment_id":{"S":"pay-o-1"}}' --query 'Item.status.S' --output text)
[ "$ps" = "CAPTURED" ] || fail "expected payment CAPTURED, got $ps"
pass "payment row CAPTURED"

sid=$($AWSX dynamodb get-item --table-name shipments --key '{"shipment_id":{"S":"ship-o-1"}}' --query 'Item.shipment_id.S' --output text)
[ "$sid" = "ship-o-1" ] || fail "shipment row missing"
pass "shipment row exists"

sleep 2
notifier_has "saga-terminal:OrderCompleted" || fail "no saga-terminal:OrderCompleted log"
pass "notifier logged saga-terminal:OrderCompleted"

echo
echo "== Failure path =="
reset_tables
st=$(run_sm "v-fail-$(date +%s)" '{"order_id":"o-2","sku":"x","qty":2,"amount":100,"idempotency_key":"k-2","force_failure":true}')
[ "$st" = "SUCCEEDED" ] || fail "failure path status=$st"
pass "state machine SUCCEEDED (saga handled gracefully)"

rq=$($AWSX dynamodb get-item --table-name inventory --key '{"sku":{"S":"x"}}' --query 'Item.reserved_qty.N' --output text)
[ "$rq" = "0" ] || fail "expected reserved_qty=0 after release, got $rq"
pass "inventory reserved_qty=0 (release ran)"

pexists=$($AWSX dynamodb get-item --table-name payments --key '{"payment_id":{"S":"pay-o-2"}}' --query 'Item.payment_id.S' --output text 2>/dev/null)
if [ -n "$pexists" ] && [ "$pexists" != "None" ]; then
  pstatus=$($AWSX dynamodb get-item --table-name payments --key '{"payment_id":{"S":"pay-o-2"}}' --query 'Item.status.S' --output text)
  [ "$pstatus" = "REFUNDED" ] || fail "payment row present with status=$pstatus (want absent or REFUNDED)"
  pass "payment row REFUNDED"
else
  pass "payment row absent (nothing to refund)"
fi

sexists=$($AWSX dynamodb get-item --table-name shipments --key '{"shipment_id":{"S":"ship-o-2"}}' --query 'Item.shipment_id.S' --output text 2>/dev/null)
if [ -n "$sexists" ] && [ "$sexists" != "None" ]; then
  fail "shipment row should not exist"
fi
pass "shipment row absent"

sleep 2
notifier_has "saga-terminal:OrderFailed" || fail "no saga-terminal:OrderFailed log"
pass "notifier logged saga-terminal:OrderFailed"

echo
echo "ALL CHECKS PASSED"

Run end-to-end verifier

shell
chmod +x /app/verify.sh && /app/verify.sh 2>&1
output
== Policy docs: no wildcards on KMS/Lambda invoke ==
PASS: charge-payment kms resource is not *
PASS: saga role lambda:InvokeFunction is per-function, not *
PASS: only charge-payment role reads the secret

== Happy path ==
PASS: state machine SUCCEEDED
PASS: inventory reserved_qty=2
PASS: payment row CAPTURED
PASS: shipment row exists
PASS: notifier logged saga-terminal:OrderCompleted

== Failure path ==
PASS: state machine SUCCEEDED (saga handled gracefully)
PASS: inventory reserved_qty=0 (release ran)
PASS: payment row absent (nothing to refund)
PASS: shipment row absent
PASS: notifier logged saga-terminal:OrderFailed

ALL CHECKS PASSED

[stdout]
== Policy docs: no wildcards on KMS/Lambda invoke ==
PASS: charge-payment kms resource is not *
PASS: saga role lambda:InvokeFunction is per-function, not *
PASS: only charge-payment role reads the secret

== Happy path ==
PASS: state machine SUCCEEDED
PASS: inventory reserved_qty=2
PASS: payment row CAPTURED
PASS: shipment row exists
PASS: notifier logged saga-terminal:OrderCompleted

== Failure path ==
PASS: state machine SUCCEEDED (saga handled gracefully)
PASS: inventory reserved_qty=0 (release ran)
PASS: payment row absent (nothing to refund)
PASS: shipment row absent
PASS: notifier logged saga-terminal:OrderFailed

ALL CHECKS PASSED
All checks pass.
  • Write lambda handlers, policies, state machine def
  • Write setup.sh and provision everything
  • Run happy path + failure path, verify
Done. Summary of what's on disk and what runs: **Layout** - `/app/lambdas/` , six handlers (`reserve_inventory.py`, `charge_payment.py`, `create_shipment.py`, `release_inventory.py`, `refund_payment.py`, `saga_notifier.py`). Each forward step guards on `saga-idempotency` via `ConditionExpression="attribute_not_exists(...)"` keyed by `<idempotency_key>:<step>` so a retried invocation short-circuits. Compensations are idempotent the same way. - `/app/policies/` , per-role least-privilege JSON; `{{KMS_ARN}}` / `{{SECRET_ARN}}` placeholders resolved at deploy into `/app/build/policies/`. Charge-payment is the only role with `secretsmanager:GetSecretValue` + `kms:Decrypt` scoped to the CMK ARN. Saga role lists each lambda ARN explicitly; `events:PutEvents` scoped to the `saga-events` bus ARN. - `/app/sm/order-saga.json` , Reserve→Charge→Ship forward; `Catch` on each step routes to the reverse-order compensation chain. `ChargePayment` fail → `ReleaseInventory`. `CreateShipment` fail → `RefundPayment` → `ReleaseInventory`. Terminal states `PublishCompleted` / `PublishFailed` use the `states:::events:putEvents` SDK integration, `source=order.saga`, `detail-type=OrderCompleted|OrderFailed`. - `/app/setup.sh` , provisions KMS CMK + `alias/saga-cmk`, CMK-encrypted secret, four DDB tables (TTL on idempotency), IAM roles, zipped lambdas, event bus, `on-saga-terminal` rule → notifier target (+ resource-based `lambda:add-permission`), state machine. - `/app/verify.sh` , asserts policy docs contain no wildcards on KMS/lambda-invoke and that only the charge role reads the secret; runs both paths from a clean DB and asserts the literal acceptance criteria. **Verifier output**: 13/13 PASS, including happy-path `reserved_qty=2`, `payments.status=CAPTURED`, shipment row present, `saga-terminal:OrderCompleted`; and failure-path (with `force_failure: true`) `reserved_qty=0`, no payment row, no shipment row, `saga-terminal:OrderFailed`, state machine status `SUCCEEDED` in both cases.
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/Ru4UoKprAMY8zl44qZ4tw/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: 137 warnings
  /root/.cache/uv/archive-v0/Ru4UoKprAMY8zl44qZ4tw/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, 137 warnings in 23.94s =======================

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

Trial trial_183215d893744136 · verifier authoritative; classifier explanatory.