Skip to content

App data (W7): attributed strap-data delete, sleep edits on a re-added strap, launch-seed attribution - #44

Merged
ayiskakov merged 13 commits into
review/phase-2from
review/w07-fixes
Oct 5, 2026
Merged

ayiskakov merged 13 commits into
review/phase-2from
review/w07-fixes

Conversation

@ayiskakov

Copy link
Copy Markdown
Owner

Phase 2 fix batch for W7 (App data layer), from the review passes in the tracker PR and a /code-review xhigh of the batch. One commit per fix (W07-041 … W07-043 share one, since they touch one function). Each row in docs/review/workstreams/W07-app-data.md holds its V0, fix and V1.

Deleting a strap's data (S2 W07-002, and the follow-ups)

The engine writes every scored day under the canonical my-whoop-noop, whichever strap supplied it. A delete therefore had to leave that namespace alone whenever another device was registered, and the deleted strap's scores stayed on screen.

  • W07-002: scoreInputProvenance already names each scored day's source. The delete removes what is attributed to the device: the cells naming it, the day rows it owns, and their unattributed cells.
  • W07-035: a day row's owner is the source its strain cell names. When the deleted strap only supplied a legacy recovery snapshot on another source's row, just the snapshot's columns are cleared.
  • W07-036: the weekly VO₂max goes with the Fitness Age it was computed with.
  • W07-037: a workout the user entered stays.
  • W07-038: a night or detected workout goes when the device's heart rate outnumbers every other source's inside it.
  • W07-032: heart rate is probed by key lookups, not an index scan per session.
  • W07-040: one spelling of the canonical id.
  • W07-039: not a bug; a provenance delete repeated on purpose.
  • Limits: days scored before provenance existed (v29) stay. Nothing re-scores after a delete (W07-033 recorded).
  • Evidence:
    • An independent V2 found no realistic over-deletion (the W02-005 class).
    • V3 on a copy of the 2026-09-30 backup, with a second strap holding 7 days' provenance: only those days lost rows.
    • The delete takes 1.6 s on 10.9 M heart-rate rows.

Sleep edits on a re-added strap (S2 W07-023, and the follow-ups)

On an install whose active strap is not my-whoop, edited nights were read only from the active strap's computed sibling, while the engine banks them under the canonical id. Their edits never reached the day's totals, Rest or Charge.

  • W07-023 / W07-041: edited nights are read through the Sleep tab's own resolver (rawComputedReadIds), active strap first.
  • W07-042: every edited copy is healed.
  • W07-043: a night is re-staged from the strap with the most gravity inside it.

