From 64cd3e7786964ad0ff73c16c5a2e7deff4d91bc8 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 19:02:45 -0700 Subject: [PATCH 01/13] spec(#92,008): AP2 mandate-chain developer surface (MandateBundle) SpecKit feature 008. Two entry surfaces (HP live-ceremony, HNP delegated), one shared MandateBundle { intentMandate?, cartMandate, paymentMandate }; presence is a flag on the Payment Mandate. Additive, dev-signed presence-only (VII). Refs #92 #12 #17 Signed-off-by: Diego Zuluaga --- .specify/feature.json | 2 +- specs/008-ap2-mandate-chain-dx/spec.md | 132 +++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 specs/008-ap2-mandate-chain-dx/spec.md diff --git a/.specify/feature.json b/.specify/feature.json index 8ae8c34..b76189b 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/007-quickstart-ladder" + "feature_directory": "specs/008-ap2-mandate-chain-dx" } diff --git a/specs/008-ap2-mandate-chain-dx/spec.md b/specs/008-ap2-mandate-chain-dx/spec.md new file mode 100644 index 0000000..560df58 --- /dev/null +++ b/specs/008-ap2-mandate-chain-dx/spec.md @@ -0,0 +1,132 @@ +# Feature Specification: AP2 Mandate-Chain Developer Surface (`MandateBundle`) + +**Feature branch:** `008-ap2-mandate-chain-dx` · **Issue:** #92 · **Date:** 2026-07-20 + +## Overview + +The library already builds all three AP2 mandates internally — the **Intent Mandate** +(`IntentBounds` / `sealIntent`), the **Cart Mandate** (`ap2.CartMandate` / `issueCartMandate`), +and the **Payment Mandate** (`ap2.PaymentMandate` / `buildPasskeyMandate`) — but never hands +them back as a consistent, retrievable artifact. A developer who wants to inspect, log, or pass +a signed mandate on to another AP2 component or the payment network has no first-class way to get +it. + +This feature adds a single shared return shape, **`MandateBundle`**, exposed by **both** consent +surfaces: the human-present live-ceremony path (`requirements()` + `mount()`, and #17's +`gateTool()`) and the human-not-present delegated path (`DelegatedGate.preApprove()` / `spend()`). +The two **entry surfaces stay separate** — each is honest to a genuinely different developer +situation (a live user signing a cart *now* vs pre-authorizing bounds for *later*) — but the +**output shape unifies**. Per AP2, "human present vs not" is a **flag on the Payment Mandate**, +not a separate pipeline. + +Everything remains **dev-signed, presence-only** (constitution VII); this feature exposes the +existing demo mandates, it does not add real signing (that is #14/#39). + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Retrieve the mandates after a human-present checkout (Priority: P1) + +A developer integrating the HP checkout completes a ceremony and wants the signed Cart + Payment +mandate for their records / to forward to the payment network. + +- Given a completed HP order, when the developer reads the completion result, then a + `MandateBundle` is present with `cartMandate` (user-signed) and `paymentMandate` + (`presence: "human_present"`), and `intentMandate` is `undefined`. +- No manual assembly: the bundle is one property access off the result. + +### User Story 2 - Retrieve the mandate chain from a delegated (HNP) spend (Priority: P1) + +A developer using `DelegatedGate` for an agent that buys while the human is away wants the full +chain that authorized a purchase. + +- Given a grant from `preApprove()`, when the developer reads `grant.intentMandate`, then they get + the user-signed `IntentBounds`. +- Given a successful `spend()`, when they read the result's `mandates`, then a `MandateBundle` + holds `intentMandate`, the algorithmically-generated `cartMandate`, and `paymentMandate` + (`presence: "human_not_present"`, pointing at the cart). + +### User Story 3 - Branch on presence (Priority: P2) + +A developer routes to different downstream handling for delegated vs live purchases. + +- Given any `MandateBundle`, when they read `paymentMandate.presence`, then it reliably reports + `"human_present"` or `"human_not_present"`. + +### User Story 4 - Hand a mandate to another AP2 component (Priority: P2) + +A developer forwards a `cartMandate` / `paymentMandate` to another AP2 agent or a settlement step. + +- Given a bundle, when they serialize a mandate, then it is a plain JSON object carrying its own + honesty marker (`signature.note` / `trust_level`) so the recipient cannot mistake demo trust for + issuer-verified trust. + +### Edge Cases + +- **HP has no Intent Mandate today** → `intentMandate` is `undefined` on the HP bundle. Do NOT + synthesize a placeholder to force symmetry. +- **A refused HNP spend** (over-cap / revoked / replay) → no `cartMandate` / `paymentMandate` is + produced; the bundle represents only an *authorized* draw. The refusal stays the typed + `SpendResult` reason. +- **Honesty** → every exposed object must read as dev-signed presence-only; none may imply + issuer/device-signed trust. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: Define a `MandateBundle` type: `{ intentMandate?: IntentBounds; cartMandate: + CartMandate; paymentMandate: PaymentMandate }`. +- **FR-002**: The HP completion path surfaces a `MandateBundle` (`cartMandate` user-signed, + `paymentMandate.presence = "human_present"`, `intentMandate` absent). +- **FR-003**: `DelegatedGate` grants expose `grant.intentMandate`; each successful `spend()` + surfaces a `MandateBundle` (`presence = "human_not_present"`). +- **FR-004**: `paymentMandate` carries the `presence` flag and a pointer to its `cartMandate`. +- **FR-005**: Every mandate object surfaces its honesty (dev-signed, presence-only); nothing may + read as issuer-verified (constitution VII). +- **FR-006**: **Additive only** — no change to the behavior or existing signatures of + `preApprove` / `spend` / `requirements` / `gateTool` / `completeOrder`; the bundle is extra + return data. No existing test changes its expectations. +- **FR-007**: A refused draw exposes no cart/payment mandate; the bundle represents authorized + draws only. + +### Key Entities + +- **MandateBundle** — the shared return artifact; the three AP2 mandates that apply. +- **IntentBounds** (Intent Mandate) — user-signed bounds; present only for HNP. +- **CartMandate** (`ap2.CartMandate`) — the exact priced cart; user-signed (HP) or agent-generated + (HNP). +- **PaymentMandate** (`ap2.PaymentMandate`) — derived; carries the presence flag + cart pointer. + +## Success Criteria *(mandatory)* + +- **SC-001**: From either surface, a developer retrieves the signed mandates via a single property + access on the result — no manual assembly, no low-level primitive calls. +- **SC-002**: Tests assert `presence` correctly distinguishes HP from HNP on real bundles. +- **SC-003**: The whole change is additive — the existing gate + storefront suites pass unchanged. +- **SC-004**: Honesty is visible in the exposed types/fields; a bypass-style test asserts no object + claims issuer-verified trust. + +## Assumptions + +- Builds on the existing `IntentBounds`/`sealIntent`, `CartMandate`/`issueCartMandate`, + `PaymentMandate`/`buildPasskeyMandate` implementations. +- Dev-signed presence-only remains the trust level; real key-bound signing is out of scope (#14/#39). +- The HP exposure point is the completion result (`completeOrder` / the `gateTool()` proven path); + confirming/adding that surfacing is part of the implementation. + +## Out of Scope + +- Wire-format / SD-JWT mandate serialization (#39) and cross-SDK conformance vs the Python AP2 SDK + (#40) — this feature is the **developer surface**, not the wire format. +- New or key-bound signing (#14). +- Redesigning `DelegatedGate` or the intent rail (#12, #69–71). +- The `gateTool()` internals (#17) — it ships mandates-hidden; this adds the exposure additively. +- A single polymorphic entry point (rejected in favor of two honest surfaces + one shared output). + +## Dependencies + +- **#12 / #69–71** — the HNP intent rail / `DelegatedGate` surface this exposes (the `005-*` + worktrees). Build on, do not redesign. +- **#17** — `gateTool()` (HP page-less); independent, additive. +- **Constitution** — Principle I (Stripe-grade, no grab-bags), Principle VII (honesty in types), + Security "per-order state" (bundles are per order/draw, never process-global). From c26cde231cf7e1ce132fdae2f2661ef9ecada366 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 20:39:41 -0700 Subject: [PATCH 02/13] spec(#92,008): lock the consent SDK surface after 9-round DX council The AI DX council (4 cold-reader personas + a 4-lens Stripe-grade council per round, 9 rounds) validated the architecture and directed the spine to be frozen. Final surface: one client, one catalog price source, one policy array, one typed {ok|pending|code} door, Money-as-type, trustLevel on every branch; two symmetric resources (orders.create/retrieve, grants.create/retrieve) + requireInTool, mapped to Stripe Checkout/PaymentIntent/SetupIntent. Applies the round-9 naming pass (credential+code, orders.create, opaque Money, approveUrl, delegated authorization stamped into the serialized mandate). Spec carries the round-by-round journey. Refs #92 #17 #12 #14 Signed-off-by: Diego Zuluaga --- specs/008-ap2-mandate-chain-dx/spec.md | 275 ++++++++++++++----------- 1 file changed, 157 insertions(+), 118 deletions(-) diff --git a/specs/008-ap2-mandate-chain-dx/spec.md b/specs/008-ap2-mandate-chain-dx/spec.md index 560df58..2a1ea9d 100644 --- a/specs/008-ap2-mandate-chain-dx/spec.md +++ b/specs/008-ap2-mandate-chain-dx/spec.md @@ -1,132 +1,171 @@ -# Feature Specification: AP2 Mandate-Chain Developer Surface (`MandateBundle`) +# Feature Specification: The Consent SDK Surface (AP2 mandate chain, three enforcement paths) **Feature branch:** `008-ap2-mandate-chain-dx` · **Issue:** #92 · **Date:** 2026-07-20 +**Informs:** #17 (`requireInTool`), #12/#69–71 (delegated grants), #39/#40 (wire format) ## Overview -The library already builds all three AP2 mandates internally — the **Intent Mandate** -(`IntentBounds` / `sealIntent`), the **Cart Mandate** (`ap2.CartMandate` / `issueCartMandate`), -and the **Payment Mandate** (`ap2.PaymentMandate` / `buildPasskeyMandate`) — but never hands -them back as a consistent, retrievable artifact. A developer who wants to inspect, log, or pass -a signed mandate on to another AP2 component or the payment network has no first-class way to get -it. - -This feature adds a single shared return shape, **`MandateBundle`**, exposed by **both** consent -surfaces: the human-present live-ceremony path (`requirements()` + `mount()`, and #17's -`gateTool()`) and the human-not-present delegated path (`DelegatedGate.preApprove()` / `spend()`). -The two **entry surfaces stay separate** — each is honest to a genuinely different developer -situation (a live user signing a cart *now* vs pre-authorizing bounds for *later*) — but the -**output shape unifies**. Per AP2, "human present vs not" is a **flag on the Payment Mandate**, -not a separate pipeline. - -Everything remains **dev-signed, presence-only** (constitution VII); this feature exposes the -existing demo mandates, it does not add real signing (that is #14/#39). - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Retrieve the mandates after a human-present checkout (Priority: P1) - -A developer integrating the HP checkout completes a ceremony and wants the signed Cart + Payment -mandate for their records / to forward to the payment network. - -- Given a completed HP order, when the developer reads the completion result, then a - `MandateBundle` is present with `cartMandate` (user-signed) and `paymentMandate` - (`presence: "human_present"`), and `intentMandate` is `undefined`. -- No manual assembly: the bundle is one property access off the result. - -### User Story 2 - Retrieve the mandate chain from a delegated (HNP) spend (Priority: P1) - -A developer using `DelegatedGate` for an agent that buys while the human is away wants the full -chain that authorized a purchase. - -- Given a grant from `preApprove()`, when the developer reads `grant.intentMandate`, then they get - the user-signed `IntentBounds`. -- Given a successful `spend()`, when they read the result's `mandates`, then a `MandateBundle` - holds `intentMandate`, the algorithmically-generated `cartMandate`, and `paymentMandate` - (`presence: "human_not_present"`, pointing at the cart). - -### User Story 3 - Branch on presence (Priority: P2) - -A developer routes to different downstream handling for delegated vs live purchases. - -- Given any `MandateBundle`, when they read `paymentMandate.presence`, then it reliably reports - `"human_present"` or `"human_not_present"`. - -### User Story 4 - Hand a mandate to another AP2 component (Priority: P2) - -A developer forwards a `cartMandate` / `paymentMandate` to another AP2 agent or a settlement step. - -- Given a bundle, when they serialize a mandate, then it is a plain JSON object carrying its own - honesty marker (`signature.note` / `trust_level`) so the recipient cannot mistake demo trust for - issuer-verified trust. - -### Edge Cases - -- **HP has no Intent Mandate today** → `intentMandate` is `undefined` on the HP bundle. Do NOT - synthesize a placeholder to force symmetry. -- **A refused HNP spend** (over-cap / revoked / replay) → no `cartMandate` / `paymentMandate` is - produced; the bundle represents only an *authorized* draw. The refusal stays the typed - `SpendResult` reason. -- **Honesty** → every exposed object must read as dev-signed presence-only; none may imply - issuer/device-signed trust. - -## Requirements *(mandatory)* +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)), 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 ∈ "under-age" | "payment-declined" | "no-membership" | "budget-exceeded" | "per-spend-exceeded" | "revoked" | … +// +// TWO RESOURCES + one wrapper — the same split Stripe uses: +// 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() +// requireInTool() page-less wrapper — its RETURN is the door (the one deliberate standalone) + +// ── 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 +}); +const res = await credentagent.orders.retrieve(id); // the DOOR: res.ok / res.pending+approveUrl / res.code + +// ── requireInTool — page-less MCP tool ───────────────────────── +server.registerTool("place-order", inputSchema, credentagent.requireInTool( + async (args) => ({ structuredContent: await placeOrder(args) }), // runs ONLY on ok — an unverified caller never reaches it + { order: (args) => ({ id: args.orderId, items: itemsFrom(args) }), policy }, +)); +// { ok:true, structuredContent, mandateBundle, authorization, trustLevel } +// { ok:false, pending:true, approveUrl, resume:"place-order", 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), 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**: Define a `MandateBundle` type: `{ intentMandate?: IntentBounds; cartMandate: - CartMandate; paymentMandate: PaymentMandate }`. -- **FR-002**: The HP completion path surfaces a `MandateBundle` (`cartMandate` user-signed, - `paymentMandate.presence = "human_present"`, `intentMandate` absent). -- **FR-003**: `DelegatedGate` grants expose `grant.intentMandate`; each successful `spend()` - surfaces a `MandateBundle` (`presence = "human_not_present"`). -- **FR-004**: `paymentMandate` carries the `presence` flag and a pointer to its `cartMandate`. -- **FR-005**: Every mandate object surfaces its honesty (dev-signed, presence-only); nothing may - read as issuer-verified (constitution VII). -- **FR-006**: **Additive only** — no change to the behavior or existing signatures of - `preApprove` / `spend` / `requirements` / `gateTool` / `completeOrder`; the bundle is extra - return data. No existing test changes its expectations. -- **FR-007**: A refused draw exposes no cart/payment mandate; the bundle represents authorized - draws only. - -### Key Entities - -- **MandateBundle** — the shared return artifact; the three AP2 mandates that apply. -- **IntentBounds** (Intent Mandate) — user-signed bounds; present only for HNP. -- **CartMandate** (`ap2.CartMandate`) — the exact priced cart; user-signed (HP) or agent-generated - (HNP). -- **PaymentMandate** (`ap2.PaymentMandate`) — derived; carries the presence flag + cart pointer. - -## Success Criteria *(mandatory)* - -- **SC-001**: From either surface, a developer retrieves the signed mandates via a single property - access on the result — no manual assembly, no low-level primitive calls. -- **SC-002**: Tests assert `presence` correctly distinguishes HP from HNP on real bundles. -- **SC-003**: The whole change is additive — the existing gate + storefront suites pass unchanged. -- **SC-004**: Honesty is visible in the exposed types/fields; a bypass-style test asserts no object - claims issuer-verified trust. - -## Assumptions - -- Builds on the existing `IntentBounds`/`sealIntent`, `CartMandate`/`issueCartMandate`, - `PaymentMandate`/`buildPasskeyMandate` implementations. -- Dev-signed presence-only remains the trust level; real key-bound signing is out of scope (#14/#39). -- The HP exposure point is the completion result (`completeOrder` / the `gateTool()` proven path); - confirming/adding that surfacing is part of the implementation. +- **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()`). `requireInTool()` is the one documented standalone wrapper. +- **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()`. +- **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). +- **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. + +### 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. ## Out of Scope -- Wire-format / SD-JWT mandate serialization (#39) and cross-SDK conformance vs the Python AP2 SDK - (#40) — this feature is the **developer surface**, not the wire format. -- New or key-bound signing (#14). -- Redesigning `DelegatedGate` or the intent rail (#12, #69–71). -- The `gateTool()` internals (#17) — it ships mandates-hidden; this adds the exposure additively. -- A single polymorphic entry point (rejected in favor of two honest surfaces + one shared output). +- 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. ## Dependencies -- **#12 / #69–71** — the HNP intent rail / `DelegatedGate` surface this exposes (the `005-*` - worktrees). Build on, do not redesign. -- **#17** — `gateTool()` (HP page-less); independent, additive. -- **Constitution** — Principle I (Stripe-grade, no grab-bags), Principle VII (honesty in types), - Security "per-order state" (bundles are per order/draw, never process-global). +- #17 (`requireInTool` — the page-less wrapper; the in-flight `gateTool` is its earlier name). +- #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. From be12f18f87111a8e2fcc32c5cc1514cc0f3cdbc4 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 22:03:57 -0700 Subject: [PATCH 03/13] spec(#92,008): completion via webhook (no poll loop) + Intent Mandate production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FR-009: the 'human finished proving' transition is delivered by a webhook (credentagent.on('order.settled')) + return-URL, mirroring Stripe success_url + checkout.session.completed; retrieve(id) is a single read, awaitProof() an inline option; a poll loop is never the documented path; orders.gate needs no signal. FR-010: the Intent Mandate is produced in the grants authorize ceremony (not orders — a human-present order signs the Cart directly); dev-sealed today, wallet-key-signed on the roadmap (#14/#39/#71). Refs #92 #17 #14 Signed-off-by: Diego Zuluaga --- specs/008-ap2-mandate-chain-dx/spec.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/specs/008-ap2-mandate-chain-dx/spec.md b/specs/008-ap2-mandate-chain-dx/spec.md index 2a1ea9d..072a26e 100644 --- a/specs/008-ap2-mandate-chain-dx/spec.md +++ b/specs/008-ap2-mandate-chain-dx/spec.md @@ -48,7 +48,11 @@ 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 }); -const res = await credentagent.orders.retrieve(id); // the DOOR: res.ok / res.pending+approveUrl / res.code +// 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 */ } +}); // ── requireInTool — page-less MCP tool ───────────────────────── server.registerTool("place-order", inputSchema, credentagent.requireInTool( @@ -106,6 +110,21 @@ await grant.revoke(); // grant.status `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 `orders.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`, + 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. 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) From 32b0e1273e8b6f7ebb17fc66f2dfa7346be22301 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 23:24:34 -0700 Subject: [PATCH 04/13] proto(#92,008): runnable orders.* prototype alongside its spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the orders.* prototype onto the 008 feature branch so the spec and the runnable demo that proves it live together (was on a stray proto/orders-surface branch). Real gate (createStorefront + mount), orders.create/retrieve over it, the order.settled webhook, two-pane lifecycle UI. node examples/orders-proto/server.mjs → :4010. Refs #92 Signed-off-by: Diego Zuluaga --- examples/orders-proto/orders.mjs | 125 ++++++++++++++++++++++++++ examples/orders-proto/server.mjs | 149 +++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 examples/orders-proto/orders.mjs create mode 100644 examples/orders-proto/server.mjs diff --git a/examples/orders-proto/orders.mjs b/examples/orders-proto/orders.mjs new file mode 100644 index 0000000..037c83c --- /dev/null +++ b/examples/orders-proto/orders.mjs @@ -0,0 +1,125 @@ +// orders.mjs — a RUNNABLE prototype of the v10 `orders.*` surface (#92 / spec 008). +// +// This is a facade: it implements the DESIGNED shape (orders.create / orders.retrieve / +// orders.gate, the one { ok | pending | code } door, Money-as-type, trustLevel on every +// branch, the "order.settled" webhook) over an in-memory store + the REAL policy builders +// from @openmobilehub/credentagent-gate. The prove step is demo-approved here; the package +// version delegates manifest resolution to requirements() and the ceremony to mount(). +// +// The point: make the new DX tangible and clickable, and prove the facade sits cleanly on +// the real primitives — not to ship this file. + +import { EventEmitter } from "node:events"; +import { randomUUID } from "node:crypto"; + +// ── Money — opaque, comparable (v10 FR-005) ──────────────────────── +export function usd(minor) { + return Object.freeze({ + currency: "usd", + lt(o) { return minor < o._minor; }, + gte(o) { return minor >= o._minor; }, + eq(o) { return minor === o._minor; }, + serialize() { return { amount: minor, currency: "usd" }; }, + toString() { return `$${(minor / 100).toFixed(2)}`; }, + get _minor() { return minor; }, + }); +} +usd.dollars = (d) => usd(Math.round(d * 100)); +usd.cents = (c) => usd(c); + +// ── the demo mandate bundle (real SHAPE; dev-sealed, presence-only-demo) ── +function mandateBundleFor(order, authorization) { + const seal = (type) => ({ + type, + trustLevel: "presence-only-demo", + signature: { alg: "MOCK-DEV-SIGNER", note: "dev-sealed integrity hash — not key/issuer-signed" }, + serialize() { return { type, trust_level: "presence-only-demo", authorization, order: order.id }; }, + }); + return { + // Intent Mandate is a grants-only artifact (FR-010) — absent on a human-present order: + intentMandate: undefined, + cartMandate: { ...seal("ap2.CartMandate"), lines: order.items }, + paymentMandate: { ...seal("ap2.PaymentMandate"), amount: order.total.serialize(), presenceMode: authorization === "delegated" ? "human_not_present" : "human_present" }, + trustLevel: "presence-only-demo", + }; +} + +// ── the facade ───────────────────────────────────────────────────── +export class CredentAgentProto { + constructor({ origin, catalog }) { + this.origin = origin; + this.catalog = catalog; + this._orders = new Map(); + this._events = new EventEmitter(); + this.orders = { + // orders.create({ order, policy }) → { id, approveUrl, manifest } (mint, not the door) + create: ({ order, policy }) => { + const id = `ord_${randomUUID().slice(0, 8)}`; + const total = this._price(order.items); // re-priced from catalog — no amount is ever trusted + const rec = { id, items: order.items, total, policy, state: "pending" }; + this._orders.set(id, rec); + return { id, approveUrl: `${this.origin}/prove/${id}`, manifest: this._manifest(policy) }; + }, + // orders.retrieve(id) → the DOOR (single read; use in a webhook handler, never a poll loop) + retrieve: (id) => this._door(this._orders.get(id)), + }; + } + + // orders.gate(handler, { order, policy }) — page-less wrapper; its RETURN is the door + gate(handler, { policy }) { + return async (args) => { + const existing = args.__orderId && this._orders.get(args.__orderId); + if (existing && existing.state === "verified") return { ok: true, structuredContent: await handler(args), mandateBundle: existing.bundle, trustLevel: "presence-only-demo" }; + const { id, approveUrl } = this.orders.create({ order: { items: args.items ?? [] }, policy }); + return { ok: false, pending: true, approveUrl, resume: args.__tool ?? "tool", trustLevel: "presence-only-demo" }; + }; + } + + // credentagent.on("order.settled", handler) — the webhook (FR-009) + on(event, handler) { this._events.on(event, handler); } + + // DEMO prove — stands in for the wallet ceremony; marks verified, seals the bundle, fires the webhook + _demoProve(id, { pass = true, failCode = "under-age", failCredential = "age" } = {}) { + const rec = this._orders.get(id); + if (!rec) return { ok: false, code: "not-found" }; + if (pass) { + rec.state = "verified"; + rec.authorization = "direct"; + rec.bundle = mandateBundleFor(rec, "direct"); + } else { + rec.state = "refused"; rec.code = failCode; rec.credential = failCredential; + } + this._events.emit("order.settled", { id }); + return this._door(rec); + } + + _price(items) { + let minor = 0; + for (const { sku, qty } of items) { + const entry = this.catalog[sku]; + if (!entry) throw new Error(`unknown sku: ${sku}`); + minor += entry._minor * (qty ?? 1); + } + return usd(minor); + } + + _manifest(policy) { + return policy.map((step) => { + const c = step.credential ?? step; // required(x) wraps a credential; tolerate either + return { + credential: c.id ?? "credential", + required: step.required !== false, + label: c.ui?.label ?? c.id ?? "credential", + minAge: c.params?.minAge, + trustLevel: "presence-only-demo", + }; + }); + } + + _door(rec) { + if (!rec) return { ok: false, code: "not-found", trustLevel: "presence-only-demo" }; + if (rec.state === "verified") return { ok: true, mandateBundle: rec.bundle, authorization: rec.authorization, trustLevel: "presence-only-demo", total: rec.total }; + if (rec.state === "refused") return { ok: false, code: rec.code, credential: rec.credential, trustLevel: "presence-only-demo" }; + return { ok: false, pending: true, approveUrl: `${this.origin}/prove/${rec.id}`, trustLevel: "presence-only-demo" }; + } +} diff --git a/examples/orders-proto/server.mjs b/examples/orders-proto/server.mjs new file mode 100644 index 0000000..f890f6c --- /dev/null +++ b/examples/orders-proto/server.mjs @@ -0,0 +1,149 @@ +// 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 } from "@openmobilehub/credentagent-gate"; + +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 rec = completed.get(req.params.id); + const door = rec + ? { ok: true, authorization: "direct", trustLevel: "presence-only-demo", completion: { amount: rec.amount, currency: rec.currency, method: rec.method, txId: rec.txId ?? null, network: rec.network ?? null, completedAt: rec.completedAt } } + : (created.has(req.params.id) + ? { ok: false, pending: true, approveUrl: `${BASE}/checkout?order=${req.params.id}`, trustLevel: "presence-only-demo" } + : { ok: false, code: "not-found", trustLevel: "presence-only-demo" }); + res.json({ door, log }); +}); + +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.

+
+
+`; From 6da92dc139760b9a986a310667c44ef74965c68d Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 23:30:21 -0700 Subject: [PATCH 05/13] =?UTF-8?q?proto(#92,008):=20increment=20A=20?= =?UTF-8?q?=E2=80=94=20real=20mandateBundle=20on=20the=20orders=20door?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orders door's ok branch now carries a real signed ap2.CartMandate (issueCartMandate over the order lines, .serialize() → base64url) + a paymentMandate assembled from the real settlement record (presenceMode human_present, amount-bound to the cart), intentMandate null (HP order, FR-010), trustLevel on every object. A test-only /api/_test/settle hook lets the ok branch be verified without a phone. Smoke-tested: cart+payment mandates present, serialize() works, amounts bind. Refs #92 Signed-off-by: Diego Zuluaga --- examples/orders-proto/server.mjs | 71 +++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/examples/orders-proto/server.mjs b/examples/orders-proto/server.mjs index f890f6c..af00c21 100644 --- a/examples/orders-proto/server.mjs +++ b/examples/orders-proto/server.mjs @@ -14,7 +14,35 @@ 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 } from "@openmobilehub/credentagent-gate"; +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}`; @@ -72,15 +100,43 @@ store.app.post("/api/checkout", async (_req, res) => { }); store.app.get("/api/order/:id", async (req, res) => { - const rec = completed.get(req.params.id); + 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", completion: { amount: rec.amount, currency: rec.currency, method: rec.method, txId: rec.txId ?? null, network: rec.network ?? null, completedAt: rec.completedAt } } - : (created.has(req.params.id) - ? { ok: false, pending: true, approveUrl: `${BASE}/checkout?order=${req.params.id}`, trustLevel: "presence-only-demo" } + ? { + 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); @@ -144,6 +200,9 @@ async function poll(){ el('log').innerHTML=log.slice(-8).map(l=>'
'+l+'
').join(''); const st=el('st');if(!st)return; if(door.ok){st.className='pill p-ok';st.textContent='ok';clearInterval(timer); - el('result').innerHTML=\`
authorization\${door.authorization}trust: \${door.trustLevel}
completion = \${JSON.stringify(door.completion,null,2)}
\`;} + const mb=door.mandateBundle; + el('result').innerHTML=\`
authorization\${door.authorization}trust: \${door.trustLevel}
+
res.mandateBundle = \${JSON.stringify({intentMandate:mb?.intentMandate,cartMandate:{type:mb?.cartMandate.type,id:mb?.cartMandate.id,total:mb?.cartMandate.total,trust_level:mb?.cartMandate.trust_level,'serialize()':(mb?.cartMandate.serialized||'').slice(0,32)+'…'},paymentMandate:{...mb?.paymentMandate,serialized:(mb?.paymentMandate.serialized||'').slice(0,32)+'…'}},null,2)}
+
res.completion = \${JSON.stringify(door.completion,null,2)}
\`;} } `; From b43117dd769dc69261dbfb28a98165872610c94f Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 23:36:32 -0700 Subject: [PATCH 06/13] =?UTF-8?q?proto(#92,008):=20increment=20B=20?= =?UTF-8?q?=E2=80=94=20runnable=20grants.*=20(human-not-present)=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v10 grants surface over the REAL DelegatedGate engine: grants.create → authorize (the Intent Mandate is produced, dev-sealed today) → grant.spend loop → grant.revoke. Facade demonstrates the v10 design over the real engine: usd() Money, the { ok | code } door, idempotent replay ({ok:true, replayed:true}, no double-charge), split reason codes (over-cap→per-spend-exceeded, over-total→ budget-exceeded), mandateBundle (intentMandate PRESENT, presenceMode human_not_present). Two-pane UI on :4020. Smoke-tested: per-spend + budget caps, replay, revoke all correct over the real bounds/ledger/revocation. Refs #92 #12 Signed-off-by: Diego Zuluaga --- examples/grants-proto/grants.mjs | 136 ++++++++++++++++++++++++++++ examples/grants-proto/server.mjs | 148 +++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 examples/grants-proto/grants.mjs create mode 100644 examples/grants-proto/server.mjs diff --git a/examples/grants-proto/grants.mjs b/examples/grants-proto/grants.mjs new file mode 100644 index 0000000..7e7f4c0 --- /dev/null +++ b/examples/grants-proto/grants.mjs @@ -0,0 +1,136 @@ +// grants.mjs — a RUNNABLE prototype of the v10 `grants.*` surface (#92 / spec 008), +// the human-NOT-present half, over the REAL DelegatedGate engine. +// +// grants.create → authorize (the Intent Mandate is produced) → grant.spend loop +// (budget / perSpend enforcement, remaining, idempotent replay) → grant.revoke. +// +// REAL: DelegatedGate.preApprove/spend/revoke — the bounds check, the single-use ledger, +// the revocation, the dev-sealed Intent Mandate (sealIntent) are the actual engine. +// FACADE polish demonstrating the v10 design: usd() Money, { ok | code } door, idempotent +// replay ({ ok:true, replayed:true }), split reason codes (per-spend vs budget), mandateBundle. +// HONEST: today preApprove seals the intent SERVER-side (presence "delegated-demo", +// trust "server-issued-demo"); the human-signs-on-the-phone ceremony is the roadmap (#71). + +import { DelegatedGate, issueCartMandate } from "@openmobilehub/credentagent-gate"; + +const SIGNING_KEY = "grants-proto-secret"; +const b64u = (o) => Buffer.from(JSON.stringify(o)).toString("base64url"); + +// Engine RefusalCode → the v10 door's `code` (spec 008). +const REASON_MAP = { + "over-cap": "per-spend-exceeded", // per-draw ceiling (TS12 max_amount) + "over-total": "budget-exceeded", // cumulative budget (TS12 total_amount) + "revoked": "revoked", + "consumed": "revoked", + "out-of-scope": "wrong-merchant", + "expired": "expired", +}; + +export function usd(minor) { + return Object.freeze({ + currency: "usd", _minor: minor, + lt(o) { return minor < o._minor; }, gte(o) { return minor >= o._minor; }, eq(o) { return minor === o._minor; }, + serialize() { return { amount: minor, currency: "usd" }; }, + toString() { return `$${(minor / 100).toFixed(2)}`; }, + }); +} +usd.dollars = (d) => usd(Math.round(d * 100)); +usd.cents = (c) => usd(c); + +export class GrantsProto { + constructor({ catalog }) { + this.catalogMinor = catalog; // { sku: minorUnits } + this.gate = new DelegatedGate({ catalog }); // the REAL delegated engine + this._grants = new Map(); + } + + // grants.create({ merchant, budget, perSpend, policy }) — authorize once; the Intent Mandate is produced. + async create({ merchant, budget, perSpend, policy = [], description }) { + const dg = await this.gate.preApprove({ + merchant, + perOrder: perSpend._minor, + total: budget._minor, + description: description ?? `Up to ${budget} at ${merchant}, ${perSpend}/purchase`, + }); + const intentMandate = { + type: "ap2.IntentMandate", + intentId: dg.id, + presence: dg.presence, // "delegated-demo" + trustLevel: dg.trustLevel, // "server-issued-demo" + bounds: { merchant, perSpend: perSpend.serialize(), budget: budget.serialize(), policy: policy.map((p) => p.credential?.id ?? p.id ?? "credential") }, + serialize() { return b64u({ ...this, serialize: undefined }); }, + }; + const rec = { id: dg.id, dg, merchant, budget, perSpend, status: "authorized", intentMandate, cache: new Map() }; + this._grants.set(dg.id, rec); + return this._view(rec); + } + + // grants.retrieve(id) — rehydrate the grant handle. + retrieve(id) { + const rec = this._grants.get(id); + return rec ? this._view(rec) : null; + } + + _view(rec) { + return { + id: rec.id, + status: rec.status, + approveUrl: `about:blank#authorize-${rec.id}`, // roadmap: the wallet ceremony that key-signs the intent + intentMandate: rec.intentMandate, + budget: rec.budget.serialize(), + perSpend: rec.perSpend.serialize(), + spend: (purchase) => this._spend(rec, purchase), + revoke: () => this._revoke(rec), + }; + } + + async _spend(rec, { idempotencyKey, items }) { + if (rec.cache.has(idempotencyKey)) return { ...rec.cache.get(idempotencyKey), replayed: true }; // v10 idempotent replay + const { sku, qty = 1 } = items[0]; + const r = await rec.dg.spend({ idempotencyKey, item: sku, quantity: qty }); + let door; + if (r.ok) { + door = { + ok: true, + amount: usd(r.amount).serialize(), + remaining: usd(r.remaining).serialize(), + replayed: false, + authorization: "delegated", + trustLevel: "presence-only-demo", + mandateBundle: this._bundle(rec, sku, qty, r.amount), + }; + } else { + // Map the engine's RefusalCode → the v10 door's `code` vocabulary. The engine already + // distinguishes the two caps: "over-cap" = the per-draw (per-spend) ceiling, "over-total" + // = the cumulative budget. + const code = REASON_MAP[r.reason] ?? r.reason; + door = { ok: false, code, remaining: usd(r.remaining).serialize(), retryable: r.retryable, trustLevel: "presence-only-demo" }; + } + rec.cache.set(idempotencyKey, door); + return door; + } + + async _revoke(rec) { + await rec.dg.revoke(); + rec.status = "revoked"; + return { revoked: true, status: "revoked" }; + } + + _bundle(rec, sku, qty, amountMinor) { + const cart = issueCartMandate( + { orderId: `${rec.id}-${sku}-${Date.now()}`, lines: [{ sku, qty }], currency: "usd", total: amountMinor }, + SIGNING_KEY, + ); + const pay = { + type: "ap2.PaymentMandate", amount: { amount: amountMinor, currency: "usd" }, + presenceMode: "human_not_present", authorization: "delegated", cart: cart.id, + intentId: rec.id, trust_level: "presence-only-demo", + }; + return { + intentMandate: { type: rec.intentMandate.type, intentId: rec.intentMandate.intentId, trustLevel: rec.intentMandate.trustLevel }, + cartMandate: { type: cart.type, id: cart.id, total: cart.total, trust_level: cart.trust_level, serialized: b64u(cart) }, + paymentMandate: { ...pay, serialized: b64u(pay) }, + trustLevel: "presence-only-demo", + }; + } +} diff --git a/examples/grants-proto/server.mjs b/examples/grants-proto/server.mjs new file mode 100644 index 0000000..e8a7bc8 --- /dev/null +++ b/examples/grants-proto/server.mjs @@ -0,0 +1,148 @@ +// server.mjs — runnable demo of the v10 `grants.*` surface (human-not-present) with a live UI. +// +// (npm run build --workspaces) # once, if not built +// node examples/grants-proto/server.mjs # → http://localhost:4020 +// +// Left pane = human PRESENT: authorize once (the Intent Mandate is produced). +// Right pane = human AWAY: the agent's spend loop (budget/perSpend, remaining, replay) + revoke. + +import { createServer } from "node:http"; +import { GrantsProto, usd } from "./grants.mjs"; + +const PORT = Number(process.env.PORT ?? 4020); +const BASE = `http://localhost:${PORT}`; + +const grants = new GrantsProto({ catalog: { wine: 2000, case: 5000 } }); // wine=$20, case=$50 (minor units; case > $30/spend) +const log = []; + +const json = (res, code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; +const read = (req) => new Promise((r) => { let d = ""; req.on("data", (c) => (d += c)); req.on("end", () => r(d ? JSON.parse(d) : {})); }); + +const server = createServer(async (req, res) => { + const url = new URL(req.url, BASE); + const p = url.pathname; + + if (p === "/") { res.writeHead(200, { "content-type": "text/html" }); res.end(PAGE); return; } + + // grants.create — authorize once (human present) + if (p === "/api/grant" && req.method === "POST") { + const g = await grants.create({ merchant: "utopia", budget: usd.dollars(100), perSpend: usd.dollars(30), policy: [{ id: "age" }] }); + log.length = 0; + log.push(`→ grants.create → ${g.id.slice(0, 14)}… · Intent Mandate sealed (${g.intentMandate.presence} · ${g.intentMandate.trustLevel})`); + return json(res, 200, { grant: pub(g), log }); + } + + // grant.spend — human away + if (p.match(/^\/api\/grant\/[^/]+\/spend$/) && req.method === "POST") { + const id = p.split("/")[3]; + const { idempotencyKey, sku = "wine" } = await read(req); + const g = grants.retrieve(id); + if (!g) return json(res, 404, { error: "unknown grant" }); + const s = await g.spend({ idempotencyKey, items: [{ sku, qty: 1 }] }); + log.push(s.ok + ? ` spend ${idempotencyKey} (${sku})${s.replayed ? " · replayed" : ""} → ok · $${(s.amount.amount / 100).toFixed(2)} · remaining $${(s.remaining.amount / 100).toFixed(2)}` + : ` spend ${idempotencyKey} (${sku}) → refused: ${s.code} · remaining $${(s.remaining.amount / 100).toFixed(2)}`); + return json(res, 200, { result: s, log }); + } + + // grant.revoke + if (p.match(/^\/api\/grant\/[^/]+\/revoke$/) && req.method === "POST") { + const id = p.split("/")[3]; + const g = grants.retrieve(id); + if (!g) return json(res, 404, { error: "unknown grant" }); + const r = await g.revoke(); + log.push(`✗ grant.revoke → ${r.status} · next spend fails closed`); + return json(res, 200, { result: r, log }); + } + + res.writeHead(404); res.end("not found"); +}); + +server.listen(PORT, () => { + console.log(`\n grants.* prototype (human not present) → ${BASE}`); + console.log(` Left: authorize once (Intent Mandate produced). Right: the spend loop + revoke.`); + console.log(` Real engine: DelegatedGate (dev-sealed intent, real bounds/ledger/revocation).\n Open ${BASE}.\n`); +}); + +const pub = (g) => ({ id: g.id, status: g.status, intentMandate: g.intentMandate, budget: g.budget, perSpend: g.perSpend }); + +// ──────────────────────────────────────────────────────────────────── +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}.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:.55rem 1rem;border-radius:9px;cursor:pointer;margin:.2rem .35rem .2rem 0} + button.ghost{background:transparent;color:var(--accent)}button.deny{border-color:var(--rf);color:var(--rf);background:transparent} + button:disabled{opacity:.4;cursor:not-allowed} + .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:.25rem 0} + .k{color:var(--muted)}.pill{font-family:var(--mono);font-weight:700;font-size:.72rem;padding:.12rem .5rem;border-radius:999px} + .p-ok{background:color-mix(in srgb,var(--ok) 18%,transparent);color:var(--ok)} + .p-rf{background:color-mix(in srgb,var(--rf) 18%,transparent);color:var(--rf)} + .p-pend{background:color-mix(in srgb,var(--pend) 20%,transparent);color:var(--pend)} + pre{font-family:var(--mono);font-size:.74rem;background:var(--surface2);border:1px solid var(--border);border-radius:9px;padding:.8rem;overflow:auto;margin:.55rem 0 0} + .bar{height:10px;border-radius:6px;background:var(--surface2);border:1px solid var(--border);overflow:hidden;margin:.4rem 0} + .bar>span{display:block;height:100%;background:var(--ok)} + .log{font-family:var(--mono);font-size:.75rem;color:var(--muted)}.log div{padding:.18rem 0;border-bottom:1px solid var(--border)} + code{font-family:var(--mono)} +`; + +const PAGE = `grants.* prototype +

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.
+
+
+
+`; From f4f957c3c3bcd4d4feac6f780c034bf65a089baf Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 23:38:10 -0700 Subject: [PATCH 07/13] =?UTF-8?q?docs(#92,008):=20overnight=20morning=20br?= =?UTF-8?q?ief=20=E2=80=94=20orders=20+=20grants=20prototypes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both goal increments built, smoke-tested green, committed: (A) real mandateBundle on the orders door; (B) runnable grants.* prototype over the real DelegatedGate. Brief covers what's real vs stubbed, how to run each (:4010/:4020/:3007), restart commands, and four open questions for the maintainer. Refs #92 Signed-off-by: Diego Zuluaga --- MORNING-BRIEF.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 MORNING-BRIEF.md diff --git a/MORNING-BRIEF.md b/MORNING-BRIEF.md new file mode 100644 index 0000000..d000050 --- /dev/null +++ b/MORNING-BRIEF.md @@ -0,0 +1,92 @@ +# Morning brief — #92 prototypes (overnight) + +Both goal increments are **built, smoke-tested green, and committed** on branch +`008-ap2-mandate-chain-dx` (worktree `.worktrees/mandate-dx`). Nothing pushed, no PR, no +`packages/` edits — all new code is under `examples/`. + +## TL;DR + +- **A — orders door now carries a real `mandateBundle`.** The `ok` branch returns a genuinely + signed `ap2.CartMandate` (`issueCartMandate`, `.serialize()` → base64url) + a `paymentMandate` + from the real settlement, `intentMandate: null` (correct for human-present), `trustLevel` on + every object. +- **B — a runnable `grants.*` prototype** (the human-*away* half you hadn't seen) over the **real + `DelegatedGate` engine**: `grants.create` → authorize (**the Intent Mandate is produced**) → + `grant.spend` loop (per-spend + budget caps, `remaining`, idempotent replay) → `grant.revoke`. + +## Play with it (3 things running) + +| Port | What | Open | Notes | +| --- | --- | --- | --- | +| **:4010** | **orders.\*** (human present) — real ceremony | http://localhost:4010 | Start checkout → real checkout page → prove on phone → door flips to `ok` with the mandateBundle | +| **:4020** | **grants.\*** (human away) — NEW | http://localhost:4020 | Create grant (Intent Mandate) → spend loop → try the $50 case (per-spend cap) and a 6th wine (budget) → Revoke | +| **:3007** | the real gate (old API, real crypto) | http://localhost:3007/checkout?order=ORD-DEMO | the genuine engine both protos sit on | + +**Fastest look:** open **:4020** and click through Create grant → Spend wine a few times → Spend the +$50 case (→ `per-spend-exceeded`) → keep spending to `budget-exceeded` → Retry (→ `replayed`) → Revoke. +No phone needed — the whole delegated engine is server-side. + +**orders on your phone (:4010):** reconnect the Pixel, then `adb reverse tcp:4010 tcp:4010` (the +reverses dropped overnight when the device slept), open the `approveUrl` shown in the left pane, +prove age with your `.mpzpass` + passkey pay. It flips `pending → ok` and the webhook log shows the +real settlement. + +## What's REAL vs STUBBED (honest) + +**orders (:4010)** — REAL: the gate (`createStorefront` + `mount`), `orders.create` → real order + +real manifest (`requirements()`), the real OpenID4VP age proof + x402 passkey payment, the +`order.settled` webhook (the completed-store `write()`), and the door's **cartMandate** (real +`issueCartMandate`). STUBBED: the **paymentMandate** is *assembled from the real settlement record*, +not the rail's own `PaymentMandate` object (that object isn't surfaced yet — see open Q2). Also +`/api/_test/settle` is a test hook to drive the `ok` branch without a phone; on-device is the real path. + +**grants (:4020)** — REAL: the `DelegatedGate` engine — bounds check, single-use ledger, revocation, +the dev-sealed Intent Mandate (`sealIntent`), per-draw signing. All enforcement is genuine. FACADE +(v10 design shown over the real engine): `usd()` Money, the `{ ok | code }` door, idempotent replay +(`{ ok:true, replayed:true }`), the split codes (`over-cap → per-spend-exceeded`, `over-total → +budget-exceeded`). HONEST GAP: **authorize is server-side today** — presence `delegated-demo`, trust +`server-issued-demo`; the phone-wallet key-signing ceremony (where the human actually signs the +Intent Mandate) is the roadmap (#71). `grant.approveUrl` is a placeholder for it. + +## If the servers died (Mac slept) + +```bash +cd ~/tools/git/attestomcp +# orders (:4010) and grants (:4020) — from the 008 worktree: +( cd .worktrees/mandate-dx && PORT=4010 node examples/orders-proto/server.mjs & ) +( cd .worktrees/mandate-dx && PORT=4020 node examples/grants-proto/server.mjs & ) +# the real gate (:3007): +( cd .worktrees/demo-pki && PORT=3007 node tools/demo-pki/run-gate.mjs & ) +``` + +## Your calls (open questions) + +1. **Naming — confirm the grants vocabulary:** `grants.create/retrieve` + `grant.spend/revoke`, and + the door codes `per-spend-exceeded | budget-exceeded | revoked`. And the biggest one: **`orders.gate`** + (the `requireInTool`/`gateTool` rename) — worth locking before **#17** merges so the first public + method already speaks this grammar. +2. **The last real-ness gap:** should the orders door surface the rail's *actual* `PaymentMandate` + object (a deeper integration into the passkey/dc-payment rail) rather than one derived from the + settlement record? Small but it's the difference between "shaped like real" and "is the real object." +3. **Graduation:** when to move the `orders`/`grants` facade from `examples/` into + `packages/credentagent-gate` as the real API — this touches `client.ts` (which #84 and #17 also + touch), so it wants sequencing after those land. +4. **Roadmap check:** grants authorize is server-sealed today; the human-present phone key-sign + ceremony is #71. Is that the ordering you want, or should the authorize ceremony come sooner? + +## Where it lives + +Branch `008-ap2-mandate-chain-dx` · this session's commits: + +``` +fe1d513 proto(#92,008): increment B — runnable grants.* (human-not-present) surface +37b9633 proto(#92,008): increment A — real mandateBundle on the orders door +8b30ef9 proto(#92,008): runnable orders.* prototype alongside its spec +e445913 spec(#92,008): completion via webhook (no poll loop) + Intent Mandate production +d4f9aa2 spec(#92,008): lock the consent SDK surface after 9-round DX council +8c7fa5e spec(#92,008): AP2 mandate-chain developer surface (MandateBundle) +``` + +Files: `examples/orders-proto/` (orders + real ceremony), `examples/grants-proto/` (grants), and the +spec at `specs/008-ap2-mandate-chain-dx/spec.md`. The order-lifecycle explainer artifact is at +https://claude.ai/code/artifact/a33a6e3c-84ab-4fd6-91e5-077d2c5297b4. From 02d9b076532f6be0e13ea2f7be4d1060c889d46b Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Tue, 21 Jul 2026 17:52:39 -0700 Subject: [PATCH 08/13] spec(#92,008): reconcile the page-less wrapper to credentagent.gate() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer decision: the page-less tool-gate is credentagent.gate(handler, opts) — top-level and action-agnostic — not 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. #17 shipped credentagent.gate(); the spec now matches it. orders/grants remain the checkout/delegated lifecycles. Refs #92 #17 Signed-off-by: Diego Zuluaga --- specs/008-ap2-mandate-chain-dx/spec.md | 34 +++++++++++++++++--------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/specs/008-ap2-mandate-chain-dx/spec.md b/specs/008-ap2-mandate-chain-dx/spec.md index 072a26e..d152421 100644 --- a/specs/008-ap2-mandate-chain-dx/spec.md +++ b/specs/008-ap2-mandate-chain-dx/spec.md @@ -1,7 +1,7 @@ # Feature Specification: The Consent SDK Surface (AP2 mandate chain, three enforcement paths) **Feature branch:** `008-ap2-mandate-chain-dx` · **Issue:** #92 · **Date:** 2026-07-20 -**Informs:** #17 (`requireInTool`), #12/#69–71 (delegated grants), #39/#40 (wire format) +**Informs:** #17 (`credentagent.gate()`), #12/#69–71 (delegated grants), #39/#40 (wire format) ## Overview @@ -38,10 +38,12 @@ const policy = [ required(age.over(21)), required(payment.in("usd")) ]; // cre // res.trustLevel ALWAYS present (every branch): "presence-only-demo" today — disclosure+binding, NOT issuer trust. // res.code ∈ "under-age" | "payment-declined" | "no-membership" | "budget-exceeded" | "per-spend-exceeded" | "revoked" | … // -// TWO RESOURCES + one wrapper — the same split Stripe uses: +// 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() -// requireInTool() page-less wrapper — its RETURN is the door (the one deliberate standalone) +// 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) => { @@ -54,13 +56,13 @@ credentagent.on("order.settled", async ({ id }) => { if (res.ok) { /* complete + settle res.mandateBundle.paymentMandate */ } }); -// ── requireInTool — page-less MCP tool ───────────────────────── -server.registerTool("place-order", inputSchema, credentagent.requireInTool( - async (args) => ({ structuredContent: await placeOrder(args) }), // runs ONLY on ok — an unverified caller never reaches it - { order: (args) => ({ id: args.orderId, items: itemsFrom(args) }), policy }, +// ── 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:"place-order", trustLevel } // agent proves, re-calls +// { 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) ── @@ -95,7 +97,9 @@ await grant.revoke(); // grant.status - **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()`). `requireInTool()` is the one documented standalone wrapper. + `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()`. - **FR-006 — MandateBundle on `ok`.** `{ intentMandate?, cartMandate, paymentMandate }`, each with @@ -115,7 +119,7 @@ await grant.revoke(); // grant.status 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 `orders.gate` path needs **no** signal — the agent's re-call is the trigger. + 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). @@ -176,6 +180,14 @@ with the shipped `envelope.ts`); `reason:'revoked'` → a `code` / `grant.status `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. @@ -185,6 +197,6 @@ serialized paymentMandate. ## Dependencies -- #17 (`requireInTool` — the page-less wrapper; the in-flight `gateTool` is its earlier name). +- #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. From 810165e757cb0806c3841a4620ac310104e5cb38 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Tue, 21 Jul 2026 18:03:23 -0700 Subject: [PATCH 09/13] =?UTF-8?q?chore(#92):=20renumber=20feature=20008=20?= =?UTF-8?q?=E2=86=92=20009-ap2-mandate-chain-dx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ever's #91 (delegated-verifier seam) is approved and keeps SpecKit feature 008; renumber ours to 009 to end the collision. Renames the spec dir, the branch, and .specify/feature.json + the feature-branch/slug references. FR-008 (a requirement number) and the historical commit quotes are unrelated and untouched. Refs #92 Signed-off-by: Diego Zuluaga --- .specify/feature.json | 2 +- MORNING-BRIEF.md | 6 +++--- .../spec.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename specs/{008-ap2-mandate-chain-dx => 009-ap2-mandate-chain-dx}/spec.md (99%) diff --git a/.specify/feature.json b/.specify/feature.json index b76189b..91f09b6 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/008-ap2-mandate-chain-dx" + "feature_directory": "specs/009-ap2-mandate-chain-dx" } diff --git a/MORNING-BRIEF.md b/MORNING-BRIEF.md index d000050..f2d1059 100644 --- a/MORNING-BRIEF.md +++ b/MORNING-BRIEF.md @@ -1,7 +1,7 @@ # Morning brief — #92 prototypes (overnight) Both goal increments are **built, smoke-tested green, and committed** on branch -`008-ap2-mandate-chain-dx` (worktree `.worktrees/mandate-dx`). Nothing pushed, no PR, no +`009-ap2-mandate-chain-dx` (worktree `.worktrees/mandate-dx`). Nothing pushed, no PR, no `packages/` edits — all new code is under `examples/`. ## TL;DR @@ -76,7 +76,7 @@ cd ~/tools/git/attestomcp ## Where it lives -Branch `008-ap2-mandate-chain-dx` · this session's commits: +Branch `009-ap2-mandate-chain-dx` · this session's commits: ``` fe1d513 proto(#92,008): increment B — runnable grants.* (human-not-present) surface @@ -88,5 +88,5 @@ d4f9aa2 spec(#92,008): lock the consent SDK surface after 9-round DX council ``` Files: `examples/orders-proto/` (orders + real ceremony), `examples/grants-proto/` (grants), and the -spec at `specs/008-ap2-mandate-chain-dx/spec.md`. The order-lifecycle explainer artifact is at +spec at `specs/009-ap2-mandate-chain-dx/spec.md`. The order-lifecycle explainer artifact is at https://claude.ai/code/artifact/a33a6e3c-84ab-4fd6-91e5-077d2c5297b4. diff --git a/specs/008-ap2-mandate-chain-dx/spec.md b/specs/009-ap2-mandate-chain-dx/spec.md similarity index 99% rename from specs/008-ap2-mandate-chain-dx/spec.md rename to specs/009-ap2-mandate-chain-dx/spec.md index d152421..f7187cc 100644 --- a/specs/008-ap2-mandate-chain-dx/spec.md +++ b/specs/009-ap2-mandate-chain-dx/spec.md @@ -1,6 +1,6 @@ # Feature Specification: The Consent SDK Surface (AP2 mandate chain, three enforcement paths) -**Feature branch:** `008-ap2-mandate-chain-dx` · **Issue:** #92 · **Date:** 2026-07-20 +**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 From a3a0fd9cae14916547df676781849eb3ab018fb9 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Tue, 21 Jul 2026 18:27:49 -0700 Subject: [PATCH 10/13] chore(#92): drop MORNING-BRIEF.md from the PR (scratch handoff doc) Its open questions are carried into the PR description; the file was an overnight handoff note, not a main-appropriate artifact. Refs #92 Signed-off-by: Diego Zuluaga --- MORNING-BRIEF.md | 92 ------------------------------------------------ 1 file changed, 92 deletions(-) delete mode 100644 MORNING-BRIEF.md diff --git a/MORNING-BRIEF.md b/MORNING-BRIEF.md deleted file mode 100644 index f2d1059..0000000 --- a/MORNING-BRIEF.md +++ /dev/null @@ -1,92 +0,0 @@ -# Morning brief — #92 prototypes (overnight) - -Both goal increments are **built, smoke-tested green, and committed** on branch -`009-ap2-mandate-chain-dx` (worktree `.worktrees/mandate-dx`). Nothing pushed, no PR, no -`packages/` edits — all new code is under `examples/`. - -## TL;DR - -- **A — orders door now carries a real `mandateBundle`.** The `ok` branch returns a genuinely - signed `ap2.CartMandate` (`issueCartMandate`, `.serialize()` → base64url) + a `paymentMandate` - from the real settlement, `intentMandate: null` (correct for human-present), `trustLevel` on - every object. -- **B — a runnable `grants.*` prototype** (the human-*away* half you hadn't seen) over the **real - `DelegatedGate` engine**: `grants.create` → authorize (**the Intent Mandate is produced**) → - `grant.spend` loop (per-spend + budget caps, `remaining`, idempotent replay) → `grant.revoke`. - -## Play with it (3 things running) - -| Port | What | Open | Notes | -| --- | --- | --- | --- | -| **:4010** | **orders.\*** (human present) — real ceremony | http://localhost:4010 | Start checkout → real checkout page → prove on phone → door flips to `ok` with the mandateBundle | -| **:4020** | **grants.\*** (human away) — NEW | http://localhost:4020 | Create grant (Intent Mandate) → spend loop → try the $50 case (per-spend cap) and a 6th wine (budget) → Revoke | -| **:3007** | the real gate (old API, real crypto) | http://localhost:3007/checkout?order=ORD-DEMO | the genuine engine both protos sit on | - -**Fastest look:** open **:4020** and click through Create grant → Spend wine a few times → Spend the -$50 case (→ `per-spend-exceeded`) → keep spending to `budget-exceeded` → Retry (→ `replayed`) → Revoke. -No phone needed — the whole delegated engine is server-side. - -**orders on your phone (:4010):** reconnect the Pixel, then `adb reverse tcp:4010 tcp:4010` (the -reverses dropped overnight when the device slept), open the `approveUrl` shown in the left pane, -prove age with your `.mpzpass` + passkey pay. It flips `pending → ok` and the webhook log shows the -real settlement. - -## What's REAL vs STUBBED (honest) - -**orders (:4010)** — REAL: the gate (`createStorefront` + `mount`), `orders.create` → real order + -real manifest (`requirements()`), the real OpenID4VP age proof + x402 passkey payment, the -`order.settled` webhook (the completed-store `write()`), and the door's **cartMandate** (real -`issueCartMandate`). STUBBED: the **paymentMandate** is *assembled from the real settlement record*, -not the rail's own `PaymentMandate` object (that object isn't surfaced yet — see open Q2). Also -`/api/_test/settle` is a test hook to drive the `ok` branch without a phone; on-device is the real path. - -**grants (:4020)** — REAL: the `DelegatedGate` engine — bounds check, single-use ledger, revocation, -the dev-sealed Intent Mandate (`sealIntent`), per-draw signing. All enforcement is genuine. FACADE -(v10 design shown over the real engine): `usd()` Money, the `{ ok | code }` door, idempotent replay -(`{ ok:true, replayed:true }`), the split codes (`over-cap → per-spend-exceeded`, `over-total → -budget-exceeded`). HONEST GAP: **authorize is server-side today** — presence `delegated-demo`, trust -`server-issued-demo`; the phone-wallet key-signing ceremony (where the human actually signs the -Intent Mandate) is the roadmap (#71). `grant.approveUrl` is a placeholder for it. - -## If the servers died (Mac slept) - -```bash -cd ~/tools/git/attestomcp -# orders (:4010) and grants (:4020) — from the 008 worktree: -( cd .worktrees/mandate-dx && PORT=4010 node examples/orders-proto/server.mjs & ) -( cd .worktrees/mandate-dx && PORT=4020 node examples/grants-proto/server.mjs & ) -# the real gate (:3007): -( cd .worktrees/demo-pki && PORT=3007 node tools/demo-pki/run-gate.mjs & ) -``` - -## Your calls (open questions) - -1. **Naming — confirm the grants vocabulary:** `grants.create/retrieve` + `grant.spend/revoke`, and - the door codes `per-spend-exceeded | budget-exceeded | revoked`. And the biggest one: **`orders.gate`** - (the `requireInTool`/`gateTool` rename) — worth locking before **#17** merges so the first public - method already speaks this grammar. -2. **The last real-ness gap:** should the orders door surface the rail's *actual* `PaymentMandate` - object (a deeper integration into the passkey/dc-payment rail) rather than one derived from the - settlement record? Small but it's the difference between "shaped like real" and "is the real object." -3. **Graduation:** when to move the `orders`/`grants` facade from `examples/` into - `packages/credentagent-gate` as the real API — this touches `client.ts` (which #84 and #17 also - touch), so it wants sequencing after those land. -4. **Roadmap check:** grants authorize is server-sealed today; the human-present phone key-sign - ceremony is #71. Is that the ordering you want, or should the authorize ceremony come sooner? - -## Where it lives - -Branch `009-ap2-mandate-chain-dx` · this session's commits: - -``` -fe1d513 proto(#92,008): increment B — runnable grants.* (human-not-present) surface -37b9633 proto(#92,008): increment A — real mandateBundle on the orders door -8b30ef9 proto(#92,008): runnable orders.* prototype alongside its spec -e445913 spec(#92,008): completion via webhook (no poll loop) + Intent Mandate production -d4f9aa2 spec(#92,008): lock the consent SDK surface after 9-round DX council -8c7fa5e spec(#92,008): AP2 mandate-chain developer surface (MandateBundle) -``` - -Files: `examples/orders-proto/` (orders + real ceremony), `examples/grants-proto/` (grants), and the -spec at `specs/009-ap2-mandate-chain-dx/spec.md`. The order-lifecycle explainer artifact is at -https://claude.ai/code/artifact/a33a6e3c-84ab-4fd6-91e5-077d2c5297b4. From e5c5d7c01d424e5c8db31bcf9a1e742c49f9d190 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Tue, 21 Jul 2026 18:54:10 -0700 Subject: [PATCH 11/13] proto(#92): address Codex P1s on #95 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove examples/orders-proto/orders.mjs — dead code (the real-ceremony server.mjs never imports it). It was the superseded first-iteration facade and carried both a caller-controlled-$__orderId bind bug (a verified order could run the handler with arbitrary new args) and the abandoned orders.gate naming. 2. grants _bundle(): build proper CartMandateLine { id, quantity, unitPrice, lineTotal } from the catalog price, so a recipient can reconcile the signed cart mandate to the priced purchase (was { sku, qty }, missing the required fields). Refs #92 Signed-off-by: Diego Zuluaga --- examples/grants-proto/grants.mjs | 6 +- examples/orders-proto/orders.mjs | 125 ------------------------------- 2 files changed, 5 insertions(+), 126 deletions(-) delete mode 100644 examples/orders-proto/orders.mjs diff --git a/examples/grants-proto/grants.mjs b/examples/grants-proto/grants.mjs index 7e7f4c0..e0b32be 100644 --- a/examples/grants-proto/grants.mjs +++ b/examples/grants-proto/grants.mjs @@ -117,8 +117,12 @@ export class GrantsProto { } _bundle(rec, sku, qty, amountMinor) { + // A CartMandateLine is { id, quantity, unitPrice, lineTotal } — so a recipient can reconcile the + // signed mandate to the priced purchase (Codex P1). Price from the catalog, never the caller. + const unitPrice = this.catalogMinor[sku] ?? Math.round(amountMinor / qty); + const line = { id: sku, quantity: qty, unitPrice, lineTotal: unitPrice * qty }; const cart = issueCartMandate( - { orderId: `${rec.id}-${sku}-${Date.now()}`, lines: [{ sku, qty }], currency: "usd", total: amountMinor }, + { orderId: `${rec.id}-${sku}-${rec.cache.size}`, lines: [line], currency: "usd", total: amountMinor }, SIGNING_KEY, ); const pay = { diff --git a/examples/orders-proto/orders.mjs b/examples/orders-proto/orders.mjs deleted file mode 100644 index 037c83c..0000000 --- a/examples/orders-proto/orders.mjs +++ /dev/null @@ -1,125 +0,0 @@ -// orders.mjs — a RUNNABLE prototype of the v10 `orders.*` surface (#92 / spec 008). -// -// This is a facade: it implements the DESIGNED shape (orders.create / orders.retrieve / -// orders.gate, the one { ok | pending | code } door, Money-as-type, trustLevel on every -// branch, the "order.settled" webhook) over an in-memory store + the REAL policy builders -// from @openmobilehub/credentagent-gate. The prove step is demo-approved here; the package -// version delegates manifest resolution to requirements() and the ceremony to mount(). -// -// The point: make the new DX tangible and clickable, and prove the facade sits cleanly on -// the real primitives — not to ship this file. - -import { EventEmitter } from "node:events"; -import { randomUUID } from "node:crypto"; - -// ── Money — opaque, comparable (v10 FR-005) ──────────────────────── -export function usd(minor) { - return Object.freeze({ - currency: "usd", - lt(o) { return minor < o._minor; }, - gte(o) { return minor >= o._minor; }, - eq(o) { return minor === o._minor; }, - serialize() { return { amount: minor, currency: "usd" }; }, - toString() { return `$${(minor / 100).toFixed(2)}`; }, - get _minor() { return minor; }, - }); -} -usd.dollars = (d) => usd(Math.round(d * 100)); -usd.cents = (c) => usd(c); - -// ── the demo mandate bundle (real SHAPE; dev-sealed, presence-only-demo) ── -function mandateBundleFor(order, authorization) { - const seal = (type) => ({ - type, - trustLevel: "presence-only-demo", - signature: { alg: "MOCK-DEV-SIGNER", note: "dev-sealed integrity hash — not key/issuer-signed" }, - serialize() { return { type, trust_level: "presence-only-demo", authorization, order: order.id }; }, - }); - return { - // Intent Mandate is a grants-only artifact (FR-010) — absent on a human-present order: - intentMandate: undefined, - cartMandate: { ...seal("ap2.CartMandate"), lines: order.items }, - paymentMandate: { ...seal("ap2.PaymentMandate"), amount: order.total.serialize(), presenceMode: authorization === "delegated" ? "human_not_present" : "human_present" }, - trustLevel: "presence-only-demo", - }; -} - -// ── the facade ───────────────────────────────────────────────────── -export class CredentAgentProto { - constructor({ origin, catalog }) { - this.origin = origin; - this.catalog = catalog; - this._orders = new Map(); - this._events = new EventEmitter(); - this.orders = { - // orders.create({ order, policy }) → { id, approveUrl, manifest } (mint, not the door) - create: ({ order, policy }) => { - const id = `ord_${randomUUID().slice(0, 8)}`; - const total = this._price(order.items); // re-priced from catalog — no amount is ever trusted - const rec = { id, items: order.items, total, policy, state: "pending" }; - this._orders.set(id, rec); - return { id, approveUrl: `${this.origin}/prove/${id}`, manifest: this._manifest(policy) }; - }, - // orders.retrieve(id) → the DOOR (single read; use in a webhook handler, never a poll loop) - retrieve: (id) => this._door(this._orders.get(id)), - }; - } - - // orders.gate(handler, { order, policy }) — page-less wrapper; its RETURN is the door - gate(handler, { policy }) { - return async (args) => { - const existing = args.__orderId && this._orders.get(args.__orderId); - if (existing && existing.state === "verified") return { ok: true, structuredContent: await handler(args), mandateBundle: existing.bundle, trustLevel: "presence-only-demo" }; - const { id, approveUrl } = this.orders.create({ order: { items: args.items ?? [] }, policy }); - return { ok: false, pending: true, approveUrl, resume: args.__tool ?? "tool", trustLevel: "presence-only-demo" }; - }; - } - - // credentagent.on("order.settled", handler) — the webhook (FR-009) - on(event, handler) { this._events.on(event, handler); } - - // DEMO prove — stands in for the wallet ceremony; marks verified, seals the bundle, fires the webhook - _demoProve(id, { pass = true, failCode = "under-age", failCredential = "age" } = {}) { - const rec = this._orders.get(id); - if (!rec) return { ok: false, code: "not-found" }; - if (pass) { - rec.state = "verified"; - rec.authorization = "direct"; - rec.bundle = mandateBundleFor(rec, "direct"); - } else { - rec.state = "refused"; rec.code = failCode; rec.credential = failCredential; - } - this._events.emit("order.settled", { id }); - return this._door(rec); - } - - _price(items) { - let minor = 0; - for (const { sku, qty } of items) { - const entry = this.catalog[sku]; - if (!entry) throw new Error(`unknown sku: ${sku}`); - minor += entry._minor * (qty ?? 1); - } - return usd(minor); - } - - _manifest(policy) { - return policy.map((step) => { - const c = step.credential ?? step; // required(x) wraps a credential; tolerate either - return { - credential: c.id ?? "credential", - required: step.required !== false, - label: c.ui?.label ?? c.id ?? "credential", - minAge: c.params?.minAge, - trustLevel: "presence-only-demo", - }; - }); - } - - _door(rec) { - if (!rec) return { ok: false, code: "not-found", trustLevel: "presence-only-demo" }; - if (rec.state === "verified") return { ok: true, mandateBundle: rec.bundle, authorization: rec.authorization, trustLevel: "presence-only-demo", total: rec.total }; - if (rec.state === "refused") return { ok: false, code: rec.code, credential: rec.credential, trustLevel: "presence-only-demo" }; - return { ok: false, pending: true, approveUrl: `${this.origin}/prove/${rec.id}`, trustLevel: "presence-only-demo" }; - } -} From 29046dc60b5fa0d116734970e932278ca3aedb29 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Tue, 21 Jul 2026 19:17:05 -0700 Subject: [PATCH 12/13] =?UTF-8?q?docs(#92):=20mark=20the=20prototypes=20as?= =?UTF-8?q?=20validation=20demos=20=E2=86=92=20graduate=20in=20#97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a spec note + a top-of-file banner on each demo saying these are validation facades, not the shipping API — they graduate into the real credentagent.orders.* / grants.* library API in #97 (rewired, not deleted). Keeps #95 honest about what the prototype code is for. Refs #92 #97 Signed-off-by: Diego Zuluaga --- examples/grants-proto/grants.mjs | 2 ++ examples/grants-proto/server.mjs | 2 ++ examples/orders-proto/server.mjs | 2 ++ specs/009-ap2-mandate-chain-dx/spec.md | 5 +++++ 4 files changed, 11 insertions(+) diff --git a/examples/grants-proto/grants.mjs b/examples/grants-proto/grants.mjs index e0b32be..5f60c98 100644 --- a/examples/grants-proto/grants.mjs +++ b/examples/grants-proto/grants.mjs @@ -1,3 +1,5 @@ +// ⚠ 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). // grants.mjs — a RUNNABLE prototype of the v10 `grants.*` surface (#92 / spec 008), // the human-NOT-present half, over the REAL DelegatedGate engine. // diff --git a/examples/grants-proto/server.mjs b/examples/grants-proto/server.mjs index e8a7bc8..00c4600 100644 --- a/examples/grants-proto/server.mjs +++ b/examples/grants-proto/server.mjs @@ -1,3 +1,5 @@ +// ⚠ 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 — runnable demo of the v10 `grants.*` surface (human-not-present) with a live UI. // // (npm run build --workspaces) # once, if not built diff --git a/examples/orders-proto/server.mjs b/examples/orders-proto/server.mjs index af00c21..4fcbdb8 100644 --- a/examples/orders-proto/server.mjs +++ b/examples/orders-proto/server.mjs @@ -1,3 +1,5 @@ +// ⚠ 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 diff --git a/specs/009-ap2-mandate-chain-dx/spec.md b/specs/009-ap2-mandate-chain-dx/spec.md index f7187cc..fa04b23 100644 --- a/specs/009-ap2-mandate-chain-dx/spec.md +++ b/specs/009-ap2-mandate-chain-dx/spec.md @@ -195,6 +195,11 @@ predates this call; the surface + FR-004 reflect it. - 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** (the demos get rewired to the +real API rather than deleted, so nothing is thrown away). + ## Dependencies - #17 (`credentagent.gate()` — the top-level, action-agnostic page-less wrapper; SHIPPED on `feat/17`). From 51e79d80f296cc7148ecdd1668bdb70b5022ab9c Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Sat, 25 Jul 2026 11:20:52 -0500 Subject: [PATCH 13/13] =?UTF-8?q?spec(#95):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20scope=20age=20gate,=20add=20allow-bounds,=20typed=20RefusalC?= =?UTF-8?q?ode,=20denied=20state,=20Money=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the #95 review (TheBlackBit + Codex): - Age gate: headline example now uses .when() so 21+ applies ONLY to age-restricted carts, not every order (the example is the DX test). - Intent bounds gain an optional `allow` constraint (SKUs/categories/attributes) — a human who's away bounds WHAT the agent buys, not just how much; enforced fail-closed each spend (new code `not-allowed`). (FR-010) - RefusalCode is documented as a TYPED union, not string (autocomplete + exhaustiveness). - Grant lifecycle: all four states documented incl. `denied` (never-authorized, terminal) vs `revoked` (authorized-then-cancelled), plus the revoke-wins-fail-closed race. (FR-007) - Prototypes note refreshed: orders graduated in #98; the caller-controlled-id wrapper was removed (Codex P1); grants graduates under #104. - Honest reconciliation: #98 shipped plain dollar numbers, not the opaque Money type — flagged as an open decision for #104 rather than pretending Money shipped. Codex P1s already fixed on-branch: orders-proto wrapper deleted; grants-proto CartMandate lines now carry { id, quantity, unitPrice, lineTotal }. Refs #95 #92 · epic #97 Signed-off-by: Diego Zuluaga --- specs/009-ap2-mandate-chain-dx/spec.md | 50 +++++++++++++++++++++----- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/specs/009-ap2-mandate-chain-dx/spec.md b/specs/009-ap2-mandate-chain-dx/spec.md index fa04b23..b0dedfd 100644 --- a/specs/009-ap2-mandate-chain-dx/spec.md +++ b/specs/009-ap2-mandate-chain-dx/spec.md @@ -28,7 +28,10 @@ const credentagent = new CredentAgent({ 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)), required(payment.in("usd")) ]; // credentials — payment is just one of them +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 @@ -36,7 +39,10 @@ const policy = [ required(age.over(21)), required(payment.in("usd")) ]; // cre // 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 ∈ "under-age" | "payment-declined" | "no-membership" | "budget-exceeded" | "per-spend-exceeded" | "revoked" | … +// 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 @@ -67,7 +73,11 @@ server.registerTool("release-records", inputSchema, credentagent.gate( // ── 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), policy }); +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: @@ -102,6 +112,12 @@ await grant.revoke(); // grant.status 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, @@ -109,7 +125,19 @@ await grant.revoke(); // grant.status - **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). + (`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 @@ -124,8 +152,12 @@ await grant.revoke(); // grant.status `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`, - 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. Today it is + 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. @@ -197,8 +229,10 @@ predates this call; the surface + FR-004 reflect it. **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** (the demos get rewired to the -real API rather than deleted, so nothing is thrown away). +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