sutra/Architecture← back to sutra
Architecture

How sutra actually works.

A group plans and agrees one thing together without pooling money. Only a merchant with a real payment adapter can turn that agreement into capped charges through Prava, the payment platform Sutra is built on. Shopify POS, checkout handoff, and at-venue groups explicitly record zero charged by Sutra.

service.ts — the commit sagaspec/PROTOCOL.md — the spec626 tests, 35 files

GMP/1 is the protocol: people, one cart, a decision rule, and an all-or-none commit. It lives under /v1/groups and /v1/members.

Sutra is the product above it: planning, discovery, browser capture, MCP, and NANDA. Those surfaces prepare the purchase; only the protocol can commit it.

§1 · The system at a glance

Five doors in, one engine, and an explicit payment boundary.

A web app, a browser extension, a bookmarklet, an MCP server for agent frameworks, and a Python plugin for Project NANDA’s town simulator all speak the exact same HTTP contract into the same engine. None of that intake traffic touches money. A Prava charge is available only behind a supported merchant adapter; a product URL or imported cart never grants that capability.

Web appweb/src/appBrowser extensionextension/content.jsBookmarkletwidget/bookmarklet.jsMCP server (any agent framework)mcp/src/server.tsNANDA Town plugin (Python)nanda-town-prava/…/plugin.pyone REST contract, no money yetsutra engineengine/src/service.ts · one process, SQLiteOpenStreetMapGET /v1/places/geocode, /searchMerchant product pagesPOST /v1/discover/resolveYou — your own device, your own passkeyopens Prava's hosted pagePravaPOST /v1/sessions → POST /v1/mandates/:id/chargemoney landsThe merchantgets paid — sutra never sees the card number
carries money no money moves

A mandate — the one term on this page worth defining up front — is a permission, not a payment: “let this one merchant charge this one card up to this one amount, one time.” Creating a mandate moves no money. The engine asks Prava to create a mandate session (POST /v1/sessions) and hands the member a link to Prava’s own hosted page. What happens next is the one step in this entire system that no script, agent, or plugin can perform: the member opens that page on their own device and approves with their own passkey. The engine is not in that loop at all — it finds out later, by asking.

Only when the group’s rule is satisfied does the engine call POST /v1/mandates/:id/charge. That call is where Prava mints a single-use, merchant-locked card credential for a supported adapter. Everything upstream of it — discovery, planning, cart-building, reading a page you’re on — is free of money by construction, not by policy: none of those code paths hold a Prava key capable of a charge.

mcp/src/server.ts · extension/content.js · widget/bookmarklet.js · nanda-town-prava/nanda_town_prava/plugin.py

§2 · The life of a group

The real state machine — every status, every legal move.

Taken directly from the type declarations (engine/src/types.ts) and the code that actually assigns them (engine/src/service.ts), not from a description of either. A group and a member each have their own machine; the member machine forks depending on which of the selected settlement capability is carrying the group (more on that in §4).

GROUP — types.ts GroupStatuscollectingmembers approve, decline, or run out the clockpolicy satisfiedcommittingfirst charge call — the point of no returnevery entry landscommittedterminal — signed receipt issuedEXITSabortedpolicy unsatisfiable, or organizer cancelsexpireddeadline passed, policy still openpartialstraggler policy leaves a mixed outcomeMEMBER — types.ts MemberStatusinvitedopens their linkviewedsession created / asked directlyawaiting_approvalmandate active / acceptShare()approvedcharge attemptedchargingPrava confirms it landedchargedterminal, charging railsettled (at_venue rail)no card touched — deliberately not "charged"declined · expired · dropped · failedreachable from invited through approvednever from charged or settled — those are terminal

collecting is where every group starts and where it spends almost all its life: members open their link, approve or decline, or simply run out the clock. The type also declares an eighth status, draft — it is never assigned anywhere in the code (one grep hit: the declaration itself), so it is left off this diagram on purpose. The spec’s deciding state is real in the same way: decide() evaluates the group’s rule and, if it passes, locks the approver set and moves straight to committing in the same synchronous call — no group row is ever observed sitting in deciding.

committing is deliberately a one-way door: cancelGroup() throws “cannot cancel past the point of no return” the instant a group reaches it, because the first charge call is about to fire and a card charge does not have an undo button.

On the member side, the dashed amber line is a detail worth having exactly right, because it is easy to get backwards: when a price drift pushes someone over their approved cap, requoteCascade() resets them not to “awaiting approval” but all the way back to viewed — because it is openMember(), entered from viewed, that mints the fresh mandate session at the new share. Consent cannot silently stretch to cover a higher number; a requote is capped at two rounds, then the group aborts rather than asking a third time.

