Multi-tenant data

In shortIsolate organizations and projects in one Postgres with tenant columns on PgCheckpointer and PgVectorStore, set reach per role and filter your own columns.

  • 16 min read
  • 13 sections
  • Updated
  • v1.0.0
  • Markdown

PgCheckpointer and PgVectorStore accept two kinds of columns you declare yourself. Tenant columns such as org_id and project_id isolate data the way user_id does. Extra columns such as public_id or plan are plain data you can read and filter. 10xGraph owns a few columns; the rest of the schema is yours. Nothing is created until you declare something.

Tenant columns and extra columns

Item Tenant columns Extra columns
Purpose Isolation: who may see the row Data: stored with the row, filterable
Option tenant_columns= extra_columns=
Value required on write Yes, by default No, by default (missing is NULL)
Opt out or opt in {"type": "text", "required": False} {"type": "integer", "required": True}
Allowed types text, bigint, integer, uuid Any Postgres type
Used for reach (tenant) Yes No
Allowed in client filters No, always rejected Yes

Both options exist on PgCheckpointer (columns on the threads table) and PgVectorStore (columns on the memories table). Each declared column becomes a real column with a B-tree index.

Reserved names. A tenant or extra column cannot be named kind, tenant, roles, permissions, reach or claims: those are fields of the Identity in config["user"] and carry authorization data, so declaring one raises ValueError. Built-in column names of the table (such as user_id, created_at, memory_key) are refused too. Pick another name, for example org_role.

A declaration is a list of names (each a text column), a mapping of name to type, or a mapping of name to {"type": ..., "required": ...}:

columns.py
tenant_columns = {"org_id": "uuid", "project_id": "text"}

extra_columns = {
    "public_id": "text",
    "settings": "jsonb",
    "tags": "text[]",
    "seats": {"type": "integer", "required": True},
}

Names match ^[a-z_][a-z0-9_]{0,62}$, cannot be a column the table already owns, and user_id can never be declared. Every name is validated and quoted, and every value is a bind parameter.

Tenant column types are scalar ids only: text, bigint, integer or uuid. boolean, timestamptz, jsonb, arrays and everything else raise when you build the backend.

Extra column types can be anything Postgres knows. Six types are checked and converted in Python and support the full filter operators: text, bigint, integer, boolean, uuid and timestamptz. Any other type (jsonb, text[], numeric(10,2), date, an enum) gets a cheap syntax check when you declare it. At setup the server resolves it with to_regtype, and only the canonical name the server returns is used in DDL, so the text you declared never reaches SQL. An unknown type fails setup. Values for json and jsonb columns are serialised with json.dumps; other values go to asyncpg as given, and a value its codec cannot encode raises ColumnValueError naming the column and type, never the value. Filters on these columns allow only is_null.

Where the values come from

Every write stores the values found in config["user"]. Every read, update and delete is filtered by the caller’s reach, using the same source. A client that sends org_id in its request config, in a message or in a tool argument changes nothing, because only config["user"] is read.

With the API server and a custom BaseAuth

Return the values from authenticate(). Every key you return ends up in the identity’s claims, and the keys named in auth.tenant_claims become its tenant values. A token with a tenant claim missing is rejected with 401.

auth/tenant_auth.py
from fastapi import HTTPException, Request, Response
from fastapi.security import HTTPAuthorizationCredentials

from tenxgraph_api import BaseAuth


class TenantAuth(BaseAuth):
    def authenticate(
        self,
        request: Request,
        response: Response,
        credential: HTTPAuthorizationCredentials,
    ) -> dict | None:
        if credential is None:
            raise HTTPException(status_code=401, detail="Missing credentials")
        claims = verify_token(credential.credentials)  # your identity provider; raises on a bad token
        return {
            "user_id": claims["sub"],
            "org_id": claims["org_id"],
            "project_id": claims["project_id"],
            "public_id": claims["public_id"],
            "roles": claims.get("roles", ["member"]),
        }
