Agent Memory
Security

Multi-tenant isolation with Postgres RLS and pgvector

Published

A memory layer holds some of the most sensitive data an AI product has: what users said, what they prefer, and the decisions a team made. When that layer serves many customers, one stray WHERE clause can leak data. We didn't want isolation to depend on every query being written correctly. In Agent Memory, Postgres enforces it.

The hierarchy

Everything belongs to a tenant:

1Tenant
2└── Workspace
3 └── Application
4 └── Run / Session

Each request carries two separate pieces of information:

  • Who is calling: the tenant, the actor and their roles. This comes only from a verified token or an agent-bound API key, never from the request body or query string.
  • Where the memory lives: platform, tenant, workspace or application scope. The rules about which ids each scope requires are enforced when the address is built.

If the two disagree, the service refuses the request before it reaches storage.

Layer 1: row-level security, forced and fail-closed

Every table that holds tenant data has a tenant_id uuid NOT NULL column, indexes that lead with tenant_id, and row-level security switched on with both ENABLE and FORCE. Each of these tables has a single policy that applies to both reads (USING) and writes (WITH CHECK):

1tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid

The NULLIF means that when no tenant is set, the policy compares against NULL and matches nothing. A query that runs without a tenant context returns zero rows. It doesn't fall back to everything. Forgetting to set a tenant fails safe.

Layer 2: a role that cannot bypass RLS

RLS only works if the connecting role is subject to it. The application connects as a dedicated runtime role with NOSUPERUSER and NOBYPASSRLS that owns no tables. Schema migrations use a separate owner connection that the runtime never gets. Another role handles the control plane: identity, grants and the registry of tenants and workspaces. The runtime role doesn't connect to the control plane database at all.

Layer 3: tenant context bound to the transaction

Connection pools make RLS harder. If a tenant setting stays on a pooled connection, the next request on that connection runs as the wrong tenant. We avoid this with a unit of work: each operation gets a fresh database context and an explicit transaction, and the tenant is set with a transaction-local set_config. When the transaction commits or rolls back, the setting is cleared automatically, so a reused connection carries nothing over. Our integration tests cover pooled-connection reuse explicitly.

RLS is defense in depth, not the only check. Every store query also includes explicit tenant and workspace predicates.

Layer 4: references that cannot cross tenants

Relationships are where cross-tenant bugs usually hide. Memory relationships, episode events and episode derivatives all use composite foreign keys on (tenant_id, id). A relationship row can only point at a memory with the same tenant id, so a cross-tenant edge can't be represented in the schema at all. A CHECK constraint also ensures that a memory's scope matches the ids it carries: a workspace-scoped row must have a workspace id, a tenant-wide row must not, and so on.

pgvector under RLS

Vector search runs in the same tenant-scoped transaction as every other query, so the RLS policy and the explicit tenant and workspace predicates apply to similarity search too. A nearest-neighbor query can only rank rows the caller is allowed to see.

There is a trade-off. A global approximate index combined with selective tenant filters can return too few results, because the filter is applied after the index scan. We measured exact search before deciding: about 10 ms for 2,000 rows in one workspace, and about 273 ms on average for 20,000 rows. Based on that, we use exact scans up to roughly 5,000 rows per workspace and have documented paths beyond that, such as pgvector's iterative index scans, partial or partitioned HNSW, or a dedicated table for a very large tenant. Correct isolation comes first, and the index strategy follows from measured load.

Retrieval also fails closed. If a primary source such as vector, full-text, structured or episode search fails, including an RLS failure, the request returns an error rather than an empty success. Only enrichment stages may degrade, and only on transient errors.

Beyond Postgres

  • Redis: session keys are built in one place and always include the scope, in the form tenant → workspace → application → run. Run ids are validated so they can't inject key separators. A per-tenant key index means offboarding never has to scan the whole keyspace.
  • API behavior: a resource outside your tenant returns 404, not 403, so its existence isn't revealed. A request without credentials returns 401.
  • Inside a tenant: workspaces are the sharing boundary. Within a workspace, each memory has a visibility of private, agent, user, workspace or tenant, and a policy check runs before every search. A memory you can't see returns 404. One you can see but aren't allowed to act on returns 403 policy_denied, and the denial is audited.

Shared by default, dedicated when you need it

The standard placement is the shared schema protected by RLS described above. When a tenant needs physical separation, an operator can provision it onto a dedicated database, using the same placement mechanism with a different connection target. The dedicated schema carries the same RLS policies, composite keys and constraints, and a test compares the catalogs of the two databases table by table.

Offboarding and quotas

When a tenant leaves, a resumable purge saga deletes data in an order that respects foreign keys, starting with Redis, then the memory tables, then the registry rows. Before the registry rows are removed, it verifies that zero tenant rows remain. Each purge is recorded in a deletion ledger so it can be replayed after a backup restore. Usage metering and quota counters also live inside the tenant's own transaction. Quota reservation is a single guarded upsert, so concurrent writes can't jointly exceed a cap.

Tested, not assumed

Isolation is covered by an acceptance suite that runs against real pgvector and Redis containers. It tests cross-tenant reads and writes, RLS fail-closed behavior, pooled-connection reuse, composite foreign keys, CHECK constraints and concurrency. You can also check it by hand: connect as the runtime role without setting a tenant, run SELECT count(*), and you get zero.

Read the security overview for more, or talk to us about dedicated placement and air-gapped deployment.

Announcements

Agents forget everything between sessions. Agent Memory is a memory layer that lets them remember, learn from outcomes and forget on purpose, through one contract over REST, MCP and SDKs.

Engineering

A tour of Agent Memory's four memory types, how consolidation promotes knowledge from short-term to long-term, and how recall turns it all back into a compact context.