A real sutra group board mid-flight: three members in 'collecting', an event log showing member.invited, fx.snapshot, member.viewed and member.awaiting_acceptance events in order, and a consent thread with each member's node still unfilled.
A real group, mid-flight — status: collecting, three members still deciding. The event log on the left is the append-only source every one of these states is read back from; nothing here is a client-side guess.
§3 · The commit saga

From “the rule passed” to a signed receipt.

A card charge cannot be rolled back, so this is written as a saga, not a transaction: every step is idempotent, every attempt is logged before it is retried, and a crash at any point resumes from the event log rather than guessing. engine/src/service.ts, runCommit() onward.

policy satisfied — approver set lockedlockAndCommit() — service.tsshortfall after caps?backstop absorbs itproportional to each caprequote, then abort2 rounds maxcharge(mandate, reference)charge the mandatePOST /v1/mandates/:id/charge — one at a time4xx — a definite nono charge exists; straggler policy decides nextunknown after reconciliationnever failed — parked; poller resumes it laterlandedDB says charged before the slow report landsreportCharge(APPROVED)report the settlementretried 5× — charge stands even if this never confirmsafter every entrycancel unused mandatesevery authorization that was never chargedcommitted or partialdepends on whether every entry landedsigned receiptEd25519, hash-chained — rail, owed, charged

If the group’s locked members can’t quite cover the total at their approved caps — a straggler dropped out, or price drifted — the shortfall goes to any armed backstop first: someone who pre-authorised a second, separate mandate as a standing offer to cover exactly this. Only if no backstop covers it does the group requote. This is the closest thing in the system to group credit, and it still never pools a cent — a backstop’s money moves from their own card, through their own mandate, the same way everyone else’s does.

The charge call itself has three outcomes, and conflating any two of them is exactly how a system double-charges someone or gets stuck forever:

  • A 4xx response is a definite no. Prava has already told us nothing was charged; retrying it burns time and risks a second definite no reading like doubt.
  • A transport failure is genuinely unknown — the request may have landed. Before doing anything else, the engine asks Prava’s own record of that mandate’s charges for the reference it sent. Found it → adopt that transaction id, never reissue. Still not there after retries → the state is recorded as unknown, which is a first-class outcome, not a bug: the group stays in committing and a background poller resumes it later under the exact same idempotency reference.
  • A success is written to the database as charged before the (slower, separately retried) settlement report is even attempted — because a restart between those two steps used to leave the database saying “charging” for a card that had already been billed, and the resumed saga would mint a fresh reference and charge it again.

At the end, every mandate that was never charged is explicitly cancelled, and the receipt is Ed25519-signed and hash-chained (engine/src/receipt.ts) so anyone — a member, a judge — can verify it offline against the printed public key, without trusting sutra’s UI at all.

§4 · Settlement capabilities

What each outcome may claim — and the rule that catches a lie.

engine/src/rails.ts — the code calls each of these four settlement paths a rail — separates a true Prava adapter from Shopify POS handoff, online checkout handoff, and at-venue agreement. The last three cannot call Prava and are structurally forbidden from claiming money moved.

prava_mandatesat_venueneeds a chargeable merchantyesnoyour consent ispasskey approval,capped & lockedan explicit yes toan exact numberdoes the engine move moneyYESNOyour terminal statuschargedsettledreceipt charged_amountthe amount chargedalways 0the only verb allowed"charged""settled at the venue"a receipt claimsrail: at_venue, charged_amount: 50000verifyReceipt()rule 6at_venue rail + non-zero chargedrejected"at_venue receipt reports a charged amount —no card is charged on this rail"

A URL proves product provenance, never payment capability. Discover requires a person to choose Shopify POS or checkout handoff. The extension is always checkout handoff. Venue plans stay plans until a real bill exists; a chosen OpenStreetMap point is never treated as a checkout.

On the honest rail, a member’s consent is a real act with its own HTTP route — not a gap: POST /v1/members/:id/accept calls acceptShare(), which is deliberately a different act from a passkey mandate so the receipt can never blur the two. Reaching committed here means every amount is agreed and recorded; it never means a card was touched.

The bottom of the diagram is the part worth trusting most: verifyReceipt() is a pure function anyone can run offline against a receipt file. It rejects a non-zero charged amount on every non-charging capability, including Shopify POS and checkout handoff.

A real sutra at_venue approval page showing a member's exact share, the itemised lines it comes from, and the disclosure: 'Nothing is charged through sutra on this split. You are agreeing that ₹426.01 is your share, then paying Toit, Indiranagar directly on your own card.'
A real approval page on the at_venue rail. The disclosure text is not UI copy written once and forgotten — it is rails.ts’s own string, the same one that lands in the receipt.
The honest boundary this system draws for itself. Sutra does not place a merchant order for a shared cart. Four people means four single-use cards, and a normal checkout has one card field — so one cart split four ways only completes automatically where the merchant adapter reconciles those payments. Today, Sutra can prepare a confirmed Shopify POS split for a cashier, or return the group to online checkout while saying that address, shipping, tax and payment are still pending. See web/src/components/discover/how-it-completes.tsx, which detects and states this distinction on every cart rather than papering over it.
§5 · The coordination layer

A sentence, made into a cart — above the protocol, not part of it.

GMP/1 begins the moment a group already knows what it’s buying. Real groups don’t start there — they start at “dinner Saturday?” and spend an hour deciding when, where, and who can make it. This layer is that hour, made into an object. engine/src/plan/, docs/ARCHITECTURE.md §10.

free text"dinner sat near Koramangala, under 900 each"agent/extract.tsslotscategory · when · where · budget · party sizeparticipants answersignalsrsvp · availability · location · budget · votesources fetch candidatesoptionsoverpass (OSM) · storefront search · manualrank.ts — a pure scorerranked options5 weighted factors, each with a checkable reasonthe group picks onechosen optionconvertToGroup()layer boundary — not part of GMP/1GMP/1 protocol enginecart + members + policy + rail — protocol owns it now

A model may propose the slots (category, timing, place, budget) from free text, but a deterministic pass is the floor and always runs underneath it — with no key and no network, the same sentence still parses. The model never picks a venue, never sets a price, and never invents a coordinate: it reports a place phrase, and a real geocoder (OpenStreetMap Nominatim) turns that into an actual location.

Ranking is a pure function over real data — five weighted factors (how well the time works, how far people travel, whether it fits the budget, group preference, and freshness), and every one of them renders as a sentence a person can check by hand against the numbers, not a black box score.

convertToGroup() is the only door between the two layers, and it only opens one way: once a group hands its chosen option through it, the plan is marked converted and the coordination layer never touches that group again. This boundary is why an agent that speaks only /v1/groups is a complete GMP/1 client — this entire layer, real venues and all, is optional product built on top, not the protocol.

Verify

Don’t take this page’s word for it.

Every command below runs against the live deployment or the repository directly.

the engine is actually up

curl -s https://engine-production-e6fa.up.railway.app/health

Look at uptime_s — a small number means it was recently deployed, not that it’s down.

the discovery documents are real

curl -s https://sutra-gmp.vercel.app/.well-known/agent-card.json

Generated from one endpoint inventory (engine/src/discovery/endpoints.ts), CORS-open, unauthenticated.

the whole test suite

npm test -w engine

626 tests across 35 files, all passing as of 2 Aug 2026 — that number will drift; run it rather than trust it.

the widget/extension/bookmarklet share one brain

npm run test:widget

33 tests, including one asserting all three delivery mechanisms carry byte-identical detection logic.

a receipt, offline, no server

npx -w cli tsx src/gmp.ts verify receipt.json

Recomputes the hash chain, checks totals against entries, verifies the Ed25519 signature. Runs on a laptop with no network.

the commit saga under fault injection

npm run chaos

Random groups, random declines, random 500s and lost responses — then both the event log and the mock ledger are checked: nobody charged twice, every receipt verifies.

the coordination layer, against real venues

npm run e2e:plan

Nothing mocked: real Nominatim geocoding, real Overpass venues, the same ranking code the UI renders.

a real sandbox charge, passkey and all

npm run e2e:proof -- --watch

The one step this page cannot verify for you: it needs a human on a phone. That is the protocol’s security property working as designed, not a missing feature.

Stated plainly

What this page will not round up.

OPEN

Prava’s charge response includes a credentials field — the single-use card number itself — and the engine reads past it. Confirmed directly in the API contract (openapi.json:988, “present for merchant callers: single-use card credentials”) and in the client: chargeMandate() in engine/src/prava/client.ts types the response as { status, transactionId, errorCode, errorMessage, deduplicated } and nothing else — the credential Prava mints for each person is minted and then dropped on the floor. Wiring it up is a PCI-scope decision, not a UI change, and it has been left alone on purpose rather than half-done.

SPLIT TENDER

GMP/1 proposes that a merchant could reconcile the shared idempotency reference every charge in a group already carries (gmp:{group}:{member}:{source}:{attempt}) to accept several single-use cards against one order. No merchant has implemented that side of it. Until one does, a shared cart’s money reaches the merchant as several separate, real charges that their order system does not know belong together — see the note in §4.

SCOPE

GroupStatus.draft is declared in types.ts and assigned nowhere — a vestigial enum member, confirmed by grep, not a bug and not load-bearing for anything on this page. Standing rules and recurring/trust-line mandates (L4 in spec/PROTOCOL.md §9) are designed, not implemented. No AP2 mandate is issued or consumed by this code — spec/AP2-EXTENSION.md is a positioning memo, not an integration.