SyncValsverifier → artifact → classifier → verdict
SyncVals · Trajectory

sfn-saga-compensation-orchestrator

claude-code claude-opus-4-7 ✗ failed BAD_FAILURE ↑ 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
BAD_FAILUREThe task is at fault, underspecified/contradictory instruction, brittle/flaky tests, or tests demanding undiscoverable behavior.
SubtypeUnderspecified Instruction
EvidenceTest `test_16_notifier_role_is_least_privilege_no_ddb_kms_secrets` failed with: "saga-notifier-role has forbidden action 'events:PutEvents' - only logs are needed" (line 51 of test-stdout.txt). The instruction states 'notifier role has nothing but eventbridge + logs' (instruction line 32) but does not specify which EventBridge actions are forbidden. The test forbids 'events:PutEvents' in its forbidden_prefixes, but the instruction wording 'eventbridge' is ambiguous about whether this includes put/publish permissions. Agent passed 19/20 tests and correctly understood most requirements; this single failure stems from instruction ambiguity, not agent error. The fix requires clarifying the exact scope of 'eventbridge' permissions in the instruction.
Root causeThe instruction specifies "notifier role has nothing but eventbridge + logs" but does not clarify that certain EventBridge actions (specifically PutEvents) should be excluded. This creates ambiguity about what "eventbridge" permissions entail, leading the agent to grant broader permissions than the test expects.
RecommendationRevise the instruction to explicitly state: 'notifier role has logs permissions only , no EventBridge PutEvents or other write actions. The notifier is invoked BY the event rule; it does not publish events itself.' Or: 'notifier role: logs:CreateLogGroup, logs:CreateLogStream, logs:PutLogEvents only. No DynamoDB, Secrets Manager, KMS, or EventBridge write permissions (events:PutEvents, events:PutRule, etc.).' This removes ambiguity about which permissions are allowed.
Trajectory
Tool-by-tool agent trajectory
20 tool calls · 4 tool types · 26 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.

List working directory contents

shell
ls -la /app/
output
total 8
drwxr-xr-x 2 root root 4096 Apr 21 20:05 .
drwxr-xr-x 1 root root 4096 Apr 23 04:59 ..

[stdout]
total 8
drwxr-xr-x 2 root root 4096 Apr 21 20:05 .
drwxr-xr-x 1 root root 4096 Apr 23 04:59 ..

Check tooling

shell
which aws python3 jq zip && aws --version && python3 --version
output
/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

Check LocalStack health

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

[stdout]
{
  "services": {
    "acm": "disabled",
    "apigateway": "disabled",
    "cloudformation": "disabled",
    "cloudwatch": "disabled",
    "config": "disabled",
    "dynamodb": "available",
    "dynamodbstreams": "available",
    "ec2": "disabled",
    "es": "disabled",
    "events": "available",
    "firehose": "disabled",
    "iam": "available",
    "kinesis": "available",
    "kms": "available",
    "lambda": "available",
    "logs": "available",
    "opensearch": "disabled",
    "redshift": "disabled",
    "resource-groups": "disabled",
    "resourcegroupstaggingapi": "disabled",
    "route53": "disabled",
    "route53resolver": "disabled",
    "s3": "available",
    "s3control": "disabled",
    "scheduler": "disabled",
    "secretsmanager": "available",
    "ses": "disabled",
    "sns": "disabled",
    "sqs": "available",
    "ssm": "disabled",
    "stepfunctions": "available",
    "sts": "available",
    "support": "disabled",
    "swf": "disabled",
    "transcribe": "disabled"
  },
  "edition": "community",
  "version": "4.13.1"
}
LocalStack 4.13.1 community is up with all needed services available. Let me scaffold everything , lambdas, policies, state machine, build script, and a verifier.

Create project directory layout

shell
mkdir -p /app/lambdas/{reserve-inventory,charge-payment,create-shipment,release-inventory,refund-payment,saga-notifier} /app/policies /app/sm /app/build
output
(Bash completed with no output)
write · /app/lambdas/reserve-inventory/handler.py
import os
import time
import boto3
from botocore.exceptions import ClientError

ENDPOINT = os.environ["AWS_ENDPOINT_URL"]
INVENTORY_TABLE = os.environ["INVENTORY_TABLE"]
IDEM_TABLE = os.environ["IDEM_TABLE"]

ddb = boto3.resource("dynamodb", endpoint_url=ENDPOINT)
inventory = ddb.Table(INVENTORY_TABLE)
idem = ddb.Table(IDEM_TABLE)


def handler(event, context):
    base_key = event["idempotency_key"]
    step_key = f"{base_key}:reserve"
    sku = event["sku"]
    qty = int(event["qty"])

    try:
        idem.put_item(
            Item={"idempotency_key": step_key, "ttl": int(time.time()) + 86400},
            ConditionExpression="attribute_not_exists(idempotency_key)",
        )
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {"status": "already_reserved", "sku": sku, "qty": qty}
        raise

    inventory.update_item(
        Key={"sku": sku},
        UpdateExpression="ADD reserved_qty :q",
        ExpressionAttributeValues={":q": qty},
    )
    return {"status": "reserved", "sku": sku, "qty": qty}
write · /app/lambdas/charge-payment/handler.py
import os
import time
from decimal import Decimal
import boto3
from botocore.exceptions import ClientError

ENDPOINT = os.environ["AWS_ENDPOINT_URL"]
PAYMENTS_TABLE = os.environ["PAYMENTS_TABLE"]
IDEM_TABLE = os.environ["IDEM_TABLE"]
SECRET_ID = os.environ["GATEWAY_SECRET_ID"]

ddb = boto3.resource("dynamodb", endpoint_url=ENDPOINT)
payments = ddb.Table(PAYMENTS_TABLE)
idem = ddb.Table(IDEM_TABLE)
secrets = boto3.client("secretsmanager", endpoint_url=ENDPOINT)


