# ADR-092: User Preference Cascading Fallback — DB → localStorage → Hardcoded Default

## Status

Proposed, 2026-06-27.

## Status History

```yaml
status_history:
  - date: 2026-06-27
    status: Proposed
    changed_by: hkl
    reason: Standardizing preference tier classification (employee-tier, device-tier, session-tier) and fallback patterns. ADR-089 (bilingual, DB-backed language) and ADR-090 (font-size, localStorage-only) establish two different patterns; this ADR formalizes the decision framework to prevent ad-hoc implementations and RPC failure handling bugs in future PWAs.
    changed_via: adr-kit (360lm)
```

## Context

The 360lm platform stores user preferences in three categories, each with different authoritative sources and fallback strategies:

1. **Employee-tier preferences** (cross-device): language, notification settings, dashboard layout
   - Stored in DB (persists across devices)
   - Cached in localStorage for offline access
   - Example: ADR-089 (bilingual, `employee.lang` in hub schema)

2. **Device-tier preferences** (per-screen): font-size, zoom level, local UI state
   - Stored in localStorage only (device-specific)
   - No DB persistence; not synced across devices
   - Example: ADR-090 (font-size device preference)

3. **Session-tier preferences** (ephemeral): active tab, scroll position, unsaved form state
   - Stored in sessionStorage only
   - Lost on page reload
   - No fallback needed

**Problem:** PWAs currently implement ad-hoc fallback logic. Some silently fail on RPC errors without falling back to localStorage; others confuse localStorage (offline cache) with localStorage (user-set preference). Without a standard contract, new PWAs may:
- Show error toast on preference RPC failure instead of falling through silently to localStorage
- Override employee DB preference with stale local data
- Lose preference on offline + RPC failure (no layered fallback)
- Store device-tier preferences in DB, wasting space and syncing unnecessary data

**Evidence:**
- ADR-089 (bilingual): establishes employee-tier pattern (DB → localStorage → hardcoded 'en'), but RPC failure handling not explicitly documented
- ADR-090 (font-size): uses device-tier only, but a future dev might not know to exclude it from DB persistence
- ADR-006 (verify_pin RPC): establishes silent fallback pattern for RPC responses that can be null
- ADR-020 (offline-first): IndexedDB is primary for field data, but preferences are lighter and fit localStorage

**Affected PWAs:**
- All PWAs that need user preferences (current and future)
- Finance, Sales, Learn, Recce, Vehicle, Admin (current implementations vary)

## Decision

**Preferences are classified into three tiers with explicit fallback patterns. Each tier has a clear authoritative source and fallback chain. RPC failures MUST NOT show errors — they MUST fall through silently to the next fallback level.**

### Preference Tier Classification

| Preference type | Tier | Authoritative store | Storage contract | Example |
|---|---|---|---|---|
| Per-employee, cross-device (language, notification settings, dashboard layout) | **Employee-tier** | DB column or table | Tier 1: DB fetch; Tier 2: localStorage; Tier 3: hardcoded default | `employee.lang`, notification_frequency, theme_mode |
| Per-device, screen-specific (font-size, zoom, dark mode override) | **Device-tier** | localStorage only | localStorage with hardcoded default; zero DB involvement | font_size, ui_zoom, device_dark_override |
| Per-session, ephemeral (active tab, scroll position, form drafts) | **Session-tier** | sessionStorage only | sessionStorage only; lost on reload | scroll_pos, active_tab_id, pending_form_id |

**Rules:**
- **Tier assignment is permanent** once a preference is classified. Move from Device → Employee requires a new migration RPC to backfill employees DB table and a new ADR.
- **Each tier has only ONE authoritative source.** Do not dual-write to localStorage and DB for the same key (breaks fallback clarity).
- **Fallback is silent.** RPC failure, null response, or missing key in localStorage MUST NOT show error toast. Fall through to the next level.
- **Offline capability:** Employee-tier preferences cached in localStorage so they persist when RPC fails or employee is offline.

### Employee-Tier Preference Pattern (3-Level Fallback)

**Use this pattern for preferences that must follow the employee across devices.**

