Skip to content

Instance-per-identity (exploration): one SDK instance = one identity - #1169

Draft
orveth wants to merge 22 commits into
masterfrom
instance-per-identity
Draft

Instance-per-identity (exploration): one SDK instance = one identity#1169
orveth wants to merge 22 commits into
masterfrom
instance-per-identity

Conversation

@orveth

@orveth orveth commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Instance-per-identity (exploration)

An exploration PR (petar's "examine what it looks like") of binding one SDK instance to exactly one identity. Instead of serializing the cross-user auth transition (sol's B3 / #1166 finding [37]), the instance never transitions identities at all — a session end disposes it and the host builds a fresh one. Stacked on 1167-session-fence-r4.

Not a final build. Scope is the three items below; gaps are flagged as notes, not fixed.

What it shows

SDK (packages/wallet-sdk)

  • signIn / signUp / signUpGuest throw a new InstanceAlreadyUsedError once an identity was established (assertUnused). A failed login that never established does not consume the instance.
  • The instance is bound to the established identity id. Any applySessionFromServer apply resolving to a different id is refused — even after the session has gone anonymous — and the foreign identity's Open Secret tokens are revoked (os.signOut) so a rebuild can't restore them.
  • signOut is terminal: clears per-session caches (endSession) then disposes the instance (requestDisposeAgicashSdk.dispose()); every later call throws DisposedError.

Web (apps/web-wallet)

  • sdk.client.ts exposes the singleton as a live binding + rebuildSdk() (dispose current → create fresh; concurrent calls coalesce). All 7 consumers read sdk in-function, so they pick up the replacement with no call-site changes.
  • useSessionEndCleanup rebuilds on every session end (sign-out and expiry), before the auth query re-runs init() — so the next sign-in lands on an unused instance.
  • The protected subtree is re-keyed on user.id (<Wallet key={user.id}>) — an identity change remounts it, resetting session-derived state and re-subscribing SDK event handlers to the fresh instance.

What it deletes / obviates

  • The D5 transition-suspension design is no longer needed: there is no in-instance transition to serialize, so beginTransition/rollback collapses to assertUnused + rebuild.
  • The different-user re-key-in-place path in applySessionFromServer is removed.

Cross-model review (run before hand-off)

  • codex-review (deep, gpt-5.5) — 1 HIGH: the different-user branch ended only the in-memory session, leaving the foreign identity's tokens in storage → the web rebuild's init() could restore that identity. Fixed: bind-to-id + revoke tokens + refuse-when-anonymous (regression test + red-on-base).
  • Adversarial Opus pass — F2 (a foreign identity via unguarded completeGoogleAuth on a used-but-anonymous instance) was the same hole → closed by the same fix. F3 (concurrent rebuildSdk could throw from create()) → fixed (coalescing guard). Everything else held. Its sharpest correctness point is the flag below.

⚠ Flag (not fixed — process-global caches)

The adversarial pass confirmed the charter's suspicion: a fresh instance does NOT sanitize process-global state. dispose()/rebuild clears only per-instance state; the module-global caches — notably cachedCAT (lib/agicash-mint-auth-provider.ts, not keyed by user), the spark-wallet map, and the feature-flag store — are cleared only by onSessionEnded, never by dispose. So identity isolation still rests on onSessionEnded running before every rebuild, not on the rebuild itself. That holds today (every rebuildSdk is reached via useSessionEndCleanup, always preceded by a session-end that wipes those caches), but the "fresh instance = clean slate" intuition is wrong for globals: instance B shares them with disposed instance A. Recommendation: dispose() should defensively run the same process-global wipe (or we narrow the claim). Left as a note per the scope-lock — happy to fix if you want it in.

Open questions for petar / jbojcic

  1. completeGoogleAuth is unguarded (scope-lock was signIn/signUp/signUpGuest). The apply-layer fence now refuses a foreign identity it would surface (safe — user lands anonymous, tokens revoked), but the verb itself doesn't throw early. Add assertUnused to it (a clean loud throw instead of a silent refuse), here or follow-up?
  2. Guest→full conversion — confirmed safe. The adversarial pass read the Open Secret source: guest→full is an in-place upgrade that preserves user.id, so the different-user fence never trips on conversion and key={user.id} doesn't spuriously remount. Even if it were wrong, it fails safe (session ends, no identity mix). Noted here since the design doc left it open.
  3. QueryClient stays root-mounted across rebuilds; existing queryClient.clear() (sign-out) + evictDerivedKeyQueries (cross-user) handle the cache, so the rebuild doesn't touch it. Remounting <Wallet> resets React state, not the query cache — intentional. OK?
  4. Live-binding vs accessor. Live-binding keeps the diff minimal but the "instance can change" dependency is implicit; a getSdk() accessor would make it explicit at ~25 call-site edits. Preference?

Verification

  • bun run fix:all → rc 0 (2 pre-existing vite-env.d.ts warnings, untouched).
  • wallet-sdk 117 pass / 0 fail; web 37 pass / 0 fail.
  • Red-on-base (each new guard reverted individually vs the committed change → its test goes red, then restored green): assertUnused; signOut dispose; different-user fence; and the review fix's two halves (revoke-tokens + refuse-when-anonymous).

Reality class: BUILT-BUT-OFF — compiles, unit-suites green, red-on-base proven; not exercised end-to-end in a running app (no e2e run; web remount + rebuild-on-signout belong in web-wallet-e2e, out of scope for the spike).

🤖 Generated with Claude Code

orveth and others added 22 commits July 19, 2026 10:49
Add createSessionKeys: per-session memoized getters for the encryption
keypair, cashu seed, spark mnemonic, cashu locking xpub, and spark identity
public key, derived from Open Secret. Each memo is generation-fenced so a
derivation started before reset() cannot repopulate the cache for the next
session, and rejections are not cached so a retry can recover.

Wire keys.reset() into the AgicashSdk onSessionEnded teardown alongside the
existing session-token, spark-wallet, and mint-auth-token clears, so a signed-in
user's key material never survives into the next login. The accounts and user
namespaces consume these getters in later commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement createAccountsApi: get(id), list() (session-gated for the userId),
and cashu.add(params) over an internal AccountRepository built from the db,
session keys, and spark config. The namespace returns the domain account types
directly per the accounts contract (#1166) — no projection mapping. cashu.add
re-injects type:'cashu' and the session userId before the service call, and
list()/add gate on the session while get() relies on RLS (repository posture).

getRepository is exposed alongside the api so the /temporary bridge and the
user namespace's ensure() build the repository through one path.

Pin AddCashuAccountParams to { name, mintUrl, currency, purpose } and wire the
accounts field into AgicashSdk, replacing the not-implemented getter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expose getInternalAccountRepository through '@agicash/wallet-sdk/temporary':
a module-scoped accessor that hands unmigrated receive/send flows and realtime
row mapping the live instance's internal domain accounts repository, built
through the same path as sdk.accounts.*. It throws 'No live AgicashSdk instance'
before create and after dispose (the module reference is cleared on dispose
alongside liveInstance), so the domain repository never leaks onto the public
AgicashSdk surface. Removed at step 18 when those flows read from the SDK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDK's user.ensure() needs the retry helper the web owned, so lift withRetry
and delay from apps/web-wallet/app/lib into @agicash/utils (named exports, barrel
re-exported) and delete the web copies. Flip the remaining consumers — the cashu
receive-quote hook, the protected-route bootstrap, and the e2e Open Secret
fixture — onto @agicash/utils, and add the workspace dependency to the e2e
package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add user.ensure(params) to the contract and implement it on the user namespace:
it derives the encryption public key, cashu locking xpub, and spark identity
public key, upserts the user row (creating the default accounts and persisting
the keys on first sign-in) through the base UpsertUserRepository, and returns
{ user, accounts } domain-typed for the host to seed its caches. The upsert
returns domain accounts already, so ensure returns them as-is — no mapping.

The key-derivation batch and the upsert each run under withRetry (matching the
resilience the host's query layer gave master); the upsert retry is Zod-aware so
a validation error fails fast. EnsureUserParams carries the replayed pending-terms
acceptance timestamps, distinct from acceptTerms' in-session boolean stamps.
ensure builds its repository through the accounts namespace's getRepository, so
the whole instance shares one construction path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flip the accounts data path onto the SDK namespace: accountsQueryOptions'
queryFn calls sdk.accounts.list(), useAccountOrNull's lazy fetch calls
sdk.accounts.get(id), useAddCashuAccount calls sdk.accounts.cashu.add (callers
drop the now-implicit type: 'cashu'), and the realtime ACCOUNT_CREATED/UPDATED
handlers map rows through getInternalAccountRepository().toAccount. The cache
still holds domain accounts, so every consumer (getAccountBalance, wallet/proofs
readers) is unchanged.

account-service-hooks is deleted (the service lives behind sdk.accounts.cashu.add)
and account-repository-hooks moves to features/receive, its only remaining
consumers being the unmigrated cashu receive-quote and receive-swap repos.

Extract getExtendedAccounts and isDefaultAccount from UserService into standalone
pure functions exported from the package root, and flip their consumers (the
accounts hooks, the claim route, and the SDK-internal claim service).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Route _protected's bootstrap through sdk.user.ensure(): it replaces the inline
key-derivation + upsert, seeds the user and accounts caches from the public
return, and keeps master's structure (cache short-circuit, session-token warm,
ensureBreezWasm placement, conditional seed on the upsert branch).

Source the web-side key queries from the live instance's session keys via a new
getInternalSessionKeys /temporary accessor: useEncryption collapses to a single
['encryption'] query over getEncryption(), and the cashu-seed and spark-mnemonic
query fns delegate to the same getters. The per-key encryption query options and
their hooks are deleted (only encryption-hooks and the two route warms read them),
sparkIdentityPublicKeyQueryOptions is dropped (its only reader was the ensure
warm), and xpubQueryOptions is untouched. The bootstrap warms encryption, seed,
and mnemonic concurrently with ensure() so the unmigrated receive/send/claim
repos keep master's warm-cache and fail-in-the-middleware behavior. The web
defaultAccounts copy is removed now that ensure() owns it SDK-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bring the step-6 decision record onto the branch and append a supersession
entry: the maintainer's contract-level domain-types ruling (d5d2f18, #1166)
superseded B1's projection apparatus after the slice was first built, so
sdk.accounts.* returns domain types directly (no mapper, no checked cast), B5
balance reads revert to getAccountBalance, and the runtime-fat reality-class
record and the step-18 physical strip retire as moot. The record's prior entries
stand as the decision trail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… scope

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…raming

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The web builds its account repository from inputs it already holds
(db client, session-key getters via /temporary), the same shape the
receive feature uses; the SDK-side repository accessor and its
/temporary export are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ctly

The encryption/seed/mnemonic queryFns import the SDK's derivation
functions via /temporary and cache once per session in TanStack; the
encryption queryFn derives-then-wraps so raw key bytes stay out of the
query cache. The session-keys instance accessor and its module holder
are removed — session keys remain SDK-internal for provision/accounts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fence

Round-2 hardening after the cross-model review of the session-lifecycle fix:

- createMemo checks the session signal before serving a cached value, so a getter
  reached while the signal is aborted — including reentrantly, from a synchronous
  abort listener during reset() before the memos clear — rejects with
  SessionEndedError instead of returning stale key material.
- getEncryption returns a revocable facade: a handle a host retains keeps working
  while its session is live but rejects once that session ends or the instance
  disposes, so cached encrypt/decrypt closures can't run on a dead session's keys.
- The web evicts the infinity-stale derived-key queries (encryption / cashu seed,
  xpub, private-key / spark mnemonic) on a login transition, so a cross-user login
  without a prior sign-out (sign-out already clears the cache) re-derives for the
  new user instead of holding a revoked facade.
- reset() is a no-op after terminal dispose (the aborted signal stays); export
  SessionEndedError from the package root with retry guidance; offer-path adds an
  explicit abort check after the uncancellable mint fetch; corrected the fence
  wording to best-effort cancellation with a no-cross-user-result guarantee.

Retires blockers 1 and 2 from the round-2 verdict. The os.signIn cross-user
window (blocker 3) is an auth-service change and is client-gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The create query receives the same abort signal, and an already-aborted
signal never issues the request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One SDK instance serves one identity — the structural kill of the cross-user
session window (sol's B3 / #1166 finding [37]) rather than serializing it.

- signIn/signUp/signUpGuest throw InstanceAlreadyUsedError once an identity was
  established on the instance; authenticating as another requires a fresh one.
- signOut is terminal: it clears per-session caches then disposes the instance
  (requestDispose wired from AgicashSdk).
- a restore/refresh resolving to a different user ends the session and emits
  auth.session-expired instead of re-keying in place.

completeGoogleAuth is left unguarded per the exploration scope-lock (flagged in
the PR body). Exploration PR for petar/jbojcic to examine the shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The SDK is one-instance-per-identity (see the wallet-sdk commit): a session end
disposes the instance, so the web builds a fresh one for the next identity.

- sdk.client.ts exposes the singleton as a live binding plus rebuildSdk(), which
  disposes the current instance and installs a fresh one.
- useSessionEndCleanup rebuilds on every session end (sign-out and expiry) before
  the auth query re-runs init(), so the next sign-in lands on an unused instance.
- the protected subtree is re-keyed on the user id so an identity change remounts
  it, resetting session-derived state and re-subscribing SDK event handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n tokens (review)

Cross-model review (codex deep + adversarial Opus) found the different-user fence
was incomplete: it ended the in-memory session but left the foreign identity's
Open Secret tokens in storage, so the web rebuild's init() could restore that
identity; and it keyed on session.isLoggedIn, so a foreign apply on a
used-but-anonymous instance (reachable via the unguarded completeGoogleAuth after
a full-account expiry) bypassed it.

- Bind the instance to the established identity id (not a boolean); refuse any
  apply resolving to a different id, even once the session has gone anonymous.
- Revoke the foreign tokens (os.signOut) in the refuse branch so a rebuild cannot
  restore that identity from storage.
- Coalesce concurrent rebuildSdk() calls so a sign-out racing an expiry handler
  cannot throw from create().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
agicash Ready Ready Preview, Comment Jul 22, 2026 9:24am

Request Review

@supabase

supabase Bot commented Jul 22, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project hrebgkfhjpkbxpztqqke because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Base automatically changed from 1167-session-fence-r4 to sdk/accounts-slice July 27, 2026 13:27
@orveth
orveth force-pushed the sdk/accounts-slice branch from 75a10a8 to 8203356 Compare July 28, 2026 13:32
@jbojcic1
jbojcic1 force-pushed the sdk/accounts-slice branch from 8203356 to cfefd30 Compare July 28, 2026 14:48
Base automatically changed from sdk/accounts-slice to master July 28, 2026 14:50
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