Skip to content
Closed
103 changes: 103 additions & 0 deletions docs/superpowers/plans/2026-07-23-grants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Grants (`credentagent.grants`) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship `credentagent.grants` — approve a spending limit once (`create` → `approveUrl` ceremony → `authorized`), then `grant.spend({ idempotencyKey, items })` against it while the human is away — per spec 009's grants half and issue #104's four accepted decisions.

**Architecture:** A durable resource layer (`grants.ts` + `grants-serve.ts`) mirroring `orders.ts` + `orders-serve.ts`, over the EXISTING delegated-draw engine (`sealIntent`/`signDraw`/`completeOrder`/`RevocationStore` — already merged; `DelegatedGate` stays untouched as the standalone facade). The authorize ceremony reuses the same rails/page/completion machinery orders reuse, with the grant presented as a one-line pseudo-order (amount = budget). Spends run in-process (#104 decision 3) and route completions through `orders._complete` so the settled event + webhooks fan out for free. `Money` (`usd`) returns, wired (#104 decision 2): the public surface never exposes a raw scalar; conversion to the engine's dollar numbers happens at ONE documented seam.

**Tech Stack:** TypeScript/ESM, vitest, Express-shaped structural types (no express dependency in the gate).

**Key decisions (fixed):**
- Resource named `grants`; the AP2 artifact stays `grant.intentMandate` (per maintainer discussion 2026-07-23).
- Grant ids: `gr_<16hex>`. Spend order ids: `<grantId>-<idempotencyKey>` (namespaced — invariant 4).
- Status lifecycle: `pending → authorized | denied`, `* → revoked`. Spend refuses unless `authorized` (code `"not-authorized"`).
- Spend door codes: engine `over-total` → `"budget-exceeded"`, `over-cap` → `"per-spend-exceeded"`; all other engine codes pass through (`"revoked"`, `"step-up"`, …). `retryable` rides along.
- Replay: spend pre-reads its completion record by order id; hit → prior result + `replayed: true` (one charge, ever).
- Age / custom gates on autopilot: already refused by the completion seam (`step-up`) — pin with a bypass test, do not re-implement.
- Authorize completion does NOT fire `order.settled` (it is not a purchase); spend completion DOES (via `orders._complete`).
- `new CredentAgent({ catalog, grantStore, revocationStore })` — all optional, in-memory defaults (FR-001; additive).
- Money: internal minor units, `usd.dollars(n)`/`usd(minor)`; engine seam converts via `dollarsOf(money) = serialize().amount / 100` in exactly one place (`grants.ts`), commented.

**File structure:**
- Create: `packages/credentagent-gate/src/money.ts` (reintroduced from #98 pre-review, unchanged semantics + a `dollarsOf` note)
- Create: `packages/credentagent-gate/src/grants.ts` (Grants resource + Grant handle + GrantRecord/GrantStore types + spend engine calls)
- Create: `packages/credentagent-gate/src/grants-serve.ts` (approve page, demo approve for ungated, decline, status, rails adapter for gated)
- Create: `packages/credentagent-gate/src/grants.test.ts`, `packages/credentagent-gate/src/grants-serve.test.ts`, `packages/credentagent-gate/src/money.test.ts`
- Modify: `packages/credentagent-gate/src/delegated.ts` (export `buildCatalog`; no behavior change)
- Modify: `packages/credentagent-gate/src/client.ts` (opts `catalog`/`grantStore`/`revocationStore`; wire `this.grants`)
- Modify: `packages/credentagent-gate/src/index.ts` (exports)
- Create: `examples/grants-preapproved/{server.mjs,smoke.mjs,README.md}`
- Modify: `packages/credentagent-gate/README.md`, `examples/README.md`

---

### Task 1: Reintroduce `money.ts` (wired consumer coming in Task 4)

- [ ] Step 1: Write `money.test.ts` — the #98-era assertions PLUS: `usd.dollars(21.99).serialize()` → `{ amount: 2199, currency: "usd" }` (no float drift), `usd.dollars(0)` valid, `.toString()` → `"USD 21.99"`.
- [ ] Step 2: Run → FAIL (module missing).
- [ ] Step 3: Recreate `money.ts` (same shape as the version removed in #98 review: opaque, integer minor units, `.lt/.gte/.eq/.plus/.minus/.serialize`, currency-mismatch throws).
- [ ] Step 4: Run → PASS. Export `usd`/`Money` from `index.ts` with a comment noting grants is the consumer.
- [ ] Step 5: Commit `feat(gate,#104): reintroduce Money (usd) — grants is the wired consumer`.

### Task 2: `grants.create()` / `retrieve()` — the pending lifecycle

- [ ] Step 1: Failing tests in `grants.test.ts`:
- `create({ merchant, budget: usd.dollars(100), perSpend: usd.dollars(30), policy: [] })` → `{ id: /^gr_/, approveUrl: <walletOrigin>/credentagent/grants/<id>, status: "pending" }`; create awaits persistence (async-store test, same pattern as orders).
- `retrieve(id)` → handle with `status: "pending"`, `approveUrl`, `terms` echo; unknown id → `{ status: "not-found" }`-style typed refusal (no throw).
- Two grants isolated (invariant 4).
- [ ] Step 2: FAIL. Step 3: implement `grants.ts` (`GrantRecord`, `GrantStore = OrderStore<GrantRecord>`, `Grants.create/retrieve`, `Grant` handle exposing `id/status/terms/approveUrl/intentMandate?`). Money→dollars conversion seam lives here with the one comment. Step 4: PASS. Step 5: commit.

### Task 3: Client wiring

- [ ] Step 1: Failing tests: `new CredentAgent({ walletOrigin, catalog })` exposes `credentagent.grants`; `grants` without `catalog` → `spend` throws a clear config error message naming the fix; stores injectable (`grantStore`, `revocationStore`).
- [ ] Steps 2–4: wire in `client.ts` mirroring the orders block (created store + revocation + records; `serve` closure with double-serve guard shared with orders' pattern). Step 5: commit.

### Task 4: `grant.spend()` — the in-process draw with the door result

- [ ] Step 1: Failing tests (the heart — every one a bypass test where applicable):
- authorized grant, `spend({ idempotencyKey: "p1", items: [{ sku: "coffee", qty: 1 }] })` → `{ ok: true, amount, remaining }`; `remaining` is Money (`.lt` works); amount re-priced from catalog (pass a lying qty? items only carry sku/qty — price never passed: assert catalog price used).
- BYPASS budget: spends beyond `budget` → `{ ok: false, code: "budget-exceeded" }` (delete the `over-total` mapping/check → red).
- BYPASS perSpend: single item over `perSpend` → `code: "per-spend-exceeded"`.
- BYPASS status: pending (unauthorized) grant → `code: "not-authorized"` — never signs a draw (delete the status gate → red).
- BYPASS age autopilot: catalog item with `minAge: 21` → `{ ok: false, code: "step-up" }` (pins the seam control).
- Idempotent replay: same key twice → second result `{ ok: true, replayed: true }`, ledger shows ONE committed draw, `remaining` unchanged.
- Distinct keys are distinct draws (two spends draw down twice).
- Invariant 4: spend on grant A never appears in grant B's ledger/remaining.
- Spend completion fires `order.settled` with the spend's order id (wired through `orders._complete`).
- [ ] Steps 2–4: implement `Grant.spend` in `grants.ts`: status gate → replay pre-read → `buildCatalog(clientCatalog).createOrder(items→refs, orderId)` → per-spend/budget enforced by `checkDraw` via sealed bounds → `signDraw` (JWK imported from record) → `completeOrder` with records adapter that forwards to `orders._complete` → door mapping (`over-total`→`budget-exceeded`, `over-cap`→`per-spend-exceeded`) + `remaining` from revocation ledger as Money + `trustLevel`/`authorization: "delegated"` + `mandateBundle` (intentMandate + draw). Step 5: commit.

### Task 5: `grant.revoke()` + `denied`

- [ ] Tests: revoke → `status: "revoked"` AND next spend `code: "revoked"` (delete either write → red: status alone must not be the only control — the revocation store is the enforced one); revoke a pending grant OK; spend after denied refuses.
- [ ] Implement: `revoke()` writes both the revocation store and the record status. Commit.

### Task 6: `grants.serve(app)` — approve page, demo approve, decline, status

- [ ] Tests in `grants-serve.test.ts` (fakeApp pattern copied from orders-serve.test.ts):
- routes registered: `GET /credentagent/grants/:id`, `POST .../approve`, `POST .../decline`, `GET .../status`.
- page renders terms (merchant, `$100.00` budget via the page formatter, per-spend) for pending; 404 unknown.
- ungated (policy `[]`) demo approve → status `authorized`, intent sealed (record has `intentMandate` + delegate JWK), approve is idempotent (double POST → one seal, still 200).
- BYPASS (invariant 1): a grant whose policy contains a blocking gate (e.g. `required(age.over(21))`) is REFUSED (403) on the demo approve path — delete the `isGated` guard → red. It must authorize through the rails only.
- decline → `denied`; approve after decline/revoke refused (fail-closed).
- [ ] Implement `grants-serve.ts`: pseudo-order adapter (`CeremonyOrderStore` presenting the grant as a one-line order, amount = budget) + `mountCeremony` reuse with `returnUrl` → the grant page + a records adapter whose `write` calls `grants._authorize(id)` (seals intent, mints delegate, flips status — does NOT fire order.settled). Demo approve mirrors orders' `place` (isGated + already-authorized guards). Commit.

### Task 7: ~~Gated authorize through the real rails~~ — DESCOPED (recorded deviation)

A second `mountCeremony` on the same app would collide with orders' rail routes
(`/credentagent/passkey` etc. — first registration wins in Express). Rails-backed grant
authorization needs a client-level composite mount (one rails registration dispatching
`ord_*`/`gr_*` ids) — that refactor belongs to the wallet-custody increment. Increment 1:
gated grants render their requirements but the approve POST is FENCED (403, fail-closed,
bypass-tested in Task 6); ungated grants authorize end-to-end via the demo approve.
Consistent with #104 decision 3 (in-process scope) and the repo's demo-fencing honesty rule.

### Task 8: Example + smoke

- [ ] `examples/grants-preapproved/server.mjs` — the spec-009 story verbatim (create → approveUrl; worker loop spends until `remaining.lt(usd.dollars(20))`); `smoke.mjs` asserts: pending→(demo approve)→authorized → 3 spends → budget-exceeded refusal → replay check → revoke → revoked refusal → **gated grant demo-approve 403**. Wire smoke into CI alongside the existing example smokes if a hook exists; otherwise runnable-only. Commit.

### Task 9: Docs + PR

- [ ] README section "Grants — approve once, spend while away" (example-first, serverless caveat N/A — spends are in-process server calls; note the trust honesty: `server-issued-demo`, age never on autopilot), `examples/README.md` line, `index.ts` export comments. Full build + both suites + all smokes. PR per template (plain-language, decisions → outcomes, bypass tests listed RED-verified), `Refs #104`, sub-issue parent #97.

**Self-review done:** every #104 decision maps to a task (1→Task 4 replay; 2→Tasks 1/4; 3→Task 4 in-process; 4→engine reused not duplicated, #44/#95 housekeeping stays OUT of this PR — separate follow-up). Spec FRs: FR-001 Task 3, FR-002/003 Task 4, FR-004 Tasks 2/6, FR-005 Tasks 1/4, FR-007 Tasks 2/4/5, FR-010 Task 6 (dev-sealed, honesty comments). HR-001/002 pinned in Task 4 result assertions.
3 changes: 3 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ Each is runnable against the two `@openmobilehub/credentagent-*` packages (build
**Order webhooks — the real HTTP completion signal** (010)
- [`order-webhooks/`](order-webhooks/) — a sender + a separate receiver: a settled order POSTs a **signed** `order.settled` event; the receiver verifies it with `constructEvent` (the Stripe idiom). Forged/tampered/replayed events are rejected

**Grants — approve once, spend while away** (009 / #104)
- [`grants-preapproved/`](grants-preapproved/) — the real `credentagent.grants` API: the human approves ONE limit at `grant.approveUrl`; the agent then spends against it unattended — catalog-priced, capped per-purchase and in total, replay-safe by `idempotencyKey`, revocable, and age-restricted items always step up to a human

**Cart Mandate / stateless** (004)
- [`stateless-orders/`](stateless-orders/) — the created order rides in a signed Cart Mandate on the link

Expand Down
66 changes: 66 additions & 0 deletions examples/grants-preapproved/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# `grants-preapproved/` — approve a spending limit once, let the agent buy against it

You're heads-down for a week and want your AI agent to keep you in coffee. You don't want to
tap "approve" for every cup — and you don't want the agent to have a blank check either. This
example is the smallest real thing that makes that safe: you approve **one limit** ("up to
$15 per purchase, $50 total, at this café"), and the agent spends against it **while you're
away** — capped, replay-safe, revocable, and never for age-restricted items.

Two things run **once at startup** (`grants.serve()`, `on()`); `grants.create()` runs when
the agent asks for authority; `grant.spend()` runs per purchase, unattended:

```js
import express from "express";
import { CredentAgent, usd } from "@openmobilehub/credentagent-gate";

const app = express();
app.use(express.json());
const credentagent = new CredentAgent({
walletOrigin: "http://localhost:4000",
catalog: { coffee: 4.5, beans: 14 }, // the price authority — spends name a sku, never an amount
});

// ── once, at startup ──────────────────────────────────────────
credentagent.grants.serve(app); // the approve page at each grant's approveUrl
credentagent.on("order.settled", ({ id }) => fulfill(id));

// ── the agent asks for authority — hand the link to the human ──
const grant = await credentagent.grants.create({
merchant: "corner-cafe",
budget: usd.dollars(50), perSpend: usd.dollars(15), // Money — never a raw number
policy: [],
});
sendToUser(grant.approveUrl); // ONE approval, then the agent is on its own

// ── later — human away — spend against it ─────────────────────
const g = await credentagent.grants.retrieve(grant.id);
if (g.status === "authorized") {
const s = await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] });
// → { ok: true, amount, remaining } · { ok: false, code: "budget-exceeded" | "per-spend-exceeded" | "revoked" | … }
}
```

## Run it

```bash
npm run build # build the @openmobilehub/credentagent-* packages
node examples/grants-preapproved/server.mjs # → http://localhost:4000
node examples/grants-preapproved/smoke.mjs # the whole flow + asserts, no browser
```

Then:

1. `curl -X POST http://localhost:4000/setup-coffee-fund` → `{ id, approveUrl }`
2. Open the `approveUrl` → **Approve this limit** (the one human step)
3. `curl -X POST http://localhost:4000/buy/<id> -H 'content-type: application/json' -d '{"purchaseId":"p1","sku":"coffee"}'` → spends, unattended
4. `curl -X DELETE http://localhost:4000/grants/<id>` → revoked; the next spend refuses

## What's honestly enforced (and what isn't)

- **Enforced, tested:** per-purchase cap, cumulative budget, replay-safe retries (reuse the
`purchaseId`), revocation, and **age-restricted items never complete on autopilot** — they
refuse with `step-up` (a live human ceremony is the only way).
- **Demo-fenced:** trust is `server-issued-demo` — the approval key is minted by this server,
not the user's wallet, and no real value moves. A grant whose `policy` names a credential
(e.g. `required(age.over(21))`) renders its requirements but **cannot** be approved from
the demo button (403) — rails-backed grant approval lands with the wallet-custody increment.
81 changes: 81 additions & 0 deletions examples/grants-preapproved/server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Runnable example — approve a spending limit once, let the agent buy against it later,
// built on the real credentagent.grants API (spec 009, the human-not-present half).
//
// node examples/grants-preapproved/server.mjs # boots on http://localhost:4000
// node examples/grants-preapproved/smoke.mjs # drives the whole flow + asserts (no browser)
//
// The human approves ONE limit at the grant's approveUrl; the agent then spends against it
// unattended. Every spend is re-priced from the server-side catalog (never a caller amount),
// capped per-purchase and in total, safely retryable by idempotency key, and revocable.
// Honesty: trust is `server-issued-demo` — no real value moves, and age-restricted items
// NEVER complete on autopilot (they step up to a live human).
import express from "express";
import { CredentAgent, usd, age, required } from "@openmobilehub/credentagent-gate";

const PORT = 4000;
const app = express();
app.use(express.json());

// ── ONCE, at startup ────────────────────────────────────────────────────────────
// The catalog is the price authority (a spend names a sku, never an amount).
const credentagent = new CredentAgent({
walletOrigin: `http://localhost:${PORT}`,
catalog: { coffee: 4.5, beans: 14, "case-of-beans": 40, wine: { price: 21, minAge: 21 } },
});
credentagent.grants.serve(app); // the approve page at each grant's approveUrl
credentagent.on("order.settled", ({ id }) => console.log(`✓ spend settled: ${id}`));

// ── The agent asks for authority — gets back a link to hand to the human ─────────
app.post("/setup-coffee-fund", async (_req, res) => {
const grant = await credentagent.grants.create({
merchant: "corner-cafe",
budget: usd.dollars(50),
perSpend: usd.dollars(15),
policy: [],
description: "Coffee while I'm heads-down this week",
});
res.json({ id: grant.id, approveUrl: grant.approveUrl, status: grant.status });
});

// A grant whose policy needs a credential — demo-approve is fenced for it (403).
app.post("/setup-wine-fund", async (_req, res) => {
const grant = await credentagent.grants.create({
merchant: "corner-cafe",
budget: usd.dollars(50),
perSpend: usd.dollars(30),
policy: [required(age.over(21))],
});
res.json({ id: grant.id, approveUrl: grant.approveUrl, status: grant.status });
});

// ── LATER — human away — the agent spends against the grant ─────────────────────
app.post("/buy/:grantId", async (req, res) => {
const grant = await credentagent.grants.retrieve(req.params.grantId);
if (grant.status !== "authorized") {
res.status(409).json({ status: grant.status, approveUrl: grant.approveUrl });
return;
}
const s = await grant.spend({
idempotencyKey: req.body.purchaseId, // REUSE on retry — never double-charges
items: [{ sku: req.body.sku, qty: req.body.qty ?? 1 }],
});
const body = s.ok
? { ok: true, amount: s.amount.serialize(), remaining: s.remaining.serialize(), replayed: s.replayed ?? false }
: { ok: false, code: s.code, retryable: s.retryable, remaining: s.remaining.serialize() };
res.status(s.ok ? 200 : 402).json(body);
});

// The kill switch.
app.delete("/grants/:grantId", async (req, res) => {
const grant = await credentagent.grants.retrieve(req.params.grantId);
await grant.revoke();
res.json({ status: "revoked" });
});

app.listen(PORT, () => {
console.log(`grants-preapproved example on http://localhost:${PORT}`);
console.log(` 1) POST /setup-coffee-fund → { id, approveUrl }`);
console.log(` 2) open the approveUrl → Approve (the one human step)`);
console.log(` 3) POST /buy/<id> {"purchaseId":"p1","sku":"coffee"} → spends, unattended`);
console.log(` 4) DELETE /grants/<id> → revoked; the next spend refuses`);
});
Loading
Loading