```javascript
// Load a preference with fallback chain
async function loadPref(key, defaultVal) {
  // Tier 1: attempt DB fetch (cross-device authoritative)
  try {
    const res = await fetch(`/rpc/get_pref?key=${encodeURIComponent(key)}`, {
      headers: {
        'Authorization': `Bearer ${window._token}`,
        'Accept-Profile': 'hub'
      }
    });
    
    if (res.ok) {
      const val = await res.json();
      // Tier 1 success: any response (including null) is stored locally
      if (val !== null) {
        localStorage.setItem(`pref_${key}`, val);
        return val;
      }
      // If val is null (no preference stored in DB), continue to Tier 2
    }
  } catch (e) {
    // RPC failed (network, timeout, 500). Fall through silently.
    console.debug(`[pref] DB fetch failed for '${key}', falling back to localStorage`);
  }
  
  // Tier 2: localStorage (offline / RPC failure safety net)
  const cached = localStorage.getItem(`pref_${key}`);
  if (cached !== null) return cached;
  
  // Tier 3: hardcoded default (always safe)
  return defaultVal;
}

// Save a preference to both DB and localStorage
async function savePref(key, val) {
  // Always update localStorage immediately (offline sync, instant feedback)
  localStorage.setItem(`pref_${key}`, val);
  
  // Fire-and-forget RPC to DB (no error handling; local cache is sufficient)
  fetch(`/rpc/set_pref`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${window._token}`,
      'Accept-Profile': 'hub',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({key, value: val})
  }).catch(e => {
    console.debug(`[pref] DB save failed for '${key}'; using localStorage only`, e);
  });
}
```

**Boot sequence (after employee session is loaded):**
```javascript
async function initPreferences() {
  // Load all employee-tier preferences at once (batching is more efficient than per-key RPCs)
  const prefs = {};
  const defaultPrefs = {
    lang: 'en',
    notification_frequency: 'daily',
    // ... other employee-tier defaults ...
  };
  
  try {
    const res = await fetch(`/rpc/get_prefs_all`, {
      headers: {
        'Authorization': `Bearer ${window._token}`,
        'Accept-Profile': 'hub'
      }
    });
    
    if (res.ok) {
      const dbPrefs = await res.json();  // Returns {lang: 'hi', ...}
      Object.assign(prefs, dbPrefs);
      
      // Sync DB values to localStorage for offline access
      Object.entries(dbPrefs).forEach(([k, v]) => {
        if (v !== null) localStorage.setItem(`pref_${k}`, v);
      });
    }
  } catch (e) {
    console.debug('[pref] DB batch load failed; using localStorage only', e);
  }
  
  // Fill in missing values from localStorage or defaults
  Object.entries(defaultPrefs).forEach(([key, defaultVal]) => {
    if (!prefs[key]) {
      prefs[key] = localStorage.getItem(`pref_${key}`) || defaultVal;
    }
  });
  
  window._prefs = prefs;  // Global accessible throughout app
}
```

### Device-Tier Preference Pattern (2-Level, No DB)

**Use this pattern for preferences that are device-specific and MUST NOT sync across devices.**

```javascript
// Load a device preference (localStorage only, no DB)
function loadDevicePref(key, defaultVal) {
  return localStorage.getItem(`dev_${key}`) ?? defaultVal;
}

// Save a device preference
function saveDevicePref(key, val) {
  localStorage.setItem(`dev_${key}`, val);
  // No RPC; this preference is device-only
}
```

**Boot sequence:**
```javascript
function initDevicePrefs() {
  window._devicePrefs = {
    fontSize: loadDevicePref('fontSize', '16px'),
    zoom: loadDevicePref('zoom', '100%'),
    darkModeOverride: loadDevicePref('darkModeOverride', 'auto'),  // 'auto' | 'light' | 'dark'
  };
}
```

**Critical rule:** Device-tier preferences MUST NOT be stored in DB. They are not employee-owned; they belong to the device. Storing them in DB will cause data bloat and confusion when employees switch devices.

### Session-Tier Preference Pattern (Ephemeral Only)

**Use this pattern for preferences that are lost on page reload.**

```javascript
function loadSessionPref(key, defaultVal) {
  return sessionStorage.getItem(`sess_${key}`) ?? defaultVal;
}

function saveSessionPref(key, val) {
  sessionStorage.setItem(`sess_${key}`, val);
}
```

**Decision Maker:** hkl

### DB Contract for Employee-Tier Preferences

**Recommended schema (for PWAs without existing preference tables):**

Per ADR-032, `expense.employees` is the canonical employee identity table across all 360lm schemas. Store preferences there:

```sql
-- Single JSONB column on canonical employee profile (recommended)
ALTER TABLE expense.employees ADD COLUMN prefs JSONB DEFAULT '{}'::jsonb;

-- Or dedicated preferences table (if needing audit trail)
CREATE TABLE expense.employee_prefs (
  employee_id BIGINT,
  key TEXT,
  value TEXT,
  updated_at TIMESTAMPTZ DEFAULT now(),
  PRIMARY KEY (employee_id, key)
);
CREATE INDEX idx_employee_prefs_updated ON expense.employee_prefs(updated_at);
```

**RPC contract:**

RPCs can be defined in any schema that PWA owns, but they read/write `expense.employees`:

```sql
-- Get a single preference (or null if not set)
CREATE FUNCTION expense.get_pref(p_key TEXT) RETURNS TEXT AS $$
BEGIN
  RETURN (SELECT prefs ->> p_key FROM expense.employees WHERE id = auth.uid());
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- Get all preferences as JSON object
CREATE FUNCTION expense.get_prefs_all() RETURNS JSON AS $$
BEGIN
  RETURN (SELECT prefs FROM expense.employees WHERE id = auth.uid());
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- Set a single preference (upsert)
CREATE FUNCTION expense.set_pref(p_key TEXT, p_value TEXT) RETURNS BOOLEAN AS $$
BEGIN
  UPDATE expense.employees SET prefs = jsonb_set(prefs, ARRAY[p_key], to_jsonb(p_value)) WHERE id = auth.uid();
  RETURN TRUE;
