> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR-027: Picklist Naming Convention (Dot-Namespace) and Three-Tier Value-Set Classification

> Dot-namespace picklist naming and three-tier value-set classification (workflow, org vocabulary, constraint-mirrored) for PF-15 picklists.

**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](https://github.com/Encore-OS/encoreos/blob/development/specs/pf/specs/PF-15-picklist-system.md), [PICKLIST\_REGISTRY.json](https://github.com/Encore-OS/encoreos/blob/development/specs/cross-cutting/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}.{domain}.{field}     e.g.  cl.notes.type, pm.payments.method, lo.meetings.type
```

* `{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

| Tier                                    | What it is                                                                       | Examples                                                                              | Rules                                                                                                                                                                                                                                                                     |
| --------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A — Workflow state**                  | Values gate code paths (state machines, wizard routing, exhaustive TS narrowing) | `rh.episode.type` routing in `NewEpisodePage`, claim statuses                         | Source of truth is the TS union / enum in code. May be mirrored as a **locked** picklist for label governance, but selects that drive code paths do **not** render org-added values. Prefer `CREATE TYPE ... AS ENUM` or TS unions per `.claude/rules/database.md`.       |
| **B — Org vocabulary**                  | Pure tenant preference; no constraint, no code branching                         | `pm.referrals.source_type`, `lo.meetings.type`, `hr.org.department`                   | Fully editable: add, remove, relabel, reorder, deactivate. Consumed via `usePicklistByEnum`/`usePicklistItems` with literal keys.                                                                                                                                         |
| **C — Constraint-mirrored / regulated** | Value set mirrors a DB CHECK constraint or a regulated code set                  | `cl.consent.type` (42 CFR Part 2 categories), `pm.payments.method`, `rh.episode.type` | Seeded with `metadata.value_set_locked: true`. UI blocks add/delete/**deactivate**; labels, colors, display order, and `metadata.aliases` remain editable. Changing the value set requires a migration that updates the CHECK constraint **and** the seed in the same PR. |

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.

## Related Documents

* Deep review: [`specs/cross-cutting/PICKLIST-DEEP-REVIEW-2026-06-12.md`](https://github.com/Encore-OS/encoreos/blob/development/specs/cross-cutting/PICKLIST-DEEP-REVIEW-2026-06-12.md) (§4 tier framework, §8 roadmap)
* Seed migration: `supabase/migrations/20260612210000_pf15_wave1_seed_core_picklist_defaults.sql`
* Registry: [`specs/cross-cutting/PICKLIST_REGISTRY.json`](https://github.com/Encore-OS/encoreos/blob/development/specs/cross-cutting/PICKLIST_REGISTRY.json)
* Enums-vs-picklists guidance: `.claude/rules/database.md`
* Related ADRs: [ADR-012](/architecture/decisions/ADR-012-pf96-jurisdiction-profiles) (regulated value sets that vary by jurisdiction belong in PF-96 profiles, not picklists)
* ADR index: [`docs/architecture/decisions/index.md`](/architecture/decisions/index)

***

**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).