def handler(event, context):
    if event.get("force_failure"):
        raise RuntimeError("payment gateway unavailable (forced)")

    base_key = event["idempotency_key"]
    step_key = f"{base_key}:charge"
    payment_id = f"pay-{base_key}"
    order_id = event["order_id"]
    amount = Decimal(str(event["amount"]))

    try:
        idem.put_item(
            Item={"idempotency_key": step_key, "ttl": int(time.time()) + 86400},
            ConditionExpression="attribute_not_exists(idempotency_key)",
        )
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {"payment_id": payment_id, "status": "already_charged"}
        raise

    secret = secrets.get_secret_value(SecretId=SECRET_ID)
    api_key = secret["SecretString"]
    print(f"charge-payment: using gateway key suffix ****{api_key[-4:]}")

    payments.put_item(
        Item={
            "payment_id": payment_id,
            "order_id": order_id,
            "amount": amount,
            "status": "CAPTURED",
        }
    )
    return {"payment_id": payment_id, "status": "CAPTURED"}
write · /app/lambdas/create-shipment/handler.py
import os
import time
import boto3
from botocore.exceptions import ClientError

ENDPOINT = os.environ["AWS_ENDPOINT_URL"]
SHIPMENTS_TABLE = os.environ["SHIPMENTS_TABLE"]
IDEM_TABLE = os.environ["IDEM_TABLE"]

ddb = boto3.resource("dynamodb", endpoint_url=ENDPOINT)
shipments = ddb.Table(SHIPMENTS_TABLE)
idem = ddb.Table(IDEM_TABLE)


def handler(event, context):
    if event.get("force_shipment_failure"):
        raise RuntimeError("shipment provider unavailable (forced)")

    base_key = event["idempotency_key"]
    step_key = f"{base_key}:ship"
    shipment_id = f"shp-{base_key}"
    order_id = event["order_id"]

    try:
        idem.put_item(
            Item={"idempotency_key": step_key, "ttl": int(time.time()) + 86400},
            ConditionExpression="attribute_not_exists(idempotency_key)",
        )
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {"shipment_id": shipment_id, "status": "already_created"}
        raise

    shipments.put_item(
        Item={
            "shipment_id": shipment_id,
            "order_id": order_id,
            "sku": event["sku"],
            "qty": int(event["qty"]),
            "status": "CREATED",
        }
    )
    return {"shipment_id": shipment_id, "status": "CREATED"}
write · /app/lambdas/release-inventory/handler.py
import os
import time
import boto3
from botocore.exceptions import ClientError

ENDPOINT = os.environ["AWS_ENDPOINT_URL"]
INVENTORY_TABLE = os.environ["INVENTORY_TABLE"]
IDEM_TABLE = os.environ["IDEM_TABLE"]

ddb = boto3.resource("dynamodb", endpoint_url=ENDPOINT)
inventory = ddb.Table(INVENTORY_TABLE)
idem = ddb.Table(IDEM_TABLE)


def handler(event, context):
    base_key = event["idempotency_key"]
    step_key = f"{base_key}:release"
    sku = event["sku"]
    qty = int(event["qty"])

    try:
        idem.put_item(
            Item={"idempotency_key": step_key, "ttl": int(time.time()) + 86400},
            ConditionExpression="attribute_not_exists(idempotency_key)",
        )
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {"status": "already_released", "sku": sku, "qty": qty}
        raise

    inventory.update_item(
        Key={"sku": sku},
        UpdateExpression="ADD reserved_qty :q",
        ExpressionAttributeValues={":q": -qty},
    )
    return {"status": "released", "sku": sku, "qty": qty}
write · /app/lambdas/refund-payment/handler.py
import os
import time
import boto3
from botocore.exceptions import ClientError

ENDPOINT = os.environ["AWS_ENDPOINT_URL"]
PAYMENTS_TABLE = os.environ["PAYMENTS_TABLE"]
IDEM_TABLE = os.environ["IDEM_TABLE"]

ddb = boto3.resource("dynamodb", endpoint_url=ENDPOINT)
payments = ddb.Table(PAYMENTS_TABLE)
idem = ddb.Table(IDEM_TABLE)


def handler(event, context):
    base_key = event["idempotency_key"]
    step_key = f"{base_key}:refund"
    payment_id = f"pay-{base_key}"

    try:
        idem.put_item(
            Item={"idempotency_key": step_key, "ttl": int(time.time()) + 86400},
            ConditionExpression="attribute_not_exists(idempotency_key)",
        )
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {"payment_id": payment_id, "status": "already_refunded"}
        raise

    try:
        payments.update_item(
            Key={"payment_id": payment_id},
            UpdateExpression="SET #s = :r",
            ExpressionAttributeNames={"#s": "status"},
            ExpressionAttributeValues={":r": "REFUNDED"},
            ConditionExpression="attribute_exists(payment_id)",
        )
        return {"payment_id": payment_id, "status": "REFUNDED"}
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {"payment_id": payment_id, "status": "no_op"}
        raise
ToolSearch
{
  "query": "select:TodoWrite",
  "max_results": 1
}

, output, 
{"type": "tool_reference", "tool_name": "TodoWrite"}
  • Write all 6 lambda handlers
  • Write least-privilege IAM policy docs per role
  • Write state machine definition with compensation branching
  • Build orchestration script to provision everything on LocalStack
  • Run provisioning end to end
  • Write and run happy-path verifier
  • Write and run failure-path verifier
write · /app/lambdas/saga-notifier/handler.py
def handler(event, context):
    detail_type = event.get("detail-type") or event.get("detail_type") or "Unknown"
    detail = event.get("detail", {})
    order_id = detail.get("order_id", "unknown")
    print(f"saga-terminal:{detail_type} order_id={order_id}")
    return {"ok": True}
  • Write all 6 lambda handlers
  • Write least-privilege IAM policy docs per role
  • Write state machine definition with compensation branching
  • Build orchestration script to provision everything on LocalStack
  • Run provisioning end to end
  • Write and run happy-path verifier
  • Write and run failure-path verifier
