Skip to content

feat(cloud): B2 — per-uid home isolation (AsyncLocalStorage seam) + lazy per-user birth#262

Merged
oratis merged 1 commit into
mainfrom
feat/per-uid-home
Jul 22, 2026
Merged

feat(cloud): B2 — per-uid home isolation (AsyncLocalStorage seam) + lazy per-user birth#262
oratis merged 1 commit into
mainfrom
feat/per-uid-home

Conversation

@oratis

@oratis oratis commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Stacked on #261. The multi-tenant core — the gate before any real cloud user beyond a reviewer.

Design

Two homes, deliberately:

  • lisaGlobalHome() — operator/process home: config.env, accounts.json, device tokens, session secret. Never per-uid.
  • lisaHome() — the ACTIVE home for identity-bearing state (soul, sessions, memory, reflections, kb, autonomy…). AsyncLocalStorage-scoped: a signed-in cloud request enters homeScope with $LISA_HOME/users/<uid> and every downstream path — across awaits — resolves there.

Path constants became functions (SESSIONS_DIRsessionsDir() etc., 65 files) because an import-time constant freezes the home — exactly what made the codebase single-tenant. Module-scope constants that captured the home at import were functionized too (active-session pointer, autonomy dir, embeddings cache, advisor state…).

Server

  • Per-uid ChatCtx {session, history, chain}: each account gets its own conversation, restore-from-disk, and serialized turn queue — one account's long turn no longer blocks another's.
  • Prompt snapshot cache keyed by active home (per-uid souls → per-uid prompts).
  • Lazy per-user birth: first signed-in request births that user's soul in the background (deduped per uid); chatting before it completes runs on the bare prompt and the soul arrives mid-conversation.
  • Account deletion clears the home subtree + in-memory contexts.

Mac edition: zero change

No scope is ever entered → lisaHome() degrades to the global home; the global chat context is byte-for-byte the old code path. Background schedulers (idle/reflect) stay global-only; per-uid autonomy is gated on per-uid budgets (B4+).

Tests

New tenancy suite: scope resolution, await-survival, two tenants writing disjoint souls, cross-tenant reads impossible, isBorn scoping. Full suite 1038 pass / 0 fail.

Notes / follow-ups

  • Firestore for accounts/balances (multi-instance unlock) is deferred to a follow-up PR — file-backed state stays correct under min=max=1, which deploy.sh still pins.
  • GCS /data needs no change: per-uid is just the /data/users/<uid> subtree.

🤖 Generated with Claude Code

…m, lazy per-user birth

The multi-tenant core (PLAN_ACCOUNTS_BILLING §6.2 / PLAN_IDENTITY I3): a
signed-in cloud request now runs inside homeScope with its home rebound to
$LISA_HOME/users/<uid>, and everything downstream — soul, sessions, memory,
reflections, kb, autonomy, skills — reads and writes that user's subtree.

- paths.ts: path CONSTANTS become path FUNCTIONS. Two homes, on purpose:
  lisaGlobalHome() (config.env, accounts.json, devices, session secret —
  never per-uid) vs lisaHome() (identity-bearing state; AsyncLocalStorage-
  scoped). soul/paths.ts + kb/paths.ts converted the same way, plus every
  module-scope constant that froze the home at import time (active-web-session
  pointer, autonomy dir, embeddings cache, advisor state, comparisons,
  scheduled dispatches, idle lock, screen-advisor config).
- server.ts: after auth, an account session enters homeScope (enterWith — the
  scope survives the whole async chain); per-uid ChatCtx {session, history,
  chain} so each account has its own conversation, history restore, and turn
  queue (one account's long turn no longer blocks another's); prompt snapshot
  cache keyed by active home; per-uid lazy birth on first sign-in (background,
  deduped; chat before it completes runs on the bare prompt and the soul
  arrives mid-conversation); account deletion now removes home + in-memory
  contexts via the same helpers.
- Mac edition: zero behavior change — no scope is ever entered, lisaHome()
  degrades to the process-global home, and the module-level global chat
  context is exactly the old code path. Background schedulers (idle/reflect)
  stay on the global context; per-uid autonomy waits for per-uid budgets (B4+).
- Hook env vars keep the LISA_HOME NAME (scripts depend on it); the VALUE now
  follows the active scope.

Tests: new tenancy suite — scope resolution, await-survival, two tenants
writing disjoint souls, cross-tenant reads impossible, isBorn scoping. Full
suite: 1038 pass / 0 fail.

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

oratis commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

🤖 Automated review — ✅ MERGE (nits) — isolation empirically verified

The filesystem home-isolation boundary (this PR's charter) is correct and verified by running the tests, not just reading:

  • tenancy.test.ts 6/6 pass (disjoint soul subtrees, cross-tenant reads impossible, scope survives awaits).
  • enterWith cross-request leak reproduced on Node 26: interleaved/keep-alive requests with accountUid=null correctly resolve to GLOBAL, never a prior request's uid.
  • uid = apple-<sanitized> / em-<hex> — always one fs-safe segment, HMAC-protected in the session token → no ..//absolute traversal.
  • Lazy birth deduped via birthsInFlight.

Nits (non-blocking at reviewer scale):

  • soul/lock.ts:135 + soul/git.ts:30 are the only two module-level constants still frozen at import (→ GLOBAL soul). Not a leak (soul files write per-uid), but every tenant serializes on one global lock and litters .write.lock/.git-write.lock in the global tree. Functionize (contradicts the PR's "all home-capturing constants functionized" note).
  • ctxForRequest (server.ts:340-350) has a same-uid cold-start race → duplicate session; guard with an in-flight promise.
  • promptCache/uidChats are unbounded (no eviction) — add LRU before real multi-user.

⚠️ Out-of-scope but important (pre-existing, NOT introduced here): the process-wide moodBus/eventClients broadcast (server.ts:380-393) fans mood + chat_start/end + idle_message text to all connected accounts regardless of uid — a genuine cross-user content leak. The SSE layer must be tenant-isolated before multi-user launch.

@oratis
oratis changed the base branch from feat/accounts-clients to main July 22, 2026 09:01
@oratis
oratis merged commit 985a234 into main Jul 22, 2026
1 check passed
@oratis
oratis deleted the feat/per-uid-home branch July 22, 2026 09:23
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