Architecture
ADR 0003 — Data layer and Row-Level Security (`packages/db`)
- Date: 2026-06-10
- Status: Accepted (implements ADR 0002 §3)
- Authors: Claude Code · Pamela
Context
ADR 0002 §3 chose row-per-tenant multi-tenancy with PostgreSQL Row-Level Security as "the key piece of the system." This ADR records how it was implemented in packages/db.
Decision
- Row-per-tenant +
tenant_idon every tenant table; the agency and each client are anorganization. Same schema for all tenants → marginal cost per client ~0. - RLS ENABLED and FORCED on each tenant table, with a
tenant_isolationpolicy comparingtenant_idto a transaction-localapp.tenant_id(SET LOCALsemantics). - A dedicated runtime role
app_userthat isNOSUPERUSERandNOBYPASSRLS. This is non-negotiable: superusers bypass RLS even withFORCE, so the application must connect as a non-privileged role. Migrations/admin use the owner; tenant queries useapp_uservia thewithTenant(tenantId, fn, actorId?)helper. - Base conventions on every tenant table:
created_at,updated_at(+version) maintained by a BEFORE UPDATE trigger,deleted_atsoft-delete,created_by. - Append-only
audit_logpopulated by an AFTER trigger (who/what/when/before/after); the runtime role may only SELECT/INSERT (UPDATE/DELETE revoked) → immutable history. - CI guard: a test fails if any table with a
tenant_idcolumn lacks enabled+forced RLS. Plus a cross-tenant isolation test (insert as A → B sees zero rows). - Single source of truth: the same schema is applied to the platform database, so Payload (
payloadschema) and the domain (publicschema) live in one database.
Key lesson
The cross-tenant isolation test caught that the default Postgres role is a superuser and bypasses RLS even when forced. The app_user role was therefore introduced. Lesson: always test isolation against the exact role the app uses.
Consequences
- Strong isolation enforced in the database, not in application code.
- Migrations run once for all tenants; trivial cross-tenant reporting for the agency.
- Escape path to schema/DB-per-tenant remains open for a future enterprise client.