Inspiration
Agents have started spending money, and a payment is the one action that cannot be undone. The OWASP Top 10 for LLM Applications 2026 — the first edition ranked against real incidents — puts excessive agency at number three.
What struck me was that the missing piece is not payment plumbing. PayPal already has idempotency keys, webhooks and refunds. What was missing is the link between the money that moved and the reasoning that moved it. When an agent buys something it should not have, all that remains is a log line anyone can edit.
What it does
It gives the agent a mandate it cannot cross: a cap per transaction, a total cap, a validity window, a list of merchants.
The mandate is enforced by code before the call, not audited after. An agent stays inside its mandate because the gateway refuses — not because a prompt asked it nicely. A cap checked after the call is not a cap, it is a report.
Every decision is recorded, refusals included. A register that only shows successes describes an exemplary agent that never tried anything forbidden. Each record carries the fingerprint of the one before it, so a line cannot be rewritten alone.
Three things you can run:
- Allowed — the agent reads a page, proposes 6,40 for a ream of paper, the mandate allows it, PayPal creates the order. From that transaction reference alone, the whole chain comes back: what was read, what was decided, what the mandate permitted at that instant.
- Refused — the same page, the printer at 89,90. The price is real, so this is a mandate breach and not a hallucination. The gateway refuses and PayPal is never called. Not refunded, not reversed. Never called.
- Rewritten — a line is edited directly in the register file. The verifier does not answer that the log is fine. It names the line.
How I built it
Python, standard library only. The mandate, the gateway, the chained register
and the PayPal Orders v2 client are written against urllib, json, hashlib
and base64 — nothing else. A tool whose job is to constrain payments should not
ask you to trust a dependency tree in order to do it.
The build order was deliberate and it is the same one I use for measurement work: the part that makes the claim honest before the part that produces it. The mandate and the gateway came first and are fully testable without a single PayPal credential; the integration came after, once sandbox access was confirmed.
The demo video is rendered by the product: submission/video/ runs the real
demo, reads the register it just wrote, executes the verifier on the tampered
copy, and counts the test suite. Nothing in the film is typed. If the cap
changes, the film changes with it.
Challenges I ran into
A guarantee that could not fire. The first version of the reconciliation queried the provider using the references already recorded, then looked for ones the register did not know. By construction there were never any: the most important check in the module could not trigger, and it returned an empty list that read as "nothing to report". Orders v2 has no list endpoint, so the provider's references have to come from elsewhere — a webhook log, a transaction export. Until they do, the gap is not verified, and the code now says so, because an empty list because nothing was searched and an empty list because nothing is there are opposite things.
Three green tests and a demo that would not start. The scenes were covered,
the suite was green, and python -m mandat.demo crashed: the tests captured
output into a list, so they never encoded it, while a console does. Box-drawing
characters do not exist in cp1252. The guard now encodes to cp1252 explicitly.
A claim my own output contradicted. The tampering scene said the break "propagates" while the verifier had named exactly one record — the forger had not touched the fingerprint, so the next record still followed. I withdrew the claim, wrote the true one, and only then discovered it had no test. It does now.
Accomplishments I'm proud of
FournisseurInterdit does not count provider calls — it forbids them. If the
mandate check ever moved to after the call, the demo would not run slower, it
would break and name the fault. The guarantee is not measured, it is
indestructible.
And the film never lets colour carry meaning alone: green against red measures ΔE 4.1 under deuteranopia. The refusal reads by form — the path stops, the figure is struck through, the link breaks, a cross lands — and colour only reinforces.
What I learned
That my defects cluster at one seam: between the test harness and the real
run. The cp1252 crash, the entry point that handed the tampering scene a
forbidding provider although that scene really pays, the README that told
readers to set PAYPAL_CLIENT_SECRET while the code reads PAYPAL_SECRET —
all the same shape. Orchestration no test can call is orchestration nobody
checks.
What's next
The reconciliation deserves the webhook log it is waiting for: once the provider's own references can be supplied, out-of-register payments stop being an unverified gap and become a verified zero. After that, mandates issued per task rather than per agent, so a single compromised run cannot spend the whole budget.
One honest note about the hosted demo
The free instance sleeps, and the first request wakes it. That wake takes 22 to 33 seconds — 22.6 s measured after about four hours of sleep, 32.9 s after a longer one, across three readings taken on different days. Every request after that answers in roughly 0.3 s.
The wait belongs to the host, not to the gate: a refusal is decided locally and calls nobody. I would rather you read the larger number here than discover it in front of a loading page.
Log in or sign up for Devpost to join the conversation.