Launch-seed attribution (follow-ups to W07-016, #42)

  • W07-045: lastSyncedAt has a private setter.
  • W07-044: an unrecorded origin is logged as unattributed, never as a completed sync.
  • W07-046: the seeded refresh logs Seed: … with trigger=stored-seed, since a retried bootstrap can seed after Connected.
  • W07-047: the test drives the app's own refresh chain (AppModel.lastSyncRefreshes).

Verification

  • WhoopStore 556 and StrandTests 2,139 pass.
  • Strand (macOS) builds. NOOPiOS builds without the watch app; this Mac has no watchOS runtime.
  • doc_comment_lint is clean.
  • No strap run is needed for the delete path. The W07-046 wording rides the next export.

Stacked on the Phase 2 tracker PR (#43).

…t from the shared namespace (W07-002)

The engine writes every scored day under the canonical my-whoop-noop,
whichever strap supplied it. Since W02-005's follow-up, deleteAllData
left that namespace alone whenever another device was registered, so
deleting a re-added strap's data, or the canonical strap's with a second
one registered, kept every score computed from it on screen.

scoreInputProvenance already names the day owner for each scored day's
recovery, strain and Rest-point cells. deleteAttributedCanonicalRows runs
first in the delete transaction: each cell naming the device goes; a
day's row goes when no recovery or strain cell names another source,
together with the day's unattributed cells; cells naming another source
or an estimator stay. Sleeps and detected workouts carry no day key and
go when only the device's heart rate covers them; edited nights stay.

Days scored before provenance existed have no cells and stay.
…dex scan (W07-032)

W07-002's sleep and workout delete asked whether any other source had
heart rate inside each canonical session with a deviceId != predicate,
which cannot use the (deviceId, ts) key: SQLite scanned the whole
heart-rate index once per session, inside the delete's write
transaction. On 115 days of 1 Hz rows that was 25 to 53 s with offload
inserts and every store read waiting behind it.

The other heart-rate sources are now read once by a skip scan over the
key, and each session is probed with key lookups.
On an install whose active strap id is not my-whoop (any remove and
re-add, ryanbr#814), Repository's computed sibling is whoop-<uuid>-noop, but
the engine banks every detected night, and so every edit of one, under
the canonical my-whoop-noop. selfHealEditedStages read only the active
sibling, found no edited nights, and the day's totals, Rest and Charge
kept the detected bounds.

It now reads every id computedReadIds unions, returns one row per night
(the active strap's copy winning), and writes each re-staging back to
the namespace its row came from.
…y snapshot it supplied is cleared (W07-035)

W07-002 kept a mixed day's whole dailyMetric row whenever any recovery
or strain cell named another source. A row whose strain the deleted
strap scored, beside a recovery a legacy snapshot kept from an older
source, therefore kept the deleted strap's strain, sleep, HRV and
resting HR, and lost its source badge as its provenance went.

The row now belongs to the source its strain cell names (the pass that
scored it), else its recovery cell. The owner's delete removes the row;
when the deleted strap supplied only the legacy snapshot on another
source's row, just the snapshot's columns are cleared.
… (W07-036)

On a day only the deleted strap supplied, the sweep removed fitness_age
but kept vo2max_est, because the VO2max cell's provenance names its
estimator rather than a device. Both come from one Fitness Age
computation over the same week, so the strap's VO2max stayed on screen
beside a missing Fitness Age and kept feeding Body Age. The sweep now
treats the estimator-named cell as unattributed.
… under the canonical id (W07-037)

The attributed workout delete selected every workout under my-whoop-noop
with no source filter, so a legacy workout the user had entered there,
covered only by the deleted strap's heart rate, went with the detected
bouts while edited nights were spared. Only detected bouts (sport
"detected" or a computed source) are attributed now; a manual row stays.
…of another source (W07-038)

The attributed delete kept a canonical night or detected workout when
any other source had a single heart-rate sample inside it, so a sparse
second source (a chest strap, an imported activity file) protected every
overlapping session the deleted strap recorded. A session now goes when
the device's heart rate inside it outnumbers every other source's; an
even split stays.
…elete (W07-040)

W07-002 added canonicalComputedId as "my-whoop" + suffix beside a bare
"my-whoop" in exclusiveComputedSibling, and tested "the canonical
namespace is this device's alone" as exclusiveSibling !=
canonicalComputedId. One canonicalDeviceId constant now feeds both, and
the condition has a name. No behaviour change; the delete tests pass
unchanged. W07-039 (the repeated provenance delete) is kept on purpose.
…egistered strap (W07-041, W07-042, W07-043)

Three gaps W07-023 left in one function, fixed together:

- Edited nights came from the active and canonical siblings only, while
  the Sleep tab shows every registered strap's computed nights; two
  re-adds later a nap under the first re-added strap was shown but never
  scored. The heal now reads through rawComputedReadIds, the Sleep tab's
  resolver, active first.
- Only the copy scoring takes was healed, so a second edited copy the
  Sleep tab still shows went stale. Every copy is healed now.
- restageFromRaw read raw under the active strap only, so a night the
  old strap recorded was never re-staged and a partial active read could
  overwrite it. The raw now comes from the strap with the most gravity
  inside the night.
… writers (W07-045)

W07-016 documented that seedLastSynced and stampCompletedSync are the
only writers of lastSyncedAt, each setting its origin first, but the
property kept a public setter, so a direct write would leave the origin
stale. The setter is private now; SyncChipStateTests stamps through
stampCompletedSync.
… a completed sync (W07-044)

W07-016's sink defaulted an unrecorded origin to completedSync, so a
value whose writer it could not name would be logged as a completed
sync with trigger=post-offload, the claim W07-016 removed. Such a value
is now reported as unattributed, with its own neutral line and trigger.
…046)

The persisted last-sync time is seeded from bootstrapStore, which
beginBackfill retries on a later tick when the first open fails, for
example on a relaunch while the phone is locked. The "Launch:" line and
trigger=launch-seed could then appear after Connected, asserting a
launch the code did not see. The line now reads "Seed: …" and the
trigger is stored-seed.
…h chain (W07-047)

W07-016's test subscribed to $lastSyncedAt directly, not to the chain the
app runs (drop first, de-duplicate, 2-second debounce, origin read
after it), so a change that broke the chain would still pass. The chain
is now AppModel.lastSyncRefreshes(_:debounce:), which the app sinks and
the test drives: a seed followed by a HISTORY_COMPLETE settles once as a
completed sync, a seed alone as the stored seed.
@ayiskakov
ayiskakov merged commit f36d648 into review/phase-2 Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant