Org-Level Settings Architecture
How per-org configuration is split across tables. Decided once, applied everywhere a new "setting" lands. Companion to data-model.md (entities) and tiers-and-subscriptions.md (tier-driven settings).
Why this exists. The
organizationstable started as identity (name, slug, branding, language_code, contact info). Layers 1–10 will accumulate many more knobs: portal self-signup gates, retention overrides, marketing preferences, billing pointers, regulated entitlement flags, integration credentials. Without a deliberate split, three failure modes appear: (a)organizationsbecomes a 60-column dumping ground with mixed sensitivity classes, (b) a JSONB blob hides shape from the database and from compliance queries, (c) every feature invents its own scattered settings table with no convention. This doc fixes the shape once.
SQL is illustrative
SQL fragments in this document are examples meant to convey shape and intent — they're not authoritative reproductions of the production schema. The real migrations live in services/api/migrations/core/.
Decision
Settings are split across four shipped typed tables today, with a fifth (organization_integrations) planned for Foundation 1C.5 — each owns one sensitivity class and one access pattern. No JSONB blob. No "everything on organizations."
| Table | Owns | Sensitivity class | Writers | Readers |
|---|---|---|---|---|
organizations | Identity (name, slug, branding, contact, language) | Public-ish | Admin (organizations.update) | Everyone in the org + public-resolve |
organization_settings | Operational + compliance knobs (marketing prefs, retention overrides, locales, portal self-signup gate, internal kill-switches) | Operational | Admin (organizations.update_settings) | Members with organizations.view_directory; specific fields read by middleware (e.g. portal onboarding gate) |
organization_billing | Billing pointers + contact (current tier FK, billing email, tax ID, external customer ID) | Regulated financial | Admin (organizations.manage_billing); subscription engine | Admin only |
organization_entitlements | Regulated entitlement gates (telerehab, treatment plans, video calls, pose) | Regulated clinical | Superadmin only (AdminPool); tier engine via projection | Every member — read on every clinical request |
organization_integrations | Encrypted third-party credentials for Connected Accounts (Cat B) clinic-owned services (Foundation 1C.5 — design pending) | Encrypted secrets | Admin | Admin (decrypt at repo) |
organization_integrations is a forward reference — no migration creates it today. It is foundation infrastructure (sibling to the other companion tables here) that was missed at the original 1B.2 design and is now slotted as part of Foundation 1C.5 (Connected Accounts — Cat B); the schema design is deferred to a dedicated discussion chat. Cat A platform-curated providers (SES, Daily.co, Twilio — credentials owned by the platform) are a separate concern handled by Foundation 1C.2 (Curated Providers) via the platform_service_providers resolution table; that table does NOT live in this doc's split because its credentials are platform-scoped (env / Secrets Manager seed, optional per-org override row for per-tenant brand isolation). See glossary.md → Integration categories for the canonical Cat A vs Cat B distinction. The first CONSUMER of organization_integrations (Cat B) will be the first OAuth-based clinic integration; see also data-model.md § organization_integrations. The other four tables ship with this design at Layer 1.
Why this split (and not the alternatives)
Three patterns were evaluated. All three are documented so the rejection is explicit.
(a) Typed columns directly on organizations — rejected
Works for ~10 columns; we already have ~13. Beyond that, three problems:
- Mixes sensitivity classes. Public-resolvable identity (
name,slug,logo_url) sits next to regulated billing (tax_id,billing_email) and clinical entitlement flags. RLS becomes a mess of column-level conditions, and grant scopes can't be cleanly per-table. - Mixes write cadence. Every settings change updates
organizations.updated_at, which is otherwise a useful "did identity change" signal for caches, public resolves, and audit. - Conflates writers. Org-entitlements must be superadmin-only (regulatory trust boundary). Settings are admin-writable. Identity is admin-writable but with stricter audit. One table can't enforce three different writer scopes cleanly.
(b) Single JSONB blob (organizations.settings JSONB) — rejected
JSONB is appropriate for snapshots and for shapes the database doesn't need to query. It is not appropriate for operational settings, because:
- Not queryable for compliance. "Show me every clinic with
marketing_emails_enabled = true" is a one-line SQL query against typed columns; against JSONB it requires GIN indexes,jsonb_path_opsoperators, and still misses values where the key isn't present versus isfalse. Romanian DPA / ANSPDCP audits will ask exactly this kind of question. - No schema enforcement. Boolean drift, typo'd keys, mixed types — none of these are caught by the database. Bugs surface in production.
- Migration story is bad. Adding a default for a new key requires backfill code (or every reader handles "missing" as "default"). Renames are migrations against every row.
- Audit/diff is hard. Field-level audit requires diffing JSON on every write.
The CLAUDE.md constraint — "Org settings shape must be queryable for compliance ('show me every clinic with marketing_emails_enabled = true' — JSONB-only is too lossy)" — disqualifies (b) directly.
JSONB is still the right tool inside a setting (e.g. organization_settings.feature_flags JSONB for ad-hoc internal staged-rollout flags — see Internal kill-switches below). The rejection is JSONB as the table shape, not JSONB anywhere.
(c) Split tables by concern — adopted
What sensitivity classes already exist in the platform:
- Public-resolvable identity — read by anonymous resolves (slug → org for domain routing, branding for login pages). RLS allows public reads of selected columns.
- Operational settings — read by middleware on authenticated requests. Org-member readable.
- Regulated financial data — billing addresses, tax IDs, external customer references. Admin-only, audited on read.
- Regulated clinical gates — entitlement flags read by clinical code. Superadmin-write-only (the trust boundary into the regulated read surface; see middleware-composition.md). Member-read.
- Encrypted secrets — third-party API keys for Connected Accounts (Cat B) clinic-owned services (Google Calendar, Slack, HubSpot, ...). Will land in
organization_integrations(Foundation 1C.5 — design pending) with field-level encryption (P12). Listed here so the five-class split is complete; this doc does not design the table. Cat A platform-curated provider credentials (SES, Daily.co, Twilio) live in env / Secrets Manager + the foundationplatform_service_providersresolution table (Foundation 1C.2) and are out of scope for this five-class split because they are platform-scoped, not per-org.
Five classes, four tables shipped today plus organization_integrations planned for Foundation 1C.5. Same number of joins as JSONB unpacking, but with type safety, queryability, and clean RLS.
Tables
organizations
Identity-class home. Public-resolvable: every column on this table is readable through GET /v1/public/organizations/resolve (allowlisted in the handler). The split rule:
- "Is this a public fact about what the clinic IS or OFFERS?" →
organizations(this table). - "Is this a knob the staff configures internally?" →
organization_settings.
| Column | Type | Notes |
|---|---|---|
id, name, slug | identity | Public, immutable post-create except name. |
tagline, description, email, phone, website, location | identity (contact) | Clinic's published contact — what they put on their landing page. Distinct from organization_billing.billing_email (admin-only). |
logo_url, icon_url | identity (visual) | Light-mode logo + favicon. Dark-mode logo + the rest of branding live in the branding JSONB below — logo_url and icon_url are kept as typed columns because they predate the JSONB and are already wired through resolve / classification / S3 paths. |
language_code | identity | Determines the public-resolve translations payload, not a setting. |
portal_self_signup_enabled | identity (signup posture) | Per-clinic gate for portal self-signup. FALSE (default) ⇒ /v1/portal/onboard refuses signup at this org; patients arrive only via staff-managed onboarding or invite tokens. TRUE ⇒ any authenticated human can self-onboard at the clinic's hostname. Lives here, not on organization_settings, because the toggle is read pre-membership by the portal /onboard page (and pre-RequireOrganizationScope by /v1/me) — same audience as name / language_code. Default fail-closed: open self-signup at every clinic is a spam/enumeration vector at platform scale, and flipping the flag is itself the audit signal "we accepted this risk." |
branding | identity (visual) — JSONB | White-label payload. Open-shape JSONB (typed Branding struct in Go); fields include primary_color, theme_mode, logo_dark_url, email_from_name, footer_text, ... New visual properties extend the shape without migrations. Validation (hex regex, theme_mode enum) lives at the service layer. Same precedent as organization_settings.feature_flags — JSONB inside a single semantic unit, not JSONB as the table shape. |
created_at, updated_at | timestamps |
RLS.
- SELECT: members of the org via
id = current_app_org_id(). Public-resolve runs on AdminPool with single-row equality match on slug or verified domain — RLS is bypassed there by design. - UPDATE: callers with
organizations.update. The same permission gates branding edits + signup-posture flips because both are clinic-admin concerns in the same trust profile as renaming the org. If a future role model needs to split branding-edit from identity-edit, add a dedicated permission then — premature now. - INSERT/DELETE: superadmin only (the AppPool policies are
WITH CHECK (FALSE)/USING (FALSE)).
Audit. Every UPDATE flows through the standard mutation middleware (P10).
organization_settings
One row per organization (PK = organization_id). Reads are typed; new settings are column adds.
| Column | Type | Notes |
|---|---|---|
organization_id | UUID PK FK → organizations(id) ON DELETE CASCADE | 1:1 |
marketing_email_enabled | BOOLEAN NOT NULL DEFAULT FALSE | Org-level kill-switch on outbound marketing email. Layered on top of per-patient consent (P17) — sender checks both. |
marketing_sms_enabled | BOOLEAN NOT NULL DEFAULT FALSE | Same shape, SMS channel. |
audit_retention_months | INT NULL | Override of the platform default (≥ 6 yr per CLAUDE.md). NULL ⇒ platform default. CHECK ≥ 72. |
support_locale | TEXT NULL | Locale for support-channel emails when different from organizations.language_code. ISO 639-1, NULL ⇒ inherit. |
feature_flags | JSONB NOT NULL DEFAULT '{}' | Internal staged-rollout flags (kill-switches) — see Internal kill-switches below. Not tier-driven entitlements. |
created_at, updated_at | TIMESTAMPTZ |
Patient-public toggles like
portal_self_signup_enabledlive onorganizations, not here. This table is staff-only by RLS (gated onorganizations.view_directory); anything readable pre-membership belongs on the public-resolvable identity table.
RLS.
- SELECT: members of the org with
organizations.view_directory. Gated on the permission rather than bare membership because (a) post-1.13 a clinic admin can mint a custom no-permission role that must not see operational settings, and (b) patients havecurrent_app_org_id()set under post-1.26 portal sessions and must not read staged-rollout flags / kill-switches. System staff role templates ship withview_directory; patients and custom no-permission roles do not. - UPDATE: callers with
organizations.update_settings(new permission, seeded with this migration; granted toadmintemplate). - INSERT: trigger on
organizationsrow creation auto-creates the correspondingorganization_settingsrow with defaults. No manual INSERT path. - DELETE: not permitted (cascade only via
organizationsdeletion, which is superadmin-only).
Audit. Every UPDATE flows through the standard audit middleware (P10). Field-level diffs in audit_log.changes.
organization_billing
One row per organization (PK = organization_id).
| Column | Type | Notes |
|---|---|---|
organization_id | UUID PK FK | 1:1 |
current_tier_id | UUID FK → tiers(id) NULL | Denormalized pointer to the org's current base tier for fast lookup in admin UI / billing reports. Canonical source of truth is organization_subscriptions (an org with a base + add-ons has multiple subscription rows; this column points only at the base). NULL ⇒ no tier (e.g. internal demo orgs). |
billing_email | TEXT NULL | Where invoices and dunning go. Distinct from organizations.email (which is org-public contact). |
billing_contact_name | TEXT NULL | |
billing_address_line1 | TEXT NULL | Structured fields, not freeform — required for tax invoicing in RO. |
billing_address_line2 | TEXT NULL | |
billing_city, billing_postal_code, billing_country | TEXT NULL | billing_country ISO 3166-1 alpha-2. |
tax_id_encrypted | BYTEA NULL | CUI for RO clinics. Encrypted (P12) — tax IDs are PII in EU jurisdictions. |
currency | TEXT NOT NULL DEFAULT 'RON' | Billing currency for this org's invoices. |
external_customer_id | TEXT NULL | Stripe / Chargebee customer ID. NULL until billing system wires up. |
payment_provider | TEXT NOT NULL DEFAULT 'manual' | `manual |
created_at, updated_at | TIMESTAMPTZ |
RLS.
- SELECT: callers with
organizations.manage_billing(new permission, seeded; granted toadmintemplate only). - UPDATE: same.
- INSERT: trigger-driven on
organizationscreation, like settings. - DELETE: not permitted.
Audit. Writes log to audit_log today via the standard mutation middleware (P10). Reads are not audited yet; the read-audit hook will be added when the audit-log read API ships (see Deferred). The intent is regulated-data treatment — read access is itself a sensitive event — and reads will gate on the existing audit_log.view_org permission once the read-audit infrastructure lands.
Encryption review. tax_id_encrypted uses P12. billing_email, addresses are not encrypted (they are PII but not high-sensitivity in this context — equivalent to humans.email).
organization_entitlements
The regulated read surface. One row per organization (PK = organization_id). Defaults to all entitlements OFF.
| Column | Type | Notes |
|---|---|---|
organization_id | UUID PK FK | 1:1 |
telerehab_enabled | BOOLEAN NOT NULL DEFAULT FALSE | Unlocks treatment plans, exercise prescription, telerehab patient flows. |
treatment_plans_enabled | BOOLEAN NOT NULL DEFAULT FALSE | Subset of telerehab — separable so a clinic can have plans without exercise videos. |
video_consultations_enabled | BOOLEAN NOT NULL DEFAULT FALSE | Daily.co / WebRTC integration unlock. |
pose_estimation_enabled | BOOLEAN NOT NULL DEFAULT FALSE | Camera-based measurement (likely Class IIa per CLAUDE.md). |
created_at, updated_at | TIMESTAMPTZ |
New entitlements are column adds — typed, queryable, defaults to FALSE for fail-closed regulatory posture.
RLS.
- SELECT: every member of the org. Read on every clinical request via
current_app_has_org_entitlement(entitlement_code)SQL function. - UPDATE: no policy — AppPool has zero write access. Only superadmin via AdminPool can change entitlements. This is the single trust boundary into the regulated read surface; see middleware-composition.md.
- INSERT: trigger-driven on
organizationscreation. - DELETE: not permitted.
Audit. Every UPDATE is logged with action_context = 'org_entitlement_change' for distinct retention/alerting. Entitlement changes are operationally significant — they cross the regulated boundary.
Tier engine projection. When organization_subscription_entitlements for an org changes (tier switch, add-on activation, override), the subscription engine projects regulated entitlements onto this table via a domain service. The projection is a one-way write — the tier engine never reads back from organization_entitlements. See tiers-and-subscriptions.md for the projection function shape.
organization_integrations (Foundation 1C.5 — design pending)
Not yet shipped — no migration creates this table. Conceptual framing lives in foundation.md § 1C.5 (Connected Accounts — Cat B); the schema design is deferred to a dedicated discussion chat. Foundation infrastructure (sibling companion table to the other four above), and scoped to Cat B Connected Accounts only (clinic-owned credentials for services the clinic configures, e.g. Google Calendar, Slack, HubSpot — see glossary.md → Connected Account). Cat A platform-curated providers (SES, Daily.co, Twilio) do NOT use this table — they resolve through platform_service_providers (Foundation 1C.2). The 1C.5 sub-phase additionally introduces the platform-defined integration_services catalog (the list of available Cat B services). Per-org third-party API credentials, AES-256-GCM encrypted at field level (P12). Admin-only read/write. Listed in this doc only so the five-class split is complete and so future readers know which table the "encrypted secrets" class will inhabit — the actual schema decisions land when 1C.5 implements.
Where each "setting" goes
Concrete mapping for the settings called out in CLAUDE.md / Layer 1 conversations:
| Setting | Lives in | Why |
|---|---|---|
| Portal self-signup gate | organizations.portal_self_signup_enabled | Identity-class. Read pre-membership by the portal /onboard page (and pre-RequireOrganizationScope by /v1/me); surfaced through the public-resolve endpoint. Lives on organizations, not organization_settings — same audience as name / language_code. Default FALSE (fail-closed). |
| White-label branding (color, theme, dark logo, email from name, footer) | organizations.branding (JSONB) | Identity-class, public via resolve. Open-shape JSONB so new visual properties extend without migrations. Typed Branding struct in Go is the canonical contract. |
| Marketing email opt-in (org-level) | organization_settings.marketing_email_enabled | Operational. Layered on top of patient consent (P17). |
| Marketing SMS opt-in (org-level) | organization_settings.marketing_sms_enabled | Same. |
| Audit retention override | organization_settings.audit_retention_months | Operational compliance knob. |
| Support locale | organization_settings.support_locale | Operational. |
| Internal kill-switches (staged rollout) | organization_settings.feature_flags JSONB | Internal-only, ad-hoc, no compliance query needed. JSONB is fine here precisely because nobody outside the engineering team queries it. |
| Current tier pointer | organization_billing.current_tier_id | Billing data. Denormalized; canonical in organization_subscriptions. |
| Billing email / address / tax ID | organization_billing.* | Regulated financial. |
| External billing customer ID (Stripe) | organization_billing.external_customer_id | Regulated financial, forward-compat. |
| Telerehab / video / pose unlock | organization_entitlements.* | Regulated. Read by clinical code. Tier engine projects, superadmin can override. |
| Daily.co / SES / Twilio API keys (Cat A — platform-curated) | platform_service_providers.* (NOT organization_integrations) | Platform-curated provider credentials. Live in env / Secrets Manager seed; the foundation platform_service_providers resolution table (Foundation 1C.2) holds optional per-org override rows for per-tenant brand isolation (available on either tenancy mode). Out of scope for this doc's five-class split (platform-scoped, not per-org-by-default). |
| Google Calendar / Slack / HubSpot OAuth tokens (Cat B — clinic-connected) | organization_integrations.* | Encrypted secrets, clinic-owned. Lands at Foundation 1C.5 — design pending in a dedicated discussion chat (forward reference; not in any migration today). |
Internal kill-switches vs tier entitlements
These are different concepts and live in different tables:
- Tier-driven entitlements (e.g.
automations,custom_domain,webhooks,bulk_export) — sold to the customer, gated byRequireTierEntitlement(code)middleware, snapshot ontoorganization_subscription_entitlementsat subscribe time, may project toorganization_entitlementsif regulated. Lives in tiers-and-subscriptions.md. Code-readable viauserCtx.HasTierEntitlement(code). - Internal kill-switches (e.g.
enable_new_scheduling_engine,force_old_audit_path) — engineering tools for staged rollout or incident response. Per-org override of platform defaults. Not customer-visible. Stored inorganization_settings.feature_flagsJSONB, read via a typed helper. Not gated through middleware — checked inside specific code paths.
A tier-driven "entitlement" is sold to the customer. A "feature flag" used by engineering is a kill-switch. Don't conflate them, don't put them in the same table.
RLS helper functions (shipped at Layer 1)
One new SQL function parallels the existing current_app_has_permission:
-- Returns TRUE if the current org has the named entitlement flag enabled.
-- Reads organization_entitlements for the current org. Unknown codes
-- RAISE EXCEPTION (fail-loud — surfaces typos like 'video_consult_enabled'
-- immediately instead of silently masking them as "entitlement disabled").
CREATE OR REPLACE FUNCTION current_app_has_org_entitlement(entitlement_code TEXT)
RETURNS BOOLEAN
LANGUAGE plpgsql STABLE SECURITY DEFINER
AS $$
-- Stub — actual shape is plpgsql with RAISE EXCEPTION on unknown codes
-- and a CASE expression mapping entitlement_code → typed column. See
-- services/api/migrations/core/000003_org_settings.up.sql.
$$;The stub above conveys shape; the authoritative implementation lives in 000003_org_settings.up.sql. Clinical RLS policies and clinical Go code both call this function — never reading subscriptions/tiers directly.
A symmetric helper for settings is unnecessary; settings are read via SELECT on organization_settings from middleware/handlers, not from RLS policies (the policy gates on the organizations.view_directory permission — see organization_settings RLS above for why this is permission-gated rather than bare member-of-org).
Layer 1 reservation
What this design lands at Layer 1, and what is deferred:
Lands at Layer 1 (foundation):
organization_settingstable with the columns listed above.organization_billingtable with the columns listed above (incl.payment_providerandexternal_customer_idforward-compat columns).organization_entitlementstable with the four entitlement flags listed above.- Trigger on
organizationsINSERT (create_organization_companion_rows) that atomically provisions the full tenant skeleton: the three companion rows (organization_settings,organization_billing,organization_entitlements) with defaults; clones every system role template into the new org and clones theirrole_permissionsgrants; creates the Free base subscription with frozenorganization_subscription_entitlements/organization_subscription_limitssnapshots; and creates a defaultpatient_tierwith its initialpatient_tier_versionsrow. Any failure rolls the whole org INSERT back — half-provisioned tenants are impossible. Implementation in000003_org_settings.up.sql. - RLS policies on all three tables per the rules above.
- New permissions seeded:
organizations.update_settings,organizations.manage_billing. Granted toadminsystem template. - New SQL helper:
current_app_has_org_entitlement(entitlement_code TEXT). - Audit-log integration on all three tables.
Deferred (later layers):
- Console UI for editing settings/billing/entitlements — lands with the Console UI work in Layer 1 (1.13).
- Tier engine projection of
organization_subscription_entitlements→organization_entitlements— lands with tiers-and-subscriptions.md implementation. - Read-audit infrastructure for
organization_billing— lands when the audit-log read API ships.
Dropped (do not reintroduce):
organization_settings.default_signup_role_id— earlier drafts of this doc proposed a per-org default role that auth middleware would assign on portal sign-up. Removed at 1.26 when the actor model split landed: patients are not memberships, are not in the role machinery, and portal access is implicit from the existence of apatientsrow. The replacement is the role-freepatient_tiers(see data-model.md) plusportal_self_signup_enabledas the binary gate. Don't propose reintroducing this column — it conflates two concepts (staff roles vs patient tiers) that the actor model deliberately separates.
Relocated (1A.18):
portal_self_signup_enabledoriginally lived onorganization_settings. Moved toorganizationsbecause the column is identity-class (a public fact about what the clinic accepts, read pre-membership by the portal). Two raw-SQL admin-pool reads (inportalonboarding.Serviceandhuman.Repository) bypassed RLS to read it; both collapsed to normal admin-pool sibling reads onorganizationsafter the move. Don't propose moving it back — the audience (anonymous portal visitors deciding whether to render the signup form) is the canonical signal that this is identity-class data, not staff-operational state.organizations.branding(JSONB) landed in the same migration. Open-shape because branding fields appear at design cadence — primary_color today, accent + font + hero_image + social_links over time — and a typed-column-per-property growth pattern bloats the schema for no compliance-query benefit (nobody queries "show every clinic with primary_color = X"). Validation is the service layer's job; the column itself stores the typedBrandingGo struct shape viajson.RawMessage.
Open questions
| Question | Decided in |
|---|---|
Does organization_settings need a version / publication model like form templates? Most settings are immediate-effect; retention overrides may want a "scheduled effective date." | When retention override UX is designed |
organization_billing.tax_id_encrypted — encrypt at field level, or rely on row-level access control? CUI is technically PII but is also publicly registered. | Before billing wires up |
Should organization_entitlements include a notes / granted_by audit hint per-flag, or rely on audit_log for "who turned this on?" | When the first entitlement ships through the tier engine |
feature_flags JSONB — schema-validated at write time (CHECK constraint with allowed keys) or trust the engineering layer? | When the first kill-switch lands |