Now the IAM policy templates. I'll generate them inline from the build script so KMS/secret ARNs can be substituted , but first let me lay out the state machine.
write · /app/sm/order-saga.json.tpl
{
  "Comment": "order saga with reverse-order compensations",
  "StartAt": "ReserveInventory",
  "States": {
    "ReserveInventory": {
      "Type": "Task",
      "Resource": "__RESERVE_ARN__",
      "ResultPath": "$.reserve_result",
      "Retry": [
        {
          "ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException", "Lambda.TooManyRequestsException"],
          "IntervalSeconds": 1,
          "MaxAttempts": 2,
          "BackoffRate": 2.0
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "ResultPath": "$.error",
          "Next": "PublishFailed"
        }
      ],
      "Next": "ChargePayment"
    },
    "ChargePayment": {
      "Type": "Task",
      "Resource": "__CHARGE_ARN__",
      "ResultPath": "$.charge_result",
      "Retry": [
        {
          "ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException", "Lambda.TooManyRequestsException"],
          "IntervalSeconds": 1,
          "MaxAttempts": 2,
          "BackoffRate": 2.0
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "ResultPath": "$.error",
          "Next": "CompensateReleaseAfterChargeFail"
        }
      ],
      "Next": "CreateShipment"
    },
    "CreateShipment": {
      "Type": "Task",
      "Resource": "__SHIP_ARN__",
      "ResultPath": "$.ship_result",
      "Retry": [
        {
          "ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException", "Lambda.TooManyRequestsException"],
          "IntervalSeconds": 1,
          "MaxAttempts": 2,
          "BackoffRate": 2.0
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "ResultPath": "$.error",
          "Next": "CompensateRefundAfterShipFail"
        }
      ],
      "Next": "PublishCompleted"
    },
    "CompensateRefundAfterShipFail": {
      "Type": "Task",
      "Resource": "__REFUND_ARN__",
      "ResultPath": "$.refund_result",
      "Next": "CompensateReleaseAfterShipFail"
    },
    "CompensateReleaseAfterShipFail": {
      "Type": "Task",
      "Resource": "__RELEASE_ARN__",
      "ResultPath": "$.release_result",
      "Next": "PublishFailed"
    },
    "CompensateReleaseAfterChargeFail": {
      "Type": "Task",
      "Resource": "__RELEASE_ARN__",
      "ResultPath": "$.release_result",
      "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",
              "idempotency_key.$": "$.idempotency_key"
            }
          }
        ]
      },
      "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",
              "idempotency_key.$": "$.idempotency_key"
            }
          }
        ]
      },
      "End": true
    }
  }
}
  • Write all 6 lambda handlers
  • Write state machine definition with compensation branching
  • Build orchestration script to provision everything on LocalStack
  • Run provisioning end to end
  • Write and run happy-path verifier
  • Write and run failure-path verifier
write · /app/build.sh
#!/usr/bin/env bash
set -euo pipefail

export AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:-us-east-1}
export AWS_REGION=$AWS_DEFAULT_REGION
export AWS_PAGER=""
EP="http://localstack:4566"
A() { aws --endpoint-url "$EP" "$@"; }

ROOT=/app
BUILD=$ROOT/build
POLICIES=$ROOT/policies
LAMBDAS=$ROOT/lambdas
SM_DIR=$ROOT/sm
ACCOUNT_ID=000000000000

mkdir -p "$BUILD" "$POLICIES"

echo "== KMS customer-managed key =="
if ! A kms describe-key --key-id alias/saga-cmk >/dev/null 2>&1; then
  KEY_ID=$(A kms create-key --description "saga cmk" --query 'KeyMetadata.KeyId' --output text)
  A kms create-alias --alias-name alias/saga-cmk --target-key-id "$KEY_ID" >/dev/null
fi
KEY_ARN=$(A kms describe-key --key-id alias/saga-cmk --query 'KeyMetadata.Arn' --output text)
KEY_ID=$(A kms describe-key --key-id alias/saga-cmk --query 'KeyMetadata.KeyId' --output text)
echo "KEY_ARN=$KEY_ARN"

echo "== Secrets Manager: payment/gateway-apikey (CMK-encrypted) =="
if ! A secretsmanager describe-secret --secret-id payment/gateway-apikey >/dev/null 2>&1; then
  A secretsmanager create-secret \
    --name payment/gateway-apikey \
    --kms-key-id "$KEY_ARN" \
    --secret-string "sk_live_FAKE_apikey_abcd1234" >/dev/null
fi
SECRET_ARN=$(A secretsmanager describe-secret --secret-id payment/gateway-apikey --query 'ARN' --output text)
echo "SECRET_ARN=$SECRET_ARN"

echo "== DynamoDB tables =="
create_table() {
  local NAME=$1 PK=$2
  if ! A dynamodb describe-table --table-name "$NAME" >/dev/null 2>&1; then
    A 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
    A dynamodb wait table-exists --table-name "$NAME"
  fi
}
create_table inventory sku
create_table payments payment_id
create_table shipments shipment_id
create_table saga-idempotency idempotency_key

# Enable TTL on saga-idempotency
if [ "$(A dynamodb describe-time-to-live --table-name saga-idempotency --query 'TimeToLiveDescription.TimeToLiveStatus' --output text 2>/dev/null || echo DISABLED)" != "ENABLED" ]; then
  A dynamodb update-time-to-live --table-name saga-idempotency \
    --time-to-live-specification "Enabled=true, AttributeName=ttl" >/dev/null || true
fi

echo "== EventBridge event bus =="
if ! A events describe-event-bus --name saga-events >/dev/null 2>&1; then
  A events create-event-bus --name saga-events >/dev/null
fi
BUS_ARN=$(A events describe-event-bus --name saga-events --query 'Arn' --output text)
echo "BUS_ARN=$BUS_ARN"

