Skip to content
gowtham472Public

About

An AI agent shops and pays for you, inside a signed spending mandate it cannot alter. Every call Ed25519-signed, every decision in an auditable ledger, real Razorpay payments.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

82 Commits

Folders and files

Repository files navigation

Santru: Bounded Agentic Commerce

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.

Why this exists

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.

Measured results (pnpm run eval, deterministic and offline)

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.

The demo in one paragraph

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.

Two mandates, and the gap between them

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.

What is real

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.

Architecture

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.

From this MVP to the full product

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.

Security posture (docs/05 has the full threat model)

  • 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.

Getting started

1. Prerequisites

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.

2. Configure

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 keys

pnpm 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.

3. Create the schema and seed

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 once

4. Run both applications

Two terminals, because they are two deployments.

pnpm run dev        # Santru + operator console  → http://localhost:3000
pnpm run dev:store  # the storefront             → http://localhost:3001

5. Provision accounts

With 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.

6. Use it

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 scenario

Long-running helpers

pnpm run sweep          # releases budget held by crashed flows; run one
pnpm run capture:watch  # settles PAYING orders locally (demo convenience)

Run it with Docker

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:merchants

To 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.

Verify it

pnpm run lint && pnpm run typecheck
TEST_DATABASE_URL="postgres://…" pnpm run test   # 243 tests
pnpm run eval                                     # the metrics table above

pnpm 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:unit

The integration suites truncate the gateway's tables, so re-seed after:

pnpm run db:seed -- --reset && pnpm run store:provision && pnpm run store:merchants

Troubleshooting

Pages 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.

Scope honesty

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).

About

An AI agent shops and pays for you, inside a signed spending mandate it cannot alter. Every call Ed25519-signed, every decision in an auditable ledger, real Razorpay payments.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages