# Phone ↔ Watch — Scenario Diagrams Companion to [`WATCH-SYNC.md`](WATCH-SYNC.md) (the channel reference). This file maps **every phone↔watch interaction** as a transition/sequence diagram, so the current protocol is fully legible before we redesign it. The governing rule is unchanged: **the iPhone is the sole writer of iCloud Drive.** The watch keeps a local SwiftData cache fed only by phone pushes, edits optimistically, and round-trips every durable change through the phone. ## Legend **Participants** (consistent across every diagram): | Alias | Type | Source | |---|---|---| | Phone UI | SwiftUI views | `WorkoutLogsView`, `WorkoutLogListView`, `ExerciseProgressView`, `ExerciseView`, `SplitDetailView` | | SyncEngine | phone persistence/orchestrator | `Workouts/Sync/SyncEngine.swift` | | iCloud | JSON documents (source of truth) | `iCloud.dev.rzen.indie.Workouts` | | PhoneBridge | WatchConnectivity (phone) | `PhoneConnectivityBridge.swift` | | WatchBridge | WatchConnectivity (watch) | `WatchConnectivityBridge.swift` | | Watch cache | watch SwiftData (read-through) | fed only by `WatchCacheApplier` | | Watch UI | SwiftUI views | `ActiveWorkoutGateView`, `WorkoutLogListView`, `ExerciseProgressView`, `LiveRunCoverView` | | Watch session | `HKWorkoutSession` holder | `WorkoutSessionManager.swift` | | watchOS | OS / HealthKit | app launch + session runtime | **Transports** (named in every message so the reliability is explicit): | Transport | API | Semantics | |---|---|---| | `appContext` | `updateApplicationContext` | Phone → Watch only. Latest-**state**-wins single slot; delivered even when the watch is asleep. Every push is the *whole* authoritative state. | | `sendMessage` | `sendMessage` | Bidirectional, **reachable-only**, immediate, not queued. | | `transferUserInfo` | `transferUserInfo` | Watch → Phone fallback. Queued, guaranteed, survives app death — but **unordered** relative to `sendMessage`. | | `startWatchApp` | HealthKit `startWatchApp(toHandle:)` | Phone → watchOS, **not** WatchConnectivity. The only way an iPhone app can foreground its watch app. | --- ## State machines ### Workout status (shared, derived from logs) `WorkoutDocument.recomputeStatusFromLogs()` is the single source of the rule; every screen that mutates a log calls it. A log is *resolved* when `completed` **or** `skipped`. ```mermaid stateDiagram-v2 [*] --> notStarted: phone mints run on split start notStarted --> inProgress: any log started inProgress --> completed: all logs resolved — end stamped inProgress --> notStarted: all logs reset to notStarted completed --> inProgress: a completed log reopened — end cleared inProgress --> [*]: discarded (tombstone) notStarted --> [*]: discarded (tombstone) completed --> [*]: pruned from watch after ~24h ``` Only `inProgress` / `notStarted` runs are "active"; a `completed` run still rides in the push (recent set, ≤25, ~24h) but drops off the watch's active list. ### Watch HKWorkoutSession lifecycle The session is what grants the watch app foreground runtime. **This is where the current design is brittle** — see the note below the diagram. ```mermaid stateDiagram-v2 [*] --> idle idle --> running: watchOS launch → WatchAppDelegate.handle(config) → start() running --> running: second start() ignored (idempotent) running --> ended: finishAndSave() — active list emptied, a completed run found running --> ended: discard() — active list emptied, no completed run running --> ended: system/error (didChangeTo .ended/.stopped, or didFailWithError) ended --> idle: clear() ``` > **The only app-driven exit from `running` is `ActiveWorkoutGateView`'s > `onChange(of: activeWorkouts)` firing on a non-empty → empty transition.** That > transition must be *witnessed by a mounted, foregrounded gate view*. When a run is > ended from the **phone** while the watch app is backgrounded (kept alive only by the > session) or was torn down and rebuilt, the `onChange` baseline is already empty, so it > never fires — the session stays `running` and watchOS keeps re-foregrounding the app. > See scenarios S4/S6 and the closing section. --- ## Scenarios ### S1 — Phone starts a split Two **independent** effects fire: a durable state push (WatchConnectivity) *and* an out-of-band HealthKit launch. They race; neither waits on the other. ```mermaid sequenceDiagram participant PUI as Phone UI participant SE as SyncEngine participant ICD as iCloud participant PB as PhoneBridge participant OS as watchOS participant WS as Watch session participant WB as WatchBridge participant WUI as Watch UI PUI->>SE: save(workout — notStarted run) SE->>SE: upsert cache + context.save SE-->>ICD: enqueue file write (async) SE->>PB: onCacheChanged PB->>WB: appContext (splits + workouts + settings + locks) PUI->>OS: launchWatchWorkout → startWatchApp(config) OS->>WS: handle(config) → start() WS->>WS: HKWorkoutSession.startActivity — foreground runtime granted WB->>WB: upsert + prune cache WB->>WUI: @Query → run appears in ActiveWorkoutGateView opt list still empty at launch WUI->>WB: requestSync WB->>PB: sendMessage(requestSync) PB->>WB: appContext (fresh pushAll) end ``` If other runs are already active, the phone first prompts and may `endKeepingProgress` them (see S6) before this `start`. ### S2 — Phone drives / starts an exercise The **live phase** (Ready→Work→Rest→Finish) and the **durable set data** travel on different channels: ephemeral frames over `sendMessage`, persisted state over `appContext`. ```mermaid sequenceDiagram participant PUI as Phone UI participant SE as SyncEngine participant PB as PhoneBridge participant WB as WatchBridge participant WUI as Watch UI Note over PUI: user advances a phase PUI->>PB: onLive(frame) → sendLiveProgress PB->>WB: sendMessage(liveProgress, version bumped) WB->>WUI: applyIncomingLive → follower cover (unless open/muted) Note over PUI: user completes a set PUI->>SE: onChange → save() (recompute status, persist) SE->>PB: onCacheChanged → pushAll PB->>WB: appContext (authoritative workout state) WB->>WB: upsert cache — durable set data lands Note over PUI: user leaves the flow PUI->>PB: onLiveEnded → sendLiveEnded PB->>WB: sendMessage(liveEnded) WB->>WUI: endIncomingLive → drop follower ``` If the watch is unreachable, the `liveProgress`/`liveEnded` frame is **staged** (depth 1, latest-wins) and re-sent on reconnect; a send that *fails* while reachable is retried with a short backoff. If the frame is lost anyway, the durable set data still lands via `appContext`, and the watch's open run screen jumps forward to match it (durable repair) — so a lost frame degrades to a briefly-stale page, never a stuck one. ### S3 — Watch drives / starts an exercise Symmetric to S2, but the durable write must round-trip through the phone (sole writer), so it uses the `workoutUpdate` channel with a `transferUserInfo` fallback. ```mermaid sequenceDiagram participant WUI as Watch UI participant WB as WatchBridge participant PB as PhoneBridge participant SE as SyncEngine participant ICD as iCloud participant PUI as Phone UI Note over WUI: user advances a phase WUI->>WB: onLive(frame) → sendLiveProgress WB->>PB: sendMessage(liveProgress, version bumped) PB->>PUI: LiveRunState.apply → follower cover Note over WUI: user completes a set WUI->>WB: onChange → update(workout doc) WB->>WB: optimistic upsert into watch cache alt phone reachable WB->>PB: sendMessage(workoutUpdate) else unreachable or send failed WB->>PB: transferUserInfo(workoutUpdate) — queued end PB->>SE: ingestFromWatch(doc) SE->>ICD: write file (if updatedAt strictly newer) SE->>PB: onCacheChanged → pushAll PB->>WB: appContext (authoritative echo) Note over WUI: user leaves the flow WUI->>WB: onLiveEnded → sendLiveEnded WB->>PB: sendMessage(liveEnded) PB->>PUI: LiveRunState.end → drop follower ``` Recovery is symmetric to S2: a failed `liveProgress`/`liveEnded` send retries with backoff, and the phone's follower cover runs the same **durable repair** — when the `WorkoutDocument` echoed back over `appContext` shows completion beyond what the cover has followed, it jumps forward — so a lost watch→phone frame is briefly-stale, not stuck. ### Live-mirror delivery invariants (apply to S2 ‖ S3) The ephemeral channel is `sendMessage`-only (reachable-only), and phone↔watch reachability drops exactly when the driving device's wrist is down. Commit `8cbe078` hardened it with four invariants — full detail in [`WATCH-SYNC.md` §3](WATCH-SYNC.md): - **Retry with backoff** — a send that *fails while reachable* retries (exponential, ≤5×) rather than waiting for a reachability edge that may never come mid-workout. (`scheduleLiveRetry`, both bridges.) - **Anti-rollback arbitration** — receiving a frame that outranks the locally *staged* outbound one drops the staged frame, so a reconnect re-send can't yank the run backward; a delivery the staged frame outranks is ignored. (`applyIncomingLive`.) - **Version + anchor ordering** — the shared per-run `version` isn't collision-free (after a lost frame both sides can mint the same number), so every staleness check tie-breaks on the wall-clock `phaseStart` — `LiveProgress.isNewer`, pinned by `LiveProgressOrderingTests`. - **Durable repair** — the safety net: if a frame is lost outright, the transition's durable write still lands (S2 via `appContext`, S3 via the `workoutUpdate` round-trip), and the open run screen jumps forward to the first unfinished set. (`repairFromDurable`, both run screens.) > The last invariant is the template for the session-end fix below: **reconcile against > authoritative data; don't depend on a transient signal or a mounted view.** The mirror > now has that safety net — the session lifecycle (S4/S6/S7) still does not. ### S4 — Phone ends an exercise (and it completes the run) A set/exercise completion is the S2 persist path. The interesting case is the **last** exercise: the run flips to `completed`, drops off the watch's active list, and *should* end the watch session — but that end is the fragile part. ```mermaid sequenceDiagram participant PUI as Phone UI participant SE as SyncEngine participant PB as PhoneBridge participant WB as WatchBridge participant WGate as Watch gate view participant WS as Watch session PUI->>SE: complete last exercise → save() SE->>SE: recomputeStatusFromLogs → completed (end stamped) SE->>PB: onCacheChanged → pushAll PB->>WB: appContext (run now completed — leaves active set) WB->>WB: upsert + prune → activeWorkouts empties WB->>WGate: @Query update alt gate view mounted AND foregrounded to witness the transition WGate->>WS: onChange (non-empty → empty) → finishAndSave() WS->>WS: session.end() → save HKWorkout, capture metrics WGate->>WB: update(workout + metrics) → back to phone (S3 tail) else watch backgrounded / view rebuilt with already-empty list Note over WGate,WS: onChange never fires → session stays running → app keeps re-foregrounding (BUG) end ``` ### S5 — Watch ends an exercise (and it completes the run) The same completion, but originated **on the watch**. This path reliably ends the session, because the gate view is mounted and foregrounded on the watch exactly while the user finishes there — the contrast that pins down the S4/S6 bug. ```mermaid sequenceDiagram participant WUI as Watch UI participant WGate as Watch gate view participant WS as Watch session participant WB as WatchBridge participant PB as PhoneBridge participant SE as SyncEngine WUI->>WB: complete last exercise → update(workout completed) WB->>WB: optimistic upsert → local run flips to completed WB->>WGate: @Query → activeWorkouts empties WGate->>WS: onChange (non-empty → empty) → finishAndSave() WS->>WS: session.end() + save HKWorkout, capture metrics WGate->>WB: update(workout + metrics) WB->>PB: sendMessage / transferUserInfo (workoutUpdate) PB->>SE: ingestFromWatch → persist → pushAll echo ``` ### S6 — Phone ends the whole workout early ("Save Workout" / start-new-split) `endKeepingProgress()` marks every unfinished log `skipped`, so the recompute resolves the run to `completed`. Downstream, the watch-session outcome is the **same fragile path as S4** — this is the exact flow the user hit ("when the split ends, the watch app keeps coming back up"). ```mermaid sequenceDiagram participant PUI as Phone UI participant SE as SyncEngine participant PB as PhoneBridge participant WB as WatchBridge participant WGate as Watch gate view participant WS as Watch session Note over PUI: Save Workout button, or picking a new split with runs active PUI->>SE: endKeepingProgress() → save() SE->>SE: unfinished logs → skipped; recompute → completed SE->>PB: onCacheChanged → pushAll PB->>WB: appContext (run completed — leaves active set) WB->>WB: upsert + prune → activeWorkouts empties alt gate view alive to witness transition WGate->>WS: onChange → finishAndSave() → session.end() else not witnessed (the common case here) Note over WGate,WS: session never ends → watch app resurfaces on every wrist raise (BUG) end ``` ### S7 — Phone discards a workout A soft delete: tombstone written, live file removed. The watch prunes the run and, on witnessing the emptied list, `discard()`s the session (no completed run exists, so nothing is saved to Health). ```mermaid sequenceDiagram participant PUI as Phone UI participant SE as SyncEngine participant ICD as iCloud participant PB as PhoneBridge participant WB as WatchBridge participant WGate as Watch gate view participant WS as Watch session PUI->>SE: delete(workout) SE->>ICD: write Stubs/id.json (tombstone) + remove live file SE->>PB: onCacheChanged → pushAll PB->>WB: appContext (workout absent from sets) WB->>WB: prune → activeWorkouts empties WB->>WGate: @Query update WGate->>WGate: endSession — no completed run found WGate->>WS: discard() → session.end(), data thrown away Note over WGate,WS: same "must witness the transition" fragility as S4/S6 ``` ### S8 — Edit lock (phone opens an editor) An exclusive-edit lock, pushed immediately so it doesn't wait on a cache change. The watch parks the matching run and blocks re-entry so the two devices never drive the same run at once. ```mermaid sequenceDiagram participant PUI as Phone editor participant PB as PhoneBridge participant WB as WatchBridge participant WGate as Watch gate view PUI->>PB: onAppear → setEditingWorkout(id) / setEditingSplit(id) PB->>WB: appContext (editing id set) — pushed immediately WB->>WGate: park run (isLockedForEditing) + pop out if inside it Note over WGate: row dimmed "Editing on iPhone…", re-entry blocked PUI->>PB: onDisappear → setEditing…(nil) PB->>WB: appContext (lock cleared — absent key means not editing) WB->>WGate: run re-enabled ``` `ExerciseView` drives `setEditingWorkout`; `SplitDetailView` drives `setEditingSplit` (matched against a run's `splitID`). ### S9 — Watch cold launch / requestSync On real hardware `WCSession` activation is async, so the eager read sees an empty dictionary; the delegate re-applies once activation completes. ```mermaid sequenceDiagram participant WB as WatchBridge participant PB as PhoneBridge participant WUI as Watch UI Note over WB: activate() at launch WB->>WB: applyReceivedContext (last context — may be empty on cold launch) WB->>PB: sendMessage(requestSync) Note over WB: activationDidComplete (async on device) WB->>WB: applyReceivedContext again (now valid) WB->>PB: requestSync again PB->>WB: appContext (fresh pushAll) WB->>WUI: @Query populates ``` ### S10 — Stale watch push vetoed The phone gates every inbound `workoutUpdate` so a stale or out-of-order watch copy can never resurrect a deleted run or roll back a newer edit. ```mermaid sequenceDiagram participant WB as WatchBridge participant PB as PhoneBridge participant SE as SyncEngine WB->>PB: workoutUpdate (stale copy) PB->>SE: ingestFromWatch(doc) alt tombstone or pending-delete exists SE->>PB: veto (no write) → onCacheChanged PB->>WB: appContext (authoritative — run absent) WB->>WB: prune stale run else updatedAt older than cache SE->>PB: re-push (no write) → onCacheChanged PB->>WB: appContext (corrects the watch) else updatedAt equal (echo) Note over SE: ignored — duplicate end ``` --- ## What actually ends the watch session (and why it leaks) Ending the run is the one operation whose reliability differs sharply by *where it originates*: | Scenario | Originates | Ends watch session? | Why | |---|---|---|---| | S5 — watch completes last exercise | Watch | **Reliable** | Gate view is mounted + foregrounded on the watch, so it witnesses `activeWorkouts` empty and calls `finishAndSave()`. | | S4 — phone completes last exercise | Phone | **Unreliable** | Depends on the *watch's* gate view being alive to witness the emptied list. | | S6 — phone "Save Workout" / new split | Phone | **Unreliable** | Same as S4 — the reported bug. | | S7 — phone discards | Phone | **Unreliable** | Same fragility; `discard()` instead of `finishAndSave()`. | **Root cause.** Ending the `HKWorkoutSession` is a *lifecycle/data* concern, but it is implemented as a *view-level side effect* — `ActiveWorkoutGateView.onChange(of: activeWorkouts)`. When a run ends from the phone, the watch app is typically backgrounded (kept alive only by the still-running session) or gets rebuilt by watchOS; in either case the non-empty → empty transition is never observed by a live view, `finishAndSave()` / `discard()` never run, and the session stays `running`. A running session is precisely what makes watchOS keep re-foregrounding the app — so it "keeps coming back up even if you close it." There is **no** protocol-level "the run is over" signal today: the watch *infers* the end from the authoritative set shrinking, and the inference is bound to a UI observer. Commit `8cbe078` fixed the analogous fragility in the **ephemeral mirror** by adding a durable safety net (`repairFromDurable` reconciles the open run screen against the authoritative document instead of trusting the transient frame). The session lifecycle needs the same move one layer down: drive `session.end()` from the authoritative data apply, in a long-lived coordinator rather than a mounted view. The concrete plan is in [`PLAN-watch-session-end.md`](PLAN-watch-session-end.md).