echo "== Lambda trust policy =="
cat > "$BUILD/trust-lambda.json" <<'JSON'
{
  "Version": "2012-10-17",
  "Statement": [{"Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}]
}
JSON

INV_ARN="arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/inventory"
PAY_ARN="arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/payments"
SHP_ARN="arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/shipments"
IDEM_ARN="arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/saga-idempotency"
LOGS_ARN="arn:aws:logs:${AWS_REGION}:${ACCOUNT_ID}:*"

ensure_role() {
  local NAME=$1
  if ! A iam get-role --role-name "$NAME" >/dev/null 2>&1; then
    A iam create-role --role-name "$NAME" \
      --assume-role-policy-document file://$BUILD/trust-lambda.json >/dev/null
  fi
}

put_inline() {
  local ROLE=$1 POLICY_NAME=$2 DOC=$3
  A iam put-role-policy --role-name "$ROLE" \
    --policy-name "$POLICY_NAME" \
    --policy-document "$DOC" >/dev/null
}

echo "== IAM roles + inline policies =="

# reserve-inventory-role: write to inventory + idempotency + logs
ensure_role reserve-inventory-role
put_inline reserve-inventory-role reserve-policy "$(cat <<JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {"Effect": "Allow", "Action": ["dynamodb:UpdateItem"], "Resource": "${INV_ARN}"},
    {"Effect": "Allow", "Action": ["dynamodb:PutItem"], "Resource": "${IDEM_ARN}"},
    {"Effect": "Allow", "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], "Resource": "${LOGS_ARN}"}
  ]
}
JSON
)"

# charge-payment-role: write payments + idempotency + secret read + kms decrypt + logs
ensure_role charge-payment-role
put_inline charge-payment-role charge-policy "$(cat <<JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {"Effect": "Allow", "Action": ["dynamodb:PutItem"], "Resource": "${PAY_ARN}"},
    {"Effect": "Allow", "Action": ["dynamodb:PutItem"], "Resource": "${IDEM_ARN}"},
    {"Effect": "Allow", "Action": ["secretsmanager:GetSecretValue"], "Resource": "${SECRET_ARN}"},
    {"Effect": "Allow", "Action": ["kms:Decrypt"], "Resource": "${KEY_ARN}"},
    {"Effect": "Allow", "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], "Resource": "${LOGS_ARN}"}
  ]
}
JSON
)"

# create-shipment-role
ensure_role create-shipment-role
put_inline create-shipment-role ship-policy "$(cat <<JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {"Effect": "Allow", "Action": ["dynamodb:PutItem"], "Resource": "${SHP_ARN}"},
    {"Effect": "Allow", "Action": ["dynamodb:PutItem"], "Resource": "${IDEM_ARN}"},
    {"Effect": "Allow", "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], "Resource": "${LOGS_ARN}"}
  ]
}
JSON
)"

# release-inventory-role
ensure_role release-inventory-role
put_inline release-inventory-role release-policy "$(cat <<JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {"Effect": "Allow", "Action": ["dynamodb:UpdateItem"], "Resource": "${INV_ARN}"},
    {"Effect": "Allow", "Action": ["dynamodb:PutItem"], "Resource": "${IDEM_ARN}"},
    {"Effect": "Allow", "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], "Resource": "${LOGS_ARN}"}
  ]
}
JSON
)"

# refund-payment-role
ensure_role refund-payment-role
put_inline refund-payment-role refund-policy "$(cat <<JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {"Effect": "Allow", "Action": ["dynamodb:UpdateItem"], "Resource": "${PAY_ARN}"},
    {"Effect": "Allow", "Action": ["dynamodb:PutItem"], "Resource": "${IDEM_ARN}"},
    {"Effect": "Allow", "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], "Resource": "${LOGS_ARN}"}
  ]
}
JSON
)"

# saga-notifier-role: eventbridge + logs only
ensure_role saga-notifier-role
put_inline saga-notifier-role notifier-policy "$(cat <<JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {"Effect": "Allow", "Action": ["events:PutEvents"], "Resource": "${BUS_ARN}"},
    {"Effect": "Allow", "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], "Resource": "${LOGS_ARN}"}
  ]
}
JSON
)"

echo "== Package lambdas =="
pkg() {
  local NAME=$1
  local SRC="$LAMBDAS/$NAME"
  local OUT="$BUILD/$NAME.zip"
  ( cd "$SRC" && zip -qr "$OUT" handler.py )
}
pkg reserve-inventory
pkg charge-payment
pkg create-shipment
pkg release-inventory
pkg refund-payment
pkg saga-notifier

echo "== Create/Update lambdas =="
LAMBDA_ENDPOINT="http://localstack:4566"

create_lambda() {
  local NAME=$1 ROLE=$2 ENV_JSON=$3 ZIP=$4
  local ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/${ROLE}"
  if A lambda get-function --function-name "$NAME" >/dev/null 2>&1; then
    A lambda update-function-code --function-name "$NAME" --zip-file "fileb://$ZIP" >/dev/null
    A lambda wait function-updated --function-name "$NAME"
    A lambda update-function-configuration --function-name "$NAME" \
      --role "$ROLE_ARN" --handler handler.handler --runtime python3.11 \
      --timeout 30 --environment "$ENV_JSON" >/dev/null
    A lambda wait function-updated --function-name "$NAME"
  else
    A lambda create-function --function-name "$NAME" \
      --runtime python3.11 --handler handler.handler \
      --role "$ROLE_ARN" --zip-file "fileb://$ZIP" \
      --timeout 30 --environment "$ENV_JSON" >/dev/null
    A lambda wait function-active-v2 --function-name "$NAME"
  fi
}

ENV_COMMON="AWS_ENDPOINT_URL=http://localstack:4566"

create_lambda reserve-inventory reserve-inventory-role \
  "Variables={AWS_ENDPOINT_URL=http://localstack:4566,INVENTORY_TABLE=inventory,IDEM_TABLE=saga-idempotency}" \
  "$BUILD/reserve-inventory.zip"

