grants.* — authorize once, spend later (human not present)
credentagent · prototype
+
+
+
① Human present — authorize once
+
+
+
event log
+
+
+
② Human away — the agent spends
+
Create a grant on the left; the spend controls appear here.
+
+
+
+`;
diff --git a/examples/orders-proto/server.mjs b/examples/orders-proto/server.mjs
new file mode 100644
index 0000000..4fcbdb8
--- /dev/null
+++ b/examples/orders-proto/server.mjs
@@ -0,0 +1,210 @@
+// ⚠ PROTOTYPE — a validation demo, NOT the shipping library API. It graduates into the
+// real credentagent.orders.* / credentagent.grants.* API in #97 (the demo is rewired to it).
+// server.mjs — the v10 `orders.*` surface wired to the REAL ceremony.
+//
+// (npm run build --workspaces) # once, if not built
+// node examples/orders-proto/server.mjs # → http://localhost:4010
+// # to prove on your phone: adb reverse tcp:4010 tcp:4010, then open the approveUrl there.
+//
+// This is now a REAL gate: it wraps createStorefront() + CredentAgent.mount() (exactly like
+// tools/demo-pki/run-gate.mjs) and exposes orders.create / orders.retrieve over it. The
+// approveUrl is the genuine checkout ceremony (age via OpenID4VP + payment via passkey/x402);
+// real completion writes the completed-order store, and that write IS the order.settled webhook.
+
+import { randomUUID } from "node:crypto";
+import { existsSync, readFileSync } from "node:fs";
+import { EventEmitter } from "node:events";
+import { createStorefront } from "@openmobilehub/credentagent-storefront/server";
+import { createOrder, SAMPLE_CATALOG } from "@openmobilehub/credentagent-storefront";
+import { CredentAgent, age, payment, required, issueCartMandate } from "@openmobilehub/credentagent-gate";
+
+const SIGNING_KEY = "orders-proto-secret";
+const b64u = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
+
+// Build the REAL mandate bundle for a settled order (increment A): the cartMandate is a
+// genuinely signed ap2.CartMandate (issueCartMandate over the order's lines); the paymentMandate
+// is assembled from the real settlement record. intentMandate is absent on a human-present order.
+function buildMandateBundle(order, rec) {
+ const cart = issueCartMandate(
+ { orderId: order.id, lines: order.lines, currency: order.currency ?? rec.currency, total: order.total ?? rec.amount },
+ SIGNING_KEY,
+ );
+ const cartMandate = { ...cart, serialize() { return b64u(cart); } };
+ const pay = {
+ type: "ap2.PaymentMandate",
+ orderId: order.id,
+ amount: { amount: rec.amount, currency: rec.currency },
+ method: rec.method,
+ presenceMode: "human_present",
+ authorization: "direct",
+ cart: cart.id,
+ ...(rec.txId ? { txId: rec.txId } : {}),
+ ...(rec.network ? { network: rec.network } : {}),
+ trust_level: "presence-only-demo",
+ };
+ const paymentMandate = { ...pay, serialize() { return b64u(pay); } };
+ return { intentMandate: undefined, cartMandate, paymentMandate, trustLevel: "presence-only-demo" };
+}
+
+const PORT = Number(process.env.PORT ?? 4010);
+const BASE = `http://localhost:${PORT}`;
+
+// Optional demo reader identity (so the wallet shows the verifier TRUSTED). Reuses the
+// demo-pki certs if present; otherwise self-signs (ceremony still works, shows "untrusted").
+const RID = "/Users/diegozuluaga/tools/git/attestomcp/.worktrees/demo-pki/tools/demo-pki";
+const readerIdentity =
+ existsSync(`${RID}/keys/reader-key.pem`) && existsSync(`${RID}/certs/reader-cert.pem`)
+ ? { key: readFileSync(`${RID}/keys/reader-key.pem`, "utf8"), cert: readFileSync(`${RID}/certs/reader-cert.pem`, "utf8") }
+ : undefined;
+
+// ── the stores (in-memory) — the completed store's write() is the order.settled webhook ──
+const created = new Map();
+const completed = new Map();
+const events = new EventEmitter();
+const createdOrderStore = { read: async (id) => created.get(id) ?? null, write: async (id, o) => { created.set(id, o); } };
+const orderStore = {
+ read: async (id) => completed.get(id) ?? null,
+ write: async (id, rec) => { completed.set(id, rec); events.emit("order.settled", { id }); },
+};
+
+const store = createStorefront({ createdOrderStore, orderStore, baseUrl: BASE, signingKey: "orders-proto-secret" });
+const credentagent = new CredentAgent({ walletOrigin: BASE, ...(readerIdentity ? { readerIdentity } : {}) });
+credentagent.mount(store.app);
+
+// The policy — age 21+ on age-restricted lines, payment last. Static array (predicates inside).
+const POLICY = [
+ required(age.over(21).when((o) => o.lines.some((l) => l.minimumAge != null))),
+ required(payment.in("usd")),
+];
+store.gate((order) => credentagent.requirements(order, POLICY));
+
+// The webhook (FR-009) — fired by the completed-store write when the REAL ceremony finishes.
+const log = [];
+events.on("order.settled", async ({ id }) => {
+ const rec = completed.get(id);
+ log.push(`✓ order.settled ${id} → ok · ${rec?.method ?? "?"} · ${rec?.currency ?? ""} ${(rec?.amount ?? 0) / 100} · ${rec?.txId ? "tx " + String(rec.txId).slice(0, 10) : "settled"}`);
+});
+
+const RESTRICTED = SAMPLE_CATALOG.find((p) => p.minimumAge != null) ?? SAMPLE_CATALOG[0];
+const manifestFor = (order) => {
+ const m = credentagent.requirements(order, POLICY);
+ const list = Array.isArray(m) ? m : (m?.requires ?? m?.manifest ?? []);
+ return list.map((e) => ({ credential: e.credential ?? e.id, required: e.required !== false, label: e.label ?? e.credential ?? e.id, minAge: e.minAge }));
+};
+
+// ── orders.* routes on the real gate's app ──
+store.app.post("/api/checkout", async (_req, res) => {
+ const id = `ord_${randomUUID().slice(0, 8)}`;
+ const order = createOrder([{ productId: RESTRICTED.id, quantity: 1 }], id, SAMPLE_CATALOG); // priced from the catalog
+ await createdOrderStore.write(id, order);
+ log.push(`→ orders.create → ${id} · ${RESTRICTED.name} · pending`);
+ res.json({ id, approveUrl: `${BASE}/checkout?order=${id}`, manifest: manifestFor(order) });
+});
+
+store.app.get("/api/order/:id", async (req, res) => {
+ const id = req.params.id;
+ const rec = completed.get(id);
+ const order = created.get(id);
+ const door = rec
+ ? {
+ ok: true,
+ authorization: "direct",
+ trustLevel: "presence-only-demo",
+ mandateBundle: order ? serializeBundle(buildMandateBundle(order, rec)) : undefined, // increment A
+ completion: { amount: rec.amount, currency: rec.currency, method: rec.method, txId: rec.txId ?? null, network: rec.network ?? null, completedAt: rec.completedAt },
+ }
+ : (order
+ ? { ok: false, pending: true, approveUrl: `${BASE}/checkout?order=${id}`, trustLevel: "presence-only-demo" }
+ : { ok: false, code: "not-found", trustLevel: "presence-only-demo" });
+ res.json({ door, log });
+});
+
+// JSON-safe view of the bundle for the wire (calls the mandates' serialize()).
+function serializeBundle(b) {
+ return {
+ intentMandate: b.intentMandate ?? null,
+ cartMandate: { type: b.cartMandate.type, id: b.cartMandate.id, total: b.cartMandate.total, trust_level: b.cartMandate.trust_level, serialized: b.cartMandate.serialize() },
+ paymentMandate: { type: b.paymentMandate.type, amount: b.paymentMandate.amount, method: b.paymentMandate.method, presenceMode: b.paymentMandate.presenceMode, authorization: b.paymentMandate.authorization, trust_level: b.paymentMandate.trust_level, serialized: b.paymentMandate.serialize() },
+ trustLevel: b.trustLevel,
+ };
+}
+
+// TEST-ONLY: simulate a ceremony completion so the ok-branch (+ mandateBundle) is verifiable
+// without a phone. Writes the completed store exactly as the real rail does → fires order.settled.
+store.app.post("/api/_test/settle/:id", async (req, res) => {
+ const id = req.params.id;
+ const order = created.get(id);
+ if (!order) return res.status(404).json({ error: "unknown order" });
+ await orderStore.write(id, { orderId: id, amount: order.total, currency: order.currency ?? "usd", method: "test-passkey", txId: "0xTEST" + id.slice(-6), network: "hedera-testnet", completedAt: new Date().toISOString() });
+ res.json({ settled: true });
+});
+
+store.app.get("/", (_req, res) => { res.type("html").send(PAGE); });
+
+const { url } = await store.listen(PORT);
+console.log(`\n orders.* prototype — wired to the REAL ceremony → ${BASE}`);
+console.log(` reader identity : ${readerIdentity ? "demo-pki (verifier shows TRUSTED)" : "self-signed (verifier shows untrusted)"}`);
+console.log(` demo UI : ${BASE}/ (Start checkout → real checkout → order.settled)`);
+console.log(` MCP endpoint : ${url}`);
+console.log(` On your phone : adb reverse tcp:${PORT} tcp:${PORT}, then open the approveUrl there for the real wallet ceremony.\n`);
+
+// ────────────────────────────────────────────────────────────────────
+const CSS = `
+ :root{--bg:#0b0f17;--surface:#121826;--surface2:#171f30;--border:#273043;--ink:#eaeff8;--muted:#9aa6bd;--accent:#6d93ff;--ok:#3ed89a;--pend:#e7a73c;--rf:#f1637c;--mono:ui-monospace,"SF Mono",Menlo,monospace}
+ @media(prefers-color-scheme:light){:root{--bg:#f4f6fa;--surface:#fff;--surface2:#eef2f8;--border:#dce3ec;--ink:#101827;--muted:#59637a;--accent:#2b54d6;--ok:#0e9e6a;--pend:#c67c0a;--rf:#d63e57}}
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.55}
+ .top{padding:1.1rem 1.5rem;border-bottom:1px solid var(--border);display:flex;align-items:baseline;gap:.7rem}
+ .top h1{font-size:1.05rem;margin:0;letter-spacing:-.01em}.top .tag{font-family:var(--mono);font-size:.7rem;color:var(--accent);letter-spacing:.08em;text-transform:uppercase}
+ .grid{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--border);min-height:calc(100vh - 58px)}
+ .pane{background:var(--bg);padding:1.4rem 1.5rem}
+ .pane h2{font-size:.78rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:0 0 1rem;font-family:var(--mono)}
+ button{font:inherit;font-weight:600;border:1px solid var(--accent);background:var(--accent);color:#fff;padding:.6rem 1.1rem;border-radius:9px;cursor:pointer}
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:11px;padding:1.1rem;margin-top:1rem}
+ .row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;font-family:var(--mono);font-size:.82rem;margin:.3rem 0}
+ .k{color:var(--muted)}.pill{font-family:var(--mono);font-weight:700;font-size:.75rem;padding:.15rem .55rem;border-radius:999px}
+ .p-ok{background:color-mix(in srgb,var(--ok) 18%,transparent);color:var(--ok)}
+ .p-pend{background:color-mix(in srgb,var(--pend) 20%,transparent);color:var(--pend)}
+ pre{font-family:var(--mono);font-size:.76rem;background:var(--surface2);border:1px solid var(--border);border-radius:9px;padding:.85rem;overflow:auto;margin:.6rem 0 0}
+ .log{font-family:var(--mono);font-size:.75rem;color:var(--muted)}.log div{padding:.2rem 0;border-bottom:1px solid var(--border)}
+ a{color:var(--accent)}code{font-family:var(--mono)}iframe{width:100%;height:520px;border:1px solid var(--border);border-radius:11px;margin-top:1rem;background:var(--surface)}
+ .hint{font-size:.85rem;color:var(--muted);margin:.6rem 0 0}
+`;
+
+const PAGE = `orders.* · real ceremony
+
orders.* — wired to the real ceremony
credentagent · prototype
+
+
+
① Merchant / Agent
+
+
+
order.settled webhook · server log
+
+
+
② The real checkout (prove age + pay)
+
Start a checkout on the left — the genuine checkout page loads here. Age is proven with your phone wallet (OpenID4VP); payment is a passkey (x402 on Hedera testnet).
+
To prove on your phone: adb reverse tcp:4010 tcp:4010, then open the approveUrl there.
+
+
+`;
diff --git a/specs/009-ap2-mandate-chain-dx/spec.md b/specs/009-ap2-mandate-chain-dx/spec.md
new file mode 100644
index 0000000..b0dedfd
--- /dev/null
+++ b/specs/009-ap2-mandate-chain-dx/spec.md
@@ -0,0 +1,241 @@
+# Feature Specification: The Consent SDK Surface (AP2 mandate chain, three enforcement paths)
+
+**Feature branch:** `009-ap2-mandate-chain-dx` · **Issue:** #92 · **Date:** 2026-07-20
+**Informs:** #17 (`credentagent.gate()`), #12/#69–71 (delegated grants), #39/#40 (wire format)
+
+## Overview
+
+This is the authoritative developer surface for CredentAgent's consent flow: how a developer
+gates a consequential agent action behind a proven wallet credential, across the **three
+situations** that genuinely differ — a hosted page, a page-less MCP tool, and a human-not-present
+delegated spend — plus how the AP2 mandates (Intent / Cart / Payment) are exposed.
+
+The surface below is the output of a **9-round adversarial DX review** (four cold-reader personas
++ a four-lens Stripe-grade council per round; see "Design journey"). The architecture was
+validated and the council directed it to be **frozen**; the final version applies its full set of
+consistency/naming fixes. The **spine** — one configured client, one catalog price source, one
+policy array, one typed result door, Money-as-type, `trustLevel` on every branch — is Stripe-grade
+and MUST NOT regress.
+
+## The surface (caller-first — this IS the DX test)
+
+```js
+import { CredentAgent, required, age, payment, usd } from "@openmobilehub/credentagent-gate";
+
+// ── Configure once ─────────────────────────────────────────────
+const credentagent = new CredentAgent({
+ origin: "https://shop.example", // your RP origin; mount() serves the /approve ceremony here
+ catalog: { wine: usd.dollars(20) }, // the ONE price source. Money is OPAQUE: compare with .lt()/.gte()/.eq(); .serialize() to the wire
+});
+credentagent.mount(app);
+const policy = [
+ required(age.over(21).when(o => o.lines.some(l => l.minimumAge >= 21))), // 21+ ONLY when the cart holds an age-restricted line — a shirt-only cart skips it
+ required(payment.in("usd")), // credentials — payment is just one of them
+];
+
+// ══ ONE CONTRACT (learn once) ═════════════════════════════════
+// PRICED INPUT: order = { id?, items: [{ sku, qty }] } // priced from the catalog; you NEVER pass an amount
+// RESULT DOOR: if (res.ok) res.mandateBundle // + res.authorization: "direct" | "delegated"
+// else if (res.pending) res.approveUrl // send to the human; then re-check / the agent re-calls
+// else res.code // switch on this; res.credential names which credential, when relevant
+// res.trustLevel ALWAYS present (every branch): "presence-only-demo" today — disclosure+binding, NOT issuer trust.
+// res.code is a TYPED union RefusalCode (NOT string) — so `s.code === "budget-exceeded"` autocompletes,
+// a typo fails to compile, and a switch is exhaustiveness-checked:
+// type RefusalCode = "under-age" | "payment-declined" | "no-membership"
+// | "budget-exceeded" | "per-spend-exceeded" | "not-allowed" | "revoked";
+//
+// TWO RESOURCES + one top-level wrapper:
+// orders one-shot verification ≈ Checkout Session / PaymentIntent orders.create()→{id,approveUrl} · orders.retrieve(id)→door
+// grants durable spend authority ≈ SetupIntent + off_session grants.create()→grant · grants.retrieve(id)→grant · grant.spend()/.revoke()
+// credentagent.gate(handler,{policy}) gate ANY page-less tool — ACTION-AGNOSTIC (a purchase, a records release,
+// a deploy). Top-level, NOT under `orders`: identity leads, payment is one
+// application. Its RETURN is the door.
+
+// ── orders — you host the consent page ─────────────────────────
+server.registerTool("checkout", inputSchema, async (args) => {
+ const { id, approveUrl, manifest } = await credentagent.orders.create({ order: { items: cartItems(args) }, policy });
+ return { structuredContent: { id, approveUrl, manifest } }; // keep id — retrieve the door by it
+});
+// later — the gate fires "order.settled"; your handler does ONE retrieve (never a poll loop):
+credentagent.on("order.settled", async ({ id }) => {
+ const res = await credentagent.orders.retrieve(id); // the DOOR: res.ok / res.pending+approveUrl / res.code
+ if (res.ok) { /* complete + settle res.mandateBundle.paymentMandate */ }
+});
+
+// ── credentagent.gate() — gate ANY page-less tool (here a NON-commerce action) ──
+server.registerTool("release-records", inputSchema, credentagent.gate(
+ async (args) => ({ structuredContent: await releaseRecords(args) }), // runs ONLY on ok — an unverified caller never reaches it
+ { order: (args) => ({ id: args.subject }), policy: [ required(age.over(21)) ] }, // no items → a pure identity gate; no purchase
+));
+// { ok:true, structuredContent, mandateBundle, authorization, trustLevel }
+// { ok:false, pending:true, approveUrl, resume:"release-records", trustLevel } // agent proves, re-calls
+// { ok:false, code:"under-age", credential:"age", trustLevel } // proven but failed policy
+
+// ── grants — authorize once, spend later (human not present) ──
+// (A) human PRESENT — create, persist id (exists before they prove), send approveUrl:
+const grant = await credentagent.grants.create({
+ merchant: "utopia", budget: usd.dollars(100), perSpend: usd.dollars(30),
+ allow: { category: ["Beverages"] }, // ← bound WHAT the agent may buy (SKUs/categories/attributes), not just how much
+ policy,
+});
+await store.save(userId, grant.id);
+sendToUser(grant.approveUrl);
+// (B) LATER, worker/cron — human AWAY — rehydrate, gate on status, spend:
+const grant = await credentagent.grants.retrieve(await store.load(userId)); // grant.status: "pending" | "authorized" | "revoked" | "denied"
+if (grant.status !== "authorized") return;
+for (const purchaseId of purchasesToMake()) {
+ const s = await grant.spend({ idempotencyKey: purchaseId, items: [{ sku: "wine", qty: 1 }] }); // durable key → s.replayed on a safe retry
+ if (!s.ok) { if (s.code === "budget-exceeded") break; throw new Error(s.code); } // "per-spend-exceeded" | "revoked"
+ forwardToPsp(s.mandateBundle.paymentMandate.serialize()); // authorization:"delegated" is stamped INTO the serialized mandate
+ if (s.remaining.lt(usd.dollars(20))) break; // Money comparison — never a raw scalar
+}
+await grant.revoke(); // grant.status → "revoked"; next spend → { ok:false, code:"revoked" }
+```
+
+## Requirements
+
+### Functional Requirements
+
+- **FR-001 — One configured client.** `new CredentAgent({ origin, catalog })` + `mount(app)`; every
+ path hangs off it. No second client, no per-path config door.
+- **FR-002 — One priced input.** `order = { id?, items: [{ sku, qty }] }` on every path; the gate
+ re-prices from the catalog server-side. A caller NEVER passes an amount (invariant #2).
+- **FR-003 — One result door.** `{ ok } | { ok:false, pending, approveUrl } | { ok:false, code,
+ credential? }`, with `res.trustLevel` and (on `ok`) `res.authorization` present on **every** branch
+ of **every** path. `code` is the switchable enum; `credential` names the failed credential; a
+ lifecycle refusal (`revoked`) is a `code`, never a credential.
+- **FR-004 — Two resources, symmetric.** `orders.create()/retrieve(id)` and `grants.create()/
+ retrieve(id)` — both awaited, both return an `id` from the mint, both retrievable by it. Orders are
+ one-shot verifications (retrieve → verdict); grants are durable (retrieve → handle with `status` +
+ `spend()`/`revoke()`). **`credentagent.gate(handler, opts)`** is the top-level, **action-agnostic**
+ page-less wrapper — it gates ANY consequential tool (a purchase, a records release, a deploy), so it
+ lives on the client, not under a commerce resource (thesis: identity leads, payment is one application).
+- **FR-005 — Money is a type.** `usd.dollars(n)`; opaque (no public scalar); compared via
+ `.lt()/.gte()/.eq()`, combined via `.plus()/.minus()`, emitted via `.serialize()`.
+ > **Reconciliation (2026-07-25):** the shipped orders half (#98) landed with **plain dollar numbers**,
+ > not this opaque `Money` type — a review call ("dollars everywhere") removed `money.ts` for the first
+ > increment. Money-as-type remains the design intent (it kills float/currency-mix footguns); whether the
+ > grants build (#104) reinstates `usd` or the SDK standardizes on plain numbers is an **open decision to
+ > settle in #104**, so the two halves don't drift. This spec keeps the `Money` design on record; the code is
+ > the interim.
+- **FR-006 — MandateBundle on `ok`.** `{ intentMandate?, cartMandate, paymentMandate }`, each with
+ its own `trustLevel` and `.serialize()`. `authorization: "direct" | "delegated"` rides on the result
+ AND is stamped into the serialized `paymentMandate` (so a PSP can't mistake an off-session,
+ human-away mandate for a live presentation).
+- **FR-007 — Delegated lifecycle.** `grants.create()` returns `grant.id` immediately (before the human
+ proves), so it persists across the authorize-now / spend-later process boundary; `grants.retrieve(id)`
+ rehydrates; `grant.status` gates spending; `idempotencyKey` is a durable per-purchase key
+ (`s.replayed` on a safe retry). **`grant.status` has exactly four states:**
+ - **`pending`** — created; waiting for the human to approve at `approveUrl`.
+ - **`authorized`** — the human approved; `spend()` is allowed within the sealed bounds.
+ - **`denied`** — the authorize ceremony ENDED WITHOUT approval: the human rejected the approve screen, or
+ it expired before they proved. It is **terminal for that grant** (unlike `pending`, no approval is still
+ coming); create a fresh grant to retry. Distinct from `revoked` in *when* it happens — `denied` is a grant
+ that was **never** authorized; `revoked` is one that **was** authorized and later cancelled.
+ - **`revoked`** — `grant.revoke()` was called after authorization; no further spend succeeds.
+ - **Race (authorize-now / spend-later): `revoke()` wins, fail-closed.** A `revoke()` can land while a
+ `spend()` is in flight. `spend()` re-reads the grant status + revocation store **server-side at settle**
+ (never trusting the rehydrated handle) and consumes the `idempotencyKey` atomically — so a spend that
+ started before the revoke but settles after it refuses with `code:"revoked"`. The revoke is authoritative;
+ an in-flight spend never sneaks past it.
+- **FR-008 — Additive.** Layers over today's `requirements()`/`mount()`, the retained Mode-B envelope,
+ `DelegatedGate`, and the existing `ap2.CartMandate`/`ap2.PaymentMandate`/`IntentBounds`. Ships without
+ breaking their current callers; `delegate()` may remain a thin alias of `grants.create()` during
+ migration.
+- **FR-009 — Completion signal, never a poll loop.** The async "human finished proving" transition is
+ delivered by a **webhook/callback** — `credentagent.on("order.settled", …)` plus a return-URL redirect
+ for the human — mirroring Stripe's `success_url` + `checkout.session.completed`. `orders.retrieve(id)`
+ is a **single current-state read** (for the callback handler, or a fallback), and an optional
+ `orders.awaitProof(id, { timeout })` resolves when settled. A hand-written poll loop MUST NOT be the
+ documented path. The page-less `credentagent.gate()` path needs **no** signal — the agent's re-call is the trigger.
+- **FR-010 — Intent Mandate production (grants only).** The AP2 **Intent Mandate** is produced in the
+ `grants` flow, at the one-time authorize ceremony (`grant.approveUrl`) — **not** in `orders` (a
+ human-present order signs the **Cart** Mandate directly, so `mandateBundle.intentMandate` is absent).
+ At authorize, the human's wallet **seals the bounded intent** — merchant, `perSpend`, `budget`, `policy`,
+ the optional **`allow`** item constraint (which SKUs / categories / attributes are permitted — so a human
+ who's away bounds *what* is bought, not only *how much*), expiry, and the delegate key permitted to sign
+ later spends — into the Intent Mandate; it then rides on `grant.intentMandate`, and every subsequent
+ spend's Payment Mandate references it. **Enforcement (invariant #1):** each `grant.spend()` re-checks the
+ requested items against the sealed `allow` (and `perSpend`/`budget`) **server-side, fail-closed** — an item
+ outside `allow` refuses with `code:"not-allowed"`, never trusting a caller-supplied item. Today it is
+ **dev-sealed** (content-addressed integrity hash, `sealIntent`), `trustLevel: "presence-only-demo"`; the
+ roadmap swaps the internals so the wallet **key-signs** it during the live ceremony (KB-JWT/SD-JWT —
+ #14/#39/#71) with no change to this surface.
+
+### Honesty (Constitution VII — load-bearing)
+
+- **HR-001** — `trustLevel` is `"presence-only-demo"` everywhere today (dev-signed integrity hash, NOT
+ issuer/key-signed). No prose, comment, or field may imply issuer-verified trust or real settlement.
+ The example says "seals/binds (dev-signed)", never "signed".
+- **HR-002** — On a delegated spend the human is absent; `authorization:"delegated"` + the fact that
+ `trustLevel` describes the *authorize* ceremony (not the spend) must be visible on the result and in
+ the serialized mandate. "presence-only" must never assert a presentation that didn't happen.
+
+## Success Criteria
+
+- **SC-001** — A cold reader picks the right path for each of the three canonical tasks (page checkout,
+ page-less tool gate, human-away budget) without re-reading, and writes the call in ~one declarative line.
+- **SC-002** — `if (res.ok) … else if (res.pending) … else switch (res.code)` compiles and is correct on
+ **every** path (byte-identical door).
+- **SC-003** — No amount is ever passed by the caller; a hand-edited price cannot change what settles
+ (bypass test).
+- **SC-004** — Every mandate and every result carries its `trustLevel`; a bypass test asserts nothing reads
+ as issuer-verified, and that a delegated mandate is marked as such through serialization.
+
+## Design journey (why these choices — the 9-round DX review)
+
+Scored 1–5 for Stripe-ease by four independent cold-reader personas each round; a four-lens council
+audited against `architecture-principles.md` + the constitution.
+
+| v | Score | What the round forced |
+| --- | --- | --- |
+| 1 | 3.67 | "three libraries stapled together"; honesty fence missing from the types |
+| 2 | 3.25 | one client + shared policy + `trustLevel`; over-corrected (purchase-in-tool went homeless) |
+| 3 | 3.50 | named present-pair; delegate consent ceremony surfaced; money/trust contracts |
+| 4 | 3.75 | one `{ok,reason}` door; catalog prices every path (kills agent-supplied-total) |
+| 5 | 3.70 | uniform input; the `ok`-on-success bug; `usd()` money helper; idempotent replay |
+| 6 | 3.67 | contract-first framing (learn once, then thin triggers); honesty-prose fix |
+| 7 | 3.75 | `proveUrl` on every result; **grant.id + rehydrate**; `requireInTool` third door; Stripe-analogue map |
+| 8 | 3.75 | **Stripe resource idiom** (`grants.create/retrieve`); specific refusal tokens; `spend().remaining` |
+| 9 | 3.75 | council froze the spine; final renames (below) |
+
+**Why it asymptotes at ~3.75, and why that is "nailed":** the score plateaued for six rounds because
+(a) a Stripe-veteran reader structurally anchors a *novel* consent API at 3–4 versus Stripe's decade of
+refinement, and (b) adversarial cold-readers always surface ~3 fresh consistency nits on any snippet. The
+council's own round-9 verdict — *"the spine is genuinely Stripe-grade and should be FROZEN; none require
+new surface — all are renames"* — is the true exit signal. The architecture is validated; the remainder was
+a naming pass, now applied.
+
+**Final naming pass applied (round-9 fixes):** `reason`+`detail` → `credential`+`code` (matches no-collision
+with the shipped `envelope.ts`); `reason:'revoked'` → a `code` / `grant.status`; `orders.require` →
+`orders.create` (awaited, symmetric with `grants.create`); mint returns `id`; `Money` made opaque;
+`proveUrl` → `approveUrl` (aligns with the shipped `approve_url`); `authorization` stamped into the
+serialized paymentMandate.
+
+**Post-lock reconciliation (2026-07-21):** the page-less wrapper — designed here as `orders.gate` /
+earlier `requireInTool` — is **`credentagent.gate(handler, opts)`**, top-level and **action-agnostic**.
+The `#17` session shipped it as `credentagent.gate()`, and that name wins over `orders.gate`: gating a
+*non-commerce* tool (a records release, a deploy) shouldn't live under a commerce resource — "identity
+leads; payment is one application." So `orders` and `grants` stay resources (the checkout and delegated
+*lifecycles*), while `gate()` is the general page-less primitive on the client. The design journey above
+predates this call; the surface + FR-004 reflect it.
+
+## Out of Scope
+
+- Wire-format / SD-JWT serialization (#39) and Python-SDK conformance (#40) — this owns the surface, not the wire.
+- Real key-bound / issuer-verified signing (#14).
+- Reworking `DelegatedGate`/intent-rail internals (#12, #69–71) — this is their surface, additive.
+- Implementation — this spec is design only; `plan.md` sequences the build over the existing primitives.
+
+**On the prototypes:** `examples/orders-proto/` and `examples/grants-proto/` are **validation demos** — facades
+that *stand in for* the API to prove the design runs; they are not the shipping library code. They graduate
+into the real `credentagent.orders.*` / `credentagent.grants.*` API in **#97**. **Status:** the orders half
+graduated — `credentagent.orders.*` + `orders.serve()` shipped in **#98** (merged) — so `orders-proto`'s
+page-less wrapper (which trusted a caller-supplied order id — a bind bug) was removed as superseded, and the
+real fail-closed path is `credentagent.gate()`. The grants half graduates under **#104** (this spec is its contract).
+
+## Dependencies
+
+- #17 (`credentagent.gate()` — the top-level, action-agnostic page-less wrapper; SHIPPED on `feat/17`).
+- #12 / #69–71 (`grants` = the delegated surface over the intent rail).
+- Constitution I (Stripe-grade, no grab-bags) and VII (honesty in types); Security invariants #1–#6.