Sequence and state diagrams for every phone↔watch interaction (splits, exercise drive, run end, edit locks, cold launch, session lifecycle), companion to WATCH-SYNC.md's channel reference. Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
19 KiB
Phone ↔ Watch — Scenario Diagrams
Companion to 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.
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.
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
runningisActiveWorkoutGateView'sonChange(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, theonChangebaseline is already empty, so it never fires — the session staysrunningand 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.
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.
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.
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:
- 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
versionisn't collision-free (after a lost frame both sides can mint the same number), so every staleness check tie-breaks on the wall-clockphaseStart—LiveProgress.isNewer, pinned byLiveProgressOrderingTests. - Durable repair — the safety net: if a frame is lost outright, the transition's
durable write still lands (S2 via
appContext, S3 via theworkoutUpdateround-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.
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.
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").
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).
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.
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.
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.
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.