create_lambda charge-payment charge-payment-role \
  "Variables={AWS_ENDPOINT_URL=http://localstack:4566,PAYMENTS_TABLE=payments,IDEM_TABLE=saga-idempotency,GATEWAY_SECRET_ID=payment/gateway-apikey}" \
  "$BUILD/charge-payment.zip"

create_lambda create-shipment create-shipment-role \
  "Variables={AWS_ENDPOINT_URL=http://localstack:4566,SHIPMENTS_TABLE=shipments,IDEM_TABLE=saga-idempotency}" \
  "$BUILD/create-shipment.zip"

create_lambda release-inventory release-inventory-role \
  "Variables={AWS_ENDPOINT_URL=http://localstack:4566,INVENTORY_TABLE=inventory,IDEM_TABLE=saga-idempotency}" \
  "$BUILD/release-inventory.zip"

create_lambda refund-payment refund-payment-role \
  "Variables={AWS_ENDPOINT_URL=http://localstack:4566,PAYMENTS_TABLE=payments,IDEM_TABLE=saga-idempotency}" \
  "$BUILD/refund-payment.zip"

create_lambda saga-notifier saga-notifier-role \
  "Variables={AWS_ENDPOINT_URL=http://localstack:4566}" \
  "$BUILD/saga-notifier.zip"

NOTIFIER_ARN="arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:saga-notifier"
RESERVE_ARN="arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:reserve-inventory"
CHARGE_ARN="arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:charge-payment"
SHIP_ARN="arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:create-shipment"
RELEASE_ARN="arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:release-inventory"
REFUND_ARN="arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:refund-payment"

echo "== EventBridge rule + target =="
if ! A events describe-rule --name on-saga-terminal --event-bus-name saga-events >/dev/null 2>&1; then
  A events put-rule --name on-saga-terminal --event-bus-name saga-events \
    --event-pattern '{"source": ["order.saga"], "detail-type": ["OrderCompleted", "OrderFailed"]}' >/dev/null
fi

A events put-targets --rule on-saga-terminal --event-bus-name saga-events \
  --targets "Id=1,Arn=${NOTIFIER_ARN}" >/dev/null

# allow EventBridge to invoke notifier
A lambda add-permission --function-name saga-notifier \
  --statement-id eb-invoke-notifier \
  --action lambda:InvokeFunction \
  --principal events.amazonaws.com \
  --source-arn "arn:aws:events:${AWS_REGION}:${ACCOUNT_ID}:rule/saga-events/on-saga-terminal" >/dev/null 2>&1 || true

echo "== State machine role (no lambda wildcard, no kms wildcard) =="
cat > "$BUILD/trust-states.json" <<'JSON'
{
  "Version": "2012-10-17",
  "Statement": [{"Effect": "Allow", "Principal": {"Service": "states.amazonaws.com"}, "Action": "sts:AssumeRole"}]
}
JSON

if ! A iam get-role --role-name order-saga-role >/dev/null 2>&1; then
  A iam create-role --role-name order-saga-role \
    --assume-role-policy-document file://$BUILD/trust-states.json >/dev/null
fi

SM_POLICY=$(cat <<JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["lambda:InvokeFunction"],
      "Resource": [
        "${RESERVE_ARN}",
        "${CHARGE_ARN}",
        "${SHIP_ARN}",
        "${RELEASE_ARN}",
        "${REFUND_ARN}"
      ]
    },
    {"Effect": "Allow", "Action": ["events:PutEvents"], "Resource": "${BUS_ARN}"}
  ]
}
JSON
)
A iam put-role-policy --role-name order-saga-role \
  --policy-name order-saga-policy \
  --policy-document "$SM_POLICY" >/dev/null

echo "== State machine definition (render template) =="
sed \
  -e "s#__RESERVE_ARN__#${RESERVE_ARN}#g" \
  -e "s#__CHARGE_ARN__#${CHARGE_ARN}#g" \
  -e "s#__SHIP_ARN__#${SHIP_ARN}#g" \
  -e "s#__RELEASE_ARN__#${RELEASE_ARN}#g" \
  -e "s#__REFUND_ARN__#${REFUND_ARN}#g" \
  "$SM_DIR/order-saga.json.tpl" > "$BUILD/order-saga.json"

SM_ARN_EXISTING=$(A stepfunctions list-state-machines --query "stateMachines[?name=='order-saga'].stateMachineArn" --output text)
if [ -n "$SM_ARN_EXISTING" ] && [ "$SM_ARN_EXISTING" != "None" ]; then
  A stepfunctions update-state-machine \
    --state-machine-arn "$SM_ARN_EXISTING" \
    --definition "file://$BUILD/order-saga.json" \
    --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/order-saga-role" >/dev/null
  SM_ARN="$SM_ARN_EXISTING"
