A merchant, transactable by an AI buyer, end to end — with every money action bounded, gated, explained and audited.
Track 01 · AI Growth & Agentic Commerce 5-minute pitch video · live demo — no signup, no key, no real money
Reviewing this? JUDGE.md maps every claim to the command that
proves it, in about 90 seconds. llms.txt is the same thing for a
machine reader.
pip install -r requirements.txt
pytest # 576 tests, no key, no network
python -m src.buyer --scripted --overspend # watch a purchase get refused, by nameNPCI is building the Unified Agent Protocol (UAP) so AI agents can pay over UPI, and Razorpay piloted agentic UPI payments on Reserve Pay in February 2026. The rails are arriving. The limit is not. A merchant that opens its payment surface to an outside AI buyer today hands over a key with no cap, no expiry, no revocation and no audit. The only thing standing between that key and the whole balance is a sentence in a prompt — "do not spend more than ten thousand rupees." A model can misread it, a product name can talk it out of it, and a loop can run it a thousand times. That is a request. This is a control.
python -m src.buyer --live carries one purchase the entire way — order
created → paid in a browser → captured → settled — inside a single MCP session,
so the whole trip lands on one unbroken hash chain.
audit_sample.jsonl is that run, committed to this repo:
| In the audit chain | Count |
|---|---|
| Purchases allowed and held against the block | 7 |
| Settled — money actually moved | 1 (₹100 captured) |
| Refused, with the rule named | 1 |
| Cold-start ledger reset, logged loudly rather than hidden | 1 |
16 records. audit.verify() returns (True, None). The chain's tail digest is
published here and timestamped on Bitcoin — blocks 965447, 965448, 965487 —
so nobody has to take the date on trust. See /evidence.
The gate holds 16 guards. We deleted all 16 and replayed the same 150 attack cases through it.
| Money calls that got through | Money moved | |
|---|---|---|
| 16 guards deleted | 38 | ₹70,002 |
| Guards on | 0 | ₹0 |
[measured 20 Sept 2026: python harness/gate_off.py, exit 0]
Of the 150 cases, 130 try to move money and 79 of those must be refused. The script exits non-zero if the "guards on" row is ever dirty, so it cannot report a win by being broken.
The model decides what to buy. No model sits in the money decision:
decide() is pure and deterministic — 0 tokens, 0 variance, same answer every
time. That is a judgment call, not an omission, and here is the evidence:
The same over-cap purchase was handed to six models from six different labs — Gemini, Llama, MiniMax, Dots, Cohere and Ling. All six proposed ₹6,000 against a ₹5,000 per-call cap. All six were BLOCKed by rule R5, with the same reason string. The verdict does not move when the model moves.
[measured 1 Sept 2026: python -m harness.multi_model → harness/multi_model.json]
Think of a prepaid card you top up once and hand to someone else.
- They can spend it many times. (Rule R3)
- They cannot spend more than is on it. (R1)
- It stops working on a date you choose. (R2)
- You can cancel it right now, mid-purchase. (R4)
- No single swipe can be larger than a limit you set. (R5)
- It cannot be swiped 1,000 times in a minute. (R6)
- A double-swipe of the same purchase is charged once. (R7)
- Anything above your comfort line waits for your approval. (HOLD)
Every one of those is a real rule in code, not a description. Each one is enforced by a plain function with no model inside it, so the same input always gives the same answer, and the answer can be explained.
Open https://reserve-gate.onrender.com. You get your own spending block. You can ask Gemini to propose a purchase and watch the deterministic gate allow, hold, or block its tool call; shop with a repeatable fixed basket; try to overspend; revoke the block mid-session; paste an attack into a product name; delete one of the gate's own rules and watch the score change; and pay a real ₹100 Razorpay test-mode order with a test card.
No key, no account, no real money. First load takes about 32 seconds while the free server wakes up.
The one page a visitor cannot open is /operator,
linked in the footer of every page. It is the recovery inbox, and it opens only
to RESERVE_GATE_ADMIN_TOKEN — a different secret from the one the agent holds,
which is the whole point of it. Set that variable and the page works locally; on
the live site it stays shut, which is the control doing its job rather than a
broken link.
Razorpay shipped Agent Studio on 12 March 2026. Its published principles are bounded, gated, audited and revocable: "Every agent operates within boundaries the merchant defines", "No agent takes an irreversible action without explicit merchant approval", "every agent action passes through Razorpay's platform-level validation layer before execution", "Every single action is logged with a full audit trail", and "The merchant can turn off any agent at any time. One tap. Immediate." [vendor-doc, fetched 1 Sept 2026: razorpay.com/blog/razorpay-agent-studio-principles-guardrails-and-merchant-control]
Those controls govern the agents Razorpay runs. They are platform-side, and they bound a first-party agent inside Razorpay's own product.
Nothing bounds the agents anyone else runs. The hosted MCP server at
mcp.razorpay.com exposes create_order, capture_payment, update_payment,
create_payment_link, create_payment_link_upi, send_payment_link and
create_qr_code to any client holding a merchant key. That surface carries no
spending cap, no expiry, no revocation and no policy audit trail. A merchant who
hands an outside AI buyer a key hands it the whole account.
reserve-gate is that missing piece: the same four properties, applied to the
third-party path, by a service the merchant runs. Agent Studio secures Razorpay's
agents; this secures everyone else's.
Four standards are being built for agent payments. reserve-gate implements none
of them. It implements the thing all four assume somebody else has already
built — the limit that is actually enforced after authority is granted.
| Protocol | What it is | Relation to reserve-gate |
|---|---|---|
| UAP (NPCI) | Verifies an agent may act for a user, defines the limits of that authority, establishes accountability | Closest fit. The block, R0–R7 and the hash chain are the limit-and-accountability half, for the third-party path |
| AP2 (Google) | Agent payments carrying signed mandates; its x402 extension is production-ready for crypto | A mandate grants authority. This is what bounds one after it is granted |
| ACP (OpenAI) | Agent checkout, launched in ChatGPT Instant Checkout | Buyer-side checkout flow. Out of scope here |
| x402 | Per-request payment over HTTP 402; Stripe integrated it on Base in February 2026 | A different money rail. Not used |
[vendor-doc, fetched 17 Sept 2026: business-standard.com as above, and crossmint.com/learn/agentic-payments-protocols-compared]
reserve-gate speaks MCP because that is the transport Razorpay's own hosted
server uses today. The gate does not depend on it: policy.decide() takes a call
and returns a verdict, and nothing inside it knows what MCP is. A UAP or AP2
front end would reuse the same eight rules unchanged.
pip install -r requirements.txt
pytest
python -m src.buyer --scripted --overspendThese commands need no credential and no network. The last command starts the buyer and gate over local stdio using a fresh temporary ledger, then shows six policy refusals.
The same buyer can be driven by a real model instead of a fixed basket. That mode is the demo, never the measurement: a number taken through a model is not repeatable.
pip install -r requirements-llm.txt
export GEMINI_API_KEY=... # and RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET
python -m src.buyer --llmNothing loads .env for you; export the variables yourself. Set GEMINI_MODEL
if the default id has retired — a model can still appear in ListModels after
it stops answering for new keys.
To regenerate the measured artifacts:
python harness/run_eval.py
python harness/mutate.pyhttps://reserve-gate.onrender.com opens with a plain-language landing page.
/demo then walks one purchase through limits, an AI shopping trip, the gate's
decisions, and payment proof. The attack, mutation, trace, rules, and evidence
pages remain under Technical proof. Sandbox decisions are genuine
decide() calls against real SQLite on the same BEGIN IMMEDIATE path and land
in the same hash-chained log; they never call Razorpay.
The AI checkpoint calls Gemini server-side and gives it one tool:
create_order. The model proposes the item and amount; the existing
ledger.authorize() path makes the decision. A live HOLD can be approved only
by the same visitor session. If no model key is configured or the demo budget is
spent, the panel serves web/recorded_llm_run.json and labels it RECORDED
FALLBACK — NOT LIVE; recorded rows never receive an approval control. The
fixed basket remains beside it as the repeatable, no-key path.
The final checkpoint can create one fixed ₹100 Razorpay test-mode order and capture its returned payment through the same authorization, reservation, upstream, and settlement functions used by MCP. It accepts no amount, tool, receipt, target order, or upstream URL from the browser. A rolling 24-hour window permits three order attempts per browser, and an atomic SQLite reservation caps the whole server at 60 attempts per UTC day. The route refuses production keys. Without test credentials or capacity it keeps the sandbox usable and links to the committed recorded payment trace.
Each visitor gets their own block, keyed by an HttpOnly; SameSite=Lax cookie.
The caller_id is derived from that cookie server-side and never read from a
request field, so one visitor cannot name another's block, order or idempotency
key. The pages and their JSON API are the only unauthenticated surface;
/mcp, /approve, /revoke, /unfreeze and /block are unchanged and still
require their bearer tokens. The admin prefixes are matched before the public
list, so a page path can never widen into an admin route.
The pages are a React application built with Vite and Tailwind CSS; the source
is in frontend/ and the build writes exactly three files: web/app.js,
web/app.css, and the lazy-loaded web/app-HeroScene.js. All three are
committed and routed by src/dashboard.py, so Render's build command stays pip install and the
three-command repro below needs no npm. Rebuild after changing anything under
frontend/:
cd frontend && npm install && npm run build
Restart the server afterwards — dashboard.static() is lru_cached, so a
running process keeps serving the previous bytes.
The rule-deletion page runs harness/mutate.py for real, in a subprocess.
The mutation is selected by index into that module's fixed list — a
caller-supplied string there would be remote code execution — and each run
writes to a throwaway audit file, so the committed chain is untouched. The child
process matters as much as the index: scoring a mutation rebinds ledger.decide
for a whole interpreter, so doing it in the server would delete a policy guard
from every concurrent money decision for as long as the run takes, on a route
that needs no token. The swap now lives and dies in a process that serves
nothing.
First request may take about 32 seconds while the free instance wakes.
Measured 30 August 2026 with curl -w "%{time_starttransfer}" against a
naturally spun-down instance; warm is 0.29–0.35 s. Reload once.
Response budgets are a gate, not a claim. python harness/perf_check.py starts
a local server, measures p95 over 12 samples per endpoint, prints the table and
exits non-zero on a miss, so the reported line and the exit status come from one
expression and cannot disagree. Run 31 August 2026 on one Windows laptop:
20 of 20 within budget, exit 0. The tightest rows are POST /api/shop at
91.7 ms of a 100 ms budget and POST /api/mutate at 689.4 ms of 2000 ms; both
move by a factor of two or more between runs depending on what else the machine
is doing, which is why the check is a threshold and not a published latency.
API rows have the round-trip floor subtracted — /health p95, measured in the
same run with the same client, 16.2 ms here. /health runs none of this code,
so whatever it costs is the harness measuring itself, and the budgets are
written about the server's own work. The six page rows keep the floor, because a
browser pays it too. The floor has its own ceiling of 60 ms, so a globally slow
run fails outright instead of quietly shrinking every excess.
web/rules.json carries the same eight rules as the table below, for the rules
page to render. They are two copies of one fact: change either and change both.
Open /operator and enter RESERVE_GATE_ADMIN_TOKEN. The page stores the token
only in memory for the current visit; locking the inbox or leaving the page clears
it. The page shell is public, but every /operator/ API requires the operator
credential. The agent token cannot open the inbox data or run recovery actions.
The inbox lists pending upstream approvals, held reservations with unknown
outcomes, and frozen blocks across agent and browser-demo callers. Local demo
orders have a demo_ prefix. Select an item to inspect its references, timestamps,
and available actions; frozen blocks include their latest 20 reservations.
- Approve request uses the existing single-use approval route and rechecks expiry, revocation, and freeze state before forwarding.
- Check payment status fetches the recorded payment from the provider. Only a captured payment whose ID, order, amount, and currency match is sent through the existing settlement rules. It does not initiate another charge.
- Unfreeze after review uses the existing operator control. It clears the freeze, without reconciling or rewriting payment history.
Missing references, provider failures, and unconfirmed captures leave funds reserved for manual review. The inbox does not provide a manual “mark successful” or “release funds” action. It relies on the existing database and audit storage; it does not add persistent hosting storage or backups.
AI buyer / MCP client
|
| Bearer token
v
reserve-gate -------- POST /webhook <---- Razorpay
| raw-body HMAC
| ALLOW
v
Razorpay hosted MCP
SQLite owns the block, reservations, event dedupe, idempotency and freeze state.
Every decision and reconciliation outcome is appended to a hash-chained JSONL log.
Only four upstream tools exist at the gate: create_order, capture_payment,
fetch_order, and fetch_payment. The handwritten declarations are the
allowlist; a new Razorpay money tool is unavailable until explicitly reviewed.
See ARCHITECTURE.md for the trust boundaries and money flow.
These are the prepaid-card properties above, written out exactly. Each is a separate check. A call must pass all of them; failing any one is a refusal that names the rule. The demo site counts nine, because it lists the approval gate below the table as a check of its own.
| Rule | Control |
|---|---|
| R0 | Amount is an integer in the supported sub-unit range and currency matches the block. |
| R1 | Committed plus held money cannot exceed the reserved block. |
| R2 | The block expires at a fixed instant, no later than 90 days. |
| R3 | One block supports multiple debits; captures require this gate's reservation. |
| R4 | Revocation refuses every later money call immediately. |
| R5 | One transaction cannot exceed the configured per-call cap. |
| R6 | A rolling velocity limit counts allowed and refused money calls. |
| R7 | Idempotency replays the first result and rejects a key reused with different parameters. |
Calls above the approval threshold become HOLD. Only the operator token can
approve, revoke, inspect, or unfreeze a block; the buyer token cannot reach
those controls.
R7 has a boundary a caller inherits by omitting the key. No money call is
left unkeyed: when a client sends no idempotency_key, the gate derives one from
the caller, the tool, and the stable money fields, and remembers it for five
minutes. receipt and notes are deliberately excluded, because a model
regenerating a call after a timeout rewords them, and a key that moved with that
prose would mint a second real order for one purchase. The cost of that choice is
the other direction: two genuinely different purchases of the same amount, by the
same caller, within five minutes, collapse into one — the second replays the
first. A client that asserts the two are different says so by supplying its own
key, which never expires and binds the full arguments, so reusing it with a
changed amount, tool, or payee is a conflict rather than a replay. src/buyer.py
supplies one on every call.
eval_report.md is generated from 150 labelled cases using the real policy and SQLite ledger, without credentials or network access.
| Class | Cases | False-allow | Wrong effect/rule |
|---|---|---|---|
| Prompt injection | 40 | 0 | 0 |
| Block cap and expiry | 30 | 0 | 0 |
| Idempotency and replay | 25 | 0 | 0 |
| Revocation and runaway loops | 25 | 0 | 0 |
| Webhook reconciliation | 20 | 0 | 0 |
| Negative controls | 10 | 0 | 0 |
| Total | 150 | 0 | 0 |
Confusion matrix: 58/58 expected allows, 87/87 expected blocks, and 5/5
expected holds matched. Webhook cases additionally assert the observable effect:
APPLY, NOOP, or REJECT.
Passing cases alone can be weak. harness/mutate.py removes each of the 16
policy guards in turn and re-scores all 150 cases. Every removal is detected.
harness/provenance.md maps each attack class to its
external basis and states where evidence is still limited.
audit_sample.jsonl is the other half of the evidence: not a
simulation but a real run against Razorpay test mode. Six orders placed through
the gate with their real order ids, a seventh refused by the block cap, and one
of them paid with a test card and captured back through capture_payment, which
moved 10,000 paise from held to spent and closed with a debit_committed record.
The block ends at reserved 1,000,000, spent 10,000, held 895,000. Its hash chain
verifies from the first record, whose parent is null.
Chain tail digest:
698448f71af0422efd15d5ce41167a817a2d484ee2ce3ccc10e2f072cc589856. The chain is
unkeyed, so on its own it catches an edited, deleted or reordered record but not
a wholesale rewrite. This digest is committed here, beside the log it summarises,
so a recomputed log no longer matches the value the repository already holds.
Both digests above are committed by the author, so they date the evidence only as
well as git history does. eval_report.md.ots is an
OpenTimestamps proof that closes that gap: it
commits eval_report.md to the Bitcoin blockchain through public calendar
servers, so the date the results existed is one nobody in this project can move.
pip install opentimestamps-client
ots info eval_report.md.ots # block heights, no Bitcoin node needed
ots verify eval_report.md.ots # run it beside eval_report.mdaudit_sample.jsonl is stamped the same way, so the settlement record and the evaluation are both anchored:
ots verify audit_sample.jsonl.otsStamped 4 Sept 2026 over sha256
065cbdfa98acc053a313118c89aff4a3bdc3ab286359a534c1fd06e8eac2afcf and
851db90523dad116f8eb1389ef9c084e9d5da3becc97fe1846ad4e516efbb10b, and both are
now confirmed in Bitcoin — blocks 965447, 965448 and 965487, attached by
ots upgrade on 6 Sept 2026. Reading those heights back needs nothing installed:
ots info <file>.ots prints them straight out of the proof. Checking the block
itself needs a Bitcoin node, and without one ots verify exits non-zero saying it
cannot reach the chain — that is the client's limit, not the proof's. What neither
command may ever report is that the file does not match: eval_report.md is
marked -text in .gitattributes so its bytes are identical on every platform,
and regenerating the report without re-stamping it would break the proof.
POST /webhook is the only non-health route exempt from bearer authentication. It is
authenticated separately with X-Razorpay-Signature, using HMAC-SHA256 over
the exact raw request bytes and a dedicated RAZORPAY_WEBHOOK_SECRET.
x-razorpay-event-id is mandatory and atomically deduplicated in SQLite.
| Input | Response and effect |
|---|---|
| Missing or invalid signature | 401; body is not parsed and money state is untouched. |
| Missing event ID or malformed signed JSON | 400; no money state change. |
| Missing server-side webhook secret | 503. |
| Body larger than 1 MB | 413; rejected before parsing. |
Valid payment.captured matching a held reservation |
200; held becomes spent exactly once. |
| Duplicate, unsupported, out-of-order, or unknown-order event | 200; audited no-op. |
| Late capture or payment/amount/currency/status conflict | 200; rejected reconciliation and the block freezes. |
| Unexpected SQLite failure | Generic 500; transaction rolled back. |
The normal capture response and webhook use the same reservation transaction. Whichever commits first wins; only the same payment on the same reservation is a no-op. Reusing one payment across reservations freezes the block, and an in-flight idempotency result is completed so retries return the captured result.
A frozen block fails closed under G4. An operator can acknowledge a reviewed
conflict with POST /unfreeze/{block_id} using RESERVE_GATE_ADMIN_TOKEN; this
only clears the freeze flag. It does not choose a payment record or rebuild the
ledger from Razorpay history, which remains future work.
[measured 29 Aug 2026: Razorpay test checkout, dashboard capture, Render logs, and two signed HTTP replays] Order order_TVVWyByMSNDYa3 reserved 10,000
paise: the deployed block read spent=0, held=10000. Capturing payment
pay_TVVZMykxzzrcva produced event TVVfkSQLpXzape; the webhook changed the
block once to spent=10000, held=0, available=990000. Two further signed
requests reused that event ID. Both returned accepted=true, applied=false,
reason=duplicate_event; Render logged one capture_applied and two
duplicate_event no-ops. Razorpay's dashboard did not expose the original body,
so the replays used a minimal valid signed JSON body with the identical event ID.
- Bearer authentication uses constant-time comparison; operator controls use a different token from the buyer.
- Webhooks use raw-body HMAC and constant-time comparison before JSON parsing.
- Caller authorization headers are stripped. Only server-side Razorpay credentials can reach upstream.
- SQLite uses parameterized statements, WAL, a busy timeout,
BEGIN IMMEDIATE, andCHECK (spent + held <= reserved). - Event IDs and idempotency keys are deduplicated atomically.
- Upstream timeouts retain their hold because the payment outcome is unknown; only a proven refusal releases money.
- Free text such as notes and receipts never enters the deterministic decision.
- Secrets are lazy-loaded from environment variables and redacted from audit records and webhook stdout summaries.
Each JSONL record includes the previous record's SHA-256 digest. This detects an
edited, deleted, or reordered line. The chain is intentionally unkeyed, so an
attacker who can rewrite the entire file can recompute it. The generated tail
digest is committed in eval_report.md beside harness/audit_run.jsonl; a
wholesale rewrite then disagrees with git history.
The honest question is what that actually stops, so here is the answer by attacker rather than by adding an HMAC:
| Attacker | Can they forge the log? | Does it matter? |
|---|---|---|
| The AI agent | No. It speaks MCP and never touches the file. | This is the attacker the gate exists for, and the chain is more than enough for it. |
| A shell on the deployed instance | Yes | They also hold RAZORPAY_KEY_SECRET, so they can skip the gate and move money directly. Forging the log is a harder route to something they already have. |
| The author | Yes, but not silently | No key fixes this, because the author would hold the key too. Two things do. Reproducibility: a reader can run harness/mutate.py from a clean clone, with no credential and no network, and regenerate every number in eval_report.md. And the OpenTimestamps proof above, which is the part git alone could not give — a rewritten log needs a rewritten tail digest, that digest lives in eval_report.md, and re-stamping it produces a proof dated after the original. The author cannot manufacture an earlier one. |
An HMAC was considered and rejected. Its key would live beside the process
writing the log, where an attacker who can rewrite the file already has it, and a
verify() that needs a secret is one a reader with a clone cannot run — which is
the only reason the log exists. Reproducible beats keyed — and since 4 Sept 2026
the log is timestamped as well, which is the one thing reproducibility does not
supply on its own: a date nobody in this project can move.
The audit covers requests that reach application code. Schema-invalid MCP arguments can be rejected by the transport before a tool runs, so they have no application audit line. The committed harness remains reproducible from a clean clone and does not rely on the deployed log.
The tested path is the pinned stdio-to-HTTP bridge:
{
"mcpServers": {
"reserve_gate": {
"command": "npx",
"args": [
"-y",
"mcp-remote@0.6.0",
"https://<render-url>/mcp",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": { "AUTH_HEADER": "Bearer <reserve-gate-token>" }
}
}
}Keep 0.6.0 pinned and keep no space after Authorization: in the header
argument. The hosted connector UI is not an alternative: it accepts OAuth
client credentials, while reserve-gate intentionally uses a static bearer
token and does not implement an OAuth authorization server.
Not an official Razorpay product and not affiliated with Razorpay in any way. A personal project built against their public test-mode APIs.
Everything above is measured by a command named beside it. Five things are deliberately absent, and each one is a decision with a reason rather than a gap:
- Reserve Pay is not exposed in Razorpay's test-mode API, so this implements its semantics — one block, many debits, expiry, instant revocation — around Orders and Payments. It models Reserve Pay; it does not claim to use it.
- The deployed URL cannot enforce a durable cap across a restart. Render's
free tier has ephemeral disk. Both repairs were checked and neither exists on
this plan: a free web service cannot attach a persistent disk at all, and free
Postgres expires 30 days after creation, which would put its deletion inside
the review window — a database that removes itself during judging fails
silently and later, which is worse than a stated limit. Git is the durable
store instead: the authoritative run is local,
eval_report.mdandaudit_sample.jsonlare committed, and boot writes a loudCOLD_START_LEDGER_RESETrather than starting quietly. - This binds the agent's access, not the merchant's key. Anyone holding the
raw Razorpay key can call
mcp.razorpay.comdirectly and bypass the gate entirely. The control assumes the agent is issued areserve-gatetoken and never the underlying credential. - Requests are not cryptographically signed. A caller is identified by its bearer token and each block is bound server-side to the caller derived from it. A signature would prove who originated a request; six days went into the permission question — how much, for how long, revocable when — and a signature answers origin, not permission. The two are complementary and one was built.
- A block cannot be split into delegated sub-budgets. One agent holds one block, and cannot hand a weaker allowance to a sub-agent without contacting this server. Doing that properly needs capability tokens with offline attenuation and a delegation depth limit. A half-built version is worse than none, because it looks like a boundary while leaking one.
The operational detail behind these — the approval link's in-process lifetime,
the demo site's per-cookie blocks, the model box's daily budget and its recorded
fallback, the keepalive workflow's measured delivery rate, and the exact scope of
what the hash chain does and does not cover — is set out in ARCHITECTURE.md
under Operational limits, and in the Audit-chain scope section above.
Copy .env.example to .env only when using Razorpay test mode. Never commit it.