Document device communication and bulletproofing findings

DEVICE-COMMUNICATION.md: exhaustive reference for every phone<->watch
exchange - triggers, wire datapoints, transports (T1-T4), and the internal
structures each side mutates - plus end-to-end sequences and failure modes.

BULLETPROOFING.md: ranked gap analysis (H1-H3 session lifecycle, M1-M4
stranding/stale-UI traps, L-tier) with code evidence, failure scenarios,
fix directions, and a recommended fix order.

Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
This commit is contained in:
2026-07-09 22:51:10 -04:00
parent c885d37d44
commit e6cea299dc
2 changed files with 270 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
# 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 T1T4, exchanges P1P9 / W1W4). Analysis date: 2026-07-09.
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 · open
`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 · open
`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 · open
`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 · open
`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 · open
`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 · open
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 · open
`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 · open
`SessionEndPlanner.decide` picks the completed survivor by iterating `previouslyActiveIDs` — a `Set`, so **iteration order is undefined** (`WorkoutSessionCoordinator.swift:49-51`). With two parallel runs finishing together, the `HKWorkout`'s metrics attach to an arbitrary one. Deterministic tie-break (e.g. most-recent `end`) would fix it.
### L2 — `requestSync` failures vanish · open
`sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)` (`WatchConnectivityBridge.swift`) — a failed pull is dropped with no retry. Low because P8 (activation/reachability edges) and the durable T2 slot cover the common cases.
### L3 — Decode-failure freeze is silent on the watch · open
The schema-mismatch trap (see `DEVICE-COMMUNICATION.md` §6) is dev-only today, but the watch could surface "can't read phone data — update both apps" instead of freezing silently; it already tracks `lastSyncDate` and logs the decode failure.
### L4 — Possible log-progress rollback from a late follower write · open · needs device testing
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.
---
## 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.
+161
View File
@@ -0,0 +1,161 @@
# Device Communication: iPhone ↔ Apple Watch
A low-level, exhaustive reference for **every** way the phone and the watch exchange data — what triggers each exchange, the exact datapoints on the wire, the transport/path, and the internal structures each device mutates as a result.
> Scope: only cross-device (phone↔watch) communication. Phone-internal iCloud/SwiftData mechanics and phone-only surfaces (widgets, Live Activity) are covered only where they *trigger* a cross-device exchange.
---
## 0. Mental model (read this first)
Three facts govern everything below:
1. **The watch never touches iCloud.** It has no ubiquity container, no `SyncEngine`, no CloudKit. Its entire world is a local SwiftData cache fed **only** by pushes from the phone. So **no datapoint ever travels phone↔watch "via iCloud."** Every cross-device byte rides one of four transports in §1.
2. **The phone is the sole writer of iCloud Drive.** The watch round-trips domain documents *through* the phone: watch → phone (WatchConnectivity) → iCloud file → phone cache → authoritative echo back to the watch.
3. **iCloud is a *source*, not a *path*.** A change that arrives on the phone from iCloud (edited on another device, or downloaded after being offloaded) lands in the phone cache and fires the same `onCacheChanged``pushAll` that a local edit does. So iCloud changes reach the watch, but always *re-transported* as a WatchConnectivity application-context push — never seen by the watch directly.
```mermaid
flowchart TB
iCloud[("iCloud Drive<br/>· other devices ·")]
subgraph phone ["iPhone — sole iCloud writer"]
direction TB
SE["SyncEngine"]
WL["WorkoutLauncher"]
Files["iCloud JSON files<br/>Splits · Workouts · Stubs"]
MO["MetadataObserver"]
CM["CacheMapper"]
Cache[("SwiftData cache")]
OCC["onCacheChanged"]
PB["PhoneConnectivityBridge"]
IFW["ingestFromWatch"]
SE --> Files --> MO --> CM --> Cache --> OCC --> PB
IFW --> SE
end
subgraph watch ["Apple Watch — no iCloud, no SyncEngine"]
direction TB
WB["WatchConnectivityBridge"]
WCA["WatchCacheApplier"]
WCache[("SwiftData cache")]
OWC["onWorkoutsChanged"]
Coord["WorkoutSessionCoordinator"]
WSM["WorkoutSessionManager<br/>· HKWorkoutSession ·"]
WB --> WCA --> WCache --> OWC --> Coord --> WSM
end
iCloud <--> SE
PB ==>|"T2 · updateApplicationContext<br/>splits · workouts · settings · edit locks"| WB
WSM ==>|"T3 / T4 · workoutUpdate<br/>one WorkoutDocument"| IFW
WL -.->|"T1 · startWatchApp(toHandle:)<br/>HKWorkoutConfiguration"| WSM
WB -.->|"T3 · requestSync"| PB
PB -.->|"T3 · live-run frame<br/>phone drives → watch mirrors"| WB
WB -.->|"T3 · live-run frame<br/>watch drives → phone mirrors"| PB
```
*Edge encoding: **thick solid** = durable, authoritative data transfer (T2 state push, T3/T4 forwarded edit); **dotted** = best-effort / ephemeral triggers (T1 launch handoff, T3 `requestSync`, T3 live-run mirror). `iCloud ↔ SyncEngine` is phone-internal — the watch has no edge to iCloud.*
---
## 1. Transports (the four physical channels)
| # | Channel | API | Direction | Delivery semantics | Used for |
|---|---------|-----|-----------|--------------------|----------|
| **T1** | HealthKit launch handoff | `HKHealthStore.startWatchApp(toHandle:)` | Phone → Watch | Best-effort, one-shot; foregrounds/launches the watch app | Launching the watch into a workout + handing it an `HKWorkoutConfiguration` |
| **T2** | WC application context | `WCSession.updateApplicationContext(_:)` | Phone → Watch | **Durable, latest-wins, single slot.** Delivered even if the watch is asleep/offline; the system holds the *latest* context and hands it over when the watch next activates (`receivedApplicationContext`) | Authoritative full state: splits, workouts, settings, edit locks |
| **T3** | WC message | `WCSession.sendMessage(_:replyHandler:errorHandler:)` | Both ways | **Immediate, reachable-only.** Fails if the peer isn't reachable | `requestSync`, watch→phone `workoutUpdate` (with T4 fallback), live-run frames |
| **T4** | WC user-info transfer | `WCSession.transferUserInfo(_:)` | Both ways | **Queued, guaranteed, FIFO.** Survives offline; delivered in order when possible | Fallback for watch→phone `workoutUpdate` when T3 isn't reachable |
Wire format for T2T4 lives in `Shared/Connectivity/WCPayload.swift`. Documents ride as JSON-encoded `Data` blobs (`DocumentCoder`, ISO-8601 dates); live-run `Date`s ride as **native plist values** to keep sub-second timer precision (`WCPayload.swift:83-94`).
---
## 2. Phone → Watch exchanges
| Event / trigger | Datapoints on the wire | Transport & path | Internal structures updated | Notes |
|---|---|---|---|---|
| **P1 — Start a workout** (the split picker is the sole start path — `SplitPickerSheet.start(with:)`, `WorkoutLogsView.swift:283-312`) | `HKWorkoutConfiguration { activityType (from the split), locationType = .indoor }` | **T1** (HealthKit, direct — *not* WatchConnectivity) | **Watch:** `WatchAppDelegate.handle(_:)``WorkoutSessionManager.start(with:)` creates `HKWorkoutSession` + `HKLiveWorkoutBuilder`, `session.startActivity`, `builder.beginCollection`, `isRunning = true`. **Phone:** requests workout-share auth inline (`WorkoutLauncher.swift:38`). | Best-effort; no-ops without HealthKit or a reachable watch. The running `HKWorkoutSession` is what makes watchOS re-foreground the app on every wrist raise. Starting also mints a `WorkoutDocument` → triggers **P3** as a separate exchange. |
| **P3 — Any phone cache mutation → full-state push** | `splits[]` (all `SplitDocument`, by `order`; each embeds its `ExerciseDocument`s) · `workouts[]` (every active [`notStarted`/`inProgress`] run **in full**, + ≤25 `completed` within 24 h; each embeds its `WorkoutLogDocument`s) · `restSeconds` · `doneCountdownSeconds` · `weightUnit` · `editingWorkoutID?` · `editingSplitID?` | **T2** (`pushAll``updateApplicationContext`), phone → watch | **Watch:** `WatchCacheApplier.apply` upserts every split/workout via `CacheMapper` **and prunes anything absent** → SwiftData cache; sets `lastSyncDate`; writes settings to `UserDefaults`; sets edit-lock state; fires `onWorkoutsChanged``WorkoutSessionCoordinator.reconcile()` (may end the `HKWorkoutSession`). `@Query` views refresh. | The workhorse. `onCacheChanged` fires on **every** cache change — local saves/deletes *and* remote iCloud deltas from `MetadataObserver` and `ingestFromWatch` (`SyncEngine.swift:290,301,307,323,378,465,725`). Active runs sent uncapped, so **"absent ⇒ no longer active"** — this is what prunes a discarded/deleted run and lets the coordinator end the session. Decode is **all-or-nothing**: one undecodable element → `nil``applyState` skips upsert/prune/reconcile entirely (schema-mismatch stale-cache trap, §5). Guarded on `activated && isPaired && isWatchAppInstalled`. |
| **P4 — Phone opens/closes an exercise editor** (`ExerciseView.swift:75,78`) | Same context as P3; salient field is `editingWorkoutID` (the workout's id, or absent = cleared) | **T2** (`setEditingWorkout` → immediate `pushAll`) | **Watch:** `editingWorkoutID` set; `ActiveWorkoutGateView` dims that run's row, blocks entry, and pops out if the user is inside it (`isLockedForEditing`). | Exclusive-edit lock: only one device drives a given run at a time, so the watch can't forward a stale optimistic write over a phone edit. Pushed immediately (doesn't wait for a cache change). |
| **P5 — Phone opens/closes a split detail/editor** (`SplitDetailView.swift:54,55`) | Same context; salient field is `editingSplitID` | **T2** (`setEditingSplit` → immediate `pushAll`) | **Watch:** `editingSplitID` set; any run whose `splitID` matches is parked (same UI treatment as P4). | Matches runs by source split rather than by run id. |
| **P6 — Settings changed** (rest / done-countdown / weight unit; `SettingsView.swift:188-190`) | Full P3 context; salient fields are `restSeconds`, `doneCountdownSeconds`, `weightUnit` | **T2** (`onChange``pushAll`) | **Watch:** `UserDefaults` updated — drives the watch's rest timer, the done countdown, and weight display units. | Settings otherwise ride along in every P3 push; these `onChange`s guarantee an immediate push even with no cache change. |
| **P7 — Live-run mirror frame** (phone is the *driver*; run-flow transitions in `RunFlowView`/`LiveRunCoverView`, wired at `WorkoutLogListView.swift:251-252`) | `LiveProgress { workoutID, logID, exerciseName, phase (ready/work/rest/finish), setIndex, setCount, detail, phaseStart, phaseEnd?, version }` | **T3** (`sendMessage`, reachable-only), staged depth-1 latest-wins with backoff retry + reconnect re-send | **Watch:** `applyIncomingLive``liveIncoming` (ephemeral, **never persisted**); presents a follower cover (`LiveRunCoverView`) or follows inline if `navigatedRunID` matches. `version` drops stale/out-of-order frames. | Ephemeral mirror — touches neither SwiftData nor HealthKit. Sent only on *human* transitions, not auto-advance ticks. `sendLiveEnded` (T3) tells the watch to stop mirroring. Bidirectional design — see W3. |
| **P8 — Activation / reachability regained** (`activationDidCompleteWith`, `sessionReachabilityDidChange` → reachable) | Full P3 context (+ re-flush of any staged live frame) | **T2** `pushAll` + **T3** `flushLive` | (Same as P3 / P7 on the watch) | Catch-up so a cold launch or a WC drop can't leave the watch stale. |
| **P9 — Phone answers a `requestSync`** (received from watch, `route``requestSyncType`) | Full P3 context | **T2** `pushAll` | (Same as P3) | Pull path: the watch asks, the phone re-pushes authoritative state. |
## 3. Watch → Phone exchanges
| Event / trigger | Datapoints on the wire | Transport & path | Internal structures updated | Notes |
|---|---|---|---|---|
| **W1 — Watch edits a workout** (check off a set, edit reps/weight, notes, complete/skip an exercise, complete the workout; `WorkoutLogListView.swift:124,166`, `LiveRunCoverView.swift:80`) | One full `WorkoutDocument` (the edited run, embedding all its `WorkoutLogDocument`s) | **T3** `sendMessage` if reachable, **else T4** `transferUserInfo`; on a T3 send *error* it also falls back to T4 | **Watch (first, optimistic):** `CacheMapper.upsertWorkout` into SwiftData + `save` + `onWorkoutsChanged` (coordinator reconcile). **Phone:** `route``SyncEngine.ingestFromWatch` → (a) tombstone/pending-delete **veto** → don't resurrect, re-push authoritative (P3); else (b) **per-log merge** (`WorkoutMergePlanner`) vs cache → if changed, `save(workout: merged)` → iCloud file → observer → cache → `onCacheChanged`**P3 echoes the merged doc back**. | Optimistic-then-reconcile. T3/T4 are **unordered**, and both devices may edit the same run, so the phone merges **per log** (newest per-log `updatedAt` wins; phone-authored `deletedLogIDs` tombstones resolve absent logs) — edits to different exercises commute regardless of delivery order (H1 fix). A no-op merge still re-pushes so the watch corrects. |
| **W2 — Watch ends the `HKWorkoutSession`** (coordinator decides `.finish`; `WorkoutSessionCoordinator.swift:107-118`) | The completed `WorkoutDocument` with `metrics` attached: `WorkoutMetrics { activeEnergyKcal, avgHeartRate, maxHeartRate, minHeartRate, totalVolume (from logs), hrZoneSeconds, healthKitWorkoutUUID, source = .watch, recordedAt }` | **HealthKit** (local save to Health on the watch) **+ T3/T4** to the phone (via the W1 path) | **Watch:** `WorkoutSessionManager.finishAndSave``session.end()`, `builder.endCollection` + `finishWorkout`**writes a real `HKWorkout` to Health** (HR, active energy); `isRunning = false`, session/builder cleared. Coordinator attaches metrics, then `bridge.update(workout:)` (W1). | **The only place a real `HKWorkout` is written to Health** (phone recording was made watch-only). Fires from durable cache state, not a view, so it runs even backgrounded. `.discard` variant: `session.end()` + `discardWorkout()` — nothing saved to Health, nothing forwarded. |
| **W3 — Live-run mirror frame** (watch is the *driver*; `WorkoutLogListView.swift:125-126`, `LiveRunCoverView.swift:56-58`) | `LiveProgress { … }` (identical shape to P7) | **T3** `sendMessage`, same staging/retry as P7 | **Phone:** `applyIncomingLive``LiveRunState.current` (ephemeral); a propped-up phone mirrors it (cover or inline). | Symmetric to P7 — whichever device drives sends, the other mirrors. (`LiveRunState`'s doc comment still says "watch-drives / phone-mirrors," but both directions are wired.) `sendLiveEnded` (T3) stops the phone mirror. |
| **W4 — Watch requests a sync** (`activate`, `activationDidCompleteWith`, `ActiveWorkoutGateView` appears with no active runs, manual **Refresh** button; `WatchConnectivityBridge.swift:94,321`, `ActiveWorkoutGateView.swift:72,112`) | `{ type: requestSync }` (no payload) | **T3** `sendMessage` (reachable-only) | **Phone:** answers with **P9** (`pushAll`). | Pull trigger. Skipped silently if the phone isn't reachable; the eager `receivedApplicationContext` read + P8 cover the cold-launch case. |
---
## 4. Internal-state reference (what lives where, and who mutates it)
### Apple Watch
| Structure | Type | Fed by | Notes |
|---|---|---|---|
| SwiftData cache (`Split`, `Workout`, `WorkoutLog`) | Rebuildable local cache | P3 (authoritative upsert **+ prune**) and W1 (optimistic self-edit) | The watch never originates a split/workout; pruning can't lose local-only data. This is the set `ActiveWorkoutGateView` renders. |
| `HKWorkoutSession` + `HKLiveWorkoutBuilder` | HealthKit runtime | Started by P1/T1; ended by W2 | `isRunning == true` is what makes watchOS re-foreground the app on wrist raise. Collects HR + active energy, accumulates `hrZoneSeconds`. |
| `WorkoutSessionCoordinator.previouslyActiveIDs` | Baseline set | Recomputed every `reconcile()` (each P3/W1 mutation + bootstrap) | The non-empty→empty transition of the active set is what ends the session. Empty baseline at launch is what makes the launch race safe. |
| `UserDefaults` (`restSeconds`, `doneCountdownSeconds`, `weightUnit`) | Settings | P3/P6 | Drives the watch's rest timer, done countdown, weight units. |
| Edit locks (`editingWorkoutID`, `editingSplitID`) | Published lock | P4/P5 | Park a run and block re-entry so the phone owns the edit. |
| Live-mirror consumer (`liveIncoming`, `mutedLogID`, `navigatedRunID`) | Ephemeral | P7 | Never persisted; drives the follower cover. |
### iPhone
| Structure | Type | Fed by | Notes |
|---|---|---|---|
| iCloud Drive JSON files (`Splits/…`, `Workouts/YYYY/MM/…`, `Stubs/…`) | **Source of truth** | `SyncEngine.save`/`delete`, including W1's merged echo | The phone is the sole writer. Deletes leave a `Tombstone` stub (veto against resurrection). |
| SwiftData cache | Rebuildable read-through | `MetadataObserver``CacheMapper` (one-way: files → observer → cache) | Never written directly by views; W1's inbound path writes a *file*, not the cache. |
| `HKHealthStore` auth | Workout-share scope | P1 (`startWatchApp` requires it) | The phone does **not** write `HKWorkout`s — that's watch-only (W2). |
| `LiveRunState` | Ephemeral mirror | W3 | Phone-side follower for a watch-driven run. |
| `editingWorkoutID` / `editingSplitID` | Published lock | P4/P5 (also re-sent in every `pushAll`) | Latest-wins context replaces wholesale, so they're included in every push to avoid clearing the lock prematurely. |
| Live-mirror staging (`pendingLive`, retry task) | Ephemeral | P7 | Depth-1 latest-wins; re-sent on reconnect. |
---
## 5. End-to-end sequences (putting it together)
**Start a workout on the phone**
1. `start()` mints a `notStarted` `WorkoutDocument`, `sync.save` writes the iCloud file.
2. **T1**: `launchWatchWorkout``startWatchApp` → watch launches, `WorkoutSessionManager.start` runs an `HKWorkoutSession` (`isRunning = true`).
3. File write → observer → phone cache → `onCacheChanged`**P3/T2** pushes the new run.
4. Watch upserts it; the run appears in `ActiveWorkoutGateView`.
**Complete a set on the watch**
1. Watch optimistically upserts the `WorkoutLog` change (SwiftData) and — **W1/T3** (or T4) — forwards the whole `WorkoutDocument`.
2. Phone `ingestFromWatch` → per-log merge → `save` → iCloud file → observer → cache → `onCacheChanged`.
3. **P3/T2** echoes the authoritative merged doc back; watch reconciles to it.
**Finish the workout**
1. The final completion flips the run to `.completed` (on whichever device drove it) and propagates (P3 or W1).
2. On the watch, `reconcile()` sees active set non-empty→empty with a completed survivor → **W2**: `finishAndSave` writes the `HKWorkout` to Health, `isRunning = false` → app stops re-surfacing.
3. Metrics-laden doc goes back to the phone (**W1**) → iCloud → echo.
**Delete a workout on the phone**
1. `sync.delete` writes a `Stubs/<id>.json` tombstone and removes the live file → cache row removed → `onCacheChanged`.
2. **P3/T2**: the run is now **absent** from `workouts[]``WatchCacheApplier` prunes it → active set empties → coordinator ends any running session.
3. If the watch had queued a stale edit for it (W1), `ingestFromWatch` sees the tombstone, **vetoes** resurrection, and re-pushes authoritative state.
---
## 6. Failure modes & gotchas
- **Schema-mismatch stale cache (the big one).** If the installed watch binary is older than the phone's, it can't decode the phone's `WorkoutDocument`/`SplitDocument` payloads. `WCPayload.decodeWorkouts` is all-or-nothing → `nil``applyState` **skips upsert, prune, and reconcile** (`WatchConnectivityBridge.swift`). The watch's cache freezes at the last successful decode; a running `HKWorkoutSession` can then never be ended (its terminating signal — active set → empty — can't arrive), so the app re-surfaces indefinitely. **Recovery:** rebuild/reinstall the watch app so both are on the same document schema (currently `WorkoutDocument` v5, `SplitDocument` v3). Cannot happen to end users — both apps ship at one version, and the `VersionedDocument` forward-gate covers files.
- **`updateApplicationContext` is single-slot.** Only the *latest* state is delivered; intermediate pushes coalesce. Fine because the context is authoritative full state, not a delta.
- **T3 is reachable-only.** Live frames (P7/W3) and `requestSync` (W4) silently no-op when unreachable; durable state (T2) and forwarded edits (W1's T4 fallback) still get through.
- **Unordered edit delivery.** T3 vs T4 can arrive out of order, and both devices may edit at once — handled by the per-log merge (W1), not by whole-document `updatedAt`.
- **The launch race is deliberately safe.** A phone-launched session can be `isRunning` before its run document has synced; `SessionEndPlanner.decide` guards with `!previouslyActiveIDs.isEmpty` so a not-yet-synced run is never mistaken for a finished one.
---
*Source anchors — Phone: `Workouts/Connectivity/PhoneConnectivityBridge.swift`, `Workouts/HealthKit/WorkoutLauncher.swift`, `Workouts/Sync/SyncEngine.swift`, `Workouts/Sync/WorkoutMergePlanner.swift`, `Workouts/Connectivity/LiveRunState.swift`. Watch: `Workouts Watch App/Connectivity/WatchConnectivityBridge.swift`, `Workouts Watch App/WorkoutSessionManager.swift`, `Workouts Watch App/WorkoutSessionCoordinator.swift`, `Workouts Watch App/WatchAppDelegate.swift`. Shared: `Shared/Connectivity/WCPayload.swift`, `Shared/Connectivity/LiveProgress.swift`.*