# ADR-130 Opaque Immutable IDs + Renameable Display Names Are Mandatory for Every Entity

## Status

Proposed, 2026-08-01.

## Status History

```yaml
status_history:
  - date: 2026-08-01
    status: Proposed
    changed_by: hkl (via DL laptop Claude Code session, Opus 4.8, chunk I0 of the ID/Name Separation Policy plan)
    reason: |
      hkl's principle, stated verbatim (2026-08-01): every Job and Tour has (a) a UNIQUE,
      IMMUTABLE, opaque alphanumeric ID, and (b) a human-readable NAME used for display only,
      renameable at any time without ever touching the ID. The same pattern applies to any future
      entity. The platform is already ~90% compliant (sales.jobs and sales.job_tours both carry a
      separate ID and name), but nothing codified the rule, so a generator once went semantic
      (the LENOVO-era JOB-2025-LENOVO-... format) and produced immutable IDs that now embed stale
      business facts. This ADR freezes the principle as a house rule so no future generator can
      repeat that mistake, and records the pre-defaulted decisions (grandfather the legacy IDs,
      keep the current opaque generators, keep job_name UNIQUE, adopt a Name / "Name (ID)" display
      convention, make tour title required). This ADR is policy only; the mechanical enforcement
      (title NOT NULL, raw-ID display audit) lands in sibling chunks I1/I2.
    changed_via: adr-kit (360lm), authored directly per hkl's explicit instruction + the ID/Name
      Separation Policy plan (2026-08-01, DL session).
```

## Context

**The principle (hkl, verbatim, 2026-08-01).** Every Job and Tour has (a) a UNIQUE, IMMUTABLE,
opaque alphanumeric ID, and (b) a human-readable NAME used for display only, renameable at any time
without ever touching the ID. The same pattern holds for any future entity.

The reasoning is the classic surrogate-vs-natural-key separation: an identifier that other rows
reference must never change, and anything a human wants to rename must never be an identifier. When
those two roles are fused into one field, you get exactly one of two failures — either the ID lies
(it embeds facts that later go stale but cannot be corrected without cascading a rename through
every foreign key), or renaming is blocked (because the "name" is load-bearing as a key).

**What the platform already does right.**

- `sales.jobs` — PK `job_id` (`text`, opaque) **plus a separate `job_name` (`NOT NULL`)** and
  further display fields (`brand`, `campaign_name`, `client`). Renaming any display field never
  touches the ID. The live generator (`sales/index.html` L1704) is `'JOB-' + Date.now()` — opaque,
  monotonic, carries no business meaning.
- `sales.job_tours` — PK `tour_id` (`text`) + `title` (the name). New tour IDs are `T-000001`
  (opaque global counter, since counters chunk 2B). The hub renders `t.title || t.tour_id`
  (`hub/index.html` L1822) — name first, ID only as a fallback.

So the two-field shape is already present for both live entities; the gap is that it was never
written down and never made a rule, and a few residual violations remain.

**The residual violations this ADR governs.**

1. **Legacy semantic IDs exist and are immutable by design, so they now lie or will lie.** The live
   dev DB contains `JOB-2025-LENOVO-LENOVOIN-CHANDIGARH_BTL-01` (embeds year, brand, client, city,
   type, sequence) and its two tours `2025-LENOVO-LENOVOIN-CHANDIGARH_BTL-01-T01` /
   `...-T02`. These are the surviving output of the LENOVO-era semantic generator. 12+ tables FK
   `sales.jobs.job_id` (artifacts, briefs, challans, invoices, offers, job_elements, tour_jobs,
   custodian.vouchers, installation.*, ...), so renaming them is a wide, risky cascade for zero
   functional gain.
2. **`sales.job_tours.title` is NULLABLE**, so a nameless tour forces raw-ID display — the exact
   failure the principle exists to prevent.
3. **`sales.jobs.job_name` carries a UNIQUE constraint** (`jobs_job_name_key`), which treats a
   display label as quasi-identity. That is a deliberate design tension worth recording explicitly.
