Track 01 — AI Growth & Agentic Commerce (Razorpay AI Buildathon)
A safety layer that makes it possible for a merchant to accept AI-agent-initiated payments at all — by never trusting the agent, and proving it structurally rather than promising it in a comment.
An AI agent just spent your money. Maybe it worked perfectly. Maybe it was jailbroken. Maybe it timed out and retried four times. From the payment layer's point of view, those three cases arrive as the exact same HTTP request: "charge this." Nothing about the request tells you which one you got. This project is the layer that has to tell the difference — and to stay correct even when the agent making the request is actively hostile.
- The core idea: don't make the agent trustworthy — make it structurally
incapable of approving its own spend. The object the AI ever returns,
ParsedIntent, has no field for a price and no field for an approval. A fully jailbroken model has nowhere to put a bypass, because the data shape doesn't have a slot for one. That's not a filter catching bad output; it's a type that cannot express it. See the trust boundary. - The hardest problem wasn't the happy path. It was "the network died mid-payment — was the customer charged?" Most systems answer "assume failure and retry," which is exactly how a timeout becomes a double charge. This one answers "unknown, and I will not guess" — see unknown outcomes stay unknown.
- It's tested like an attacker would test it, not like a demo. A
simulated hostile agent throws 14 attacks at the live HTTP API — replay,
race, price-tamper, SQL-level audit-log tampering, a jailbroken parser
proposing an over-cap purchase. 14/14 defended, 0 unintended charges,
regenerated by running
scripts/run_adversarial_suite.py, not hand-edited. See adversarial results. - It says what it didn't build, out loud. No fraud scoring, no MFA, capture is simulated and the README says exactly why. A security page that only lists wins is marketing; see honest limits.
- Fastest way to see it live:
uv run pytest tests/ -q(whole suite, no credentials needed), thencd frontend && npm install && npm run build && cd .. && uv run python scripts/run_ui.pyand open the Security Transparency Hub tab — every claim in this README has a button next to it that makes the system prove it, live, against a real request.
Two of Track 01's own published judging criteria shaped real decisions
here, not just the pitch. Problem Taste is why upsell/cross-sell got built
after initially being declined — the project could show risk removal nine
different ways and revenue growth zero, which is a real gap against a track
literally named AI Growth & Agentic Commerce. AI Judgment is why that
upsell engine is deliberately not an LLM — pairing coffee with biscuits is
a lookup, not a language problem, and reaching for a model there would be the
unnecessary AI that criterion warns against. Both calls, and the reasoning
behind them, are recorded in RAZORPAY.md at the point they
were made, not cleaned up in hindsight.
- The problem
- The idea, in one sentence
- Architecture
- Request lifecycle (workflow)
- The trust boundary — what the agent can and cannot reach
- Security features implemented
- Why a human can trust this environment
- Adversarial results
- Honest limits
- Running it
- Seeing it work — the reference client
- Where this fits
AI agents are starting to transact on behalf of people. A merchant asked to accept one of those payments has no good options today:
- Approve every transaction manually — which defeats the entire point of handing the task to an agent.
- Trust the agent — which is unacceptable when real money moves, and especially unacceptable to the merchant, who eats the disputed charges.
So merchants do the rational thing: they keep agents out of checkout, or cap what an agent may spend so low that automation isn't worth building against.
The blocker isn't that agents are useless. It's that nobody can currently guarantee three specific things about an agent-initiated payment:
- It cannot exceed a boundary the merchant agreed to in advance.
- It cannot be executed twice, even if the agent retries, replays, or crashes mid-transaction.
- Every decision — approved or denied — can be explained afterward, by someone holding only the log, not the code.
Don't try to make the agent trustworthy. Make the checkout infrastructure safe no matter what the agent does — by giving the agent a role that is structurally incapable of approving its own spend, not merely a role that is told not to.
The agent proposes. It never decides. Every guarantee in this document holds regardless of whether the AI making the request is well-behaved, buggy, or actively adversarial — because none of them depend on the AI's behaviour in the first place.
Two HTTP surfaces exist, on purpose, and they are not the same size.
/api— the production surface. This is the only thing an agent (or any real integration) ever talks to. It has exactly 11 routes — read the catalog, submit an intent, confirm, decline, look up an explanation, fetch the end-to-end-encryption public key, get a recommendation, receive a webhook. There is no route on it that edits a mandate, changes a price, or touches an admin session.tests/test_demo_ui.py::test_the_production_api_gained_no_routespins the exact set — a route added here without a decision to add it fails a test, not a code review someone might skip./demo— merchant and admin operations. Everything a merchant does to configure the system (edit a mandate, manage the catalog, sign in as admin, view the audit log, arm a fault for a demonstration) lives here, behind the admin login described below. This surface exists only because a reference client needs somewhere to call; a real deployment would not expose it to the internet at all.
graph TD
Agent["🤖 AI Agent<br/><i>untrusted — proposes only</i>"]
Human["🧑 Human<br/>confirms or declines"]
Frontend["React reference client<br/>(chat · dashboard · checkout · security hub)"]
subgraph PROD["/api — production surface (11 routes, pinned by test)"]
IntentLayer["Intent Layer<br/>LLM (Groq) or rule-based parser<br/><b>ParsedIntent has no price/approval field</b>"]
Checkout["Checkout Service<br/>display → confirm → execute"]
end
subgraph CORE["Authorization core — deterministic, no AI involved"]
Policy["Policy Engine<br/>checks the mandate, names the rule on denial"]
Mandate[("Mandate Store<br/>cap · allowlist · expiry · velocity · cooldown")]
Idempotency[("Idempotency Store<br/>UNIQUE-constraint claim, SQLite")]
Catalog[("Catalog<br/>source of truth for price")]
end
subgraph LEDGER["Ledger & recovery"]
Audit[("Append-only Audit Log<br/>SHA-256 hash chain, DB triggers block UPDATE/DELETE")]
Reconcile["Reconciliation Scheduler<br/>polls the provider every 20s"]
Webhook["Webhook Receiver<br/>HMAC-SHA256 verified, informs only"]
end
Provider[("Razorpay test-mode API<br/>orders: real · capture: simulated")]
subgraph DEMO["/demo — merchant & admin surface (not agent-reachable)"]
AdminAuth["Admin Auth<br/>bcrypt + signed session, fail-closed"]
CatalogAdmin["Catalog admin: add / rename / reprice / delete"]
MandateAdmin["Mandate admin: cap / allowlist / expiry / velocity / revoke"]
end
Agent -->|natural-language purchase text| IntentLayer
IntentLayer -->|structured draft: SKU + qty only| Human
Human -->|confirm / decline| Checkout
Checkout --> Policy
Policy --> Mandate
Policy -->|approved| Idempotency
Idempotency --> Catalog
Idempotency --> Provider
Policy -.->|every decision| Audit
Idempotency -.->|every attempt & outcome| Audit
Provider -.->|delivery, signed| Webhook
Webhook -->|verified receipt only, never a ledger write| Reconcile
Reconcile <--> Provider
Reconcile -.->|resolves PENDING_VERIFICATION| Audit
Frontend -->|E2E-encrypted purchase text| IntentLayer
Frontend -->|login required| AdminAuth
AdminAuth --> CatalogAdmin
AdminAuth --> MandateAdmin
CatalogAdmin --> Catalog
MandateAdmin --> Mandate
classDef untrusted fill:#3a1414,stroke:#c0392b,color:#f5b7b1
classDef core fill:#132a1a,stroke:#27ae60,color:#a9dfbf
classDef ledger fill:#1a2233,stroke:#2980b9,color:#a9cce3
classDef admin fill:#2a1a33,stroke:#8e44ad,color:#d2b4de
class Agent untrusted
class Policy,Mandate,Idempotency,Catalog core
class Audit,Reconcile,Webhook ledger
class AdminAuth,CatalogAdmin,MandateAdmin admin
Reading the diagram: the agent only ever touches the top box. Every arrow that can move money or change a rule runs through the deterministic core in the middle — code with no model in the loop at all. The bottom-right box (admin operations) has no incoming arrow from the agent anywhere in this diagram, because there isn't one.
The exact order of operations, taken from zerotrust/gateway.py — not a
simplification of it:
audit(intent) → policy check → audit(decision) → audit(attempt) → idempotent execution → audit(outcome)
Two rules fall out of that ordering and are enforced by tests, not convention: policy runs before execution (a denied request never reaches the idempotency layer or the provider — proven by counting provider calls on denial), and every stage is logged, including the ones that end in a refusal.
sequenceDiagram
autonumber
participant Agent as 🤖 Agent (untrusted)
participant Intent as Intent Layer
participant Human as 🧑 Human
participant Checkout as Checkout Service
participant Policy as Policy Engine
participant Idem as Idempotency Store
participant Provider as Razorpay (test-mode)
participant Audit as Audit Log
Agent->>Intent: "buy me 2 mugs" (natural language)
Intent->>Intent: parse → ParsedIntent{sku, qty}<br/>(no price field, no approval field)
Intent-->>Audit: log INTENT_PARSED
Intent->>Human: "Confirm — buy 2× Ceramic Mug for ₹500.00?"
alt Human declines
Human->>Checkout: decline
Checkout-->>Audit: log USER_DECLINED
Note over Checkout: terminal — no path back to execution
else Human confirms
Human->>Checkout: confirm
Checkout->>Checkout: re-read price from catalog<br/>(never trust the displayed amount)
Checkout-->>Audit: log USER_CONFIRMED, PRICE_VALIDATED
Checkout->>Policy: evaluate(request, mandate)
alt Policy denies
Policy-->>Audit: log POLICY_DENIED + named rule<br/>(e.g. AMOUNT_EXCEEDS_CAP)
Policy-->>Human: denied — specific reason
Note over Idem,Provider: never reached
else Policy approves
Policy-->>Audit: log POLICY_APPROVED
Policy->>Idem: claim(idempotency_key)
alt Key already claimed, same payload
Idem-->>Audit: log IDEMPOTENCY_REPLAYED
Idem-->>Human: original result, returned again
else Key claimed, different payload
Idem-->>Audit: log IDEMPOTENCY_CONFLICT
Idem-->>Human: 409 — rejected, not executed
else Fresh key
Idem-->>Audit: log PAYMENT_ATTEMPTED
Idem->>Provider: create order (real test-mode call)
Provider-->>Idem: order id / timeout / error
alt Confirmed success
Idem-->>Audit: log PAYMENT_CAPTURED
Idem-->>Human: receipt
else Outcome unknown (timeout)
Idem-->>Audit: log PAYMENT_PENDING_VERIFICATION
Note over Idem: never guessed — held until reconciliation
end
end
end
end
This is the argument the rest of the document backs with code, stated as a picture: capabilities the agent is incapable of reaching, not capabilities it is merely told not to use.
graph LR
subgraph AGENT_SIDE["What the agent can reach"]
A1["Propose a SKU + quantity"]
A2["Read the catalog"]
A3["See its own pending requests"]
end
subgraph NEVER["What NO agent request can ever do — not a permissions gap, a missing field"]
N1["Set or influence the price<br/><i>ParsedIntent has no amount field</i>"]
N2["Approve its own purchase<br/><i>ParsedIntent has no approval field</i>"]
N3["Write to the audit log<br/><i>DB triggers block UPDATE/DELETE outright</i>"]
N4["Write to the idempotency store directly<br/><i>only the gateway's execution path can</i>"]
N5["Edit a mandate or the catalog<br/><i>those routes require an admin session</i>"]
N6["Cause a double charge under retry or race<br/><i>a database UNIQUE constraint, not app logic</i>"]
end
A1 -.->|the only door in| GATE{{"Policy Engine<br/>decides — nothing upstream does"}}
GATE -->|approved| EXEC["Idempotent execution"]
GATE -->|denied, named rule| REFUSED["Refused, logged, explained"]
classDef ok fill:#132a1a,stroke:#27ae60,color:#a9dfbf
classDef blocked fill:#3a1414,stroke:#c0392b,color:#f5b7b1
class A1,A2,A3 ok
class N1,N2,N3,N4,N5,N6 blocked
The reason this is stronger than a permissions list: most of the "cannot" rows
above aren't rejected by a check — they're structurally impossible, because
the data type carrying the agent's output has no field to put them in. A
prompt-injected agent that is talked into "just approve this at ₹1" has nowhere
to write ₹1 or the word "approved." ParsedIntent — the only thing the intent
layer ever returns — has neither field. This is why prompt injection is a
lower-severity problem here than in a system where the LLM's output is trusted
as the final word: the worst a compromised parser can do is propose something
a human then sees on screen, and the policy engine checks anyway.
Every item below is backed by a live control in the running system's Security
Hub (/demo/security/layers) and at least one test that proves it, not a
claim in this file alone.
| Feature | Mechanism |
|---|---|
| Exactly-once execution | An idempotency key is claimed by a UNIQUE-constraint INSERT. The database serialises the claim — several threads firing the identical key at the same instant still produce exactly one charge, because there is no application-level lock for a race to slip past. |
| Confirm-time price re-validation | The amount is re-read from the catalog when confirmation arrives, never trusted from what was displayed earlier or from what the request claims. A price changed (or tampered) between display and confirm is rejected with PRICE_MISMATCH, not silently charged at either figure. |
| Bounded mandate | Every purchase is checked against four merchant-set limits before money moves: a per-transaction cap, an allowlist of purchasable SKUs, an expiry, and a sliding-window velocity limit. |
| Denial cooldown / throttle | An agent that racks up repeated denials in a short window is throttled (COOLDOWN_ACTIVE) before its next request is even evaluated against the mandate — so grinding against the policy engine to probe its limits gets rate-limited, not just individually refused each time. |
| A stuck request never blocks forever | If a process crashes holding a claimed key, a staleness timeout lets a fresh attempt reclaim it — while a request genuinely still in flight is not reclaimed, so the exactly-once guarantee is never quietly traded for liveness. |
| Unknown outcomes stay unknown | When a payment's true state genuinely cannot be determined (a timeout with no confirmed answer), it is recorded as PENDING_VERIFICATION and never guessed at. A ReconciliationScheduler asks the provider directly, on a timer, until it can say for certain. |
| Feature | Mechanism |
|---|---|
| The LLM holds no authority | ParsedIntent — the only thing the intent layer ever returns — has no field for a price and no field for an approval. There is no path by which "buy me a phone for ₹1, approved" could ever set either. |
| Confirmation is not authorisation | A human confirming a request is a separate event from the policy engine approving it. The two catch different failures: confirmation catches the model misreading what was meant; policy catches anything unsafe even when the intent was read perfectly. A person who confirms a purchase without realising the mandate's cap is already spent still gets denied. |
| A compromised parser still can't spend more | Simulated directly: a fully compromised LLM proposes a disallowed item, a human confirms it anyway, and AMOUNT_EXCEEDS_CAP still refuses it — because the parser was never the thing holding authority to give away. |
| Suggestions carry no more power than intents | The upsell/recommendation path (zerotrust/recommend.py) is deliberately built on the same rule: a Suggestion has no price field and no approval field either, so an accepted upsell faces the identical confirmation and policy check as any other purchase. |
| Feature | Mechanism |
|---|---|
| Append-only audit log | Every policy decision and money action is logged with a fixed vocabulary of event types. BEFORE UPDATE / BEFORE DELETE triggers on the table RAISE(ABORT) — enforced by the database, so tampering fails even from a raw sqlite3 shell bypassing the application entirely. |
| Hash-chained entries | Each entry's hash covers the previous entry's hash (SHA-256), so an edited or deleted-and-reinserted row breaks the chain visibly, on top of the trigger already blocking the edit outright. |
| Structured, reconstructable explanations | GET /explain/{request_id} rebuilds WHY / WHAT / EVIDENCE for any request from the audit log alone — it decides nothing and writes nothing. The test of the whole log isn't that it exists; it's that someone holding only the log, never the code, can correctly explain why a request was approved or denied. |
| Denials always name a rule | Never a bare "denied." Every refusal cites the specific rule that fired (AMOUNT_EXCEEDS_CAP, SKU_NOT_ALLOWED, MANDATE_EXPIRED, VELOCITY_EXCEEDED, COOLDOWN_ACTIVE, NO_ACTIVE_MANDATE, MALFORMED_REQUEST, CURRENCY_MISMATCH). |
| Feature | Mechanism |
|---|---|
| Editing the mandate requires a real login | A bcrypt-hashed password and a short-lived HMAC-signed session (verified by recomputing the signature, never looked up — stateless by construction) gate every mandate-affecting route: cap, allowlist, expiry, velocity, and revoke. An unconfigured admin account refuses every login rather than leaving the editor open. |
| Catalog management requires the same login | Stocking, renaming, repricing, or unstocking an item are merchant decisions with the same blast radius as editing a mandate (the price set here is what confirm-time re-validation trusts), so they sit behind the identical require_admin gate — not left open just because they touch products instead of mandates. |
| Anti-enumeration login | Wrong username and wrong password return the identical error message, and the password check runs unconditionally rather than short-circuiting on a bad username — a faster rejection on a bad username is exactly the timing oracle that lets an attacker enumerate valid usernames one attempt at a time. |
| Zero-config-safe by default | If no admin credentials are configured, the demo generates a real random password at startup and prints it once (the same pattern Jenkins uses for its first-run admin password) rather than silently disabling the login it exists to enforce. |
| Authority can be withdrawn instantly | Revoking a mandate takes effect on the very next request — including one already sitting in "awaiting confirmation." A revocation that only blocked new drafts would leave every already-pending request still spendable, which is exactly the window a merchant reaching for a kill switch is trying to close. |
| Revocation records, never deletes | A revoked mandate's row stays in the store with revoked_at set — the same append-only principle as the audit log, applied to mandate history. |
| Feature | Mechanism |
|---|---|
| End-to-end encrypted chat | The browser encrypts a customer's raw purchase text to the server's public key (NaCl Box: X25519 + XSalsa20-Poly1305) before it ever leaves the client. Only ciphertext is ever written to the audit log. Verified with a real cross-implementation check: a message sealed by the actual frontend library was decrypted successfully by the backend's independent implementation, not just round-tripped in one language. |
| Webhook signature verification | Every inbound delivery is verified with HMAC-SHA256 over the raw request body using hmac.compare_digest (constant-time, not ==). An unconfigured secret means every delivery is refused, not silently trusted. |
| A verified webhook still cannot authorise anything | The receiver's only side effect is triggering a reconciliation check — it never writes ledger state directly, even when the signature checks out. The payload's claimed amounts and statuses are discarded; only the receipt is used, to go and ask the provider directly what is actually true. |
Three protections commonly expected of a payment system are genuinely absent, and the running Security Hub says so in plain terms instead of rendering an indicator for them:
- Fraud detection — no scoring or anomaly detection. Velocity limits are a mandate rule, not fraud scoring.
- Tokenization — not applicable in the literal sense, because this system never touches card data at all. That is avoidance, not tokenization.
- Multi-factor authentication — the one admin login has a password as its only factor, and there is no per-customer account system at all.
Trusting an agent's intentions was never the plan. What this project gives a human to actually rely on is narrower and checkable:
- The agent's entire capability surface is "propose." Not by policy, by
data shape —
ParsedIntenthas no field for a price or an approval, so there is no message a prompt-injected or malicious agent could construct that would let it set its own price or grant its own approval. See The trust boundary above. - The two things that actually authorise a spend — human confirmation and the policy engine — are independent, and both must agree. A person saying yes is not enough; the mandate is still checked. A parser reading the request correctly is not enough either; a human still has to say yes to that specific request.
- Every guarantee that matters under adversity is a database property, not
an application promise. Exactly-once execution is a
UNIQUEconstraint. Append-only is a trigger that fires even against raw SQL. Both fail closed: they refuse rather than silently doing the unsafe thing when something goes wrong, including when the "something" is a hostile actor with direct database access. - Nothing claims to know what it doesn't. A timed-out payment is recorded as unknown, not assumed successful or assumed failed — the single most common way payment systems lie to their own operators under failure.
- The blast radius of a bug is bounded and legible. A denial always names the exact rule, so a merchant reviewing decisions never has to trust a black box's "no" — they can see which specific limit fired, and reconstruct the whole story from the audit log alone, without reading a line of code.
- The claims are adversarially tested against the running system, not just unit-tested against its internals. The adversarial suite below attacks the live HTTP API — the same surface a real hostile agent would reach — rather than calling internal functions directly, so a guarantee that only holds for well-behaved in-process callers would be caught rather than assumed.
- What isn't solved is said out loud. Honest limits and the Hub's "not implemented" panel exist specifically so this document cannot be read as claiming more than the code does.
In short: a human isn't asked to trust the agent, or even to trust this project's authors. They're asked to trust a database constraint, a set of triggers, and a policy engine with no model in its decision path — and then shown, adversarially, that those hold.
A simulated hostile agent attacks the running system through its HTTP API. The table below is generated by the suite itself — regenerate it with:
uv run python scripts/run_adversarial_suite.pywhich rewrites docs/adversarial-results.md and exits non-zero if anything
gets through. The full per-attack evidence, including the exact reason the
system gave in each case, is in that file.
14 of 14 attacks defended. 0 unintended charges.
Every attack below was run against a live stack through the HTTP API, under this mandate:
- max per transaction: Rs. 500.00
- allowed items: SKU-CAKE, SKU-COFFEE, SKU-TEA
- velocity: 3 purchases per hour
| # | Attack | Outcome | Defence | Charges |
|---|---|---|---|---|
| 1 | Re-submit a completed purchase with its original payload | DEFENDED | Idempotency key claimed by a unique-constraint INSERT | 1 |
| 2 | Confirm a displayed request while claiming amount_paise=1 | DEFENDED | Confirm-time price re-validation against the catalog | 0 |
| 3 | Reuse a spent idempotency key with a changed amount | DEFENDED | Payload fingerprint compared against the stored claim | 1 |
| 4 | Purchase an allowed item priced 10x over the mandate cap | DEFENDED | Policy engine: AMOUNT_EXCEEDS_CAP | 0 |
| 5 | Purchase a catalog item absent from the mandate allowlist | DEFENDED | Policy engine: SKU_NOT_ALLOWED (allowlist fails closed) | 0 |
| 6 | Fire 12 simultaneous purchases against a limit of 3 per hour | DEFENDED | Velocity slot claimed inside one BEGIN IMMEDIATE transaction | 3 |
| 7 | Two different valid purchases submitted simultaneously | DEFENDED | Keys are independent; only identical keys collide | 2 |
| 8 | A fully compromised LLM proposes a disallowed item, and a human confirms it | DEFENDED | Policy engine: AMOUNT_EXCEEDS_CAP (the parser holds no authority to give away) | 0 |
| 9 | Decline a request, then confirm it anyway | DEFENDED | Declined requests are terminal; no path back to execution | 0 |
| 10 | Spend under a mandate that has already expired | DEFENDED | Policy engine: MANDATE_EXPIRED | 0 |
| 11 | Purchase a SKU that is not in the catalog at all | DEFENDED | Catalog lookup precedes the policy engine | 0 |
| 12 | Cause a payment timeout, then retry it 5 times to force a second charge | DEFENDED | PENDING_VERIFICATION freezes the record; the claim refuses and staleness will not reclaim it | 0 |
| 13 | Probe the policy engine with 8 disallowed purchases, then submit a valid one | DEFENDED | Policy engine: COOLDOWN_ACTIVE (repeated-denial throttle) | 0 |
| 14 | Rewrite and delete audit entries with raw SQL, bypassing the application entirely | DEFENDED | BEFORE UPDATE / BEFORE DELETE triggers RAISE(ABORT) | 0 |
One row is not a denial: two legitimate purchases racing must both succeed. A system that refuses everything would score perfectly on the other thirteen and be useless, so the suite includes a case that has to pass.
- Capture is simulated. Order creation is a real server-to-server call
against a live Razorpay test-mode account. Capture requires a
payment_idthat standard Razorpay Checkout only produces through a browser-based customer step. Driving that step with a headless browser was attempted, not assumed impossible — the checkout rendered and the card form was fillable, but submitting it produced no result and nopayment_id. The probe is kept asscripts/probe_browser_capture.pyso the conclusion can be re-checked. The authorization wrapper behaves identically whether capture is real or simulated — and the wrapper is what this project asserts is correct — but the capture leg is simulated, and that is stated here rather than buried. - A payment's true outcome can be unknown, and the system says so. A
timeout leaves a purchase in
PENDING_VERIFICATION: retries are refused and the spending budget stays held until reconciliation asks the provider what actually happened. A sweep can only resolve what the provider can answer, and an order the provider has not yet made visible stays unknown until it is. - Single instance. Coordinating the idempotency store across multiple instances is out of scope.
- The LLM is a dependency, and a fallible one. With
GROQ_API_KEYset, a customer's message is parsed by a model hosted by Groq, which means it leaves this machine. A failure degrades to the deterministic parser rather than breaking, and the swap is labelled in the UI and the audit log — but "the agent is an LLM" and "the message is sent to a third party" are the same sentence. - One admin account, not a multi-user system. There is no per-user audit attribution or role-based access control for the admin login — it matches the one-merchant shape of the rest of the reference client, not a claim of RBAC.
- Deleting a catalog item does not scrub it from any mandate's allowlist.
A purchase attempt against a removed SKU still fails cleanly
(
ITEM_NOT_IN_CATALOG), just not by the deletion route reaching into mandate state it has no business touching. - Not covered: live browser checkout, multi-currency, production-grade secrets management.
Requires uv (brew install uv), which
provisions Python itself.
git clone <repo-url>
cd razorpay
uv sync
uv run pytest tests/ -qThat runs the whole suite. Most of it needs no credentials at all — the payment provider is stubbed at the HTTP transport, so the request-building code is still exercised for real.
To exercise the live Razorpay test-mode API as well, add credentials:
cp .env.example .env # then fill in your rzp_test_ keys
uv run pytest tests/ -q -m liveTests that need credentials skip themselves without them, so a fresh clone shows green either way.
Watch it work. Each phase has a script that narrates itself and prints a line every time money actually moves, so the guarantees can be counted by eye rather than taken from an assertion:
uv run python scripts/demo_phase1.py # exactly-once, under retries and races
uv run python scripts/demo_phase3.py # the mandate refusing things
uv run python scripts/demo_phase4.py # the audit log, and failing to tamper with it
uv run python scripts/demo_phase5.py # the full agent flow, including a compromised LLM
uv run python scripts/demo_phase7.py # a payment breaking, and being recoveredA reference client ships with the project: a React application with five surfaces — a chat where a customer talks to the agent, a dashboard (the mandate in force, plus full catalog management), a multi-step checkout, live transaction tracking, and a Security Transparency Hub.
cd frontend && npm install && npm run build && cd ..
uv run python scripts/run_ui.py # then open http://127.0.0.1:8000The build output is not committed, so the npm step is required once after
cloning. The Python test suite needs none of this — uv sync and
uv run pytest work on their own, and the API runs with or without the UI.
The agent is a real LLM; the replies are not. With GROQ_API_KEY set, a
customer's message is parsed by a live model on Groq. What the model returns is
a SKU and a quantity — nothing else, because ParsedIntent has no field for a
price or an approval. Every agent reply the customer reads is still built from
real system state, never generated prose. Each message is labelled with the
parser that produced it: if Groq is unreachable, the request falls back to the
deterministic RuleBasedIntentParser rather than failing, and the label
changes from groq to rule-based — a silent downgrade to a keyword matcher
would otherwise be indistinguishable from a working AI agent, which is
precisely the thing worth being unable to fake.
The Security Hub is the part worth using directly. It shows the eleven protections this system actually implements (the full table is in Security features implemented above), each with a live control that proves it against a real request — a button that says "denied — AMOUNT_EXCEEDS_CAP" is a claim, and a claim drawn by the system you're being asked to trust is not evidence. Watching the collision happen — two requests racing, a price changing underneath a pending purchase, raw SQL aborting against a live database, a webhook's signature failing after the body is edited — is.
What this page is not. A demonstration client for the API, not a checkout
product and not part of the security boundary. It holds no authorisation
logic — the production app in zerotrust/api.py gained no routes for it, and
the adversarial suite still reports 14/14 unchanged, because those attacks
bypass the UI entirely.
Explaining a decision. Any request can be reconstructed from the audit log as structured WHY / WHAT / EVIDENCE:
curl localhost:8000/api/explain/req_5f3f476c52WHY carries the deciding rule, its reason, the numbers behind it, and whether a human confirmed — because a human saying yes and the policy engine saying yes are separate events, and this is where that becomes legible.
Conversational checkout, agent-readable catalogs, upsell agents — the agentic commerce products people are building all share one unstated dependency: something has to make it safe for a merchant to accept a money action from an agent in the first place.
This is that dependency. It isn't a checkout experience; it's the authorization boundary a checkout experience would sit on top of, and the piece that has to be correct before any of them can responsibly move real money.
The growth side of the same boundary. After a purchase completes, the shop
suggests a complement — the revenue direction Track 01 asks about. The
interesting part is that it buys the agent nothing: a Suggestion has no
field for a price or an approval, and accepting one drafts an ordinary
purchase facing the same confirmation and the same policy engine.
On the emerging standards. Google's AP2 splits an agent purchase into an
Intent Mandate (bounded authority agreed in advance) and a Cart Mandate
(the specific basket a human approved), and treats them as two separate
credentials rather than one. This project's Mandate and PendingPurchase
land on the same split, for the same reason — a standing permission and a
specific approval answer different questions, and collapsing them loses the
ability to say which one refused. That convergence is worth noting; it is
not a compliance claim. Nothing here is signed, portable, or interoperable
with AP2, ACP or x402.
docs/protocol-alignment.md works through all
three protocols properly: what they specify, where this design genuinely
agrees, where the resemblance breaks (the idempotency key is not a scoped
payment credential, and saying so matters), and what it would actually take to
become AP2-compatible — including the two items that are not achievable from
inside this codebase at all.