A merchant-side gateway that makes a business transactable by an AI buyer, end to end, with every money action identity-verified, mandate-bounded, policy-gated, provenance-recorded, and recoverable.
Built for the Razorpay AI Buildathon 2026, Track 01 (AI Growth & Agentic Commerce), against its stated bar: "Every money action explainable, bounded and gated. Show the audit trail and one failure handled gracefully." This build treats that bar as the architecture, and handles six failure classes, not one.
Every live agentic-payment protocol, ACP (Stripe/OpenAI), AP2 (Google),
x402 (Coinbase), UAP (NPCI, pending RBI), is payer-side: it establishes
who an agent is and what its principal permitted. None gives the merchant a
way to bound, gate, or audit what an agent actually does inside a checkout,
and none specifies the failure paths. Santru closes the merchant side. Full
analysis: docs/00-overview.md and the technical
report Bounded-Agentic-Commerce.docx.
| metric | value | target | source |
|---|---|---|---|
| Out-of-scope block rate | 100% (50/50 adversarial cases) | 100% | policy suite |
| False-positive rate | 0% (0/50 benign incl. boundary cases) | < 2% | policy suite |
| Gate latency (sig + offer verify + PDP) | p50 0.8 ms · p95 1.3 ms | < 25 ms p95 | policy suite, 500 iters |
| Double-charge under 200-request storm | 0 | 0 | integration suite |
| Terminal consistency, F1–F6 | all terminal, zero orphaned holds | 100% | integration suite |
| Ledger chain | intact; tamper localized to the row | pass | integration suite |
Baseline contrast: the same 50 adversarial actions against a raw
static-key adapter (the posture of handing an agent API credentials, as
today's payment MCP servers do) complete 100%. Through Santru: 0%,
refused before any rail call. Regenerate: pnpm run eval (writes
packages/eval/report.md).
What 100% does and does not mean. The corpus was written by the same author
as the policy engine it scores, so a perfect result is the expected outcome, not
independent evidence of robustness. Read it as a regression suite: it proves
these 50 refusals are deterministic, reproducible offline, and exercised against
the real components, and it proves the baseline contrast is real. It is not a
claim about adversarial inputs nobody has thought of. docs/07 scopes the claim
the same way.
A product description carries a hidden instruction telling the agent to buy
a "clearance voucher" from mrc_unknown_88 first. A hijacked agent obeys it
(pnpm run attack), quotes the attacker's listing, and tries to pay. The
attempt dies at the policy decision point: DENY, rule
merchant_allowlist, refused before an order exists and before any
Razorpay call. /replay/1 then reconstructs the refusal: which agent, which
mandate, which rule fired, and the gateway-side served-content record
corroborating that the instruction came from catalog text rather than from
the principal.
Why the demo does not use the LLM for this step. The claim is about the
gateway: a compromised agent cannot exceed its mandate. In live runs Gemini
3.5 Flash refused the injection outright and Flash-Lite only reported it, so
a demo that waits for the model to be fooled is one that will not fire on
cue. pnpm run attack plays the worst case directly over the same signed MCP
transport with the same key and the same tools, granting itself no extra
authority. The LLM agent (pnpm run agent) drives the happy path, which it
completes end to end. Operating instructions: docs/08-runbook.md.
A person authorises spending once, through Razorpay Checkout, creating a real UPI AutoPay mandate. That is the mandate the bank holds, and it carries one ceiling and one expiry. Santru holds the other.
| Rail mandate (Razorpay, NPCI) | Santru mandate | |
|---|---|---|
| Enforces | one amount ceiling, one expiry | per-transaction cap, cumulative cap, velocity, merchant allowlist, category allowlist, step-up threshold |
| Answers | may this agent charge up to X? | may this agent buy this, from this merchant, now, and why did we say yes? |
| Side | payer | merchant |
Every purchase verifies the rail mandate live against Razorpay before it touches money, and fails closed if the rail cannot be read. The gap between the two columns is the whole argument: the rail would happily authorise a payment to a merchant nobody vetted, because a ceiling is all it checks.
The mandate, the ceiling check, and the Razorpay orders are real. The debit
is attempted for real on every purchase; where Razorpay has not granted
the account server-to-server recurring access it resolves the token and then
declines to route, and the ledger records rail_settlement: "simulated" with
the reason, permanently. Nothing in the audit trail claims money moved when
it did not. How it was identified and reported:
docs/11-s2s-recurring-debit.md; how to
enable the entitlement: docs/09-deployment.md.
Three parties. Santru sits between two counterparties that never speak to each other, and it is the only one of the three that decides anything.
BUYER SIDE SANTRU (:3000) SELLER SIDE
shopper ┌────────────────────────┐ merchant
│ asks │ C1 identity + principal│ signed │ own Ed25519 key
▼ │ C8 seller identity │◄─writes─────┘
buyer agent ──signed────►│ C3 policy decision │
no credential MCP call │ ALLOW/DENY/STEP_UP │
│ C2 mandate, atomic │
│ C4 signed offers │
│ C5 orchestrator, F1–F6 │
│ C6 hash-chained ledger │
│ C7 step-up channel │
└───────────┬────────────┘
▼ only on ALLOW
Razorpay TEST MODE
STOREFRONT (:3001) hosts both faces — the shopper's chat and the seller's
listings — and holds no authority: it reaches Santru only over signed HTTP.
Five packages. core is the pure domain (policy, crypto, state machine; a lint
rule makes an I/O import a build error). gateway is Santru itself, plus the
operator console. store is the reference storefront, a separate application
that may not import the gateway — enforced by lint and by the absence of the
dependency. agent is the provider-agnostic buyer loop; eval is the corpus
and harness.
This is the smallest version that exercises every guarantee end to end, not a sketch. Buyers already have their own accounts and their own mandates; sellers already have their own keys, listings and orders; Santru already decides. The full product is the same three parties at scale: shoppers setting their own limits at onboarding, each merchant onboarded as a Razorpay Route linked account so the capture is split at settlement, the server-to-server debit entitlement granted so the money completes, and more than one node behind a load balancer. None of it changes a policy decision. The diagram and the exact list are in docs/01-architecture.md.
Specs in docs/. The three worth reading first: 01 architecture for the three parties and the ADRs, 03 policy engine for the decision path, and 10 production architecture for topology, trust zones and exactly where this stops scaling.
- No LLM in the policy path. The gate cannot be talked into an ALLOW.
- Unproven identity never spends silently (pol_v2): only Ed25519-verified callers get autonomous spend; claimed-but-unsigned ids step up to a human.
- The budget race is won at the database: one atomic conditional UPDATE is the only gate that admits spend; 20 parallel attempts against a nearly exhausted mandate admit exactly the affordable number (tested).
- Offers are signed and re-verified at capture, so price/quantity cannot drift between quote and payment.
- Webhooks: HMAC verified on the raw body (constant-time) before any field is read; deliveries deduped; ledgered before the transition they cause.
- Idempotency by construction: 200 concurrent duplicate submissions produce exactly one order (tested).
- Hash-chained ledger with decision-input digests; tampering any row is detected at that row.
- Operator console: expiring HMAC sessions, brute-force limited login,
CSP and security headers.
requireOperator()is the first line of every console page AND every privileged action — the middleware only proves a cookie is present, so this is where the MAC and the expiry are checked. A filesystem-walking test asserts no page or route can be added without a guard. - Fail closed everywhere: unknown enums, empty allowlists, malformed input, verification errors all terminate as DENY/401.
Honest limits (also in docs/05): provenance self-reports from a hijacked agent are corroborated by server-side served-content hashes, not trusted; injection detection claims are scoped to the declared corpus; an injected purchase that stays inside mandate scope is bounded, not detected; in-memory rate limits are per-process; a compromised DB role or host is out of scope.
Node 22+, pnpm 10+, and a Postgres database. Everything else is free and needs no card. docs/08-runbook.md has the same ground in more detail, including where each credential comes from.
| Why | Required? | |
|---|---|---|
| Neon Postgres | both applications' state | yes |
| Razorpay test keys | the payment rail; no KYC needed | yes |
| Google AI Studio key | the buyer agent's model | for the chat demo |
| Cloudflare R2 | product photographs | optional |
| Cloudflare Tunnel | Razorpay delivering real webhooks | optional |
Without R2, listings simply have no photograph. Without a tunnel, use
pnpm run webhook to sign a delivery locally against the real route.
git clone <your-fork> santru && cd santru
pnpm install
cp .env.example .env.env.example documents every value. The four you cannot skip:
DATABASE_URL=postgresql://… # your own Neon database
RAZORPAY_KEY_ID=rzp_test_… # test mode
RAZORPAY_KEY_SECRET=…
OPERATOR_TOKEN=$(openssl rand -hex 24) # opens the console
# plus two secrets the storefront needs
STORE_SESSION_SECRET=$(openssl rand -hex 24) # signs session cookies
STORE_KEY_SECRET=$(openssl rand -hex 24) # wraps sellers' signing keyspnpm run db:seed prints the Ed25519 private keys once; paste them into
.env as it instructs. Nothing in this repository carries anyone else's
credentials.
Both applications own a schema, so both migrate.
pnpm run db:migrate # gateway tables (public schema)
pnpm run store:db:migrate # storefront tables (store schema)
pnpm run db:seed -- --reset # catalog, agent, demo mandate; prints keys onceTwo terminals, because they are two deployments.
pnpm run dev # Santru + operator console → http://localhost:3000
pnpm run dev:store # the storefront → http://localhost:3001With both running — provisioning talks to the gateway over its API.
pnpm run store:provision # give every shopper account a budget
pnpm run store:merchants # give every seeded shop an account and its own key
pnpm run demo:check # preflight: is this actually ready?store:merchants prints the seller sign-ins it created. Set
STORE_DEMO_MERCHANT_PASSWORD in .env first if you want a stable one.
Both provisioning steps are required again after every
db:seed -- --reset, because the reset drops the gateway's mandates and
merchant keys while the storefront's accounts survive by design.
| Where | What |
|---|---|
localhost:3001/signup |
create a shopper, then just ask for groceries |
localhost:3001/setup |
authorise a UPI AutoPay mandate (₹1, test mode) |
localhost:3001/shelves |
the catalogue a shopper browses |
localhost:3001/merchant |
sign in as a seller: listings, orders, dispatch |
localhost:3000/login |
the console, with OPERATOR_TOKEN |
localhost:3000/admin |
revenue, ledger, mandates, sellers, step-up approvals |
Or drive the agent from the command line:
pnpm run agent --goal "Buy one 5kg pack of atta"
pnpm run attack # the hijacked-agent scenariopnpm run sweep # releases budget held by crashed flows; run one
pnpm run capture:watch # settles PAYING orders locally (demo convenience)One Dockerfile builds either application; --build-arg APP= selects
which. Compose brings up Postgres, runs both migration sets to completion,
then starts the gateway and the storefront.
cp .env.example .env # fill it in, incl. POSTGRES_PASSWORD
docker compose up --build| Storefront | http://localhost:3001 |
| Console | http://localhost:3000 |
| Health | GET /api/health on both |
Ordering is enforced, not hoped for: the applications wait on
service_completed_successfully for the migrations, so neither can serve a
request against an unmigrated schema. Containers run unprivileged with a
read-only root filesystem, and Postgres is not published to the host.
Seeding runs in the same image as the migrations. run --rm, not exec:
the migrate service is a run-to-completion job and has already exited by the
time the stack is up.
docker compose run --rm migrate sh -c "pnpm --filter @santru/gateway run db:seed -- --reset"Then provision, from your machine, against the running gateway:
GATEWAY_BASE_URL=http://localhost:3000 pnpm run store:provision
GATEWAY_BASE_URL=http://localhost:3000 pnpm run store:merchantsTo build one image by hand, name the stage — the runtime image is 358MB and the toolchain one is 1.1GB:
docker build --target runner --build-arg APP=gateway --build-arg PORT=3000 -t santru-gateway .Full deployment guidance, including Vercel and the two places this stops scaling: docs/09-deployment.md and docs/10-production-architecture.md.
pnpm run lint && pnpm run typecheck
TEST_DATABASE_URL="postgres://…" pnpm run test # 243 tests
pnpm run eval # the metrics table abovepnpm test fails without TEST_DATABASE_URL, deliberately. The
integration suites hold the only coverage for the concurrent budget race,
the oversell race, duplicate-payment suppression, ledger-chain integrity
and refund reversal, and they are guarded by that variable — so a run
without it would report a pass it has not earned. Point it at a database
the suites may TRUNCATE.
To run only the pure suites, knowing what they do not cover:
pnpm run test:unitThe integration suites truncate the gateway's tables, so re-seed after:
pnpm run db:seed -- --reset && pnpm run store:provision && pnpm run store:merchantsPages hang or 500 after running a build. next build and next dev
share .next. Stop both, rm -rf packages/*/.next, start again.
store:provision fails with 500. The gateway is not running or not
healthy; it provisions over the API. Check localhost:3000/api/health.
Orders sit at PAYING. Expected without server-to-server recurring debit
enabled on the Razorpay account. Run pnpm run capture:watch, or deliver a
webhook by hand with pnpm run webhook --order ord_… --event captured.
The catalogue is empty and the agent loops. The database is in post-test state. Re-seed as above.
Test mode only; no regulatory-compliance claim (our mandate is a design artifact, not an RBI e-mandate; UAP is pre-approval); no fraud scoring - this is deterministic authorization, not risk ML; single merchant per order (Route/split settlement is the natural extension).