ADR-0023 — Tenant ID convention: UUID PK + slug secondary¶
- Date: 2026-04-30 (Proposed). 2026-05-02 (Accepted).
- Status: Accepted
- Author: Curaway engineering
- Status flip rationale: ADR was Proposed pending the first new tenant provisioned under the convention. Istanbul Medical Center already lives in production with a UUIDv4 PK + slug, satisfying that trigger. The next provider/tenant to be provisioned will follow the same shape; no further evidence required. Closes #513.
- Context: Inconsistent tenant ID shapes surfaced during #267 Phase 1 (patient tenant separation). Current production has 4 slug-PK tenants (
tenant-apollo-001,tenant-curaway-admin,tenant-curaway-ops,tenant-mso-panel) and 1 UUID-PK outlier (e16b83eb-…Istanbul Medical Center). No written policy exists.
Context¶
Curaway's tenants.id column has historically held human-readable slug-style identifiers (tenant-apollo-001, tenant-curaway-ops, etc.). The same value is used as:
- The PK in
tenantsand FK target across 30+ tenant-scoped tables (patients,cases,events,audit_logs,tenant_org_mappings, …) - The
X-Tenant-IDHTTP header on every request - The Clerk JWT claim resolver target (via
tenant_org_mappings) - A literal value baked into env vars (
VITE_TENANT_ID,default_tenant_id), config files, test fixtures, runbooks, and seed migrations
The slug-PK pattern is operator-friendly (psql + log readability) but carries known risks of name churn, information leakage, enumeration, and slug-collision under multi-region/M&A. The Istanbul outlier shows the team has experimented with UUID-PK before but the convention was never formalized.
The decision below sets the going-forward policy and the migration policy for existing tenants.
Decision¶
Going forward, every new tenant uses a UUID PK + a unique human-readable slug column.
| Column | Convention |
|---|---|
tenants.id (PK) |
UUIDv4 string (e.g. b1d8e2f4-3c91-4f02-9c24-7c2f50ab3e10). Generated server-side via gen_random_uuid()::text (Postgres) or str(uuid.uuid4()) (Python). Immutable. |
tenants.slug |
Unique, human-readable, kebab-case (e.g. apollo-hospitals, curaway-patients). Mutable when business name changes. Indexed. |
tenants.name |
Display name (already present). |
X-Tenant-ID header / JWT claim |
Carries the UUID PK, not the slug. Operator tooling (admin portal, logs viewer) resolves UUID → slug for display. |
Existing slug-PK tenants are migrated lazily, not via active backfill.
| Population | Migration trigger |
|---|---|
tenant-apollo-001 |
Convert during #267 Phase 2b (the patient cutover was split into 2a/2b in spec patient-tenant-cutover-phase2-feature.md to bound rollback scope; 2b handles the apollo rename + UUID-PK conversion together) |
tenant-curaway-patients |
Convert during the same #267 Phase 2b window (FK rewrite on the post-2a populated tenant). |
tenant-curaway-admin, tenant-curaway-ops, tenant-mso-panel |
Stay slug-PK indefinitely. Convert only if a future rename, schema refactor, or audit opens the window. No forced migration. |
Istanbul (e16b83eb-…) |
Already UUID-PK ✅ |
| Future provider onboarding | UUID-PK from row creation per the new policy. Operator-facing portal admin UI must show slug, not UUID. |
The lazy-migration choice is deliberate: the curaway-internal tenants serve operator-facing portals where slug-in-logs ergonomics matter most, and there's no forcing function to migrate them today. We accept the inconsistency as a permanent state for those four tenants.
Migration discipline (operational rules)¶
- New Alembic migrations creating tenant rows must use UUID PKs by default. Slug PKs are an exception requiring a docstring justification (e.g., "matches existing curaway-internal convention; slated for UUID conversion in #267 Phase 2").
- Test fixtures for new tests should use UUID literals + descriptive slug. Existing slug-PK fixtures stay until the file they live in is otherwise modified.
- Hardcoded tenant IDs in production paths (
config.py default_tenant_id,_ORG_TENANT_MAP_FALLBACK) must use UUID values once the underlying tenant has converted. Until then, they continue to point at the slug PK to avoid premature breakage. - Operator tooling (admin portal, audit log viewer, Metabase dashboards) must always render
slugnext toid. UUID-only displays are user-hostile and the lazy-migration policy depends on operators staying productive.
Consequences¶
Pro
- Industry-aligned (Stripe, Clerk, GitHub, Vercel all use opaque-PK + slug)
- Tenant renames become safe (slug change ≠ identity change, no FK cascade)
- No information leakage in URLs / partner APIs / data exports
- No enumeration attacks (
tenant-apollo-002no longer guessable) - GDPR audit-friendly: tenant IDs in exports don't reveal business relationships
Con
- Operator UX cost: UUID-in-logs is unreadable without an admin-portal lookup. Mitigation: every operator surface must show slug.
- Migration cost: existing slug-PK FK references must be re-keyed during conversion (Phase 2 of #267 swallows this for apollo + patients; remaining 3 curaway-internal tenants accept permanent inconsistency)
- Cognitive load: future devs see two id shapes in prod and may not understand why. Mitigation: this ADR is the answer; link from the migration docstring whenever a conversion happens.
Neutral
tenant_org_mappings.tenant_idcontinues to be either shape; the FK target column isString(36)which fits both UUIDs and slugs without schema change.
Status history¶
- 2026-04-30 — Proposed. Initial draft alongside #267 Phase 1; flip-to-Accepted gated on the first new tenant provisioned under the convention.
- 2026-05-02 — Accepted (#513). Trigger satisfied: Istanbul Medical Center provisioned in production with UUIDv4 PK + unique slug. Going forward, every new provider/tenant ships under this shape. Existing slug-PK tenants migrate lazily (apollo + curaway-patients in Phase 2b; the three Curaway-internal tenants stay on slug-PK indefinitely unless touched).
Open follow-ups¶
- File issue: implement admin-portal Tenants page rendering of UUID + slug pair (operator UX prerequisite for UUID-PK).
- File issue: tooling for "convert slug-PK tenant to UUID-PK" — generic helper for the lazy-migration trigger. Don't write until the second slug → UUID conversion is needed (apollo conversion in Phase 2 will be hand-coded; helper extracted from that experience).
References¶
- ADR-0018 — Multi-tenancy platform architecture
- ADR-0021 — Clerk org model
-
267 — Patient tenant separation (Phase 2b will perform first slug→UUID conversion)¶
-
511 — Phase 2 spec; split into 2a (data move, current spec) and 2b (rename + UUID-PK)¶
docs/specs/patient-tenant-cutover-phase2-feature.md— Phase 2a operator spec