# Phone ↔ Watch Communication

How the iPhone app and the Watch app talk to each other. The governing rule: **the
iPhone is the sole writer of iCloud Drive**. The watch never touches iCloud — it keeps
a local SwiftData cache fed only by phone pushes, edits optimistically, and round-trips
every durable change through the phone.

> For a per-scenario walkthrough (phone/watch starts a split, drives an exercise, ends a
> run, edit locks, cold launch, …) as sequence + state diagrams — including where the
> watch's HealthKit session leaks — see [`WATCH-SYNC-SCENARIOS.md`](WATCH-SYNC-SCENARIOS.md).

Source files:

- Wire format: `Shared/Connectivity/WCPayload.swift`
- Phone side: `Workouts/Connectivity/PhoneConnectivityBridge.swift`
- Watch side: `Workouts Watch App/Connectivity/WatchConnectivityBridge.swift`
- Phone ingest: `SyncEngine.ingestFromWatch` (`Workouts/Sync/SyncEngine.swift`)
- Watch app launch: `Workouts/HealthKit/WorkoutLauncher.swift` + `Workouts Watch App/WorkoutSessionManager.swift`

## Channels at a glance

| Channel | Direction | WatchConnectivity API | Semantics | Payload |
|---|---|---|---|---|
| State push | Phone → Watch | `updateApplicationContext` | Latest-wins, delivered even if watch is asleep | All splits + active/recent workouts (JSON `Data` blobs), settings, edit locks |
| Workout update | Watch → Phone | `sendMessage`, falling back to `transferUserInfo` | Immediate when reachable; queued-guaranteed otherwise (unordered mix) | One `WorkoutDocument` |
| Sync request | Watch → Phone | `sendMessage` | Reachable-only, best-effort | Type marker only |
| Live-run mirror | Both ways | `sendMessage` only | Reachable-only + a depth-1 staged re-send on reconnect | `LiveProgress` frame / `liveEnded` marker |
| Watch app launch | Phone → Watch | — (HealthKit, not WC) | Best-effort | `HKWorkoutConfiguration` via `startWatchApp(toHandle:)` |

```mermaid
flowchart LR
    subgraph iPhone
        Views[SwiftUI views] --> SE[SyncEngine]
        SE -->|writes files| ICD[(iCloud Drive)]
        ICD -->|NSMetadataQuery deltas| SE
        SE -->|upserts| PC[(SwiftData cache)]
        SE -->|onCacheChanged| PB[PhoneConnectivityBridge]
        PB --- LRS[LiveRunState]
        WL[WorkoutLauncher]
    end

    subgraph Watch
        WB[WatchConnectivityBridge]
        WB -->|upsert + prune| WC[(SwiftData cache)]
        WC -->|"@Query"| WViews[Watch views]
        WViews -->|"update(workout:)"| WB
        WSM[WorkoutSessionManager]
    end

    PB -->|"application context: splits + workouts + settings + edit locks"| WB
    WB -->|"workoutUpdate / requestSync"| PB
    PB <-->|"live-run frames (ephemeral)"| WB
    WL -->|"HealthKit startWatchApp"| WSM
```

## 1. Phone → Watch: the state push

`PhoneConnectivityBridge.pushAll()` serializes the phone's cache into one
`updateApplicationContext` dictionary. Application context is **latest-state-wins**:
watchOS keeps only the newest dictionary and delivers it when the watch app runs, so
every push must be complete — a push that omitted a key would read as that state being
cleared.

**What's in it:**

- **Splits** — all of them, as `[SplitDocument]` JSON.
- **Workouts** — only what the watch can act on: every active run (in-progress /
  not-started, uncapped) plus up to 25 completed ones from the last ~24 h, as
  `[WorkoutDocument]` JSON.
- **Settings** — `restSeconds`, `doneCountdownSeconds`, `weightUnit` (from the phone's
  `UserDefaults`; the watch writes them into its own).
- **Edit locks** — `editingWorkoutID` / `editingSplitID`: while the phone has a workout
  or split open in an editor, the watch parks any matching run and blocks re-entry, so
  only one device drives a run at a time. Absent keys mean "not editing".

**When it fires:** on every `SyncEngine.onCacheChanged` (local edits *and* changes
arriving from iCloud), on session activation, on reachability restored, on a watch
`requestSync`, and immediately on any edit-lock change.

**How the watch applies it:** `WatchCacheApplier` upserts every document sent, then
**prunes** anything absent — the pushed sets are authoritative, which is how a delete
on the phone propagates (the deleted workout simply stops being sent). A payload that
fails to decode (phone and watch running different document schemas) is skipped
entirely — no upsert, no prune — so a bogus empty set can never wipe real rows.

## 2. Watch → Phone: the workout round trip

