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 /admin/* provisioning routes — the model a self-hosted operator
uses directly. The hosted app now offers a minimal self-serve version of multi-user access
for Team-plan customers — see Usage & billing → Team
invites for that customer-facing flow.
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.
role is audit-only metadata, not an authorization boundary
issue_token accepts and stores any non-blank role string (e.g. member, owner,
admin) for provenance and listing — it shows up in /admin/tokens output so you can
tell who's who. But no route reads it to make an authorization decision. Every
tenant token is equal in capability regardless of its role, including managing that
org's own tokens: role doesn't gate /admin/* or anything else at the engine layer.
The sole gate for /admin/* is the separate ERGO_SUPERADMIN_TOKEN (below) — a
member-role token and an owner-role token can do exactly the same things against the
core API.
| Role | Can do (identical for both — role is descriptive only) |
|---|---|
member | Read and write claims within its own org |
owner / admin | Same — role carries no additional engine-level capability |
The hosted app's "owner can invite teammates" (Usage & billing → Team
invites) is a panel feature, not an engine
capability: the panel itself holds the superadmin token and calls /admin/tokens on
the customer's behalf when an owner-labeled user clicks invite. A self-hosted operator
talking to the raw HTTP/MCP surface gets no such enforcement from role alone — that
gate lives entirely in the hosted panel's own application logic, not in the engine.
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/tenants/name
Set or clear a tenant's display_name — a presentation-only label (64 characters
max after trimming) the panel shows instead of the raw org_id slug. It is never an
auth or isolation boundary, and org_id itself stays immutable — this only ever
changes what's shown. This is what backs the customer-facing rename control on the
hosted app's Overview page.
curl -s -H "$SUPER" -X POST http://127.0.0.1:8788/admin/tenants/name -d '{
"org_id": "acme", "display_name": "Acme Corp"
}'
# 200 → {"org_id":"acme","display_name":"Acme Corp"}
# 400 → missing org_id / missing display_name / display_name over 64 chars
# 404 → org not provisionedPass display_name: null to clear it back to unset.
POST /admin/tokens
Mint a token for a principal within an already-provisioned tenant. expires_at is
optional — omit it (or pass null) for a token that never expires; give it an ISO
timestamp ("%Y-%m-%dT%H:%M:%SZ") and the token stops authenticating the instant it
passes, exactly like a revoked one (a plain 401, no distinct error shape).
curl -s -H "$SUPER" -X POST http://127.0.0.1:8788/admin/tokens -d '{
"org_id": "acme", "who": "alice", "role": "member", "expires_at": null
}'
# 200 → {"token": "<plaintext, shown once>", "token_id":"...", "expires_at": null}
# 404 → org not provisioned
# 400 → expires_at given but not a well-formed ISO timestampStore the returned token immediately — it isn't recoverable after this response.
POST /admin/tokens/rotate
Atomically mints a replacement token for the same (org_id, who, role) and revokes the
old one, in a single transaction — a rotation never leaves both the old and new secret
live, and never leaves neither working if it's interrupted midway. The new plaintext is
returned once, same contract as minting. expires_at applies only to the new token; the
old token's own expiry (if it had one) is never inherited — omit it for a never-expiring
replacement regardless of what the old token's expiry was.
curl -s -H "$SUPER" -X POST http://127.0.0.1:8788/admin/tokens/rotate -d '{
"token_id": "<old token_id>", "expires_at": null
}'
# 200 → {"result":"rotated","token":"<new plaintext, shown once>",
# "token_id":"<new>","old_token_id":"<old, now revoked>",
# "org_id":"acme","who":"alice","role":"member","expires_at":null}
# 404 → unknown token_idEvery minted token also carries an ergo_ plaintext prefix and a key_hint — a
masked preview (ergo_xK7f…q8Zw) captured once at mint/rotate time, since that's the
only moment the plaintext exists — plus a last_used_at timestamp GET /admin/tokens reports, bumped (throttled) whenever the token authenticates a
request. This is what backs the Keys page's key list on the hosted app; see The app
→ Keys.
POST /admin/tokens/rename
Set or clear a token's name label (64 characters max after trimming) — purely a
customer-chosen way to tell keys apart, distinct from who/role. Names are
unique per org, case-insensitively; renaming a token to its own current name is
always a no-op success, but colliding with a different token in the same org is a
409, not a silent overwrite.
curl -s -H "$SUPER" -X POST http://127.0.0.1:8788/admin/tokens/rename -d '{
"token_id": "<token_id>", "name": "CI pipeline"
}'
# 200 → {"result":"renamed","token_id":"<token_id>","name":"CI pipeline"}
# 400 → missing token_id / missing name / name over 64 chars
# 404 → unknown token_id
# 409 → {"error":"a key named 'CI pipeline' already exists"}Pass name: "" (or any blank-after-trim string) to clear it back to unset — the
panel shows an unnamed key as "Unnamed key".
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.
This is the operator-driven route for a self-hosted deployment — on the hosted app,
a customer triggers the equivalent purge themselves from the Danger Zone on their
Data page; see Usage & billing → Deleting your account &
data.
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