ergo
Sign inGet started

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:

LabelRole
org_idThe tenant — the isolation unit. Each tenant is a separate database file.
projectA namespace within a tenant. Organizational, not a security boundary.
whoThe 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>.db

There 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:

BearerResolves toorg_id / who
Legacy (ERGO_API_TOKEN)the back-compat principaltaken from the request body (single-tenant)
Tenant tokena real per-tenant principalauthoritative 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_id and who. 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.

RoleCan do (identical for both — role is descriptive only)
memberRead and write claims within its own org
owner / adminSame — 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 provisioned

POST /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 provisioned

Pass 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 timestamp

Store 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_id

Every 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