# Bulletproofing the Watch/Phone UX — Findings

An adversarial gap analysis of the iPhone ↔ Apple Watch communication layer, aimed at making the cross-device workout UX bulletproof. Architecture reference: `DEVICE-COMMUNICATION.md` (transports T1–T4, exchanges P1–P9 / W1–W4). Analysis date: 2026-07-09. **Second pass 2026-07-18** (live-run mirror & flow hand-off — see the dated section at the end), prompted by a field incident.

Findings are ranked by user impact. Each carries code evidence, the concrete failure scenario, and a fix direction. Statuses: **open** / **fixed** (update in place as items land).

---

## What's already solid (verified)

Re-verified against code, not just prior docs — these need no work and should not be re-litigated:

- **Per-log merge** (`WorkoutMergePlanner`) — unordered T3/T4 delivery and concurrent edits to different exercises commute; phone-authored `deletedLogIDs` tombstones disambiguate absent logs.
- **Tombstone veto** (`SyncEngine.ingestFromWatch`) — a stale watch push can never resurrect a deleted workout; authoritative state is re-pushed instead.
- **Latest-wins context + authoritative prune** (`WatchCacheApplier`) — "absent ⇒ no longer active" reliably ends the watch session on phone-side delete/discard, and a nil-decode can never wipe real rows.
- **Launch-race guard** (`SessionEndPlanner.decide`: `!previouslyActiveIDs.isEmpty`) — a freshly launched session is never killed just because its run document hasn't synced yet (empty-baseline case).
- **Timer hardening** — countdown/haptic logic is wall-clock anchored on both platforms; sleep and Always-On drift self-correct without double-firing.
- **Pop-on-prune** (`ActiveWorkoutGateView.popIfNavigatedRunUnavailable`) — deletion/discard of the navigated run, or a phone edit lock, pops the watch back to the gate.

---

## HIGH — the session lifecycle has a single point of failure

### H1 — The `HKWorkoutSession` can only ever be born via the T1 one-shot  ·  **fixed**

