Multi-user app with isolation
A SaaS app, a desktop assistant with several profiles, an internal tool with hundreds of employees — all of them are multi-tenant from day one. iLAB Memory is multi-user out of the box: every facade method takes a user_id and the orchestrator scopes the SQL queries automatically. This recipe shows the pattern, the anti-pattern, and the gotchas.
What you'll need
Python 3.11+
hashlib from the stdlib for stable user IDs.
ilab-memory
pip install ilab-memory
A stable identifier
OIDC subject, hashed email, UUID — anything that won't change for the lifetime of the user.
WAL mode SQLite
Enabled automatically by ILabMemory.from_path. Concurrent reads, serialized writes.
Architecture
┌────────────────┐
│ FastAPI app │
└────────┬───────┘
│
one ILabMemory
shared instance
│
┌── ──────▼────────┐
│ ./memory.db │
│ (WAL mode) │
└─────────────────┘
▲ ▲ ▲
│ │ │
user_id user_id user_id
"u_a" "u_b" "u_c"
One instance, many tenants. The orchestrator filters every read and write by user_id — there is no shared row across users (other than the SQLite metadata).
Implementation
1. Pick a stable user_id
user_id is a string. Use anything stable, opaque, and non-PII when possible. A SHA-256 of the user's email + a per-app salt works well; OIDC sub is better when available.
import hashlib
import os
SALT = os.environ["USER_ID_SALT"] # rotate this only on a coordinated migration
def stable_user_id(email: str) -> str:
return hashlib.sha256(f"{SALT}:{email.lower()}".encode()).hexdigest()[:24]
Do not use the raw email as user_id. Logs, backups, and query plans expose it. A hash is opaque, deterministic, and irreversible without the salt.
2. Share a single ILabMemory instance
Construct it once at startup. The orchestrator is single-threaded but SQLite WAL allows concurrent readers — perfect for FastAPI / desktop apps with light concurrency.
from ilab_memory import ILabMemory
mem = ILabMemory.from_path("./memory.db")
For high-write multi-tenant workloads (hundreds of writes per second across many users), consider sharding: one DB file per tenant, looked up at request time. Mentioned as an advanced pattern — see Architecture.
3. Save scoped to a user
Every write takes user_id. The orchestrator stores it as a column on the row — there is no global namespace.
user_id = stable_user_id("alice@example.com")
mem.mem_save(
user_id=user_id,
type="preference",
title="Newsletter opt-in",
content="Subscribed to the weekly digest.",
topic_key=f"user/{user_id}/newsletter",
)
4. Read scoped to the same user
Reads filter by user_id. Memory written under user_id="u_a" is invisible to user_id="u_b" — the SQL WHERE user_id = ? clause is non-negotiable.
results = mem.mem_search(
user_id=user_id,
query="newsletter",
limit=5,
)
# results contain only Alice's observations
mem_get_observation(user_id, observation_id) returns None if the id belongs to a different user — no exception, no leak. This is enforced at the store level.
5. Show per-user stats in the UI
mem_stats(user_id) returns counts (observations, sessions) for one tenant. Perfect for "you have N memories" UI badges or quota dashboards.
stats = mem.mem_stats(user_id=user_id)
print(stats)
# {'observations': 42, 'sessions': 3}
There is no global stats endpoint by design — privacy and tenant isolation come first.
6. Avoid the cross-tenant anti-pattern
Saving two users under the same user_id mixes their memories, breaks search relevance, and can leak in sessions_context. If your app supports multiple identities per human (e.g. work + personal), give each one its own user_id.
# ❌ ANTI-PATTERN — both Alice and her colleague write to "team"
mem.mem_save(user_id="team", ...)
# ✅ Pattern — each human has their own scope
mem.mem_save(user_id=stable_user_id("alice@example.com"), ...)
mem.mem_save(user_id=stable_user_id("bob@example.com"), ...)
Full file
Complete code (copy-paste ready)
from __future__ import annotations
import hashlib
import os
from typing import Mapping
from ilab_memory import ILabMemory
SALT = os.environ.get("USER_ID_SALT", "dev-only-salt")
DB_PATH = os.environ.get("ILAB_MEMORY_DB_PATH", "./memory.db")
def stable_user_id(email: str) -> str:
"""Opaque, deterministic, salted hash. Safe to log."""
return hashlib.sha256(f"{SALT}:{email.lower()}".encode()).hexdigest()[:24]
def onboard(mem: ILabMemory, email: str, prefers_dark_mode: bool) -> str:
user_id = stable_user_id(email)
mem.mem_save(
user_id=user_id,
type="profile",
title="Account created",
content=f"User registered with email <private>{email}</private>.",
topic_key=f"user/{user_id}/profile",
)
mem.mem_save(
user_id=user_id,
type="preference",
title="UI theme",
content=f"prefers_dark_mode={prefers_dark_mode}",
topic_key=f"user/{user_id}/preferences/theme",
)
return user_id
def show_dashboard(mem: ILabMemory, user_id: str) -> Mapping[str, int]:
"""Display counts on the user's settings page."""
return mem.mem_stats(user_id=user_id)
def main() -> None:
with ILabMemory.from_path(DB_PATH) as mem:
alice = onboard(mem, "alice@example.com", prefers_dark_mode=True)
bob = onboard(mem, "bob@example.com", prefers_dark_mode=False)
# Each user only sees their own data
alice_results = mem.mem_search(user_id=alice, query="theme", limit=5)
bob_results = mem.mem_search(user_id=bob, query="theme", limit=5)
assert all(r.id != b.id for r in alice_results for b in bob_results)
print("Alice stats:", show_dashboard(mem, alice))
print("Bob stats:", show_dashboard(mem, bob))
if __name__ == "__main__":
main()
Concurrency note
ILabMemory is single-threaded by design (v0.1). SQLite is in WAL mode, so concurrent reads from multiple threads are safe; writes are serialized by SQLite's locking. For FastAPI workloads with light-to-moderate write traffic, this is enough — wrap heavy writes in run_in_threadpool if needed. For sustained high-throughput multi-tenant write loads, the per-tenant DB pattern (one file per user_id) is the recommended escalation.
What's next?
FastAPI + OpenAI
Multi-user pattern dropped into the chat recipe — user_id flows through POST /chat.
Long-running agent
Same isolation guarantees, applied to a 50+ turn agent loop.
Architecture
Hexagonal store, Braess points, and the rationale behind per-user scoping.
Privacy
<private> blocks, redaction order, and what never reaches disk.