Skip to main content

Multi-Tenancy Deep Dive

Surrogate OS implements schema-per-tenant multi-tenancy in PostgreSQL. Every organization gets a dedicated database schema with full data isolation. This page explains the pattern, the TenantManager class, and how to extend tenant tables.


Architecture Overview

Two Data Tiers

TierSchemaManaged ByContains
PlatformpublicPrisma migrationsUsers, Orgs, Marketplace, API Keys, Webhooks, Federation, Notifications
Tenanttenant_{slug}TenantManager (raw SQL)Surrogates, SOPs, Audit, Sessions, Memory, Debriefs, Proposals, Handoffs, Personas, Bias, Humanoid Devices, Executions, Compliance

Request Flow: JWT to Tenant Schema

Every authenticated request goes through this pipeline:

Key properties:

  • SET search_path and the query run inside the same transaction to avoid connection pool issues
  • Slug validation rejects any characters outside [a-z0-9-]
  • Schema names convert hyphens to underscores: acme-corp becomes tenant_acme_corp

TenantManager Class

The TenantManager (apps/api/src/tenancy/tenant-manager.ts) provides four core methods:

createTenantSchema(orgSlug)

Called during org registration. Creates the schema and all tenant tables with indexes. Also enables the pgvector extension for embedding storage.

executeInTenant<T>(orgSlug, sql, params?)

Executes a query that returns results (SELECT) within a tenant's schema. Uses $transaction to guarantee the SET search_path and query share the same database connection.

const surrogates = await tenantManager.executeInTenant<Surrogate[]>(
orgSlug,
'SELECT * FROM surrogates WHERE status = $1',
['ACTIVE']
);

executeStatementInTenant(orgSlug, sql, params?)

Executes a statement that returns an affected row count (INSERT, UPDATE, DELETE) within a tenant's schema.

getTenantConnection(orgSlug)

Returns a scoped query function for cases where you need multiple queries within the same tenant context:

const { query } = await tenantManager.getTenantConnection(orgSlug);
const surrogates = await query('SELECT * FROM surrogates');
const sops = await query('SELECT * FROM sops');

Tenant Tables

Each tenant schema contains 15 tables:

TablePurpose
surrogatesAI agent definitions (role, domain, jurisdiction, config)
sopsVersioned SOP graphs with hash-chain integrity
audit_entriesAppend-only audit log with cryptographic chaining
sessionsSurrogate work sessions
decision_outcomesPer-session decision records
debriefsPost-session analysis reports
sop_proposalsProposed SOP modifications with review workflow
org_documentsUploaded organizational documents
document_chunksDocument chunks with pgvector embeddings (1536 dimensions)
memory_entriesSTM/LTM institutional memory entries
handoffsD2D, D2H, H2D handoff records
persona_templatesReusable persona definitions
persona_versionsVersioned persona configurations
bias_checksBias audit analysis results
humanoid_devicesRegistered humanoid/device endpoints
executionsReal-time SOP execution state
compliance_checksRegulatory compliance results
sop_signaturesCryptographic SOP signatures (Ed25519)

Adding a New Tenant Table

To add a new table to the tenant schema:

  1. Add the CREATE TABLE statement in TenantManager.createTenantSchema():
await this.prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}".my_new_table (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
surrogate_id UUID REFERENCES "${schemaName}".surrogates(id),
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now()
)
`);
  1. Add indexes for common query patterns:
await this.prisma.$executeRawUnsafe(`
CREATE INDEX IF NOT EXISTS idx_my_new_table_surrogate_id
ON "${schemaName}".my_new_table(surrogate_id)
`);
  1. Create a service that uses executeInTenant for all data access.

  2. Create routes that extract request.tenant! and pass the org slug to the service.

Important: Tenant tables are NOT managed by Prisma migrations. They are created via raw SQL in createTenantSchema(). If you modify the schema of an existing table, you need a migration strategy for existing tenants.


Security Properties

  • Schema isolation: Cross-tenant queries are architecturally impossible because search_path is set per-transaction
  • Slug validation: Rejects SQL injection via strict regex: /^[a-z0-9]+(-[a-z0-9]+)*$/
  • No shared mutable state: Each tenant's data lives in its own schema with independent indexes
  • Audit chain integrity: Tenant audit entries use hash-chaining for tamper detection

See also: Platform Architecture | API Endpoints