LLMs Technical Reviews

How is memory scoped and isolated?

User / agent / session / tenant scoping; multi-tenancy; access control; privacy controls.

Verdict

Cognee and Honcho enforce isolation in the memory layer itself. Most other projects filter on an id that the caller passes and trust the application to pass the right one.

Access control built in. Cognee stores read, write, delete and share rights as ACL rows per dataset. When the backends support it, each user and dataset gets its own graph and vector database, and authentication is forced on. Honcho uses JWT claims for admin, workspace, peer and session, and keeps a separate collection for each (observer, observed) pair. An empty session allowlist returns nothing, and only explicit conclusions are served under one. Auth is off until you set AUTH_USE_AUTH. MemOS scopes each request to memory cubes (one per user by default). It has scoped, expiring API keys, but AUTH_ENABLED is off by default, and a database per user is opt-in. In claude-mem’s server mode, API keys are bound to a team (and optionally a project) with read and write scopes. Local mode is single-user, scoped by project.

Partition by id. Hindsight filters every query by bank_id and supports tag modes (any, all, the strict variants and exact) inside a bank. A Postgres schema per tenant exists only when HINDSIGHT_API_TENANT_EXTENSION is set. Graphiti uses group_id. On FalkorDB that becomes a separate graph, but on Neo4j it is only a property filter, and neither server has auth. Mem0 requires user_id, agent_id or run_id on writes and searches, but get, update and delete take only a memory id. Memori skips memory entirely without an entity_id. Only BYODB augmentation hashes the ids, and cloud mode sends them raw. Supermemory scopes by container tag (default sm_project_default) and leaves enforcement to its API.

Weak inside a project. MemMachine partitions by organisation and project. User and agent separation is a metadata filter built by the client that the server does not enforce, and the rolling summary is shared by the whole project.

Pick: Cognee or Honcho when several users share one deployment. Pick: Hindsight for clean per-agent banks with tag filters. Pick: Mem0 or Graphiti when your backend already controls which ids reach memory.

Per-project answers

thedotmack/claude-mem

answered

Memory is scoped by a team → project → session hierarchy with multi-tenancy at the team level. Team/Project isolation: Every Postgres observation carries team_id and project_id, and every query enforces scoped WHERE clauses (src/storage/postgres/observations.ts:199-200). API keys are bound to a team (and optionally a project: src/storage/postgres/schema.ts:122-131). The auth middleware requires memories:read or memories:write scopes (src/server/routes/v1/ServerV1Routes.ts:62-71). The ProviderObservationGenerator validates scope bullMQ payloads against canonical Postgres rows at execution time (src/server/generation/ProviderObservationGenerator.ts:202-227) and refuses to run on mismatch. Session scoping: Observations link to server_session_id, and the server_sessions table records platform_source (claude-code, opencode, cursor, etc.), enabling platform-aware filtering (src/storage/postgres/schema.ts:149-167). Search queries support filtering by platform source (src/storage/postgres/observations.ts:223-240). Multi-tenancy: Teams are the tenant boundary with team_members controlling roles (owner, admin, member, viewer: src/storage/sqlite/schema.ts:44-53). Foreign keys cascade from team through projects, sessions, events, and observations — deleting a team cascades all its data (src/storage/postgres/schema.ts:91-256). Privacy controls: Event payloads pass through stripTags before extraction (src/server/generation/providers/shared/prompt-builder.ts:149), removing <private>, <claude-mem-context>, <system-reminder> tags. Subagent observations are dropped early via shouldSkipAgentObservation (src/cli/handlers/observation.ts:59-67), and excluded projects are skipped via shouldTrackProject. The excludeSubagents filter (src/storage/postgres/observations.ts:208-221) keeps subagent-derived rows out of context injection when configured.

mem0ai/mem0

answered

Memory scoping is enforced through three mandatory entity identifiers: user_id, agent_id, and run_id. At least one is required for every operation (_build_filters_and_metadata in mem0/memory/main.py:314-404). All three may be combined for composite scoping (e.g., a specific user interacting with a specific agent in a specific run).

Write-time scoping — when add() is called, the provided entity IDs are validated (trimmed, whitespace-checked, non-empty enforced via _validate_and_trim_entity_id at line 175-209) and stored directly in the vector store payload. Caller-supplied metadata dicts are stripped of identity keys by _strip_identity_keys (mem0/memory/main.py:143-162) — you cannot set user_id inside metadata to override or change the scope. On the update path, re-sending the existing values is silently accepted, but changed values are dropped with a warning.

