Skip to content

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 tenants and FK target across 30+ tenant-scoped tables (patients, cases, events, audit_logs, tenant_org_mappings, …)
  • The X-Tenant-ID HTTP 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 slug next to id. 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-002 no 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_id continues to be either shape; the FK target column is String(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