Skip to main content
Status: Accepted (2026-06-14) Date: 2026-06-12 Participants: Platform Architecture, PF-15 owner Supersedes:Related: ADR-012 (PF-96 jurisdiction profiles), PF-15 spec, PICKLIST_REGISTRY.json

Context

The 2026-06-12 picklist deep review (specs/cross-cutting/PICKLIST-DEEP-REVIEW-2026-06-12.md) found three structural problems in the PF-15 picklist system:
  1. No naming convention. Seeded picklist names mix flat snake_case (hr_employment_status, work_order_priority, resident_status) with the dot-namespace style introduced by GR-33 (gr.compliance.requirement_type). New seeds had no rule to follow, and name collisions across cores were only avoided by luck.
  2. No classification of what a picklist is. Some vocabularies are pure org preference (referral sources), some mirror database CHECK constraints (consent types, payment methods), and some are workflow states with hardcoded code paths (episode types that route to specific wizards). Treating all three as equally editable lets a tenant admin break claim submission or wizard routing by deleting a value the code requires.
  3. No single registration point. specs/cross-cutting/PICKLIST_REGISTRY.json existed but had drifted from the seeds; the CI consumption check (scripts/registry/check-picklist-consumption.ts) only enforces the consumption→registry direction.
The Wave 1 seed migration (20260612210000_pf15_wave1_seed_core_picklist_defaults.sql) added 23 picklists and needed both rules settled. This ADR records the rules it followed so future seeds, reviews, and audits can enforce them mechanically.

Options Considered

Option A: Keep flat snake_case names; no classification

  • How it works: Continue {core}_{field} naming; all picklists equally editable.
  • Pros: No change; matches the oldest seeds.
  • Cons: Collision-prone (hr_status of what?); no signal separating “safe to edit” from “mirrors a DB constraint”; tenant edits can silently break CHECK-constrained inserts and enum-narrowed code paths.
  • Why not chosen: The deep review traced two classes of latent production bugs (deleted CHECK-constrained values, org-added workflow states with no code path) directly to this gap.

Option B: Postgres ENUMs for everything constraint-backed; picklists only for free vocabulary

  • How it works: Convert every CHECK-constrained column to a CREATE TYPE ... AS ENUM; reserve picklists for unconstrained sets.
  • Pros: DB-enforced integrity; impossible to drift.
  • Cons: ENUM values need a migration to add, can’t be soft-deleted, don’t surface in the picklist settings UI, and lose per-org label/color/order customization entirely — orgs legitimately want to relabel “EFT” as “ACH” without a schema change. Contradicts the existing .claude/rules/database.md enums-vs-picklists guidance.
  • Why not chosen: Throws away the org-customization value of PF-15 to solve a governance problem that metadata can solve.

Option C: Dot-namespace naming + three-tier classification with metadata.value_set_locked

  • How it works: Documented below — naming is {core}.{domain}.{field}; every picklist is classified Tier A/B/C; Tier C sets carry metadata.value_set_locked: true and the settings UI blocks add/delete/deactivate (labels, colors, order, aliases stay editable).
  • Pros: Collision-free names that sort by core; the lock travels with the data (seed → per-org copy via the metadata merge in pf_backfill_picklist_defaults_for_org); UI enforcement needs no per-picklist code; Tier A states stay code-owned without becoming editable rows.
  • Cons: Lock enforcement is UI-level, not DB-level (a direct SQL write could still desync a locked list — acceptable because the CHECK constraint itself still rejects bad values at insert time); legacy flat names persist until renamed.
  • Why chosen: Preserves org customization where it’s safe, blocks it where it isn’t, and is enforceable by review + registry audit today.

Decision

1. Naming: dot-namespace {core}.{domain}.{field}

All new picklist names (in pf_picklist_default_definitions.name and the per-org pf_picklists.name rows seeded from them) use lowercase dot-namespace form:
  • {core} is the owning core abbreviation (cl, pm, hr, fa, rh, gr, fw, fm, lo, it, ce) or pf/platform for shared sets.
  • Segments are [a-z0-9_]+; the UI form validates /^[a-z0-9_]+(\.[a-z0-9_]+)*$/ (PicklistForm zod schema).
  • Legacy flat names (hr_employment_status, work_order_*, resident_status, ce_contact_type, …) are grandfathered — do not rename them; renames would break literal-key consumption sites and saved org data. New seeds must not add flat names.
  • Every seeded name must have an entry in specs/cross-cutting/PICKLIST_REGISTRY.json (name, category, seed_file, spec_path) in the same PR as the seed migration.

2. Classification: every picklist is Tier A, B, or C

A picklist can be both A and C (e.g. rh.episode.type): the lock governs the settings UI; the code-ownership rule governs which UI surfaces consume it.

3. Seeding and lock propagation

  • System defaults go in pf_picklist_default_definitions / pf_picklist_default_items (never per-org backfill loops); the org-create trigger and pf_backfill_picklist_defaults_for_all_orgs() propagate them.
  • pf_backfill_picklist_defaults_for_org merges definition metadata into per-org rows (metadata = COALESCE(existing,'{}') || EXCLUDED.metadata), so value_set_locked reaches every tenant copy, including pre-existing orgs.
  • Dual-read consumption (usePicklistByEnum(name, fallbackLabels)) is the sanctioned migration path from hardcoded option lists: stored values stay identical; only the option source changes.

Consequences

Positive

  • New seeds have one obvious name shape and one obvious classification step; reviewers can reject violations mechanically.
  • Tenant admins can relabel/reorder/alias every vocabulary — including regulated ones — without being able to break CHECK-constrained inserts or workflow routing.
  • metadata.aliases on items gives the PF-88 import value-mapper a per-org synonym table with no extra schema.
  • Registry, seeds, and constraints are auditable as a set (npm run registry:audit, check-picklist-consumption.ts).

Negative

  • Two name styles coexist indefinitely (grandfathered flat names vs. new dot-namespace) — cosmetic, but permanent unless a coordinated data+code rename is ever funded.
  • The lock is advisory below the UI layer; direct SQL can still mutate a locked list (the underlying CHECK constraint remains the hard backstop).
  • Tier assignment is judgment at seed time; a misclassified Tier B set that later gains a CHECK constraint needs a follow-up migration to add the lock.

Constitution Reference: Supports §5 (single canonical entity — one vocabulary store, no parallel per-core lookup tables) and §10 (jurisdiction profiles — state-specific value sets route through PF-96, not picklist forks).