credentagent.grants: approve a spending limit once, spend while the human is away (#104) - #106
credentagent.grants: approve a spending limit once, spend while the human is away (#104)#106dzuluaga wants to merge 8 commits into
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 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".
| const orderId = `${this.id}-${idempotencyKey}`; | ||
| const prior = await this.deps.readSpend(orderId); | ||
| if (prior) { |
There was a problem hiding this comment.
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; | ||
| } | ||
|
|
There was a problem hiding this comment.
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>
Review addressed (
|
…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>
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.
What you're approving
credentagent.grantslands alongsideorders;no current caller has to touch anything.
idempotencyKeyis answered once-charged (replayed: true) — no double-buying;Moneyis 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;money moves anywhere (trust level
server-issued-demo, stated on the page and in the types), andthe caps/refusals are pinned by tests that go red when their checks are deleted (verified, listed below).
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
For reviewers — the detail
Shape:
grants.ts+grants-serve.tsmirrororders.ts+orders-serve.ts(spec 009'stwo 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 itsinternals 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 sharedcompletion 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, soorder.settledand the #102 webhook fan-out fire for delegatedspends 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 secondmountCeremonywould 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 mustrehydrate in a worker process, so the delegate private key is stored in the grant store
(
DelegatePrivateJwk, documented) — exactly whatserver-issued-democlaims. Inject a sharedgrantStore/revocationStorefor multi-instance; in-memory defaults otherwise.Bypass tests — each verified RED with its control deleted:
_authorizepending-only guard deleted → red)Files: new
money.ts(+test),grants.ts,grants-serve.ts(+tests),examples/grants-preapproved/;modified
client.ts(optscatalog/grantStore/revocationStore,grantswiring),types.ts,index.ts,delegated.ts(exportsbuildCatalog— no behavior change),orders-serve.ts(exportsisGated— no behavior change), READMEs.Closes #104 · under #97 · epic #12 · spec 009
🤖 Generated with Claude Code
https://claude.ai/code/session_014MphtihdwkALRVgLhhW3dm