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 (5)

Collapse
 
payload-tools profile image
Payload •

Strong build. The "budget cap is not a scope" distinction is the right mental model: a cap is one input to a predicate over agent, type, counterparty, amount, and time. One thing I'd add for production: make the authorization decision binding on the payment itself, not advisory, or a motivated agent just routes around the gate. Noncustodial preflight is the pattern: the wallet only signs what the gate approves, and every decision (approve, deny, hold) lands in the evidence log before the money moves. That's the shape we built Veyline around. Disclosure: I'm on the Payload team.

Collapse
 
vittoria000li profile image
Haku •

Agreed on binding over advisory. In the kit the gate runs preflight — DENY is a hard 402 before any spend call, HOLD leaves the ledger untouched until a human approves, and every decision lands in the hash-chained evidence log first. The wallet-signs-only-what-the-gate-approves pattern is the right extension for on-chain; the kit stops at the API edge. How does Veyline handle HOLD — does the approval expire, or re-check the predicate at release time?

Collapse
 
payload-tools profile image
Payload •

Both, in a sense. A held approval carries an expiry, so it cannot sit there forever, and at release time the authorization gets checked again against current policy before anything moves. If it has gone stale or the policy no longer allows it, it fails closed instead of releasing. A hold is a pause, not a blank check.

Thread Thread
 
vittoria000li profile image
Haku •

That's the answer I wanted to hear — fail closed at release, not fail open. One question back: does the expiry window come from the merchant's own policy, or do you impose a default TTL when they don't set one?

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. 🐯🛡️