Docs/Decisions/ADR-0002 Schema-per-Tenant

ADR-0002: Tenant isolation by Postgres schema per tenant

Status
accepted
Decided
2026-07 (Phase 0 Stage 4)
Deciders
Platform Engineering
Motivating failures
PgBouncer wrong-schema routing (fixed July 2026); a Flyway version-collision outage (July 2026)

Context

Every tenant's application data — agents, workflows, chat history, knowledge metadata — must be isolated from every other tenant's, auditable for GDPR erasure, and cheap to provision (a signup creates a tenant in seconds). The realistic options were a discriminator column on shared tables, a database per tenant, or a schema per tenant inside one database.

Two forces shaped the choice. First, GDPR-focused research for this platform specifically recommends schema/database-per-tenant because right-to-erasure and per-tenant data residency become mechanically simple — drop a schema, or route a tenant's schema to an EU cluster at provisioning time (TenantProvisioningService is the exact seam). Second, connection economics: database-per-tenant needs a pool per tenant; schema-per-tenant can reuse the app's single pooled DataSource — which the SchemaMultiTenantConnectionProvider javadoc calls "the whole point of choosing schema-based over database-based multi-tenancy."

Decision

Tenant isolation is a Postgres schema per tenant, implemented as Hibernate multi-tenancy:

flowchart LR req["Request + JWT"] --> filter["TenantContextFilter
reads tenant_id claim"] filter --> ctx["TenantContext (ThreadLocal)"] ctx --> res["TenantSchemaResolver"] res --> prov["SchemaMultiTenantConnectionProvider
setSchema → SET search_path"] prov --> pool["Shared DataSource
via PgBouncer (session mode)"] pool --> pg[("Postgres: public / control_plane / <tenant> / <tenant>_kg")]

Alternatives considered

OptionWhy it lost
Discriminator column on shared tables Every query and every new table has to remember the predicate forever; one missed WHERE tenant_id=… is a silent cross-tenant leak. Erasure is row-by-row surgery, not a dropped schema.
Database per tenant Needs a connection pool per tenant and per-database provisioning/migration orchestration; at tens-to-hundreds of tenants it exhausts max_connections and operations. Offers stronger isolation than was needed at this scale.
Schema-qualified SQL, no session state Would make transaction pooling safe (removing the PgBouncer constraint below), but requires every statement to carry the schema — incompatible with Hibernate's model without abandoning its multi-tenancy support. Recorded in SchemaMultiTenantConnectionProvider's javadoc as the real escape hatch if session pooling ever becomes the constraint.

Consequences

Positive

Negative / costs

Operating rule
If queries are hitting the wrong tenant's data, suspect PgBouncer left in transaction pooling first — it must be POOL_MODE=session. Getting transaction pooling back means dropping session-scoped search_path altogether, not tuning the proxy.

Evidence & references