Multi-Tenant Memory
How to scope conversations, traces, and preferences to a user identity
in production. Pre-0.4 every consumer rolled their own :User node
and per-user scoping logic; v0.4 makes user identity a first-class
concept.
Goal
Two users sharing a Neo4j instance, with reads and writes scoped per user and zero application-level Cypher:
async with MemoryClient(settings) as client:
await client.users.upsert_user(identifier="sara@omg.com", attributes={"role": "manager"})
await client.users.upsert_user(identifier="liam@omg.com", attributes={"role": "consultant"})
# Sara's conversation, scoped via user_identifier=
await client.short_term.add_message(
"sara-2026-05-01", "user", "Find me a healthcare team",
user_identifier="sara@omg.com",
)
# Liam's conversation, scoped separately via user_identifier=
await client.short_term.add_message(
"liam-2026-05-01", "user", "Find me a fintech team",
user_identifier="liam@omg.com",
)
# Sara's preference, attached to her :User node
await client.long_term.add_preference(
"consultants",
"Senior on healthcare clients",
user_identifier="sara@omg.com",
applies_to=[EntityRef(name="Healthcare", type="Industry")],
)
# Read back: bootstrap query collapses to one call
prefs = await client.long_term.get_preferences_for("sara@omg.com")
Steps
1. Upsert the user
Idempotent — safe to call on every request or session start:
user = await client.users.upsert_user(
identifier="sara@omg.com",
attributes={"role": "manager", "team": "consulting"},
)
Identifiers are typically email addresses, but anything stable works.
The library enforces a unique constraint on User.identifier at the
database level.
2. Pass user_identifier= on writes
user_identifier is an optional kwarg on the short-term,
long-term, and reasoning memory APIs. When
MemorySettings.memory.multi_tenant=True is set, the library
enforces that every write includes a user_identifier; omitting it
raises a ValueError at the call site.
# Short-term: scope conversations per user
await client.short_term.add_message(
"sara-2026-05-01", "user", "Find me a healthcare team",
user_identifier="sara@omg.com",
)
# Long-term: scope preferences per user
await client.long_term.add_preference(
"consultants",
"Senior on healthcare",
user_identifier="sara@omg.com",
applies_to=[EntityRef(name="Healthcare", type="Industry")],
)
# Reasoning: scope traces per user
trace = await client.reasoning.start_trace(
"sara-2026-05-01", "consultant search",
user_identifier="sara@omg.com",
)
3. Read back with get_preferences_for
# All active preferences for Sara
prefs = await client.long_term.get_preferences_for("sara@omg.com")
# Only preferences scoped to Healthcare
prefs = await client.long_term.get_preferences_for(
"sara@omg.com",
applies_to=EntityRef(name="Healthcare", type="Industry"),
)
# Include superseded preferences (history)
prefs = await client.long_term.get_preferences_for(
"sara@omg.com", active_only=False
)
4. Supersede preferences over time
When a user changes their mind, supersede_preference writes
(:Preference)-[:SUPERSEDED_BY]→(:Preference) and sets valid_until
on the old preference so time-travel queries return the right
snapshot:
old = await client.long_term.add_preference(
"consultants", "Junior", user_identifier="sara@omg.com"
)
new = await client.long_term.add_preference(
"consultants", "Senior", user_identifier="sara@omg.com"
)
await client.long_term.supersede_preference(old.id, new.id)
# Active only — returns just the new preference
active = await client.long_term.get_preferences_for("sara@omg.com")
# Time-travel — what did Sara prefer at that instant?
from datetime import datetime, timedelta
snapshot = await client.long_term.get_preferences_for(
"sara@omg.com",
as_of=datetime.utcnow() - timedelta(days=1),
active_only=False,
)
Schema
After v0.4 a multi-tenant graph contains:
-
(:User {id, identifier, attributes_json, created_at})— one per user, identifier-unique. -
(:User)-[:HAS_PREFERENCE]→(:Preference)— written byadd_preference(user_identifier=…). -
(:Preference)-[:APPLIES_TO]→(:Entity)— written byadd_preference(applies_to=[…]). -
(:Preference)-[:SUPERSEDED_BY]→(:Preference)— written bysupersede_preference. -
Preference.valid_fromandPreference.valid_until— bi-temporal interval.