10xgraph.json
{
  "agent": "graph.agent:app",
  "auth": {
    "method": "custom",
    "path": "auth.tenant_auth:TenantAuth",
    "tenant_claims": ["org_id", "project_id"],
    "roles": {
      "member": {"permissions": ["graph:*", "threads:*", "store:*", "files:*"], "reach": "own"},
      "org_admin": {"inherits": ["member"], "reach": "tenant"}
    }
  },
  "store": "graph.dependencies:store"
}

The server uses the checkpointer you pass to graph.compile(checkpointer=...). The store key points to the same PgVectorStore instance you compile with. tenant_claims must list the same names as the tenant columns on both. Boot refuses a mismatch under MODE=production and warns otherwise.

With the API server and JWT

The built-in "auth": "jwt" backend returns the whole decoded token as the user, so every claim ends up in config["user"]. Put org_id, project_id and public_id in the token when your identity provider issues it, next to the required user_id claim:

token payload
{
  "user_id": "u-42",
  "org_id": "7b0e2b3a-9a57-4b14-8e1b-2a1e3f1c9c11",
  "project_id": "docs",
  "public_id": "usr_8f2k",
  "roles": ["org_admin"],
  "exp": 1893456000
}

The signature is what makes the claims trustworthy, so keep JWT_SECRET_KEY secret and issue these claims only from your own login service. See Set up API authentication.

Core only, without the API server

When you call the compiled graph from your own code, your process is the trust boundary. Build config["user"] yourself from your verified session or token. It takes an Identity or a dict that validates into one; an unknown key raises:

run.py
def run_config(session, thread_id: str) -> dict:
    """`session` comes from your own verified login, never from the request body."""
    return {
        "thread_id": thread_id,
        "user": {
            "user_id": session.user_id,
            "tenant": {"org_id": session.org_id, "project_id": session.project_id},
            "claims": {"public_id": session.public_id},
            "reach": "own",
        },
    }

result = await app.ainvoke({"messages": messages}, config=run_config(session, "t-1"))

A full runnable version is in examples/long_term_memory/multi_tenant_memory.py.

Declare the same columns on the checkpointer and the store

Threads and memories of one request must be isolated by the same columns. If the checkpointer filters by org_id and the store does not, memories of two tenants with the same user_id mix while their threads do not.

graph/dependencies.py
from tenxgraph.storage.checkpointer import PgCheckpointer
from tenxgraph.storage.store import OpenAIEmbedding, PgVectorStore

TENANT_COLUMNS = {"org_id": "uuid", "project_id": "text"}
EXTRA_COLUMNS = {"public_id": "text", "plan": "text"}

checkpointer = PgCheckpointer(
    postgres_dsn=POSTGRES_DSN,
    redis_url=REDIS_URL,
    tenant_columns=TENANT_COLUMNS,
    extra_columns=EXTRA_COLUMNS,
)
store = PgVectorStore(
    embedding=OpenAIEmbedding(),
    dsn=POSTGRES_DSN,
    tenant_columns=TENANT_COLUMNS,
    extra_columns=EXTRA_COLUMNS,
)

compile() checks that both declare the same tenant column names and types. With MODE=production a mismatch raises GraphError with code GRAPH_TENANT_001; in any other mode it logs a warning. Pass allow_tenant_mismatch=True to allow a mismatch on purpose. It never raises then, and logs the mismatch once at INFO level. Use it when the store holds only shared data, for example a knowledge base:

Python
app = graph.compile(checkpointer=checkpointer, store=kb_store, allow_tenant_mismatch=True)

The API server runs the same check on a store bound with the store key in 10xgraph.json, so a mismatch there also refuses to boot in production. The opt-out for that store is a top-level key:

JSON
{
  "store": "graph.stores:kb_store",
  "store_allow_tenant_mismatch": true
}

