The decode-failure freeze (BULLETPROOFING.md L3) is production-reachable
after all - a phone app can update days before the watch app
auto-updates, and every push in between fails to decode, silently
freezing the watch at its last good sync. The bridge now tracks
schemaMismatch (set on a failed decode, cleared on the next good apply)
and ActiveWorkoutGateView shows an "Update both apps to resume sync"
banner while it's set.
Also give requestSync an error handler that logs the dropped pull (L2;
deliberately no retry - the activation/reachability edges re-pull), and
sync DEVICE-COMMUNICATION.md to the post-bulletproofing reality (T1 as
optimization, session recovery, degraded pushes, surfaced mismatch).
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
@@ -99,13 +99,17 @@ The live-mirror cover (`LiveRunCoverView`, both platforms) is not a passive disp
> **Fix (2026-07-09):** `decide` now picks the most recently started completed survivor, ties broken by id (landed with the H-tier session-lifecycle commit; pinned by `parallelCompletions_pickMostRecentlyStarted`).
`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.
`sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)` — a failed pull was dropped invisibly.
### L3 — Decode-failure freeze is silent on the watch · open
> **Fix (2026-07-09):** the send now logs its error. Deliberately no retry — the activation/reachability edges re-pull and the durable T2 slot delivers regardless.
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.
### L3 — Decode-failure freeze is silent on the watch · **fixed**
The schema-mismatch trap (see `DEVICE-COMMUNICATION.md` §6) is production-reachable after all: a user's phone app can update days before the watch app auto-updates, and every push in between fails to decode — silently.
> **Fix (2026-07-09):** `WatchConnectivityBridge.schemaMismatch` is set when a push fails to decode (cleared on the next good apply), and `ActiveWorkoutGateView` shows an "Update both apps to resume sync" banner while it's set — the freeze is now labeled instead of silent.
### L4 — Possible log-progress rollback from a late follower write · open · needs device testing
@@ -77,7 +77,7 @@ Wire format for T2–T4 lives in `Shared/Connectivity/WCPayload.swift`. Document
| 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. |
| **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. T1 is a *fast-launch optimization*, not the only session source: the watch self-starts a session from any reconcile that finds an active run with none running, and recovers a crash-orphaned one at launch (BULLETPROOFING.md H1). |
| **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. |
@@ -104,7 +104,7 @@ Wire format for T2–T4 lives in `Shared/Connectivity/WCPayload.swift`. Document
| 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`. |
| `HKWorkoutSession` + `HKLiveWorkoutBuilder` | HealthKit runtime | Started by P1/T1**or self-started by the coordinator** (active run with no session); recovered at launch after a crash/reboot; ended by W2 (or salvaged into a Health save on a system-driven end) | `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. |
| Edit locks (`editingWorkoutID`, `editingSplitID`) | Published lock | P4/P5 | Park a run and block re-entry so the phone owns the edit. |
@@ -150,8 +150,8 @@ Wire format for T2–T4 lives in `Shared/Connectivity/WCPayload.swift`. Document
## 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.
- **Schema-mismatch stale cache.** 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. This *is* production-reachable (the phone app can update before the watch app auto-updates), so the state is surfaced: `schemaMismatch` drives an "Update both apps to resume sync" banner in `ActiveWorkoutGateView`. **Recovery:**update/rebuild the watch app so both are on the same document schema (currently `WorkoutDocument` v5, `SplitDocument` v3); 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. A push that fails outright (realistically payload-too-large) retries with the recently-completed tail dropped, so an oversized context degrades instead of freezing the watch.
- **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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.