EXCEPTION WHEN OTHERS THEN
  RAISE WARNING 'set_pref failed: %', SQLERRM;
  RETURN FALSE;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

GRANT EXECUTE ON FUNCTION expense.get_pref(TEXT) TO web_anon;
GRANT EXECUTE ON FUNCTION expense.get_prefs_all() TO web_anon;
GRANT EXECUTE ON FUNCTION expense.set_pref(TEXT, TEXT) TO web_anon;
```

## Implementation Notes

**Files and identifiers:**
- **Global fallback helpers:** `loadPref(key, default)`, `savePref(key, val)`, `loadDevicePref(key, default)`, `saveDevicePref(key, val)` — copy-verbatim into each PWA's `<script>` block
- **DB schema:** hub.employees.prefs (JSONB), or dedicated hub.employee_prefs table
- **RPCs:** hub.get_pref, hub.get_prefs_all, hub.set_pref (or mirrored in PWA schema)
- **localStorage key prefixes:** `pref_` (employee-tier), `dev_` (device-tier), `sess_` (session-tier) for clarity
- **Boot order:** load employee session → loadPref/initPreferences → initialize UI

**Find existing uses of preferences:**
```bash
grep -r "localStorage.getItem" /var/www/360lm --include="*.html" | grep -v "pref_\|dev_\|sess_" | head -20
grep -r "sessionStorage.getItem" /var/www/360lm --include="*.html" | head -10
grep -r "^_lang = " /var/www/360lm --include="*.html"  # Existing employee-tier (language)
```

**Critical gotchas:**

1. **RPC failure is not an error:** `loadPref()` should never throw or show error toast on RPC failure. It silently falls back to localStorage. Logging is optional (debug level only).

2. **null vs undefined:** DB returns null for missing keys, localStorage returns null for missing keys, defaults fill in undefined. The fallback chain treats null as "move to next tier" and returns defaultVal as fallback.

3. **Dual-write trap:** Never store a device-tier preference in DB. Never store an employee-tier preference in sessionStorage. The tier classification is final.

4. **localStorage key collision:** Always prefix keys (`pref_`, `dev_`, `sess_`). If PWA stores other localStorage data, namespace it separately.

5. **Boot sequence order:** loadPref must run after employee session is loaded (auth token available, employee_id known). Running before will fail silently and use hardcoded defaults (correct behavior, but unexpected).

6. **Preferences UI does not exist in this ADR:** This ADR covers storage and fallback. Each PWA decides how to expose preference toggles (settings screen, top-bar button, modal). ADR-089 shows an example (language toggle button).

## Alternatives Considered

- **All preferences in localStorage only (no DB).** Rejected. Employee-tier preferences must follow the employee across devices; localStorage is per-browser. Without DB, an employee switching devices would lose their language preference.

- **All preferences in DB (no localStorage).** Rejected. During network outage or RPC failure, preferences would be unavailable offline. localStorage caching improves UX and resilience without cost.

- **Silent RPC failure, but keep using stale localStorage value.** Accepted for Tier 2 fallback. But if DB value is newer and RPC fails, user sees outdated value. Tier 1 fetch success → update localStorage mitigates this. If RPC fails, user sees locally cached value (acceptable tradeoff).

- **Separate preferences table per PWA (finance.prefs, recce.prefs, etc.).** Rejected. Employee-tier preferences are employee-owned, not PWA-owned. Centralizing in hub.employees.prefs allows single sign-on to share preferences across PWAs (e.g., language, notifications).

- **Show error toast if preference RPC fails.** Rejected. Preference failure is not an error condition; it is a network hiccup. Silent fallback to localStorage is correct behavior. Showing toast would spam the user when offline.

- **Use IndexedDB for preferences (like field data).** Rejected. Preferences are small, human-edited, and rarely need multi-version history. localStorage is simpler and sufficient. IndexedDB is better suited for large bulk data (field submissions, inventory).

## Consequences

**Positive:**
- **Consistent fallback across all PWAs:** every PWA uses the same tier classification and fallback order. Developers don't have to re-derive the pattern.
- **Offline resilience:** employee-tier preferences cached in localStorage; app works offline with user's last-known preferences.
- **RPC failure forgiveness:** network hiccup doesn't break preferences; silently falls back to cache.
- **Device independence:** employee-tier preferences follow the employee across devices; device-tier stays local.
- **Clear ownership:** tier classification makes it obvious where each preference lives and why.
- **Minimal implementation cost:** copy-paste helpers into each PWA; optional RPC batch load for efficiency.

**Negative / Trade-offs:**
- **Copy-paste duplication:** helpers are copied into each PWA (per ADR-013, single-file constraint). No shared import; inconsistency risk if one PWA drifts.
  - **Mitigation:** Code review checklist includes copy-paste verification; tests verify correct fallback behavior.
- **Silent RPC failures hide bugs:** if a preference RPC is broken in production, users won't notice (they'll see cached value from prior session).
  - **Mitigation:** Log RPC failures to analytics or error tracking; monitor preference save success rate in dashboards.
- **localStorage size limits:** localStorage has ~5–10 MB limit per origin. Preferences are small, but if a PWA caches lots of employee-tier data, limit could be reached.
  - **Mitigation:** Store only small, lightweight preferences in localStorage. Large data (user lists, catalogs) goes to IndexedDB (ADR-020).

**Risks and mitigations:**

| Risk | Mitigation |
|------|-----------|
| Developer forgets to prefix localStorage keys; collision with other PWA data | Prefix rule in VCC checklist (every `localStorage.setItem` in preference code must use `pref_`, `dev_`, or `sess_` prefix). Code review catches this. |
| Preferences are stored in DB but developer never calls `loadPref()`; defaults always used | Unit test: load a preference after a prior save; verify it round-trips (DB → localStorage → reload → verify). Also: manual QA: toggle a preference, refresh page, verify it persists. |
| RPC fails silently, user sees stale cached value, thinks their change was lost | Acceptable. User's last-known preference is better than a hardcoded default. If user is online, next RPC should succeed and sync the correct value. If offline, cached value is correct. |
| Employee-tier preference stored in localStorage key without `pref_` prefix; never synced to DB | Code review + test. Test that `savePref()` calls DB RPC with correct key; test that `loadPref()` reads from localStorage with correct prefix. |
| Session-tier preference persists across page reload (due to typo in sessionStorage vs localStorage) | Test: save a session-tier preference, reload page, verify it's gone. Code review checks for sessionStorage use only in session-tier helpers. |
| Device-tier preference bloat: developer stores 100 device prefs in localStorage | App is responsible for cleanup. Recommendation: periodic `localStorage.removeItem()` for unused keys during major feature removals. Size monitoring in browser DevTools. |

## Related Decisions

- **ADR-032:** expense.employees Is the Canonical Employee Identity Table — preferences are stored on the canonical employee record, not per-schema copies.
- **ADR-089:** Bilingual Platform Architecture — DB-backed language preference `employee.lang` is an employee-tier preference; this ADR formalizes its fallback pattern.
- **ADR-090:** Font Size Accessibility Preference — font-size device-tier preference is a concrete example of localStorage-only storage (never synced to DB).
- **ADR-006:** verify_pin RPC Returns TABLE Array — similar silent fallback pattern for RPC responses.
- **ADR-012:** Hub as SSO Gateway — employee session is loaded before preferences are initialized.
- **ADR-013:** Single HTML File, No Framework — why helpers are copy-pasted, not imported.
- **ADR-020:** Offline-First, IndexedDB Primary — preferences are lighter than field data; localStorage is sufficient for employee-tier caching.
- **ADR-026:** Cross-PWA Session Bridge via localStorage — localStorage is used for session handoff; preference keys use separate prefixes to avoid collision.
- **ADR-067:** Cross-PWA Change Safety Gate — adding a new employee-tier preference that affects multiple PWAs requires explicit confirmation.

## References

- **ADR-089:** `/var/www/360lm/docs/adr/ADR-089-bilingual-platform-architecture.md` — full bilingual preference implementation example (DB → localStorage → 'en')
- **Bilingual DevGuide:** `/var/www/360lm/docs/bilingual_devguide.md` — reference implementation of language preference load/save
- **Recce v15a implementation:** `/var/www/360lm/recce/index.html` (lines ~100–500) — working example of employee-tier preference with fallback
- **Vehicle implementation:** `/var/www/360lm/vehicle/index.html` — bilingual language preference (similar pattern)
- **Learn implementation:** `/var/www/360lm/learn/index.html` — language preference stored in employee profile
- **Memory: ADR Kit:** `/root/.claude/projects/-var-www-360lm/memory/adr_kit.md` — tools and conventions
- **VCC Checklist:** `/var/www/360lm/docs/vcc_checklist.md` — pre-build safety checks (add: preference key prefix verification)

---

**Changed via:** adr-kit (360lm)  
**Last updated:** 2026-06-27
