Repository navigation
Description
On Apple, the active strap id is used as though it were a durable key — for where a manual workout is
stored, and for the identity of everything written out to Apple Health. It isn't durable: it changes when a
strap is re-paired or identified by serial, and each thing keyed on it is stranded when it does. This is a design question about which id manual rows should live under — not a bug
fix — so it is filed rather than patched.
Split out of #1489, which fixed the Android half of #1488. Nothing here is a regression from that PR; all
of it predates both.
The divergence
The two platforms disagree about what "the strap source" is:
| "strap source" is | manual rows written to | engine reads | |
|---|---|---|---|
| Android | "my-whoop" (the seed) |
"my-whoop" |
"my-whoop" |
| Apple | the active strap id | the active id | the active id |
Each is internally coherent today. The trouble is that Apple's active id is not stable: importedReadIds
is [active, canonical] while a re-added strap is active, and collapses to [canonical] alone once
the active id returns to canonical.
Three symptoms (one split out)
1. An edit can stop being read. A manual row written under an active strap id is invisible once that id
changes — the workout, or an edit to it, disappears long after the fact with nothing linking cause to
effect. This is the Apple face of #1488. It differs from Android's only in timing: Android showed a stale
row immediately, Apple shows the right row until the id moves.
2. deleteWorkout can't find the row. Swift's WorkoutRow carries no deviceId, so the id is
reconstructed at the call site — as the active id. Deleting a manual row that lives anywhere else silently
does nothing, and the row returns on the next read. Any manual row predating a strap re-add is already in
this state.
3. Everything NOOP has written to Apple Health becomes unreachable — split out as #1503. Same root
cause, different surface: HealthKitBridge stamps the active strap id into every HKMetadataKeyExternalUUID
it writes, and reconciles by deleting only the keys built from the id active now. A re-pair strands every
record written before it and duplicates them. Listed here because it is the same mistake in a different
place; fixed independently of everything below.
Why the obvious fix is wrong
"Write manual rows to the canonical id" looks right — canonical is in the read union in both states — but
it only moves the writes. An enumeration of every Apple site that reads, writes or deletes a workout by
deviceId:
| Site | Reads | Under a canonical-write rule |
|---|---|---|
Repository.workoutsUnion, CsvExport, DebugDataDiagnostics |
read-id union / explicit list | fine |
IntelligenceEngine:1388 — #107 cross-source dedup guard |
active id + apple-health |
manual rows invisible → detected bouts stop being suppressed, duplicate sessions return |
IntelligenceEngine:2207 — rescoreManualWorkouts |
active id | silently no-ops → manual workouts never receive HR, strain or calories |
HealthKitBridge:931 |
injected noopDeviceId |
same assumption |
LocalAccessCore:504 |
injected deviceId |
same assumption |
The comment at IntelligenceEngine:1388 states the assumption outright: "the strap source carries
imported WHOOP rows AND manual / re-labelled rows (both written under deviceId)". Moving writes without
moving reads leaves Apple coherent around neither id, and the second row of that table is the sharper one:
WorkoutsView calls analyzeRecent() immediately after a save specifically so a new manual workout is
scored right away.
Options
- Move Apple wholesale to canonical, analytics included — the engine, the write-back and the local
access reads all move too. Matches Android. Touches scoring, so it needs its own hardware validation. - Keep writing to the active id and migrate rows off a retired id when the active id changes. Smaller
blast radius, no analytics involvement, but needs somewhere durable to hang the migration. - Put
deviceIdon Swift'sWorkoutRow, so no site reconstructs it. Addresses the root cause — the
same assumption is currently duplicated across every call site with no single point of truth, which is
why Android is structurally immune and Apple is not. Largest change; would make symptom 2 disappear
outright.
None of the three fixes symptom 3, which is why it is #1503 rather than a bullet here. That key is
built from the bridge's injected id, not from where the row is stored, so moving rows to canonical (1) or
putting deviceId on the row (3) both leave it exactly as it is. Fixing this issue does not fix that one,
and fixing that one does not fix this.
No preference recorded yet. Worth noting that (3) is what Android already does, and Android has had none of
these problems.
Activity
Compare the whole workout key when an edit replaces a row (#1488)
Compare the whole workout key when an edit replaces a row (#1488)
Compare the whole workout key when an edit replaces a row (#1488) (#1489
[-]Apple: manual workouts are stored under the active strap id, and the paths that read them disagree[/-][+]Apple: the active strap id is used as a durable key, and it is not one[/+]Compare the whole workout key when an edit replaces a row (ryanbr#1488)…