Skip to content

credentagent.grants: approve a spending limit once, spend while the human is away (#104) - #106

Closed
dzuluaga wants to merge 8 commits into
mainfrom
feat/104-grants
Closed

credentagent.grants: approve a spending limit once, spend while the human is away (#104)#106
dzuluaga wants to merge 8 commits into
mainfrom
feat/104-grants

Conversation

@dzuluaga

Copy link
Copy Markdown
Contributor

In plain terms

You're heads-down for a week and want your AI agent to keep you in coffee — without tapping
"approve" for every cup, and without giving the agent a blank check. This PR ships that:
the human approves one spending limit ("up to $15 per purchase, $50 total, at this café")
on a link the agent hands them, and the agent then buys against it while they're away
like a prepaid card with rules. It's the second half of the orders work that merged in #98:
orders need the human every time; a grant needs them once.

const grant = await credentagent.grants.create({ merchant: "corner-cafe", budget: usd.dollars(50), perSpend: usd.dollars(15), policy: [] });
sendToUser(grant.approveUrl);                    // the ONE human step

// later, unattended:
const g = await credentagent.grants.retrieve(grantId);
const s = await g.spend({ idempotencyKey: purchaseId, items: [{ sku: "coffee" }] });
// → { ok: true, remaining } · { ok: false, code: "budget-exceeded" | "per-spend-exceeded" | "revoked" | … }

What you're approving

  • New code only — nothing existing changes. credentagent.grants lands alongside orders;
    no current caller has to touch anything.
  • The four decisions from issue Grants — approve a spending limit once, let the agent buy against it later (spec 009, human-not-present half) #104, implemented as accepted:
    1. a network retry with the same idempotencyKey is answered once-charged (replayed: true) — no double-buying;
    2. Money is back and wired (usd.dollars(50)) — the caller never sees a raw number, so the unit confusion from the orders.serve(app): the human-present checkout, wired in one call (#97) #98 review can't recur;
    3. spends run in-process on your server — no signing key is handed to the agent over the network;
    4. the existing, already-merged checking engine is reused, not duplicated (the draft-PR cleanup is a separate follow-up).
  • Worst case if it's wrong: an unattended purchase mis-refuses or mis-completes — but no real
    money moves anywhere
    (trust level server-issued-demo, stated on the page and in the types), and
    the caps/refusals are pinned by tests that go red when their checks are deleted (verified, listed below).
  • What's fenced, honestly: a grant whose policy needs a credential (say, proof of being 21+)
    renders its requirements but cannot be approved from the demo button (403) — wallet-ceremony
    approval is the next increment. And age-restricted items never complete on autopilot — a
    spend on wine refuses with "a live human must approve this" no matter what the grant allows.

How to test

npm run build && npm test                        # gate 376 + storefront 86 = 462 pass
node examples/grants-preapproved/smoke.mjs       # → ALL SMOKE CHECKS PASSED (13 checks, no browser)
node examples/grants-preapproved/server.mjs      # then:
#   curl -X POST localhost:4000/setup-coffee-fund                → { id, approveUrl }
#   open the approveUrl → "Approve this limit"                   (the one human step)
#   curl -X POST localhost:4000/buy/<id> -H 'content-type: application/json' -d '{"purchaseId":"p1","sku":"coffee"}'
#   curl -X DELETE localhost:4000/grants/<id>                    → revoked; next buy refuses

For reviewers — the detail

Shape: grants.ts + grants-serve.ts mirror orders.ts + orders-serve.ts (spec 009's
two symmetric resources). The grant is the durable authority (status pending → authorized | denied, * → revoked); the AP2 Intent Mandate is the artifact it carries
(grant.intentMandate), dev-sealed at approve time — the wallet-custody increment swaps its
internals to real key-signing without changing this surface.

Engine reuse (#104 decision 4): spends run through the already-merged delegated-draw seams —
sealIntent / signDraw / completeOrder / the revocation + committed-draw ledger. The shared
completion seam is what refuses age-restricted and custom-gated items on autopilot (step-up) —
this PR pins that from the grants surface rather than re-implementing it. Spend completions route
through orders._complete, so order.settled and the #102 webhook fan-out fire for delegated
spends exactly as for human-present orders.

One deliberate deviation from the plan: rails-backed approval for policy-gated grants was
descoped (recorded in docs/superpowers/plans/2026-07-23-grants.md). A second mountCeremony
would collide with orders' rail routes on the same app; the composite mount belongs to the
wallet-custody increment. Until then the gated approve path is fail-closed (403) and bypass-tested.

Key custody note: unlike DelegatedGate (in-process, non-extractable key), a grant must
rehydrate in a worker process, so the delegate private key is stored in the grant store
(DelegatePrivateJwk, documented) — exactly what server-issued-demo claims. Inject a shared
grantStore / revocationStore for multi-instance; in-memory defaults otherwise.

Bypass tests — each verified RED with its control deleted:

  • pending grant refuses to spend (status gate deleted → red)
  • same-key retry replays once-charged (replay pre-read deleted → red)
  • revoke: spend refusal + status (both writes deleted → red) · never-resurrect (_authorize pending-only guard deleted → red)
  • policy-gated grant refused on demo approve (isGated fence deleted → red)
  • budget / per-spend / age-step-up pinned end-to-end (engine checks, re-asserted from this surface)

Files: new money.ts (+test), grants.ts, grants-serve.ts (+tests), examples/grants-preapproved/;
modified client.ts (opts catalog/grantStore/revocationStore, grants wiring), types.ts,
index.ts, delegated.ts (exports buildCatalog — no behavior change), orders-serve.ts (exports
isGated — no behavior change), READMEs.

Closes #104 · under #97 · epic #12 · spec 009

🤖 Generated with Claude Code

https://claude.ai/code/session_014MphtihdwkALRVgLhhW3dm

dzuluaga and others added 7 commits July 23, 2026 21:30
Removed in the #98 review as unwired; grants (budget / perSpend / remaining)
is the consumer that earns it back. The one conversion to the repo's
dollar-number amounts lives in grants.ts only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MphtihdwkALRVgLhhW3dm
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
gr_ ids, approveUrl minted after awaited persistence (same control as
orders.create), terms echoed as Money, typed not-found on retrieve.
Client wiring: catalog / grantStore / revocationStore options (additive,
in-memory defaults); spend completions will route through orders._complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MphtihdwkALRVgLhhW3dm
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
items re-priced from the client catalog (never a caller amount, invariant 2);
idempotencyKey namespaced per grant (invariant 4) with replayed:true on a safe
retry — one charge, ever; over-total → budget-exceeded, over-cap →
per-spend-exceeded; age + custom gates step up to a human via the shared
completion seam; completions route through orders._complete so order.settled
and webhook fan-out apply to delegated spends. Status gate + replay pre-read
verified RED when deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MphtihdwkALRVgLhhW3dm
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…fusal paths

Writes the revocation ledger (the draw engine's authority — a stale handle
refuses) AND the stored status (what retrieve/UIs read); a revoked grant is
never resurrected (_authorize seals only pending). All three controls
verified RED when deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MphtihdwkALRVgLhhW3dm
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…enced for gated policies)

Approve / decline / status routes; an ungated grant authorizes end-to-end
from the button, a policy-gated grant is REFUSED there (403, fail-closed —
same rule as orders' instant-demo place path; rails-backed grant approval is
the wallet-custody increment). Decline: pending → denied, never resurrected.
The fence verified RED when deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MphtihdwkALRVgLhhW3dm
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MphtihdwkALRVgLhhW3dm
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MphtihdwkALRVgLhhW3dm
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
credentagent-demo Ready Ready Preview, Comment Jul 24, 2026 12:36pm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 19d8078c8a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +299 to +301
const orderId = `${this.id}-${idempotencyKey}`;
const prior = await this.deps.readSpend(orderId);
if (prior) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate grant replay records from ordinary orders

completedStore is shared with orders, while orders.create() permits caller-supplied IDs. If an ordinary completed order has an ID equal to ${grant.id}-${idempotencyKey}, this pre-read treats it as a completed grant spend and returns ok: true, replayed: true without creating a draw or reducing the grant ledger. Use a grant-specific completion namespace/store or validate that the stored completion is a delegated draw for this grant before accepting it as a replay.

Useful? React with 👍 / 👎.

export interface DelegatePrivateJwk extends DelegateJwk {
d: string;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep grant bounds in minor units during spend checks

This converts the cent-exact public budget to a binary floating-point dollar amount, and catalog prices use the same representation. For example, with budget/perSpend of $0.30 and a catalog item priced at $0.10, the third valid spend reaches 0.30000000000000004; checkDraw and commitDraw compare that value directly to 0.3 and reject it as over-total. Preserve integer minor units (or normalize every catalog total and ledger comparison) at this seam so exact-budget purchases are not spuriously refused.

Useful? React with 👍 / 👎.

…tant tier

Security:
- spend() validates each quantity as a positive integer, closing a below-cost
  settlement: qty 0.1 of a $40 item settled at $4, and a negative-qty line in a
  multi-item cart netted to a hidden discount that committed a draw. Both refuse
  "invalid-quantity" now (bypass-tested, red when the guard is deleted).
- per-grant KeyedMutex serializes every lifecycle + spend transition, closing the
  revoke∥authorize TOCTOU (the kill switch could resurrect) and the double-POST
  double-seal that rebound the ledger to an empty budget. Concurrent same-key spends
  now collapse to one charge with a clean replay (pins the mutex, red when removed).

Correctness:
- integer-cents end-to-end on the grants path (catalog, sealed bounds, draws) — the
  Money↔dollars seam no longer re-introduces binary-float drift; exact-boundary spends
  ($4.90×3 vs $14.70) pass. Settled records store dollars (consistent webhook unit).
- replay is checked BEFORE the lifecycle gates, so a worker retrying a settled spend
  after revoke gets replayed:true (history isn't rewritten), not a false "revoked".
- same idempotencyKey + different items → "idempotency-conflict" (Stripe's rule),
  not a silent replay of the old spend. Missing key / empty items throw.
- create() rejects a non-positive or non-USD budget/perSpend at configure time.

DX / honesty-in-types:
- dropped the ambiguous bare-callable usd(n); the unit is always explicit
  (usd.dollars / usd.cents); .dollars rejects a genuine sub-cent input.
- trustLevel and the spend code are closed unions (GrantTrustLevel, SpendCode), not
  bare string. The not-found handle never throws (terms/approveUrl are undefined).
- README: runnable Grants snippet (imports, grant.id), the full door with
  remaining/retryable, a Grants-vs-DelegatedGate chooser, Webhooks promoted to a
  top-level section, and the API-surface stanza updated.

Tests: +18 (quantity, exact-boundary money, idempotency correctness, concurrency,
denied-terminal, ledger-write mutation, not-found handle). Gate 391 + storefront 86
= 477 pass; all three example smokes pass. Six new controls verified red when deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MphtihdwkALRVgLhhW3dm
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
@dzuluaga

Copy link
Copy Markdown
Contributor Author

Review addressed (a7af2c7)

Ran a multi-agent, ergonomics-first review over this branch; 29 findings survived adversarial verification. Fixed the blocking + important tier. Two security bugs I reproduced before fixing (both new in this PR's multi-item spend path):

  • Below-cost settlement via quantity. qty: 0.1 of a $40 item settled at $4; a [espresso×1, coffee×−20] cart netted to a hidden $20 discount and committed a draw. Now: spend() validates each quantity as a positive integer → invalid-quantity. Bypass-tested (red when the guard is deleted).
  • Lifecycle TOCTOU. revoke() ∥ _authorize() could end authorized-and-spendable (kill switch resurrects), and a concurrent double-approve rebound the ledger to an empty budget (allowed $60 on a $40 grant). Now: a per-grant mutex serializes every lifecycle + spend transition; concurrent same-key spends collapse to one charge with a clean replay (pins the mutex).

Also fixed: integer-cents math end-to-end (no float drift at the Money↔dollars seam; exact-boundary spends pass), replay checked before the lifecycle gates (a settled spend replays even after revoke — history isn't rewritten), same-key/different-items → idempotency-conflict, create() validates budget/perSpend, the ambiguous bare-callable usd(n) is gone (unit always explicit), trustLevel/spend code are closed unions, and the not-found handle never throws. README: runnable snippet, full door (remaining/retryable), a Grants-vs-DelegatedGate chooser, Webhooks promoted to its own section.

Tests: +18 (quantity, exact-boundary money, idempotency, concurrency, denied-terminal, the revoke-ledger mutation gap, not-found). Gate 391 + storefront 86 = 477 pass; all three example smokes pass. Six new controls each verified red when deleted.

Deliberately deferred (unchanged from the plan's recorded scope): rails-backed approval of policy-gated grants (fenced 403), agent-held signing key, real key-signed mandates — all wallet-custody-increment work. Cross-process lifecycle serialization (the mutex is in-process) is noted as a follow-up; cross-process budget safety already rides on the engine's atomic single-use consume.

@dzuluaga dzuluaga closed this Jul 25, 2026
dzuluaga added a commit that referenced this pull request Jul 28, 2026
…cents (#104) (#135)

* fix(grants,#104): serialize concurrent same-key spends + price in integer cents

Two correctness bugs shipped in the just-merged grants feature (PR #112), both found by
comparing against the closed alternative implementation (PR #106) and reproduced against
the merged code. This ports #106's fixes forward with NO public API change — additive,
plain-dollar surface unchanged.

1. Concurrent same-key spends were misreported as "revoked". Two spend() calls with the
   SAME idempotencyKey that overlap in time both missed the in-memory idempotency cache
   and reached the engine; the loser hit the atomic single-use ledger's "consumed" branch,
   which the spend door maps to "revoked"/terminal — telling an unattended agent the grant
   is dead when nothing was revoked and the purchase actually succeeded once. A per-grant
   async mutex now serializes each grant's spend + lifecycle transitions, so a losing retry
   replays the original outcome cleanly (ok, replayed:true). Money was never double-charged
   (the ledger is atomic); the bug was the misreport + broken retry-safety under concurrency.

2. Exact-budget spends on non-round prices were falsely refused. Pricing ran in
   floating-point dollars, so 4.9 + 4.9 + 4.9 === 14.700000000000001, which exceeds a $14.70
   budget and refused the third spend with budget-exceeded. The engine now receives the
   catalog + per-grant caps in integer cents and its amounts are converted back to dollars at
   the door, so every cap comparison is exact. The public API stays plain dollars (no Money
   type — the spec-009 FR-005 decision stands).

Adds the concurrency + money-boundary tests the merged suite lacked (which is exactly why
these shipped): three load-bearing bypass tests (each verified RED with its control reverted,
then restored) plus two regressions. delegated.ts — the unit-agnostic engine — is untouched.

Known follow-up, NOT in this PR: grants are in-memory only, so "authorize now / spend later
from a worker" (spec-009 FR-007) holds only within one process; a shared store + CAS is the
durable multi-instance step (see the issue #104 comparison comment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>

* fix(grants,#104): address Codex review — revoke-wins mid-spend, live cents catalog, sub-cent reject

Three follow-ups on PR #135 from the automated Codex review.

1. P1 — revoke-wins mid-spend. #135 wrapped revoke() in the per-grant mutex, so a revoke
   issued while a spend was in flight QUEUED behind that spend and could not reach the
   engine's revocation ledger until after the draw committed — the spend settled after
   revocation, which spec 009 FR §136-140 requires be refused. The ledger revoke now runs
   IMMEDIATELY, outside the per-grant queue, so an in-flight spend's atomic settle-time
   re-check sees it and refuses; the status flip + the authorize-race re-revoke stay
   serialized. The same-key serialization from #135 is unchanged.

2. P1 — live catalog re-pricing. #135's centsCatalog() snapshotted prices at first engine
   construction, so a host that re-priced an item in memory kept spending at the stale
   price (and a sealed cap was enforced against it). Replaced with a live cents VIEW (a
   Proxy) the engine reads per spend, restoring the pre-#135 per-read behavior — a
   re-price, or a newly-added item, is honored at the next spend.

3. P2 — sub-cent inputs. toCents() silently rounded ($0.006 -> $0.01, 1.005 -> $1.00).
   Per the DX rubric it now REJECTS sub-cent precision with a clear error at the earliest
   point — grant caps at create(), catalog prices when they are priced — rather than
   silently changing the value.

Each fix has a load-bearing bypass test (verified RED with its control reverted, then
restored): a spend refused by a revoke landing mid-flight; a re-price honored at the next
spend with the cap enforced against the live price; sub-cent rejected at config and at
pricing. delegated.ts stays untouched; the public API stays plain dollars.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>

---------

Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Grants — approve a spending limit once, let the agent buy against it later (spec 009, human-not-present half)

1 participant