# ADR-112: `lm360-session` Is the Single Canonical Session — Every PWA Mirrors To It on Login, and Every PWA Exposes an Always-Visible Logout

## Status

Accepted, 2026-07-12.

## Status History

```yaml
status_history:
  - date: 2026-07-12
    status: Proposed
    changed_by: hkl
    reason: User-reported bug — Hub nav button shows login screen despite an active session in the calling PWA
    changed_via: adr-kit (360lm)
  - date: 2026-07-12
    status: Accepted
    changed_by: hkl
    reason: Root cause confirmed platform-wide (one-way session mirroring, missing logout affordance); fix approved same session
    changed_via: adr-kit (360lm)
```

## Context

Reported bug: in the Activity PWA, clicking the "🏡 Hub" nav button sends the user to Hub's
login screen instead of its dashboard, even though the user is already logged in — to
Activity.

Root cause, verified directly against the code (not inferred): `hub/index.html`'s `boot()`
correctly checks `localStorage['lm360-session']` and goes straight to the dashboard if it's
valid. The bug is that several PWAs maintain their **own separate session key** and only do a
**one-way mirror**: on load, if `lm360-session` (Hub's key) already exists, they auto-log the
user in using it. But when a user logs in **directly** to one of these PWAs via its own PIN
screen (never having visited Hub first), that PWA's `saveSession()`-equivalent only writes its
own key — it never writes `lm360-session` back. Hub then genuinely has no session recorded,
and correctly (from its own narrow view) shows its login screen.

A full audit of every PWA under `/var/www/360lm/` found this is not unique to Activity:

- **Activity** (`activity/index.html:685-690`) — `saveSession()` writes only
  `lm360-activity-session`, never `lm360-session`. Confirmed bug.
- **Expense** (`expense/index.html:2155-2166`) — same pattern: `saveSession()`/`loadSession()`
  only touch `360exp-session`, never read or write `lm360-session`. Additionally, Expense's
  own logout button (`expense/index.html:3668-3677`) only calls `clearSession()` (its own key)
  — `lm360-session` is left intact, so a user who clicks "Log out" in Expense is still
  considered logged in by Hub and every other PWA. Confirmed bug (worse than Activity's: not
  just login, logout is inconsistent too).
- **Vehicle** (`vehicle/index.html:2047-2053`) — `attemptLogin()` calls `saveSession()`, but
  **that function is never defined anywhere in the file**. Login in Vehicle currently throws
  at runtime and the session is never persisted at all — the user is asked to log in again on
  every reload. This is a more severe, independent bug, found only because this audit checked
  every PWA's session write path rather than assuming "has code that looks like a save
  function" means "works." Vehicle's own `loadSession()`/`logout()` (lines 2017-2029) already
  correctly use `lm360-session` directly — the fix is simply to define the missing function.

Separately, most PWAs that DO have a persistent "🏡 Hub" nav button either have no visible
Logout affordance at all, or bury it inside a Settings/Profile modal (e.g., Activity's "Sign
out" only reachable via ⚙️ → Settings sheet) rather than next to the Hub button where users
would expect it. The user explicitly asked for logout to be available on every page, not
hidden behind a settings tap.

## Decision

1. **`lm360-session` is the single canonical session key platform-wide.** Every PWA's direct
   login path MUST write to `lm360-session` (in the shape `{empId, name, role, loginAt}`,
   matching what Hub and most PWAs already read), in addition to any PWA-specific key it needs
   for its own purposes. A PWA-specific key is not banned (e.g. Activity's
   `lm360-activity-session` can keep carrying Activity-specific fields), but it must never be
   the *only* place a successful login is recorded.
2. **Every PWA's logout path MUST clear `lm360-session`** (in addition to its own key), so that
   logging out of any one PWA logs the user out of all of them, consistent with them sharing
   one canonical session.
3. **Every PWA with a persistent nav bar/topbar MUST expose an always-visible Logout button in
   that same nav**, next to the existing "🏡 Hub" button where one exists — not buried in a
   Settings/Profile screen. Clicking it clears `lm360-session` (+ any PWA-specific key) and
   navigates to bare `/hub/` (deliberate navigation — same treatment ADR-001 already gives the
   "🏡 Hub" button: the user chose to leave, no `?next=` needed).
4. **PWAs that are deliberately isolated systems keep their own session key with no mirroring**
   — `btl/` (public promotional-claim microsite, its own `btl-session`), `vrs/` (own
   `vrs_sess`), `tour-planner/` (own `lm360-tp-session`, PIN gate distinct from the main
   employee roster), `recce-client/` (own `recce-client-session`, explicitly documented as
   intentionally distinct). These are not bugs; they are separate auth domains by design and
   out of scope for this ADR.
5. **Static/no-auth PWAs** (`mother`, `nikhil-portfolio`, `tutorials`, `yagya-portfolio`) are
   out of scope — no session concept exists to fix.

**Decision Maker:** hkl

## Alternatives Considered

- **Remove all PWA-specific session keys, use only `lm360-session` everywhere.** Rejected:
  some PWAs store extra fields on their own key (e.g. Activity's quality-slider preference is
  unrelated but colocated) and rewriting every read-site across ~15 PWAs is a much larger,
  riskier change than mirroring writes. The bug is the missing write-back, not the existence
  of a second key.
- **Fix only the reported PWA (Activity).** Rejected: the audit found the identical pattern in
  Expense and a more severe independent bug in Vehicle; fixing one and leaving the others would
  mean this exact complaint resurfaces PWA by PWA.
- **Add a server-side session (JWT/cookie) instead of localStorage mirroring.** Rejected as
  out of scope here — ADR-105 (Proposed) already covers a signed-JWT direction for a different
  reason (unsigned "Hub <base64>" header forgery); this ADR is a scoped, immediate fix for the
  reported UX bug, not a full auth redesign.

## Consequences

**Positive:**
- Logging in anywhere in the platform is recognized everywhere — no more "Hub button shows
  login despite being logged in."
- Logging out anywhere logs the user out everywhere — closes the Expense inconsistency where
  "Log out" didn't actually log out.
- Vehicle's login actually persists across reloads (was silently broken before this ADR).
- Consistent, discoverable logout UX — always in the main nav, never buried.

**Negative / Trade-offs:**
- Every PWA touched needs a small, mechanical edit (session write + logout clear + nav button);
  more surface area than a single-file fix, though each change is low-risk and independently
  verifiable.
- PWAs with no existing nav chrome (rare) need a new button added, which is a minor visual
  change reviewers should sanity-check on their own device.

**Risks and mitigations:**
- Risk: a PWA's `lm360-session` shape (`empId`/`name`/`role`/`loginAt`) drifts from what a
  write-back uses (e.g. wrong field name silently breaks Hub's read). Mitigation: every
  write-back added under this ADR copies the exact shape already used successfully by Sales/
  Hub/Vehicle's own reader, and is live-verified against the real dev DB/UI, not just reviewed.
- Risk: adding a logout button that clears too much/too little. Mitigation: each PWA's logout
  clears its own key (if any) + `lm360-session` only — never a broader `localStorage.clear()`.

## Related Decisions

- **ADR-001** (`?next=` on auth-gate redirects) — this ADR's logout/Hub-button behavior
  (redirect to bare `/hub/`, no `?next=`) is explicitly the same "deliberate navigation" case
  ADR-001 already carved out; this ADR does not change ADR-001, it extends the same principle
  to logout.
- **ADR-105** (Proposed — signed JWT for proxy/native auth) — a deeper future auth redesign;
  this ADR's localStorage-mirroring fix is orthogonal and does not block or depend on it.

## Scope Notes

- Applies to PWAs sharing the main employee/PIN login system via `lm360-session`. Does not
  apply to isolated auth domains (§Decision item 4) or static/no-auth sites (§Decision item 5).
- The Logout button placement convention: reuse the exact spot/style already used by Sales,
  Expense, Production, Tour Planner, Admin (a `.btn-icon`/icon button showing 🔒, in the main
  topbar, next to the 🏡 Hub button where one exists).

## References

- Full per-PWA fix log: `dbt_hub.md` / `dbt_pending.md` (memory), and this session's direct
  file:line verification (not the initial audit-agent's unreliable table output — verified by
  hand against `activity/index.html`, `vehicle/index.html`, `expense/index.html`,
  `sales/index.html`, `hub/index.html`).
- `memory/feedback_hub_next_redirect.md` — related but distinct convention (ADR-001).

## Amendment — 2026-07-16: read-side canonicality (two gaps found live)

The original decision covered the WRITE side (PWAs mirror to `lm360-session`). Two read-side gaps surfaced in production and are now part of the contract:

1. **PWA-local caches must lose to canonical identity.** A PWA's own session key restored by TTL alone showed the *previous* user after a different Hub login (activity v35, admin v11 — admin had been missed by the original sweep entirely). Rule: discard the local cache whenever a valid `lm360-session` carries a different `empId`.
2. **Canonical-absent means logged out — no fallback source may override.** Hub's own `loadSession()` fell back to the legacy `360exp-session` mirror, resurrecting sessions after a PWA logout (dashboard instead of login screen). Rule: `lm360-session` is the ONLY read source for identity; legacy mirrors are write-only compatibility artifacts. Logout should clear known mirrors too (recce v41 does).

Also: recce adopted redirect-only login per ADR-001 (`/hub/?next=%2Frecce%2F`), retiring its anonymous mode — all recce submissions are now attributable. See VCC Class 26 (all three instances) for detection/prevention recipes.
