ergo · myzona

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:

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.

Two roles exist today:

RoleCan do
memberRead and write claims within its own org
owner / adminSame, 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 provisioned

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

Store 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