Skip to content

Apple: the active strap id is used as a durable key, and it is not one #1501

Open

Description

@ryanbr

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

  1. 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.
  2. 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.
  3. Put deviceId on Swift's WorkoutRow, 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

  1. changed the title [-]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[/+] on Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions