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

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.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.
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:
committing and a background poller resumes it later under the exact same idempotency reference.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.
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.
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.

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.web/src/components/discover/how-it-completes.tsx, which detects and states this distinction on every cart rather than papering over 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.
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.
Every command below runs against the live deployment or the repository directly.
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.
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.
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.
npm run test:widget
33 tests, including one asserting all three delivery mechanisms carry byte-identical detection logic.
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.
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.
npm run e2e:plan
Nothing mocked: real Nominatim geocoding, real Overpass venues, the same ranking code the UI renders.
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.
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.
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.
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.