DEV Community

Haku
Haku

Posted on

Six banks published agent-payment guardrails. They're voluntary — so I built the enforcement.

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_customer even 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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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:

  1. The charge carries its evidence_id (it's on the receipt and in your ledger).
  2. You pull the record: agent_id, transaction type, merchant, amount, the exact rule that fired, timestamp — and the hash chain verifies intact.
  3. 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)

Collapse
 
koda2026 profile image
Harun - solo dev •

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_spend validation: in a high-throughput environment, checking daily_spend and 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. 🐯🛡️