Audit aid, not legal advice. This article and the kit it describes are engineering tools for defining and enforcing your own agent-payment policies. They don't certify compliance with any bank framework, and nothing here is legal counsel.
On Oct 9–10, 2026, six banks published a voluntary guardrail framework for "agentic commerce" — AI agents that initiate payments (reported by Forkast via OceanAlt). The framework names the three genuinely hard questions: agent identity verification, transaction authorization scope, and dispute resolution. Then, to its credit, it admits the whole thing is voluntary — self-enforced, with no unified audit, no cross-institution recognition, and no substantive penalties for violators.
Read that twice. The institutions that move the money published a framework with no enforcement mechanism. This is the October pattern: every mandate arrives without the machinery to enforce it. The White House SI Force told every AI company that incident disclosure is "not optional" — and named no penalties. A governor's AI kill-switch proposal is a commissioned design study, not a control you can deploy. The framework is a reference template. The enforcement has to live in your stack, or it doesn't exist.
A budget cap is not a scope
"The agent may spend $50 a month" is not a scope. It's a number on a dashboard. It answers "how much" while ignoring every question that matters when money actually moves:
-
Should this transaction exist at all? (type — is
refund_customereven something this agent may initiate?) - With this counterparty? (merchant — the allowlist, not the vibe)
- Initiated by which agent? (identity — when the payee asks "who paid me," you need an answer)
- With what evidence left behind? (audit — the record you'll need when the charge is disputed)
A scope is a predicate over (agent, type, counterparty, amount, time). A cap is one input to that predicate. Teams that confuse the two discover the difference during their first dispute, when "we had a $50 cap" turns out to answer none of the investigator's questions.
Building block 1: the agent identity registry
Every agent gets a transaction identity before it can pay. Not a display name — an identity record with an owner, an allowlisted set of transaction types, and numeric bounds:
from dataclasses import dataclass
@dataclass
class AgentIdentity:
agent_id: str
owner: str # the human accountable for this agent
allowed_types: set[str] # allowlisted transaction types
per_tx_cap: float # max single transaction
daily_cap: float # max per type per day
approval_threshold: float # above this -> HOLD for a human
REGISTRY: dict[str, AgentIdentity] = {}
def register(agent: AgentIdentity) -> None:
REGISTRY[agent.agent_id] = agent
The rule is absolute: no registry entry, no payment. Unknown agents fail closed — as far as the payment path is concerned, they don't exist.
Building block 2: scope rules
Scope rules combine the allowlist with three numeric controls, each bounding a different failure mode:
- Per-transaction caps bound single-event blast radius. One bad decision costs at most X.
- Per-type daily caps bound accumulation. A thousand individually reasonable charges still can't exceed the daily budget for that type.
-
Approval thresholds convert spending into requesting above a line. Cross the threshold and the decision becomes
HOLD_FOR_APPROVAL— the money doesn't move until a human says so.
Caps limit damage. Thresholds insert judgment. The allowlist defines the universe of permissible actions. You need all three; any two leave a hole.
Building block 3: the dispute-evidence log
Append-only and hash-chained. Every ALLOW, HOLD, and DENY lands here with an evidence_id — including the denies, especially the denies:
import hashlib, json, time
LOG: list[dict] = []
def log_evidence(decision: str, agent_id: str, detail: dict) -> str:
prev = LOG[-1]["hash"] if LOG else "GENESIS"
record = {
"evidence_id": f"ev_{len(LOG):06d}",
"ts": time.time(),
"decision": decision,
"agent_id": agent_id,
"detail": detail,
"prev_hash": prev,
}
record["hash"] = hashlib.sha256(
json.dumps(record, sort_keys=True).encode()
).hexdigest()
LOG.append(record)
return record["evidence_id"]
The hash chain is the point: alter any row and the chain breaks. When a customer, a processor, or an auditor asks what happened, you hand them records, not recollections.
The decision engine: POST /authorize
Below is a simplified illustration of the validator that ships in the kit (the full FastAPI app carries the complete implementation with 17 passing tests). Check order is deliberate — cheapest and most decisive checks first, evidence written for every decision:
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
KILL_SWITCH = False # account-level kill switch
@app.post("/authorize")
def authorize(agent_id: str, tx_type: str, merchant: str, amount: float):
# 1. Kill switch: everything stops, immediately.
if KILL_SWITCH:
ev = log_evidence("DENY", agent_id, {"reason": "kill_switch"})
raise HTTPException(status_code=402, detail={"evidence_id": ev})
# 2. Unknown agent: fail closed.
agent = REGISTRY.get(agent_id)
if agent is None:
ev = log_evidence("DENY", agent_id, {"reason": "unknown_agent"})
raise HTTPException(status_code=402, detail={"evidence_id": ev})
# 3. Type not allowlisted: DENY.
if tx_type not in agent.allowed_types:
ev = log_evidence("DENY", agent_id,
{"reason": "type_not_allowlisted",
"tx_type": tx_type})
raise HTTPException(status_code=402, detail={"evidence_id": ev})
# 4. Per-transaction cap: DENY.
if amount > agent.per_tx_cap:
ev = log_evidence("DENY", agent_id,
{"reason": "per_tx_cap", "amount": amount})
raise HTTPException(status_code=402, detail={"evidence_id": ev})
# 5. Approval threshold: HOLD for a human (202).
if amount > agent.approval_threshold:
ev = log_evidence("HOLD_FOR_APPROVAL", agent_id,
{"reason": "approval_threshold",
"amount": amount})
return JSONResponse(
{"decision": "HOLD_FOR_APPROVAL", "evidence_id": ev},
status_code=202,
)
# 6. Per-type daily cap: DENY.
if daily_spend(agent_id, tx_type) + amount > agent.daily_cap:
ev = log_evidence("DENY", agent_id,
{"reason": "daily_cap", "amount": amount})
raise HTTPException(status_code=402, detail={"evidence_id": ev})
# 7. Otherwise: ALLOW.
ev = log_evidence("ALLOW", agent_id,
{"tx_type": tx_type, "merchant": merchant,
"amount": amount})
return {"decision": "ALLOW", "evidence_id": ev}
Three properties worth noting. Denies are hard 402s — HTTP's own "payment required," repurposed as the protocol's "no." Holds return 202 and the money waits for a human; nothing auto-retries its way into an approval. And evidence is written before the response is built, so the log is complete even for decisions the caller never sees.
A dispute, walked through
It's Tuesday. A customer writes: "Which agent charged me $18.40 on Sunday?"
Without an evidence log, this is archaeology — grepping request logs, correlating timestamps, guessing. With one, it's a lookup:
- The charge carries its
evidence_id(it's on the receipt and in your ledger). - You pull the record:
agent_id, transaction type, merchant, amount, the exact rule that fired, timestamp — and the hash chain verifies intact. - You forward the record to the customer. The conversation is now about a specific decision with a specific reason, not about vibes.
That's the whole pitch: disputes stop being mysteries and start being queries.
Get the kit
I packaged all of this as AgentScope: the FastAPI scope validator (17 passing tests), plus policy, identity-record, dispute-log, and review-checklist templates you can adapt to your own stack. $39 one-time. By Haku.
https://vittoriali.gumroad.com/l/xfugeg
Audit aid, not legal advice.
Top comments (1)
haku, this is a masterclass in translating voluntary compliance into enforceable, deterministic code.
the distinction between a simple "budget cap" and a true "scope" predicate is exactly the mental model shift engineering teams need. repurposing http 402 for DENY and 202 for HOLD is also a brilliantly semantic use of the protocol.
regarding the check order and the
daily_spendvalidation: in a high-throughput environment, checkingdaily_spendand then appending to the hash-chained log introduces a classic time-of-check to time-of-use (TOCTOU) race condition. are you wrapping the authorization check and the log append in a distributed lock (e.g., redis redlock) or a database transaction with row-level locking (SELECT FOR UPDATE) to prevent concurrent transactions from slipping past the daily cap?additionally, for the hash chain at scale, have you considered migrating to a sparse merkle tree structure? it would allow you to provide O(log n) cryptographic proofs of inclusion to an auditor without requiring them to download and verify the entire append-only log.
incredible work packaging this. the 17 passing tests give me a lot of confidence in the kit's reliability. 🐯🛡️