Your coding agent just invoked apply_patch twice for the same tool_call_id. The model did not “decide” to double-spend — Lambda retried after a timeout, Step Functions retried the Task, and your three separate PutItem calls (debit quota, mark effect applied, bump session cursor) partially committed. One path wrote the side-effect row; another path charged 50k tokens again. DynamoDB Transactions (TransactWriteItems) are the unfair advantage: bind quota debit + idempotent effect ledger + session stamp into one atomic unit so retries are safe by construction. Pair with DynamoDB Streams session expiry for cleanup and S3 Conditional Writes for artifact uploads — this post is the ledger layer.
⚡ TL;DR: Model each tool side effect as a ledger row keyed by
tenant#session#tool_call_id. UseTransactWriteItemsto debit quota, insert the ledger row with a conditionattribute_not_exists(pk), and update session state together. OnTransactionCanceledExceptionwith ConditionalCheckFailed, treat as success (already applied). Related: Streams session expiry, Step Functions agent graphs, Verified Permissions Cedar.
Why separate PutItems fail under agent retries
Coding-agent runtimes retry aggressively: API Gateway timeouts, Lambda 15s cold paths, Bedrock throttles, Step Functions Retry on States.TaskFailed. If your tool runner does:
UpdateItemquota − costPutItemeffect appliedUpdateItemsession cursor
…then a crash between 1 and 2 leaves a debited quota with no effect, and a crash between 2 and 3 leaves an applied effect with a stale cursor that the planner re-issues. Idempotency keys in Redis help for caches (ElastiCache scratchpads), but the source of truth for spend and side effects belongs in DynamoDB with transactional semantics.
| Pattern | Atomic? | Retry-safe? | When it breaks |
|---|---|---|---|
| Three Put/Update | ❌ | ❌ | Any mid-flight timeout |
| Conditional Put only on effect | Partial | Mostly | Quota still double-debits |
| TransactWriteItems (quota+ledger+session) | ✅ | ✅ | Only on true business conflict |
| Saga + compensating debit | Soft | Complex | Compensations fail too |
Schema: tool ledger + quota + session
Keep three item shapes in one table (single-table friendly) or three tables — transactions work across tables in the same account/region (up to 100 items).
// ✅ Keys designed for idempotent tool side effects
// Ledger: PK = TENANT#acme#SESSION#s1 SK = TOOL#call_abc123
// Quota: PK = TENANT#acme SK = QUOTA#2026-09
// Session:PK = TENANT#acme#SESSION#s1 SK = META
type ToolLedgerItem = {
pk: string;
sk: string;
tool_name: string;
status: "applied" | "rejected";
cost_tokens: number;
created_at: string;
ttl?: number;
};
type QuotaItem = {
pk: string;
sk: string;
remaining_tokens: number;
hard_cap: number;
};
// ❌ Soft "mark done" without uniqueness
await ddb.put({
TableName: "agents",
Item: { pk: sessionId, sk: `effect#${Date.now()}`, tool: name },
});
// Same tool_call_id can insert forever under retries
TransactWriteItems for debit + ledger + session
Authorize the tool first (Verified Permissions), then commit the ledger atomically before or immediately after the external side effect depending on your risk model. Prefer ledger-first with pending → applied for irreversible tools (git push, ticket create); for reversible tools, apply then ledger.
import {
DynamoDBClient,
TransactWriteItemsCommand,
TransactionCanceledException,
} from "@aws-sdk/client-dynamodb";
const ddb = new DynamoDBClient({});
export async function commitToolLedger(opts: {
tenantId: string;
sessionId: string;
toolCallId: string;
toolName: string;
costTokens: number;
nextCursor: string;
}) {
const ledgerPk = `TENANT#${opts.tenantId}#SESSION#${opts.sessionId}`;
const ledgerSk = `TOOL#${opts.toolCallId}`;
const month = new Date().toISOString().slice(0, 7); // YYYY-MM
try {
await ddb.send(
new TransactWriteItemsCommand({
TransactItems: [
{
Update: {
TableName: "AgentRuntime",
Key: {
pk: { S: `TENANT#${opts.tenantId}` },
sk: { S: `QUOTA#${month}` },
},
UpdateExpression:
"SET remaining_tokens = remaining_tokens - :c",
ConditionExpression: "remaining_tokens >= :c",
ExpressionAttributeValues: {
":c": { N: String(opts.costTokens) },
},
},
},
{
Put: {
TableName: "AgentRuntime",
Item: {
pk: { S: ledgerPk },
sk: { S: ledgerSk },
tool_name: { S: opts.toolName },
status: { S: "applied" },
cost_tokens: { N: String(opts.costTokens) },
created_at: { S: new Date().toISOString() },
},
ConditionExpression: "attribute_not_exists(pk) AND attribute_not_exists(sk)",
},
},
{
Update: {
TableName: "AgentRuntime",
Key: {
pk: { S: ledgerPk },
sk: { S: "META" },
},
UpdateExpression: "SET cursor = :cur, updated_at = :ts",
ExpressionAttributeValues: {
":cur": { S: opts.nextCursor },
":ts": { S: new Date().toISOString() },
},
},
},
],
})
);
return { applied: true, duplicate: false };
} catch (err) {
if (err instanceof TransactionCanceledException) {
const reasons = err.CancellationReasons ?? [];
// Index 1 = ledger Put conditional — already applied
if (reasons[1]?.Code === "ConditionalCheckFailed") {
return { applied: true, duplicate: true };
}
if (reasons[0]?.Code === "ConditionalCheckFailed") {
return { applied: false, duplicate: false, reason: "quota_exhausted" };
}
}
throw err;
}
}
Wire this into your Step Functions tool Task (agent graphs): on duplicate: true, return the cached tool result from Redis/S3 instead of re-running the side effect.
Pending → applied for irreversible tools
For git_push, create_pr, or paid API calls, use a two-phase ledger inside one or two transactions:
- Reserve: Transact
Putledgerstatus=pending+ debit quota (condition not exists). - Perform external side effect.
- Finalize:
Updateledgerpending → applied(condition status=pending).
If step 2 fails, a sweeper (or DynamoDB Streams on TTL) refunds quota for stale pending rows older than N minutes. Do not leave orphan debits without a sweeper — that is how tenants “lose” budget mysteriously.
// ✅ Finalize only if still pending (second writer loses cleanly)
UpdateExpression: "SET #s = :applied",
ConditionExpression: "#s = :pending",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: {
":applied": { S: "applied" },
":pending": { S: "pending" },
},
Limits, IAM, and failure modes you must handle
- 100 actions / 4 MB per transaction — keep ledger rows small; put patch blobs in S3 with If-None-Match.
- Idempotent retries: same
ClientRequestToken(optional) + stabletool_call_id. - IAM: grant
dynamodb:ConditionCheckItem,PutItem,UpdateItem,TransactWriteon the table ARNs only — not*. - Hot partitions:
TENANT#idquota keys can throttle under bursty agents; shard quota by hour (QUOTA#YYYY-MM-DD#HH) if a single tenant burns thousands of tools/min. - Observability: log
tool_call_id,duplicate,reasonas structured JSON for Logs Insights forensics.
| Symptom | Likely cause | Fix |
|---|---|---|
| Double PR created | Ledger after side effect, no pending | Pending-first + sweeper |
| Quota jumps by 2× | Non-transactional debit | Move debit into TransactWrite |
| Frequent TransactionConflict | Hot session META | Soft cursor in Redis; META less often |
| ConditionalCheckFailed on quota | Cap hit | Surface to planner; AppConfig kill switch |
Production checklist
- [ ] Every irreversible tool has a stable
tool_call_idfrom the planner (notuuid()inside the tool Lambda). - [ ] Quota debit + ledger Put + session update are one
TransactWriteItems(or pending/finalize pair). - [ ]
ConditionalCheckFailedon ledger Put is treated as success/duplicate, not error. - [ ] Sweeper refunds stale
pendingrows; Streams TTL hooks clean sessions. - [ ] IAM least-privilege on table; no god-key env credentials (KMS grants pattern).
- [ ] Metrics:
ToolLedgerDuplicate,QuotaExhausted,TxnConflictper tenant. - [ ] Load-test retry storms (Lambda + Step Functions Retry) prove zero double side effects.
Transactions do not make bad tool design safe — they make honest retries safe. Put the ledger first, name the tool_call_id, and stop treating DynamoDB like three lucky PutItems.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Distributed Locks Reality Check: When Redis Redlock Is the Wrong Tool
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
- Python asyncio vs Threading: The Benchmark That Changes How You Think About Concurrency
- LLM context windows: Tokens, Context Windows, and Why Models Forget Mid-Task
Newly added
- AWS Budgets + Cost Anomaly Detection: Cap Runaway Coding-Agent Spend
- OpenSearch Serverless: Semantic Scratch Memory for Multi-Turn Coding Agents
- ECR + Lambda Container Images: Heavyweight Coding Tools Without Zip Limits
- CloudTrail Lake: Query Agent IAM Abuses Without Spreadsheets
- DynamoDB Transactions: Atomic Tool-Ledger Writes for Coding Agents
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
Already subscribed? or open the subscribe page.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.