Skip to content

Organizations Feature

A multi-tenant container for clinic/practice data, branding, and integrations.

What this enables

Multi-clinic operations: Specialists and administrators can hold staff memberships at multiple clinics from a single platform identity, switching between them instantly.

Brand control: Each organization has its own logo, colors, and dedicated domains (e.g., healthcorp.clinic.restartix.pro for staff, healthcorp.portal.restartix.pro for patients). Custom domains are also supported (e.g., portal.myclinic.com). White-labeling is universal — every clinic gets it; it is not a tier upsell and not a synonym for a tenancy mode.

Data isolation: Clinical records, consents, programs, sessions, and settings are completely separate per organization — accidentally mixing clinics is impossible. The one deliberate exception is the patient's portable profile (patient_profiles): demographics, blood type, allergies, and insurance travel with the patient across clinics, so they are not re-entered at each one. A clinic reads it because the patient is registered there, and a clinic they are not registered at reads nothing. See Patients →.

Integration storage: Securely store credentials for external services the clinic owns (Cat B Connected Accounts) without exposing secrets in code.

Platform model

RestartiX is a curated platform — clinics cannot self-register. Every organization is created and onboarded by the platform team (superadmin). This ensures quality control, proper configuration, and compliance vetting before a clinic goes live. Self-service organization creation may be added in the future, but is explicitly out of scope for now.

How it works

  1. Superadmin creates organization: A platform operator (superadmin) creates the clinic/practice via POST /v1/organizations. A database trigger (create_organization_companion_rows) clones the system role templates into the new org and seeds its settings / billing / entitlements / legal-document rows in the same transaction.
  2. Staff are added: An org admin invites staff via POST /v1/organizations/{id}/staff-invitations (the onboarding path for someone who has never signed in), or attaches an existing platform identity via POST /v1/organizations/{id}/members (email + role code). Patients never appear here — they are not memberships and have no role. See Why patients are not memberships.
  3. Set organization context: All data operations work within that organization's boundary — determined by the domain the user visits.
  4. Switch organizations (optional): If they hold memberships at multiple clinics, they switch by navigating to the other clinic's hostname. The clinic app's TeamSwitcher does a full-page redirect to {slug}.{CLINIC_DOMAIN}.

Technical Reference

Overview

Organizations are the root of multi-tenancy. Every piece of tenant-scoped data belongs to an organization. A principal can hold staff memberships in multiple organizations and switch between them.

Key Principle: An external identity provider handles authentication (login, MFA, sessions — Clerk today; the verifier package is provider-agnostic). Organizations handle tenancy (data isolation, access control).

Key Concepts

The actor model, in one paragraph

There is no users table. Every actor — human, AI agent, integration service account, and the singleton system job — is a row in principals. Human profiles live in humans (PK principal_id, FK to principals(id)). Staff membership in an organization is a row in organization_memberships (principal_id, organization_id, role_id, is_owner, …). Patients are a separate axis entirely: a portable patient_profiles row plus a per-org patients row. See Actor model →.

Organizations as Multi-Tenancy Root

Every request operates within an organization context determined by the domain:

  1. User visits {slug}.clinic.restartix.pro or a custom domain
  2. Frontend proxy resolves the hostname → organization via GET /v1/public/organizations/resolve?slug={slug} (or ?domain={hostname})
  3. Organization ID is passed to the API via the X-Organization-ID header
  4. The OrganizationContext middleware binds the transaction's RLS session variables through a SECURITY DEFINER wrapper
  5. All queries are automatically filtered by organization_id via Row-Level Security (RLS)

The Golden Rule: WHERE organization_id = current_app_org_id() — direct column check, no sub-queries. Only the identity tables (principals, humans) need a membership sub-query, because a principal is not owned by any single org.

Organization Slug

Every organization has a unique slug (URL-safe identifier) used for subdomain routing:

  • Clinic app: {slug}.clinic.restartix.pro (e.g., healthcorp.clinic.restartix.pro)
  • Patient Portal: {slug}.portal.restartix.pro (e.g., healthcorp.portal.restartix.pro)
  • Custom domains: Organizations can optionally configure their own domains. Portal-type custom domains are shipped; clinic-type custom domains are refused at the API until the edge dispatcher that routes a custom hostname to the clinic app exists (see custom-domains.md).
  • Public resolution: GET /v1/public/organizations/resolve?slug={slug} (no auth required, per-IP rate limited)

Example slugs: restartix, healthcorp, medcenter-amsterdam

Organization Switching

Staff who hold memberships at multiple organizations switch by navigating to the other organization's hostname. The TeamSwitcher component (apps/clinic/components/team-switcher.tsx) renders from the memberships array on GET /v1/me and calls window.location.assign() on {slug}.{CLINIC_DOMAIN} — a full page navigation, not an API call.

There is no PUT /v1/me/switch-organization endpoint. Older docs describe one; it is not in routes.go and never shipped. Active-org selection is carried entirely by the hostname. There is also no cached "current organization" column on humans — default-org-on-first-sign-in is derived from MAX(last_used_at) across organization_memberships (staff) and patients (patient).

Use cases:

  • Specialist working for multiple clinics
  • Admin managing multiple organizations
  • Support staff with cross-org access

Connected Accounts (per-org external credentials)

Per-org encrypted credentials for services the clinic owns live in organization_integrations, against a platform catalog in integration_services (migration 000017, foundation 1C.5, Cat B in the glossary). Credentials are encrypted at the application layer (AES-256-GCM via internal/core/crypto) into credentials_encrypted BYTEA.

The framework shipped; the catalog is seeded empty. Each F-tier consumer adds its integration_services row plus the connector implementation in the same PR. The OAuth callback handler (state-token CSRF, code→token exchange, refresh worker) is deferred to the first OAuth-using consumer — the service rejects auth_type = 'oauth2' from the create endpoint today.

Database Schema

Tables

organizations

The root table for multi-tenancy. Defined in services/api/migrations/core/000002_tenancy_rbac.up.sql.

ColumnTypeDescription
idUUID PRIMARY KEY DEFAULT gen_random_uuid()Primary key
nameTEXT NOT NULLOrganization name (e.g., "RestartiX")
slugTEXT NOT NULL UNIQUEURL-safe identifier
taglineTEXTShort description
descriptionTEXTFull description
emailTEXTPublished contact email (public-class; not the billing contact)
phoneTEXTPublished contact phone
websiteTEXTWebsite URL
locationTEXTPhysical location
logo_urlTEXTLogo image URL (Bunny CDN)
icon_urlTEXTIcon/favicon URL (Bunny CDN)
language_codeTEXT NOT NULL DEFAULT 'en'Default UI language (ISO 639-1, e.g. en, ro)
portal_self_signup_enabledBOOLEAN NOT NULL DEFAULT FALSEPer-clinic toggle for portal walk-up signup; surfaced through the public resolve endpoint
brandingJSONB NOT NULL DEFAULT '{}'White-label payload (colors, theme mode, …). JSONB because it's read as a whole blob and never queried per-field
tenancy_modeTEXT NOT NULL DEFAULT 'shared' CHECK IN (shared,dedicated)Tenancy posture. shared is the only sellable mode today; dedicated is a schema reservation — see tenant-isolation.md
activated_atTIMESTAMPTZNULL = draft (not routable from public endpoints); non-NULL = active
created_atTIMESTAMPTZ NOT NULL DEFAULT NOW()Creation timestamp
updated_atTIMESTAMPTZ NOT NULL DEFAULT NOW()Last update timestamp (auto-updated by the set_updated_at trigger)

