Skip to content

Apple Health records are keyed on the active strap id, so re-pairing strands and duplicates them #1503

Open

Description

@ryanbr

Every identity NOOP writes into Apple Health embeds the active strap id. That id is not durable — it
changes when a strap is re-paired or identified by serial — and when it changes, everything previously
written becomes unreachable to the code that maintains it.

Where the id gets into the key

HealthKitBridge is constructed with noopDeviceId: model.deviceId (StrandiOS/App/StrandiOSApp.swift),
which is the active strap id, and stamps it into each external identity:

Record HKMetadataKeyExternalUUID Site
daily metrics noop:<activeId>:<metric>:<day> HealthKitBridge.swift:749
sleep noop:<activeId>:sleep:<startTs> HealthKitBridge.swift:831
workouts noop:<activeId>:workout:<startTs> HealthKitBridge.swift:940

Why that strands records

Each of those three write paths reconciles the same way — delete first, then write:

HKQuery.predicateForObjects(from: HKSource.default()),
HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID, allowedValues: keys),

keys is always built from the id that is active now. So after the active id changes:

  • records written under the old id no longer match the delete predicate — they are never selected;
  • the write proceeds and adds fresh records under the new id;
  • the old ones stay in Apple Health permanently, and the bridge cannot clean them up later because it no
    longer knows the key they were written under.

The user sees duplicated days, sleeps and workouts in Apple Health after re-pairing a strap, with no way to
get rid of them from inside NOOP.

Not affected

The heart-rate export (HealthKitBridge.swift:886) deletes by date range plus HKSource.default()
rather than by external UUID, so it is immune to the id change and does not duplicate.

Android

Not affected. Its Health Connect clientRecordId carries no device id at all —
noop-workout-<startTs> (HealthConnectWriter.kt:318) — so a re-pair changes nothing about record
identity. Same structural asymmetry as #1501: Apple derives identity from a mutable id, Android does not.

Options

  1. Pin the key to the canonical id ("my-whoop"), which is stable in every state. Smallest change;
    still leaves a <id> segment that carries no information once it is constant.
  2. Drop the id segment entirely — noop:workout:<startTs> — matching what Android already does and has
    had no trouble with. Cleanest, and makes the key mean what it is: a natural key.

Either way the records already stranded under an old id need a migration, and by construction the bridge
cannot find them by key. A one-off sweep by HKSource.default() plus date range — the way the heart-rate
path already deletes — would reach them, but it would also delete records written under the current key,
so it has to run before a re-write rather than beside one.

Scope

Split out of #1501. Related root cause — an active strap id treated as durable — but a separate fix: this
one lives entirely in the export identity and is untouched by any of the options in that issue, which all
concern where rows are stored in NOOP's own database.

Activity

  1. added a commit that references this issue on Sep 6, 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