Query-time scoping — search(), get_all(), and delete_all() require filters containing at least one entity ID. Top-level user_id/agent_id/run_id kwargs are explicitly rejected (_reject_top_level_entity_params at line 165-172); callers must pass filters={"user_id": "..."} instead. Filters are passed directly to the vector store's search() and list() methods, meaning the vector store itself enforces the scope at query time.

Session scope & messages — a deterministic session scope string is built from sorted entity IDs (_build_session_scope at mem0/memory/main.py:412-419), used as the key for storing recent conversation messages in the SQLite messages table.

Actor filtering — an optional actor_id narrows queries to memories from a specific speaker. This is separate from entity scoping and resolved via filters["actor_id"].

Multi-tenancy — the three-identifier system provides natural multi-tenancy. Different users, agents, or runs have fully isolated memory spaces because every store operation writes the identifiers into the payload and every query filters on them. The server's entities REST endpoint (server/routers/entities.py:43-67) scans all payloads to aggregate entities by type, supporting admin-level cross-entity queries.

No tenant-level access control in the OSS SDK itself — that lives in the hosted platform. The FastAPI server (server/main.py:1-80) adds JWT auth and API key authentication.

Editor's note. Addition: get, update, delete and history take only a memory id and do not check scope. The REST server authenticates the caller but does not bind them to the user_id in the request.

vectorize-io/hindsight

answered

Memory isolation is primarily by bank — each bank is an isolated memory store ("brain" for one user/agent). All operations require a bank_id parameter. Under the hood, tables like memory_units, entities, and documents carry a bank_id column, and all queries filter on it (e.g., memories/pg/recall.py parameterises every SELECT with WHERE bank_id = $1). Multi-tenancy is implemented via the TenantExtension (extensions/tenant.py:54-100). Each tenant gets a PostgreSQL schema; all queries use fully-qualified table names (schema_name.memory_units) isolated at the database level. The default is ApiKeyTenantExtension (extensions/builtin/tenant.py) which authenticates via an API key and returns the tenant's schema. Tag-based scoping provides a second isolation layer within a bank (search/tags.py:1-23). Tags are TEXT[] arrays on memory_units, filtered via GIN-indexed PostgreSQL operators (&& for overlap, @> for containment). Five matching modes control strictness: any/any_strict (OR), all/all_strict (AND), and exact (set equality for observation scopes). Compound tag_groups support boolean trees with AND/OR/NOT groups at any nesting depth. Each bank operation can carry tags and tags_match, and the reflect agentic loop enforces the caller's tag scope on every internal tool call (memory_engine.py:15184-15193). Admission control (api/admission.py:1-100) bounds concurrency per operation class (recall, retain, reflect) with lane-specific max_in_flight and max_wait_seconds limits, returning 503 when a request cannot be served within its deadline. Memory Defense (retain/orchestrator.py imports) is a configurable policy that can block or redact sensitive content before storage, acting as a privacy/safety gate at write time.

Editor's note. Correction: multi-tenancy is opt-in. The built-in ApiKeyTenantExtension is loaded only when HINDSIGHT_API_TENANT_EXTENSION is set; without it every bank lives in the default Postgres schema and isolation is by bank_id (plus tags).

getzep/graphiti

answered

All graph data is scoped by group_id, a string partition key present on every node and edge (Node.group_id, Edge.group_id). It serves as multi-tenancy, separating user/agent/session or tenant data. All queries — extraction, search, maintenance — accept group_ids: list[str] parameters. Search filters (SearchFilters) combine with group_ids for access control at query time. The @handle_multiple_group_ids decorator normalizes single/multi group_id calls.

