Skip to content

feat(gate,storefront): durable grant store — cross-instance grant persistence (#152) [DRAFT] - #153

Draft
dzuluaga wants to merge 4 commits into
mainfrom
feat/152-grant-store
Draft

feat(gate,storefront): durable grant store — cross-instance grant persistence (#152) [DRAFT]#153
dzuluaga wants to merge 4 commits into
mainfrom
feat/152-grant-store

Conversation

@dzuluaga

@dzuluaga dzuluaga commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

DRAFT — preservation PR, not ready to merge. This captures a prior-session agent's durable-grant-store work as a reviewable unit so it isn't stranded. It builds and tests green as authored, but it needs the dedicated #152 review pass before it can land (see "Open for the real review" below).

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.

  • Optional + zero-config default: with no store configured, grants stay in-memory exactly as today.
  • redisGrantStore (Upstash): a ready backend, keyed under its own namespace so a shared Redis database stays isolated per deployment.
  • Faithful rehydration: a grant reconstructed on a fresh instance re-seeds its ledger to the known cumulative spend, so remaining and the budget cap are honest — a rehydrated grant can't over-spend just because a new instance started empty.

What's here

  • packages/credentagent-gateGrantStore / GrantSnapshot interfaces, a MemoryGrantStore, a grantStore? option on the grants engine, and the DelegatedGate.rehydrate() / seedSpent() seam for faithful ledger reconstruction.
  • packages/credentagent-storefrontredisGrantStore (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() + the grantLifecycle derivation (commit "grants: expose live budget usage + the display lifecycle"). This is a functional dependency, not just a merge-context one: the durable-store tests assert await 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 off main would 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 onto main once #151 lands). Flagging so nobody double-counts it.

Deferred from this PR: the durable-grants quickstart wiring (examples/quickstart/server.mjs, was the branch's 3cbefc7) 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.ts overlap: this touches packages/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.
  • Security invariants to confirm: durable grant state is security-adjacent (cross-instance authority). The dedicated pass must confirm the per-grant fail-closed behavior and that store keys are scoped per grant/session, never process-global (Security invariant 4) — with load-bearing bypass tests, not just happy-path round-trips.
  • Honesty: 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). Stays trust_level: "server-issued-demo". Copy/tests must not imply signed persistence.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y5Y1aX9ffAF26hP4fgQFi7

dzuluaga and others added 4 commits July 28, 2026 19:14
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>
@vercel

vercel Bot commented Jul 29, 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 29, 2026 12:16am

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>
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.

1 participant