Companion rows (organization_settings, organization_billing, organization_entitlements, organization_legal_documents, the cloned role set, the Free-plan subscription) are all created by the create_organization_companion_rows trigger on INSERT. That function was introduced in 000003 and is redefined via CREATE OR REPLACE in 000009 — edit it there.

organization_domains

Stores additional verified hostnames for an org (custom domains beyond {slug}.clinic.restartix.pro). Also in 000002_tenancy_rbac.up.sql.

Bring-your-own domains are provisioned through Cloudflare for SaaS: the platform registers the hostname with Cloudflare, the clinic points a CNAME at the SaaS target, Cloudflare validates ownership and issues the edge certificate, and status flips pending → verified to gate traffic routing.

ColumnTypeDescription
idUUID PRIMARY KEY DEFAULT gen_random_uuid()Primary key
organization_idUUID NOT NULLFK to organizations (CASCADE)
domainTEXT NOT NULL UNIQUEHostname (lowercased, trimmed)
domain_typedomain_type enumclinic or portal (clinic is currently refused by the service — see above)
statusdomain_status enumpending / verified / failed
cloudflare_hostname_idTEXTCF-assigned custom-hostname UUID, used for status polling and deregistration
ssl_statusTEXTMirrors the CF certificate sub-state for admin-UI surfacing
verification_tokenTEXT NOT NULLPredates the CF flow (legacy DNS-TXT ownership check). Retained, but no longer the routing gate
verified_atTIMESTAMPTZWhen the hostname first reached verified
last_check_atTIMESTAMPTZMost recent re-verification attempt
created_atTIMESTAMPTZ NOT NULL DEFAULT NOW()Creation timestamp
updated_atTIMESTAMPTZ NOT NULL DEFAULT NOW()Last update timestamp (auto-updated)

organization_memberships

Staff membership + per-org role assignment. This is the only per-org membership table. It replaced the earlier principal_organizations naming, and the never-shipped user_organizations shape that older revisions of this doc described. Also in 000002_tenancy_rbac.up.sql.

sql
CREATE TABLE organization_memberships (
    principal_id    UUID NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
    organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    role_id         UUID NOT NULL REFERENCES roles(id),
    is_owner        BOOLEAN NOT NULL DEFAULT FALSE,
    last_used_at    TIMESTAMPTZ,
    invited_at      TIMESTAMPTZ,
    invited_by      UUID REFERENCES principals(id) ON DELETE SET NULL,
    accepted_at     TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (principal_id, organization_id)
);

Load-bearing details:

  • principal_id, not a user id. The column FKs to principals(id) so the table works for any non-patient actor type (agents and service accounts as those features ship), not just humans.
  • A principal has exactly one role per org (composite PK). Different roles in different orgs is the normal case.
  • is_owner is a flag, not a role. The owner still carries a role_id (typically the system admin clone) so ordinary RLS works through the standard (principal, org, role) → permissions chain. Override authority comes from current_app_is_owner() in RLS and Subject.IsOwner short-circuiting RequirePermission(...) in middleware. A partial unique index uniq_owner_per_org enforces at most one owner per org; the org-creation flow guarantees at least one.
  • The owner row is protected by trigger. protect_owner_membership blocks DELETE of an owner row and blocks the is_owner TRUE → FALSE flip unless the transaction sets app.allow_owner_demote = 'true' (the ownership-transfer flow does).
  • Non-human principals are single-org, enforced by the enforce_single_membership_for_non_humans trigger against agents.organization_id / service_accounts.organization_id.
  • Granting superadmin wipes tenant memberships. clear_organization_memberships_on_superadmin_grant fires AFTER INSERT ON platform_memberships and deletes the principal's organization_memberships rows, emitting one audit row per deletion.

Use cases:

  • Multi-clinic specialists
  • Consultants working across organizations
  • Support staff with cross-org access