4. **No ADR codifies the principle**, so nothing stops a future generator from going semantic again
   — which is precisely how the LENOVO-era IDs were born.

This ADR resolves the *policy* questions. The mechanical fixes (making `title` required, auditing
raw-ID renderings across PWAs) are executed in sibling chunks I1 and I2 and are out of scope here.

## Decision

### 1. The two-field pattern is mandatory platform-wide, for current AND future entities

Every entity — every current one and every one introduced hereafter — MUST have:

- an **opaque, immutable, unique ID**: alphanumeric, carrying no business meaning, never edited
  after creation, and the only value other rows/foreign keys reference; and
- a **human-readable display NAME**: renameable at any time, used for display only, never
  referenced as a key by any other row.

"Opaque" means the ID must not encode year, brand, client, city, type, sequence, or any other fact
that can change or go stale. Deriving an ID from a timestamp or a monotonic counter is opaque;
concatenating business fields is not. New generators MUST follow this; this rule is the standing
gate any future ID scheme is reviewed against.

### 2. Legacy semantic IDs are grandfathered — documented, never renamed, never cascaded

The pre-2026 semantic IDs are frozen as historical artifacts. The explicit grandfather list:

- `JOB-2025-LENOVO-LENOVOIN-CHANDIGARH_BTL-01` (`sales.jobs`)
- `2025-LENOVO-LENOVOIN-CHANDIGARH_BTL-01-T01` (`sales.job_tours`, title "Chandigarh Week 1")
- `2025-LENOVO-LENOVOIN-CHANDIGARH_BTL-01-T02` (`sales.job_tours`, title "Chandigarh Week 2")

These are never renamed and never cascaded through their 12+ FK dependents. The "lying ID" harm is
removed not by fixing the ID but by fixing *display*: UIs simply stop showing the raw ID as primary
text (chunk I2), so the embedded stale facts are never presented to a user as current truth. Because
each of these already carries a valid display name, grandfathering them is harmless.

### 3. KEEP the `'JOB-' + Date.now()` job-ID generator

The current job generator (`sales/index.html` L1704, `'JOB-' + Date.now()`) is retained unchanged.
It is already opaque and monotonic. Switching to `J-NNNNNN` for cosmetic symmetry with the tour
`T-NNNNNN` counter buys nothing functional and introduces real risk (a new counter path that could
collide with in-flight code and existing rows). Symmetry is not a goal; opacity and immutability are,
and this generator already satisfies both.

### 4. KEEP `job_name` UNIQUE — a deliberate, reversible call

