feat(gate,storefront): durable grant store — cross-instance grant persistence (#152) [DRAFT] - #153
Draft
dzuluaga wants to merge 4 commits into
Draft
feat(gate,storefront): durable grant store — cross-instance grant persistence (#152) [DRAFT]#153dzuluaga wants to merge 4 commits into
dzuluaga wants to merge 4 commits into
Conversation
The grant handle already knew its sealed bounds (budget, per-purchase cap,
allowed items) but not how much had actually been spent — the number a
budget widget must show. This adds, additively:
- grant.usage() → { budget, spent, remaining } in dollars, read from the
engine's committed-draws ledger (the same source spend() already returns
`remaining` from), so a display never re-derives money the engine owns.
- grantLifecycle({ status, budget, remaining }) → the one place that maps a
grant to its display state (pending | active | low | exhausted | revoked |
denied). "low" is <= 20% of budget remaining; "exhausted" is spent out.
Deriving it once here keeps every surface — server projection, tests,
custom views — from re-deriving "low"/"exhausted" and drifting.
Both are covered by tests, including the low/exhausted thresholds pinned
from both sides.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…e on any instance The grants engine kept every grant in process memory, so on a serverless deployment a step that landed on a different instance than the one that created the grant saw "unknown grant" (the known #104 follow-up). This adds an optional durable store, injected the same way as the verification store: new CredentAgent({ grantStore }) // default stays in-memory, single-process - A small typed async KV interface (GrantStore) keyed by grant id, plus its JSON-safe snapshot (status, sealed bounds, drawn-down total, idempotency door cache) and an in-memory reference impl (MemoryGrantStore). - Every state change persists (create, authorize, deny, revoke, each spend outcome incl. the door cache + spent total). - On a retrieve/spend that misses local memory, the grant REHYDRATES from the store: it re-seals the engine from the stored bounds and re-draws the stored spend (chunked under the per-purchase cap) so remaining AND the budget cap are faithful. This re-seals a dev-signed intent — it does not reconstruct signed continuity with the original consent; it stays at trust_level server-issued-demo (documented on the rehydrate seam). - Fail-closed throughout: a store read/write error refuses the operation rather than falling back to a stale in-memory authorization. Revoke-wins survives across instances because the spend door re-reads the store's status before it spends — a grant revoked on instance A can't be spent on instance B. Additive: with no store injected, behavior is byte-for-byte the in-memory path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…shared store) Simulates two serverless instances by pointing two CredentAgent clients at one shared grant store. Covers: create+approve on A then spend on B (B rehydrates, remaining faithful); the budget cap holding across instances; idempotency replaying identically after rehydration; and rehydration staying honest (trust_level unchanged). Two are bypass tests that go red when their control is removed — the cross-instance revoke-wins (B refuses a spend on a grant A revoked) and fail-closed (a store read error at spend refuses, never falls through to the cached authorized engine). Verified red-on-revert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…eir own namespace
An Upstash-compatible implementation of the gate's GrantStore, reusing the same
redis plumbing as redisStorage. `redisGrantStore({ url, token, namespace })`
plugs into `new CredentAgent({ grantStore })`; `redisGrantStoreFromEnv()` reads
the standard Vercel-KV / Upstash env vars and returns undefined when none are
set (so a caller keeps the in-memory default with no branching). Grants get
their OWN default namespace ("credentagent-grants"), separate from the
storefront's order/cart namespace, so a deployment sharing one Redis database
can't collide keys. Tested: cross-instance round-trip, namespace isolation
(distinct key prefixes), and fail-closed on a backend error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
dzuluaga
added a commit
that referenced
this pull request
Jul 29, 2026
) The grant-widgets branch had accreted a separate, larger feature — a durable (cross-process) grant store, an Upstash redisGrantStore, and quickstart wiring — authored by a prior session and interleaved with the #143 widget commits. A "grant widgets" PR must not also ship a security-adjacent persistence layer, so it gets its own focused review. This reverts the four durable commits (3cbefc7, a31a3e1, 2b2c596, 2e16910) so this PR (#151) is widgets-only. The work is preserved verbatim on feat/152-grant-store (draft PR #153) for the dedicated #152 review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5Y1aX9ffAF26hP4fgQFi7 Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
In plain terms
A spending grant (from #143) is a bounded permission an agent spends against while the human is away. Today grant state lives in memory in one server process. On a serverless deployment (multiple instances that share no memory), an agent that created a grant on instance A and tries to spend on instance B gets "unknown grant" — and, worse, a fresh instance's empty ledger could let a grant over-spend its budget. This adds an optional durable grant store so grant state survives a restart and rehydrates on any instance, with the cumulative-budget enforcement preserved across instances.
redisGrantStore(Upstash): a ready backend, keyed under its own namespace so a shared Redis database stays isolated per deployment.remainingand the budget cap are honest — a rehydrated grant can't over-spend just because a new instance started empty.What's here
packages/credentagent-gate—GrantStore/GrantSnapshotinterfaces, aMemoryGrantStore, agrantStore?option on the grants engine, and theDelegatedGate.rehydrate()/seedSpent()seam for faithful ledger reconstruction.packages/credentagent-storefront—redisGrantStore(Upstash), namespaced.For reviewers — the detail
Preserves issue #152 (durable grant store, under #104). The commits were authored by an autonomous prior-session build agent; this PR cherry-picked them onto a fresh branch off
main(unchanged content, original sign-off preserved) so the work is reviewable rather than lost. Verified as authored: build + lint green, 612 tests / 39 files pass.Why the first commit here is a #143 widget commit. The durable store was built directly on top of the grant-widgets branch's small read-only gate accessor
grant.usage()+ thegrantLifecyclederivation (commit "grants: expose live budget usage + the display lifecycle"). This is a functional dependency, not just a merge-context one: the durable-store tests assertawait grant.usage()to prove cross-instance spend is faithful, so without that commit this branch does not build/test green — a strictly-durable-only branch offmainwould have red tests. It is therefore carried here as a prerequisite — the SAME commit is in the grant-widgets PR #151. Whichever merges second, that commit becomes an empty no-op; de-duplicate at merge (or rebase this branch ontomainonce #151 lands). Flagging so nobody double-counts it.Deferred from this PR: the durable-grants quickstart wiring (
examples/quickstart/server.mjs, was the branch's3cbefc7) is intentionally NOT included — it depends on the grant-tools quickstart wiring that lives in #151. It's example-only, and the quickstart installs the published packages, so it's a post-publish concern regardless: re-add it after #151 lands.Open for the real review (why this is a draft)
grants.tsoverlap: this touchespackages/credentagent-gate/src/grants.ts, which the just-merged concurrency work (fix(grants): serialize concurrent same-key spends + price in integer cents (#104) #135) and the device-signed-grants PR (Device-signed spending grants: the wallet signs the Intent Mandate first (spec 012, #144) #148) also touch. Needs a knowing merge / rebase.rehydrate()re-seals a dev-signed intent — it does NOT reconstruct cryptographic continuity with the human's original consent (no wallet key to re-bind). Staystrust_level: "server-issued-demo". Copy/tests must not imply signed persistence.🤖 Generated with Claude Code
https://claude.ai/code/session_01Y5Y1aX9ffAF26hP4fgQFi7