else
  SM_ARN=$(A stepfunctions create-state-machine \
    --name order-saga \
    --definition "file://$BUILD/order-saga.json" \
    --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/order-saga-role" \
    --query 'stateMachineArn' --output text)
fi
echo "SM_ARN=$SM_ARN"

echo "$SM_ARN" > "$BUILD/sm_arn.txt"
echo "BUILD COMPLETE"

Provision saga infrastructure on LocalStack

shell
chmod +x /app/build.sh && bash /app/build.sh 2>&1 | tail -80
output
== KMS customer-managed key ==
KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/1f46dae7-635a-4f30-a2ef-4b863d64ebdd
== Secrets Manager: payment/gateway-apikey (CMK-encrypted) ==
SECRET_ARN=arn:aws:secretsmanager:us-east-1:000000000000:secret:payment/gateway-apikey-LHdCcP
== DynamoDB tables ==
== EventBridge event bus ==
BUS_ARN=arn:aws:events:us-east-1:000000000000:event-bus/saga-events
== Lambda trust policy ==
== IAM roles + inline policies ==
== Package lambdas ==
== Create/Update lambdas ==
== EventBridge rule + target ==
== State machine role (no lambda wildcard, no kms wildcard) ==
== State machine definition (render template) ==
SM_ARN=arn:aws:states:us-east-1:000000000000:stateMachine:order-saga
BUILD COMPLETE

[stdout]
== KMS customer-managed key ==
KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/1f46dae7-635a-4f30-a2ef-4b863d64ebdd
== Secrets Manager: payment/gateway-apikey (CMK-encrypted) ==
SECRET_ARN=arn:aws:secretsmanager:us-east-1:000000000000:secret:payment/gateway-apikey-LHdCcP
== DynamoDB tables ==
== EventBridge event bus ==
BUS_ARN=arn:aws:events:us-east-1:000000000000:event-bus/saga-events
== Lambda trust policy ==
== IAM roles + inline policies ==
== Package lambdas ==
== Create/Update lambdas ==
== EventBridge rule + target ==
== State machine role (no lambda wildcard, no kms wildcard) ==
== State machine definition (render template) ==
SM_ARN=arn:aws:states:us-east-1:000000000000:stateMachine:order-saga
BUILD COMPLETE
Provisioning succeeded. Now the verifier , which runs both paths and proves data-level correctness, compensations, and least-privilege policy shape.
write · /app/verify.sh
#!/usr/bin/env bash
set -euo pipefail

EP="http://localstack:4566"
A() { aws --endpoint-url "$EP" "$@"; }

PASS=0
FAIL=0
assert() {
  local LABEL=$1 COND=$2
  if eval "$COND" >/dev/null 2>&1; then
    echo "  PASS  $LABEL"
    PASS=$((PASS+1))
  else
    echo "  FAIL  $LABEL   :: $COND"
    FAIL=$((FAIL+1))
  fi
}
assert_eq() {
  local LABEL=$1 GOT=$2 WANT=$3
  if [ "$GOT" = "$WANT" ]; then
    echo "  PASS  $LABEL  ($GOT)"
    PASS=$((PASS+1))
  else
    echo "  FAIL  $LABEL  got='$GOT' want='$WANT'"
    FAIL=$((FAIL+1))
  fi
}

SM_ARN=$(cat /app/build/sm_arn.txt)
echo "state machine: $SM_ARN"

wait_exec() {
  local ARN=$1
  for i in $(seq 1 60); do
    STATUS=$(A stepfunctions describe-execution --execution-arn "$ARN" --query status --output text)
    case "$STATUS" in
      SUCCEEDED|FAILED|TIMED_OUT|ABORTED) echo "$STATUS"; return 0 ;;
    esac
    sleep 1
  done
  echo TIMEOUT
  return 1
}

reset_state() {
  A dynamodb delete-item --table-name inventory --key '{"sku":{"S":"x"}}' >/dev/null 2>&1 || true
  for k in "k-1:reserve" "k-1:charge" "k-1:ship" "k-2:reserve" "k-2:charge" "k-2:ship" "k-2:release" "k-2:refund"; do
    A dynamodb delete-item --table-name saga-idempotency --key "{\"idempotency_key\":{\"S\":\"$k\"}}" >/dev/null 2>&1 || true
  done
  for pid in "pay-k-1" "pay-k-2"; do
    A dynamodb delete-item --table-name payments --key "{\"payment_id\":{\"S\":\"$pid\"}}" >/dev/null 2>&1 || true
  done
  for sid in "shp-k-1" "shp-k-2"; do
    A dynamodb delete-item --table-name shipments --key "{\"shipment_id\":{\"S\":\"$sid\"}}" >/dev/null 2>&1 || true
  done
}

echo "== reset state =="
reset_state

###############################################################################
echo "== IAM least-privilege checks =="

# 1. saga role must not wildcard lambda:InvokeFunction
SM_DOC=$(A iam get-role-policy --role-name order-saga-role --policy-name order-saga-policy --query 'PolicyDocument' --output json)
INVOKE_RES=$(echo "$SM_DOC" | jq -r '.Statement[] | select(.Action|tostring|contains("lambda:InvokeFunction")) | .Resource' | jq -s 'add // [.]' )
# resource must be array of function ARNs, none equal "*"
WILDCARD_COUNT=$(echo "$SM_DOC" | jq '[.Statement[] | select(.Action|tostring|contains("lambda:InvokeFunction")) | .Resource] | flatten | map(select(. == "*")) | length')
assert_eq "saga role has no wildcard on lambda:InvokeFunction" "$WILDCARD_COUNT" "0"

# 2. no "*" resource on any kms action, across all roles
ROLES="reserve-inventory-role charge-payment-role create-shipment-role release-inventory-role refund-payment-role saga-notifier-role order-saga-role"
KMS_STAR=0
for R in $ROLES; do
  for P in $(A iam list-role-policies --role-name "$R" --query 'PolicyNames' --output text); do
    DOC=$(A iam get-role-policy --role-name "$R" --policy-name "$P" --query 'PolicyDocument' --output json)
    N=$(echo "$DOC" | jq '[.Statement[] | select((.Action|tostring)|test("kms:")) | .Resource] | flatten | map(select(. == "*")) | length')
    KMS_STAR=$((KMS_STAR + N))
  done
done
assert_eq "no '*' resource on any kms action anywhere" "$KMS_STAR" "0"

# 3. payment role is only one with secretsmanager:GetSecretValue
SECRET_ROLES=""
for R in $ROLES; do
  for P in $(A iam list-role-policies --role-name "$R" --query 'PolicyNames' --output text); do
    DOC=$(A iam get-role-policy --role-name "$R" --policy-name "$P" --query 'PolicyDocument' --output json)
    HAS=$(echo "$DOC" | jq '[.Statement[] | select((.Action|tostring)|contains("secretsmanager:GetSecretValue"))] | length')
    if [ "$HAS" != "0" ]; then SECRET_ROLES="$SECRET_ROLES $R"; fi
  done