> **Fix (2026-07-09):** the coordinator now self-starts a session on any reconcile that finds an active run with none running (`SessionEndPlanner.shouldStart` / `runToStart`; activity type resolved from the run's split), and `WorkoutSessionManager.recover()` re-adopts a crash/reboot-orphaned session at launch (`recoverActiveWorkoutSession`). T1 is now a fast-launch optimization, not the sole session source.

`WorkoutSessionManager.start(with:)` has exactly one caller: `WatchAppDelegate.handle(_:)` (`WatchAppDelegate.swift:31`), i.e. the phone's `startWatchApp(toHandle:)` handoff. That handoff is best-effort `try?` with **no retry** (`WorkoutLauncher.swift:37-40`). There is no `HKHealthStore.recoverActiveWorkoutSession` anywhere, no `WKExtendedRuntimeSession`, and nothing gates run-flow entry on `sessionManager.isRunning`.

Failure scenarios (all real):

1. **T1 fails silently** (watch briefly unreachable, Bluetooth blip, watch on charger at start) → the entire workout runs sessionless: no HR/energy, no Health save, no wrist-raise resurfacing, and the watch app **fully suspends** wrist-down — rest haptics and done-countdowns don't fire until the user manually reopens the app. The user gets no hint anything is wrong.
2. **Watch app crash or reboot mid-run** → watchOS expects the app to reclaim the live session via `recoverActiveWorkoutSession`; unimplemented, so the session is orphaned — metrics lost, zombie session may linger.
3. **User manually opens an active run on the watch** (run arrived via T2; T1 never fired for this device state) → same sessionless degradation.

**Fix direction:** make the watch own its session lifecycle. Self-start a session when entering (or reconciling into) an active run with `isRunning == false`; implement session recovery at launch. T1 then becomes a fast-launch optimization rather than the sole source of sessions. This also absorbs most of H2 and much of the M-tier degradation.

### H2 — "End Current & Start New" swallows the new run's session  ·  **fixed**

> **Fix (2026-07-09):** absorbed by H1's self-start (a swallowed T1 heals at the next reconcile), plus a session-age rule in the planner: a `.finish` decided for a session younger than `minimumFinishAge` (30 s) demotes to `.discard`, so the transient "old run completed, new run not yet pushed" context can no longer save a junk seconds-long HKWorkout attributed to the old run — the session is discarded and self-start brings it back when the new run's push lands.

`endActiveThenStart` (`WorkoutLogsView.swift:271-281`) runs the end-saves in a `Task` but calls `start(with:)` synchronously — so **T1 goes out while the old run's session is still running on the watch**. `handle(_:)` → `start(with:)` hits the `session == nil` idempotency guard (`WorkoutSessionManager.swift:61`) and is **silently dropped**. Moments later the "A completed" push arrives and the coordinator finishes A's session — leaving run B sessionless (all of H1's fallout). The one-shot was consumed while blocked; nothing replays it.

Timing variant: if B's session *does* start (old one already ended) but the transient "A completed, B not yet present" push applies next, `SessionEndPlanner.decide` sees `isRunning`, empty active set, non-empty baseline `{A}` → `.finish(A)` — **ending B's seconds-old session and attributing its metrics to A**. The empty-baseline launch guard doesn't cover a *non-empty stale* baseline.

**Fix direction:** on the watch, when `handle(_:)` arrives while a session is running, stash the configuration and start it immediately after the current session finishes; and/or a short grace window after `session.startActivity(...)` during which `decide` refuses `.finish`/`.discard`. H1's self-start largely absorbs this too.

### H3 — A system-ended session silently drops the Health save  ·  **fixed**

> **Fix (2026-07-09):** `salvageSystemEndedSession` — a system-driven `.ended`/`.stopped` now ends collection and finishes the builder's workout (saving the `HKWorkout` to Health) instead of clearing everything. Residual: the salvaged workout isn't linked to a run document (`healthKitWorkoutUUID` stays nil) and no metrics are forwarded — the runs are still active, so there's no completed run to attach them to.

`workoutSession(_:didChangeTo:)` for `.ended`/`.stopped` only calls `clear()` (`WorkoutSessionManager.swift:199-202`) — no `endCollection`/`finishWorkout`, no metrics capture, no forward to the phone. `PLAN-watch-session-end.md` lists this as a known residual. Rare (OS reclaim under resource pressure), but it is a "my workout never made it to Health" support report waiting to happen.

**Fix direction:** on a system-driven `.ended`, attempt `finishAndSave()` (or at minimum salvage the builder's collected data) before clearing, and forward whatever metrics exist.

---

## MEDIUM — stranding and stale-UI traps

### M1 — Edit locks have no expiry and outlive their editor  ·  **fixed** (expiry) / open (split-lock scope)

> **Fix (2026-07-09):** `PhoneConnectivityBridge.setLocksSuspended` — the scene-phase hook publishes the locks as *cleared* while the app is backgrounded (without forgetting them locally) and re-asserts them on return to foreground, so a pocketed or force-quit phone can no longer park the watch's run indefinitely. The split-lock *scope* (viewing `SplitDetailView` parks runs) was deliberately left as-is: that screen hosts inline edits (reorder, swipe-delete, add), so narrowing the lock to its sheets would reopen the clobber risk it exists to prevent.

`editingWorkoutID` / `editingSplitID` ride in the latest-wins context and are cleared only by `onDisappear` (`ExerciseView.swift:78`, `SplitDetailView.swift:55`). Force-quit the phone with an editor open — or just leave it open in a pocket — and the **last-pushed context says "editing" indefinitely**; the watch parks the run ("Editing on iPhone…") until the phone app next runs `pushAll`. Compounding it, `SplitDetailView` is a mostly-*read* screen: merely **viewing** a split in Settings parks any active watch run sourced from it (`SplitDetailView.swift:54`).

**Fix direction:** clear the locks on phone `scenePhase` → background/inactive; scope the split lock to the actual edit sheets rather than the detail screen.

### M2 — Remote completion doesn't pop the watch's open exercise screen  ·  **fixed**

> **Fix (2026-07-09):** both platforms' `ExerciseProgressView` now observe `log?.status` and dismiss when the log resolves to `.completed`/`.skipped` remotely (a `locallyResolved` flag keeps their own Done / flow hand-off from re-dismissing) — a stale open flow can no longer write a resolved log back to in-progress. The watch also gained the phone's `startsSkipped` terminal page (`SkippedPhaseView`), so opening a skipped exercise shows a static badge instead of a live, resurrectable flow. Gate-level popping was deliberately not added: with the screen-level dismissal the resurrection path is closed, and reviewing a just-completed run's list is legitimate.

`popIfNavigatedRunUnavailable` pops on prune and on lock — but a run flipping to `.completed`/`.skipped` remotely **stays in the cache** (recently-completed runs are still pushed for ~24 h), so no pop fires. `repairFromDurable`'s safety net is gated on `status == .inProgress`, going silent exactly when needed. And the watch's `ExerciseProgressView` lacks the phone's `.skipped` handling, so a user sitting on a stale open exercise can keep ticking and write it back to a **non-terminal status** — resurrecting a skip through the per-log merge, since their edit is "newer".

**Fix direction:** pop (or overlay a terminal state) when the navigated run leaves the active set, not only when it leaves the cache; port the phone's `.skipped` terminal handling to the watch's progress view.

### M3 — Follower mirror cover freezes in auto-advance splits  ·  **fixed** (freeze) / open (mid-set staleness)

> **Fix (2026-07-09):** the terminal between-exercise rest now falls back to `dismiss()` when there's no `onAdvance` host (the live-mirror cover on both platforms) — the exercise still completes durably, the cover closes instead of freezing at 0:00, and the driver's next-exercise frame re-presents it. Residual: a cover sitting on a *count-up work page* when the driver dies still has no staleness timeout — acceptable because that surface is a take-over driver by design and dismissible by hand.

The live-mirror cover (`LiveRunCoverView`, both platforms) is not a passive display — it runs the same local phase engine as a real driver, seeded from the last frame's anchors, and keeps advancing if the driver goes silent. Single-exercise runs self-heal (Finish auto-Done dismisses). But the cover is wired with `onAdvance: nil`, so in a flow-mode (auto-advance) split, when its local chain reaches the terminal between-exercise rest it completes the exercise (durable data stays correct) then **freezes at a 0:00 countdown** — the exercise hand-off exists only in `RunFlowView`, which the cover doesn't use. Related: if the driver dies without `sendLiveEnded`, the follower has **no staleness timeout** at all.

**Fix direction:** give the cover a terminal-rest hand-off (advance or dismiss), and add a staleness timeout that dismisses the cover when no frame arrives past `phaseEnd` + grace.

### M4 — `updateApplicationContext` size ceiling; failure is log-only  ·  **fixed**

> **Fix (2026-07-09):** `pushAll` now degrades on failure instead of freezing the watch: a failed push retries with the recently-completed tail dropped (display-only on the watch — active runs, splits, settings, and locks all still go through), logged as a warning; only a failure of the slim push too remains an error. Deeper tiers (trimming embedded logs) weren't needed — active runs are a handful by construction.

`pushAll` sends *all* splits plus all active and ≤25 recent-completed runs with their full logs. A heavy user (many splits × many exercises; long runs with `setEntries`) can plausibly exceed WatchConnectivity's ~65 KB context limit. On throw, the error is logged (`PhoneConnectivityBridge.swift:121-124`) and **the watch silently never hears about anything again** — functionally the same total-freeze as the schema-mismatch trap, but reachable in production.

**Fix direction:** on payload-too-large, degrade in tiers (drop completed runs, then trim embedded logs to what the watch renders) and re-push; surface the condition in Diagnostics.

---

## LOW

### L1 — Nondeterministic metrics attribution with parallel runs  ·  **fixed**

`SessionEndPlanner.decide` picked the completed survivor by iterating `previouslyActiveIDs` — a `Set`, so iteration order was undefined. With two parallel runs finishing together, the `HKWorkout`'s metrics attached to an arbitrary one.

> **Fix (2026-07-09):** `decide` now picks the most recently started completed survivor, ties broken by id (landed with the H-tier session-lifecycle commit; pinned by `parallelCompletions_pickMostRecentlyStarted`).

### L2 — `requestSync` failures vanish  ·  **fixed** (logged)

`sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)` — a failed pull was dropped invisibly.

> **Fix (2026-07-09):** the send now logs its error. Deliberately no retry — the activation/reachability edges re-pull and the durable T2 slot delivers regardless.

### L3 — Decode-failure freeze is silent on the watch  ·  **fixed**

The schema-mismatch trap (see `DEVICE-COMMUNICATION.md` §6) is production-reachable after all: a user's phone app can update days before the watch app auto-updates, and every push in between fails to decode — silently.

> **Fix (2026-07-09):** `WatchConnectivityBridge.schemaMismatch` is set when a push fails to decode (cleared on the next good apply), and `ActiveWorkoutGateView` shows an "Update both apps to resume sync" banner while it's set — the freeze is now labeled instead of silent.

### L4 — Possible log-progress rollback from a late follower write  ·  open · Variant A refuted on device

> **Device result (2026-07-10, Variant A):** refuted — after the gap, the phone kept its higher completed-set count and the watch corrected *upward*. Read carefully, though: neither `WorkoutMergePlanner.merge` nor `ingestFromWatch` contains any progress guard — the merge is pure newest-`modTime`-wins per log — so what won was **timing**, not structure (most likely the phone's log ended up with the newer stamp by the time the queued watch write delivered). The mechanics remain permissive; the pinning test and the regressive-write detector log below are still worth adding, and **Variant B** (watch rest-expiry auto-advance instead of a manual swipe) is untested.

Both the driver and a follower cover persist auto-advance progress via whole-log newest-`updatedAt`-wins merge (not a monotonic max on progress). A follower resuming late from suspend could in theory regress a log if its delayed write's timestamp outranks the driver's more-advanced state before the durable echo catches it up. Unconfirmed on device.

Two caveats that shaped the deferral: the merge *mechanics* are provable without a device (a `WorkoutMergePlannerTests` case can pin that a stale-content/newer-stamp log wins), but whether real UI timing ever produces such a write is the device question. And the obvious fix — making `currentStateIndex` monotonic — is wrong: swiping back to Ready is a *deliberate* reset to 0 that must propagate, so a real fix has to distinguish "reset" from "stale echo" (per-log reset marker, or comparing set-entry contents).

#### Device test protocol

The merge runs **phone-side** (`SyncEngine.ingestFromWatch`), so the repro is: *the watch sends a semantically-behind write stamped newer than the phone's more-advanced one.* Three ingredients: a connectivity gap, divergence on both sides during the gap (phone further ahead; watch one step past its own last-known state), and the watch's write stamped **later**.

**Test rig** (created 2026-07-09): the **L4 Merge Test** split — orange, `testtube.2` icon, one exercise (Bench Press 5×8, rep-based so nothing but rests advances on its own), rest 45 s, auto-advance off — written directly into the iCloud container as `Splits/01KX513THZZ6RSYQ0ZA558BT5W.json`. It is ordinary user data; swipe-delete it in-app when done.

1. Start the workout from the phone's split picker; open Bench Press on **both** devices (both on Set 1's work page).
2. **Sever the link.** Phone-side Airplane Mode does **not** work (confirmed in practice, 2026-07-10) — modern iOS deliberately keeps Bluetooth (and often Wi-Fi) alive for the watch, and the Control Center BT tile explicitly preserves the watch link. Use one of these, in order of preference:
   - **A — Watch: Airplane Mode ON** (watch Control Center). Kills the watch's own BT + Wi-Fi radios; the phone stays fully usable.
   - **B — Phone: Settings → Bluetooth → Off *and* Settings → Wi-Fi → Off.** Must be the real switches in the Settings app, not the CC tiles (the tiles only disconnect, and WC falls back to Wi-Fi if either radio survives).
   - **C — Range separation:** with Wi-Fi off on the phone (Settings switch), walk the phone well out of Bluetooth range (another floor / far end of the home). Slowest to take effect but needs no settings on the watch.

   Whichever method you use, step 3's sanity check is the actual gate — don't trust the toggle, trust the check.
3. **Sanity-check the gap is real:** swipe a page on the phone — the watch must *not* follow. If it follows, the link is still up; do not proceed.
4. **Phone first — get ahead:** swipe through two full work→rest cycles (~2–3 sets recorded, stamped `t0`). Note the exact completed-set count.
5. Wait ~10 s, then **watch — one step:** swipe forward one page (less progress, newer stamp `t1 > t0`; the update queues via `transferUserInfo`). *Variant B (the original hypothesis): don't touch the watch — let its 45 s rest countdown expire on its own; the auto-advance records with a fresh stamp the same way.*
6. **Restore the link** (undo whatever you used in step 2 — Airplane Mode off / radios back on / walk back into range). Wait up to a minute — the queued update delivers, the phone merges, the echo pushes back.
7. **Read the phone's exercise screen / log list:**
   - **Confirmed:** the phone's completed-set count **drops** to the watch's lower number (and the echo drags the watch to the same rolled-back state). Phone-recorded set entries for the "lost" sets vanishing from the rest screen is the same signal.
   - **Refuted (for this path):** the phone keeps its higher count and the watch corrects *upward* — an existing guard won the race.

Observability: Console.app → iPhone → subsystem `dev.rzen.indie.Workouts` shows `phone-bridge` receiving the update, but there is no merge-outcome logging yet — the verdict is the UI diff. Offered but not yet added: a regressive-write detector log in `ingestFromWatch` (warn when an incoming log wins on `modTime` with a lower `currentStateIndex`) — worth keeping permanently as a grep-able smoking gun — plus the merge-planner pinning test above.

---

# 2026-07-18 — Live-run mirror & flow hand-off

Second-pass analysis prompted by a field incident (2026-07-17, Cardio flow routine: Warm Up → Main Circuit → Cool Off, all single-set duration exercises): run started on the phone, phone locked in pocket, watch ran the flow correctly — but ~5 min into the 30-min Main Circuit the phone was already counting down Main Circuit's terminal "Coming up: Cool Off" rest, then durably completed the phase early; both devices fought (dismiss ↔ resurrect) until manual swipes forced agreement.

Design context: both devices run the *same* local phase engine; only **human** transitions are broadcast as `LiveProgress` frames — auto-advances are reached independently off shared wall-clock anchors. Flow routines cross exercises via `RunFlowView.advance`, and the incoming exercise broadcasts one hand-off frame.

### H4 — Remote completion dismisses the flow instead of handing off; cross-log frames are ignored  ·  **fixed**

> **Fix (2026-07-18):** `RunFlowView` (both platforms) now follows a frame for a *different* log of the same flow workout by advancing to it — forward-only by plan order, and only into a still-runnable log, so a late-delivered frame for an earlier exercise can't yank the flow backwards. And `ExerciseProgressView`'s remote-resolve observer now hands off (`onAdvance(nextUnfinished)`) instead of dismissing when the current exercise completes remotely mid-flow; dismissal remains for skipped/last-exercise/non-flow cases.

Two halves of one gap. (a) `RunFlowView` filtered incoming frames to `currentLogID` only (phone `RunFlowView.swift:67`, watch `:54`) — the driver's hand-off frame for the *next* exercise was silently dropped, so a follower could only cross an exercise boundary via its own timers (which on the phone need background runtime). (b) When the peer's durable "exercise completed" write landed first, `.onChange(of: log?.status)` called `dismiss()` (phone `ExerciseProgressView.swift:520`, watch `:352`) — popping the whole run, tearing down the Live Activity, sending `liveEnded`, and re-entering later through the mirror cover (a host with different terminal behavior — M3's residual). Which path fired was a pure race between the local timer tick and the ingest of the peer's write inside the same background wake.

### H5 — The between-exercise rest completes a phase it never ran  ·  **fixed**

> **Fix (2026-07-18):** wall-clock plausibility gate on *automatic* completion of duration exercises (both platforms): an auto-fire earlier than `startedAt + sets × duration − slack` is provably wrong — the flow-terminal rest then self-repairs by jumping back to `computeResume()` instead of completing, and the Finish page's auto-Done holds at zero awaiting a human tap. Human Done taps are never gated.

The terminal "Coming up" rest fired `completeExercise()` purely because its countdown window elapsed — regardless of how the page was reached. For a **1-set** exercise any single forward page slip lands on the terminal page (work + 1), so one spurious transition (see L5) durably completed a 30-minute phase ~45 s later; the write then propagated to the watch, whose driver saw `completed` and popped, while its own re-records resurrected the log through the merge — the observed tug-of-war. Likely epicenter of the incident.

### M5 — Reconciliation dead zone across a single-set duration phase  ·  open (mitigated by H5)

Between "started" and "completed" of a 1-set duration exercise there is **no durable write** (`recordProgress` caps at `setCount − 1 = 0`) and **no frame** (all transitions are auto) — for a 30-min phase, a half-hour window in which nothing corrects a desynced peer. `repairFromDurable` can't help: it only jumps set boundaries, and a 1-set exercise has none. A lost hand-off frame (`sendMessage` is reachable-only; the backoff retry gives up after 5 attempts ≈ 15 s) leaves a late-waking follower anchored at local `now` — minutes of drift with no repair until the next human transition.

**Fix direction:** stage auto-advance frames too (the depth-1 latest-wins slot makes this one message per phase), and/or extend the durable repair to recompute the expected phase from `startedAt + duration` for duration exercises. H5's gate already bounds the worst outcome (early durable completion).

### M6 — `liveVersion` resets on app restart; the peer then drops every frame  ·  **fixed**

> **Fix (2026-07-18):** both bridges persist the version as a high-water mark in `UserDefaults` (stamped on send, raised on receive), so a relaunched app keeps outranking what the peer has already seen.

The frame sequence lived in the bridge instance (`PhoneConnectivityBridge.swift:39`, `WatchConnectivityBridge.swift:44`). If the sender's app was killed mid-run (watchdog, memory), its next frames restarted at v1 while the receiver's `lastAppliedVersion` for the run was higher — `isNewer` rejected every one, and since a pocketed phone makes no human transitions to bump the sender's counter back up, the mirror stayed dead for the rest of the run, silently.

### M7 — Phone-side absorbs lack the watch's strictly-newer guard  ·  **fixed**

> **Fix (2026-07-18):** the phone list absorb and the mirror cover absorb now require `incoming > doc.updatedAt`, matching the watch (`Workouts Watch App/Views/WorkoutLogListView.swift:115` and its rationale comment).

`WorkoutLogListView.swift:174` (phone) and `LiveRunCoverView.swift:92` re-seeded the working doc on *any* `updatedAt` change, including a regression (stale cache echo, late-delivered older file version). The next local save then re-stamped the stale content as newest — exactly the clobber the watch-side guard exists to prevent, but on the device that owns the durable write path.

### M8 — Context push can roll the watch cache back per-log  ·  open

`WatchCacheApplier` → `CacheMapper.upsertWorkout` applies the phone's copy with **no per-log staleness check** — the watch-side counterpart of H1's per-log merge exists only on the phone ingest path (`SyncEngine.ingestFromWatch`). The open doc is shielded by the strictly-newer absorb, but only at *workout* granularity: a push newer overall yet stale for one log regresses that log in the cache mid-run. Mostly self-healing (the phone's ingest of the watch's next write re-merges and re-pushes), but it can flap the watch UI and feeds L4's rollback mechanics.

**Fix direction:** per-log `modTime` guard in the watch apply (or full merge symmetry with the phone).

### L5 — Spurious pager write-backs are indistinguishable from human swipes  ·  open

Any `TabView` selection write-back not caused by a real swipe takes the `pageChangeCause == nil` branch in the page observer (both platforms) — recorded *and broadcast* as if the user swiped. One instance (the first-layout snap-to-0) is already guarded by `didRestorePage`, but a later relayout snap (foregrounding, page-count change) would broadcast a wrong page to the peer — and on a 1-set exercise any forward slip is the terminal page (see H5). H5's gate plus the new transition logging bound the damage; a structural fix (distinguishing UIKit write-backs from human interaction) is deferred.

### Diagnostics (added 2026-07-18)

Both platforms' `ExerciseProgressView` now log every page transition (old → new, cause, anchors), every applied remote frame (version/phase/set), durable repairs, remote-resolve hand-offs, and H5 gate trips under subsystem `dev.rzen.indie.Workouts`, category **`run-flow`** — the next incident should be attributable from Console.app instead of reconstructed.

### Recommended order (this pass)

1. **H4 + H5** — the incident killers (follow hand-offs; never auto-complete a phase that provably hasn't run). ✅
2. **M7, M6** — cheap correctness parity + restart survival. ✅
3. **Diagnostics** — `run-flow` logging on both platforms. ✅
4. **M5** — auto-advance keep-alive frames + duration-aware repair (design change; do deliberately).
5. **M8** — per-log guard in the watch context apply.
6. **L5** — structural cause detection for pager write-backs.

---

## Recommended order

The common theme of every HIGH: **the watch doesn't own its session lifecycle — it's a passive recipient of a fire-and-forget launch.** Fix in this order:

1. **H1** — watch self-starts + recovers its session (one coherent change in `WorkoutSessionManager` / coordinator / run-flow entry; collapses most of H2, part of M-tier).
2. **H2** — pending-config replay and/or post-start grace window in `SessionEndPlanner`.
3. **H3** — salvage the Health save on system-ended sessions.
4. **M1** — lock expiry on scene background; narrow the split lock.
5. **M4** — tiered payload degradation + Diagnostics surfacing.
6. Then M2, M3, and the L-tier as opportunity allows.
