pgmem
Guides

Multi-tenancy & isolation

Isolate tenants with group_id.

Every memory operation in pgmem is scoped by group_id — it is the tenant boundary. Get it right on every call and tenants can't see each other's memory; the session layer enforces it transitively so you don't have to thread it everywhere.

Prerequisites

  • A group_id per tenant (a customer, an org, a workspace — whatever your isolation unit is).

Steps

Scope every entry point by group_id

Sessions, graph ingestion, and search all take it:

session = await mem.session.create(group_id="tenant-acme", user_id="user-1")
await mem.add_episode(content=..., group_id="tenant-acme")
results = await mem.search("...", group_ids=["tenant-acme"])

Resume is group-scoped on purpose

resume requires the group_id and looks the session up scoped to it — a caller who only knows a session_id cannot reach another tenant's session.

session = await mem.session.resume(session_id, "tenant-acme")

The same applies to summarize_saga(saga_uuid, group_id): knowing a saga_uuid is not enough without the owning tenant.

Delete per tenant

await mem.clear_group(["tenant-acme"])

How isolation holds

Top-level rows (sessions, entities, edges, sagas) carry group_id and are filtered on it. Session child data (turns, state nodes, facts, compactions) deliberately has no group_id column — it's reached only through a session handle, which was itself minted by a group-scoped lookup. So the boundary is transitive through the handle: once you hold an authorised session, its turns and facts are already scoped, and you never pass group_id to per-turn calls.

Gotchas

Never reuse a session handle across tenants, and never derive a handle from an untrusted session_id without the tenant's group_id. The handle is the authorisation — treat it like one.

  • clear_group requires explicit group_ids; there is no "all groups" sweep by design.
  • user_id scopes within a group (e.g. per-user sagas); it is not a substitute for group_id as the isolation boundary.

On this page