The watch edits its local cache **optimistically** (so its UI is instant), then
forwards the whole updated `WorkoutDocument` to the phone. Transport is
`sendMessage` when the phone is reachable, with a `transferUserInfo` fallback
(guaranteed, queued, survives the watch app dying) when it isn't or the send fails.
The two are **unordered** relative to each other, which is why the phone gates intake
by `updatedAt`.

`SyncEngine.ingestFromWatch` on the phone:

1. **Tombstone veto** — a workout deleted on the phone is never resurrected by a stale
   watch copy; the phone just re-pushes authoritative state so the watch drops it.
2. **Pending-delete veto** — same veto for a queued delete whose stub hasn't landed yet.
3. **`updatedAt` intake gate** — strictly newer than the cache is accepted; strictly
   older means the watch is behind (re-push state to correct it); equal is a
   duplicate/echo (ignored). Note: this arbitrates timestamps, not content — see the
   known limit documented at `ingestFromWatch` (durable fix would be per-log merge).
4. An accepted document is written to iCloud Drive and upserted into the cache, which
   fires `onCacheChanged` → `pushAll()` — so the watch always gets an authoritative echo.

```mermaid
sequenceDiagram
    participant WV as Watch view
    participant WB as WatchConnectivityBridge
    participant PB as PhoneConnectivityBridge
    participant SE as SyncEngine (phone)
    participant IC as iCloud Drive

    WV->>WB: 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)
    alt tombstoned or pending delete
        SE-->>PB: veto — onCacheChanged only
    else updatedAt strictly newer
        SE->>IC: write Workouts/YYYY/MM/id.json
        SE->>SE: upsert phone cache
        SE-->>PB: onCacheChanged
    end
    PB->>WB: updateApplicationContext (authoritative echo)
    WB->>WB: upsert + prune watch cache
```

A cold-starting watch sends `requestSync` (and re-applies the last received context
eagerly); the phone answers with a fresh `pushAll()`.

## 3. Live-run mirror (ephemeral, both directions)

While a run's exercise flow is open on one device, that device broadcasts
`LiveProgress` frames — workout/log IDs, exercise name, phase, set index/count, and a
**wall-clock phase anchor** — so the other device can follow along (the phone's
follower cover, or the watch's, unless that run is already open there or the user
dismissed it). `liveEnded` tells the peer to drop the follower.

This channel is deliberately *not* persistence:

- Frames go over `sendMessage` only (reachable-only); they are never written anywhere.
- Each side stages **one** pending frame (latest-wins) and re-sends it when
  reachability or activation returns — a newer frame or the terminal `liveEnded`
  replaces whatever is staged, so a re-send is never stale. Because frames carry an
  absolute wall-clock anchor, a late arrival self-corrects its timers.
- A send that *fails* while the peer is nominally reachable (no reachability edge to
  re-flush on) is retried with a short backoff, a handful of times per staged message.
- Receiving a frame that outranks the staged outbound one for the same run **drops the
  staged frame** — the peer has moved past it, and a reconnect re-send would yank the
  run backwards. Symmetrically, a delivery the staged frame outranks (late or
  version-collided) is ignored.
- Both sides stamp frames from a shared monotonic `version` (each bumps its counter
  past anything it receives), so either side can drop an out-of-order delivery. The
  sequence isn't collision-free — after a lost frame both devices can mint the same
  version — so every staleness comparison tie-breaks on the frame's wall-clock anchor
  (`LiveProgress.isNewer`): the later human action wins.
- **Durable repair**: if a frame is lost outright (retries spent, no reconnect), the
  transition's durable write still lands via its own channel. The run screens compare
  the absorbed doc's `currentStateIndex` against everything they've recorded or
  followed, and jump *forward* to the first unfinished set's work page — so a lost
  frame degrades to a briefly-stale page, never a stuck one.
- **Auto-advances are never sent** — both devices cross countdown boundaries
  independently off the shared deadline. Each crossing anchors the *next* phase at the
  finished phase's computed end, not at tick time, so a device whose ticker slept
  through the boundary (wrist-down throttling) shows the same next-phase timer as one
  that crossed on time — and a stack of missed boundaries fast-forwards itself, one
  tick per phase, silently (catch-up hops skip the haptics).
- Date anchors ride as native plist values, not JSON — `DocumentCoder` is ISO-8601,
  which would round off the sub-second precision the timers need.

## 4. Launching the Watch app (HealthKit, not WatchConnectivity)

An iPhone app cannot foreground its Watch app via WatchConnectivity. When a workout
starts on the phone, `WorkoutLauncher` uses the one sanctioned path — HealthKit's
`startWatchApp(toHandle:)` with an `HKWorkoutConfiguration` — which wakes the Watch
app; there, `WorkoutSessionManager` starts a matching `HKWorkoutSession` so the watch
stays foregrounded for the duration of the run. Best-effort: it silently tolerates no
paired watch, and the session is runtime-only — it plays no part in data sync.