Notes on each backend:

  • PgCheckpointer sets the values when a thread is created and never changes them. Every query that touches a thread (threads, states, messages, tool executions) is filtered through the thread row. Schema version 6 adds the columns to threads with ADD COLUMN IF NOT EXISTS, and a column you declare later is added on the next setup. aget_thread_scope(thread_id) returns {"user_id": ..., "org_id": ...} for a thread (all tenant columns), without scoping to the caller. aget_thread_owner is kept.
  • PgVectorStore adds missing columns on every asetup(). The tenant columns also join the unique memory_key index, so the same user_id in two tenants keeps separate memories for the same key. Shared data written under system_user_id(name) is shared only inside the caller’s tenant values, on every reach.
  • InMemoryCheckpointer and SqliteCheckpointer raise if you pass tenant_columns, extra_columns or row_level_security. Use PgCheckpointer for tenant data.

Reach

Reach says which rows a caller’s queries match. You set it per role in auth.roles, and the server puts it on the Identity. Storage reads it on every query.

Reach Rows the caller’s queries match
own user_id and every declared tenant column: the caller’s own rows in their tenant
tenant Every declared tenant column, any user: all rows of the caller’s tenant
all No filter: every row of every tenant
10xgraph.json
{
  "auth": {
    "method": "jwt",
    "tenant_claims": ["org_id", "project_id"],
    "roles": {
      "member": {"permissions": ["graph:*", "threads:*", "store:*", "files:*"], "reach": "own"},
      "org_admin": {"inherits": ["member"], "reach": "tenant"},
      "super_admin": {"permissions": ["*"], "reach": "all"}
    }
  }
}
Caller Reach What they see
Super admin all Every thread and memory
Org admin tenant All threads and memories with the caller’s org_id and project_id
User own Their own threads and memories

Rules to know:

  • Reach widens reads, lists and deletes only. Runs and state or message writes always stay own, admins included. Nobody runs inside another user’s thread. Stop follows reach.
  • Reach is never a fallback. A role with a missing or unknown reach refuses to boot. A token cannot name its own reach.
  • A picked column needs a value. Reach own needs a value for every declared tenant column in Identity.tenant. A missing value raises, and the API answers 401 because the token lacks a tenant claim. The column is never dropped from the filter quietly.
  • Reach tenant needs tenant columns. InMemoryCheckpointer and SqliteCheckpointer declare none. On them a read, list or delete by a caller with reach tenant raises ValueError, so the request fails; it never falls back to own. Writes use own and still work. Use PgCheckpointer with tenant columns for any role with reach tenant.
  • Reach tenant matches every declared tenant column. All declared columns must equal the caller’s values. A role cannot pick a subset.
  • A foreign row is a 404. A thread outside the caller’s reach looks the same as a missing one.
  • Store calls still carry a user_id. Writes and aforget_memory always name an owner, whatever the reach is.
  • Shared data stays shared inside the tenant. Rows written under a __system__:<name> owner (agent memory, knowledge bases) are pinned to that owner on every reach, so a wider reach never reads other users’ rows through a shared-data query.

In core-only use, put the reach on the identity yourself:

Python
from tenxgraph.core.authz import Identity, Reach

config["user"] = Identity(user_id=session.user_id, tenant={"org_id": "acme"}, reach=Reach.TENANT)

Without any identity, each backend applies its own default, and the filter helper owner_filter(config, tenant_columns) returns None.

Filters

filters narrows a listing or search on the columns you declared. It is ANDed after the isolation conditions, so a filter can only narrow what the caller may already see.

Python
threads = await checkpointer.alist_threads(
    config, filters={"plan": "pro", "created_at": {"gte": "2026-01-01T00:00:00+00:00"}}
)

hits = await store.asearch(config, "billing", filters={"category": {"in": ["faq", "policy"]}})

Format: {"column": value} means equality; {"column": {"op": value, ...}} applies one or more operators.

Operators by type

Column type Operators
text eq, ne, in, nin, like, ilike, is_null
bigint, integer, timestamptz eq, ne, gt, gte, lt, lte, in, nin, is_null
boolean, uuid eq, ne, in, nin, is_null
Any other extra column type is_null only

Values: text takes a string (or an integer, sent as its decimal string); integer and bigint take an integer (not a bool) or a string of digits, in range; boolean takes a bool; uuid takes a UUID or its string; timestamptz takes an ISO 8601 string with an offset. Floats are never accepted, and None is not a value: use is_null.

Filterable columns

