# 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).
