Multi-tenancy
Silo isolation, per-principal tokens, and the admin API for running Ergo as a team server.
Ergo's engine is multi-tenant: each tenant is isolated at the storage layer, not just by a
query filter. This page covers the silo model, the control plane, tenant isolation
guarantees, and the four /admin/* provisioning routes. A hosted, deployed team server
built on this engine is coming; the model described here is shipped and live today.
The scoping model
Three labels travel with every claim, and they play distinct roles:
| Label | Role |
|---|---|
org_id | The tenant — the isolation unit. Each tenant is a separate database file. |
project | A namespace within a tenant. Organizational, not a security boundary. |
who | The author — set from the authenticated token, not trusted from the request body. |
Contradictions are only ever checked within a single (org_id, project) scope. A claim in
one tenant is never compared against another tenant's — the silo (below) makes that
physically impossible, not just a query convention.
The silo model
Every tenant gets its own physical SQLite database file:
/data/tenants/<tenant_id>.dbThere is no shared table with an org_id column to filter. A dropped WHERE org_id=?
elsewhere in the code can't leak another tenant's claims into a response, because the rows
for a different tenant aren't in the same file at all.
tenant_id is an opaque, server-minted UUID — never client-chosen. Clients only ever see
it as an opaque identifier, so it can't be used to construct a path and reach another
tenant's file.
Control plane: identity, not self-assertion
A single-tenant deployment can still run on one shared bearer token (ERGO_API_TOKEN) —
that legacy mode is unchanged, so existing setups keep working. Multi-tenant deployments add
a control plane of per-principal tokens on top of it. On every request the server
resolves the bearer into one of three kinds before doing any work:
| Bearer | Resolves to | org_id / who |
|---|---|---|
Legacy (ERGO_API_TOKEN) | the back-compat principal | taken from the request body (single-tenant) |
| Tenant token | a real per-tenant principal | authoritative from the token, never the body |
Superadmin (ERGO_SUPERADMIN_TOKEN) | the provisioning principal | — (only /admin/*) |
For a tenant token:
- A token is minted for a specific
(org_id, who, role)and returned to the caller once, in plaintext — it is never logged and never retrievable again. - The engine stores only a sha256 hash of the token, in a separate
control.db. - On every request, the bearer token resolves server-side to its
org_idandwho. Those values are never accepted as self-asserted fields from the request body — the token is the identity, so a caller can't spoof which tenant it's writing to.
Two roles exist today:
| Role | Can do |
|---|---|
member | Read and write claims within its own org |
owner / admin | Same, plus manage tokens for that org |
project is a namespace, not an auth boundary
Within a tenant, project (used throughout the core API) is a scoping
label for organizing claims — it is not an access-control boundary. Any valid token for
an org can read and write any project within that org. Isolation between tenants is
enforced by the silo (separate DB files); isolation between projects in the same tenant is
organizational, not cryptographic. If you need hard separation, use separate tenants.
Isolation guarantees
Tenant isolation holds at two layers, because a file boundary alone is not enough:
- Data — separate files. Each tenant's claims and vector index live in a different
SQLite file. A dropped
WHERE org_id=?can't return another tenant's rows, and each tenant's KNN search only scans its own vectors. - Process — stateless shared components. The normalizer, NLI judge, and embedder are shared singletons across tenants for efficiency, so they are held to a hard invariant: they carry no cross-tenant state between requests. This is what stops one tenant's content leaking into another's contradiction check through a shared in-process component — the file split alone would not catch that. The invariant is pinned by the engine's test suite.
The admin API
Gated by a separate ERGO_SUPERADMIN_TOKEN env var — distinct from any tenant token. If
that variable is unset, every /admin/* route 404s, as if the feature doesn't exist.
Authorization: Bearer <ERGO_SUPERADMIN_TOKEN>POST /admin/tenants
Provision a new tenant.
curl -s -H "$SUPER" -X POST http://127.0.0.1:8788/admin/tenants -d '{
"org_id": "acme"
}'
# 200 → {"tenant_id":"a1f9c2d4e6...", "org_id":"acme"}
# 409 → org_id already provisionedPOST /admin/tokens
Mint a token for a principal within an already-provisioned tenant.
curl -s -H "$SUPER" -X POST http://127.0.0.1:8788/admin/tokens -d '{
"org_id": "acme", "who": "alice", "role": "member"
}'
# 200 → {"token": "<plaintext, shown once>"}
# 404 → org not provisionedStore the returned token immediately — it isn't recoverable after this response.
GET /admin/control-health
Shape-only counts, no secrets:
curl -s -H "$SUPER" http://127.0.0.1:8788/admin/control-health
# 200 → {"tenant_count":3,"token_count":7,"tenants_dir":"/data/tenants"}POST /admin/tenants/delete
Full right-to-be-forgotten purge: evicts the tenant's store from any in-memory cache,
deletes its control-plane rows (tokens), and unlinks its .db / -wal / -shm files.
curl -s -H "$SUPER" -X POST http://127.0.0.1:8788/admin/tenants/delete -d '{
"org_id": "acme"
}'
# 200 → {"result":"deleted","org_id":"acme","tenant_id":"...",
# "store_evicted":true,"control_rows_deleted":true,
# "db_removed":true,"wal_removed":false,"shm_removed":false}The wal_removed / shm_removed flags are false when those companion journal files
simply weren't present at delete time.
This is destructive and irreversible — there is no soft-delete for a tenant purge (compare
this to retract on a single claim, which keeps the row in /history).
Concurrency: bounded, per-tenant
The engine runs a ThreadingHTTPServer capped by a
threading.BoundedSemaphore(ERGO_HTTP_MAX_WORKERS) (default 8), and the store takes a
per-tenant lock rather than one global lock. In practice that means:
- One tenant's slow request (e.g. a normalizer call queued behind an LLM) can't head-of-line-block another tenant's requests.
- Within a tenant, writes are still single-writer (see Why Ergo) — but N tenants give you N independent single-writer domains, so cross-tenant throughput scales with tenant count.
End-to-end
SUPER="Authorization: Bearer $ERGO_SUPERADMIN_TOKEN"
# Provision two tenants
curl -s -H "$SUPER" -X POST .../admin/tenants -d '{"org_id":"acme"}'
curl -s -H "$SUPER" -X POST .../admin/tenants -d '{"org_id":"beta"}'
# Mint a token for each
curl -s -H "$SUPER" -X POST .../admin/tokens -d '{"org_id":"acme","who":"alice","role":"member"}'
curl -s -H "$SUPER" -X POST .../admin/tokens -d '{"org_id":"beta","who":"bob","role":"member"}'
# alice writes into acme's silo
curl -s -H "Authorization: Bearer <alice token>" -X POST .../remember -d '{
"org_id":"acme","project":"platform","who":"alice",
"statement":"Deploy only on green CI","reason":"a red build cost us an outage"
}'
# bob's recall against beta's silo never sees it — different file entirely
curl -s -H "Authorization: Bearer <bob token>" ".../recall?org_id=beta&project=platform&q=deploy"
# → 0 matches