The `jobs_job_name_key` UNIQUE constraint on `sales.jobs.job_name` is retained, as a conscious
exception to the "name is display-only" framing. Rationale: two live jobs sharing an identical
display name is a real, concrete confusion risk in pickers and lists (a user picking "the wrong
LENOVO job" because two read identically). Uniqueness of the *name* prevents that at the source while
leaving renaming completely free — an operator may rename a job to any value not currently in use.
This is explicitly recorded as a deliberate and **reversible** decision: if a genuine need for
duplicate live names appears, the constraint can be dropped without touching any ID (see
Alternatives). Tours deliberately do NOT get an equivalent `title` UNIQUE — tour disambiguation is
handled by the required-title rule plus the `Name (ID)` display convention below.

### 5. Display convention — Name primary, `Name (ID)` in pickers and detail metadata

- Lists and detail headers show the **NAME only**.
- Pickers, type-aheads, and detail-page metadata show `Name (ID)` so the immutable handle stays
  discoverable for support and audit conversations (where someone needs to quote the exact ID).
- A raw ID is NEVER the primary label of any user-facing element. The hub's existing
  `title || tour_id` fallback is compliant. This convention is what makes grandfathering the legacy
  IDs safe, and it is the rule new pickers built by concurrent work (e.g. the P&L plan's custodian
  job picker and tour-expense persistence) must follow, in combination with ADR-030's
  searchable-picker requirement.

### 6. Tour `title` becomes required — enforcement in I1/I2

`sales.job_tours.title` becomes required at both the UI and DB levels, so no tour can exist without a
display name and thus no screen is ever forced to show a bare `tour_id`. **This ADR states the
policy only.** The actual enforcement — UI required-field validation on every `job_tours` writer,
then `ALTER TABLE sales.job_tours ALTER COLUMN title SET NOT NULL` (writers first, constraint
second) — is executed in sibling chunk I1; the cross-PWA raw-ID display audit is chunk I2. Those
chunks carry their own Playwright verification.

## Implementation Notes

- This ADR is **policy, not migration**. It changes no schema and no code. It is the authority the
  I1 (title required) and I2 (raw-ID display audit) chunks execute against, and the gate future ID
  generators are reviewed under.
- The grandfather list in §2 is the complete set of legacy semantic IDs found in the live `lm360`
  dev DB on 2026-08-01. If more surface later, they are added to this list under the same rule
  (document, never rename, fix display), not treated as a reason to reopen the cascade question.
- Concurrent work that introduces new pickers (the P&L plan chunks 2A/2B/2C/P3) MUST follow §5's
  `Name (ID)` convention; the orchestrator adds an explicit pointer to this ADR in those briefs once
  this ADR has landed.
- Dev (`lm360`) only. No prod cutover implied.

## Alternatives Considered

- **Cascading-rename the legacy semantic IDs into opaque ones.** Rejected. 12+ tables foreign-key
  `sales.jobs.job_id` (and the tour IDs have their own dependents), so a rename is a wide,
  error-prone cascade across artifacts, briefs, challans, invoices, offers, job_elements, tour_jobs,
  vouchers, and the installation tables. It carries real data-integrity risk for **zero functional
  gain** — fixing the *display* (§5) removes the entire "lying ID" harm at a fraction of the risk.
  The IDs being ugly is not a problem; the IDs being *shown as if they were current facts* is, and
  that is a display fix.
- **Adopt semantic-but-versioned IDs** (e.g. embed business facts but stamp a version so a "v2" ID
  can supersede a "v1" when facts change). Rejected. It adds a versioning mechanism and supersession
  bookkeeping on top of the ID, and it still **lies between versions** — the window where an ID
  embeds a now-stale fact but has not yet been re-versioned is exactly the failure mode being
  eliminated. An opaque ID never lies because it never claims anything.
- **Drop the `job_name` UNIQUE constraint** (treat the name as pure, unconstrained display text, per
  the strict reading of the principle). Rejected **for now**. Two live jobs with identical display
  names is a concrete picker/list confusion risk, and the constraint costs nothing while leaving
  renaming free. Recorded in §4 as reversible: if duplicate live names become a genuine need, the
  constraint drops with no ID impact. Rejected-for-now, not rejected-forever.

## Consequences

**Positive:**
- The platform's already-present two-field shape is now a written rule, so no future generator can
  quietly go semantic again — the single mechanism that produced every legacy violation is closed.
- Legacy IDs are handled at near-zero risk: documented, frozen, and de-fanged by display fixes
  rather than a 12-table cascade.
- A single, consistent display convention (`Name` primary, `Name (ID)` in pickers) across all PWAs,
  including all future ones, so support/audit can always recover the immutable handle without any
  screen leaning on a raw ID as its label.

**Negative / Trade-offs:**
- `job_name` UNIQUE is a deliberate deviation from the pure "name is display-only" ideal — a display
  label carries a light identity constraint. Accepted as a reversible, confusion-preventing call.
- The legacy semantic IDs remain in the database forever, visibly ugly in any raw query or audit
  export (they are only hidden in the *UI*). Accepted as the cost of not cascading.
- The principle is enforced by convention + review for new generators, not by a mechanical guard —
  a future author could still write a semantic generator; this ADR is what a reviewer points to when
  rejecting it.

**Risks and mitigations:**

| Risk | Mitigation |
|---|---|
| A future generator embeds business facts in an ID again | This ADR is the explicit review gate; §1 defines "opaque" concretely (timestamp/counter yes, concatenated facts no) |
| A new PWA renders a raw `job_id`/`tour_id` as a primary label | §5 display convention is mandatory platform-wide; chunk I2 audits current PWAs, and new-PWA briefs cite this ADR |
| `job_name` UNIQUE later obstructs a legitimate duplicate-name need | §4 records it as reversible with no ID impact; drop the constraint if the need is real |
| More legacy semantic IDs surface beyond the §2 list | Same rule applies (document, never rename, fix display); extend the grandfather list, do not reopen the cascade |

## Related Decisions

- **ADR-111** — Unified Job Record in `sales.jobs` (supersedes ADR-046). Establishes `sales.jobs` as
  the single job record whose `job_id`/`job_name` split this ADR now governs as the reference case
  of the pattern. ADR-111 is untouched.
- **ADR-125** (revised 2026-08-01) — Tours link to multiple Jobs via `sales.tour_jobs`. The
  `tour_id` opacity and the required-`title` rule here apply to the tour entity ADR-125 builds on.
- **ADR-126** — Effective-dated many-to-many bridges + unified job identity. Its bridges reference
  the opaque `job_id`/`tour_id` handles this ADR mandates; the display convention here governs how
  bridged entities are shown.
- **ADR-129** — Per-job attributable P&L view. Its job-facing pickers and reports must follow §5's
  `Name (ID)` convention; ADR-129 is untouched by this ADR.
- **ADR-030** — No `<select>` for more than 5 options; use searchable type-ahead. §5's `Name (ID)`
  picker labels ride on top of ADR-030's searchable-picker requirement.
- **ADR-127** — Counter-to-Job junction. This ADR mirrors ADR-127's section layout as the house ADR
  format; ADR-127's `job_id`/`counter_id` references are consumers of the opacity rule stated here.

## References

- `sales.jobs` schema (live `lm360` dev DB, psql 2026-08-01): PK `jobs_pkey` on `job_id` (`text`,
  `NOT NULL`); `job_name` `text NOT NULL`; UNIQUE constraint `jobs_job_name_key` on `job_name`; 12+
  referencing FK tables (artifacts, briefs, challans, invoices, offers, job_elements, tour_jobs,
  custodian.vouchers, installation.counters, installation.jobs, job_tour_requests, ...).
- `sales.job_tours` schema (same DB/date): PK `job_tours_pkey` on `tour_id` (`text`); `title` `text`
  **NULLABLE** (the gap §6 closes via chunk I1); referenced by rentveh.expenses, tourexp.expenses,
  job_tour_requests, installation.jobs, tour_jobs.
- Job-ID generator: `sales/index.html` L1704, `job_id: 'JOB-' + Date.now()`.
- Hub name-first render: `hub/index.html` L1822, `${t.title || t.tour_id}`.
- Legacy semantic IDs (live query, 2026-08-01): `JOB-2025-LENOVO-LENOVOIN-CHANDIGARH_BTL-01` and
  tours `...-T01` / `...-T02` (titles "Chandigarh Week 1" / "Chandigarh Week 2").
- `counters/MDD_counters.md` §15.1 (Unified Job ID) — lineage of the cross-PWA job-identity /
  portable-`job_id`-contract thread that this ID/Name policy sits atop.
- ID/Name Separation Policy plan (hkl, DL session, 2026-08-01):
  `C:\Users\Lenovo\Documents\tour-job-architecture\ID_NAME_POLICY_PLAN.md`, §0 (verified state), §1
  (pre-defaulted decisions), chunk I0.
- The LENOVO-era semantic generator is the in-house cautionary tale: an immutable ID built by
  concatenating business fields, now frozen because it cannot be corrected without a 12-table
  cascade — the concrete precedent motivating §1.