Backend Built-in columns Plus
Threads (PgCheckpointer, GET /v1/threads) thread_name, created_at, updated_at Your extra columns
Memories (PgVectorStore) memory_type, category, thread_id, memory_key, created_at, updated_at Your extra columns

On the store, a key that is not a column matches a top-level key of the JSONB metadata by equality, with str, int, float or bool values only.

Behaviour to know

  • NULL. eq, gt, gte, lt and lte never match a row whose column is NULL. ne and nin treat NULL as a value of its own, so a row with no value does match “not equal to x”. Add {"is_null": false} on the same column to leave those rows out.
  • like and ilike (text only). The value is the pattern; % and _ are wildcards and a backslash escapes them. A pattern with a leading % scans every row the caller can see. See Contains search with pg_trgm.
  • Limits. At most 10 conditions per request (a column with two operators counts as two, and metadata keys count), at most 100 items in an in or nin list (never empty), at most 256 characters per string value. Conditions are joined with AND only.
  • Rejected. Tenant columns and user_id are isolation columns, so a filter that names one is rejected. So is an unknown column, an operator the column’s type does not support, and a malformed value. The error names the column and operator but never repeats a value and never contains SQL. In core these raise FilterError, a ValueError.

Filtering threads over the API

GET /v1/threads takes a filters query parameter holding a JSON object, URL-encoded:

Terminal
curl -G https://api.example.com/v1/threads \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode 'filters={"plan": "pro", "created_at": {"gte": "2026-01-01T00:00:00+00:00"}}'

The server validates it with the core validator before any database work and answers 422 when:

  • the value is not valid JSON, is not a JSON object, or is longer than 8192 characters;
  • a column is not filterable, or names a tenant column or user_id;
  • an operator or value does not fit the column’s type, or a limit is exceeded;
  • the checkpointer has no filterable columns (the in-memory and SQLite checkpointers).

The store routes take the same kind of filters object in the request body (for example on POST /v1/store/search), and bad filters answer 422 the same way.

Native row-level security

The WHERE filter is always on. For defense in depth you can add Postgres row-level security (RLS) on top:

Python
checkpointer = PgCheckpointer(..., tenant_columns=TENANT_COLUMNS, row_level_security=True)
store = PgVectorStore(..., tenant_columns=TENANT_COLUMNS, row_level_security=True)

It needs at least one tenant column. Setup runs ENABLE and FORCE ROW LEVEL SECURITY and creates a policy that compares each tenant column with current_setting('tenxgraph.<column>', true). On the checkpointer the policy covers threads, and states, messages and tool_executions are visible only when their thread is. Every call then runs in a transaction that sets those values with set_config(..., true) (SET LOCAL).

  • Cost. One transaction and one extra statement per call, plus a thread lookup per child row on the checkpointer. It is off by default.
  • Superusers and roles with BYPASSRLS skip RLS. A warning is logged when the connected role does. Connect with an ordinary role. The table owner is filtered only because of FORCE.
  • Maintenance calls that deliberately cross tenants (aget_thread_scope, abackfill_embeddings) run with no tenant values set, which the policy reads as all rows.
  • Turning it off. Keep the tenant columns and set row_level_security=False: the next setup drops the policies and disables RLS. This works the same on the store.

Backfill old rows

Rows written before a tenant column existed hold NULL in it. Reach own and tenant never match them, so those threads and memories become invisible to every caller on those reaches, admins included, until you fill them in. Reach all has no filter and still sees them.

After declaring the column and running setup, update the old rows yourself:

SQL
UPDATE public.threads SET org_id = '7b0e2b3a-9a57-4b14-8e1b-2a1e3f1c9c11' WHERE org_id IS NULL;
UPDATE public.tenxgraph_memories SET org_id = '7b0e2b3a-9a57-4b14-8e1b-2a1e3f1c9c11' WHERE org_id IS NULL;

Map each old user_id to its tenant in your own data. If your tenant column was added as uuid, use real UUID values.

Add tenant columns to a PgVectorStore table that has data

