tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.List working directory contents
ls -la /app/
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
which aws python3 jq zip && aws --version && python3 --version
/usr/local/bin/aws /opt/venv/bin/python3 /usr/bin/jq /usr/bin/zip aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24 Python 3.12.3 [stdout] /usr/local/bin/aws /opt/venv/bin/python3 /usr/bin/jq /usr/bin/zip aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24 Python 3.12.3
Check LocalStack health
curl -s http://localstack:4566/_localstack/health | jq .
{
"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"
}Create project directory layout
mkdir -p /app/lambdas/{reserve-inventory,charge-payment,create-shipment,release-inventory,refund-payment,saga-notifier} /app/policies /app/sm /app/build(Bash completed with no output)
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}
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"}
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"}
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}
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
{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
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}
{
"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
}
}
}
#!/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
chmod +x /app/build.sh && bash /app/build.sh 2>&1 | tail -80
== 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
#!/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
chmod +x /app/verify.sh && bash /app/verify.sh 2>&1 | tail -80
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 ===================================================================
No file changes, the agent left the workspace untouched.
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.1, pluggy-1.6.0 -- /root/.cache/uv/archive-v0/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.