Tenant Isolation
A per-org deployment posture where the org becomes a self-contained tenant — its own auth-provider organisation, with the option to bolt on its own S3 bucket and its own encryption key as paid addons when those operational mechanisms ship. Patients at a dedicated-mode tenant have identities scoped to that tenant only; same email at another tenant is a distinct platform identity. The platform's default mode is
shared(logical isolation via RLS + prefix scoping). The premium mode isdedicated(real identity separation via a per-tenant Clerk org). Both modes can be fully visually white-labeled — branding is universal, not mode-gated.
What this is
Tenant Isolation is the architectural pattern. Tenancy Mode is the schema property that toggles it. Two values: shared and dedicated. The values describe identity topology, not branding — every clinic gets logo, colours, theme, custom domain, custom mail-from regardless of mode. The mode controls who shares the auth-provider organisation with whom.
| Aspect | Shared mode | Dedicated mode |
|---|---|---|
| Auth-provider organisation | Pooled (platform's shared Clerk org) | Tenant-specific (reserved — not yet provisionable) |
| Cross-tenant patient profile portability | Available with consent (profile_shared = true) | Not possible by construction |
| S3 storage | Shared bucket, per-tenant prefix | Shared bucket today; own-bucket addon available when the exit/portability tool ships |
| Encryption (column-level CMK) | Platform CMK | Platform CMK today; own-CMK addon available when the crypto-shred runbook ships |
| Branding | Full clinic-branded UI | Full clinic-branded UI |
| Custom domain | Yes | Yes |
| Custom mail-from / SMS sender | Yes (via platform_service_providers) | Yes (via platform_service_providers) |
| Patient terms / privacy notice | Platform + clinic legal stack | Tenant-only — patients never see platform_terms |
| Pricing posture | Standard MRR | One-time setup + premium MRR + termination service fee |
The marketing label "White-Label" maps to dedicated mode in current sales conversations, but is decoupled from the architectural name — a shared-mode clinic with a custom domain and "Powered by" attribution hidden is also visually white-labeled.
When to use which mode
| Use dedicated mode when... | Use shared mode when... |
|---|---|
| Clinic demands a fully separated identity namespace at the auth-provider layer | Clinic accepts logical isolation (RLS + audit + restricted DB access) as the security boundary |
| Clinic wants patient identity that explicitly cannot recognise the same email at another clinic | Clinic is fine with portable patient identity (most cases) |
| Clinic plans to fund the future own-bucket / own-CMK addons as part of their data-sovereignty story | Clinic is fine with platform-as-custodian for orphan patient identity |
| Clinic can fund the operational setup and premium MRR | Clinic is on standard SMB pricing |
Shared-mode clinics get the full portable-profile UX (auto-fill onboarding across clinics, single platform identity, caregiver-link portability). Dedicated-mode clinics give that up by construction — there is no shared identity layer to port through.
Topology decision
The schema column that distinguishes modes is a single enum:
ALTER TABLE organizations
ADD COLUMN tenancy_mode TEXT NOT NULL DEFAULT 'shared'
CHECK (tenancy_mode IN ('shared', 'dedicated'));This is intentionally a single column, not a multi-axis bundle. Earlier rounds of this design carried three BOOLEAN columns (dedicated_identity, dedicated_storage, dedicated_encryption) plus a CHECK constraint coupling them. That shape has been retired — see decisions.md → Why tenancy_mode is a single enum, not multi-axis.
The values:
shared(default): pooled platform infrastructure. Logical isolation via RLS + prefix scoping + app-layer entitlement checks. All shared-mode tenants live in the platform's default Clerk organisation; identity is platform-global.dedicated(reserved): per-tenant Clerk organisation. Identity is tenant-scoped — same email at this tenant and any other tenant is two distinct platform identities. Not provisionable via API today.
Identity axis (the only structural difference today)
The defining feature of dedicated mode is the per-tenant auth-provider organisation. Each dedicated-mode tenant has its own Clerk org; the platform's auth verifier picks the right org from the platform_service_providers row for that tenant. Same email at "WellnessRehab" (provider tenant A) and "MediSport" (platform default tenant) produces two distinct provider_subject_id values → two humans rows → two patient_profiles rows. There is no cross-org identity recognition because there is no shared identity at the auth layer.
Today this maps to Clerk's per-org partition. The architectural primitive is provider-agnostic — swapping the auth provider is an auth/<provider> package change, not a schema or API change. See services/api/internal/core/auth/doc.go.
The schema reservations that support this are already in place from foundation 1B and the tenancy schema simplification:
organizations.tenancy_mode TEXT NOT NULL DEFAULT 'shared'— set to'dedicated'when the per-tenant Clerk org provisioner ships.humans.provider_org_id TEXT NULL— populated by the provisioner with the dedicated Clerk org ID; always NULL today.UNIQUE INDEX humans (email, provider_org_id) NULLS NOT DISTINCT— keeps email globally unique while every row'sprovider_org_idis NULL; future-proofs the composite uniqueness for dedicated mode without a migration.
This axis is reserved, not shipped. No creation flow accepts tenancy_mode = 'dedicated' today; no provisioning code provisions per-tenant Clerk orgs. The columns and indexes exist so that when a paying dedicated contract closes, the provisioner can ship as service-layer code without touching foundational schema.
Deferred addons
Earlier design carried two additional structural axes — per-tenant S3 bucket (dedicated_storage) and per-tenant CMK (dedicated_encryption) — as BOOLEAN columns on organizations. Those columns have been retired.
When per-tenant storage and per-tenant encryption ship as products, they enter the platform as paid entitlements in the entitlements catalog (and corresponding per-org platform_service_providers override rows), not as columns on the organizations row. They join the existing family of paid addons that are universally available regardless of tenancy mode — custom domain, custom mail-from, custom SMS sender, custom branding.
Each addon ships in one PR alongside the operational mechanism it depends on. The trigger is a paying customer who funds the work, not a schema flag.
| Future addon | Operational mechanism required before it can ship |
|---|---|
entitlement = own_s3_bucket | An exit / portability tool that uses the per-tenant bucket. Customers buy own-bucket because they want clean GDPR export and (eventually) data portability when they leave. Without that tool, the flag would change which bucket files land in — and nothing else customer-visible. |
entitlement = own_cmk | A documented crypto-shred runbook for GDPR erasure. The cryptographic story is that crypto-shred makes erasure tractable: on contract termination, the per-tenant CMK is retired in KMS and the column-encrypted data is unrecoverable. Without that runbook, BYOK is a config row that produces identical encryption behaviour to the platform-shared CMK. |
The pricing posture for these addons is paid uplift available on either tenancy mode. A shared-mode clinic can subscribe to own-bucket if they want the export tool, without becoming tenancy_mode = 'dedicated'. A dedicated-mode tenant can package both addons into their dedicated SKU. The schema doesn't enforce coupling because the operational mechanisms are independent.
Lifecycle (activated_at)
ALTER TABLE organizations
ADD COLUMN activated_at TIMESTAMPTZ NULL;activated_at IS NULL = draft; activated_at IS NOT NULL = active.
Three concrete behaviours gate on this:
GET /v1/public/organizations/resolve?slug=…returns 404 for draft orgs — the org's slug/domain is unroutable.- Welcome email is queued by the activation transition, not by raw INSERT.
- Owner first-login bind-on-first-auth checks
activated_atbefore binding the auth-provider user to the org row.
Console superadmin always sees all orgs regardless of state.
Today. Every creation path sets activated_at = NOW() in the same transaction as the INSERT. Dedicated mode is not yet provisionable via API — there's no per-tenant Clerk org provisioner, so there's no provisioning step to wait for. Every org we create is tenancy_mode = 'shared' and is immediately routable.
Future (when dedicated provisioning ships). The dedicated creation path will INSERT with activated_at = NULL, run the Clerk org provisioner asynchronously, and a re-introduced finalize endpoint (with proper preconditions — Clerk org exists, platform_service_providers overrides written, etc.) will flip activated_at = NOW() and queue the welcome email. The column is the reservation that lets that flow slot in without a foundation-schema migration.
See decisions.md → Why activated_at as the org draft-state mechanism.
Cache layer correctness
The existing cache tag taxonomy (org:{id}:*, platform:*, me:{principal_id}) is correctly partitioned for dedicated-mode tenants:
org:{id}:*is per-org_id— already isolated regardless of mode.me:{principalId}is per-principal; dedicated-mode tenants have distinct principal IDs by construction (differentprovider_org_id→ differenthumansrow → different principal ID).platform:*is platform-wide and intentionally shared (entitlement catalog, role definitions). Acceptable for dedicated-mode tenants — the cached resources are not patient data and don't carry tenant-identifying signal.
No cache-layer changes needed for dedicated mode.
Controller / processor model
The legal roles don't change between modes:
- Clinic = data controller for their patients, always
- Platform (RestartiX) = data processor, always
- Platform = data controller for orphaned patient profiles (when a patient leaves all clinics, their profile is no longer under any clinic's control)
Dedicated mode doesn't elevate the clinic to "sole controller for everything"; the clinic is already controller, and the platform's processor role is bounded by the DPA. What dedicated mode changes is the identity boundary — pooled provider tenant (shared) vs dedicated provider tenant (dedicated). The legal stack is the same shape on either mode.
In dedicated mode, patient terms / privacy notices are tenant-only — patients never see platform_terms / platform_privacy_notice. Foundation-side this is enforced by the tenant's legal documents subsuming the platform's. The clinic's privacy notice still names RestartiX as the underlying processor (legally required by GDPR Art. 13), but the patient-facing UX shows only the clinic.
Mode change (upgrade / downgrade)
v1 does not ship migration tooling for switching between modes. Tenant mode is set at provisioning and effectively immutable for v1.
Switching is technically possible but has patient-facing UX consequences that the v1 product doesn't address:
- shared → dedicated requires migrating every human (staff + patients) from the platform Clerk org to a new dedicated Clerk org; patients have to re-authenticate; cross-clinic patients with shared profiles need their profile forked (the dedicated-mode tenant gets a fresh copy, the other clinics keep theirs).
- dedicated → shared requires collapsing a dedicated identity namespace into the shared one; matching emails would collide with humans that already exist in the shared namespace; cross-clinic profile sharing can't retroactively re-link. More disruptive than the upgrade direction.
A clinic that started shared and now wants dedicated = manual ops project. Not a self-serve flow; not a supported product motion in v1. The door stays open for the future if a real customer asks; the tooling gets built then.
Foundation pre-work
Completed pre-work
- [x]
humans.provider_org_id TEXT NULLcolumn added in000002_tenancy_rbac.up.sql(no FK; FK target lands with runtime feature). - [x]
humans.email NOT NULL UNIQUEreplaced withUNIQUE (email, provider_org_id) NULLS NOT DISTINCT. Functionally identical for today's shared-only fleet; future-proofs for dedicated mode. - [x]
humans.provider_org_idrow in column-classification registry (data-classification.md). - [x]
humans.provider_org_idrow indata-model.md → humans. - [x]
organizations.tenancy_mode TEXT NOT NULL DEFAULT 'shared' CHECK IN ('shared', 'dedicated')added to000002_tenancy_rbac.up.sql. - [x]
organizations.activated_at TIMESTAMPTZ NULLadded to the same migration withidx_organizations_draft (id) WHERE activated_at IS NULL. - [x] Column-classification registry entries for
tenancy_modeandactivated_at. - [x] Column entries for
tenancy_modeandactivated_atindata-model.md → organizations. - [x] Public org-resolve handler (
GET /v1/public/organizations/resolve) gates onactivated_at IS NOT NULL— returns 404 for draft orgs. - [x] Owner welcome dispatch gates on
activated_at IS NOT NULL. - [x] Organization service sets
activated_at = NOW()on every creation path (no path produces a draft today). - [x]
make migrate-reset && make check && make test-integrationgreen.
What is intentionally NOT in foundation
The dedicated-mode runtime — the part that turns the schema reservation into a sellable feature — is deferred. Foundation lands the schema vocabulary so future work doesn't need to touch foundational migrations under contract pressure.
Deferred design surface
Runtime infrastructure provisioning, per-tenant operational templating, the addon mechanisms behind own-bucket / own-CMK, and the dedicated DPA template are NOT part of foundation. They build when the first paying dedicated contract closes (or when an individual addon is funded by a paying customer). The list below is the shape, not the spec.
When built, the runtime feature will need:
- Per-tenant Clerk org provisioner. Programmatic API call to create the dedicated Clerk org, bind app credentials to it, write the resulting
provider_org_idinto the relevanthumansrows and into aplatform_service_providersoverride row for the tenant. This is the work that flipstenancy_mode = 'dedicated'from a schema reservation into a sellable product. - Re-introduced finalize-provisioning endpoint.
POST /v1/superadmin/organizations/{id}/finalize-provisioning(gated to superadmin). Preconditions: Clerk org exists,platform_service_providersoverrides written, any subscribed addons (own-bucket, own-CMK) are provisioned. Effect: setsactivated_at = NOW(), queues welcome email. The endpoint was prototyped and then dropped during the foundation tenancy simplification; it returns when dedicated provisioning ships with proper preconditions instead of being a no-op flip. - Console post-creation UI for dedicated-mode draft orgs (was prototyped as
DraftProvisioningChecklistand dropped during the simplification). Rebuilds when the provisioner exists so the checklist reflects real preconditions, not placeholder steps. - Own-S3-bucket addon.
entitlement = own_s3_bucketrow in the catalog + plumbing in the storage capability to route uploads to the per-org bucket + a Terraform module underiac/dedicated/tenants.hcl(per-tenant bucket + bucket policy + CORS + IAM bindings) + the exit / portability tool that uses the per-org bucket to produce GDPR export packages. Ships as one PR when the first paying customer funds the work. - Own-CMK addon.
entitlement = own_cmkrow in the catalog + plumbing in the encryption capability to wrap column-level encryption with the per-org CMK + a Terraform module that creates the CMK and key alias + the crypto-shred runbook documenting how the key is retired on contract termination. Ships as one PR when funded. - Per-tenant operational templating. Per-tenant DNS (
portal.{tenant}.com,auth.{tenant}.com,mail.{tenant}.com), TLS cert (AWS ACM with DNS-01), SES sender with DKIM/SPF/DMARC on tenant DNS, Twilio SMS messaging service, Daily.co domain, terms / privacy URL editor, per-tenantsupport@contact. Most of this surface is already available on either tenancy mode as paid customisation viaplatform_service_providersoverrides; dedicated tenants get it bundled. - API surface for data export / purge on contract termination.
POST /v1/superadmin/organizations/{id}/export-data(structured ZIP of patient-scoped data + media S3 sync from the per-org bucket when own-bucket is subscribed).POST /v1/superadmin/organizations/{id}/purge-data(triple-gated: break-glass session + signed export receipt + 30-day delay between export and purge). Depends on own-bucket addon to deliver a clean export. - Legal / compliance. Separate dedicated-mode DPA template: tenant-as-controller for the entire patient relationship including account-level identity, platform-as-processor under Art. 28(3), data-export-and-purge clause with concrete SLAs, no orphan custodianship on termination, exclusion from cross-tenant analytics, sub-processor disclosure flowing through tenant. Audit-log retention carve-out for legal-hold rules.
- Pricing. One-time setup fee (funds operational provisioning) + premium MRR uplift (industry comparable: 5–10× default-mode pricing) + termination service fee (funds export-and-purge work) + addon line items for own-bucket / own-CMK. Sales decision; not architecturally constrained.
A separate ADR captures the dedicated-mode controllership story: see decisions.md → Why tenant-isolation has its own controllership story.
Open questions
These defer to the runtime build, not to foundation:
- Cross-dedicated-tenant identity for the same person. A patient who exists at WellnessRehab (dedicated) and MediSport (shared) is two distinct platform identities. If the same person exists at WellnessRehab and another dedicated clinic, they are also two distinct identities. This is the intended behaviour — but worth surfacing in sales conversations so customers know what they're signing up for.
- Per-tenant audit log partitioning. Today's
audit_logis monthly-range-partitioned globally (P41). Dedicated tenants might want their audit log fully separated for export-on-termination cleanliness. Consider at build time: filter on export, or implement per-tenant audit-log partitioning as a separate enhancement. - Auth-provider SLA pass-through. The auth provider's SLA may differ between shared and dedicated provider tenants. The DPA must reflect the provider's actual SLA at the tier negotiated for dedicated tenants. (Today the provider is Clerk.)
- Sub-processor change notification flow. When the platform changes a sub-processor (e.g., new SMS provider), shared tenants are notified via the platform's standard channel; dedicated tenants likely want direct notification per their DPA. Build a per-tenant sub-processor change webhook / email channel at feature time.
- Per-tenant rate limits / quotas. Noisy-neighbor concern on shared infrastructure. Could surface as a future entitlement (
max_request_rate,max_storage_bytes) — defer until a real customer hits the problem.
These are normal feature-build questions — they don't block the architectural commitment.