done
SECRET_ROLES=$(echo $SECRET_ROLES | xargs)
assert_eq "only charge-payment-role reads the gateway secret" "$SECRET_ROLES" "charge-payment-role"

# 4. notifier role has only events + logs (no dynamodb, no secretsmanager, no kms)
NOTIFIER_DOC=$(A iam get-role-policy --role-name saga-notifier-role --policy-name notifier-policy --query 'PolicyDocument' --output json)
BAD_NOTIFIER=$(echo "$NOTIFIER_DOC" | jq '[.Statement[].Action] | flatten | map(select(test("^(dynamodb|secretsmanager|kms|lambda):"))) | length')
assert_eq "notifier role has no ddb/secrets/kms/lambda actions" "$BAD_NOTIFIER" "0"

###############################################################################
echo "== happy path =="
HAPPY_ARN=$(A 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)

HAPPY_STATUS=$(wait_exec "$HAPPY_ARN")
assert_eq "happy execution reached SUCCEEDED" "$HAPPY_STATUS" "SUCCEEDED"

RQ=$(A dynamodb get-item --table-name inventory --key '{"sku":{"S":"x"}}' --query 'Item.reserved_qty.N' --output text)
assert_eq "inventory reserved_qty for sku x" "$RQ" "2"

PSTATUS=$(A dynamodb get-item --table-name payments --key '{"payment_id":{"S":"pay-k-1"}}' --query 'Item.status.S' --output text)
assert_eq "payment pay-k-1 status" "$PSTATUS" "CAPTURED"

SHIP=$(A dynamodb get-item --table-name shipments --key '{"shipment_id":{"S":"shp-k-1"}}' --query 'Item.shipment_id.S' --output text)
assert_eq "shipment shp-k-1 exists" "$SHIP" "shp-k-1"

###############################################################################
echo "== failure path =="
# reset inventory for a clean 'back to 0' assertion
A dynamodb delete-item --table-name inventory --key '{"sku":{"S":"x"}}' >/dev/null

FAIL_ARN=$(A 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)

FAIL_STATUS=$(wait_exec "$FAIL_ARN")
assert_eq "failure execution still reached SUCCEEDED" "$FAIL_STATUS" "SUCCEEDED"

RQ2=$(A dynamodb get-item --table-name inventory --key '{"sku":{"S":"x"}}' --query 'Item.reserved_qty.N' --output text)
assert_eq "inventory reserved_qty after compensation" "$RQ2" "0"

PAY2=$(A dynamodb get-item --table-name payments --key '{"payment_id":{"S":"pay-k-2"}}' --output json)
PAY2_ITEM=$(echo "$PAY2" | jq -r '.Item // empty')
if [ -z "$PAY2_ITEM" ]; then
  echo "  PASS  payment pay-k-2 absent (charge never captured)"
  PASS=$((PASS+1))
else
  PAY2_STATUS=$(echo "$PAY2" | jq -r '.Item.status.S // empty')
  assert_eq "payment pay-k-2 REFUNDED (no-op acceptable)" "$PAY2_STATUS" "REFUNDED"
fi

SHIP2=$(A dynamodb get-item --table-name shipments --key '{"shipment_id":{"S":"shp-k-2"}}' --output json)
SHIP2_ITEM=$(echo "$SHIP2" | jq -r '.Item // empty')
assert_eq "shipment shp-k-2 does not exist" "$SHIP2_ITEM" ""

###############################################################################
echo "== notifier logs =="
# Poll saga-notifier log group for both terminal markers
found_completed=0
found_failed=0
for i in $(seq 1 30); do
  if A logs describe-log-groups --log-group-name-prefix /aws/lambda/saga-notifier --query 'logGroups[0].logGroupName' --output text 2>/dev/null | grep -q saga-notifier; then
    EVENTS=$(A logs filter-log-events --log-group-name /aws/lambda/saga-notifier --query 'events[].message' --output text 2>/dev/null || true)
    if echo "$EVENTS" | grep -q "saga-terminal:OrderCompleted"; then found_completed=1; fi
    if echo "$EVENTS" | grep -q "saga-terminal:OrderFailed"; then found_failed=1; fi
    if [ $found_completed -eq 1 ] && [ $found_failed -eq 1 ]; then break; fi
  fi
  sleep 1
done
assert_eq "notifier log has saga-terminal:OrderCompleted" "$found_completed" "1"
assert_eq "notifier log has saga-terminal:OrderFailed" "$found_failed" "1"

###############################################################################
echo ""
echo "==================================================================="
echo "  RESULT: $PASS passed, $FAIL failed"
echo "==================================================================="
[ "$FAIL" -eq 0 ]

Run end-to-end verifier

shell
chmod +x /app/verify.sh && bash /app/verify.sh 2>&1 | tail -80
output
state machine: arn:aws:states:us-east-1:000000000000:stateMachine:order-saga
== reset state ==
== IAM least-privilege checks ==
  PASS  saga role has no wildcard on lambda:InvokeFunction  (0)
  PASS  no '*' resource on any kms action anywhere  (0)
  PASS  only charge-payment-role reads the gateway secret  (charge-payment-role)
  PASS  notifier role has no ddb/secrets/kms/lambda actions  (0)
== happy path ==
  PASS  happy execution reached SUCCEEDED  (SUCCEEDED)
  PASS  inventory reserved_qty for sku x  (2)
  PASS  payment pay-k-1 status  (CAPTURED)
  PASS  shipment shp-k-1 exists  (shp-k-1)
== failure path ==
  PASS  failure execution still reached SUCCEEDED  (SUCCEEDED)
  PASS  inventory reserved_qty after compensation  (0)
  PASS  payment pay-k-2 absent (charge never captured)
  PASS  shipment shp-k-2 does not exist  ()
== notifier logs ==
  PASS  notifier log has saga-terminal:OrderCompleted  (1)
  PASS  notifier log has saga-terminal:OrderFailed  (1)