The memory_key unique index gains the tenant columns, so the same user_id in two tenants keeps separate keyed memories. On a table in use, roll it out in three steps:

  1. Deploy the new version with tenant_columns. Setup adds the columns and builds the new index <table>_kuq_<hash> with CREATE INDEX CONCURRENTLY. It keeps the old index <table>_key_uq, because pods still on the previous version need it as the ON CONFLICT target of their keyed writes. While both indexes exist, a keyed write for the same (collection, user_id, memory_type, memory_key) in a second tenant fails with ValueError, whose message names the old index. Nothing else changes, and setup logs a warning that the old index is kept.
  2. Backfill the tenant values of old rows once every pod runs the new version (see Backfill old rows). Old rows hold NULL and are invisible to reach own and tenant.
  3. Drop the old index once:
Python
dropped = await store.adrop_legacy_key_index()   # names of the indexes it dropped, [] if none

It runs DROP INDEX CONCURRENTLY under the setup lock and ddl_lock_timeout, and only after it checks that a valid index for the current declaration exists. It raises RuntimeError if none does (run asetup() first).

Setup on a table that is in use

PgCheckpointer and PgVectorStore take ddl_lock_timeout (seconds, default 10.0; None waits forever). It is the Postgres lock_timeout for every setup DDL statement. A statement that cannot get its table lock in time fails with tenxgraph.storage.columns.SetupLockTimeoutError (a RuntimeError) instead of hanging behind an open transaction, which would also queue every later read of the table behind it. The statement that timed out changed nothing, so retry setup after the other transaction ends, or raise the timeout.

  • Setup skips what is in place. It reads the catalog first and runs no DDL for anything that exists, so a restart takes no table lock.
  • Indexes on a table already in use are built CONCURRENTLY, so reads and writes continue. Adding a declared column is a short ALTER TABLE under the timeout.
  • A crashed build is repaired. An index left invalid by a build that failed or was cancelled is dropped and built again on the next setup.
  • Setup is serialised across processes by a session-level advisory lock, which also covers the concurrent builds.

Uploaded files

Uploaded files follow the same tenant isolation as threads and memories. When the checkpointer declares tenant columns, each upload records the uploader’s user_id and tenant values, taken only from config["user"]. Every later use of the file must match all of them:

  • download, /info, /url and delete (a mismatch answers 404)
  • GET /v1/files, which lists only the caller’s files in their tenant
  • any graph://media/ or file_id reference in a run, PUT state or POST messages

Reads and deletes follow the caller’s reach: a role with reach tenant can read and delete the files of its tenant, and reach all any file. Uploads always belong to the uploader. The same user_id in two organisations is two identities: neither sees the other’s files, and each has its own upload quota. An identity with no tenant value cannot upload (403) or read files.

Files uploaded before tenant columns were declared carry no tenant values and are denied. To keep them, backfill each file’s metadata with {"tenant": {"org_id": "<value>"}}, using the canonical text form (for example a lowercase UUID).

Deleting a thread, including by an org admin, releases the files the thread’s owner linked to it; media.delete_with_thread still decides whether an unlinked file is deleted.

RAGAgent and tenant stores

RAGAgent forwards only config["user"] from the run config into the config it passes to store.asearch, so a store with tenant columns scopes knowledge-base searches to the caller’s tenant. It pins the forwarded identity to the knowledge-base owner (system_user_id("knowledge_base") by default) with reach own, so a caller with reach tenant or all cannot widen a knowledge-base search to other users’ rows. The tenant values are read from the run config only, never from state, messages or model output.

  • Documents written with tenant values are visible only inside that tenant.
  • To share one knowledge base across tenants, use a store without tenant columns, and compile with allow_tenant_mismatch=True when the checkpointer declares tenant columns.

Contains search with pg_trgm

like '%word%' and ilike '%word%' scan every row the caller can see, because a B-tree index cannot serve a leading wildcard. For a large table, add a trigram index on the column you search. 10xGraph does not create it for you:

SQL
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX threads_title_trgm ON public.threads USING gin (title gin_trgm_ops);

title stands for an extra column you declared. Creating the extension needs the same kind of permission as vector.

Next steps

Last updated for v1.0.0Edit this page on GitHubReport an issue