Data Architecture & Tenancy
One Postgres database, one schema per tenant, one control-plane schema —
routed by SET search_path on every connection. This is the
highest-leverage and highest-risk design decision in the platform (ADR-0002):
it gives tenants hard data isolation inside a single database, and it makes
connection pooling and migration ordering genuinely dangerous.
flowchart LR REQ["HTTP request
JWT with tenant_id"] --> F["TenantContextFilter"] F --> TC["TenantContext
(ThreadLocal)"] TC --> R["TenantSchemaResolver"] R --> CP["SchemaMultiTenantConnectionProvider
SET search_path = tenant_schema"] CP --> DB[("Postgres
· control plane: public
· tenant_a
· tenant_b
· …")] F -->|"no claim / bootstrap / health"| FB["public fallback
(metriced)"] FB --> DB
1. Tenant resolution request path
TenantContextFilterreads thetenant_idJWT claim on every authenticated request.- The claim is stored in
TenantContext(a ThreadLocal; the AI layer restores it across Reactor thread hops on streamed calls). TenantSchemaResolvermaps tenant → schema name.SchemaMultiTenantConnectionProviderissuesSET search_path = <tenant_schema>when handing out a connection. From then on, every unqualified table reference lands in the tenant schema.- Requests with no resolvable tenant — bootstrap, health checks, platform-admin paths — fall back to
public, and the fallback is metriced.
If tenant data appears to "vanish" or writes land in the wrong place, the
first suspect is a code path that ran without TenantContext —
async executors, scheduled jobs, webhook handlers that skip the filter chain —
and silently fell back to public. The fallback exists so
bootstrap/health/platform-admin work without a tenant; it is deliberately
observable (a dedicated metric) precisely because silent fallback is the
classic failure shape.
2. What lives where
| Schema | Contents |
|---|---|
public (control plane) |
tenant, app_user, plan_limits,
platform_hosted_llm_provider, platform_setting,
per_user_budget_settings, community_template |
<tenant_schema> (one per tenant) |
Agents, workflows, knowledge documents + pgvector vector_store,
Apache AGE graph memory, channel credentials and sessions, provider credentials
(LlmProviderCredential), llm_call_log, audit_log,
LLM gateway tables, approval records — everything tenant-owned |
| Redis | Session memory (SessionMemoryService: per-conversation history lists and owner records under caller-scoped keys agentic:session:c1:<sha256>, 24h TTL; see Security Architecture §4.1),
MCP ownership registry (RedisMcpOwnershipRegistry) for cross-replica
STDIO connection ownership. Cache/coordination only — never the system of record. |
| Object storage | S3-API abstraction via storage module (FileStorageService):
MinIO in compose, S3/R2 in prod by config. Tenant data export and generated
media/documents land here. |
3. Flyway — two directories, one version namespace
Migrations live in two directories under
core/app/src/main/resources/db/migration/:
| Directory | Applies to | ≈ Files | When it runs |
|---|---|---|---|
db/migration/controlplane | public | ~80 | On boot, once |
db/migration/tenant | Every tenant schema | ~139+ (V334 latest claimed) | At tenant provisioning (replayed into the new schema) and migrate-on-boot on the API shape |
The two directories share a single Flyway version namespace.
Two files with the same V### number — or a renamed/renumbered
file — corrupt the shared flyway_schema_history and stop core from
booting. Rules that exist because of it:
- Always pick the next free version across both directories.
- Never rename or renumber an applied migration.
- Run
flyway-lint.shbefore committing — it guards exactly this.
4. Connection pooling — PgBouncer must be session mode
SET search_path is connection state, not transaction state.
Under PgBouncer transaction pooling a connection checked out for one
tenant's transaction can be handed back and reused mid-session for a different
tenant — with the previous tenant's search_path still set. The
result is reads and writes in the wrong tenant's schema. PgBouncer
MUST run in pool_mode = session anywhere it fronts this
database. This is a hard requirement of ADR-0002, not a tuning preference.
5. Secrets and encryption
One chokepoint: EncryptionService (core/crypto),
AES-256-GCM, keyed by APP_ENCRYPTION_KEY (base64 32-byte) with
rotation support via APP_ENCRYPTION_KEY_PREVIOUS. It encrypts LLM
provider keys, channel tokens, MCP connection env vars, Google refresh tokens,
Sarvam/ElevenLabs/Jev credentials, and hosted-pool keys. The same key value
must be present everywhere core boots — a divergent key renders every stored
credential undecryptable rather than merely failing loudly.
6. Provisioning and lifecycle
- Signup:
/api/signupcreates the Keycloak user and provisions a fresh tenant schema; tenant migrations replay into it at provisioning time. - Migrate-on-boot: the API shape (
core/app) runs tenant migrations across all schemas on startup;app-workerandapp-jobsdeliberately do not run Flyway. - Data export: tenant-data export uses the
storagemodule (Master Roadmap item #22).
7. Backup posture
| Layer | Self-hosted default | Note |
|---|---|---|
| Postgres | Bundled PostgreSQL 16 (pgvector + AGE) in Compose, or a CloudNativePG cluster / your managed Postgres with Helm | Irreplaceable. Back it up with pg_dumpall (Compose) or your operator's backup; see Backup & Restore |
| Valkey (Redis API) | Bundled Valkey or your own Redis-compatible service | Contents are disposable by design (sessions/ownership can be rebuilt) |
| Object storage | SeaweedFS S3 gateway (Full / Helm), local disk (Lite), or any S3 API | Tenant exports and generated artifacts |
- Back up the database together with
APP_ENCRYPTION_KEY; a dump without the key cannot decrypt stored provider keys and channel secrets. - Redis is reconstructable; Postgres is not. Never conflate the two when weighing an operation's blast radius.
llm_call_logandaudit_logare per-tenant tables — platform-wide cost/audit queries must iterate schemas, not assume a central table.