From 9dbd7ff4d1a56f830352b215c5019814f2d1d345 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Wed, 8 Jul 2026 17:27:42 -0700 Subject: [PATCH 01/24] =?UTF-8?q?docs(005):=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=20Option=20B=20seams-first=20(ratified=202026-07-08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan authored 2026-07-07 (parallel session), committed now that the maintainer ratified the sequencing fork (Option B: wallet-custody, seams-first) and the Group-A decisions D1-D3. Signed-off-by: Diego Zuluaga --- specs/005-human-not-present/plan.md | 156 ++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 specs/005-human-not-present/plan.md diff --git a/specs/005-human-not-present/plan.md b/specs/005-human-not-present/plan.md new file mode 100644 index 0000000..c4dd773 --- /dev/null +++ b/specs/005-human-not-present/plan.md @@ -0,0 +1,156 @@ +# Implementation Plan: HNP First Increment — the shared gate seams (Option B) + +**Branch**: `005-human-not-present` | **Date**: 2026-07-07 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification `specs/005-human-not-present/spec.md`, the ratified +[`sequencing-fork-memo.md`](sequencing-fork-memo.md) (**Option B — wallet-custody, seams-first**), and +[`connector-architecture-design.md`](connector-architecture-design.md) §9–§11. Prototype: +[`spike/intent-mandate/`](../../spike/intent-mandate/) (ES256 `K_s` + `checkDraw` + 13 tests). + +## Summary + +Ship the **first increment of HNP under Option B**: the four **signer-agnostic shared gate seams** in +`credentagent-gate` that both the GDC demo and the eventual wallet server depend on — built once, in service of +the wallet-custody model, **without** the merchant-side minting rail on top. + +1. **Draw-verification envelope** (`checkDraw`) — a **pure, total** function that verifies a proposed + **Payment Mandate draw** against an **Intent Mandate**'s canonical bounds: signature over the bounds → + window → scope-contains → re-priced total ≤ cap → step-up threshold → single-use → returns a **typed refusal + list** (never throws, never first-fails). Signer-agnostic: it verifies a signature over `canonical(bounds)` — + the spike's **ES256 `K_s`** (the B target) via an injected verifier, HMAC still possible. +2. **`completeOrder` draw branch** — an **additive, fail-closed** branch on the shared completion seam that + **re-runs** `checkDraw` + revocation + an **atomic single-use consume** server-side (never trusting an + upstream verify), writes a `delegationId` onto the completion record, and **suppresses real settlement**. +3. **Revocation store** — an injectable `RevocationStore` seam (default in-memory) keyed per `intentId` with a + per-subject kill-switch; **fail-closed** (unreachable ⇒ refuse) and TOCTOU-safe (re-checked at the seam). +4. **Typed-refusal vocabulary** (§9) — the shared refusal enum (`over-cap`, `out-of-scope`, `expired`, + `bad-signature`, `consumed`, `step-up-required`, `revoked`, `replay`) with `enforcer` / `retryable` tags, + used identically on every surface. + +This **productizes** `spike/intent-mandate/` (already B-shaped: asymmetric `K_s`, content-addressed `intentId`, +`checkDraw`) into the library, and reuses existing primitives (`mandate.ts` AP2 shapes, `VerificationStore` +pattern, the shared `completeOrder`, `jose`). + +**Out of scope this increment (later, per the memo's build order):** the settlement verifier (UPay-style, +increment 2), the **wallet server** (`credentagent-wallet`, Kotlin/JVM — increment 3, sibling repo), the thin +`credentagent-agent` (increment 4), and the **HTTP intent rail / approve page** (arrives with the wallet +connector). The **merchant-side server-HMAC minting rail is dropped** under Option B. + +## Technical Context + +**Language/Version**: TypeScript, Node ≥ 20, ESM (the `@openmobilehub/credentagent-gate` workspace). + +**Primary Dependencies**: existing only — `jose` (ECDSA P-256 / ES256 verify + JWK, already used by the +OpenID4VP rails), the gate's own `mandate.ts`, `store.ts` (`VerificationStore`), `completion.ts` +(`completeOrder`). **No new runtime dependency.** + +**Crypto / identity**: the delegate key **`K_s`** is **ES256 (P-256)** — the spike's model and the B target +(the wallet server holds `K_s`; the signed bounds name its public key). Verification is **signer-agnostic**: +`checkDraw` takes an injected `verifyBounds(sig, canonicalBounds) → bool` so the HMAC path remains expressible. +Identity is **content-addressed**: `intentId = "int_" + b64url(SHA-256(canonical(bounds \ intentId)))`; the +canonical encoding is deterministic (sorted keys, no floats-as-strings drift). + +**Storage**: injectable `RevocationStore` (default in-memory `Map`), mirroring `VerificationStore` — inject a +shared store for multi-instance. **Fail-closed**: a store read that throws refuses the draw (never opens). + +**Testing**: `vitest` (gate package). Bypass tests per bound (over-cap · out-of-scope · expired · bad-signature +· consumed · step-up · revoked) submitted **directly to `completeOrder` / place-order**, not only the rail +(FR US2 scenario 8); a **TOCTOU** test (revoke lands between verify and complete → seam still refuses); an +**atomic single-use** test (two concurrent draws → exactly one completes); **age-always-step-up** (an +age-restricted cart never completes from a grant). **Each bypass test MUST go red when its control is removed.** + +**Target Platform**: Node (library). **Project Type**: Library — npm workspace `credentagent-gate`. + +**Performance Goals**: `checkDraw` is a pure O(lines) function; one signature verify + O(1) revocation/consume +store ops per draw. Zero cost on the existing HP paths (the branch is only taken when a draw is present). + +**Constraints**: (1) **Additive + fail-closed** — HP paths unchanged; the draw branch only activates on a draw. +(2) **Re-derive amounts from the catalog** — never a price carried in the grant (Inv #2). (3) **Scope state per +`intentId`/order** — never process-global (Inv #4). (4) **Explicit positive bound checks** — over-cap/scope are +real controls; age-restricted **always** steps up (Inv #5, "delegate actions, not identity"). (5) **No +settlement** — `ctx.settle` suppressed for the demo-fenced draw; honesty label per `connector-arch` §11. +(6) DCO sign-off. (7) **Signer-agnostic envelope** — no hard dependency on HMAC or ES256 in `checkDraw` itself. + +**Scale/Scope**: Small–medium — extend `mandate.ts` with the Intent/Payment-draw types + `checkDraw` +(ported from the spike), one `RevocationStore` seam, one additive `completeOrder` branch, one shared refusal +module, and their bypass-test files. No HTTP surface, no new backend. + +## Constitution Check + +*GATE: Must pass before Phase 0. Re-checked after design.* Instantiated against the CredentAgent SDK Constitution +(Principles I–VII + Security). **⚠️ Prerequisite:** Decision-13 requires a **MINOR amendment to Principles II, +III, VII** ([`constitution-amendment-draft.md`](constitution-amendment-draft.md)) — a separate +`/speckit-constitution` step **before `/speckit-implement`**. The rows below assume that amendment lands. + +| Gate | Assessment | +| :-- | :-- | +| **I. Stripe-grade, MCP-idiomatic API** | ✅ One typed `IntentMandate` + a pure `checkDraw`; injectable `RevocationStore` mirrors the existing `VerificationStore` seam. No callback grab-bags. | +| **II. Three execution contexts sacred** | ⚠️→✅ *with amendment* — HNP adds a **fourth, no-live-human** completion path; the amendment names it explicitly rather than bending "three contexts". | +| **III. Consolidated checkout flow** | ⚠️→✅ *with amendment* — the draw redeems **through the same `completeOrder`**; the amendment records the additive branch as in-policy, not a second flow. | +| **IV. One ordered policy array / amount server-derived** | ✅ Reinforced — the draw re-prices from the catalog and derives the payment amount from the re-priced total; the grant carries bounds, never a price. | +| **V. Extensible to any credential** | ✅ The Intent Mandate bounds a **scope** of effects; `payment` is one — the same envelope bounds any delegable action effect. Age/membership explicitly **non-delegable**. | +| **VI. structuredContent is data** | ✅ Refusals are typed data (`enforcer`/`retryable`), not prose. | +| **VII. Honesty in types; prefer simplicity** | ⚠️→✅ *with amendment* — the honesty axis moves from `server-issued-demo` to **`delegated` presence + `issuer-verified (demo PKI)`** (B); the amendment records the new axis. Simplicity: reuse `mandate.ts`, the store pattern, `completeOrder`; defer the rail + wallet server. | +| **Security — enforce on every completion path (Inv #1)** | ✅ **The gate this increment defends.** The draw branch re-checks at `completeOrder`; the bypass test submits a bad draw **directly to the seam** and must be refused. | +| **Security — never trust the token; re-derive (Inv #2)** | ✅ Re-price from catalog; the grant's bounds are re-verified, never a balance/price trusted from the blob. | +| **Security — per-subject/per-intent state (Inv #4)** | ✅ Revocation + single-use consume key off `intentId`/subject; no process-global state. | +| **Security — positive claims / step-up (Inv #5)** | ✅ Age-restricted ⇒ **always** step-up; over-cap/scope are positive, explicit refusals. | +| **Security — origin/replay (Inv #6)** | ⚠️ **Partial (named)** — v0.1 seams enforce disclosure+binding+bounds, not issuer/device trust; fenced per honesty label until the wallet-server increment wires DeviceKey PoP. | +| **Dev workflow & quality gates** | ✅ Plan cites real code + the spike; bypass tests required and must fail red when the control is removed; `npm run build`+`test` green before "done"; DCO. | + +**Result: PASS conditional on the Decision-13 amendment** (Principles II/III/VII). Invariant 6 is **partial by +design** and honesty-fenced. Residual items → Complexity Tracking. + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-human-not-present/ +├── plan.md # This file +├── spec.md # Feature spec (+ Option-B reconciliation callout) +├── sequencing-fork-memo.md # RATIFIED: Option B, seams-first +├── connector-architecture-design.md # §9 refusals · §10 pivot · §11 honesty labels +├── intent-bounds-schema-draft.md # AP2 + EUDI TS12 bounds fields +├── redemption-choreography-draft.md # the six-call redeem sequence +├── constitution-amendment-draft.md # Decision-13 (Principles II/III/VII) — prerequisite +├── checklists/requirements.md # Spec quality checklist +└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here) +``` + +### Source Code (repository root) + +```text +packages/credentagent-gate/src/ceremony/ +├── mandate.ts # EXISTING (AP2 mandate + deterministic gates) — EXTEND: +│ # IntentMandate + PaymentMandate(draw) types; canonical(bounds); +│ # contentAddressId(); checkDraw(intent, draw, ctx) → Refusal[] +│ # (pure/total; signer-agnostic verifyBounds seam). Ported from spike/intent-mandate. +├── refusals.ts # NEW — the §9 typed-refusal vocabulary (enum + enforcer/retryable tags), +│ # shared by checkDraw, the completeOrder branch, and (later) the rail. +├── revocation.ts # NEW — RevocationStore seam (per-intentId + per-subject kill-switch); +│ # MemoryRevocationStore default; fail-closed; mirrors store.ts/VerificationStore. +├── completion.ts # EXISTING completeOrder — ADD an additive, fail-closed DRAW branch: +│ # re-run checkDraw + revocation + ATOMIC single-use consume; write delegationId; +│ # suppress ctx.settle. HP paths untouched. +├── mandate.test.ts # EXTEND / NEW — checkDraw bounds tests (port the spike's 13 + the FR US2 scenarios) +└── completion.test.ts # EXTEND — draw-branch BYPASS tests: bad draw direct-to-completeOrder refused; + # TOCTOU revoke; atomic single-use; age-always-step-up. Each red-when-control-removed. +``` + +**Structure Decision**: The seams land entirely in `credentagent-gate`, extending the existing `mandate.ts` +(which already holds the AP2 mandate + deterministic gates) rather than adding a rail directory — because this +increment ships **no HTTP surface**. The Intent/Payment-draw types + `checkDraw` port directly from +`spike/intent-mandate/` (already ES256/`K_s`-shaped); `RevocationStore` mirrors the `VerificationStore` seam so +multi-instance deploys inject a shared store; the `completeOrder` draw branch is additive and fail-closed so +every existing HP path is byte-unchanged. The HTTP **intent rail** (`ceremony/intent/` with the +`dcql/request/verify/page/routes` split) and the **wallet server** are deliberately deferred to the next +increments per the ratified build order. + +## Complexity Tracking + +| Item | Why it's allowed / how it's fenced | +| :-- | :-- | +| **Constitution Principles II/III/VII need a MINOR amendment** | Tracked as Decision-13; a separate `/speckit-constitution` step gates `/speckit-implement`. The plan does not proceed to implement until it lands. | +| **Invariant 6 only partially met** | By design for v0.1 seams (disclosure/binding/bounds, not issuer/device trust). Named + honesty-fenced (`connector-arch` §11); closed in the wallet-server increment (DeviceKey PoP). | +| **Signer-agnostic envelope (extra seam)** | Small cost, deliberate: keeps `checkDraw` reusable by the wallet server's ES256 `K_s` and the HMAC path without a rewrite. | From 09e34fe5f9fd675255faeb13f3da379ca5a152c7 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Wed, 8 Jul 2026 17:29:44 -0700 Subject: [PATCH 02/24] =?UTF-8?q?docs(005):=20record=20maintainer=20ratifi?= =?UTF-8?q?cations=20=E2=80=94=20Group-A=20D1-D3=20+=20sequencing=20Option?= =?UTF-8?q?=20B=20(2026-07-08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Diego Zuluaga --- .../sequencing-fork-memo.md | 2 +- specs/005-human-not-present/spec.md | 23 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/specs/005-human-not-present/sequencing-fork-memo.md b/specs/005-human-not-present/sequencing-fork-memo.md index 45226ba..ce8aa62 100644 --- a/specs/005-human-not-present/sequencing-fork-memo.md +++ b/specs/005-human-not-present/sequencing-fork-memo.md @@ -1,6 +1,6 @@ # 005 sequencing fork — decision memo (2026-07-03) -**For the maintainer to ratify.** Resolves the "Open maintainer decision" left in +**RATIFIED by the maintainer 2026-07-08: Option B (wallet-custody directly, seams-first).** Resolves the "Open maintainer decision" left in `connector-architecture-design.md` §10, now that the on-device spike is complete (Runs 1–5, `on-device-spike-runbook.md`) **including a green settlement**. This memo does not change any load-bearing concept in `spec.md` — only *what ships first*. diff --git a/specs/005-human-not-present/spec.md b/specs/005-human-not-present/spec.md index 65b5b1d..f5e15e5 100644 --- a/specs/005-human-not-present/spec.md +++ b/specs/005-human-not-present/spec.md @@ -18,22 +18,21 @@ a 13-item decision menu). --- -> ## ⚠️ Decisions baked in from the research — confirm or override before `/speckit-plan` +> ## ✅ Decisions baked in from the research — Group-A + sequencing RATIFIED 2026-07-08 > > Drafted **autonomously while the maintainer was asleep** from the research's **recommended** answers, then > **hardened by a 4-lens adversarial review** (invariants / honesty / faithfulness / real-code feasibility) that -> read the actual library code. They are recommendations, **not settled**. The Group-A calls (1–3) were -> discussed with the maintainer on 2026-07-01 and a **tentative direction** was noted (D3: WebAuthn + -> on-device DPC via Multipaz as the v0.2 user-signing ceremonies) — **tentative pending the maintainer's -> fuller understanding of the key architecture; do not treat as confirmed**. Confirm/override each before -> `/speckit-plan`; Decision 13 (the constitution amendment) remains a separate prerequisite step for -> `/speckit-implement`. +> read the actual library code. The Group-A calls (1–3) were discussed with the maintainer on 2026-07-01 +> (wallet/key-architecture deep-dives) and **RATIFIED on 2026-07-08**, D3 including the v0.2 user-signing +> direction (WebAuthn + on-device DPC via Multipaz). The sequencing fork is likewise **ratified: Option B** +> (`sequencing-fork-memo.md`). Decisions 4–12 stand as baked-in. Decision 13 (the constitution amendment) is +> the remaining prerequisite for `/speckit-implement` — executed as the amendment in `.specify/memory/constitution.md`. > > | # | Decision | Baked-in choice (post-review) | > | :-- | :-- | :-- | -> | 1 | Model the Intent Mandate now? | **Yes** — typed `ap2.IntentMandate` + a deterministic bounds-check gate. *Tentative (discussed 2026-07-01; not settled)* | -> | 2 | Represent the absent human honestly? | **New orthogonal `presence` axis** (`live \| delegated-demo \| delegated`) AND a new weaker authorization value **`server-issued-demo`** — do NOT reuse `presence-only-demo` for HNP. *Tentative (discussed 2026-07-01; not settled)* | -> | 3 | What signs the v0.1 grant? | **Server-HMAC** (reuse `signingKey`); proves *issuance only*. In v0.1 the **server**, not the user, composes + signs the bounds. `alg` is reserved (no working swap exists yet). *Tentative v0.2 direction (discussed 2026-07-01; not settled): two user-signing ceremonies — WebAuthn (passkey over hash-of-bounds) + on-device DPC via Multipaz (DC API / OpenID4VP)* | +> | 1 | Model the Intent Mandate now? | **Yes** — typed `ap2.IntentMandate` + a deterministic bounds-check gate. **Ratified 2026-07-08** | +> | 2 | Represent the absent human honestly? | **New orthogonal `presence` axis** (`live \| delegated-demo \| delegated`) AND a new weaker authorization value **`server-issued-demo`** — do NOT reuse `presence-only-demo` for HNP. **Ratified 2026-07-08** | +> | 3 | What signs the v0.1 grant? | **Server-HMAC** (reuse `signingKey`); proves *issuance only*. In v0.1 the **server**, not the user, composes + signs the bounds. `alg` is reserved (no working swap exists yet). **Ratified 2026-07-08**, incl. the v0.2 direction: two user-signing ceremonies — WebAuthn (passkey over hash-of-bounds) + on-device DPC via Multipaz (DC API / OpenID4VP)* | > | 4 | Which effects get delegation? | **Action effects only**; age/membership **not delegable**; age-restricted ⇒ **always step up** | > | 5 | Single-use vs reusable? | **Single-use** grant, enforced by an **atomic** per-grant consume | > | 6 | Caps? | **Per-action cap only**, an **absolute ceiling (tolerance = 0)**; cumulative/velocity out | @@ -239,7 +238,7 @@ user-authorization field; assert the `completeOrder` HNP branch never calls `ctx field is **reserved**: v0.1 fixes it to the HMAC suite; a future user/agent-key-signed variant (ES256 / KB-JWT) requires **widening the `alg` union AND adding a verify-dispatch that does not exist today** (this branch has only `mandate.ts` with `alg: "MOCK-DEV-SIGNER"`). Do not present `alg` as a working drop-in. - **Tentative direction (discussed 2026-07-01; not settled): v0.2 would ship two user-signing ceremonies for + **Ratified direction (2026-07-08): v0.2 ships two user-signing ceremonies for the intent bounds** — (a) **WebAuthn**: the passkey assertion's challenge = hash(intent bounds, including the agent key), mirroring the `passkey/` rail; (b) **on-device DPC (Multipaz)**: a device-stored Digital Payment Credential presented via @@ -372,7 +371,7 @@ user-authorization field; assert the `completeOrder` HNP branch never calls `ctx - **Holder binding + per-draw proof-of-possession** (the grant is a bearer token in v0.1) — the v0.2/v0.3 line where invariant 6 becomes fully upheld. - **User/agent-key signing** — the v0.2 bridge (widening `alg` + a two-format verify-dispatch). - **Tentative direction (2026-07-01; not settled): two user-signing rails** — (a) **WebAuthn** passkey assertion over hash(bounds) and + **Ratified direction (2026-07-08): two user-signing rails** — (a) **WebAuthn** passkey assertion over hash(bounds) and (b) an **on-device DPC (Multipaz)** presented via the DC API / OpenID4VP with the bounds in the transcript. **Issuer-verified credentials** — the v0.3 destination where an HNP grant becomes a *real* control (the DPC rail is the natural bridge: device-bound today, issuer-verifiable once the trust anchor lands — but **not From 0ea98d4b651b66025f4d0c73a91a60a39f4404b9 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Wed, 8 Jul 2026 17:31:37 -0700 Subject: [PATCH 03/24] =?UTF-8?q?governance:=20constitution=20v1.1.0=20?= =?UTF-8?q?=E2=80=94=20HNP=20amendment=20(Decision=2013,=20ratified=202026?= =?UTF-8?q?-07-08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MINOR bump per the ratified draft: Principle II (Context 1 may verify a pre-existing delegation grant — redeem is verification, not a ceremony), Principle III (HNP consolidation = one delegate ceremony + full server-side gate chain on every redemption), Principle VII (orthogonal presence axis; trust_level gains server-issued-demo, weaker than presence-only-demo; enforcedAt gains "intent"; delegated-demo surfaces carry a disclaimer, no authorizedByUser field, never settle). Adds the envelope-vs-grant terminology ruling and retains Mode-B gated() unchanged. Signed-off-by: Diego Zuluaga --- .specify/memory/constitution.md | 70 +++++++++++++------ .../constitution-amendment-draft.md | 8 +-- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 3d10e62..7f52a50 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,20 +1,22 @@ @@ -36,15 +38,23 @@ benchmark is `stripe-node`. ### II. The three execution contexts are sacred Every example and design decision MUST respect the split (spec §0): (1) the MCP tool handler runs ONCE when checkout is requested and only mints the link + reports requirements — there is no phone in the loop, so it -MUST NOT perform a credential ceremony; (2) the checkout page/phone is where the gates actually run; (3) a -poll reports completion. Conflating these contexts is the documented root cause of confusion and is -forbidden. +MUST NOT perform a credential ceremony. It MAY, however, **verify a pre-existing delegation grant** minted +in an earlier, separate live ceremony: redeeming a grant is verification of consent already given, not a +new ceremony, and MUST run entirely server-side against the full gate chain. (2) the checkout page/phone is +where ceremonies actually run — including the **delegate ceremony** that mints a grant; (3) a poll reports +completion. Conflating these contexts is the documented root cause of confusion and is forbidden. ### III. Consolidated checkout flow Checkout MUST be one handoff: the buyer opens the link once and completes all verifications and payment in a single browser session. The agent orchestrates URLs and polls; it MUST NOT perform the ceremony. A blocking-tool mode (for page-less tools) is roadmap, not v0.1. +These rules govern **human-present** flows. A human-not-present redemption has, by definition, no browser +session and no live buyer; its consolidation invariant is instead: **one delegate ceremony** (itself a +single consolidated handoff, per this principle) authorizes a bounded set of later redemptions, and **every +redemption runs the full server-side gate chain on every completion path** (Security Requirements). The +agent still only orchestrates and polls; it MUST NOT hold or perform authorization itself. + ### IV. One ordered, conditional policy array Gates MUST be expressed as a single ordered array: array position is run order, payment MUST settle last, and the payment amount MUST be derived server-side from the order — never passed as a field. Gates are @@ -65,10 +75,17 @@ a flat data manifest (`[{ credential, required, effect, label, minAge? }]`). Fun wire; `requirements()` is the code→data boundary. ### VII. Honesty in the types; prefer simplicity -Status MUST be carried in types, not prose: `enforcedAt: "tool" | "checkout"` and -`trust_level: "presence-only-demo" | "issuer-verified"`. v0.1 is presence-only (disclosure + nonce -binding, NOT issuer/device signatures) and MUST be fenced as a demonstration, never sold as a real safety -control. Prefer simplicity — defer complexity (e.g. real mdoc-verifier integration) rather than overbuild. +Status MUST be carried in types, not prose: `enforcedAt: "tool" | "checkout" | "intent"`, an orthogonal +**`presence`** axis (`"live" | "delegated-demo" | "delegated"`) carrying *when consent happened*, and +`trust_level` (`"presence-only-demo" | "server-issued-demo" | "issuer-verified"`) carrying *how strongly +the authorization is bound* — the live-ceremony/nonce connotation lives on the presence axis, not in +`trust_level`. `"server-issued-demo"` is WEAKER than `"presence-only-demo"`: it proves issuance only, not +user authorization. Every `presence: "delegated-demo"` surface MUST carry a non-empty `disclaimer`, MUST +NOT expose any `authorizedByUser`-style field, and MUST NOT settle real value. A real HNP control requires +`presence: "delegated"` AND `trust_level: "issuer-verified"`. v0.1 human-present rails remain +presence-only (disclosure + nonce binding, NOT issuer/device signatures) and MUST be fenced as a +demonstration, never sold as a real safety control. Prefer simplicity — defer complexity (e.g. real +mdoc-verifier integration) rather than overbuild. ## Security Requirements @@ -90,6 +107,15 @@ Load-bearing controls; a change that breaks one is blocking even in demo code (m Until cryptographic mdoc trust verification (issuer/device signatures) lands, any gate relying on it MUST be fenced behind a demo-only mode and MUST NOT be presented as a real safety control. +## Terminology & Retained-Primitive Rulings + +- **"envelope"** refers ONLY to the Mode-B `verification_required` wire envelope (`envelope.ts`). A + standing HNP authorization is a **grant** (typed `ap2.IntentMandate`); the merchant's standing + delegation config is the **delegation policy**. The phrase "spending envelope" MUST NOT be used. +- **Mode-B `gated()`** and the wire envelope are **retained unchanged** as the page-less blocking + primitive, decoupled from HNP. Async step-up (an HNP redeem answering with a blocking envelope) remains + roadmap — explicitly out of 005's scope. + ## Development Workflow & Quality Gates - **Spec-grounded.** The spec and docs MUST cite real code (file/line). Claims that drift from the code are @@ -111,4 +137,4 @@ Versioning (semantic): **MAJOR** — backward-incompatible principle removal or new principle/section or materially expanded guidance; **PATCH** — clarifications and wording. Runtime guidance for agents lives in `CLAUDE.md` and `specs/001-attesto-sdk/spec.md`. -**Version**: 1.0.0 | **Ratified**: 2026-06-25 | **Last Amended**: 2026-06-25 +**Version**: 1.1.0 | **Ratified**: 2026-06-25 | **Last Amended**: 2026-07-08 diff --git a/specs/005-human-not-present/constitution-amendment-draft.md b/specs/005-human-not-present/constitution-amendment-draft.md index 1154d46..62b1b75 100644 --- a/specs/005-human-not-present/constitution-amendment-draft.md +++ b/specs/005-human-not-present/constitution-amendment-draft.md @@ -1,8 +1,8 @@ -# DRAFT — Constitution amendment for HNP (Decision 13) +# Constitution amendment for HNP (Decision 13) — APPLIED 1.1.0 (2026-07-08) -**Status**: proposal only — the constitution itself is untouched. Ratifying = maintainer applies these -edits to `.specify/memory/constitution.md` with the version bump. Required before `/speckit-implement` -of 005 (spec.md, Dependencies). +**Status**: APPLIED to `.specify/memory/constitution.md` as v1.1.0 on 2026-07-08 (maintainer ratified +Decision 13 alongside Group-A/Option-B; this file is the historical proposal). The `/speckit-implement` +prerequisite for 005 is satisfied. **Version**: 1.0.0 → **1.1.0** (MINOR: materially expanded guidance on II, III, VII; no principle removed or redefined incompatibly — every existing rule remains true for human-present flows). From 81ae70ca8d0ccf75f944ee58cfd09eee73fd876c Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Wed, 8 Jul 2026 17:34:48 -0700 Subject: [PATCH 04/24] =?UTF-8?q?docs(005):=20tasks.md=20=E2=80=94=20seams?= =?UTF-8?q?-first=20task=20breakdown=20(T001-T009)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Diego Zuluaga --- specs/005-human-not-present/tasks.md | 68 ++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 specs/005-human-not-present/tasks.md diff --git a/specs/005-human-not-present/tasks.md b/specs/005-human-not-present/tasks.md new file mode 100644 index 0000000..cf458dc --- /dev/null +++ b/specs/005-human-not-present/tasks.md @@ -0,0 +1,68 @@ +# Tasks: HNP First Increment — the shared gate seams (Option B) + +**Input**: [plan.md](plan.md) (ratified 2026-07-08) · [spec.md](spec.md) (Group-A ratified) · +constitution **v1.1.0** (Decision-13 amendment applied — the `/speckit-implement` gate is OPEN) + +**Discipline** (constitution · Development Workflow): TDD; every security-bypass test MUST go red when its +control is removed (spot-verified during implementation); DCO on every commit; build + full suite green +before "done". + +## Phase A — Typed refusals (the shared vocabulary) + +- [ ] **T001** `src/ceremony/refusals.ts` (NEW): the §9/choreography-draft discriminated refusal — + `code` union (`signature · intent-mismatch · currency-mismatch · over-cap · over-total · not-yet-valid · + expired · out-of-scope · unpermitted-presentment · replay · step-up · revoked · consumed · + revocation-unavailable`), `enforcer: "wallet" | "merchant" | "psp"` (attribution), `retryable: "retry" | + "needs-human" | "terminal"` (the bit an unattended loop branches on), per-reason detail fields. Pure data. + +## Phase B — Intent bounds + checkDraw (port `spike/intent-mandate/`) + +- [ ] **T002** Extend `src/ceremony/mandate.ts`: `IntentBounds` + `Draw` types (typed `ap2.IntentMandate` + bounds per `intent-bounds-schema-draft.md`), `canonical()` (stable recursive key sort), + `contentAddressId()` / `sealIntent()` (`int_` + b64url(SHA-256(canonical(bounds \ intentId)))), + ES256 (P-256, WebCrypto) draw signing/verification with a **signer-agnostic injectable verifier**, + `checkDraw(intent, draw, ctx) → { ok, refusals }` — pure, total, never throws, accumulates (no first-fail). +- [ ] **T003** `src/ceremony/mandate.intent.test.ts` (NEW): port the spike's 13 tests to vitest/TS — + content-addressing commits to every field · canonical order-independence · in-bounds passes · over-cap + (documents the co-fire finding) · over-total · window both edges · out-of-scope merchant · replay · + unpermitted-presentment (age never delegable) · step-up is `needs-human` · tamper-after-sign refused · + wrong-key refused · refusals accumulate. + +## Phase C — RevocationStore seam + +- [ ] **T004** `src/ceremony/revocation.ts` (NEW): `RevocationStore` interface — `isRevoked(intentId, + subject?)`, `revoke(intentId)`, `revokeSubject(subject)` (kill-switch), `priorDraws(intentId)`, + **`commitDraw(intentId, draw)` atomic check-and-append** (returns false on duplicate + `pspTransactionId` — the single-use control; per-order idempotency does NOT cover two orders drawing one + intent). `MemoryRevocationStore` default (mirrors `MemoryVerificationStore`); multi-instance deploys + inject Redis/CAS. +- [ ] **T005** Revocation tests (in T007's file or standalone): revoke → next draw refused · + kill-switch by subject · commitDraw atomicity (same pspTransactionId twice → second false). + +## Phase D — `completeOrder` draw branch (additive, fail-closed) + +- [ ] **T006** Extend `src/ceremony/types.ts` + `completion.ts`: `CompletionInput.draw?: { intent, draw }`; + `CompletionContext.revocation?: RevocationStore`, `now?()`; `CompletedRecord.delegationId?`; + `CompletionResult.refusals?`. Branch order (only when `input.draw` present, HP paths byte-unchanged): + missing store ⇒ refuse fail-closed → `isRevoked` (throw ⇒ `revocation-unavailable`, fail-closed) → + `checkDraw` with store-sourced `priorDraws` → existing catalog re-price stays authoritative → + **draw.amount must equal the re-priced total** (invariants 2+3) → **age-restricted ⇒ always `step-up`** + (never completes from a grant; invariant 5) → **atomic `commitDraw`** (false ⇒ `consumed`/`replay`) → + write record with `delegationId = intentId` → **suppress `ctx.settle`** for the demo-fenced draw. +- [ ] **T007** `completion.test.ts` EXTEND — bypass tests, each control-dependent (spot-verify red): + in-bounds draw completes: `delegationId` written, settle spy NOT called · over-cap draw direct to + `completeOrder` refused, nothing recorded · tampered signature refused · revoked refused (incl. TOCTOU: + revocation lands after a passing pre-check → seam still refuses) · store-throws ⇒ fail-closed refuse · + two draws, one `pspTransactionId`, two order ids ⇒ exactly one completes · age-restricted cart via draw ⇒ + refused `step-up` even with `ageVerified` in the verification store · amount-mismatch (draw.amount ≠ + re-priced total) refused. + +## Phase E — Surface + docs + +- [ ] **T008** Export the new types/functions from `src/index.ts`; gate README gains a short + "Delegated draws (HNP seams, v0-preview)" section with the honesty labels (presence axis per + constitution VII v1.1.0). +- [ ] **T009** Full suite green (`npm run build` + `npm test`); red-verification spot checks recorded in + the PR body; STATUS.md updated; draft PR referencing this tasks.md. + +**Out of scope** (per plan): HTTP intent rail, wallet server, settlement verifier, `credentagent-agent`. From 9fbfce196d4822f2be10a2b4d352b3f7e9cdfe1d Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Wed, 8 Jul 2026 17:44:51 -0700 Subject: [PATCH 05/24] =?UTF-8?q?feat(gate,005):=20HNP=20shared=20seams=20?= =?UTF-8?q?=E2=80=94=20Intent=20Mandate=20bounds=20+=20checkDraw=20+=20com?= =?UTF-8?q?pleteOrder=20draw=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option B, seams-first (plan.md T001-T009). Ships the four signer-agnostic gate seams the GDC demo and the eventual wallet server both depend on — no merchant-side minting rail, no HTTP surface. - refusals.ts: shared typed-refusal vocabulary (code/enforcer/retryable) — §9 - mandate.ts: IntentBounds + Draw types, canonical()/contentAddressId()/sealIntent, ES256 delegate signing, checkDraw() (pure, total, accumulating) — ported from spike/intent-mandate (13 tests → mandate.intent.test.ts, 15 green) - revocation.ts: RevocationStore seam (per-intent + subject kill-switch, atomic commitDraw single-use), MemoryRevocationStore default; fail-closed - completion.ts: additive fail-closed draw branch — re-runs checkDraw + revocation + atomic consume server-side, age ALWAYS steps up (non-delegable), draw.amount must equal the re-priced total, writes delegationId, suppresses ctx.settle. HP paths byte-unchanged. Constitution v1.1.0 compliant. 272 tests green (+25). Bypass tests spot-verified RED when their control is removed (settlement suppression; revocation check). Finding: single-use is defended in two layers — checkDraw's replay guard (sequential) and commitDraw's atomic consume (concurrent race); test accepts either code. Signed-off-by: Diego Zuluaga --- .../src/ceremony/completion.test.ts | 145 ++++++++++++- .../src/ceremony/completion.ts | 84 ++++++++ .../src/ceremony/mandate.intent.test.ts | 167 +++++++++++++++ .../credentagent-gate/src/ceremony/mandate.ts | 201 ++++++++++++++++++ .../src/ceremony/refusals.ts | 53 +++++ .../src/ceremony/revocation.ts | 53 +++++ .../credentagent-gate/src/ceremony/types.ts | 16 +- packages/credentagent-gate/src/index.ts | 25 +++ 8 files changed, 739 insertions(+), 5 deletions(-) create mode 100644 packages/credentagent-gate/src/ceremony/mandate.intent.test.ts create mode 100644 packages/credentagent-gate/src/ceremony/refusals.ts create mode 100644 packages/credentagent-gate/src/ceremony/revocation.ts diff --git a/packages/credentagent-gate/src/ceremony/completion.test.ts b/packages/credentagent-gate/src/ceremony/completion.test.ts index 7829dcd..07b2ff9 100644 --- a/packages/credentagent-gate/src/ceremony/completion.test.ts +++ b/packages/credentagent-gate/src/ceremony/completion.test.ts @@ -46,8 +46,9 @@ function harness(opts: { settle?: CompletionContext["settle"] } = {}): Harness { ...(opts.settle ? { settle: opts.settle } : {}), }; const input: Harness["input"] = (items, over = {}) => { - const order = catalog.createOrder(items, over.order?.id ?? "ORD-1", {}); - return { order, mandateId: "m", amount: order.total, currency: "USD", method: "test", gates: [{ gate: "g", pass: true, detail: "" }], ...over }; + const { order: overOrder, ...rest } = over; + const order = catalog.createOrder(items, overOrder?.id ?? "ORD-1", {}); + return { order, mandateId: "m", amount: order.total, currency: "USD", method: "test", gates: [{ gate: "g", pass: true, detail: "" }], ...rest }; }; return { ctx, records, store, cleared, input }; } @@ -123,3 +124,143 @@ describe("completeOrder — core controls (direct seam tests)", () => { expect(h.cleared.count).toBe(1); }); }); + +// ── HNP delegated-draw branch (005 FR-006/009) — bypass tests, each control-dependent. +import { MemoryRevocationStore } from "./revocation.js"; +import { sealIntent, signDraw, generateDelegate, type IntentBounds, type Draw } from "./mandate.js"; + +async function drawFixture(over: Partial = {}) { + const { privateKey, delegate } = await generateDelegate(); + const intent = await sealIntent({ + type: "credentagent.IntentBounds/v0", + merchants: ["utopia-marketplace"], + currency: "USD", + maxAmount: 120, + totalAmount: 120, + stepUpOver: 500, + delegate, + mayPresent: [], + presence: "delegated", + trust_level: "issuer-verified (demo PKI)", + ...over, + }); + const mkDraw = (amount: number, pspTransactionId = "tx_1") => + signDraw( + { type: "credentagent.Draw/v0", intentId: intent.intentId, paymentMandateId: "d", merchant: "utopia-marketplace", amount, currency: "USD", pspTransactionId }, + privateKey, + ); + return { intent, mkDraw, privateKey, delegate }; +} + +describe("completeOrder — HNP delegated-draw branch (bypass tests)", () => { + it("in-bounds draw completes: delegationId written, settle NOT called", async () => { + const settle = vi.fn(async () => ({ network: "demo", txId: "x", status: "ok" }) as SettlementRecordLike); + const h = harness({ settle }); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture(); + const draw = await mkDraw(20); // 2 widgets @ 10 + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(true); + expect(res.delegationId).toBe(intent.intentId); + expect(h.records.get("ORD-1")?.delegationId).toBe(intent.intentId); + expect(settle).not.toHaveBeenCalled(); // ← settlement suppression is the control (FR-014) + }); + + it("BYPASS: an over-cap draw submitted DIRECTLY to completeOrder is refused, nothing recorded", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture({ maxAmount: 15 }); + const draw = await mkDraw(20); + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(false); + expect(res.reason).toBe("draw"); + expect(res.refusals?.some((r) => r.code === "over-cap")).toBe(true); + expect(h.records.size).toBe(0); + }); + + it("BYPASS: a tampered-signature draw is refused", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture(); + const draw = { ...(await mkDraw(20)), amount: 5 } as Draw; // edit after signing + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(false); + expect(res.refusals?.some((r) => r.code === "signature")).toBe(true); + }); + + it("BYPASS: a revoked grant is refused (revocation is the control)", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture(); + rev.revoke(intent.intentId); + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw: await mkDraw(20) } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(false); + expect(res.refusals?.[0]?.code).toBe("revoked"); + }); + + it("TOCTOU: revocation landing before the seam's own check still refuses", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture(); + const draw = await mkDraw(20); + // Simulate a revoke that lands between an upstream rail-verify and completeOrder: the + // seam re-checks isRevoked itself, so it refuses regardless of any prior pass. + rev.revoke(intent.intentId); + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(false); + expect(res.refusals?.[0]?.code).toBe("revoked"); + }); + + it("FAIL-CLOSED: an unreachable revocation store refuses (never fail-open)", async () => { + const h = harness(); + const throwing = { isRevoked: () => { throw new Error("down"); }, revoke() {}, revokeSubject() {}, priorDraws() { return []; }, commitDraw() { return true; } }; + const { intent, mkDraw } = await drawFixture(); + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw: await mkDraw(20) } }), { ...h.ctx, revocation: throwing }); + expect(res.completed).toBe(false); + expect(res.refusals?.[0]?.code).toBe("revocation-unavailable"); + }); + + it("FAIL-CLOSED: a draw with no revocation store configured is refused", async () => { + const h = harness(); + const { intent, mkDraw } = await drawFixture(); + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw: await mkDraw(20) } }), h.ctx); + expect(res.completed).toBe(false); + expect(res.refusals?.[0]?.code).toBe("revocation-unavailable"); + }); + + it("ATOMIC single-use: two draws (one pspTransactionId, two order ids) ⇒ exactly one completes", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture(); + const a = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, order: { id: "ORD-A" } as CompletionInput["order"], draw: { intent, draw: await mkDraw(20, "tx_same") } }), { ...h.ctx, revocation: rev }); + const b = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, order: { id: "ORD-B" } as CompletionInput["order"], draw: { intent, draw: await mkDraw(20, "tx_same") } }), { ...h.ctx, revocation: rev }); + expect([a.completed, b.completed].filter(Boolean)).toHaveLength(1); + const loser = a.completed ? b : a; + // Two layers defend single-use: run sequentially, checkDraw's replay guard catches the + // reused pspTransactionId (priorDraws now holds the winner) BEFORE commitDraw; a true + // concurrent race that clears checkDraw is then stopped by the atomic commitDraw + // ("consumed"). Either refusal proves single-use held. + expect(["replay", "consumed"]).toContain(loser.refusals?.[0]?.code); + }); + + it("AGE NON-DELEGABLE: an age-restricted cart via a draw is refused step-up even with ageVerified set", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + h.store.write("ORD-1", { ageVerified: true } as never); // even WITH a snapshot… + const { intent, mkDraw } = await drawFixture({ maxAmount: 100 }); + const draw = await mkDraw(20); // 1 wine @ 20 + const res = await completeOrder(h.input([{ productId: "wine", quantity: 1 }], { amount: 20, draw: { intent, draw } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(false); + expect(res.refusals?.[0]?.code).toBe("step-up"); // …a grant NEVER completes an age gate + }); + + it("BYPASS: draw.amount ≠ catalog re-priced total is refused (grant never carries the price)", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture(); + const draw = await mkDraw(10); // signs amount 10, but 2 widgets re-price to 20 + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 10, draw: { intent, draw } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(false); + expect(res.reason).toBe("draw"); + }); +}); diff --git a/packages/credentagent-gate/src/ceremony/completion.ts b/packages/credentagent-gate/src/ceremony/completion.ts index e93afac..2024bf9 100644 --- a/packages/credentagent-gate/src/ceremony/completion.ts +++ b/packages/credentagent-gate/src/ceremony/completion.ts @@ -9,6 +9,9 @@ import type { VerificationStore } from "../types.js"; import type { CartItemRef, CeremonyCatalog, CompletionInput, CompletionResult, GateOutcome } from "./types.js"; import { verifyCartMandate, type CartMandate } from "./cartMandate.js"; import { reconcileCartPayment } from "./reconciliation.js"; +import { checkDraw, type DrawVerifier } from "./mandate.js"; +import type { RevocationStore } from "./revocation.js"; +import { refusal, type Refusal } from "./refusals.js"; // One on-chain (demo-mode) settlement backing a completed order. Kept structural // so the demo's richer SettlementRecord is assignable without the package taking @@ -33,6 +36,9 @@ export interface CompletedRecord { gates: GateOutcome[]; completedAt: string; settlement?: SettlementRecordLike; + /** The authorizing Intent Mandate id, when this order completed via a delegated draw + * (005) — the audit link from an unattended completion back to its grant. */ + delegationId?: string; } export interface CompletedOrderStore { @@ -57,6 +63,14 @@ export interface CompletionContext { * `cartMandate`, completion verifies it (signature + order-id binding + expiry) * before re-pricing. Absent ⇒ the cart-mandate check is skipped (additive). */ signingKey?: string; + /** Optional revocation + committed-draw store (005). REQUIRED when the input carries a + * `draw`: its absence is fail-closed (a draw without a store to check it is refused). + * Consulted fail-closed (a throwing read refuses) and holds the atomic single-use consume. */ + revocation?: RevocationStore; + /** Optional signer-agnostic draw verifier (defaults to ES256/P-256). */ + verifyDraw?: DrawVerifier; + /** Injectable clock for the draw window check (testability). */ + now?: () => number; } export async function completeOrder(input: CompletionInput, ctx: CompletionContext): Promise { @@ -123,6 +137,76 @@ export async function completeOrder(input: CompletionInput, ctx: CompletionConte // is the shared-completion-seam half of CT9; the demo's place-order + MCP // order-completion-tool halves are wired in T014. const ageRestricted = repriced.lines.some((l) => typeof l.minimumAge === "number" && l.minimumAge > 0); + + // ── HNP delegated-draw branch (005 FR-006): additive + fail-closed. Only taken when a + // draw is present; every HP path above/below is byte-unchanged. The gate re-runs the FULL + // bounds check here at the seam — never trusting an upstream rail verify (invariant 1) — so + // a producer reaching completeOrder DIRECTLY with a draw is still fully checked. On success + // it writes a delegationId (the audit link) and SUPPRESSES real settlement. + if (input.draw) { + const { intent, draw } = input.draw; + const store = ctx.revocation; + // Fail-closed: a draw with no store to check it against cannot complete. + if (!store) return { completed: false, reason: "draw", refusals: [refusal("revocation-unavailable")] }; + + // Age is NON-DELEGABLE: an age-restricted cart ALWAYS steps up to a live ceremony and + // never completes from a grant, regardless of any snapshot (invariant 5, spec FR-013). + if (ageRestricted) return { completed: false, reason: "draw", refusals: [refusal("step-up", { cause: "age-restricted" })] }; + + // Revocation + prior draws — fail-closed if the store errors (never fail-open). + let revoked: boolean; + let priorDraws; + try { + revoked = await store.isRevoked(intent.intentId, intent.subject); + priorDraws = await store.priorDraws(intent.intentId); + } catch { + return { completed: false, reason: "draw", refusals: [refusal("revocation-unavailable")] }; + } + if (revoked) return { completed: false, reason: "draw", refusals: [refusal("revoked", { intentId: intent.intentId })] }; + + // The deterministic bounds gates (signature/cap/window/scope/replay/step-up/…). + const verdict = await checkDraw(intent, draw, { + now: ctx.now ? ctx.now() : undefined, + priorDraws, + verify: ctx.verifyDraw, + }); + if (!verdict.ok) return { completed: false, reason: "draw", refusals: verdict.refusals }; + + // Invariants 2+3: the draw's amount must equal the catalog-RE-DERIVED total — the grant + // never carries an authoritative price, and the bound payment can't drift from ground truth. + if (draw.amount !== repriced.total) { + return { completed: false, reason: "draw", refusals: [refusal("over-cap", { pricedAt: repriced.total, amount: draw.amount })] }; + } + + // Atomic single-use consume — keyed per intent (NOT per order: completeOrder's own + // idempotency is order-keyed, so two concurrent redemptions minting two order ids would + // both pass without this). Exactly one of N racing draws with one pspTransactionId wins. + let committed: boolean; + try { + committed = await store.commitDraw(intent.intentId, { amount: draw.amount, pspTransactionId: draw.pspTransactionId }); + } catch { + return { completed: false, reason: "draw", refusals: [refusal("revocation-unavailable")] }; + } + if (!committed) return { completed: false, reason: "draw", refusals: [refusal("consumed", { pspTransactionId: draw.pspTransactionId })] }; + + // The draw is authorized. Record it with the delegationId audit link and SUPPRESS real + // settlement (the honesty control — a demo-fenced draw never moves real value, spec FR-014). + await ctx.records.write({ + orderId: input.order.id, + mandateId: input.mandateId, + amount: draw.amount, + currency: input.currency, + method: input.method, + instrument: input.instrument, + gates: input.gates, + completedAt: new Date().toISOString(), + delegationId: intent.intentId, + } as Parameters[0]); + if (ctx.cart) await ctx.cart.clear(); + await ctx.verificationStore.clear(input.order.id); + return { completed: true, delegationId: intent.intentId }; + } + if (ageRestricted && (verification as { ageVerified?: boolean } | undefined)?.ageVerified !== true) { return { completed: false, reason: "age" }; } diff --git a/packages/credentagent-gate/src/ceremony/mandate.intent.test.ts b/packages/credentagent-gate/src/ceremony/mandate.intent.test.ts new file mode 100644 index 0000000..cdfd4f0 --- /dev/null +++ b/packages/credentagent-gate/src/ceremony/mandate.intent.test.ts @@ -0,0 +1,167 @@ +// checkDraw bounds tests — the spike's 13 (spike/intent-mandate/intent-mandate.test.mjs) +// ported to the library surface, per 005 tasks T003. Each fixture mutation asserts +// EXACTLY which gate refuses (typed refusal codes, not first-fail). +import { describe, expect, it } from "vitest"; +import { + canonical, + contentAddressId, + sealIntent, + generateDelegate, + signDraw, + checkDraw, + type IntentBounds, + type Draw, +} from "./mandate.js"; + +const JUL15 = Date.parse("2026-07-15T12:00:00Z"); + +async function fixture() { + const { privateKey, delegate } = await generateDelegate(); + const intent = await sealIntent({ + type: "credentagent.IntentBounds/v0", + naturalLanguageDescription: "Buy Ghost 17 size 10, up to $120, until Jul 31, from approved stores", + merchants: ["utopia-marketplace", "runfast.example"], + skus: ["gtin:00195394069122"], + currency: "USD", + maxAmount: 120, + totalAmount: 120, + stepUpOver: 50, + intentExpiry: "2026-07-31T23:59:59Z", + notBefore: "2026-07-01T00:00:00Z", + delegate, + mayPresent: ["membership:acme-loyalty"], + presence: "delegated", + trust_level: "issuer-verified (demo PKI)", + }); + const draw = await signDraw( + { + type: "credentagent.Draw/v0", + intentId: intent.intentId, + paymentMandateId: "draw_1", + merchant: "runfast.example", + amount: 40, + currency: "USD", + pspTransactionId: "tx_1", + presentments: ["membership:acme-loyalty"], + }, + privateKey, + ); + return { privateKey, delegate, intent, draw }; +} + +const codes = (r: { refusals: { code: string }[] }) => r.refusals.map((x) => x.code).sort(); + +describe("intent bounds — content addressing + canonical form", () => { + it("intentId commits to every field; any edit re-hashes it", async () => { + const { intent } = await fixture(); + expect(intent.intentId.startsWith("int_")).toBe(true); + expect(await contentAddressId(intent)).toBe(intent.intentId); // stable + omits the id field + const tampered = { ...intent, maxAmount: 100000 }; + expect(await contentAddressId(tampered)).not.toBe(intent.intentId); // raising the cap orphans the id + }); + + it("canonical() is order-independent", () => { + expect(canonical({ b: 1, a: [3, { y: 2, x: 1 }] })).toBe(canonical({ a: [3, { x: 1, y: 2 }], b: 1 })); + }); +}); + +describe("checkDraw — the deterministic gates", () => { + it("an in-bounds draw passes all gates", async () => { + const { intent, draw } = await fixture(); + const r = await checkDraw(intent, draw, { now: JUL15 }); + expect(r.ok).toBe(true); + expect(r.refusals).toEqual([]); + }); + + it("over-cap: a draw above maxAmount is refused (co-fires over-total + step-up — the schema finding)", async () => { + const { intent, draw, privateKey } = await fixture(); + const big = await signDraw({ ...draw, amount: 200 }, privateKey); + // With stepUpOver ≤ maxAmount (schema) and maxAmount == totalAmount here, an over-cap + // draw NECESSARILY also trips over-total and step-up — over-cap can't fire alone. + expect(codes(await checkDraw(intent, big, { now: JUL15 }))).toEqual(["over-cap", "over-total", "step-up"]); + }); + + it("over-total: cumulative prior draws + this one exceed totalAmount", async () => { + const { intent, draw } = await fixture(); + const prior = [{ amount: 100, pspTransactionId: "tx_prev" }]; // 100 spent, cap 120, this draw 40 → 140 + expect(codes(await checkDraw(intent, draw, { now: JUL15, priorDraws: prior }))).toEqual(["over-total"]); + }); + + it("window: before notBefore and after intentExpiry are refused", async () => { + const { intent, draw } = await fixture(); + expect(codes(await checkDraw(intent, draw, { now: Date.parse("2026-06-01T00:00:00Z") }))).toEqual(["not-yet-valid"]); + expect(codes(await checkDraw(intent, draw, { now: Date.parse("2026-08-01T00:00:00Z") }))).toEqual(["expired"]); + }); + + it("scope: a draw to a merchant not in the bounds is refused", async () => { + const { intent, draw, privateKey } = await fixture(); + const off = await signDraw({ ...draw, merchant: "sketchy.example" }, privateKey); + expect(codes(await checkDraw(intent, off, { now: JUL15 }))).toEqual(["out-of-scope"]); + }); + + it("replay: reusing a pspTransactionId is refused", async () => { + const { intent, draw } = await fixture(); + const r = await checkDraw(intent, draw, { now: JUL15, priorDraws: [{ amount: 10, pspTransactionId: "tx_1" }] }); + expect(codes(r)).toEqual(["replay"]); + }); + + it("presentment: a credential not in mayPresent is refused (age is never delegable)", async () => { + const { intent, draw, privateKey } = await fixture(); + const age = await signDraw({ ...draw, presentments: ["age:over-21"] }, privateKey); + expect(codes(await checkDraw(intent, age, { now: JUL15 }))).toEqual(["unpermitted-presentment"]); + }); + + it("step-up: a draw over stepUpOver is a needs-human refusal", async () => { + const { intent, draw, privateKey } = await fixture(); + const over = await signDraw({ ...draw, amount: 75 }, privateKey); // > stepUpOver 50, ≤ cap 120 + const r = await checkDraw(intent, over, { now: JUL15 }); + expect(codes(r)).toEqual(["step-up"]); + expect(r.refusals[0]!.retryable).toBe("needs-human"); + }); + + it("signature: tampering a signed draw's amount is refused (K_s covers the canonical draw)", async () => { + const { intent, draw } = await fixture(); + const tampered = { ...draw, amount: 5 }; // edit AFTER signing → signature no longer matches + const r = await checkDraw(intent, tampered, { now: JUL15 }); + expect(r.refusals.some((x) => x.code === "signature")).toBe(true); + }); + + it("signature: a draw signed by a DIFFERENT key (not the delegate) is refused", async () => { + const { intent, draw } = await fixture(); + const attacker = await generateDelegate(); + const forged = await signDraw({ ...draw }, attacker.privateKey); + expect(codes(await checkDraw(intent, forged, { now: JUL15 }))).toEqual(["signature"]); + }); + + it("intent-mismatch: a draw bound to a different intentId is refused", async () => { + const { intent, draw, privateKey } = await fixture(); + const other = await signDraw({ ...draw, intentId: "int_other" }, privateKey); + expect(codes(await checkDraw(intent, other, { now: JUL15 }))).toEqual(["intent-mismatch"]); + }); + + it("multiple violations accumulate (typed refusal list, not first-fail)", async () => { + const { intent, draw, privateKey } = await fixture(); + const bad = await signDraw({ ...draw, amount: 200, currency: "EUR", merchant: "sketchy.example" }, privateKey); + expect(codes(await checkDraw(intent, bad, { now: JUL15 }))).toEqual([ + "currency-mismatch", + "out-of-scope", + "over-cap", + "over-total", + "step-up", + ]); + }); + + it("every refusal is merchant-attributed typed data", async () => { + const { intent, draw, privateKey } = await fixture(); + const bad = await signDraw({ ...draw, amount: 200 }, privateKey); + const r = await checkDraw(intent, bad, { now: JUL15 }); + for (const ref of r.refusals) { + expect(ref.enforcer).toBe("merchant"); + expect(["retry", "needs-human", "terminal"]).toContain(ref.retryable); + } + }); +}); + +// Type-level: IntentBounds/Draw are exported, usable shapes. +const _typecheck: (i: IntentBounds, d: Draw) => string = (i, d) => i.intentId + d.pspTransactionId; +void _typecheck; diff --git a/packages/credentagent-gate/src/ceremony/mandate.ts b/packages/credentagent-gate/src/ceremony/mandate.ts index 92c25b5..a5ab51e 100644 --- a/packages/credentagent-gate/src/ceremony/mandate.ts +++ b/packages/credentagent-gate/src/ceremony/mandate.ts @@ -183,3 +183,204 @@ export function runGates(mandate: PasskeyMandate, opts: { loyaltyDiscountPct?: n return results; } + +// ──────────────────────────────────────────────────────────────────────────── +// HNP seams (005, Option B): the Intent Mandate BOUNDS model + the deterministic +// draw gates — productized from spike/intent-mandate/ (13 tests ported alongside). +// The user's ceremony seals BOUNDS (caps / window / scope / delegate key); each +// DRAW (a per-purchase Payment Mandate) is checked in-bounds server-side on every +// completion path. Honesty: the wire crypto here is REAL (ES256 over the canonical +// draw; content-addressed intentId over SHA-256) — what the demo fakes is the PKI +// and the money, never the bounds enforcement, which is deterministic and total. +import { webcrypto } from "node:crypto"; +import { refusal, type Refusal } from "./refusals.js"; + +const subtle = webcrypto.subtle; +const utf8 = new TextEncoder(); +const b64url = (buf: ArrayBuffer | Uint8Array): string => Buffer.from(buf as Uint8Array).toString("base64url"); + +/** The delegate key K_s — the ONLY key that may sign draws — as a public JWK. */ +export interface DelegateJwk { + kty: "EC"; + crv: "P-256"; + x: string; + y: string; +} + +/** The user-sealed Intent Mandate bounds (intent-bounds-schema-draft.md: AP2 intent + * fields + EUDI SCA TS12 `PaymentTransaction` amounts). Content-addressed: `intentId` + * transitively commits to every other field, delegate key and honesty labels included. */ +export interface IntentBounds { + type: "credentagent.IntentBounds/v0"; + intentId: string; + /** The human-readable mandate the user actually approved (AP2 natural-language field). */ + naturalLanguageDescription?: string; + /** Merchant allowlist; absent/empty ⇒ any suitable merchant (multi-merchant is native under B). */ + merchants?: string[]; + /** SKU/GTIN scope allowlist (checked by the wallet/rail; the seam checks merchants). */ + skus?: string[]; + currency: string; + /** Per-draw cap (TS12 max_amount) — an absolute ceiling, tolerance 0. */ + maxAmount: number; + /** Cumulative cap (TS12 total_amount) across all committed draws. */ + totalAmount: number; + /** Presence-required threshold: a draw above it needs a fresh human tap (step-up ≤ cap). */ + stepUpOver?: number; + intentExpiry?: string; + notBefore?: string; + delegate: DelegateJwk; + /** Credentials the agent MAY present under this grant. Age is NEVER delegable → never listed. */ + mayPresent?: string[]; + /** Honesty axes (constitution VII v1.1.0): when consent happened / how strongly bound. */ + presence: "delegated" | "delegated-demo"; + trust_level: string; + subject?: string; +} + +/** One draw — the per-purchase spend against an intent, signed by the delegate key. */ +export interface Draw { + type: "credentagent.Draw/v0"; + intentId: string; + paymentMandateId: string; + merchant: string; + amount: number; + currency: string; + /** The PSP-issued settlement transaction id — single-use per intent (replay guard). */ + pspTransactionId: string; + presentments?: string[]; + /** b64url ES256 signature by the delegate key over the canonical draw (sans this field). */ + signature?: string; +} + +/** A committed (already-drawn) spend, as the RevocationStore records it. */ +export interface CommittedDraw { + amount: number; + pspTransactionId: string; +} + +/** Canonical JSON (stable, recursive key sort) — the exact bytes hashed + signed. Any + * edit to any field changes these bytes, so a signature/hash covers the whole document. */ +export function canonical(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]"; + const obj = value as Record; + const keys = Object.keys(obj).sort(); + return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonical(obj[k])).join(",") + "}"; +} + +/** intentId = "int_" + b64url(SHA-256(canonical(bounds \ intentId))) — no circularity, + * and it transitively commits to EVERY other field. */ +export async function contentAddressId(bounds: Record): Promise { + const { intentId: _omit, ...rest } = bounds; + const digest = await subtle.digest("SHA-256", utf8.encode(canonical(rest))); + return "int_" + b64url(digest); +} + +export async function sealIntent(boundsWithoutId: Omit): Promise { + return { ...boundsWithoutId, intentId: await contentAddressId(boundsWithoutId) } as IntentBounds; +} + +/** Generate a delegate keypair K_s (ES256 / P-256). The bounds carry the PUBLIC JWK. */ +export async function generateDelegate(): Promise<{ privateKey: CryptoKey; delegate: DelegateJwk }> { + const pair = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + const jwk = await subtle.exportKey("jwk", pair.publicKey); + return { privateKey: pair.privateKey, delegate: { kty: "EC", crv: "P-256", x: jwk.x!, y: jwk.y! } }; +} + +/** Sign a draw with the delegate key over its canonical form (any prior signature stripped). */ +export async function signDraw(draw: Draw, privateKey: CryptoKey): Promise { + const { signature: _omit, ...unsigned } = draw; + const sig = await subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, utf8.encode(canonical(unsigned))); + return { ...(unsigned as Draw), signature: b64url(sig) }; +} + +/** Signer-agnostic verification seam: the default verifies ES256/P-256 (the wallet + * server's K_s — the Option-B target); hosts may inject e.g. an HMAC verifier. */ +export type DrawVerifier = (draw: Draw, delegate: DelegateJwk) => Promise; + +export const verifyDrawEs256: DrawVerifier = async (draw, delegate) => { + try { + const { signature, ...unsigned } = draw; + if (typeof signature !== "string") return false; + const key = await subtle.importKey( + "jwk", + { ...delegate, ext: true }, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["verify"], + ); + return await subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + Buffer.from(signature, "base64url"), + utf8.encode(canonical(unsigned)), + ); + } catch { + return false; + } +}; + +export interface CheckDrawContext { + now?: number; + priorDraws?: CommittedDraw[]; + verify?: DrawVerifier; +} + +export interface DrawVerdict { + ok: boolean; + refusals: Refusal[]; +} + +/** THE DETERMINISTIC GATES: is this draw in-bounds? Pure and total — injected `now` / + * `priorDraws`, never throws, accumulates typed refusals (no first-fail) so the surface + * can act on the full picture. This is defense-in-depth's inner ring: the completion + * seam re-runs it server-side on EVERY path (invariant 1). */ +export async function checkDraw(intent: IntentBounds, draw: Draw, ctx: CheckDrawContext = {}): Promise { + const now = ctx.now ?? Date.now(); + const priorDraws = ctx.priorDraws ?? []; + const verify = ctx.verify ?? verifyDrawEs256; + const refusals: Refusal[] = []; + + // 1. binds to THIS intent + if (draw.intentId !== intent.intentId) refusals.push(refusal("intent-mismatch")); + + // 2. signed by the delegate key named in the (content-addressed) bounds + if (!(await verify(draw, intent.delegate))) refusals.push(refusal("signature")); + + // 3. currency + if (draw.currency !== intent.currency) + refusals.push(refusal("currency-mismatch", { expected: intent.currency, got: draw.currency })); + + // 4. per-draw cap (TS12 max_amount) — absolute ceiling + if (draw.amount > intent.maxAmount) refusals.push(refusal("over-cap", { cap: intent.maxAmount, amount: draw.amount })); + + // 5. cumulative cap (TS12 total_amount) — committed draws + this one + const spent = priorDraws.reduce((s, d) => s + d.amount, 0); + if (spent + draw.amount > intent.totalAmount) + refusals.push(refusal("over-total", { total: intent.totalAmount, wouldBe: spent + draw.amount })); + + // 6. window (notBefore ≤ now ≤ intentExpiry) + if (intent.notBefore && now < Date.parse(intent.notBefore)) + refusals.push(refusal("not-yet-valid", { notBefore: intent.notBefore })); + if (intent.intentExpiry && now > Date.parse(intent.intentExpiry)) + refusals.push(refusal("expired", { intentExpiry: intent.intentExpiry })); + + // 7. scope — merchant allowlist (absent/empty ⇒ any suitable merchant) + if (Array.isArray(intent.merchants) && intent.merchants.length > 0 && !intent.merchants.includes(draw.merchant)) + refusals.push(refusal("out-of-scope", { merchant: draw.merchant })); + + // 8. replay — the PSP transaction id is single-use per intent + if (priorDraws.some((d) => d.pspTransactionId === draw.pspTransactionId)) + refusals.push(refusal("replay", { pspTransactionId: draw.pspTransactionId })); + + // 9. presentments ⊆ mayPresent. Age is NEVER delegable → never in mayPresent (invariant 5). + for (const p of draw.presentments ?? []) { + if (!(intent.mayPresent ?? []).includes(p)) refusals.push(refusal("unpermitted-presentment", { presentment: p })); + } + + // 10. step-up: over the presence-required threshold ⇒ a fresh human tap resumes it. + if (typeof intent.stepUpOver === "number" && draw.amount > intent.stepUpOver) + refusals.push(refusal("step-up", { threshold: intent.stepUpOver })); + + return { ok: refusals.length === 0, refusals }; +} diff --git a/packages/credentagent-gate/src/ceremony/refusals.ts b/packages/credentagent-gate/src/ceremony/refusals.ts new file mode 100644 index 0000000..cfac2be --- /dev/null +++ b/packages/credentagent-gate/src/ceremony/refusals.ts @@ -0,0 +1,53 @@ +// The shared typed-refusal vocabulary (design §9 + the redemption-choreography draft): +// ONE discriminated shape across the gate seam, the (future) intent rail, and the +// (future) wallet server — every refusal names the failure, WHO refused (`enforcer`), +// and the recovery class (`retryable`) an unattended agent loop branches on. Pure data +// (Principle VI): refusals cross the MCP wire to agents, so no functions, no Errors. +// +// The agent-facing projection is intentionally minimal (`enforcer` + `code`); the +// per-code detail fields exist for the merchant's own logs and the human approve +// surface — do not leak bounds detail to the counterparty (choreography draft, the +// security persona's oracle concern). + +/** Why a draw was refused. Finer-grained than §9's collapsed wire set so the seam can + * log precisely; surfaces MAY coarsen (e.g. `not-yet-valid`/`expired` → "expired"). */ +export type RefusalCode = + | "signature" // draw not signed by the delegate key named in the bounds + | "intent-mismatch" // draw binds to a different intentId + | "currency-mismatch" + | "over-cap" // per-draw cap (TS12 max_amount) — absolute ceiling, tolerance 0 + | "over-total" // cumulative cap (TS12 total_amount) across committed draws + | "not-yet-valid" // now < notBefore + | "expired" // now > intentExpiry + | "out-of-scope" // merchant (or, later, SKU) outside the bounds allowlist + | "unpermitted-presentment" // credential not in mayPresent — age is NEVER delegable + | "replay" // pspTransactionId already committed for this intent + | "step-up" // over the presence-required threshold — a human tap resumes it + | "revoked" // the grant (or its subject, via kill-switch) was revoked + | "consumed" // single-use grant already drawn + | "revocation-unavailable"; // the revocation store errored — fail-closed, never open + +/** Who refused. The gate seam always answers as the merchant; the wallet server and + * PSP use their own values so cross-party logs attribute cleanly. */ +export type RefusalEnforcer = "wallet" | "merchant" | "psp"; + +/** The recovery class an unattended loop branches on: + * - "retry": transient (e.g. store unavailable) — retry with backoff, unattended. + * - "needs-human": a live ceremony resumes it (step-up) — surface an approve link. + * - "terminal": do not retry; the draw can never succeed as-is. */ +export type RefusalRetryable = "retry" | "needs-human" | "terminal"; + +export interface Refusal { + code: RefusalCode; + enforcer: RefusalEnforcer; + retryable: RefusalRetryable; + /** Per-code detail for the enforcer's own logs / approve page (not the wire). */ + detail?: Record; +} + +/** The gate-seam constructor: merchant-attributed, retryability derived from the code. */ +export function refusal(code: RefusalCode, detail?: Record): Refusal { + const retryable: RefusalRetryable = + code === "step-up" ? "needs-human" : code === "revocation-unavailable" ? "retry" : "terminal"; + return { code, enforcer: "merchant", retryable, ...(detail ? { detail } : {}) }; +} diff --git a/packages/credentagent-gate/src/ceremony/revocation.ts b/packages/credentagent-gate/src/ceremony/revocation.ts new file mode 100644 index 0000000..b6757ba --- /dev/null +++ b/packages/credentagent-gate/src/ceremony/revocation.ts @@ -0,0 +1,53 @@ +// The revocation + committed-draw store behind HNP grants (005 FR-009/FR-010). +// Keyed per intentId — never process-global (invariant 4) — with a per-subject +// kill-switch. Consulted FAIL-CLOSED at the completion seam: a store that errors +// refuses the draw (revocation-unavailable), never allows it. +// +// `commitDraw` is the single-use / replay control and MUST be atomic +// (check-and-append): `completeOrder`'s idempotency is keyed by ORDER id, so two +// concurrent redemptions producing two order ids would otherwise both pass. The +// in-memory default is single-instance only (Node's single-threaded event loop +// makes the check-and-append atomic per tick); multi-instance deploys MUST inject +// a shared CAS-capable store (Redis SETNX/Lua), mirroring VerificationStore. +import type { MaybePromise } from "./types.js"; +import type { CommittedDraw } from "./mandate.js"; + +export interface RevocationStore { + /** Is this grant (or its subject, via the kill-switch) revoked? */ + isRevoked(intentId: string, subject?: string): MaybePromise; + revoke(intentId: string): MaybePromise; + /** Kill-switch: revoke every grant carrying this subject. */ + revokeSubject(subject: string): MaybePromise; + /** Committed draws for the intent (feeds checkDraw's cumulative + replay gates). */ + priorDraws(intentId: string): MaybePromise; + /** Atomically commit a draw iff its pspTransactionId is unused for this intent. + * Returns false (commits nothing) on a duplicate — exactly one of N concurrent + * draws with one pspTransactionId may win. */ + commitDraw(intentId: string, draw: CommittedDraw): MaybePromise; +} + +export class MemoryRevocationStore implements RevocationStore { + private readonly revoked = new Set(); + private readonly revokedSubjects = new Set(); + private readonly draws = new Map(); + + isRevoked(intentId: string, subject?: string): boolean { + return this.revoked.has(intentId) || (subject !== undefined && this.revokedSubjects.has(subject)); + } + revoke(intentId: string): void { + this.revoked.add(intentId); + } + revokeSubject(subject: string): void { + this.revokedSubjects.add(subject); + } + priorDraws(intentId: string): CommittedDraw[] { + return this.draws.get(intentId) ?? []; + } + commitDraw(intentId: string, draw: CommittedDraw): boolean { + const list = this.draws.get(intentId) ?? []; + if (list.some((d) => d.pspTransactionId === draw.pspTransactionId)) return false; + list.push(draw); + this.draws.set(intentId, list); + return true; + } +} diff --git a/packages/credentagent-gate/src/ceremony/types.ts b/packages/credentagent-gate/src/ceremony/types.ts index b5098b4..6f666d4 100644 --- a/packages/credentagent-gate/src/ceremony/types.ts +++ b/packages/credentagent-gate/src/ceremony/types.ts @@ -99,6 +99,12 @@ export interface CompletionInput { * re-pricing and refuses a tampered/replayed/expired cart — additive, fail-closed * defense-in-depth; the catalog stays the price authority either way. */ cartMandate?: CartMandate; + /** Optional HNP delegated draw (005): a user-sealed Intent Mandate + the delegate-signed + * draw redeeming it. When present, completion re-runs the full bounds + revocation + + * atomic single-use checks server-side (additive, fail-closed) — never trusting an + * upstream verify (invariant 1). Absent ⇒ the draw branch is skipped entirely (HP paths + * byte-unchanged). */ + draw?: { intent: import("./mandate.js").IntentBounds; draw: import("./mandate.js").Draw }; } export interface CompletionResult { @@ -108,9 +114,13 @@ export interface CompletionResult { /** Why a non-completion happened — a failed ceremony ("gates"), a tampered/replayed/ * expired Cart Mandate ("cart-mandate"), a tampered token re-priced against the * catalog ("reprice"), a signed Cart Mandate and signed Payment Mandate that - * disagree on order/amount/currency ("reconcile"), or an age-restricted order with - * no proven per-order age claim ("age"). */ - reason?: "gates" | "cart-mandate" | "reprice" | "reconcile" | "age"; + * disagree on order/amount/currency ("reconcile"), an age-restricted order with + * no proven per-order age claim ("age"), or a refused delegated draw ("draw"). */ + reason?: "gates" | "cart-mandate" | "reprice" | "reconcile" | "age" | "draw"; + /** For a refused delegated draw, the typed refusals (why + who + recovery class). */ + refusals?: import("./refusals.js").Refusal[]; + /** For a completed delegated draw, the authorizing grant id (the audit link). */ + delegationId?: string; } /** diff --git a/packages/credentagent-gate/src/index.ts b/packages/credentagent-gate/src/index.ts index fc2dee5..badaa24 100644 --- a/packages/credentagent-gate/src/index.ts +++ b/packages/credentagent-gate/src/index.ts @@ -69,6 +69,31 @@ export type { ClearableCart, SettlementRecordLike, } from "./ceremony/completion.js"; +// ── HNP delegated-draw seams (005, Option B) — the Intent Mandate bounds model, the +// deterministic draw gates, the typed refusals, and the revocation/single-use store. The +// completeOrder draw branch re-runs checkDraw + revocation + atomic consume server-side. +export { + canonical, + contentAddressId, + sealIntent, + generateDelegate, + signDraw, + checkDraw, + verifyDrawEs256, +} from "./ceremony/mandate.js"; +export type { + IntentBounds, + Draw, + DelegateJwk, + CommittedDraw, + DrawVerifier, + DrawVerdict, + CheckDrawContext, +} from "./ceremony/mandate.js"; +export { MemoryRevocationStore } from "./ceremony/revocation.js"; +export type { RevocationStore } from "./ceremony/revocation.js"; +export { refusal } from "./ceremony/refusals.js"; +export type { Refusal, RefusalCode, RefusalEnforcer, RefusalRetryable } from "./ceremony/refusals.js"; export type { CeremonyOrder, CeremonyOrderLine, From 646410e36c2ab244cd1eb303e1e698b8c2c9bc7c Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Wed, 8 Jul 2026 17:48:35 -0700 Subject: [PATCH 06/24] =?UTF-8?q?fix(gate):=20CI=20typecheck=20=E2=80=94?= =?UTF-8?q?=20webcrypto.CryptoKey=20type=20alias=20(lib=20ES2022,=20no=20D?= =?UTF-8?q?OM)=20+=20drop=20unused=20Refusal=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's gate tsconfig uses lib ES2022 without DOM, so the global CryptoKey type is unavailable (local build resolved it via leaked DOM types). Alias it from node:crypto's webcrypto namespace. Also remove the unused Refusal type import. Signed-off-by: Diego Zuluaga --- packages/credentagent-gate/src/ceremony/completion.ts | 2 +- packages/credentagent-gate/src/ceremony/mandate.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/credentagent-gate/src/ceremony/completion.ts b/packages/credentagent-gate/src/ceremony/completion.ts index 2024bf9..45735c9 100644 --- a/packages/credentagent-gate/src/ceremony/completion.ts +++ b/packages/credentagent-gate/src/ceremony/completion.ts @@ -11,7 +11,7 @@ import { verifyCartMandate, type CartMandate } from "./cartMandate.js"; import { reconcileCartPayment } from "./reconciliation.js"; import { checkDraw, type DrawVerifier } from "./mandate.js"; import type { RevocationStore } from "./revocation.js"; -import { refusal, type Refusal } from "./refusals.js"; +import { refusal } from "./refusals.js"; // One on-chain (demo-mode) settlement backing a completed order. Kept structural // so the demo's richer SettlementRecord is assignable without the package taking diff --git a/packages/credentagent-gate/src/ceremony/mandate.ts b/packages/credentagent-gate/src/ceremony/mandate.ts index a5ab51e..e194734 100644 --- a/packages/credentagent-gate/src/ceremony/mandate.ts +++ b/packages/credentagent-gate/src/ceremony/mandate.ts @@ -196,6 +196,7 @@ import { webcrypto } from "node:crypto"; import { refusal, type Refusal } from "./refusals.js"; const subtle = webcrypto.subtle; +type CryptoKey = webcrypto.CryptoKey; const utf8 = new TextEncoder(); const b64url = (buf: ArrayBuffer | Uint8Array): string => Buffer.from(buf as Uint8Array).toString("base64url"); From 7c9bae186ba5e577ead076292416797cabd0c745 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Wed, 8 Jul 2026 17:50:14 -0700 Subject: [PATCH 07/24] =?UTF-8?q?docs(gate):=20README=20=E2=80=94=20Delega?= =?UTF-8?q?ted=20draws=20(HNP=20seams)=20section,=20honesty-fenced=20(T008?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Diego Zuluaga --- packages/credentagent-gate/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/credentagent-gate/README.md b/packages/credentagent-gate/README.md index 56bb83b..acfe68e 100644 --- a/packages/credentagent-gate/README.md +++ b/packages/credentagent-gate/README.md @@ -136,6 +136,23 @@ cryptography. The mandate is AP2-shaped and dev-signed (integrity hash), not key > **`verification_required`** envelope the agent *drives* (which credential, a per-order approve link, > the tool to poll) instead of completing — the retained blocking **Mode B** primitive. +## Delegated draws — human-not-present seams (005, preview) + +The gate exposes the first HNP increment: **signer-agnostic seams** for redeeming a user-sealed +**Intent Mandate** (a bounded, revocable delegation) with no live human — `sealIntent` / `checkDraw` +(pure, total, typed refusals), a `RevocationStore` (per-intent + subject kill-switch, atomic +single-use consume), and an additive, fail-closed **draw branch** in `completeOrder` that re-runs +every bounds + revocation check server-side, writes a `delegationId`, and **suppresses settlement**. +Age is **non-delegable** — an age-restricted cart always steps up to a live ceremony. + +Honesty (Principle VII, constitution v1.1.0): draws carry a **`presence`** axis (`"delegated"` / +`"delegated-demo"`) — *when* consent happened — separate from `trust_level` — *how strongly it's +bound*. The wire crypto is **real** (ES256 over the canonical draw; content-addressed `intentId`), but +v0.1 has **no issuer/DeviceKey trust anchor and no per-draw proof-of-possession** — the grant is +effectively a bearer instrument, fenced as a demo. A *real* HNP control requires `presence: +"delegated"` **and** `trust_level: "issuer-verified"`; the HTTP intent rail + the wallet server that +provide those are later increments. + ## API surface (v0.1) ```ts From 8ab2f354498c409dec40bc115bd6d8d3eb23703a Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 15:09:45 -0700 Subject: [PATCH 08/24] docs(examples): organize with a grouped index; move HNP demo into examples/hnp-draws/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - examples/README.md gains a grouped table of contents (storefront+gate / gating patterns / cart-mandate / human-not-present) linking each demo. - The HNP draws demo moves to examples/hnp-draws/{demo.mjs,README.md}, matching the run-storefront/ + stateless-orders/ subdir convention for demos that warrant their own walkthrough. Verified: runs from the new path. - Existing flat single-file demos stay put (referenced by the published package READMEs — moving them would break documented commands). Signed-off-by: Diego Zuluaga --- examples/README.md | 21 +++++++++++ examples/hnp-draws/README.md | 45 ++++++++++++++++++++++ examples/hnp-draws/demo.mjs | 72 ++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 examples/hnp-draws/README.md create mode 100644 examples/hnp-draws/demo.mjs diff --git a/examples/README.md b/examples/README.md index ce7710b..b39b24e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,26 @@ # CredentAgent examples +Each is runnable against the two `@openmobilehub/credentagent-*` packages (build them first: +`npm run build:packages`). Grouped by what they show: + +**Storefront + gate** (connect to Goose / any MCP host) +- [`storefront.mjs`](#storefrontmjs--a-credential-gated-storefront-in-8-lines) — a credential-gated storefront in ~8 lines +- [`custom-credential.mjs`](#custom-credentialmjs--gate-any-action-with-any-credential) — gate any action with **any** credential (Principle V) +- [`with-x402-settlement.mjs`](#with-x402-settlementmjs--settle-payment-on-chain-via-the-settle-seam) — settle payment on-chain via the `settle` seam +- [`storefront-redis.mjs`](storefront-redis.mjs) / [`storefront-firestore.mjs`](storefront-firestore.mjs) — injectable persistence (Upstash Redis stores / Firestore catalog source) +- [`run-storefront/`](run-storefront/) — run THIS repo's storefront directly (stateful + stateless side by side) + +**Gating patterns** (identity-first, beyond commerce) +- [`gate-any-action.mjs`](#gate-any-actionmjs--gate-a-non-commerce-action-identity-first-no-checkout) — gate a non-commerce action, no checkout + +**Cart Mandate / stateless** (004) +- [`stateless-orders/`](stateless-orders/) — the created order rides in a signed Cart Mandate on the link + +**Human-not-present** (005, preview) +- [`hnp-draws/`](hnp-draws/) — the delegated-draw "doorman": one pre-approval, good + bad draws, all decided server-side + +--- + ## `storefront.mjs` — a credential-gated storefront in ~8 lines A minimal, runnable agentic storefront you add to **Goose** (or any MCP host) as an HTTP connector and diff --git a/examples/hnp-draws/README.md b/examples/hnp-draws/README.md new file mode 100644 index 0000000..c07b56a --- /dev/null +++ b/examples/hnp-draws/README.md @@ -0,0 +1,45 @@ +# `hnp-draws/` — the human-not-present "doorman" in action (005 seams, preview) + +Watch the [PR #41](https://github.com/openmobilehub/credentagent/pull/41) HNP seams decide, with **no web +page and no phone** — a self-contained script that mints a **pre-approval** (an Intent Mandate) and throws +good + bad purchases (**draws**) at `completeOrder`, printing what the gate allows and refuses. + +## Run it + +```bash +npm run build:packages # build the two @openmobilehub/credentagent-* packages +node examples/hnp-draws/demo.mjs +``` + +## What you see + +``` +🎫 Pre-approval: "Reorder Blue Bottle coffee, up to $40, this month" +✅ 1 bag of coffee ($18) → COMPLETED (no real money moved) +⛔ reuse same transaction → REFUSED: replay +⛔ $54 cart, over the cap → REFUSED: over-cap, over-total +⛔ different store (Starbucks) → REFUSED: out-of-scope +⛔ wine on a coffee approval → REFUSED: step-up (age is never delegable) +🔴 you revoke from your phone… +⛔ another reorder → REFUSED: revoked +``` + +## What it proves + +- **The gate is the doorman, not the agent.** Every draw is re-checked server-side in `completeOrder` — + bounds (`checkDraw`), revocation, and an **atomic single-use** consume — so a producer reaching the seam + directly is still fully checked (invariant 1). Refusals are **typed data** (a `code` per failure), not prose. +- **One pre-approval = one purchase.** Reusing a transaction id is refused; two racing draws yield exactly one + completion. +- **Age is non-delegable.** An age-restricted line always steps up to a live ceremony — a grant can never + complete it. +- **Revocation is immediate + fail-closed.** Revoke, and the next draw is refused; an unreachable revocation + store refuses too (never fail-open). + +## Honest limits + +The wire crypto is **real** (ES256 over the canonical draw; content-addressed `intentId`), but v0.1 has **no +issuer/DeviceKey trust anchor and no per-draw proof-of-possession** — the grant is a bearer instrument, +demo-fenced (`presence: "delegated-demo"`, `trust_level: "server-issued-demo"`), and **no real money moves**. +The user ceremony that seals the bounds on a phone, the HTTP intent rail, and the wallet server that provide a +*real* control are later increments (spec.md Out of Scope). This script is that flow's logic, minus the web. diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs new file mode 100644 index 0000000..c3168ef --- /dev/null +++ b/examples/hnp-draws/demo.mjs @@ -0,0 +1,72 @@ +// See the HNP "doorman" (PR #41) in action — no web page needed. +// Mints a pre-approval (Intent Mandate), then throws good + bad purchases (draws) +// at completeOrder and prints what the gate decides. Run: +// node examples/hnp-draws-demo.mjs +import { + sealIntent, + generateDelegate, + signDraw, + completeOrder, + MemoryRevocationStore, + MemoryVerificationStore, +} from "@openmobilehub/credentagent-gate"; + +// ── a tiny catalog: coffee $18, wine $20 (age-restricted) ──────────────────── +const PRODUCTS = { coffee: { price: 18 }, wine: { price: 20, minimumAge: 21 } }; +const catalog = { + createOrder(items, orderId) { + const lines = items.map((it) => { + const p = PRODUCTS[it.productId]; + return { id: it.productId, name: it.productId, unitPrice: p.price, currency: "USD", quantity: it.quantity, lineTotal: p.price * it.quantity, ...(p.minimumAge ? { minimumAge: p.minimumAge } : {}) }; + }); + const total = lines.reduce((s, l) => s + l.lineTotal, 0); + return { id: orderId, lines, itemCount: items.length, subtotal: total, discount: 0, total, currency: "USD", createdAt: new Date().toISOString() }; + }, +}; + +// ── the user pre-approves ONCE (this is the phone ceremony, faked here) ─────── +const { privateKey, delegate } = await generateDelegate(); +const intent = await sealIntent({ + type: "credentagent.IntentBounds/v0", + naturalLanguageDescription: "Reorder Blue Bottle coffee, up to $40, this month", + merchants: ["blue-bottle"], + currency: "USD", + maxAmount: 40, + totalAmount: 40, + stepUpOver: 500, + intentExpiry: "2026-07-31T23:59:59Z", + delegate, + mayPresent: [], + presence: "delegated-demo", + trust_level: "server-issued-demo", +}); +console.log(`\n🎫 Pre-approval minted: "${intent.naturalLanguageDescription}"`); +console.log(` id ${intent.intentId.slice(0, 22)}… · presence=${intent.presence} trust=${intent.trust_level}\n`); + +// shared server-side state (revocations + single-use ledger, per-order verification) +const revocation = new MemoryRevocationStore(); +const verificationStore = new MemoryVerificationStore(); +const records = new Map(); +const ctx = { catalog, verificationStore, revocation, records: { read: (id) => records.get(id), write: (r) => records.set(r.orderId, r) } }; + +let n = 0; +async function attempt(label, { items, merchant, amount, pspTransactionId, orderId }) { + const order = catalog.createOrder(items, orderId ?? `ORD-${++n}`); + const draw = await signDraw({ type: "credentagent.Draw/v0", intentId: intent.intentId, paymentMandateId: `d${n}`, merchant, amount, currency: "USD", pspTransactionId }, privateKey); + const res = await completeOrder({ order, mandateId: draw.paymentMandateId, amount, currency: "USD", method: "delegated", gates: [{ gate: "draw", pass: true, detail: "" }], draw: { intent, draw } }, ctx); + if (res.completed) console.log(`✅ ${label}\n → COMPLETED, logged delegationId ${res.delegationId.slice(0, 18)}… (no real money moved)\n`); + else console.log(`⛔ ${label}\n → REFUSED: ${res.refusals.map((r) => r.code).join(", ")}\n`); +} + +console.log("─".repeat(64)); +await attempt("Agent reorders 1 bag of Blue Bottle coffee ($18)", { items: [{ productId: "coffee", quantity: 1 }], merchant: "blue-bottle", amount: 18, pspTransactionId: "tx_1" }); +await attempt("Agent tries to reuse the SAME transaction (double-spend)", { items: [{ productId: "coffee", quantity: 1 }], merchant: "blue-bottle", amount: 18, pspTransactionId: "tx_1" }); +await attempt("Agent tries a $54 cart — 3 bags, over the $40 cap", { items: [{ productId: "coffee", quantity: 3 }], merchant: "blue-bottle", amount: 54, pspTransactionId: "tx_2" }); +await attempt("Agent tries a DIFFERENT store (Starbucks)", { items: [{ productId: "coffee", quantity: 1 }], merchant: "starbucks", amount: 18, pspTransactionId: "tx_3" }); +await attempt("Agent tries to buy WINE on the coffee pre-approval", { items: [{ productId: "wine", quantity: 1 }], merchant: "blue-bottle", amount: 20, pspTransactionId: "tx_4" }); + +console.log("🔴 You revoke the pre-approval from your phone…\n"); +revocation.revoke(intent.intentId); +await attempt("Agent tries another coffee reorder after revocation", { items: [{ productId: "coffee", quantity: 1 }], merchant: "blue-bottle", amount: 18, pspTransactionId: "tx_5" }); +console.log("─".repeat(64)); +console.log("\nThe doorman: 1 legit purchase through, 5 refused with reasons, 0 real money moved.\n"); From ff956fa08a537512ee67d58828e6af5ab3f09803 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 17:07:16 -0700 Subject: [PATCH 09/24] =?UTF-8?q?fix(gate,005):=20close=20two=20Codex=20P1?= =?UTF-8?q?s=20=E2=80=94=20content-address=20integrity=20+=20atomic=20cumu?= =?UTF-8?q?lative=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1a (checkDraw trusted the intentId string): checkDraw now recomputes contentAddressId(intent) and refuses `bounds-tampered` on mismatch. Without it, a caller could keep a victim's intentId while swapping delegate/cap/scope and sign with the substituted key — every downstream check ran against unapproved bounds. The content-address IS the trust root; now it's verified. P1b (cap check non-atomic with commit): RevocationStore.commitDraw now makes BOTH the single-use AND the cumulative-cap decision atomically (takes totalAmount, returns { ok } | { ok:false, reason:'consumed'|'over-total' }). checkDraw's over-total stays a fast pre-check; commitDraw is the atomic backstop, so two concurrent distinct-txid draws can't both pass the pre-check and breach the cap. Tests: +6 (bounds-tampered sole-refusal; store-level atomic cap incl. the two-draws race + exact-cap boundary; completion over-total wiring). 278 green. Both new controls spot-verified RED when removed. Signed-off-by: Diego Zuluaga --- .../src/ceremony/completion.test.ts | 14 +++++- .../src/ceremony/completion.ts | 18 +++++--- .../src/ceremony/mandate.intent.test.ts | 18 ++++++++ .../credentagent-gate/src/ceremony/mandate.ts | 9 +++- .../src/ceremony/refusals.ts | 1 + .../src/ceremony/revocation.test.ts | 45 +++++++++++++++++++ .../src/ceremony/revocation.ts | 38 ++++++++++------ 7 files changed, 121 insertions(+), 22 deletions(-) create mode 100644 packages/credentagent-gate/src/ceremony/revocation.test.ts diff --git a/packages/credentagent-gate/src/ceremony/completion.test.ts b/packages/credentagent-gate/src/ceremony/completion.test.ts index 07b2ff9..052c093 100644 --- a/packages/credentagent-gate/src/ceremony/completion.test.ts +++ b/packages/credentagent-gate/src/ceremony/completion.test.ts @@ -213,7 +213,7 @@ describe("completeOrder — HNP delegated-draw branch (bypass tests)", () => { it("FAIL-CLOSED: an unreachable revocation store refuses (never fail-open)", async () => { const h = harness(); - const throwing = { isRevoked: () => { throw new Error("down"); }, revoke() {}, revokeSubject() {}, priorDraws() { return []; }, commitDraw() { return true; } }; + const throwing = { isRevoked: () => { throw new Error("down"); }, revoke() {}, revokeSubject() {}, priorDraws() { return []; }, commitDraw() { return { ok: true } as const; } }; const { intent, mkDraw } = await drawFixture(); const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw: await mkDraw(20) } }), { ...h.ctx, revocation: throwing }); expect(res.completed).toBe(false); @@ -254,6 +254,18 @@ describe("completeOrder — HNP delegated-draw branch (bypass tests)", () => { expect(res.refusals?.[0]?.code).toBe("step-up"); // …a grant NEVER completes an age gate }); + it("ATOMIC cumulative cap: an over-total verdict from commitDraw refuses at the seam", async () => { + // The store makes the cap decision atomically (Codex P1). If commitDraw reports the + // committed sum would breach the cap, the seam must refuse — not complete. + const h = harness(); + const { intent, mkDraw } = await drawFixture(); + const capBreaching = { isRevoked: () => false, revoke() {}, revokeSubject() {}, priorDraws() { return []; }, commitDraw() { return { ok: false, reason: "over-total" } as const; } }; + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw: await mkDraw(20) } }), { ...h.ctx, revocation: capBreaching }); + expect(res.completed).toBe(false); + expect(res.refusals?.[0]?.code).toBe("over-total"); + expect(h.records.size).toBe(0); + }); + it("BYPASS: draw.amount ≠ catalog re-priced total is refused (grant never carries the price)", async () => { const h = harness(); const rev = new MemoryRevocationStore(); diff --git a/packages/credentagent-gate/src/ceremony/completion.ts b/packages/credentagent-gate/src/ceremony/completion.ts index 45735c9..0430b0b 100644 --- a/packages/credentagent-gate/src/ceremony/completion.ts +++ b/packages/credentagent-gate/src/ceremony/completion.ts @@ -178,16 +178,22 @@ export async function completeOrder(input: CompletionInput, ctx: CompletionConte return { completed: false, reason: "draw", refusals: [refusal("over-cap", { pricedAt: repriced.total, amount: draw.amount })] }; } - // Atomic single-use consume — keyed per intent (NOT per order: completeOrder's own - // idempotency is order-keyed, so two concurrent redemptions minting two order ids would - // both pass without this). Exactly one of N racing draws with one pspTransactionId wins. - let committed: boolean; + // Atomic consume — keyed per intent (NOT per order: completeOrder's own idempotency is + // order-keyed, so two concurrent redemptions minting two order ids would both pass without + // this). The store makes BOTH the single-use AND the cumulative-cap decision atomically, so + // concurrent draws with distinct pspTransactionIds cannot both slip past checkDraw's + // (non-atomic) over-total pre-check and breach the cap. `consumed` = replayed txid; + // `over-total` = would breach the cumulative cap at commit time. + let commit: import("./revocation.js").CommitResult; try { - committed = await store.commitDraw(intent.intentId, { amount: draw.amount, pspTransactionId: draw.pspTransactionId }); + commit = await store.commitDraw(intent.intentId, { amount: draw.amount, pspTransactionId: draw.pspTransactionId }, { totalAmount: intent.totalAmount }); } catch { return { completed: false, reason: "draw", refusals: [refusal("revocation-unavailable")] }; } - if (!committed) return { completed: false, reason: "draw", refusals: [refusal("consumed", { pspTransactionId: draw.pspTransactionId })] }; + if (!commit.ok) { + const detail = commit.reason === "consumed" ? { pspTransactionId: draw.pspTransactionId } : { total: intent.totalAmount }; + return { completed: false, reason: "draw", refusals: [refusal(commit.reason, detail)] }; + } // The draw is authorized. Record it with the delegationId audit link and SUPPRESS real // settlement (the honesty control — a demo-fenced draw never moves real value, spec FR-014). diff --git a/packages/credentagent-gate/src/ceremony/mandate.intent.test.ts b/packages/credentagent-gate/src/ceremony/mandate.intent.test.ts index cdfd4f0..5d7d482 100644 --- a/packages/credentagent-gate/src/ceremony/mandate.intent.test.ts +++ b/packages/credentagent-gate/src/ceremony/mandate.intent.test.ts @@ -139,6 +139,24 @@ describe("checkDraw — the deterministic gates", () => { expect(codes(await checkDraw(intent, other, { now: JUL15 }))).toEqual(["intent-mismatch"]); }); + it("bounds-tampered: mutated bounds under a victim's intentId are refused (content-address integrity)", async () => { + const { intent, draw } = await fixture(); + // The attack (Codex P1): keep the victim's intentId, but swap the delegate key to the + // attacker's, raise the cap, and change the merchant — then sign the draw with the + // ATTACKER's key. Signature/cap/scope all pass against the mutated bounds; only the + // content-address self-check catches that these bounds never produced this intentId. + const attacker = await generateDelegate(); + // Inflate every bound so the forged draw would sail through signature/cap/scope/window — + // then the ONLY thing that can refuse it is the content-address integrity check. + const tampered = { ...intent, maxAmount: 100000, totalAmount: 100000, stepUpOver: 100000, merchants: ["attacker.example"], delegate: attacker.delegate }; + const forged = await signDraw({ ...draw, merchant: "attacker.example", amount: 9999 }, attacker.privateKey); + const r = await checkDraw(tampered, forged, { now: JUL15 }); + expect(r.ok).toBe(false); + // load-bearing: bounds-tampered is the SOLE refusal — remove the check and this forged + // draw (attacker's key, inflated cap + scope) would pass every other gate. + expect(codes(r)).toEqual(["bounds-tampered"]); + }); + it("multiple violations accumulate (typed refusal list, not first-fail)", async () => { const { intent, draw, privateKey } = await fixture(); const bad = await signDraw({ ...draw, amount: 200, currency: "EUR", merchant: "sketchy.example" }, privateKey); diff --git a/packages/credentagent-gate/src/ceremony/mandate.ts b/packages/credentagent-gate/src/ceremony/mandate.ts index e194734..ed6ae16 100644 --- a/packages/credentagent-gate/src/ceremony/mandate.ts +++ b/packages/credentagent-gate/src/ceremony/mandate.ts @@ -342,10 +342,17 @@ export async function checkDraw(intent: IntentBounds, draw: Draw, ctx: CheckDraw const verify = ctx.verify ?? verifyDrawEs256; const refusals: Refusal[] = []; + // 0. INTEGRITY: the intent's own fields must hash to its intentId. Content-addressing is + // the whole trust root — without recomputing it here, `intentId` is a bare string label, + // and a caller could keep a victim's id while swapping `delegate` / `maxAmount` / `merchants` + // and signing the draw with the substituted key (every check below would then run against + // bounds the user never approved). Recompute and refuse on mismatch. + if ((await contentAddressId(intent)) !== intent.intentId) refusals.push(refusal("bounds-tampered")); + // 1. binds to THIS intent if (draw.intentId !== intent.intentId) refusals.push(refusal("intent-mismatch")); - // 2. signed by the delegate key named in the (content-addressed) bounds + // 2. signed by the delegate key named in the (content-addressed, integrity-checked) bounds if (!(await verify(draw, intent.delegate))) refusals.push(refusal("signature")); // 3. currency diff --git a/packages/credentagent-gate/src/ceremony/refusals.ts b/packages/credentagent-gate/src/ceremony/refusals.ts index cfac2be..b3e0196 100644 --- a/packages/credentagent-gate/src/ceremony/refusals.ts +++ b/packages/credentagent-gate/src/ceremony/refusals.ts @@ -13,6 +13,7 @@ * log precisely; surfaces MAY coarsen (e.g. `not-yet-valid`/`expired` → "expired"). */ export type RefusalCode = | "signature" // draw not signed by the delegate key named in the bounds + | "bounds-tampered" // intent's fields don't hash to its own intentId — bounds mutated after sealing | "intent-mismatch" // draw binds to a different intentId | "currency-mismatch" | "over-cap" // per-draw cap (TS12 max_amount) — absolute ceiling, tolerance 0 diff --git a/packages/credentagent-gate/src/ceremony/revocation.test.ts b/packages/credentagent-gate/src/ceremony/revocation.test.ts new file mode 100644 index 0000000..8e952da --- /dev/null +++ b/packages/credentagent-gate/src/ceremony/revocation.test.ts @@ -0,0 +1,45 @@ +// RevocationStore — the fail-closed revocation + atomic single-use/cap ledger (005). +import { describe, it, expect } from "vitest"; +import { MemoryRevocationStore } from "./revocation.js"; + +describe("MemoryRevocationStore", () => { + it("revoke + kill-switch", () => { + const s = new MemoryRevocationStore(); + expect(s.isRevoked("int_1")).toBe(false); + s.revoke("int_1"); + expect(s.isRevoked("int_1")).toBe(true); + expect(s.isRevoked("int_2", "user_a")).toBe(false); + s.revokeSubject("user_a"); + expect(s.isRevoked("int_2", "user_a")).toBe(true); // subject kill-switch, any intent + expect(s.isRevoked("int_2", "user_b")).toBe(false); + }); + + it("single-use: a duplicate pspTransactionId is rejected (consumed)", () => { + const s = new MemoryRevocationStore(); + expect(s.commitDraw("int_1", { amount: 10, pspTransactionId: "tx" }, { totalAmount: 100 })).toEqual({ ok: true }); + expect(s.commitDraw("int_1", { amount: 10, pspTransactionId: "tx" }, { totalAmount: 100 })).toEqual({ ok: false, reason: "consumed" }); + }); + + it("ATOMIC cumulative cap (Codex P1): two DIFFERENT-txid draws that each pass a cap pre-check cannot both commit", () => { + const s = new MemoryRevocationStore(); + // Both draws are $30 against a $40 cumulative cap. checkDraw's non-atomic over-total + // pre-check would let each through when read against empty priorDraws (the concurrent + // race). The commit is the atomic backstop: the first wins, the second is refused + // over-total — the committed sum can never exceed totalAmount. + expect(s.commitDraw("int_1", { amount: 30, pspTransactionId: "a" }, { totalAmount: 40 })).toEqual({ ok: true }); + expect(s.commitDraw("int_1", { amount: 30, pspTransactionId: "b" }, { totalAmount: 40 })).toEqual({ ok: false, reason: "over-total" }); + // the ledger holds exactly the one committed draw + expect(s.priorDraws("int_1")).toEqual([{ amount: 30, pspTransactionId: "a" }]); + }); + + it("cap counts only committed draws, per intent", () => { + const s = new MemoryRevocationStore(); + s.commitDraw("int_1", { amount: 25, pspTransactionId: "a" }, { totalAmount: 40 }); + // a DIFFERENT intent has its own independent ledger + expect(s.commitDraw("int_2", { amount: 40, pspTransactionId: "z" }, { totalAmount: 40 })).toEqual({ ok: true }); + // back on int_1: 25 committed, 20 more would be 45 > 40 → refused + expect(s.commitDraw("int_1", { amount: 20, pspTransactionId: "b" }, { totalAmount: 40 })).toEqual({ ok: false, reason: "over-total" }); + // …but 15 more (40 total) is exactly the cap → allowed + expect(s.commitDraw("int_1", { amount: 15, pspTransactionId: "c" }, { totalAmount: 40 })).toEqual({ ok: true }); + }); +}); diff --git a/packages/credentagent-gate/src/ceremony/revocation.ts b/packages/credentagent-gate/src/ceremony/revocation.ts index b6757ba..37e75e4 100644 --- a/packages/credentagent-gate/src/ceremony/revocation.ts +++ b/packages/credentagent-gate/src/ceremony/revocation.ts @@ -3,27 +3,35 @@ // kill-switch. Consulted FAIL-CLOSED at the completion seam: a store that errors // refuses the draw (revocation-unavailable), never allows it. // -// `commitDraw` is the single-use / replay control and MUST be atomic -// (check-and-append): `completeOrder`'s idempotency is keyed by ORDER id, so two -// concurrent redemptions producing two order ids would otherwise both pass. The -// in-memory default is single-instance only (Node's single-threaded event loop -// makes the check-and-append atomic per tick); multi-instance deploys MUST inject -// a shared CAS-capable store (Redis SETNX/Lua), mirroring VerificationStore. +// `commitDraw` is the single-use/replay AND cumulative-cap control, and MUST be atomic: +// it makes BOTH decisions (unused pspTransactionId AND committed-sum + amount ≤ totalAmount) +// in one check-and-append. checkDraw's own `over-total` / `replay` gates are a fast pre-check, +// but they read `priorDraws` and commit separately — so two concurrent draws with different +// psp ids can both pass those gates and, without an atomic cap decision here, both commit and +// breach the cumulative cap. Likewise `completeOrder`'s idempotency is keyed by ORDER id, so +// two redemptions minting two order ids would both pass without the atomic per-intent consume. +// The in-memory default is single-instance only (Node's single-threaded event loop makes the +// check-and-append atomic per tick); multi-instance deploys MUST inject a shared CAS-capable +// store (Redis Lua doing both checks), mirroring VerificationStore. import type { MaybePromise } from "./types.js"; import type { CommittedDraw } from "./mandate.js"; +/** The atomic result: committed, or rejected for a specific, cap-or-single-use reason. */ +export type CommitResult = { ok: true } | { ok: false; reason: "consumed" | "over-total" }; + export interface RevocationStore { /** Is this grant (or its subject, via the kill-switch) revoked? */ isRevoked(intentId: string, subject?: string): MaybePromise; revoke(intentId: string): MaybePromise; /** Kill-switch: revoke every grant carrying this subject. */ revokeSubject(subject: string): MaybePromise; - /** Committed draws for the intent (feeds checkDraw's cumulative + replay gates). */ + /** Committed draws for the intent (feeds checkDraw's cumulative + replay pre-checks). */ priorDraws(intentId: string): MaybePromise; - /** Atomically commit a draw iff its pspTransactionId is unused for this intent. - * Returns false (commits nothing) on a duplicate — exactly one of N concurrent - * draws with one pspTransactionId may win. */ - commitDraw(intentId: string, draw: CommittedDraw): MaybePromise; + /** Atomically commit a draw iff (a) its pspTransactionId is unused for this intent AND + * (b) the already-committed sum + this amount does NOT exceed `totalAmount`. Both the + * single-use and the cumulative-cap decision are made here so concurrent draws cannot + * race past either. `consumed` = duplicate txid; `over-total` = would breach the cap. */ + commitDraw(intentId: string, draw: CommittedDraw, opts: { totalAmount: number }): MaybePromise; } export class MemoryRevocationStore implements RevocationStore { @@ -43,11 +51,13 @@ export class MemoryRevocationStore implements RevocationStore { priorDraws(intentId: string): CommittedDraw[] { return this.draws.get(intentId) ?? []; } - commitDraw(intentId: string, draw: CommittedDraw): boolean { + commitDraw(intentId: string, draw: CommittedDraw, opts: { totalAmount: number }): CommitResult { const list = this.draws.get(intentId) ?? []; - if (list.some((d) => d.pspTransactionId === draw.pspTransactionId)) return false; + if (list.some((d) => d.pspTransactionId === draw.pspTransactionId)) return { ok: false, reason: "consumed" }; + const spent = list.reduce((s, d) => s + d.amount, 0); + if (spent + draw.amount > opts.totalAmount) return { ok: false, reason: "over-total" }; list.push(draw); this.draws.set(intentId, list); - return true; + return { ok: true }; } } From bd3036971850a549450d130fe69d81a7e52d2945 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 17:11:27 -0700 Subject: [PATCH 10/24] =?UTF-8?q?fix(gate):=20CI=20typecheck=20=E2=80=94?= =?UTF-8?q?=20contentAddressId=20accepts=20object,=20not=20Record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IntentBounds (an interface) isn't assignable to Record under CI's strict tsconfig (no implicit index signature). Widen the param to `object` and cast internally for the destructure — the function already works on any object (sealIntent passes Omit). Signed-off-by: Diego Zuluaga --- packages/credentagent-gate/src/ceremony/mandate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/credentagent-gate/src/ceremony/mandate.ts b/packages/credentagent-gate/src/ceremony/mandate.ts index ed6ae16..d599730 100644 --- a/packages/credentagent-gate/src/ceremony/mandate.ts +++ b/packages/credentagent-gate/src/ceremony/mandate.ts @@ -271,8 +271,8 @@ export function canonical(value: unknown): string { /** intentId = "int_" + b64url(SHA-256(canonical(bounds \ intentId))) — no circularity, * and it transitively commits to EVERY other field. */ -export async function contentAddressId(bounds: Record): Promise { - const { intentId: _omit, ...rest } = bounds; +export async function contentAddressId(bounds: object): Promise { + const { intentId: _omit, ...rest } = bounds as Record; const digest = await subtle.digest("SHA-256", utf8.encode(canonical(rest))); return "int_" + b64url(digest); } From e1d56eb8e154a0337fbbad85ab9f375b4d1b3ec0 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 17:24:31 -0700 Subject: [PATCH 11/24] chore: re-trigger CI (dropped synchronize event on bd30369) Signed-off-by: Diego Zuluaga From c2e6d707aa52323ef131ef104bab0fe7e90f7435 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 17:25:07 -0700 Subject: [PATCH 12/24] =?UTF-8?q?docs(examples):=20clarify=20hnp-draws=20d?= =?UTF-8?q?emo=20=E2=80=94=20one=20intent,=20many=20draws;=20canonical=20m?= =?UTF-8?q?erchant=20ids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader confusion (2 real questions): (1) it looked like each tx_N might be a separate approval — now the demo states up front that all draws spend against ONE pre-approval, labels steps 1/2/3, and tags each line with its [tx_N]; (2) merchant 'starbucks' read like a display name — now blue-bottle.example / starbucks.example with a comment that scope ids are canonical machine ids matched exactly, never brand names. Signed-off-by: Diego Zuluaga --- examples/hnp-draws/demo.mjs | 67 ++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs index c3168ef..30151e4 100644 --- a/examples/hnp-draws/demo.mjs +++ b/examples/hnp-draws/demo.mjs @@ -1,7 +1,7 @@ // See the HNP "doorman" (PR #41) in action — no web page needed. -// Mints a pre-approval (Intent Mandate), then throws good + bad purchases (draws) -// at completeOrder and prints what the gate decides. Run: -// node examples/hnp-draws-demo.mjs +// The user pre-approves ONCE (one Intent Mandate). Then the agent makes several +// purchases (draws) AGAINST THAT ONE PRE-APPROVAL, and the gate decides each. Run: +// node examples/hnp-draws/demo.mjs import { sealIntent, generateDelegate, @@ -24,15 +24,21 @@ const catalog = { }, }; -// ── the user pre-approves ONCE (this is the phone ceremony, faked here) ─────── +// Merchant scope uses CANONICAL MACHINE IDS (a domain here), never display names. +// The scope check is an exact match, so "blue-bottle.example" is an identifier — not +// the brand "Blue Bottle" — and it must be identical on the mandate and every draw. +const BLUE_BOTTLE = "blue-bottle.example"; +const STARBUCKS = "starbucks.example"; + +// ── STEP 1: the user pre-approves ONCE (the phone ceremony, faked here) ─────── const { privateKey, delegate } = await generateDelegate(); const intent = await sealIntent({ type: "credentagent.IntentBounds/v0", - naturalLanguageDescription: "Reorder Blue Bottle coffee, up to $40, this month", - merchants: ["blue-bottle"], + naturalLanguageDescription: "Reorder Blue Bottle coffee, up to $40 total, this month", + merchants: [BLUE_BOTTLE], currency: "USD", - maxAmount: 40, - totalAmount: 40, + maxAmount: 40, // per-purchase ceiling + totalAmount: 40, // cumulative ceiling across ALL draws stepUpOver: 500, intentExpiry: "2026-07-31T23:59:59Z", delegate, @@ -40,33 +46,40 @@ const intent = await sealIntent({ presence: "delegated-demo", trust_level: "server-issued-demo", }); -console.log(`\n🎫 Pre-approval minted: "${intent.naturalLanguageDescription}"`); -console.log(` id ${intent.intentId.slice(0, 22)}… · presence=${intent.presence} trust=${intent.trust_level}\n`); +console.log(`\n🎫 ONE pre-approval minted (an Intent Mandate):`); +console.log(` "${intent.naturalLanguageDescription}"`); +console.log(` id ${intent.intentId.slice(0, 22)}… · presence=${intent.presence} trust=${intent.trust_level}`); +console.log(`\n Every purchase below is a DRAW against this one pre-approval —`); +console.log(` tx_1, tx_2, … are transaction ids (like check numbers), not separate approvals.\n`); -// shared server-side state (revocations + single-use ledger, per-order verification) +// shared server-side state (revocation + single-use ledger, per-order verification). +// ONE store for the whole run, so draws see each other's history. const revocation = new MemoryRevocationStore(); const verificationStore = new MemoryVerificationStore(); const records = new Map(); const ctx = { catalog, verificationStore, revocation, records: { read: (id) => records.get(id), write: (r) => records.set(r.orderId, r) } }; let n = 0; -async function attempt(label, { items, merchant, amount, pspTransactionId, orderId }) { - const order = catalog.createOrder(items, orderId ?? `ORD-${++n}`); - const draw = await signDraw({ type: "credentagent.Draw/v0", intentId: intent.intentId, paymentMandateId: `d${n}`, merchant, amount, currency: "USD", pspTransactionId }, privateKey); - const res = await completeOrder({ order, mandateId: draw.paymentMandateId, amount, currency: "USD", method: "delegated", gates: [{ gate: "draw", pass: true, detail: "" }], draw: { intent, draw } }, ctx); - if (res.completed) console.log(`✅ ${label}\n → COMPLETED, logged delegationId ${res.delegationId.slice(0, 18)}… (no real money moved)\n`); - else console.log(`⛔ ${label}\n → REFUSED: ${res.refusals.map((r) => r.code).join(", ")}\n`); +// Each call is ONE draw against the shared `intent` above. +async function draw(label, { items, merchant, amount, pspTransactionId }) { + const order = catalog.createOrder(items, `ORD-${++n}`); + const signed = await signDraw({ type: "credentagent.Draw/v0", intentId: intent.intentId, paymentMandateId: `d${n}`, merchant, amount, currency: "USD", pspTransactionId }, privateKey); + const res = await completeOrder({ order, mandateId: signed.paymentMandateId, amount, currency: "USD", method: "delegated", gates: [{ gate: "draw", pass: true, detail: "" }], draw: { intent, draw: signed } }, ctx); + const tag = `[${pspTransactionId}] ${label}`; + if (res.completed) console.log(`✅ ${tag}\n → COMPLETED (delegationId ${res.delegationId.slice(0, 18)}… · no real money moved)\n`); + else console.log(`⛔ ${tag}\n → REFUSED: ${res.refusals.map((r) => r.code).join(", ")}\n`); } -console.log("─".repeat(64)); -await attempt("Agent reorders 1 bag of Blue Bottle coffee ($18)", { items: [{ productId: "coffee", quantity: 1 }], merchant: "blue-bottle", amount: 18, pspTransactionId: "tx_1" }); -await attempt("Agent tries to reuse the SAME transaction (double-spend)", { items: [{ productId: "coffee", quantity: 1 }], merchant: "blue-bottle", amount: 18, pspTransactionId: "tx_1" }); -await attempt("Agent tries a $54 cart — 3 bags, over the $40 cap", { items: [{ productId: "coffee", quantity: 3 }], merchant: "blue-bottle", amount: 54, pspTransactionId: "tx_2" }); -await attempt("Agent tries a DIFFERENT store (Starbucks)", { items: [{ productId: "coffee", quantity: 1 }], merchant: "starbucks", amount: 18, pspTransactionId: "tx_3" }); -await attempt("Agent tries to buy WINE on the coffee pre-approval", { items: [{ productId: "wine", quantity: 1 }], merchant: "blue-bottle", amount: 20, pspTransactionId: "tx_4" }); +console.log("─".repeat(70)); +console.log("STEP 2: the agent makes draws against that ONE pre-approval\n"); +await draw("reorder 1 bag of coffee ($18) from blue-bottle", { items: [{ productId: "coffee", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 18, pspTransactionId: "tx_1" }); +await draw("re-submit tx_1 — the SAME transaction again (double-spend)", { items: [{ productId: "coffee", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 18, pspTransactionId: "tx_1" }); +await draw("a $54 cart — 3 bags — over the $40 cap", { items: [{ productId: "coffee", quantity: 3 }], merchant: BLUE_BOTTLE, amount: 54, pspTransactionId: "tx_2" }); +await draw("a purchase at a DIFFERENT store (starbucks, not approved)", { items: [{ productId: "coffee", quantity: 1 }], merchant: STARBUCKS, amount: 18, pspTransactionId: "tx_3" }); +await draw("buy WINE (age-restricted) on this coffee pre-approval", { items: [{ productId: "wine", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 20, pspTransactionId: "tx_4" }); -console.log("🔴 You revoke the pre-approval from your phone…\n"); +console.log("🔴 STEP 3: you revoke the pre-approval from your phone…\n"); revocation.revoke(intent.intentId); -await attempt("Agent tries another coffee reorder after revocation", { items: [{ productId: "coffee", quantity: 1 }], merchant: "blue-bottle", amount: 18, pspTransactionId: "tx_5" }); -console.log("─".repeat(64)); -console.log("\nThe doorman: 1 legit purchase through, 5 refused with reasons, 0 real money moved.\n"); +await draw("another coffee reorder, after revocation", { items: [{ productId: "coffee", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 18, pspTransactionId: "tx_5" }); +console.log("─".repeat(70)); +console.log("\nThe doorman: 1 legit draw through, 5 refused with reasons, 0 real money moved.\n"); From ef637b4811bf90465a7db6cd527c16643a3258c5 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 17:43:19 -0700 Subject: [PATCH 13/24] build: add build:packages script alias (the name ~20 docs + examples already use) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The examples READMEs, getting-started, PUBLISHING, and the 001-003 specs all say 'npm run build:packages' (carried from the demo repo), but this library's root package.json only had 'build' — so the documented command errored with 'Missing script'. Add build:packages as an alias (identical to build; this repo is packages-only, no app) so every doc's command works as written. Signed-off-by: Diego Zuluaga --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 3710ae4..b0f4b97 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ ], "scripts": { "build": "npm run build --workspaces --if-present", + "build:packages": "npm run build --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present", "test": "vitest run" }, From 78f2c4fb2bf16072bb0415bd46663255945fe492 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 17:48:04 -0700 Subject: [PATCH 14/24] =?UTF-8?q?docs(examples):=20show=20the=20mandate's?= =?UTF-8?q?=20journey=20=E2=80=94=20agent=20HOLDS=20it,=20gate=20re-checks?= =?UTF-8?q?=20each=20draw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader confusion: the mandate seemed to vanish after sealIntent(). Now the demo models the three parties as explicit objects — you (mint) → agent { holds: mandate } → gate { ledger, no mandate } — and each purchase builds presented = { intent: agent.holds, draw } and calls gate.receiveDraw(), so where the mandate 'goes' is visible: the agent carries it and re-presents it every draw; the gate stores only the revocation/consume ledger and re-verifies from scratch. Revocation flips the gate's ledger while the agent still holds the mandate — the stateless-grant model, made visible. Signed-off-by: Diego Zuluaga --- examples/hnp-draws/demo.mjs | 104 ++++++++++++++++++++++-------------- 1 file changed, 65 insertions(+), 39 deletions(-) diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs index 30151e4..70c30f3 100644 --- a/examples/hnp-draws/demo.mjs +++ b/examples/hnp-draws/demo.mjs @@ -1,6 +1,13 @@ // See the HNP "doorman" (PR #41) in action — no web page needed. -// The user pre-approves ONCE (one Intent Mandate). Then the agent makes several -// purchases (draws) AGAINST THAT ONE PRE-APPROVAL, and the gate decides each. Run: +// +// Follow the pre-approval's JOURNEY between three parties: +// 1. YOU (phone) — pre-approve once, then hand the mandate to your agent. +// 2. YOUR AGENT — HOLDS the mandate; later signs draws and presents them. +// 3. THE GATE (server) — stores NO mandate; re-checks each presented draw and decides. +// +// Key idea: the gate never keeps the mandate. The agent carries it and re-presents it +// (mandate + a freshly signed draw) on every purchase; the gate only keeps a small ledger +// (what's been revoked / already drawn) and re-verifies from scratch each time. // node examples/hnp-draws/demo.mjs import { sealIntent, @@ -23,22 +30,37 @@ const catalog = { return { id: orderId, lines, itemCount: items.length, subtotal: total, discount: 0, total, currency: "USD", createdAt: new Date().toISOString() }; }, }; - -// Merchant scope uses CANONICAL MACHINE IDS (a domain here), never display names. -// The scope check is an exact match, so "blue-bottle.example" is an identifier — not -// the brand "Blue Bottle" — and it must be identical on the mandate and every draw. +// Merchant scope uses CANONICAL MACHINE IDS (a domain here), never display names — the +// scope check is an exact match, so this is an identifier, not the brand "Blue Bottle". const BLUE_BOTTLE = "blue-bottle.example"; const STARBUCKS = "starbucks.example"; -// ── STEP 1: the user pre-approves ONCE (the phone ceremony, faked here) ─────── +// ═════════════════════════════════════════════════════════════════════════════ +// PARTY 3 — THE GATE (the merchant's server). It holds a LEDGER (revocation + +// single-use + per-order state) and the catalog — but NOT the mandate itself. +// ═════════════════════════════════════════════════════════════════════════════ +const gate = { + ledger: new MemoryRevocationStore(), // what's revoked / already drawn — the only state it keeps + ctx: null, +}; +gate.ctx = { catalog, verificationStore: new MemoryVerificationStore(), revocation: gate.ledger, records: new Map() }; +gate.ctx.records = { store: new Map(), read(id) { return this.store.get(id); }, write(r) { this.store.set(r.orderId, r); } }; +// The gate's one job: given a presented (mandate + signed draw), re-check and decide. +gate.receiveDraw = async (order, amount, presented) => + completeOrder({ order, mandateId: presented.draw.paymentMandateId, amount, currency: "USD", method: "delegated", gates: [{ gate: "draw", pass: true, detail: "" }], draw: presented }, gate.ctx); + +// ═════════════════════════════════════════════════════════════════════════════ +// STEP 1 — YOU pre-approve once (the phone ceremony, faked here), then HAND the +// mandate to your agent. After this line you can go to sleep. +// ═════════════════════════════════════════════════════════════════════════════ const { privateKey, delegate } = await generateDelegate(); -const intent = await sealIntent({ +const mandate = await sealIntent({ type: "credentagent.IntentBounds/v0", naturalLanguageDescription: "Reorder Blue Bottle coffee, up to $40 total, this month", merchants: [BLUE_BOTTLE], currency: "USD", - maxAmount: 40, // per-purchase ceiling - totalAmount: 40, // cumulative ceiling across ALL draws + maxAmount: 40, + totalAmount: 40, stepUpOver: 500, intentExpiry: "2026-07-31T23:59:59Z", delegate, @@ -46,40 +68,44 @@ const intent = await sealIntent({ presence: "delegated-demo", trust_level: "server-issued-demo", }); -console.log(`\n🎫 ONE pre-approval minted (an Intent Mandate):`); -console.log(` "${intent.naturalLanguageDescription}"`); -console.log(` id ${intent.intentId.slice(0, 22)}… · presence=${intent.presence} trust=${intent.trust_level}`); -console.log(`\n Every purchase below is a DRAW against this one pre-approval —`); -console.log(` tx_1, tx_2, … are transaction ids (like check numbers), not separate approvals.\n`); -// shared server-side state (revocation + single-use ledger, per-order verification). -// ONE store for the whole run, so draws see each other's history. -const revocation = new MemoryRevocationStore(); -const verificationStore = new MemoryVerificationStore(); -const records = new Map(); -const ctx = { catalog, verificationStore, revocation, records: { read: (id) => records.get(id), write: (r) => records.set(r.orderId, r) } }; +// PARTY 2 — YOUR AGENT. It now HOLDS the mandate (+ the delegate key to sign draws). +// This is where the mandate "went": onto the agent, which carries it from here on. +const agent = { holds: mandate, key: privateKey }; + +console.log(`\n🎫 STEP 1 — YOU minted ONE pre-approval and handed it to your agent:`); +console.log(` "${mandate.naturalLanguageDescription}"`); +console.log(` id ${mandate.intentId.slice(0, 22)}… · presence=${mandate.presence} trust=${mandate.trust_level}`); +console.log(` → the AGENT now holds it. The GATE holds nothing yet. You can go to sleep 😴\n`); +// The agent makes ONE draw: it signs a purchase referencing the mandate it holds, then +// PRESENTS (its held mandate + the signed draw) to the gate. tx ids are per-purchase. let n = 0; -// Each call is ONE draw against the shared `intent` above. -async function draw(label, { items, merchant, amount, pspTransactionId }) { +async function agentBuys(label, { items, merchant, amount, pspTransactionId }) { const order = catalog.createOrder(items, `ORD-${++n}`); - const signed = await signDraw({ type: "credentagent.Draw/v0", intentId: intent.intentId, paymentMandateId: `d${n}`, merchant, amount, currency: "USD", pspTransactionId }, privateKey); - const res = await completeOrder({ order, mandateId: signed.paymentMandateId, amount, currency: "USD", method: "delegated", gates: [{ gate: "draw", pass: true, detail: "" }], draw: { intent, draw: signed } }, ctx); + const signed = await signDraw( + { type: "credentagent.Draw/v0", intentId: agent.holds.intentId, paymentMandateId: `d${n}`, merchant, amount, currency: "USD", pspTransactionId }, + agent.key, + ); + // ↓↓↓ the mandate travels here — the agent PRESENTS { its mandate, this draw } to the gate. + const presented = { intent: agent.holds, draw: signed }; + const res = await gate.receiveDraw(order, amount, presented); const tag = `[${pspTransactionId}] ${label}`; - if (res.completed) console.log(`✅ ${tag}\n → COMPLETED (delegationId ${res.delegationId.slice(0, 18)}… · no real money moved)\n`); - else console.log(`⛔ ${tag}\n → REFUSED: ${res.refusals.map((r) => r.code).join(", ")}\n`); + if (res.completed) console.log(`✅ ${tag}\n → gate COMPLETED it (delegationId ${res.delegationId.slice(0, 16)}… · no real money moved)\n`); + else console.log(`⛔ ${tag}\n → gate REFUSED it: ${res.refusals.map((r) => r.code).join(", ")}\n`); } -console.log("─".repeat(70)); -console.log("STEP 2: the agent makes draws against that ONE pre-approval\n"); -await draw("reorder 1 bag of coffee ($18) from blue-bottle", { items: [{ productId: "coffee", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 18, pspTransactionId: "tx_1" }); -await draw("re-submit tx_1 — the SAME transaction again (double-spend)", { items: [{ productId: "coffee", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 18, pspTransactionId: "tx_1" }); -await draw("a $54 cart — 3 bags — over the $40 cap", { items: [{ productId: "coffee", quantity: 3 }], merchant: BLUE_BOTTLE, amount: 54, pspTransactionId: "tx_2" }); -await draw("a purchase at a DIFFERENT store (starbucks, not approved)", { items: [{ productId: "coffee", quantity: 1 }], merchant: STARBUCKS, amount: 18, pspTransactionId: "tx_3" }); -await draw("buy WINE (age-restricted) on this coffee pre-approval", { items: [{ productId: "wine", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 20, pspTransactionId: "tx_4" }); +console.log("─".repeat(72)); +console.log("STEP 2 — while you're away, the AGENT presents draws; the GATE re-checks each\n"); +await agentBuys("reorder 1 bag of coffee ($18) from blue-bottle", { items: [{ productId: "coffee", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 18, pspTransactionId: "tx_1" }); +await agentBuys("re-submit tx_1 — the SAME transaction again (double-spend)", { items: [{ productId: "coffee", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 18, pspTransactionId: "tx_1" }); +await agentBuys("a $54 cart — 3 bags — over the $40 cap", { items: [{ productId: "coffee", quantity: 3 }], merchant: BLUE_BOTTLE, amount: 54, pspTransactionId: "tx_2" }); +await agentBuys("a purchase at a DIFFERENT store (starbucks, not approved)", { items: [{ productId: "coffee", quantity: 1 }], merchant: STARBUCKS, amount: 18, pspTransactionId: "tx_3" }); +await agentBuys("buy WINE (age-restricted) on this coffee pre-approval", { items: [{ productId: "wine", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 20, pspTransactionId: "tx_4" }); -console.log("🔴 STEP 3: you revoke the pre-approval from your phone…\n"); -revocation.revoke(intent.intentId); -await draw("another coffee reorder, after revocation", { items: [{ productId: "coffee", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 18, pspTransactionId: "tx_5" }); -console.log("─".repeat(70)); -console.log("\nThe doorman: 1 legit draw through, 5 refused with reasons, 0 real money moved.\n"); +console.log("🔴 STEP 3 — you revoke the pre-approval from your phone (the gate's ledger flips)…\n"); +gate.ledger.revoke(agent.holds.intentId); // the agent still HOLDS the mandate — but the gate now refuses it +await agentBuys("another coffee reorder, after revocation", { items: [{ productId: "coffee", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 18, pspTransactionId: "tx_5" }); +console.log("─".repeat(72)); +console.log("\nThe agent kept holding the mandate the whole time — the GATE decided every draw."); +console.log("1 legit draw through, 5 refused with reasons, 0 real money moved.\n"); From ce625f25166c6251c880de561e94f07e5de69786 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 18:09:07 -0700 Subject: [PATCH 15/24] docs(examples): simplify hnp-draws demo to the 'huh, it's easy' bar (council-reviewed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 5-persona council scored the prior demo 4/10 (unanimous) and diagnosed three complexity leaks: tx-id + quantity + amount bookkeeping the reader must track; hand-restated prices; and limits tuned so a bad case tripped two rules at once (jargon-soup output). Fix (judge synthesis): story-first layout — the whole point reads top-to-bottom in ~18 lines via preApprove() + agent.buy(), with the real API wiring pushed below a PLUMBING banner and refusal codes humanized so each line prints ONE plain-English reason. Still runs against the real exports. Signed-off-by: Diego Zuluaga --- examples/hnp-draws/demo.mjs | 190 +++++++++++++++++++----------------- 1 file changed, 100 insertions(+), 90 deletions(-) diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs index 70c30f3..4873603 100644 --- a/examples/hnp-draws/demo.mjs +++ b/examples/hnp-draws/demo.mjs @@ -1,111 +1,121 @@ -// See the HNP "doorman" (PR #41) in action — no web page needed. +// CredentAgent — a pre-approval your AI agent can spend against, but can't abuse. // -// Follow the pre-approval's JOURNEY between three parties: -// 1. YOU (phone) — pre-approve once, then hand the mandate to your agent. -// 2. YOUR AGENT — HOLDS the mandate; later signs draws and presents them. -// 3. THE GATE (server) — stores NO mandate; re-checks each presented draw and decides. +// You approve ONCE on your phone ("reorder my coffee, up to $30 an order"). Your +// agent then shops on its own while you sleep — but it never holds a blank cheque: +// the gate re-checks EVERY purchase against your limits and refuses any that breaks +// them, in plain English. Change your mind? Revoke, and the next one dies. // -// Key idea: the gate never keeps the mandate. The agent carries it and re-presents it -// (mandate + a freshly signed draw) on every purchase; the gate only keeps a small ledger -// (what's been revoked / already drawn) and re-verifies from scratch each time. -// node examples/hnp-draws/demo.mjs +// It's a demo grant — no real money moves. Run it: node examples/hnp-draws/demo.mjs + +// ═════════════════════════════════════════════════════════════════════════════ +// THE WHOLE POINT — reads top-to-bottom like plain English. (Plumbing is below.) +// ═════════════════════════════════════════════════════════════════════════════ +async function story() { + // 1 — Pre-approve once, in plain words. Then go to sleep. 😴 + const agent = await preApprove("Reorder coffee from Blue Bottle — up to $30 an order.", { + store: BLUE_BOTTLE, + perOrder: 30, // never a single order over $30 + perMonth: 100, // ...and no more than $100 all month + }); + + // 2 — Your agent shops on its own. The gate re-checks each purchase: + await agent.buy("tx1", "1 coffee"); // ✅ within your rules + await agent.buy("tx1", "1 coffee"); // ⛔ the SAME charge, again + await agent.buy("tx2", "3 coffee"); // ⛔ $54 — blows your $30 cap + await agent.buy("tx3", "1 coffee", { at: STARBUCKS }); // ⛔ a store you never approved + await agent.buy("tx4", "1 wine"); // ⛔ age-restricted + + // 3 — You change your mind and revoke from your phone. + agent.revoke(); + await agent.buy("tx5", "1 coffee"); // ⛔ too late — the grant is dead +} + +// ═════════════════════════════════════════════════════════════════════════════ +// PLUMBING — the real @openmobilehub/credentagent-gate API, wired once. Skip on a +// first read: the two helpers the story uses (preApprove + buy) are defined here. +// ═════════════════════════════════════════════════════════════════════════════ import { - sealIntent, - generateDelegate, - signDraw, - completeOrder, - MemoryRevocationStore, - MemoryVerificationStore, + sealIntent, generateDelegate, signDraw, completeOrder, + MemoryRevocationStore, MemoryVerificationStore, } from "@openmobilehub/credentagent-gate"; -// ── a tiny catalog: coffee $18, wine $20 (age-restricted) ──────────────────── -const PRODUCTS = { coffee: { price: 18 }, wine: { price: 20, minimumAge: 21 } }; +// A pretend shop. Merchant scope matches on a machine id (a domain), not a brand name. +const BLUE_BOTTLE = "blue-bottle.example"; +const STARBUCKS = "starbucks.example"; +const PRICES = { coffee: { price: 18 }, wine: { price: 20, minimumAge: 21 } }; const catalog = { createOrder(items, orderId) { - const lines = items.map((it) => { - const p = PRODUCTS[it.productId]; - return { id: it.productId, name: it.productId, unitPrice: p.price, currency: "USD", quantity: it.quantity, lineTotal: p.price * it.quantity, ...(p.minimumAge ? { minimumAge: p.minimumAge } : {}) }; + const lines = items.map(({ productId, quantity }) => { + const p = PRICES[productId]; + return { id: productId, name: productId, unitPrice: p.price, currency: "USD", quantity, + lineTotal: p.price * quantity, ...(p.minimumAge ? { minimumAge: p.minimumAge } : {}) }; }); const total = lines.reduce((s, l) => s + l.lineTotal, 0); return { id: orderId, lines, itemCount: items.length, subtotal: total, discount: 0, total, currency: "USD", createdAt: new Date().toISOString() }; }, }; -// Merchant scope uses CANONICAL MACHINE IDS (a domain here), never display names — the -// scope check is an exact match, so this is an identifier, not the brand "Blue Bottle". -const BLUE_BOTTLE = "blue-bottle.example"; -const STARBUCKS = "starbucks.example"; -// ═════════════════════════════════════════════════════════════════════════════ -// PARTY 3 — THE GATE (the merchant's server). It holds a LEDGER (revocation + -// single-use + per-order state) and the catalog — but NOT the mandate itself. -// ═════════════════════════════════════════════════════════════════════════════ +// The gate keeps only a small ledger (what's revoked / already drawn) + per-order state. +// It does NOT store your pre-approval — the agent re-presents it on every single purchase. +const ledger = new MemoryRevocationStore(); +const records = new Map(); const gate = { - ledger: new MemoryRevocationStore(), // what's revoked / already drawn — the only state it keeps - ctx: null, + catalog, + revocation: ledger, + verificationStore: new MemoryVerificationStore(), + records: { read: (id) => records.get(id), write: (r) => records.set(r.orderId, r) }, }; -gate.ctx = { catalog, verificationStore: new MemoryVerificationStore(), revocation: gate.ledger, records: new Map() }; -gate.ctx.records = { store: new Map(), read(id) { return this.store.get(id); }, write(r) { this.store.set(r.orderId, r); } }; -// The gate's one job: given a presented (mandate + signed draw), re-check and decide. -gate.receiveDraw = async (order, amount, presented) => - completeOrder({ order, mandateId: presented.draw.paymentMandateId, amount, currency: "USD", method: "delegated", gates: [{ gate: "draw", pass: true, detail: "" }], draw: presented }, gate.ctx); -// ═════════════════════════════════════════════════════════════════════════════ -// STEP 1 — YOU pre-approve once (the phone ceremony, faked here), then HAND the -// mandate to your agent. After this line you can go to sleep. -// ═════════════════════════════════════════════════════════════════════════════ -const { privateKey, delegate } = await generateDelegate(); -const mandate = await sealIntent({ - type: "credentagent.IntentBounds/v0", - naturalLanguageDescription: "Reorder Blue Bottle coffee, up to $40 total, this month", - merchants: [BLUE_BOTTLE], - currency: "USD", - maxAmount: 40, - totalAmount: 40, - stepUpOver: 500, - intentExpiry: "2026-07-31T23:59:59Z", - delegate, - mayPresent: [], - presence: "delegated-demo", - trust_level: "server-issued-demo", -}); - -// PARTY 2 — YOUR AGENT. It now HOLDS the mandate (+ the delegate key to sign draws). -// This is where the mandate "went": onto the agent, which carries it from here on. -const agent = { holds: mandate, key: privateKey }; +// The gate returns terse codes; we humanize them so the output reads like the intent. +const WHY = { + replay: "the same charge, twice", + "over-cap": "over your per-order cap", + "over-total": "over your monthly cap", + "out-of-scope": "a store you never approved", + "step-up": "age-restricted — needs you there in person", + revoked: "you revoked this grant", +}; +const tally = { ok: 0, no: 0 }; +let orderSeq = 0; -console.log(`\n🎫 STEP 1 — YOU minted ONE pre-approval and handed it to your agent:`); -console.log(` "${mandate.naturalLanguageDescription}"`); -console.log(` id ${mandate.intentId.slice(0, 22)}… · presence=${mandate.presence} trust=${mandate.trust_level}`); -console.log(` → the AGENT now holds it. The GATE holds nothing yet. You can go to sleep 😴\n`); +// preApprove — mint ONE grant (your intent + a delegate key), hand it to your agent. +async function preApprove(sentence, { store, perOrder, perMonth }) { + const { privateKey, delegate } = await generateDelegate(); + const mandate = await sealIntent({ + type: "credentagent.IntentBounds/v0", + naturalLanguageDescription: sentence, + merchants: [store], currency: "USD", + maxAmount: perOrder, totalAmount: perMonth, + delegate, mayPresent: [], + presence: "delegated-demo", trust_level: "server-issued-demo", + }); + console.log(`\n🎫 You pre-approved once: “${sentence}”`); + console.log(` Your agent now holds it; the gate stores nothing. Off to sleep. 😴\n`); + return { + buy: (tx, spec, opts) => buy(mandate, privateKey, tx, spec, opts), + revoke: () => { ledger.revoke(mandate.intentId); console.log(`\n🔴 You revoked the grant from your phone.\n`); }, + }; +} -// The agent makes ONE draw: it signs a purchase referencing the mandate it holds, then -// PRESENTS (its held mandate + the signed draw) to the gate. tx ids are per-purchase. -let n = 0; -async function agentBuys(label, { items, merchant, amount, pspTransactionId }) { - const order = catalog.createOrder(items, `ORD-${++n}`); - const signed = await signDraw( - { type: "credentagent.Draw/v0", intentId: agent.holds.intentId, paymentMandateId: `d${n}`, merchant, amount, currency: "USD", pspTransactionId }, - agent.key, +// buy — your agent signs ONE draw against the grant it holds, presents { grant, draw } +// to the gate, and we print the verdict. Amount is derived from the catalog, never trusted. +async function buy(mandate, key, tx, spec, { at = mandate.merchants[0] } = {}) { + const [qty, product] = spec.split(" "); + const order = catalog.createOrder([{ productId: product, quantity: Number(qty) }], `ORD-${++orderSeq}`); + const draw = await signDraw( + { type: "credentagent.Draw/v0", intentId: mandate.intentId, paymentMandateId: tx, + merchant: at, amount: order.total, currency: "USD", pspTransactionId: tx }, + key, ); - // ↓↓↓ the mandate travels here — the agent PRESENTS { its mandate, this draw } to the gate. - const presented = { intent: agent.holds, draw: signed }; - const res = await gate.receiveDraw(order, amount, presented); - const tag = `[${pspTransactionId}] ${label}`; - if (res.completed) console.log(`✅ ${tag}\n → gate COMPLETED it (delegationId ${res.delegationId.slice(0, 16)}… · no real money moved)\n`); - else console.log(`⛔ ${tag}\n → gate REFUSED it: ${res.refusals.map((r) => r.code).join(", ")}\n`); + const res = await completeOrder( + { order, mandateId: tx, amount: order.total, currency: "USD", method: "delegated", + gates: [{ gate: "draw", pass: true, detail: "" }], draw: { intent: mandate, draw } }, + gate, + ); + const what = `${spec} @ ${at.split(".")[0]}`.padEnd(22); + if (res.completed) { tally.ok++; console.log(` ✅ ${tx} ${what} approved — $${order.total} (no real money moved)`); } + else { tally.no++; const code = res.refusals[0].code; console.log(` ⛔ ${tx} ${what} refused — ${WHY[code] ?? code}`); } } -console.log("─".repeat(72)); -console.log("STEP 2 — while you're away, the AGENT presents draws; the GATE re-checks each\n"); -await agentBuys("reorder 1 bag of coffee ($18) from blue-bottle", { items: [{ productId: "coffee", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 18, pspTransactionId: "tx_1" }); -await agentBuys("re-submit tx_1 — the SAME transaction again (double-spend)", { items: [{ productId: "coffee", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 18, pspTransactionId: "tx_1" }); -await agentBuys("a $54 cart — 3 bags — over the $40 cap", { items: [{ productId: "coffee", quantity: 3 }], merchant: BLUE_BOTTLE, amount: 54, pspTransactionId: "tx_2" }); -await agentBuys("a purchase at a DIFFERENT store (starbucks, not approved)", { items: [{ productId: "coffee", quantity: 1 }], merchant: STARBUCKS, amount: 18, pspTransactionId: "tx_3" }); -await agentBuys("buy WINE (age-restricted) on this coffee pre-approval", { items: [{ productId: "wine", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 20, pspTransactionId: "tx_4" }); - -console.log("🔴 STEP 3 — you revoke the pre-approval from your phone (the gate's ledger flips)…\n"); -gate.ledger.revoke(agent.holds.intentId); // the agent still HOLDS the mandate — but the gate now refuses it -await agentBuys("another coffee reorder, after revocation", { items: [{ productId: "coffee", quantity: 1 }], merchant: BLUE_BOTTLE, amount: 18, pspTransactionId: "tx_5" }); -console.log("─".repeat(72)); -console.log("\nThe agent kept holding the mandate the whole time — the GATE decided every draw."); -console.log("1 legit draw through, 5 refused with reasons, 0 real money moved.\n"); +await story(); +console.log(`\n ${tally.ok} purchase through · ${tally.no} refused, each with a reason · $0 real money moved.\n`); From 90ea929a24b37e91b48a375b573ba50eb632afdc Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 18:18:48 -0700 Subject: [PATCH 16/24] docs(examples): buy() takes named parameters, not positional string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader feedback: agent.buy("tx1", "1 coffee") forced decoding positional args plus a split string. Now buy({ charge, item, quantity=1, store=approved }) — every field self-labels: quantity:3 / store:STARBUCKS / item:'wine' read at a glance, the double-spend is visible because charge:'c1' appears twice, and the normal line stays short via defaults. Signed-off-by: Diego Zuluaga --- examples/hnp-draws/demo.mjs | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs index 4873603..6e5f6a5 100644 --- a/examples/hnp-draws/demo.mjs +++ b/examples/hnp-draws/demo.mjs @@ -18,16 +18,17 @@ async function story() { perMonth: 100, // ...and no more than $100 all month }); - // 2 — Your agent shops on its own. The gate re-checks each purchase: - await agent.buy("tx1", "1 coffee"); // ✅ within your rules - await agent.buy("tx1", "1 coffee"); // ⛔ the SAME charge, again - await agent.buy("tx2", "3 coffee"); // ⛔ $54 — blows your $30 cap - await agent.buy("tx3", "1 coffee", { at: STARBUCKS }); // ⛔ a store you never approved - await agent.buy("tx4", "1 wine"); // ⛔ age-restricted + // 2 — Your agent shops on its own. Every field is named, so each line says what it is. + // The gate re-checks each purchase against your limits: + await agent.buy({ charge: "c1", item: "coffee" }); // ✅ within your rules + await agent.buy({ charge: "c1", item: "coffee" }); // ⛔ same charge id — sent twice + await agent.buy({ charge: "c2", item: "coffee", quantity: 3 }); // ⛔ $54 — over your $30 cap + await agent.buy({ charge: "c3", item: "coffee", store: STARBUCKS }); // ⛔ a store you never approved + await agent.buy({ charge: "c4", item: "wine" }); // ⛔ age-restricted // 3 — You change your mind and revoke from your phone. agent.revoke(); - await agent.buy("tx5", "1 coffee"); // ⛔ too late — the grant is dead + await agent.buy({ charge: "c5", item: "coffee" }); // ⛔ too late — the grant is dead } // ═════════════════════════════════════════════════════════════════════════════ @@ -92,29 +93,29 @@ async function preApprove(sentence, { store, perOrder, perMonth }) { console.log(`\n🎫 You pre-approved once: “${sentence}”`); console.log(` Your agent now holds it; the gate stores nothing. Off to sleep. 😴\n`); return { - buy: (tx, spec, opts) => buy(mandate, privateKey, tx, spec, opts), + buy: (purchase) => buy(mandate, privateKey, purchase), revoke: () => { ledger.revoke(mandate.intentId); console.log(`\n🔴 You revoked the grant from your phone.\n`); }, }; } // buy — your agent signs ONE draw against the grant it holds, presents { grant, draw } // to the gate, and we print the verdict. Amount is derived from the catalog, never trusted. -async function buy(mandate, key, tx, spec, { at = mandate.merchants[0] } = {}) { - const [qty, product] = spec.split(" "); - const order = catalog.createOrder([{ productId: product, quantity: Number(qty) }], `ORD-${++orderSeq}`); +// `charge` is the payment's transaction id: reuse one and the gate sees a double-spend. +async function buy(mandate, key, { charge, item, quantity = 1, store = mandate.merchants[0] }) { + const order = catalog.createOrder([{ productId: item, quantity }], `ORD-${++orderSeq}`); const draw = await signDraw( - { type: "credentagent.Draw/v0", intentId: mandate.intentId, paymentMandateId: tx, - merchant: at, amount: order.total, currency: "USD", pspTransactionId: tx }, + { type: "credentagent.Draw/v0", intentId: mandate.intentId, paymentMandateId: charge, + merchant: store, amount: order.total, currency: "USD", pspTransactionId: charge }, key, ); const res = await completeOrder( - { order, mandateId: tx, amount: order.total, currency: "USD", method: "delegated", + { order, mandateId: charge, amount: order.total, currency: "USD", method: "delegated", gates: [{ gate: "draw", pass: true, detail: "" }], draw: { intent: mandate, draw } }, gate, ); - const what = `${spec} @ ${at.split(".")[0]}`.padEnd(22); - if (res.completed) { tally.ok++; console.log(` ✅ ${tx} ${what} approved — $${order.total} (no real money moved)`); } - else { tally.no++; const code = res.refusals[0].code; console.log(` ⛔ ${tx} ${what} refused — ${WHY[code] ?? code}`); } + const what = `${quantity} ${item} @ ${store.split(".")[0]}`.padEnd(22); + if (res.completed) { tally.ok++; console.log(` ✅ ${charge} ${what} approved — $${order.total} (no real money moved)`); } + else { tally.no++; const code = res.refusals[0].code; console.log(` ⛔ ${charge} ${what} refused — ${WHY[code] ?? code}`); } } await story(); From 5b8096880fc1b8b28dc8986f21ce8a69f843d251 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 18:36:27 -0700 Subject: [PATCH 17/24] =?UTF-8?q?docs(examples):=20reduce=20hnp-draws=20de?= =?UTF-8?q?mo=20133=E2=86=9299=20lines,=20reviewed=20before=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trimmed the plumbing to the minimum the real API reads (minimal catalog {id, lines,total}; gates:[]; inline verificationStore stub) — verified by running, identical 6-scenario output. Independent code-review pass (traced against the gate source) returned no must-fix; applied its nice-to-haves: reworded the re-price comment so it doesn't imply the demo exercises amount-tamper (one shared catalog can't diverge); restored the required paymentMandateId field for TS-copy fidelity; guarded refusals?.[0]?.code against non-draw refusals; noted why the verification-store stub exists; grammar. Signed-off-by: Diego Zuluaga --- examples/hnp-draws/demo.mjs | 155 +++++++++++++++--------------------- 1 file changed, 66 insertions(+), 89 deletions(-) diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs index 6e5f6a5..290bf12 100644 --- a/examples/hnp-draws/demo.mjs +++ b/examples/hnp-draws/demo.mjs @@ -1,122 +1,99 @@ // CredentAgent — a pre-approval your AI agent can spend against, but can't abuse. // -// You approve ONCE on your phone ("reorder my coffee, up to $30 an order"). Your -// agent then shops on its own while you sleep — but it never holds a blank cheque: -// the gate re-checks EVERY purchase against your limits and refuses any that breaks -// them, in plain English. Change your mind? Revoke, and the next one dies. +// You approve ONCE ("reorder my coffee, up to $30 an order"); your agent then shops on +// its own while you sleep. It never holds a blank cheque — the gate re-checks every +// purchase against your limits and refuses any that break them. Revoke, the next dies. // -// It's a demo grant — no real money moves. Run it: node examples/hnp-draws/demo.mjs +// A demo — no real money moves. Run: node examples/hnp-draws/demo.mjs -// ═════════════════════════════════════════════════════════════════════════════ -// THE WHOLE POINT — reads top-to-bottom like plain English. (Plumbing is below.) -// ═════════════════════════════════════════════════════════════════════════════ +// ── THE WHOLE POINT — reads like plain English (plumbing is below) ──────────── async function story() { - // 1 — Pre-approve once, in plain words. Then go to sleep. 😴 + // Pre-approve once, in plain words. Then go to sleep. 😴 const agent = await preApprove("Reorder coffee from Blue Bottle — up to $30 an order.", { - store: BLUE_BOTTLE, - perOrder: 30, // never a single order over $30 - perMonth: 100, // ...and no more than $100 all month + store: BLUE_BOTTLE, perOrder: 30, perMonth: 100, }); - // 2 — Your agent shops on its own. Every field is named, so each line says what it is. - // The gate re-checks each purchase against your limits: - await agent.buy({ charge: "c1", item: "coffee" }); // ✅ within your rules - await agent.buy({ charge: "c1", item: "coffee" }); // ⛔ same charge id — sent twice - await agent.buy({ charge: "c2", item: "coffee", quantity: 3 }); // ⛔ $54 — over your $30 cap - await agent.buy({ charge: "c3", item: "coffee", store: STARBUCKS }); // ⛔ a store you never approved - await agent.buy({ charge: "c4", item: "wine" }); // ⛔ age-restricted - - // 3 — You change your mind and revoke from your phone. - agent.revoke(); - await agent.buy({ charge: "c5", item: "coffee" }); // ⛔ too late — the grant is dead + // Your agent shops on its own; the gate re-checks each purchase. (Prices come from the + // catalog — the agent gives a quantity, never an amount.) + await agent.buy({ charge: "c1", item: "coffee" }); // ✅ within your rules + await agent.buy({ charge: "c1", item: "coffee" }); // ⛔ reused charge — the same payment, twice + await agent.buy({ charge: "c2", item: "coffee", quantity: 3 }); // ⛔ 3 × $18 = $54, over your $30 cap + await agent.buy({ charge: "c3", item: "coffee", store: STARBUCKS }); // ⛔ a store you never approved + await agent.buy({ charge: "c4", item: "wine" }); // ⛔ age-restricted + agent.revoke(); // change your mind, from your phone + await agent.buy({ charge: "c5", item: "coffee" }); // ⛔ the grant is dead } -// ═════════════════════════════════════════════════════════════════════════════ -// PLUMBING — the real @openmobilehub/credentagent-gate API, wired once. Skip on a -// first read: the two helpers the story uses (preApprove + buy) are defined here. -// ═════════════════════════════════════════════════════════════════════════════ -import { - sealIntent, generateDelegate, signDraw, completeOrder, - MemoryRevocationStore, MemoryVerificationStore, -} from "@openmobilehub/credentagent-gate"; - -// A pretend shop. Merchant scope matches on a machine id (a domain), not a brand name. -const BLUE_BOTTLE = "blue-bottle.example"; -const STARBUCKS = "starbucks.example"; -const PRICES = { coffee: { price: 18 }, wine: { price: 20, minimumAge: 21 } }; -const catalog = { - createOrder(items, orderId) { - const lines = items.map(({ productId, quantity }) => { - const p = PRICES[productId]; - return { id: productId, name: productId, unitPrice: p.price, currency: "USD", quantity, - lineTotal: p.price * quantity, ...(p.minimumAge ? { minimumAge: p.minimumAge } : {}) }; - }); - const total = lines.reduce((s, l) => s + l.lineTotal, 0); - return { id: orderId, lines, itemCount: items.length, subtotal: total, discount: 0, total, currency: "USD", createdAt: new Date().toISOString() }; - }, -}; - -// The gate keeps only a small ledger (what's revoked / already drawn) + per-order state. -// It does NOT store your pre-approval — the agent re-presents it on every single purchase. -const ledger = new MemoryRevocationStore(); -const records = new Map(); -const gate = { - catalog, - revocation: ledger, - verificationStore: new MemoryVerificationStore(), - records: { read: (id) => records.get(id), write: (r) => records.set(r.orderId, r) }, -}; +// ── PLUMBING — the real API, wired once. Skip on a first read ───────────────── +import { sealIntent, generateDelegate, signDraw, completeOrder, MemoryRevocationStore } from "@openmobilehub/credentagent-gate"; -// The gate returns terse codes; we humanize them so the output reads like the intent. -const WHY = { - replay: "the same charge, twice", +const BLUE_BOTTLE = "blue-bottle.example", STARBUCKS = "starbucks.example"; +const PRICE = { coffee: 18, wine: 20 }, MIN_AGE = { wine: 21 }; +const REASON = { + replay: "the same payment, twice", "over-cap": "over your per-order cap", - "over-total": "over your monthly cap", "out-of-scope": "a store you never approved", "step-up": "age-restricted — needs you there in person", revoked: "you revoked this grant", }; -const tally = { ok: 0, no: 0 }; -let orderSeq = 0; -// preApprove — mint ONE grant (your intent + a delegate key), hand it to your agent. +// The gate re-prices each draw from its OWN catalog rather than trusting the agent's figure +// (in production the two are separate — that mismatch is what catches a lying agent; this +// demo shares one catalog, so it never diverges). It keeps only a ledger (revoked / +// already-drawn); it never stores your grant. +const orders = new Map(); +const gate = { + catalog: { + createOrder: (items, id) => ({ + id, + lines: items.map(({ productId, quantity }) => ({ id: productId, quantity, minimumAge: MIN_AGE[productId] })), + total: items.reduce((sum, { productId, quantity }) => sum + PRICE[productId] * quantity, 0), + }), + }, + revocation: new MemoryRevocationStore(), + // Delegated draws carry no live-verification state; the shared completion path still + // reads/clears this seam, so a no-op stub satisfies it. + verificationStore: { read: () => undefined, write() {}, clear() {} }, + records: { read: (id) => orders.get(id), write: (r) => orders.set(r.orderId, r) }, +}; + +const outcomes = []; +let seq = 0; + +// Mint ONE grant (your intent + a delegate key) and hand it to your agent. async function preApprove(sentence, { store, perOrder, perMonth }) { const { privateKey, delegate } = await generateDelegate(); - const mandate = await sealIntent({ - type: "credentagent.IntentBounds/v0", - naturalLanguageDescription: sentence, - merchants: [store], currency: "USD", - maxAmount: perOrder, totalAmount: perMonth, - delegate, mayPresent: [], - presence: "delegated-demo", trust_level: "server-issued-demo", + const grant = await sealIntent({ + type: "credentagent.IntentBounds/v0", naturalLanguageDescription: sentence, + merchants: [store], currency: "USD", maxAmount: perOrder, totalAmount: perMonth, + delegate, presence: "delegated-demo", trust_level: "server-issued-demo", }); console.log(`\n🎫 You pre-approved once: “${sentence}”`); - console.log(` Your agent now holds it; the gate stores nothing. Off to sleep. 😴\n`); + console.log(` Your agent holds it; the gate stores nothing. Off to sleep. 😴\n`); return { - buy: (purchase) => buy(mandate, privateKey, purchase), - revoke: () => { ledger.revoke(mandate.intentId); console.log(`\n🔴 You revoked the grant from your phone.\n`); }, + buy: (purchase) => buy(grant, privateKey, purchase), + revoke: () => { gate.revocation.revoke(grant.intentId); console.log(`\n🔴 You revoked the grant from your phone.\n`); }, }; } -// buy — your agent signs ONE draw against the grant it holds, presents { grant, draw } -// to the gate, and we print the verdict. Amount is derived from the catalog, never trusted. -// `charge` is the payment's transaction id: reuse one and the gate sees a double-spend. -async function buy(mandate, key, { charge, item, quantity = 1, store = mandate.merchants[0] }) { - const order = catalog.createOrder([{ productId: item, quantity }], `ORD-${++orderSeq}`); +// Your agent signs ONE draw against the grant and presents it; the gate decides. +async function buy(grant, key, { charge, item, quantity = 1, store = grant.merchants[0] }) { + const order = gate.catalog.createOrder([{ productId: item, quantity }], `ORD-${++seq}`); const draw = await signDraw( - { type: "credentagent.Draw/v0", intentId: mandate.intentId, paymentMandateId: charge, - merchant: store, amount: order.total, currency: "USD", pspTransactionId: charge }, + { type: "credentagent.Draw/v0", intentId: grant.intentId, paymentMandateId: charge, merchant: store, amount: order.total, currency: "USD", pspTransactionId: charge }, key, ); - const res = await completeOrder( - { order, mandateId: charge, amount: order.total, currency: "USD", method: "delegated", - gates: [{ gate: "draw", pass: true, detail: "" }], draw: { intent: mandate, draw } }, + const { completed, refusals } = await completeOrder( + { order, mandateId: charge, amount: order.total, currency: "USD", method: "delegated", gates: [], draw: { intent: grant, draw } }, gate, ); - const what = `${quantity} ${item} @ ${store.split(".")[0]}`.padEnd(22); - if (res.completed) { tally.ok++; console.log(` ✅ ${charge} ${what} approved — $${order.total} (no real money moved)`); } - else { tally.no++; const code = res.refusals[0].code; console.log(` ⛔ ${charge} ${what} refused — ${WHY[code] ?? code}`); } + outcomes.push(completed); + const line = `${charge} ${quantity} ${item} @ ${store.replace(".example", "")}`.padEnd(28) + `$${order.total}`.padStart(4); + const why = REASON[refusals?.[0]?.code] ?? refusals?.[0]?.code ?? "refused"; + console.log(completed ? ` ✅ ${line} approved (no real money moved)` : ` ⛔ ${line} refused — ${why}`); } +// Run the story now that every helper and constant above is defined. await story(); -console.log(`\n ${tally.ok} purchase through · ${tally.no} refused, each with a reason · $0 real money moved.\n`); +const ok = outcomes.filter(Boolean).length; +console.log(`\n ${ok} purchase through · ${outcomes.length - ok} refused, each with a reason · $0 real money moved.\n`); From f7252c76abad7d8b569b441256eb8e13832a1269 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 18:41:18 -0700 Subject: [PATCH 18/24] docs(examples): print the shop's unit prices above the purchases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader could not see where '3 × $18' came from — the unit price lived only in the PRICE map down in the plumbing. Now the pre-approval banner prints the menu (coffee $18 · wine $20 (21+)) from the real PRICE/MIN_AGE data, so it appears above every purchase and can't drift from what the gate charges. Signed-off-by: Diego Zuluaga --- examples/hnp-draws/demo.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs index 290bf12..26e87f4 100644 --- a/examples/hnp-draws/demo.mjs +++ b/examples/hnp-draws/demo.mjs @@ -68,8 +68,10 @@ async function preApprove(sentence, { store, perOrder, perMonth }) { merchants: [store], currency: "USD", maxAmount: perOrder, totalAmount: perMonth, delegate, presence: "delegated-demo", trust_level: "server-issued-demo", }); + const menu = Object.entries(PRICE).map(([i, p]) => `${i} $${p}${MIN_AGE[i] ? ` (${MIN_AGE[i]}+)` : ""}`).join(" · "); console.log(`\n🎫 You pre-approved once: “${sentence}”`); - console.log(` Your agent holds it; the gate stores nothing. Off to sleep. 😴\n`); + console.log(` Shop (the gate's prices): ${menu}`); + console.log(` Your agent holds the grant; the gate stores nothing. Off to sleep. 😴\n`); return { buy: (purchase) => buy(grant, privateKey, purchase), revoke: () => { gate.revocation.revoke(grant.intentId); console.log(`\n🔴 You revoked the grant from your phone.\n`); }, From b12a24c21739d0daae2fa1dfb45d9c2704592bb1 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 18:56:24 -0700 Subject: [PATCH 19/24] docs(examples): story states quantities + rules, never prices (gate prices) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader noted the story's '3 × $18 = $54' comment reached across the file to a PRICE map defined ~10 lines below. Root fix: the story shouldn't state prices at all — the agent deals in quantities, the gate in prices. Comment is now '3 coffees — over your $30 cap (the gate prices it)'; the price appears only in the PRICE map and at runtime (menu + the $54 column), never cross-referenced up top. Signed-off-by: Diego Zuluaga --- examples/hnp-draws/demo.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs index 26e87f4..b35392e 100644 --- a/examples/hnp-draws/demo.mjs +++ b/examples/hnp-draws/demo.mjs @@ -17,7 +17,7 @@ async function story() { // catalog — the agent gives a quantity, never an amount.) await agent.buy({ charge: "c1", item: "coffee" }); // ✅ within your rules await agent.buy({ charge: "c1", item: "coffee" }); // ⛔ reused charge — the same payment, twice - await agent.buy({ charge: "c2", item: "coffee", quantity: 3 }); // ⛔ 3 × $18 = $54, over your $30 cap + await agent.buy({ charge: "c2", item: "coffee", quantity: 3 }); // ⛔ 3 coffees — over your $30 cap (the gate prices it) await agent.buy({ charge: "c3", item: "coffee", store: STARBUCKS }); // ⛔ a store you never approved await agent.buy({ charge: "c4", item: "wine" }); // ⛔ age-restricted agent.revoke(); // change your mind, from your phone From 0c628d5f4cbcd783806a5a80bf590815795853d8 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 18:59:56 -0700 Subject: [PATCH 20/24] docs(examples): make the code self-explanatory on READ (prices above the story) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guiding insight (Diego): a developer reads the code first to decide if they understand it, and only then runs it — so the CODE, not the runtime output, must carry understanding. Prior version leaned on the runtime menu/$54 column for prices, invisible to a code-first reader. Fix: a 'THE SHOP' block at the top (coffee $18 · wine $20 21+) before the story, so each story line is self-contained on a read ('3 × $18 = $54 — over your $30 cap', 'wine is 21+ — age is never delegable') with the price defined a few lines above, not across the file. Signed-off-by: Diego Zuluaga --- examples/hnp-draws/demo.mjs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs index b35392e..331efc2 100644 --- a/examples/hnp-draws/demo.mjs +++ b/examples/hnp-draws/demo.mjs @@ -6,20 +6,25 @@ // // A demo — no real money moves. Run: node examples/hnp-draws/demo.mjs -// ── THE WHOLE POINT — reads like plain English (plumbing is below) ──────────── +// ── THE SHOP — what your agent can buy, and the gate's price for each ────────── +const BLUE_BOTTLE = "blue-bottle.example", STARBUCKS = "starbucks.example"; +const PRICE = { coffee: 18, wine: 20 }; // dollars; the GATE sets these, not the agent +const MIN_AGE = { wine: 21 }; // wine is 21+ + +// ── THE STORY — reads top-to-bottom like plain English (machinery is below) ─── async function story() { // Pre-approve once, in plain words. Then go to sleep. 😴 const agent = await preApprove("Reorder coffee from Blue Bottle — up to $30 an order.", { store: BLUE_BOTTLE, perOrder: 30, perMonth: 100, }); - // Your agent shops on its own; the gate re-checks each purchase. (Prices come from the - // catalog — the agent gives a quantity, never an amount.) - await agent.buy({ charge: "c1", item: "coffee" }); // ✅ within your rules - await agent.buy({ charge: "c1", item: "coffee" }); // ⛔ reused charge — the same payment, twice - await agent.buy({ charge: "c2", item: "coffee", quantity: 3 }); // ⛔ 3 coffees — over your $30 cap (the gate prices it) + // Your agent shops on its own — it names an item + quantity; the gate prices it and + // re-checks it against your limits. (Prices are the SHOP's, defined just above.) + await agent.buy({ charge: "c1", item: "coffee" }); // ✅ $18 — within your $30 cap + await agent.buy({ charge: "c1", item: "coffee" }); // ⛔ reused charge id — the same payment, twice + await agent.buy({ charge: "c2", item: "coffee", quantity: 3 }); // ⛔ 3 × $18 = $54 — over your $30 cap await agent.buy({ charge: "c3", item: "coffee", store: STARBUCKS }); // ⛔ a store you never approved - await agent.buy({ charge: "c4", item: "wine" }); // ⛔ age-restricted + await agent.buy({ charge: "c4", item: "wine" }); // ⛔ wine is 21+ — age is never delegable agent.revoke(); // change your mind, from your phone await agent.buy({ charge: "c5", item: "coffee" }); // ⛔ the grant is dead } @@ -27,8 +32,6 @@ async function story() { // ── PLUMBING — the real API, wired once. Skip on a first read ───────────────── import { sealIntent, generateDelegate, signDraw, completeOrder, MemoryRevocationStore } from "@openmobilehub/credentagent-gate"; -const BLUE_BOTTLE = "blue-bottle.example", STARBUCKS = "starbucks.example"; -const PRICE = { coffee: 18, wine: 20 }, MIN_AGE = { wine: 21 }; const REASON = { replay: "the same payment, twice", "over-cap": "over your per-order cap", From 5c880944c4be1086849e40ef865463c658693557 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 20:42:02 -0700 Subject: [PATCH 21/24] =?UTF-8?q?feat(gate,005):=20DelegatedGate=20?= =?UTF-8?q?=E2=80=94=20Stripe-grade=20facade=20over=20the=20HNP=20seams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constitution Principle I: the example exposed raw seams (hand-wired catalog, stores, completeOrder). This adds a configure-once facade so the flow reads `new DelegatedGate({catalog})` -> `gate.preApprove({...})` -> `grant.spend({...})` -> `grant.revoke()`; the ceremony (keys, signing, stores, completeOrder) is bundled. The example drops 104 -> 36 lines with the ease now IN the package. Reviewed before commit (superpowers:code-reviewer traced the seams) — fixed two must-fix bugs it found, both order-id/idempotency-shaped: - SECURITY (invariant 4): per-grant seq + per-gate records let a 2nd grant on one gate read the 1st's completion via a colliding order id, bypassing revocation/scope/cap. Fixed: order id namespaced by the grant's intentId. Regression test added (revoked 2nd grant must refuse, no delegationId). - spend() threw on an unknown item (TypeError), breaking its no-throw contract. Now throws a helpful error at the boundary (programming error, fail fast); contract reworded; regression test added. Also from review: perMonth -> total (it's a lifetime cap, no monthly reset); honesty axis exposed via grant.presence/grant.trustLevel + asserted in a test that would fail on a real value (constitution VII); revoke() is async for the wallet-custody swap; SpendResult carries retryable (the unattended-loop signal); charge -> paymentId (Stripe naming). 287 tests green (+9 facade). Signed-off-by: Diego Zuluaga --- examples/hnp-draws/demo.mjs | 134 +++-------- .../credentagent-gate/src/delegated.test.ts | 80 +++++++ packages/credentagent-gate/src/delegated.ts | 209 ++++++++++++++++++ packages/credentagent-gate/src/index.ts | 5 + 4 files changed, 327 insertions(+), 101 deletions(-) create mode 100644 packages/credentagent-gate/src/delegated.test.ts create mode 100644 packages/credentagent-gate/src/delegated.ts diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs index 331efc2..bf022a1 100644 --- a/examples/hnp-draws/demo.mjs +++ b/examples/hnp-draws/demo.mjs @@ -1,104 +1,36 @@ // CredentAgent — a pre-approval your AI agent can spend against, but can't abuse. // -// You approve ONCE ("reorder my coffee, up to $30 an order"); your agent then shops on -// its own while you sleep. It never holds a blank cheque — the gate re-checks every -// purchase against your limits and refuses any that break them. Revoke, the next dies. -// -// A demo — no real money moves. Run: node examples/hnp-draws/demo.mjs - -// ── THE SHOP — what your agent can buy, and the gate's price for each ────────── -const BLUE_BOTTLE = "blue-bottle.example", STARBUCKS = "starbucks.example"; -const PRICE = { coffee: 18, wine: 20 }; // dollars; the GATE sets these, not the agent -const MIN_AGE = { wine: 21 }; // wine is 21+ - -// ── THE STORY — reads top-to-bottom like plain English (machinery is below) ─── -async function story() { - // Pre-approve once, in plain words. Then go to sleep. 😴 - const agent = await preApprove("Reorder coffee from Blue Bottle — up to $30 an order.", { - store: BLUE_BOTTLE, perOrder: 30, perMonth: 100, - }); - - // Your agent shops on its own — it names an item + quantity; the gate prices it and - // re-checks it against your limits. (Prices are the SHOP's, defined just above.) - await agent.buy({ charge: "c1", item: "coffee" }); // ✅ $18 — within your $30 cap - await agent.buy({ charge: "c1", item: "coffee" }); // ⛔ reused charge id — the same payment, twice - await agent.buy({ charge: "c2", item: "coffee", quantity: 3 }); // ⛔ 3 × $18 = $54 — over your $30 cap - await agent.buy({ charge: "c3", item: "coffee", store: STARBUCKS }); // ⛔ a store you never approved - await agent.buy({ charge: "c4", item: "wine" }); // ⛔ wine is 21+ — age is never delegable - agent.revoke(); // change your mind, from your phone - await agent.buy({ charge: "c5", item: "coffee" }); // ⛔ the grant is dead +// You pre-approve ONCE; your agent then shops on its own while you sleep. It never holds a +// blank cheque — every purchase is re-checked against your limits and refused if it breaks +// one, in plain terms. Revoke and the next one dies. A demo — no real money moves. +// Run: node examples/hnp-draws/demo.mjs +import { DelegatedGate } from "@openmobilehub/credentagent-gate"; + +// 1. Configure the gate once with your priced catalog. (Like `new Stripe(key)`.) +const gate = new DelegatedGate({ + catalog: { coffee: 18, wine: { price: 20, minAge: 21 } }, +}); + +// 2. Pre-approve once. Your agent holds the returned grant and shops while you sleep. 😴 +const grant = await gate.preApprove({ + merchant: "blue-bottle", + perOrder: 30, // no single order over $30 + total: 100, // and $100 total before the grant is spent out +}); +console.log("\n🎫 Pre-approved: coffee at blue-bottle, up to $30/order. Off to sleep. 😴\n"); + +// 3. Your agent spends against it. Each spend() returns { ok, amount, reason? } — no throwing. +await show("1 coffee", { paymentId: "c1", item: "coffee" }); +await show("the same payment again", { paymentId: "c1", item: "coffee" }); +await show("3 coffees at once", { paymentId: "c2", item: "coffee", quantity: 3 }); +await show("coffee — different store", { paymentId: "c3", item: "coffee", merchant: "starbucks" }); +await show("wine — age-restricted", { paymentId: "c4", item: "wine" }); +await grant.revoke(); // you change your mind, from your phone +await show("1 coffee — after revoke", { paymentId: "c5", item: "coffee" }); + +// Pretty-print one spend (demo output only — not part of the API). +async function show(label, purchase) { + const { ok, amount, reason } = await grant.spend(purchase); + const verdict = ok ? "approved (no real money moved)" : `refused — ${reason}`; + console.log(` ${ok ? "✅" : "⛔"} ${label.padEnd(24)} $${String(amount).padStart(2)} ${verdict}`); } - -// ── PLUMBING — the real API, wired once. Skip on a first read ───────────────── -import { sealIntent, generateDelegate, signDraw, completeOrder, MemoryRevocationStore } from "@openmobilehub/credentagent-gate"; - -const REASON = { - replay: "the same payment, twice", - "over-cap": "over your per-order cap", - "out-of-scope": "a store you never approved", - "step-up": "age-restricted — needs you there in person", - revoked: "you revoked this grant", -}; - -// The gate re-prices each draw from its OWN catalog rather than trusting the agent's figure -// (in production the two are separate — that mismatch is what catches a lying agent; this -// demo shares one catalog, so it never diverges). It keeps only a ledger (revoked / -// already-drawn); it never stores your grant. -const orders = new Map(); -const gate = { - catalog: { - createOrder: (items, id) => ({ - id, - lines: items.map(({ productId, quantity }) => ({ id: productId, quantity, minimumAge: MIN_AGE[productId] })), - total: items.reduce((sum, { productId, quantity }) => sum + PRICE[productId] * quantity, 0), - }), - }, - revocation: new MemoryRevocationStore(), - // Delegated draws carry no live-verification state; the shared completion path still - // reads/clears this seam, so a no-op stub satisfies it. - verificationStore: { read: () => undefined, write() {}, clear() {} }, - records: { read: (id) => orders.get(id), write: (r) => orders.set(r.orderId, r) }, -}; - -const outcomes = []; -let seq = 0; - -// Mint ONE grant (your intent + a delegate key) and hand it to your agent. -async function preApprove(sentence, { store, perOrder, perMonth }) { - const { privateKey, delegate } = await generateDelegate(); - const grant = await sealIntent({ - type: "credentagent.IntentBounds/v0", naturalLanguageDescription: sentence, - merchants: [store], currency: "USD", maxAmount: perOrder, totalAmount: perMonth, - delegate, presence: "delegated-demo", trust_level: "server-issued-demo", - }); - const menu = Object.entries(PRICE).map(([i, p]) => `${i} $${p}${MIN_AGE[i] ? ` (${MIN_AGE[i]}+)` : ""}`).join(" · "); - console.log(`\n🎫 You pre-approved once: “${sentence}”`); - console.log(` Shop (the gate's prices): ${menu}`); - console.log(` Your agent holds the grant; the gate stores nothing. Off to sleep. 😴\n`); - return { - buy: (purchase) => buy(grant, privateKey, purchase), - revoke: () => { gate.revocation.revoke(grant.intentId); console.log(`\n🔴 You revoked the grant from your phone.\n`); }, - }; -} - -// Your agent signs ONE draw against the grant and presents it; the gate decides. -async function buy(grant, key, { charge, item, quantity = 1, store = grant.merchants[0] }) { - const order = gate.catalog.createOrder([{ productId: item, quantity }], `ORD-${++seq}`); - const draw = await signDraw( - { type: "credentagent.Draw/v0", intentId: grant.intentId, paymentMandateId: charge, merchant: store, amount: order.total, currency: "USD", pspTransactionId: charge }, - key, - ); - const { completed, refusals } = await completeOrder( - { order, mandateId: charge, amount: order.total, currency: "USD", method: "delegated", gates: [], draw: { intent: grant, draw } }, - gate, - ); - outcomes.push(completed); - const line = `${charge} ${quantity} ${item} @ ${store.replace(".example", "")}`.padEnd(28) + `$${order.total}`.padStart(4); - const why = REASON[refusals?.[0]?.code] ?? refusals?.[0]?.code ?? "refused"; - console.log(completed ? ` ✅ ${line} approved (no real money moved)` : ` ⛔ ${line} refused — ${why}`); -} - -// Run the story now that every helper and constant above is defined. -await story(); -const ok = outcomes.filter(Boolean).length; -console.log(`\n ${ok} purchase through · ${outcomes.length - ok} refused, each with a reason · $0 real money moved.\n`); diff --git a/packages/credentagent-gate/src/delegated.test.ts b/packages/credentagent-gate/src/delegated.test.ts new file mode 100644 index 0000000..0d899c4 --- /dev/null +++ b/packages/credentagent-gate/src/delegated.test.ts @@ -0,0 +1,80 @@ +// The DelegatedGate facade must produce the same verdicts as the raw seams, through a +// Stripe-grade surface. These pin that the ceremony is wired correctly (catalog re-price, +// single-use, scope, age-non-delegable, revocation, per-grant isolation) — each +// assertion control-dependent. +import { describe, it, expect } from "vitest"; +import { DelegatedGate } from "./delegated.js"; + +const catalog = { coffee: 18, wine: { price: 20, minAge: 21 } }; + +async function grantFor(gate = new DelegatedGate({ catalog }), perOrder = 30, total = 100) { + return gate.preApprove({ merchant: "blue-bottle", perOrder, total, description: "coffee, up to $30/order" }); +} + +describe("DelegatedGate — Stripe-grade facade over the delegated seams", () => { + it("an in-bounds spend completes: ok, catalog-priced amount, delegationId, no throw", async () => { + const grant = await grantFor(); + const res = await grant.spend({ paymentId: "c1", item: "coffee" }); + expect(res.ok).toBe(true); + expect(res.amount).toBe(18); // priced by the gate, not passed in + expect(res.delegationId).toBe(grant.id); + }); + + it("reusing a paymentId is a double-spend → refused replay", async () => { + const grant = await grantFor(); + await grant.spend({ paymentId: "c1", item: "coffee" }); + const again = await grant.spend({ paymentId: "c1", item: "coffee" }); + expect(again).toMatchObject({ ok: false, reason: "replay" }); + }); + + it("over the per-order cap → refused over-cap (3 × $18 = $54 > $30)", async () => { + const grant = await grantFor(); + const res = await grant.spend({ paymentId: "c2", item: "coffee", quantity: 3 }); + expect(res).toMatchObject({ ok: false, reason: "over-cap", amount: 54 }); + }); + + it("a store the grant never approved → refused out-of-scope", async () => { + const grant = await grantFor(); + const res = await grant.spend({ paymentId: "c3", item: "coffee", merchant: "starbucks" }); + expect(res).toMatchObject({ ok: false, reason: "out-of-scope" }); + }); + + it("age-restricted goods are non-delegable → refused step-up (needs-human) even under the cap", async () => { + const grant = await grantFor(); + const res = await grant.spend({ paymentId: "c4", item: "wine" }); // $20 < $30 cap, still refused + expect(res).toMatchObject({ ok: false, reason: "step-up", retryable: "needs-human" }); + }); + + it("revoke() makes the next spend die → refused revoked", async () => { + const grant = await grantFor(); + await grant.revoke(); + const res = await grant.spend({ paymentId: "c5", item: "coffee" }); + expect(res).toMatchObject({ ok: false, reason: "revoked" }); + }); + + // ── Regressions for the two review-found bugs ──────────────────────────────── + + it("PER-GRANT ISOLATION: a second grant on the SAME gate is not bypassed by the first's records", async () => { + const gate = new DelegatedGate({ catalog }); + const a = await grantFor(gate); + const b = await grantFor(gate); + await a.spend({ paymentId: "c1", item: "coffee" }); // A succeeds, writes a record (ORD id A-1) + await b.revoke(); + // B is revoked; its spend must be refused, NOT echo A's completion via a colliding order id. + const res = await b.spend({ paymentId: "c1", item: "coffee" }); + expect(res).toMatchObject({ ok: false, reason: "revoked" }); + expect(res.delegationId).toBeUndefined(); // (a delegationId here would be the idempotency-bypass fingerprint) + }); + + it("an unknown item id is a programming error → throws a helpful message (not a silent refusal)", async () => { + const grant = await grantFor(); + await expect(grant.spend({ paymentId: "cX", item: "tea" })).rejects.toThrow(/unknown catalog item "tea"/); + }); + + it("the honesty axis is carried in the types and readable through the facade (constitution VII)", async () => { + const grant = await grantFor(); + expect(grant.presence).toBe("delegated-demo"); + expect(grant.trustLevel).toBe("server-issued-demo"); // weaker than presence-only-demo; would fail if set to a real value + expect(grant.id.startsWith("int_")).toBe(true); + }); +}); diff --git a/packages/credentagent-gate/src/delegated.ts b/packages/credentagent-gate/src/delegated.ts new file mode 100644 index 0000000..f4873e5 --- /dev/null +++ b/packages/credentagent-gate/src/delegated.ts @@ -0,0 +1,209 @@ +// A Stripe-grade, configure-once facade for delegated grants (HNP, 005). +// +// The seams (sealIntent / signDraw / completeOrder / RevocationStore) are the +// security-critical primitives; this bundles them so a caller writes +// preApprove() / spend() / revoke() instead of wiring a catalog, stores, key +// generation and a 7-field completeOrder call by hand (Principle I — the bar is +// `new Stripe(key)` → `stripe.charges.create({...})`). +// +// The API is the stable surface; the implementation is demo-fenced. TODAY the +// delegate key is generated HERE (the server, not the user's phone), so grants +// carry presence "delegated-demo" + trust_level "server-issued-demo" (readable via +// grant.presence / grant.trustLevel) and no real value settles (constitution VII). +// The wallet-server increment swaps the internals (the key is minted in the user's +// wallet during a live ceremony) WITHOUT changing this surface. + +import type { CeremonyCatalog, CeremonyOrder } from "./ceremony/types.js"; +import { MemoryVerificationStore } from "./store.js"; +import { MemoryRevocationStore, type RevocationStore } from "./ceremony/revocation.js"; +import { sealIntent, generateDelegate, signDraw, type IntentBounds } from "./ceremony/mandate.js"; +import { completeOrder, type CompletedRecord, type CompletionContext } from "./ceremony/completion.js"; +import type { RefusalCode, RefusalRetryable } from "./ceremony/refusals.js"; + +/** The delegate private key type, without naming the DOM `CryptoKey` global. */ +type DelegateKey = Awaited>["privateKey"]; + +/** A catalog entry: a bare price, or a price plus an age restriction. */ +export type CatalogEntry = number | { price: number; minAge?: number }; + +export interface DelegatedGateOptions { + /** Your priced catalog: item id → price, or → { price, minAge }. */ + catalog: Record; + /** Shared revocation + single-use ledger (defaults to in-memory, single-process). */ + revocation?: RevocationStore; +} + +export interface PreApproveOptions { + /** The one merchant this grant may spend at. */ + merchant: string; + /** Per-purchase ceiling (an absolute cap). */ + perOrder: number; + /** Cumulative LIFETIME cap across every draw — it does NOT reset (there is no time + * window in v0.1). Once total spend reaches it, the grant is spent out. */ + total: number; + /** A human sentence describing the grant (shown in your UI). */ + description?: string; + /** Who delegated — informational in v0.1 (an audit key; not yet an enforced identity). */ + subject?: string; +} + +export interface Purchase { + /** The payment's idempotency key. Reuse one and the gate sees a double-spend. */ + paymentId: string; + item: string; + quantity?: number; + /** Spend at a merchant other than the approved one (to exercise scope). */ + merchant?: string; +} + +export interface SpendResult { + /** Did the gate complete the purchase? */ + ok: boolean; + /** The amount the gate priced from the catalog (never trusted from the caller). */ + amount: number; + /** Why it was refused — present when `!ok` (e.g. "over-cap", "replay", "revoked"). */ + reason?: RefusalCode; + /** How to recover from a refusal — the bit an unattended loop branches on: + * "needs-human" (surface an approve link), "retry" (transient), "terminal". */ + retryable?: RefusalRetryable; + /** The authorizing grant id — present when `ok` (the audit link on the record). */ + delegationId?: string; +} + +const priceOf = (e: CatalogEntry) => (typeof e === "number" ? e : e.price); +const minAgeOf = (e: CatalogEntry) => (typeof e === "number" ? undefined : e.minAge); + +function buildCatalog(items: Record): CeremonyCatalog { + return { + // Must honor the passed orderId — completeOrder re-prices under the SAME id, and its + // idempotency is keyed by it, so a duplicate id would echo a prior completion instead + // of running the per-draw checks. An unknown item is a programming error, not a gate + // decision, so it throws (fail fast) rather than silently refusing. + createOrder(refs, orderId): CeremonyOrder { + const lines = refs.map(({ productId, quantity }) => { + const entry = items[productId]; + if (entry === undefined) { + throw new Error(`[credentagent] unknown catalog item "${productId}". Known items: ${Object.keys(items).join(", ")}.`); + } + const unitPrice = priceOf(entry); + const minimumAge = minAgeOf(entry); + return { id: productId, unitPrice, quantity, lineTotal: unitPrice * quantity, currency: "USD", ...(minimumAge ? { minimumAge } : {}) }; + }); + const total = lines.reduce((sum, l) => sum + l.lineTotal, 0); + return { id: orderId, lines, itemCount: refs.length, subtotal: total, discount: 0, total, currency: "USD" }; + }, + }; +} + +/** + * The configure-once gate: give it a priced catalog, get `preApprove()`. It holds + * the revocation ledger and per-order stores so callers never wire them. Mint as many + * grants as you like from one gate — each is isolated. + */ +export class DelegatedGate { + private readonly catalog: CeremonyCatalog; + private readonly ctx: CompletionContext; + + constructor(opts: DelegatedGateOptions) { + this.catalog = buildCatalog(opts.catalog); + const records = new Map(); + this.ctx = { + catalog: this.catalog, + revocation: opts.revocation ?? new MemoryRevocationStore(), + // Delegated draws carry no live-verification state; the shared completion path + // still reads/clears this seam, so a fresh per-order store satisfies it. + verificationStore: new MemoryVerificationStore(), + records: { read: (oid) => records.get(oid), write: (r) => void records.set(r.orderId, r) }, + }; + } + + /** Mint ONE grant and hand it back for your agent to hold. */ + async preApprove(opts: PreApproveOptions): Promise { + const { privateKey, delegate } = await generateDelegate(); + const grant = await sealIntent({ + type: "credentagent.IntentBounds/v0", + naturalLanguageDescription: opts.description, + merchants: [opts.merchant], + currency: "USD", + maxAmount: opts.perOrder, + totalAmount: opts.total, + subject: opts.subject, + delegate, + presence: "delegated-demo", + trust_level: "server-issued-demo", + }); + return new DelegatedGrant(grant, privateKey, this.catalog, this.ctx); + } +} + +/** + * A minted grant your agent holds. `spend()` signs one draw and runs it through the + * gate; `revoke()` flips the ledger so the next spend dies. + */ +export class DelegatedGrant { + private seq = 0; + + constructor( + private readonly grant: IntentBounds, + private readonly key: DelegateKey, + private readonly catalog: CeremonyCatalog, + private readonly ctx: CompletionContext, + ) {} + + /** The grant's content-addressed id (the delegationId written on each draw). */ + get id(): string { + return this.grant.intentId; + } + + /** When consent happened — "delegated-demo" in v0.1 (constitution VII honesty axis). */ + get presence(): string { + return this.grant.presence; + } + + /** How strongly the authorization is bound — "server-issued-demo" in v0.1 (demo-fenced). */ + get trustLevel(): string { + return this.grant.trust_level; + } + + /** The human sentence this grant was described with, if any. */ + get description(): string | undefined { + return this.grant.naturalLanguageDescription; + } + + /** + * Spend against the grant. Returns `{ ok, amount, reason?, retryable?, delegationId? }` + * for any GATE decision — it never throws to signal a refusal. (It does throw on a + * programming error, e.g. an item id not in the catalog.) + */ + async spend({ paymentId, item, quantity = 1, merchant }: Purchase): Promise { + // Order id namespaced by the grant so two grants on one gate never collide (their + // shared idempotency store must not let grant B read grant A's completion — invariant 4). + const orderId = `${this.grant.intentId}-${++this.seq}`; + const order = this.catalog.createOrder([{ productId: item, quantity }], orderId); + const draw = await signDraw( + { + type: "credentagent.Draw/v0", + intentId: this.grant.intentId, + paymentMandateId: paymentId, + merchant: merchant ?? this.grant.merchants![0], + amount: order.total, + currency: "USD", + pspTransactionId: paymentId, + }, + this.key, + ); + const res = await completeOrder( + { order, mandateId: paymentId, amount: order.total, currency: "USD", method: "delegated", gates: [], draw: { intent: this.grant, draw } }, + this.ctx, + ); + if (res.completed) return { ok: true, amount: order.total, delegationId: res.delegationId }; + const refusal = res.refusals?.[0]; + return { ok: false, amount: order.total, reason: refusal?.code, retryable: refusal?.retryable }; + } + + /** Revoke the grant — the very next spend is refused, fail-closed. Async so a remote + * revocation store (the wallet-custody increment) can be awaited before the next spend. */ + async revoke(): Promise { + await this.ctx.revocation!.revoke(this.grant.intentId); + } +} diff --git a/packages/credentagent-gate/src/index.ts b/packages/credentagent-gate/src/index.ts index badaa24..826b971 100644 --- a/packages/credentagent-gate/src/index.ts +++ b/packages/credentagent-gate/src/index.ts @@ -94,6 +94,11 @@ export { MemoryRevocationStore } from "./ceremony/revocation.js"; export type { RevocationStore } from "./ceremony/revocation.js"; export { refusal } from "./ceremony/refusals.js"; export type { Refusal, RefusalCode, RefusalEnforcer, RefusalRetryable } from "./ceremony/refusals.js"; +// The Stripe-grade facade over the delegated-draw seams: configure a gate with a priced +// catalog, preApprove() once, spend()/revoke() — the ceremony (keys, signing, stores, +// completeOrder) is bundled. Demo-fenced today; stable surface for the wallet-server increment. +export { DelegatedGate, DelegatedGrant } from "./delegated.js"; +export type { DelegatedGateOptions, PreApproveOptions, Purchase, SpendResult, CatalogEntry } from "./delegated.js"; export type { CeremonyOrder, CeremonyOrderLine, From e84f2154a698cd758bbc315dc682a66606e7cf09 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 20:52:42 -0700 Subject: [PATCH 22/24] =?UTF-8?q?docs(examples):=20rename=20demo=20helper?= =?UTF-8?q?=20show=20=E2=86=92=20attempt=20(it=20spends,=20not=20just=20pr?= =?UTF-8?q?ints)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'show' named the trivial half (the console.log) and hid the real half (grant.spend). 'attempt' reads true at the call site — the agent attempts a purchase and the gate decides (half are refused). Signed-off-by: Diego Zuluaga --- examples/hnp-draws/demo.mjs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs index bf022a1..88e15e0 100644 --- a/examples/hnp-draws/demo.mjs +++ b/examples/hnp-draws/demo.mjs @@ -20,16 +20,16 @@ const grant = await gate.preApprove({ console.log("\n🎫 Pre-approved: coffee at blue-bottle, up to $30/order. Off to sleep. 😴\n"); // 3. Your agent spends against it. Each spend() returns { ok, amount, reason? } — no throwing. -await show("1 coffee", { paymentId: "c1", item: "coffee" }); -await show("the same payment again", { paymentId: "c1", item: "coffee" }); -await show("3 coffees at once", { paymentId: "c2", item: "coffee", quantity: 3 }); -await show("coffee — different store", { paymentId: "c3", item: "coffee", merchant: "starbucks" }); -await show("wine — age-restricted", { paymentId: "c4", item: "wine" }); +await attempt("1 coffee", { paymentId: "c1", item: "coffee" }); +await attempt("the same payment again", { paymentId: "c1", item: "coffee" }); +await attempt("3 coffees at once", { paymentId: "c2", item: "coffee", quantity: 3 }); +await attempt("coffee — different store", { paymentId: "c3", item: "coffee", merchant: "starbucks" }); +await attempt("wine — age-restricted", { paymentId: "c4", item: "wine" }); await grant.revoke(); // you change your mind, from your phone -await show("1 coffee — after revoke", { paymentId: "c5", item: "coffee" }); +await attempt("1 coffee — after revoke", { paymentId: "c5", item: "coffee" }); -// Pretty-print one spend (demo output only — not part of the API). -async function show(label, purchase) { +// Attempt one purchase and print the verdict (demo output only — not part of the API). +async function attempt(label, purchase) { const { ok, amount, reason } = await grant.spend(purchase); const verdict = ok ? "approved (no real money moved)" : `refused — ${reason}`; console.log(` ${ok ? "✅" : "⛔"} ${label.padEnd(24)} $${String(amount).padStart(2)} ${verdict}`); From cb2589ca8dcee566923da17d666488962fa1e422 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 20:58:56 -0700 Subject: [PATCH 23/24] =?UTF-8?q?feat(gate,005):=20SpendResult.remaining?= =?UTF-8?q?=20=E2=80=94=20the=20grant's=20headroom=20after=20each=20spend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delegated grant authorizes MANY purchases (one per paymentId) drawing down a cumulative cap; the demo only ever showed one approved spend, so the draw-down was invisible. spend() now returns `remaining` (total − committed draws, after this spend), so a running balance is API-reported, not demo-tallied — the Stripe-grade 'balance' shape. Demo shows two real purchases (100 → 82 → 64) then the refusals. Tested incl. the over-total boundary (22 → 4 → refused, headroom unchanged on refusal). 288 tests green. Signed-off-by: Diego Zuluaga --- examples/hnp-draws/demo.mjs | 20 ++++++++++--------- .../credentagent-gate/src/delegated.test.ts | 10 ++++++++++ packages/credentagent-gate/src/delegated.ts | 11 ++++++++-- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs index 88e15e0..9f11ffa 100644 --- a/examples/hnp-draws/demo.mjs +++ b/examples/hnp-draws/demo.mjs @@ -19,18 +19,20 @@ const grant = await gate.preApprove({ }); console.log("\n🎫 Pre-approved: coffee at blue-bottle, up to $30/order. Off to sleep. 😴\n"); -// 3. Your agent spends against it. Each spend() returns { ok, amount, reason? } — no throwing. +// 3. Your agent spends. Each spend() returns { ok, amount, remaining, reason? } — no throwing. +// Two real purchases draw the $100 down; the rest are refused, each with a reason. await attempt("1 coffee", { paymentId: "c1", item: "coffee" }); -await attempt("the same payment again", { paymentId: "c1", item: "coffee" }); -await attempt("3 coffees at once", { paymentId: "c2", item: "coffee", quantity: 3 }); -await attempt("coffee — different store", { paymentId: "c3", item: "coffee", merchant: "starbucks" }); -await attempt("wine — age-restricted", { paymentId: "c4", item: "wine" }); +await attempt("another coffee (new id)", { paymentId: "c2", item: "coffee" }); +await attempt("reuse c1 — double-spend", { paymentId: "c1", item: "coffee" }); +await attempt("3 coffees at once", { paymentId: "c3", item: "coffee", quantity: 3 }); +await attempt("coffee — different store", { paymentId: "c4", item: "coffee", merchant: "starbucks" }); +await attempt("wine — age-restricted", { paymentId: "c5", item: "wine" }); await grant.revoke(); // you change your mind, from your phone -await attempt("1 coffee — after revoke", { paymentId: "c5", item: "coffee" }); +await attempt("1 coffee — after revoke", { paymentId: "c6", item: "coffee" }); // Attempt one purchase and print the verdict (demo output only — not part of the API). async function attempt(label, purchase) { - const { ok, amount, reason } = await grant.spend(purchase); - const verdict = ok ? "approved (no real money moved)" : `refused — ${reason}`; - console.log(` ${ok ? "✅" : "⛔"} ${label.padEnd(24)} $${String(amount).padStart(2)} ${verdict}`); + const { ok, amount, remaining, reason } = await grant.spend(purchase); + const verdict = ok ? `approved — $${remaining} of $100 left` : `refused — ${reason}`; + console.log(` ${ok ? "✅" : "⛔"} ${label.padEnd(26)} $${String(amount).padStart(2)} ${verdict}`); } diff --git a/packages/credentagent-gate/src/delegated.test.ts b/packages/credentagent-gate/src/delegated.test.ts index 0d899c4..45f151d 100644 --- a/packages/credentagent-gate/src/delegated.test.ts +++ b/packages/credentagent-gate/src/delegated.test.ts @@ -52,6 +52,16 @@ describe("DelegatedGate — Stripe-grade facade over the delegated seams", () => expect(res).toMatchObject({ ok: false, reason: "revoked" }); }); + it("draws down the cumulative cap: remaining falls per approved spend, and over-total is refused", async () => { + const grant = await grantFor(new DelegatedGate({ catalog }), 30, 40); // perOrder 30, total 40 + const a = await grant.spend({ paymentId: "c1", item: "coffee" }); // $18 + expect(a).toMatchObject({ ok: true, remaining: 22 }); // 40 − 18 + const b = await grant.spend({ paymentId: "c2", item: "coffee" }); // $18 more + expect(b).toMatchObject({ ok: true, remaining: 4 }); // 40 − 36 + const c = await grant.spend({ paymentId: "c3", item: "coffee" }); // would be 54 > 40 + expect(c).toMatchObject({ ok: false, reason: "over-total", remaining: 4 }); // refused; headroom unchanged + }); + // ── Regressions for the two review-found bugs ──────────────────────────────── it("PER-GRANT ISOLATION: a second grant on the SAME gate is not bypassed by the first's records", async () => { diff --git a/packages/credentagent-gate/src/delegated.ts b/packages/credentagent-gate/src/delegated.ts index f4873e5..b165ab5 100644 --- a/packages/credentagent-gate/src/delegated.ts +++ b/packages/credentagent-gate/src/delegated.ts @@ -61,6 +61,9 @@ export interface SpendResult { ok: boolean; /** The amount the gate priced from the catalog (never trusted from the caller). */ amount: number; + /** Headroom left on the grant's cumulative cap AFTER this spend (a completed spend + * draws it down; a refused one leaves it unchanged). Reaches 0 when spent out. */ + remaining: number; /** Why it was refused — present when `!ok` (e.g. "over-cap", "replay", "revoked"). */ reason?: RefusalCode; /** How to recover from a refusal — the bit an unattended loop branches on: @@ -196,9 +199,13 @@ export class DelegatedGrant { { order, mandateId: paymentId, amount: order.total, currency: "USD", method: "delegated", gates: [], draw: { intent: this.grant, draw } }, this.ctx, ); - if (res.completed) return { ok: true, amount: order.total, delegationId: res.delegationId }; + // Headroom AFTER this spend: the store's committed draws now include this one iff it + // completed, so `total − committed` reflects the draw-down (or is unchanged on refusal). + const committed = await this.ctx.revocation!.priorDraws(this.grant.intentId); + const remaining = this.grant.totalAmount - committed.reduce((sum, d) => sum + d.amount, 0); + if (res.completed) return { ok: true, amount: order.total, remaining, delegationId: res.delegationId }; const refusal = res.refusals?.[0]; - return { ok: false, amount: order.total, reason: refusal?.code, retryable: refusal?.retryable }; + return { ok: false, amount: order.total, remaining, reason: refusal?.code, retryable: refusal?.retryable }; } /** Revoke the grant — the very next spend is refused, fail-closed. Async so a remote From c1624d97166a41f96cbdb1a855475e3d8269b728 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Thu, 9 Jul 2026 21:49:39 -0700 Subject: [PATCH 24/24] =?UTF-8?q?docs:=20STATUS=20refresh=20=E2=80=94=20DX?= =?UTF-8?q?=20gate=20merged=20(#43,=20constitution=201.2.0),=20005=20seams?= =?UTF-8?q?+facade=20(#41),=20next=20increment=20=3D=20intent=20rail=20?= =?UTF-8?q?=E2=86=92=20wallet=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolved decisions moved to Done (Group-A/Option-B/Decision-13 ratified; rename shipped; 0.2.0 published). Open decisions: approve+merge #41, CLAUDE_CODE_OAUTH_TOKEN, counsel brief. Added the DX-is-blocking standing constraint and the next-increment pointer (HTTP intent rail → wallet server). Signed-off-by: Diego Zuluaga --- STATUS.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/STATUS.md b/STATUS.md index 6e92bd2..1f6ad60 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,7 +1,7 @@ # Project Status — CredentAgent _Single source of truth for what's done, what's next, and what's waiting on you._ -_Updated **2026-07-08** · rename shipped (#37): repos + packages live as CredentAgent · CI green · 247 tests pass._ +_Updated **2026-07-10** · rename shipped · 0.2.0 published · 005 seams + `DelegatedGate` facade (#41, awaiting merge) · DX gate merged (#43) · constitution 1.2.0 · 288 tests pass._ > **How this file works.** Read it at the start of every working session and update it at the end. It is > decisions-first: "Decisions for you" (each a checkbox + recommendation), then In flight / next, a rolling @@ -19,21 +19,20 @@ _Updated **2026-07-08** · rename shipped (#37): repos + packages live as Creden us; that window is the accepted risk. - [ ] **Add the `CLAUDE_CODE_OAUTH_TOKEN` secret** + a `claude-code-review.yml` workflow if you want the automated PR review (the org-managed review also covers it). -- [ ] **005 sequencing fork — decision memo ready to ratify (2026-07-03).** Ship merchant-side v0.1 - (server-HMAC) first, or re-scope 005 to wallet-custody directly? Full analysis + - recommendation in [`sequencing-fork-memo.md`](specs/005-human-not-present/sequencing-fork-memo.md). - **Recommendation: Option B (wallet-custody directly), seams-first** — the spike (Runs 1–5, incl. a - green settlement) retired Option A's de-risking rationale, so A now only ships a merchant-side minting - rail the wallet model obsoletes. Build the shared gate seams first (envelope, completeOrder branch, - revocation store, typed refusals), then the wallet server (the one new backend). Not urgent to - *execute* (005 builds on 004 → publish → rename, deferred a week), but ratifying unblocks the 005 plan. -- [ ] **Confirm the 005 Group-A decisions** (D1–D3, still *tentative* per the 2026-07-01 discussion) + the - Decision-13 constitution amendment — both gate `/speckit-plan` → `/speckit-implement` for 005. +- [ ] **Approve + merge [PR #41](https://github.com/openmobilehub/credentagent/pull/41)** — 005 seams + + `DelegatedGate` facade. All checks green (DCO · build-test · claude-review), MERGEABLE, reconciled with + `main` (constitution folded to 1.2.0). Blocked only on your approving review. +- [x] **005 Group-A (D1–D3) + sequencing (Option B) + Decision-13 — RATIFIED/DONE 2026-07-08** (see Done). --- ## 🔨 In flight / next +- **005 NEXT INCREMENT (after #41 merges): the HTTP intent rail → the wallet server.** The seams + facade + land in #41; the next pieces turn them into the live "delegate on your phone → agent buys while you sleep" + demo: a `ceremony/intent/` rail (mirroring the `dcql`/`request`/`verify`/`page`/`routes` split), then the + wallet server (`credentagent-wallet`, Kotlin/JVM — the one new backend, likely a sibling repo). Wants a + short `/speckit-plan` pass. Both were explicitly out of #41's scope. - **AP2 v2 alignment — captured as [#39](https://github.com/openmobilehub/credentagent/issues/39) + [#40](https://github.com/openmobilehub/credentagent/issues/40)** (2026-07-08), prompted by AP2-team feedback (Yanhe Chen, Google, Discord 2026-06-02: v1-shaped mandate, unsigned amount, unverified issuer/deviceAuth). @@ -119,6 +118,9 @@ _Updated **2026-07-08** · rename shipped (#37): repos + packages live as Creden | What | Where | | :-- | :-- | +| **DX rubric is now a BINDING review gate + constitution 1.2.0** — `architecture-principles.md` gains Principle 12 ("the example IS the DX test") + the `DelegatedGate` golden before/after; CLAUDE.md DX section at the security-invariant tier; automated review checks it. Folds with the HNP amendment (II/III/VII) into constitution v1.2.0. | [#43](https://github.com/openmobilehub/credentagent/pull/43) | +| **005 ratified + first increment built** — Group-A D1–D3 + sequencing **Option B** + Decision-13 (constitution amendment) all ratified 2026-07-08; the shared gate seams (`checkDraw`, `RevocationStore`, `completeOrder` draw branch, typed refusals) + the Stripe-grade **`DelegatedGate`** facade + a 36-line example, **288 tests** | [PR #41](https://github.com/openmobilehub/credentagent/pull/41) (awaiting merge) | +| **HNP §12 spikes COMPLETE** — on-device (Runs 1–5, incl. a green settlement) + headless-auth (PASS: claude.ai routines carry the unattended leg; two setup gates found) | `specs/005-…` | | **Rename EXECUTED: AttestoMCP → CredentAgent (2026-07-08)** — library ([PR #38](https://github.com/openmobilehub/credentagent/pull/38), 132 files, verified live both custody modes), GitHub repos renamed (`credentagent`, `credentagent-website`), website content ([credentagent-website#8](https://github.com/openmobilehub/credentagent-website/pull/8), Pages live), #31 retrofitted via the committed rename script | [#37](https://github.com/openmobilehub/credentagent/issues/37) | | **Published `0.2.0` as `@openmobilehub/credentagent-*`** (release `v0.2.0-credentagent` → CI publish with provenance; `NPM_TOKEN` secret set). Full deprecation chain: `attesto-*` + `attestomcp-*` all point at `credentagent-*` | npm | | `AttestoMcp` → `AttestoMCP` brand-casing rename (class, options type, ~171 sites across code + docs), version bumped `0.1.0` → `0.2.0` *(historical — pre-CredentAgent)* | [#26](https://github.com/openmobilehub/credentagent/issues/26) | @@ -134,3 +136,4 @@ _Updated **2026-07-08** · rename shipped (#37): repos + packages live as Creden - **Honesty:** `trust_level` stays `presence-only-demo` for the OpenID4VP rails (real wire crypto, no issuer trust anchor yet) — never sold as a real safety control. A pro trademark search is advised before publish. - **DCO** `git commit -s` on every commit; bypass tests must fail with their control removed. +- **DX is Stripe-grade or it's a request-changes** (constitution 1.2.0 Principle I + `docs/reference/architecture-principles.md`) — the example IS the DX test: if it needs a plumbing block, fix the API, not the example. Same blocking tier as the security invariants.