(Patients switching providers is not one of them — that's a second patients row, not a second membership.)

Indexes

sql
CREATE INDEX idx_organizations_slug  ON organizations(slug);
CREATE INDEX idx_organizations_draft ON organizations(id) WHERE activated_at IS NULL;

CREATE INDEX idx_org_domains_org      ON organization_domains(organization_id);
CREATE INDEX idx_org_domains_domain   ON organization_domains(domain);
CREATE INDEX idx_org_domains_verified ON organization_domains(status) WHERE status = 'verified';

CREATE INDEX idx_org_memberships_org        ON organization_memberships(organization_id);
CREATE INDEX idx_org_memberships_principal  ON organization_memberships(principal_id);
CREATE INDEX idx_org_memberships_role       ON organization_memberships(role_id);
CREATE UNIQUE INDEX uniq_owner_per_org      ON organization_memberships(organization_id) WHERE is_owner = TRUE;

Uniqueness on organization_domains.domain comes from the table-level UNIQUE constraint, not from idx_org_domains_domain (which is a plain lookup index).

Every organization_id column across the schema has an index for fast RLS checks.

Row-Level Security (RLS)

RLS policies enforce organization boundaries at the database level. Superadmins bypass RLS by connecting through the AdminPool (restartix owner role); tenant traffic runs on the AppPool (restartix_app, which does not bypass RLS).

organizations

PolicyOperationRule
organizations_selectSELECTid = current_app_org_id()
organizations_insertINSERTWITH CHECK (FALSE) — superadmin-only via AdminPool
organizations_updateUPDATEid = current_app_org_id() AND current_app_has_permission('organizations','update') (same expression in USING and WITH CHECK)
organizations_deleteDELETEUSING (FALSE) — superadmin-only via AdminPool

There is no public SELECT policy. The public hostname-resolve endpoint runs on AdminPool with a single-row equality match on slug or verified domain. An AppPool connection with no session vars intentionally sees zero organization rows — an earlier broad current_app_role() IS NULL policy made every clinic on the platform listable to any unauthenticated caller, and was removed.

organization_domains

PolicyOperationRule
org_domains_selectSELECTorganization_id = current_app_org_id()
org_domains_insert/update/deleteINSERT/UPDATE/DELETEorganization_id = current_app_org_id() AND current_app_has_permission('organizations','manage_domains')

Same note as above: no public carve-out. Public domain resolution runs on AdminPool.

organization_memberships

PolicyOperationRule
org_memberships_selectSELECTorganization_id = current_app_org_id()
org_memberships_insert/update/deleteINSERT/UPDATE/DELETEorganization_id = current_app_org_id() AND current_app_has_permission('organizations','manage_members')

How RLS Works

Every authenticated request runs inside an explicit pgx.Tx. Middleware calls a SECURITY DEFINER wrapper on that transaction; the wrapper validates the tuple and then binds the session variables itself. Application code never calls set_config('app.*', …) directly — the app.* namespace is not grantable to restartix_app.

sql
-- Authenticated, no org chosen yet (e.g. listing the caller's memberships):
SELECT set_app_principal('0190af3b-1c2e-7c00-8a4f-b2d9c4e5f001');

-- Staff request scoped to an org. Validates (principal, org, role) against
-- organization_memberships ⨝ roles before setting anything:
SELECT set_app_staff_context(
    '0190af3b-1c2e-7c00-8a4f-b2d9c4e5f001',   -- principal_id (UUID)
    '0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100',   -- organization_id (UUID)
    'specialist'                              -- role code
);

-- Patient sessions use set_app_patient_context (defined in 000006 —
-- it joins patients / patient_profiles / patient_caregivers).

The session variables those wrappers bind are:

Session varReader helperNotes
app.current_principal_idcurrent_app_principal_id()UUID. Never an integer. There is no app.current_user_id.
app.current_actor_typecurrent_app_principal_type()Read from principals.principal_type by the wrapper — middleware can't lie about it
app.current_org_idcurrent_app_org_id()UUID
app.current_rolecurrent_app_role()The role code, carried for diagnostics. Not the authorization primitive

All are set with set_config(..., true) — transaction-scoped, wiped at COMMIT/ROLLBACK by Postgres. Connection lifetime and authorization lifetime never have to match; a pooled connection carries no GUCs into the next request. The wrappers additionally lock-once on principal_id: within a transaction they refuse to re-bind to a different principal, so an SQLi attacker inside a request cannot re-scope it.

Authorization itself is always a permission check, never a role-string compare:

sql
CREATE POLICY organizations_update ON organizations FOR UPDATE USING (
    id = current_app_org_id()
    AND current_app_has_permission('organizations', 'update')
) WITH CHECK (
    id = current_app_org_id()
    AND current_app_has_permission('organizations', 'update')
);

current_app_has_permission(resource, action) is SECURITY DEFINER + STABLE. It short-circuits TRUE for the org's owner (current_app_is_owner()), then joins organization_memberships → role_permissions → permissions, and AND's the result with principal_is_active(...) so a blocked or soft-deleted principal is denied regardless of granted role.

The full helper set available to policies: current_app_principal_id(), current_app_principal_type(), current_app_org_id(), current_app_role(), current_app_has_permission(), current_app_is_owner(), current_app_has_patient_access(), current_app_has_org_entitlement(), current_app_break_glass_id(), current_app_impersonation_id(), current_app_action_context(), current_app_ip(), current_app_request_id() / _method() / _path(), current_app_user_agent(), current_human_patient_profile_ids(), current_human_is_patient_at(). There is no current_app_user_id(), no is_superadmin() (superadmins bypass RLS via AdminPool), and no current_app_location_ids() — per-location scoping was deliberately not added (000014_locations.up.sql:144).

Result: callers on the AppPool see only data for their current organization, automatically enforced by PostgreSQL.

See architecture/data-model.md → Area 1 (Foundation) for the canonical schema.

API Reference

Endpoints

The organization identity, membership, and domain surface, with gates listed verbatim as enforced by RequirePermission / RequireSuperadmin / RequirePerOrgPermissionOrBreakGlass in services/api/internal/core/server/routes.go:

MethodEndpointAuthGate
GET/v1/public/organizations/resolve?slug=… or ?domain=…None— (per-IP rate limit RATELIMIT_PUBLIC_RESOLVE_*, default 30/min)
GET/v1/organizationsBearersuperadmin
POST/v1/organizationsBearersuperadmin
GET/v1/organizations/{id}Bearer— (RLS scopes to the caller's org)
PATCH/v1/organizations/{id}Bearerorganizations.update or org_management break-glass
POST/v1/organizations/{id}/branding/{asset}Bearerorganizations.update or org_management break-glass (assetlogo, icon)
GET/v1/organizations/{id}/membersBearerorganizations.manage_members
POST/v1/organizations/{id}/membersBearerorganizations.manage_members or break-glass (upsert: re-roles existing members)
DELETE/v1/organizations/{id}/members/{principalId}Bearerorganizations.manage_members or break-glass
GET/v1/organizations/{id}/rolesBearerorganizations.manage_members
POST/v1/organizations/{id}/staff-invitationsBearerorganizations.manage_members or break-glass
GET/v1/organizations/{id}/staff-invitationsBearerorganizations.manage_members
GET/v1/organizations/{id}/domainsBearerorganizations.manage_domains
POST/v1/organizations/{id}/domainsBearerorganizations.manage_domains + custom_domain tier entitlement
POST/v1/organizations/{id}/domains/{domainId}/verifyBearerorganizations.manage_domains
DELETE/v1/organizations/{id}/domains/{domainId}Bearerorganizations.manage_domains
GET/v1/organizations/{id}/settingsBearer— (membership-gated by RLS)
PATCH/v1/organizations/{id}/settingsBearerorganizations.update_settings
GET / PATCH/v1/organizations/{id}/billingBearerorganizations.manage_billing
GET / PUT / DELETE/v1/organizations/{id}/designations[/{kind}]Bearerread via RLS (view_directory); write organizations.manage_designations
POST / GET / DELETE/v1/organizations/{id}/ownership-transfersBearerRequireOwner() for initiate + cancel; pending is read-only
POST/v1/organizations/{id}/owner/resend-welcomeBearersuperadmin

Every route under /v1/organizations/{id} inherits middleware.RequireURLOrgMatchesScope("id") (P47) at the route-group level — the URL {id} must match the caller's resolved org scope, or the request is refused before the handler runs. Superadmins bypass. This is the guard that makes URL-keyed caching safe; without it RLS would hide one mismatched response and the cache would propagate it.

Other route families are also mounted under /v1/organizations/{id} — patients, patient-tiers, patient-subscriptions, subscriptions, locations, consents, access-offers, outbound-webhook-subscriptions, integrations, legal-documents, share-links, patient-invitations, impersonation sessions, stats, edit locks. Those are documented in their own feature docs; routes.go is authoritative for the complete list.

Listing members reuses organizations.manage_members (there is no separate "view members" permission). All gated routes also enforce the same permission code via current_app_has_permission(...) in RLS — the Go middleware is a fast-fail layer, the database is the authoritative one.

The full set of organizations.* permission codes seeded across migrations: update, update_settings, view_directory, manage_members, manage_domains, manage_billing, manage_designations, manage_integrations, manage_webhooks, manage_share_links, manage_privacy_notice, transfer_ownership. See rbac-permissions.md.

Examples

All identifiers are UUIDs (UUIDv7 per P26); the platform has no integer IDs anywhere on the wire.

Resolve organization by slug (public, for domain routing)

bash
GET /v1/public/organizations/resolve?slug=restartix

Response:
{
  "data": {
    "id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
    "name": "RestartiX",
    "slug": "restartix",
    "tagline": "…",
    "description": "…",
    "email": "contact@restartix.ro",
    "phone": "+40…",
    "website": "https://restartix.ro",
    "location": "Bucharest",
    "logo_url": "https://cdn.../logo.png",
    "icon_url": "https://cdn.../icon.png",
    "language_code": "ro",
    "portal_self_signup_enabled": false,
    "branding": { }
  }
}

Every field here is public-class per data-classification.mdorganizations. email/phone are the clinic's published contact (the kind that goes on a landing page), not the internal billing contact — that lives on organization_billing and is admin-only.

List organizations (superadmin)

bash
GET /v1/organizations
Authorization: Bearer <token>

Response:
{
  "data": [
    { "id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100", "name": "RestartiX", "slug": "restartix" },
    { "id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f200", "name": "HealthCorp", "slug": "healthcorp" }
  ]
}

Listing is superadmin-only. The RLS policy on organizations is id = current_app_org_id(), so a tenant request would see at most one row — not a useful "list" semantic. Tenant staff get their own org list from the memberships array on GET /v1/me (org id + name + slug, enough for the sidebar switcher).

Update organization

Requires organizations.update (or an org_management break-glass session). Method is PATCH, not PUT — only fields present in the body are updated.

bash
PATCH /v1/organizations/0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "RestartiX NL",
  "email": "info@restartix.nl"
}

Response:
{
  "data": {
    "id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
    "name": "RestartiX NL",
    "slug": "restartix",
    "email": "info@restartix.nl",
    "updated_at": "2026-04-26T14:22:00Z"
  }
}

Add a member

Requires organizations.manage_members. The endpoint is upsert-style: if the principal is already a member, their role is re-assigned. The role value must match a role code that exists in this org — the cloned system templates are admin, specialist, customer_support. There is no patient role: patients are not memberships.

Two additional refusals worth knowing:

  • 404 user_not_found if no humans row has that email. This endpoint attaches an existing platform identity; to onboard someone who has never signed in, use POST /v1/organizations/{id}/staff-invitations.
  • 400 superadmin_not_assignable if the target holds a platform superadmin grant — superadmins operate at the platform level and are not assigned to individual organizations.
bash
POST /v1/organizations/0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100/members
Authorization: Bearer <token>
Content-Type: application/json

{
  "email": "specialist@example.clinic",
  "role": "specialist"
}

Response:
{
  "data": {
    "principal_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f300",
    "email": "specialist@example.clinic",
    "organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
    "role_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f400",
    "role_code": "specialist"
  }
}

The audit row uses entity_type = "organization_membership" with action CREATE for new memberships and UPDATE for role changes; a no-op upsert emits nothing. The same mutations publish organization.member_added / organization.member_role_changed / organization.member_removed on the internal event bus (Cat E).

See api.md for the full endpoint reference (members, domains, request/response shapes, error codes).

Authentication

  • An external identity provider handles authentication (login, signup, MFA, sessions) — Clerk today; the verifier package in internal/core/auth is provider-agnostic and the binding column is humans.provider_subject_id.
  • A principal can hold staff memberships in multiple organizations.
  • Authorization is per-org permission codes, resolved through organization_memberships → roles → role_permissions → permissions. The four canonical staff role codes (specialist, customer_support, admin, plus platform-level superadmin) are rows, not an enum, so custom per-org roles need no schema change.

See ../auth/index.md for authentication documentation.

Connected Accounts / Integrations

Per-org connections to external services live in organization_integrations, keyed to a platform catalog row in integration_services (migration 000017). Each connection stores:

  • integration_service_id — which catalog entry (Google Calendar, Slack, an EHR, …)
  • auth_typeoauth2 | api_key | webhook_in_only
  • external_account_id + title — the third-party-side identity and a human label
  • credentials_encrypted BYTEA — AES-256-GCM sealed via internal/core/crypto
  • config JSONB — plaintext non-secret connector configuration
  • inbound_token / inbound_signing_secret_encrypted — the Cat D inbound-webhook endpoint, provisioned on demand

The catalog ships empty; each F-tier consumer seeds its own row alongside the connector implementation. All routes gate on organizations.manage_integrations.

integration_services (the catalog of Cat B services we connect to) is a different table from service_accounts (principals that authenticate into our API, Cat F). The word "service" collides; the concepts don't. See the glossary.

See api-keys.md for encryption details.

Organization-Scoped Data

Every tenant-scoped table carries organization_id UUID NOT NULL REFERENCES organizations(id). As of migration 000039, 66 of the 109 tables declare the column; 43 do not (see database-overview → Multi-tenancy for the five scoping shapes the other 43 use). A meaningful number leave it nullable on purpose — roles for the NULL-org system templates, exercises / session_exercises / program_phases for platform-tier content per P49, consents for platform-level consent rows. A representative slice:

TableDescription
organization_membershipsStaff membership + per-org role
organization_settingsOperational + compliance knobs, feature flags
organization_billingBilling contact, tax data, current plan pointer
organization_entitlementsPer-org entitlement overrides
organization_domainsCustom hostnames
organization_designationsDPO / billing-contact assignments
organization_legal_documentsPer-org ToS + privacy notice editor state
rolesPer-org role clones (+ NULL-org system templates)
patientsPer-org link to a portable patient_profiles row
consentsPer-clinic consent grants
locationsClinic sites
exercises, sessions, programs, protocolsClinical content
session_runs, session_exercises, session_pairingsSession execution + telemetry
patient_tiers, patient_subscriptions, organization_subscriptionsCommerce
access_offers + access_offer_*F14 commerce/campaign access grants
outbound_webhook_subscriptionsCat C outbound webhooks
organization_integrationsCat B connected accounts
break_glass_sessionsCross-tenant access sessions (always audited)
audit_logAudit events (range-partitioned monthly, P41)

All are filtered by RLS on organization_id = current_app_org_id().

Tables named in older revisions of this doc — specialties, specialists, appointments, form_templates, forms, custom_fields, custom_field_values, appointment_templates, segmentsdo not exist. F1 Specialists, F3 Forms, F4 Scheduling, F5 Appointments, and F6 Documents are scaffold-only. F8 Segments is out of scope. See platform-completion.md for what's planned and data-model.md for the designed-not-built schema.

Organization Lifecycle Workflows

Not built. There is no automations domain in the API and no automation-rule table in any migration. The event names described in the Automations spec (patient.onboarded, appointment.first_booked, service_plan.enrolled) are design, not code.

What does exist today for lifecycle reactions:

  • The internal event bus (internal/core/events, Cat E) with a code-defined registry. Organization events are organization.created, organization.updated, organization.member_added, organization.member_role_changed, organization.member_removed.
  • Cat C outbound webhook subscriptions (outbound_webhook_subscriptions), which let a clinic POST those events to their own URL (Make.com, Zapier, n8n).
  • The create_organization_companion_rows trigger, which seeds every per-org companion row at org creation.

Consent requirements are enforced by the consents domain (RequireCurrentConsents middleware + consent_purpose_versions), not by an automation engine.

See:

Design Principles

1. organization_id on Every Tenant-Scoped Table

Why: Direct RLS checks, no sub-queries, maximum performance.

Exceptions:

  • principals / humans have no organization_id — a principal is not owned by any single org. Tenant binding for humans is organization_memberships (staff) and patients (patient). Non-human actors do get a direct binding, but on their actor-type child table (agents.organization_id, service_accounts.organization_id, both NOT NULL), so the column shape matches the actor's nature.
  • patient_profiles and patient_caregivers have no organization_id — they are patient-owned portable data that travels across orgs. patient_profiles RLS keys on current_human_patient_profile_ids() instead, plus a staff branch requiring a patients row at the current org. That row is the whole boundary — there is no second per-field gate above it. See Patients →.
  • roles allows organization_id IS NULL for the system templates that get cloned into each new org.

2. Clean Table Naming

Why: Human-readable, predictable, easy to understand.

  • organizations, principals, humans, organization_memberships
  • auth_organizations, system_users, appt_v2

Column names use the long form organization_id everywhere in DDL. The short org_id is reserved for wire forms — JWT claims, log keys, query params, S3 path templates, Postgres session-var names (app.current_org_id). See glossary → Naming conventions.

3. Explicit Foreign Keys with ON DELETE Behavior

Why: Referential integrity, no orphaned records, clear cascade behavior.

sql
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE

The cascade is a schema-integrity guarantee, not an operational workflow: organizations_delete is USING (FALSE), there is no DELETE /v1/organizations/{id} route, and patient data is soft-deleted, never hard-deleted.

4. Encryption Only Where Classification Requires It

Column-level encryption is reserved for two classes: auth_secret (credentials, API keys, signing secrets) and pii_regulated (national IDs — CNP, SSN, passport). Everything else — names, emails, phones, addresses, allergies, diagnoses — is plaintext plus layered defense (RLS + audit + at-rest disk encryption + encrypted backups + restricted DB access). The rule is mechanically enforced by cmd/check-classification in make check.

Phone numbers are explicitly not encrypted: partial and last-N-digit search is a required clinic feature, and random-nonce AES-GCM makes it impossible. See decisions.md → Why most PII is plaintext.

5. Timestamps Where They Mean Something

Why: Audit trail, debugging, analytics.

Mutable state tables carry created_at (set once on INSERT) and updated_at (bumped by the set_updated_at trigger). Append-only registries carry only created_atprincipals, for example, has created_at + deleted_at and no updated_at, because there's nothing to update on an identity row.

6. UUIDv7 Primary Keys Everywhere

Why: No enumeration, no coordination on insert, and — unlike UUIDv4 — time-ordered so B-tree inserts stay sequential.

Every PK on the platform is a UUID. There are no bigserial / integer surrogate keys and no separate public/internal ID pair. See P26.

Organizations additionally expose slug for human-friendly hostnames, but id is the identifier on the wire.

7. Own Organizations, Not Provider Orgs

Why: The identity provider handles auth only. Organizations are a core domain concept with business logic (integrations, settings, branding, billing, entitlements).

Our organizations table is the source of truth. humans.provider_org_id is reserved for the future dedicated tenancy mode, where each tenant gets its own provider-side organization; it is NULL for every shared-mode tenant today.

8. Append-Only Local Audit

Why: Forensic integrity. All mutations write synchronously to the local audit_log, which has an INSERT policy and no UPDATE/DELETE policies, with mutating grants revoked from restartix_app. The table is range-partitioned monthly (P41).

Audit rows are written through one canonical writer (audit_log_insert), so handler-emitted rows, trigger-cascaded rows, and any SECURITY DEFINER caller all share the same per-request envelope (request id, IP, path, method, user agent, action context) bound by set_app_request_envelope.

Architecture Decisions

Why Not Use the Identity Provider's Organizations?

Clerk offers an organizations feature, but we build our own:

  1. Data ownership — organizations are a core domain entity with complex business logic
  2. Integration data — encrypted credentials, settings, branding, entitlements
  3. Compliance — GDPR (day-one requirement) needs full control over data access and audit trail
  4. Flexibility — extend organizations with designations, tiers, locations, billing
  5. RLS integration — direct PostgreSQL integration for row-level security

The provider handles authentication; our organizations handle tenancy.

Why Denormalize organization_id?

Many tables carry organization_id even when it could be derived via JOIN:

sql
-- patient_tier_versions could derive its org from patient_tiers.organization_id,
-- but stores it directly so the RLS predicate is a plain column check.
CREATE TABLE patient_tier_versions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tier_id         UUID NOT NULL REFERENCES patient_tiers(id) ON DELETE CASCADE,
    organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,  -- denormalized
    version         INT NOT NULL,
    ...
);

(A few child tables leave the denormalized column nullable on purpose — session_exercises.organization_id and program_phases.organization_id are NULL for platform-tier content that belongs to no org, per P49. That's a deliberate carve-out for platform-owned rows, not an exemption from the rule.)

Why:

  1. RLS performance — a direct column check beats a sub-query on every row
  2. Index usage — PostgreSQL uses the organization_id index directly
  3. Simplicity — no nested logic in RLS policies

Trade-off: slight denormalization for a large, per-query performance gain.

Why bytea for Encrypted Credentials?

Encrypted values are stored as binary:

sql
credentials_encrypted BYTEA NOT NULL
  1. Binary format — AEAD output is binary, not text
  2. No encoding overhead — no base64/hex round-trip
  3. Integrity — binary can't be silently mangled by text operations

Wire format: [version: 1 byte][nonce: 12 bytes][ciphertext + GCM tag] — see internal/core/crypto/envelope.go. The leading version byte selects the key from the keyring, which is what makes key rotation possible without a bulk re-encrypt.

The public API is crypto.Encrypt / crypto.EncryptString / crypto.Decrypt / crypto.DecryptString. There is no per-column helper and no organization-id associated-data binding.

See api-keys.md for more.

Security Considerations

RLS Enforcement

RLS is enabled on all tenant-scoped tables. The AppPool connects as restartix_app, which does NOT bypass RLS. The AdminPool (restartix owner) is used only by auth middleware, superadmin requests, public resolve, and system queries — it bypasses RLS by design.

Critical: never grant BYPASSRLS to the restartix_app role.

Defense in depth on top of policies: for tables where AppPool has no legitimate write surface (principals, humans, platform_memberships, agents, service_accounts, roles, role_permissions, permissions, audit_log), the broad DML grants are explicitly REVOKEd. A future migration that accidentally adds a write policy still can't write — the grant has to be restored deliberately in the same migration.

Credential Security

  1. Encrypted at rest — AES-256-GCM application-level encryption into BYTEA
  2. Encrypted in transit — TLS verify-full to Aurora; TLS for every external call
  3. Permission-gatedorganizations.manage_integrations at the route layer and in RLS
  4. Audited — access and rotation logged to audit_log
  5. No caching — decrypted secrets are never cached
  6. Column-level REVOKE where the value should never be listed — e.g. REVOKE SELECT (api_key_hash) ON service_accounts FROM restartix_app, so SELECT * fails loudly at review time instead of leaking into a list endpoint

Organization Isolation

  1. RLS at database level — PostgreSQL enforces boundaries
  2. Transaction-scoped session variables — bound through SECURITY DEFINER wrappers that validate the (principal, org, role) tuple and lock-once on principal_id
  3. URL ≡ scope guard (P47) — every per-org route group mounts RequireURLOrgMatchesScope, so a URL org and a header org can never disagree
  4. Superadmin override — via AdminPool, and cross-tenant identifiable access goes through the break-glass pattern (per-org scope, time-bound, justified, always-on clinic notification, all actions including reads audited)
  5. Portable profile exceptionpatient_profiles is reachable by any org the patient is registered at, and registering IS the disclosure: extended fields (DOB, allergies, insurance, …) are readable to that org's staff. An org with no patients row reads nothing. The scoping is enforced in every staff-facing query as well as RLS (P8, retired).

Audit Trail

Organization operations logged to audit_log, with entity_type in organization / organization_membership / organization_domain:

  • Organization created / updated
  • Member added / role changed / removed (including the cascade when a superadmin grant lands)
  • Domain added / verified / removed
  • Settings, billing, designations, legal documents changed
  • Ownership transfers initiated / accepted / declined / cancelled

Operational-metadata bumps are exempt: organization_memberships.last_used_at (P35 activity tracker) and the universal set_updated_at trigger record presence, not a state transition.

Performance Considerations

Index Coverage

Every organization_id column has an index:

sql
CREATE INDEX idx_org_memberships_org  ON organization_memberships(organization_id);
CREATE INDEX idx_patients_org         ON patients(organization_id);
CREATE INDEX idx_patients_org_active  ON patients(organization_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_consents_active_by_org ON consents(organization_id, ...);
-- ... etc.

Why: RLS filters by organization_id on every query.

RLS Policy Design

Policies use direct column checks, not sub-queries:

sql
-- Fast (direct check)
CREATE POLICY locations_select ON locations FOR SELECT USING (
    organization_id = current_app_org_id()
);

-- Slow (sub-query — avoid)
CREATE POLICY locations_select ON locations FOR SELECT USING (
    id IN (
        SELECT location_id FROM some_other_table
        WHERE organization_id = current_app_org_id()
    )
);

Exception: the identity tables need a membership sub-query, because a principal is not owned by an org. Both arms are additionally gated on the organizations.view_directory permission — patient sessions also carry current_app_org_id(), so without that gate a patient at a clinic's portal could enumerate every staff member's email and provider subject id.

sql
CREATE POLICY humans_select ON humans FOR SELECT USING (
    principal_id = current_app_principal_id()
    OR (
        current_app_has_permission('organizations', 'view_directory')
        AND principal_id IN (
            SELECT po.principal_id FROM organization_memberships po
            WHERE po.organization_id = current_app_org_id()
        )
    )
);

This is acceptable because organization_memberships is small per org and indexed on organization_id. principals has the equivalent policy pair (principals_select_self + principals_select_via_membership).

Response Caching

The public resolve path is the hottest read on the platform — every Next.js proxy request for every org hits it. It's wrapped with cache.Aside (P45) against Redis with a 5-minute TTL, keyed separately for slug and domain lookups. GetByID uses the same pattern with its own 5-minute TTL and is the canonical P45 reference example in the codebase.

Invalidation is explicit per key on the write paths: an org update invalidates the slug key and every verified-domain key for that org; domain add/remove/status-change invalidates that domain's key.

Session Variable Overhead

Binding the transaction context costs one round-trip per request:

sql
SELECT set_app_staff_context(
    '0190af3b-1c2e-7c00-8a4f-b2d9c4e5f001',
    '0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100',
    'specialist'
);

set_config(..., true) makes each GUC transaction-scoped, so it auto-clears at COMMIT/ROLLBACK and a pooled connection never carries context into the next request.

pgbouncer note (P44): runtime traffic goes through pgbouncer in transaction pooling mode on port 6432. No session-mode Postgres features in runtime paths — no advisory locks, no LISTEN/NOTIFY, no session-scoped SET (use set_config(..., true)), no temp tables. Migrations use DATABASE_DIRECT_URL on 5432.

Migration Guide

Adding a New Table

When creating a new tenant-scoped table (run /new-migration — it encodes the full checklist):

  1. Add organization_id column:

    sql
    CREATE TABLE new_table (
        id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
        -- ... other columns
        created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );

    If the table records occurrences (append-only, time-ordered, multi-year retention) rather than state, range-partition it monthly from day one — audit_log is the reference shape (P41). Retrofitting a partition later is not an option.

  2. Add index:

    sql
    CREATE INDEX idx_new_table_org ON new_table(organization_id);
  3. Seed the permission and grant it to the system role templates:

    sql
    INSERT INTO permissions (code, resource, action, description) VALUES
        ('new_table.manage', 'new_table', 'manage', 'Manage new_table rows');
    
    -- Grant to the NULL-org system templates AND to the already-cloned
    -- is_system roles under every existing org.
    INSERT INTO role_permissions (role_id, permission_code)
    SELECT r.id, 'new_table.manage'
    FROM roles r
    WHERE r.is_system = TRUE AND r.code = 'admin';
  4. Add RLS policies — permission checks, never role-string compares:

    sql
    ALTER TABLE new_table ENABLE ROW LEVEL SECURITY;
    
    -- Superadmins bypass RLS via AdminPool (owner role) — no is_superadmin() exists
    CREATE POLICY new_table_select ON new_table FOR SELECT USING (
        organization_id = current_app_org_id()
    );
    
    CREATE POLICY new_table_modify ON new_table FOR ALL USING (
        organization_id = current_app_org_id()
        AND current_app_has_permission('new_table', 'manage')
    ) WITH CHECK (
        organization_id = current_app_org_id()
        AND current_app_has_permission('new_table', 'manage')
    );
  5. Add the auto-update trigger:

    sql
    CREATE TRIGGER set_updated_at BEFORE UPDATE ON new_table
        FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
  6. Register every new column in data-classification.md in the same PR — make check fails the build otherwise.

Production is live. A migration already applied to production cannot be edited in place; it needs a catch-up DDL script per environment (infra/scripts/000023-skip-note-prod.sql is the canonical pattern). Migrations not yet applied anywhere may still be edited freely. New migrations start at 000040.

Adding a New Integration Service

There is no integration_service enum. The catalog is rows in integration_services. To add a connector:

  1. Insert the catalog row in the same PR as the connector implementation:

    sql
    INSERT INTO integration_services (slug, name, description, auth_type, oauth_scopes, oauth_client_capability)
    VALUES ('google_calendar', 'Google Calendar', '…', 'oauth2',
            ARRAY['https://www.googleapis.com/auth/calendar'], 'google_oauth');

    auth_typeoauth2 | api_key | webhook_in_only. OAuth connectors must declare oauth_client_capability (a CHECK enforces it); non-OAuth connectors must leave it NULL.

  2. Implement the connector behind the Cat B interface.

  3. Add a SOUP row in soup.md for any new direct dependency, and a glossary entry for any new vocabulary — both in the same PR.

  4. Create the connection via API:

    bash
    POST /v1/organizations/0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100/integrations
    {
      "integration_service_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f500",
      "auth_type": "api_key",
      "external_account_id": "acct_123",
      "title": "Production account",
      "credentials": { "api_key": "sk_live_..." }
    }

    auth_type: "oauth2" is rejected from this path until the OAuth callback handler ships.

Testing

RLS Policy Testing

RLS tests run against a real Postgres (testcontainers) via the internal/test/rlstest harness. Scope a transaction through the same SECURITY DEFINER wrappers production uses, then assert what the principal can and can't see:

go
// Staff context: requires the (principal, org, role) tuple to exist in
// organization_memberships ⨝ roles — seeded by SeedDefaults.
tx := h.AsPrincipal(t, adminPrincipalID, orgAID, "admin")

var count int
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM organizations`).Scan(&count)
require.NoError(t, err)
require.Equal(t, 1, count) // only org A is visible

// Unauthenticated: no wrapper call, no GUCs — RLS denies everything.
pub := h.AsPublic(t)

// Patient session: validated against patients / patient_profiles / patient_caregivers.
pt := h.AsPatient(t, patientPrincipalID, orgAID)

Equivalent by hand in psql, inside an explicit transaction:

sql
BEGIN;
SELECT set_app_staff_context(
    '0190af3b-1c2e-7c00-8a4f-b2d9c4e5f001',  -- principal_id
    '0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100',  -- organization_id
    'specialist'
);

-- Sees only that org
SELECT * FROM organizations;

-- Fails: the specialist template has no organizations.update grant
UPDATE organizations SET name = 'Hacked'
 WHERE id = '0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100';
COMMIT;

Note what you cannot do: there is no way to grant yourself permission by changing a role string. app.current_role is diagnostic only; current_app_has_permission reads organization_memberships → role_permissions → permissions. To test the admin path, bind a principal who actually holds an admin membership.

Multi-Organization Testing

sql
-- Given a principal with memberships at orgs A and B (seeded via
-- organization_memberships, one row per org, each with its own role_id).

BEGIN;
SELECT set_app_staff_context('<principal-uuid>', '<org-A-uuid>', 'admin');
SELECT * FROM patients;   -- org A patients only
COMMIT;

BEGIN;
SELECT set_app_staff_context('<principal-uuid>', '<org-B-uuid>', 'specialist');
SELECT * FROM patients;   -- org B patients only
COMMIT;

The wrapper rejects any (principal, org, role) tuple that has no matching organization_memberships ⨝ roles row, so a test can't accidentally assert against a scope that wouldn't exist in production.

Encryption Testing

go
func TestCredentialEncryption(t *testing.T) {
    require.NoError(t, crypto.Init(keyring))
    t.Cleanup(crypto.Reset)

    plaintext := "sk_live_test_key_123"

    blob, err := crypto.EncryptString(plaintext)
    require.NoError(t, err)

    got, err := crypto.DecryptString(blob)
    require.NoError(t, err)
    assert.Equal(t, plaintext, got)
}

Run the suites with make test (unit, race detector on) and make test-integration (testcontainers Postgres + S3 LocalStack + ratelimit) from services/api/, or make check-all from the repo root.

Troubleshooting

Staff can't see data after switching orgs

Symptom: a staff member navigates to another clinic's hostname but sees empty lists, or gets a 403.

Causes:

  1. No organization_memberships row for that (principal, org)
  2. The hostname didn't resolve — the proxy's org-id cookie is stale or missing, so X-Organization-ID points at the wrong org
  3. RequireURLOrgMatchesScope refused because the URL {id} disagrees with the resolved scope
  4. The role clone in that org lacks the permission the RLS policy checks
  5. The principal is blocked or soft-deleted — principal_is_active() makes every permission check return FALSE

Debug:

sql
-- Which orgs does this principal have staff access to, and with which role?
SELECT om.organization_id, o.slug, r.code AS role_code, om.is_owner, om.last_used_at
  FROM organization_memberships om
  JOIN organizations o ON o.id = om.organization_id
  JOIN roles r         ON r.id = om.role_id
 WHERE om.principal_id = '<principal-uuid>';

-- Is the principal still allowed to act at all?
SELECT principal_is_active('<principal-uuid>');

-- What does the role actually grant in that org?
SELECT rp.permission_code
  FROM organization_memberships om
  JOIN role_permissions rp ON rp.role_id = om.role_id
 WHERE om.principal_id = '<principal-uuid>'
   AND om.organization_id = '<org-uuid>'
 ORDER BY 1;

-- Inside a scoped transaction, what does the session think it is?
BEGIN;
SELECT set_app_staff_context('<principal-uuid>', '<org-uuid>', '<role-code>');
SELECT current_app_principal_id(), current_app_principal_type(),
       current_app_org_id(), current_app_role(), current_app_is_owner();
COMMIT;

Run these on AdminPool (or as the owner role) — on AppPool the diagnostic queries are themselves RLS-filtered.

Stale organization data after an update

Symptom: an org rename or logo change doesn't show up on the portal.

Causes:

  1. The Redis resolve cache hasn't expired (5-minute TTL) and the write path didn't invalidate
  2. A Next.js tagged GET (P42) wasn't invalidated — the server action must call updateTag(...) and refresh()
  3. A client component seeded useState from a server prop (P48), so the new server value is silently ignored

Credentials not decrypting

Symptom: decrypt fails with an authentication-tag error.

Causes:

  1. The keyring's key for that blob's version byte isn't loaded in this environment
  2. The active key version rotated but the old version was dropped from the keyring
  3. Data corruption

Debug: check the first byte of the blob — it's the key version — and confirm the keyring exposes a key for it (crypto.Keyring.Key(version)). Keys live in AWS KMS; never in code, config, or environment variables outside local dev.

Domain Mapping

How Domain Resolution Works

Organization context is request-scoped — determined by the domain the user visits, not stored on the principal record. This is the full flow for every page load:

  Browser ──► healthcorp.clinic.restartix.pro    (subdomain)
         OR   portal.myclinic.com                (custom domain)


  ┌──────────────────────────────────────────────────────────┐
  │  Next.js Proxy (proxy.ts)                                │
  │                                                          │
  │  extractSlugFromHostname(hostname, CLINIC_DOMAIN)        │
  │    ├── Match? slug = "healthcorp"                        │
  │    │   └── resolveOrganization(slug, API_URL)            │
  │    │       GET /v1/public/organizations/resolve          │
  │    │           ?slug=healthcorp                          │
  │    │                                                     │
  │    └── No match? (custom domain)                         │
  │        └── resolveOrganizationByDomain(hostname, ...)    │
  │            GET /v1/public/organizations/resolve          │
  │                ?domain=portal.myclinic.com               │
  │                                                          │
  │  Set httpOnly cookies: org-id, org-slug, org-name        │
  │  Enforce auth for protected routes                       │
  └──────────────────────────┬───────────────────────────────┘


  ┌──────────────────────────────────────────────────────────┐
  │  API — Public Resolve Endpoint (no auth, IP rate-limited) │
  │                                                          │
  │  ?slug=healthcorp                                        │
  │    ├── Redis cache hit? (org:resolve:slug:healthcorp)    │
  │    │   └── Return cached org                             │
  │    └── Cache miss?                                       │
  │        └── repo.FindBySlugPublic (AdminPool, no RLS)     │
  │            └── Cache result in Redis (5 min TTL)         │
  │                                                          │
  │  ?domain=portal.myclinic.com                             │
  │    ├── Redis cache hit? (org:resolve:domain:portal.my..) │
  │    │   └── Return cached org                             │
  │    └── Cache miss?                                       │
  │        └── repo.FindByDomainPublic (AdminPool, no RLS)   │
  │            JOIN organization_domains (status='verified') │
  │            └── Cache result in Redis (5 min TTL)         │
  │                                                          │
  │  Returns the public-class field set (see example above)  │
  └──────────────────────────────────────────────────────────┘


  ┌──────────────────────────────────────────────────────────┐
  │  Server Component / Route Handler                        │
  │                                                          │
  │  createApiClient() reads the org-id cookie               │
  │  ApiClient sends X-Organization-ID: {org-id} header      │
  └──────────────────────────┬───────────────────────────────┘


  ┌──────────────────────────────────────────────────────────┐
  │  API — Authenticated Request                             │
  │                                                          │
  │  1. Authenticate middleware — verify the JWT             │
  │  2. SubjectLoader — build principal.Subject once         │
  │     (memberships, patient orgs, platform grants)         │
  │  3. OrganizationContext middleware:                      │
  │     ├── Read X-Organization-ID header                    │
  │     ├── Confirm the principal has access to that org     │
  │     │   (staff membership OR patients row)               │
  │     └── BEGIN tx + call set_app_staff_context(...) or    │
  │         set_app_patient_context(...)                     │
  │  4. RequireURLOrgMatchesScope on per-org routes (P47)    │
  │  5. Handler executes — every query scoped by RLS         │
  └──────────────────────────────────────────────────────────┘

Cache invalidation: when an org is updated (PATCH /v1/organizations/{id}), both the slug cache and all verified-domain caches for that org are invalidated. Domain caches are also invalidated when a domain is added, removed, or its status changes.

Custom Domain Verification Flow

Verification goes through Cloudflare for SaaS, not a DNS TXT record:

  Admin adds domain via POST /v1/organizations/{id}/domains
  ├── { "domain": "portal.myclinic.com", "domain_type": "portal" }
  ├── Gate: organizations.manage_domains + `custom_domain` tier entitlement
  ├── Reject if the hostname falls inside the platform's own subdomain space
  ├── Reject domain_type = "clinic" (edge dispatcher not built)
  ├── INSERT organization_domains row (status = 'pending')
  └── cloudflaresaas.Register(hostname)
      ├── Success → store cloudflare_hostname_id + ssl_status, map CF status
      └── Failure → roll the row back so the domain can be re-added


  Clinic points a CNAME at the platform's SaaS target


  Cloudflare validates ownership and issues the edge certificate


  Admin (or the admin UI) triggers
  POST /v1/organizations/{id}/domains/{domainId}/verify
  ├── cloudflaresaas.GetStatus(cloudflare_hostname_id)
  ├── Map CF status → pending | verified | failed
  ├── Set verified_at on the first flip to verified
  └── Invalidate the domain resolve cache

verification_token is retained on the row from the legacy DNS-TXT design and is still returned by the list endpoint (which is why every domain route gates on manage_domains — a stable secret is need-to-know), but it no longer gates routing.

Not built: there is no re-verification cron. Nothing polls Cloudflare on a schedule; status only refreshes when someone calls the verify endpoint. An operator who removes their CNAME will keep a verified row until a manual re-check.


Today vs Planned

The organization feature is the most-shipped surface on the platform. "Today" describes behavior you can rely on right now; "Planned" is design that has not landed.

Today: Subdomain Routing

Each organization is accessed via its slug-based subdomain:

  • Clinic app: {slug}.clinic.restartix.pro
  • Patient Portal: {slug}.portal.restartix.pro
  • Local dev: {slug}.clinic.localhost:9100 / {slug}.portal.localhost:9200

How it works:

  1. Browser hits healthcorp.clinic.restartix.pro
  2. The Next.js proxy extracts slug healthcorp from the hostname
  3. Proxy calls GET /v1/public/organizations/resolve?slug=healthcorp (public, no auth)
  4. API returns the public-class org payload
  5. Proxy sets org-id, org-slug, org-name as httpOnly cookies
  6. createApiClient() reads the org-id cookie and sends X-Organization-ID
  7. OrganizationContext middleware binds the RLS transaction context
  8. All downstream queries are scoped to that organization

Organization switching: TeamSwitcher performs a full-page navigation to {slug}.{CLINIC_DOMAIN}. There is no API switching endpoint.

Today: Custom Domain Support (portal only)

Organizations can configure their own portal hostnames in addition to the default subdomains. Clinic-app custom domains are refused by the service until the edge dispatcher ships.

Database: organization_domains (shape documented above).

API endpoints:

MethodPathGateDescription
GET/v1/organizations/{id}/domainsorganizations.manage_domainsList domains (response includes verification_token)
POST/v1/organizations/{id}/domainsorganizations.manage_domains + custom_domain entitlementAdd a custom domain and register it with Cloudflare
DELETE/v1/organizations/{id}/domains/{domainId}organizations.manage_domainsRemove a custom domain
POST/v1/organizations/{id}/domains/{domainId}/verifyorganizations.manage_domainsPoll Cloudflare and refresh status

Adding a domain consumes the custom_domain tier entitlement; list/verify/delete stay permission-only so a plan downgrade doesn't strand an admin from cleaning up domains they already added.

Resolve endpoint extension: GET /v1/public/organizations/resolve?domain=portal.myclinic.com looks up verified custom domains. The frontend proxy uses ?slug= for platform subdomains and ?domain= for custom domains.

Caching: Redis cache-aside for both resolve paths, 5-minute TTL, invalidated on org update or domain status change.

Planned (not shipped today)

  • OAuth connected accountsorganization_integrations supports auth_type = 'oauth2' at the schema level, but the callback handler (state-token CSRF, code→token exchange, refresh worker) is deferred to the first OAuth-using F-tier consumer. The create endpoint rejects oauth2 today.
  • Custom per-org roles — the roles table already holds non-system rows (organization_id set, is_system = FALSE), but there is no management API and no INSERT/UPDATE/DELETE policy; the DML grants on roles / role_permissions are revoked from restartix_app. Only the cloned system templates (admin, specialist, customer_support) are in use. The organizations.view_directory permission was introduced specifically so this can ship without a no-permission custom role over-granting visibility into staff metadata.
  • Domain re-verification cron — nothing polls Cloudflare on a schedule today.
  • Clinic-app custom domains — needs the Cloudflare Worker dispatcher that routes a custom hostname to the clinic app instead of the portal. Designed, not built; see custom-domains.md.
  • Per-domain auth — custom-domain auth currently routes through the primary hostname via token handoff. Per-domain provider configuration is a scaling gap, not a shipped feature.
  • Org-branded auth pages — pass org branding to the provider's sign-in/sign-up pages for white-label login screens.
  • Dedicated tenancy modeorganizations.tenancy_mode = 'dedicated' is a schema reservation. Its only structural axis is a per-tenant provider organization; own-S3 and own-CMK ship later as entitlements, once an exit/portability tool and a documented crypto-shred runbook exist. API paths refuse dedicated until then. See tenant-isolation.md.
  • Automations / lifecycle workflow engine — see Organization Lifecycle Workflows above.

Further Reading