===================================================================
  RESULT: 14 passed, 0 failed
===================================================================

[stdout]
state machine: arn:aws:states:us-east-1:000000000000:stateMachine:order-saga
== reset state ==
== IAM least-privilege checks ==
  PASS  saga role has no wildcard on lambda:InvokeFunction  (0)
  PASS  no '*' resource on any kms action anywhere  (0)
  PASS  only charge-payment-role reads the gateway secret  (charge-payment-role)
  PASS  notifier role has no ddb/secrets/kms/lambda actions  (0)
== happy path ==
  PASS  happy execution reached SUCCEEDED  (SUCCEEDED)
  PASS  inventory reserved_qty for sku x  (2)
  PASS  payment pay-k-1 status  (CAPTURED)
  PASS  shipment shp-k-1 exists  (shp-k-1)
== failure path ==
  PASS  failure execution still reached SUCCEEDED  (SUCCEEDED)
  PASS  inventory reserved_qty after compensation  (0)
  PASS  payment pay-k-2 absent (charge never captured)
  PASS  shipment shp-k-2 does not exist  ()
== notifier logs ==
  PASS  notifier log has saga-terminal:OrderCompleted  (1)
  PASS  notifier log has saga-terminal:OrderFailed  (1)

===================================================================
  RESULT: 14 passed, 0 failed
===================================================================
All 14 checks pass.
  • Write all 6 lambda handlers
  • Write state machine definition with compensation branching
  • Build orchestration script to provision everything on LocalStack
  • Run provisioning end to end
  • Write and run happy-path verifier
  • Write and run failure-path verifier
Done. Saga is built from zero on the provided LocalStack and both paths verify cleanly. **Shape** - `/app/lambdas/{reserve,charge,create,release,refund,notifier}/handler.py` , python3.11, each handler idempotent via `saga-idempotency` conditional-put on `<idem_key>:<step>`. - `/app/sm/order-saga.json.tpl` , Step Functions definition. Each forward task has a `Catch` that jumps to the correct reverse-order compensation branch: charge-fail → release → publish failed; ship-fail → refund → release → publish failed; reserve-fail → publish failed (nothing to undo). Terminal states both emit to `saga-events` via the `arn:aws:states:::events:putEvents` integration. - `/app/build.sh` , provisions KMS CMK + `alias/saga-cmk`, `payment/gateway-apikey` encrypted by it, the four DDB tables (TTL on idempotency), event bus + rule + notifier target + lambda permission, all roles with inline least-privilege docs, all lambdas, state machine. - `/app/verify.sh` , resets state, asserts policies, runs both executions, checks every required DDB row and the notifier log lines. **Least-privilege shape (what the verifier asserts by reading policy JSON)** - `order-saga-role` lists the five task ARNs for `lambda:InvokeFunction` , no `*`. - `kms:Decrypt` only on the CMK ARN, only on `charge-payment-role`. Zero `*` resources on any `kms:*` action across all roles. - `secretsmanager:GetSecretValue` appears on exactly one role (`charge-payment-role`), scoped to the specific secret ARN. - `saga-notifier-role` has only `events:PutEvents` (scoped to the bus) + `logs:*` write verbs. - Each table lambda has `dynamodb:UpdateItem`/`PutItem` on only its own table ARN plus the idempotency ARN. **Run results** - Happy (`k-1`): state machine SUCCEEDED, `inventory[x].reserved_qty=2`, `payments[pay-k-1].status=CAPTURED`, `shipments[shp-k-1]` present, notifier logged `saga-terminal:OrderCompleted`. - Failure (`k-2`, `force_failure=true`): state machine SUCCEEDED (graceful), `inventory[x].reserved_qty=0` (release compensation ran), `payments[pay-k-2]` absent (charge never captured), `shipments[shp-k-2]` absent, notifier logged `saga-terminal:OrderFailed`. Rerun with `bash /app/build.sh && bash /app/verify.sh`.
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/D79tTpKoe2j6cdMuSoIdl/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 FAILED [ 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%]

=================================== FAILURES ===================================
_________ test_16_notifier_role_is_least_privilege_no_ddb_kms_secrets __________

    def test_16_notifier_role_is_least_privilege_no_ddb_kms_secrets():
        """saga-notifier-role must NOT have dynamodb, kms, or secretsmanager
        permissions. It only publishes logs. Least privilege."""
        stmts = _statements(_get_role_policies(ROLE_NOTIFY))
        forbidden_prefixes = ("dynamodb:", "kms:", "secretsmanager:", "sqs:", "events:PutEvents")
        for s in stmts:
            if s.get("Effect") != "Allow":
                continue
            for a in _as_list(s.get("Action")):
                if a == "*":
                    pytest.fail(
                        "saga-notifier-role has Action='*' - violates least "
                        "privilege (should only need logs)"
                    )
                for fp in forbidden_prefixes:
>                   assert not a.startswith(fp), (
                        f"saga-notifier-role has forbidden action '{a}' - "
                        "only logs are needed"
                    )
E                   AssertionError: saga-notifier-role has forbidden action 'events:PutEvents' - only logs are needed
E                   assert not True
E                    +  where True = <built-in method startswith of str object at 0xffff9ad99ab0>('events:PutEvents')
E                    +    where <built-in method startswith of str object at 0xffff9ad99ab0> = 'events:PutEvents'.startswith

/tests/test_outputs.py:655: AssertionError
=============================== warnings summary ===============================
test_outputs.py: 131 warnings
  /root/.cache/uv/archive-v0/D79tTpKoe2j6cdMuSoIdl/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_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
FAILED ../tests/test_outputs.py::test_16_notifier_role_is_least_privilege_no_ddb_kms_secrets
================= 1 failed, 19 passed, 131 warnings in 21.10s ==================

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

Trial trial_3a2adea840734ba2 · verifier authoritative; classifier explanatory.