Group-level isolation is enforced at the database level as well. When a group_id differs from the default database name, _resolve_request_scope() (graphiti.py:1066) clones the driver with .clone(database=group_id), creating a physically separate graph in FalkorDB or a logically partitioned one in Neo4j. This prevents concurrent requests from different groups from interfering (fix for issue #1676). The MCP server caches these group-scoped driver clones in _group_drivers (graphiti_mcp_server.py:188).

Deletion is group-aware: Node.delete_by_group_id() removes all nodes in a partition. build_communities() scopes its community detection to specified group_ids. Communities, sagas, and episodes all carry group_id.

Entity type scoping: users can define custom Pydantic entity type models (e.g., Person, Organization) and pass them as entity_types to add_episode(). The system uses these to classify extracted entities, plus the excluded_entity_types parameter to filter out unwanted types. Similarly, edge_types scopes which relation types are valid between which entity type pairs.

There is no separate user/role-based access control layer — that's delegated to the application layer. The REST server implements no auth middleware; the MCP server relies on the host's transport security.

topoteretes/cognee

answered

Memory is scoped via a multi-tenant hierarchy of Tenant -> User -> Dataset -> Data with Read/Write/Delete/Share permissions enforced through ACLs (cognee/modules/users/models/ACL.py:8). The User model (cognee/modules/users/models/User.py:15) has roles, tenants, and parent_user_id for agent accounts. With ENABLE_BACKEND_ACCESS_CONTROL=true (default), each user+dataset pair gets isolated graph and vector databases through a dataset-database registry. Session records tie sessions to (user_id, dataset_id). recall() supports scope=['session', 'trace', 'session_context', 'graph', 'tools', 'code'] with session_first short-circuit to avoid graph search. The MCP server adds per-client dataset scoping (cursor_vscode_memory, etc.) from clientInfo.name. The teacher-student hierarchy via parent_user_id lets agent accounts inherit permissions. Querying unpermitted datasets raises PermissionDeniedError; name-based lookups only resolve within the caller's own datasets.

Editor's note. Addition: multi-tenant mode (ENABLE_BACKEND_ACCESS_CONTROL) is on by default only when the configured graph and vector backends support per-dataset databases (multi_user_support_possible). In that mode authentication is forced on.

supermemoryai/supermemory

answered

Memory scoping uses a container-tag (space) model. Space isolation: every tool operation is scoped by containerTag — a string identifier for a named space (apps/mcp/src/shared/types.ts:39-53). The SupermemoryClient constructor takes an optional containerTag which defaults to 'sm_project_default' (apps/mcp/src/server/client/index.ts:158-159). When no explicit tag exists, getProfile() returns empty facts (line 271-278). Multi-tenancy: the ActorContext carries userId, organizationId, bearerToken, and oauthClientId (apps/mcp/src/server/index.ts:227-232). The SpaceState DurableObject (apps/mcp/src/server/space-state.ts:26-73) persists per-actor state keyed by ${org}:${user}. Access control: the rbac.ts module provides effectiveContainerTagAccess() returning read/write permissions per tag. Session scope (sessionScopeSchema in shared/types.ts:13-21) discriminates full vs scoped access with optional tag and rate-limit constraints. Privacy: there are no client-side privacy controls (redaction, content filtering) — the API is trusted to enforce permissions server-side.

MemoriLabs/Memori

answered

Memories are scoped through a three-level hierarchy of entity → session → conversation, with optional process-level isolation.

Entity. The top-level scope is an entity_id — a string provided by the caller (typically a user ID). It is hashed with SHA-256 before being sent to cloud endpoints for privacy (memori/memory/augmentation/_models.py:15-18). In BYODB mode it maps to memori_entity.external_id. Every fact, session, and conversation is ultimately owned by an entity. If entity_id is not set, memory extraction (AdvancedAugmentation.process() at memori/memory/augmentation/augmentations/memori/_augmentation.py:126-127) and recall (inject_recalled_facts at memori/llm/pipelines/recall_injection.py:112-114) are both skipped entirely.

Process. An optional process_id distinguishes which agent or process within an application is interacting on behalf of the entity. This maps to memori_process and enables process-attribute memories (preferences about a specific agent) separate from entity-level facts. When provided, it is included in cloud API payloads as attribution and stored in the session relationship.

Session. A session_id (UUID string) ties together entity + process into a session row in memori_session. Conversations are scoped to a session. A session has a configurable timeout (session_timeout_minutes, default 30, memori/_config.py:84). The conversation.create driver method (memori/storage/drivers/postgresql/_driver.py:37-93) checks the last activity time against this timeout: if the most recent message is within the window, the existing conversation is reused; otherwise a new conversation is created, effectively starting a fresh conversation within the same session.

Multi-tenancy. In BYODB mode, each application provides its own database, so tenant isolation is at the database level. In cloud mode, entity IDs are the isolation boundary — the API key determines the project/account, and entity IDs scope within it. There is no role-based access control or per-entity ACL enforcement in the open-source SDK; these are presumed to be handled by the hosting application.

Privacy. The entity_id is SHA-256 hashed before transmission to the cloud API, preventing direct plaintext ID leaks to the server.

Editor's note. Correction: only the BYODB augmentation payload hashes entity and process ids with SHA-256. In cloud mode, recall, augmentation metadata and agent endpoints send the raw entity_id.

MemTensor/MemOS

answered

Multi-layered: user, cube, session, database. User-level: MOSCore (core.py:38-68) validates via UserManager (user_manager.py:56-100, SQLite/MySQL/Redis) with roles ROOT/ADMIN/USER/GUEST + user_cube_association. Cube-level: GeneralMemCube (mem_cube/general.py:24-48) has separate stores per cube_id. SingleCubeView (single_cube.py:48-100) scopes via UserContext + search_filter. Admin API (admin_router.py) manages per-cube API keys with scopes + expiry. Database-level: PolarDB/Neo4j use_multi_db (api/config.py:868-903) gives each user separate DB; shared mode uses user_name tag. TextualMemoryItem fields: user_id, session_id, visibility private/public/session (item.py:101-148). Search auto-filters user_name + status=activated (preference.py:92-94). CompositeCubeView (composite_cube.py:18-50) fan-out writes + parallel searches across cubes.

plastic-labs/honcho

answered

Memory scoping has four layers. Workspace isolation: Nearly every table includes workspace_name as a composite foreign key, making cross-workspace data leakage structurally impossible at the schema level (src/models.py — all Document, Message, Collection, QueueItem, etc. tables). This is enforced by the FK constraints themselves, not by query filters. Observer/Observed (Collection) isolation: Documents live in collections keyed by (workspace_name, observer, observed). This means every peer pair has its own vector namespace — peer A's view of peer B is a separate collection from C's view of B. Self-representation (observer == observed) is just the diagonal of this matrix. All CRUD operations query through this triple-key filter (src/crud/document.py:81-87). Authentication scoping via JWT: src/security.py:31-63 defines JWTParams with a hierarchy — admin (ad), workspace (w), peer (p), session (s). A narrower token never falls back to wider access: {w: ws, p: alice} only acts on alice, never on a sibling peer. The require_auth FastAPI dependency (security.py:143-194) checks these claims per-route. Session-scoped peer access is gated by allow_member_read=True exclusively on read-only routes, enforced by an explicit allowlist in tests (tests/routes/test_auth_route_policy.py). Scopes: A Scope is a named grouping of sessions that acts as a visibility boundary on recall. Chat, representation, session context, and workspace search answered through a scope see only the contents of that scope's member sessions (CLAUDE.md:126-131). Adding a session to a scope copies its explicit conclusions into the scope's collection; removing one reconciles copies back out. Session allowlists (session_allowlist parameter on queries) restrict retrieval of both conclusions and messages to specified sessions — and only explicit-level conclusions (level "explicit") are considered safe to scope under this mechanism (src/utils/representation.py:24), because higher-level consolidated observations may have been synthesized from sources outside the allowlisted sessions. An empty allowlist fails closed.

Editor's note. Correction: workspace isolation comes from workspace_name filters in the CRUD queries; the composite foreign keys enforce referential integrity, not access isolation.

MemMachine/MemMachine

answered

Memory scoping is hierarchical and metadata-driven. At the top level, organizations and projects form the identity boundary: every API call passes org_id and project_id, which together construct the session key {org_id}/{project_id} (packages/server/src/memmachine_server/server/api_v2/service.py:44). Within a project, the client creates a Memory instance with a metadata dictionary that typically includes group_id, agent_id, user_id, and session_id (packages/client/src/memmachine_client/memory.py:116). When adding or searching memories, this metadata is automatically merged into a SQL-like filter string (e.g. metadata.user_id='alice' AND metadata.agent_id='travel_agent'), scoping every operation to that context. Semantic memory uses the set_id concept — a string keyed by metadata tags (like ["org_id", "project_id", "user_id"]). A SemanticSetType defines the tag schema; get_semantic_set_id() derives or creates the set from concrete metadata values (memory.py:1105). Set types can be org-level (shared across projects) or project-scoped. Within a set, categories define what facts to extract, and all features carry a set_id plus category_name for filtering. The MCP server passes org_id, proj_id (project), and user_id via HTTP headers or environment variables (MM_ORG_ID, MM_PROJ_ID, MM_USER_ID) (mcp.py:127), stored in ContextVars for thread-safe access. There is no built-in cryptographic access control — scoping is by key-based partition isolation rather than authentication/authorization — and the README mentions no ACL system. On the event backend, separate VectorStore collections and SegmentStore partitions (long_term_memory.py:140) provide physical data isolation per session. The declarative backend uses Neo4j collection-scoped queries filtered by session_id. For multi-tenancy, the DatabasesConf supports named database instances, but tenant isolation is at the application layer (org/project keys filtering), not at the database layer.

Editor's note. Correction: episodic memory is partitioned by org_id/project_id only. Per-user or per-agent scoping is a metadata filter that the client adds and the server does not enforce, and the short-term rolling summary covers the whole project and is returned even for filtered queries.

← How are memories updated, consolidated or forgotten? · How do agents integrate with it, and what is self-hostable? →