Skip to content

App data (W7): a launch's seeded refresh is not logged as a completed sync - #42

Merged
ayiskakov merged 34 commits into
mainfrom
review/w07-launch-seed
Oct 8, 2026
Merged

ayiskakov merged 34 commits into
mainfrom
review/w07-launch-seed

Conversation

@ayiskakov

Copy link
Copy Markdown
Owner

Whole-project review, W7, from the owner's 2026-09-30 strap export (recorded in #41). Two commits: the fix, and the Phase 1 census with W01-006's independent V2.

W07-016 (S3): a launch is not logged as a completed sync

At launch, LiveState seeds lastSyncedAt from its persisted value. The seed is an emission of the same published property a HISTORY_COMPLETE stamps, so every launch logged "refreshing dashboard cache from completed sync" and ran a trigger=post-offload re-score before the strap had connected. On a background launch that line was the only pass, and it read as a sync that never happened.

  • LiveState carries a LastSyncOrigin beside lastSyncedAt, written only by seedLastSynced and stampCompletedSync, each before the value.
  • The AppModel sink reads the origin after its debounce. A seeded pass logs Launch: … and trigger=launch-seed.
  • The pass itself is kept, since on a background launch it is the only one. The completed-sync strings are unchanged.

Verification: V0 (the test fails with the fix reverted), LastSyncAttributionTests; StrandTests 2,132 pass; Strand (macOS) and NOOPiOS (without the watch app) build. Strap check still due: a launch's log reads the launch line before Connected, and post-offload only after a HISTORY_COMPLETE.

Tracker: W01-006 V2 and the Phase 1 census

  • W01-006's independent V2 held with caveats, so it moves to Verified. The caveats are recorded as W01-017 … W01-019 and W02-021 … W02-023, all S4.
  • Phase 1 census against PLAN.md's scope: no S1 or S2 open in W1, W2 or the safe-trim and backfill part of W6.

… (W07-016)

The launch seed of lastSyncedAt is an emission of the same published
property a HISTORY_COMPLETE stamps, so every launch logged "refreshing
dashboard cache from completed sync" and a trigger=post-offload re-score
before the strap had connected.

LiveState now carries a LastSyncOrigin beside the value, written only by
seedLastSynced and stampCompletedSync, each before the value. The sink
reads it after the debounce and the seeded pass logs its own line and
trigger=launch-seed. The pass itself is kept: on a background launch it
is the only one. The completed-sync strings are unchanged.
An independent V2 of W01-006 holds with caveats, so the row moves to
Verified. Its caveats are recorded as W01-017 … W01-019 and
W02-021 … W02-023, all S4. The board gains the Phase 1 census: no S1 or
S2 in scope is open, and 46 Reported rows still carry no V0.
The owner closed Phase 1 on 2026-10-03. The census stands: no S1 or S2
open in its scope. W6's 34 rows without a V0 are Phase 3 scope; the 12
W1 and W2 rows are V0'd with the first later batch touching their file,
or by Phase 6.

Phase 2 is claimed for W3, W4 and W7, with W07-002 (S2) fixed first.
PLAN's phase table now gives AD-1 to Phase 2, which W7 already binds.
Workflow run wf_b77f01a5-1aa: six reviewers (W3 sleep, scores, activity
and ECG; W4; W7 orchestration, resolution) and one adversary per
workstream. P5 refuted none. Recorded W03-023 … W03-042, W04-002 …
W04-018 and W07-017 … W07-031; the single pass offset and the pass-global
sleep parameters, found by both W3 and W7, are one W7 row each.

AD-1, AD-7 and AD-8 settle as Amend; AD-14 and AD-4 gain evidence.
W07-001 re-scoped, W07-015's cause named, W07-004's CPU figures noted as
process-wide.
…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.
…002)

0xFF is FIT's uint8 invalid value, written whenever the HR sensor drops
out. The record, lap and session heart-rate fields passed it to
validHr, which accepts 1...300, so a ride with dropouts stored a
per-second stream with 255 bpm samples and an avg/max of 255, which
ryanbr#137 then used for day Effort on a strap-less day.

FitParser.heartRateField drops 0xFF before validHr; the shared validHr
is unchanged for GPX and TCX.
sleepDaily keyed every sleep-stage interval by the local day of its own
end. Apple Watch writes a staged night as many short intervals, so the
part before midnight landed on the bed day and each stored day held the
tail of one night plus the head of the next.

Intervals are now joined into nights first (a gap of two hours or more
starts a new one) and each night is keyed by the day it ends on.
…ot a cross-source sum (W04-005)

The aggregator summed active and basal energy and sleep minutes across
every source, the double count ryanbr#589 fixed for steps only. A watch and
another app (the WHOOP app, or NOOP's own iOS write-back) both writing
the same day doubled its energy and its night.

Energy sums within a source and takes the max source per day, as steps
do. Each wake day's sleep is accumulated per source, and the day takes
the source with the most sleep.
…import filled (W04-003)

Every writer of the apple-health daily rows replaced the whole row, so
a Shortcut payload, which carries only steps, sleep, resting HR and a
few others, nulled the deep/REM/light minutes, SpO2, respiration, SDNN,
basal kcal and max and walking HR an export.zip import had filled for
the same days. They came back only by re-importing the zip.

upsertDailyMetrics and upsertAppleDaily take keepingStoredValuesForNil:
a nil column keeps the stored value. The Shortcut ingest uses it; every
other writer keeps the whole-row replace.
…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.
…03-023)

detectSleep took the sparse-gravity flag, the fragmentation rescue and
the HR baseline over its whole read, about 54 h. A gravity gap or the
heart rate half a day after a night decided whether a pre-sleep fragment
was bridged into it, so the same night read by two windows of different
reach came out with two starts, and a night first scored in the morning
moved when re-scored after midnight.

SleepStager.LocalContext gives every decision at time t only its own
neighbourhood: the bridge judges sparseness and fragmentation within 6 h
of each pair, and run building, the bridge's HR band, the HR
confirmation and the morning guard use the median HR within 12 h of t.
A read that covers a session's 12 h neighbourhood gives the same
session. The trace counts locally sparse gaps instead of asserting one
read-wide flag.
…s banked once (W03-024)

Each past day's sleep read stopped at its next local midnight. A night
that began with an evening fragment and a short stir before running
across midnight was joined into one session by the next day's read, but
the earlier day saw only the fragment and banked it as its own sleep:
two rows with one start and two ends, and an evening nap scored on the
wrong day.

Every day now reads 12 h past its next midnight (capped at now), the
neighbourhood W03-023 gives each detection decision, so a session ending
on the day is detected whole and the earlier day filters out a night
that ends after it. StreamReadCap.forwardSeconds carries the span, so
the stream caps grow with it; the skin cap is sized like gravity's. The
day-owner probe stays on the day's own span.
…n motion (W03-044)

stagingSparse, stored on every computed night and shown as the Sleep
tab's "may be incomplete" caption, came from isGravitySparse over the
whole read, as did the Rest confidence guard and the sleep-motion
trace. A dense night was stamped sparse for a gravity gap half a day
away: 8 of 12 banked nights on the 2026-09-30 backup, none with a gap
inside its own span.

Each session now takes LocalContext.sparse at its midpoint, the
bridge's 6 h neighbourhood; the day's value is whether any session of
its main night is sparse.
…indow (W03-046)

W03-024 widened the sleep read to 66 hours, but the steps, respiration,
raw SpO2 and v18 auxiliary reads kept a literal 200,000 limit, below a
full window at one row a second (about 230,000 step rows on the
2026-09-30 backup). A read that reaches its limit drops its newest rows
without a word. StreamReadCap now names a cap for each, sized like
gravity.
…n cap (W03-045)

daySliceFromNight (ryanbr#997) declines a night read at or above its limit,
taking it for a truncated read, and the limit defaulted to 200,000.
W03-024's 66-hour window holds about 237,600 rows of a 1 Hz stream, so
on almost every past day the shortcut declined and the HR, steps and
gravity day streams were read again: about 60 extra store reads per
pass, with no data difference.

The limit is now required and each call passes the cap its night read
used, so a read that came back at its cap still declines and a complete
one is sliced.
After W03-023 the sparseBridge line still printed the read-wide
fragmentedToNothing value the bridge no longer used, and gated its own
emission on it, while its sparseGaps=k/m count always read k = m.

Each SparseBridgeAttempt now carries enabledBy (sparse, fragmented, or
none for a pair the local rule did not open, recorded as reason=dense).
The pair line prints it, the night line counts pairs by it, and the
unused read-wide value is gone.
…ow (W03-048)

W03-023's LocalContext doc claimed any read covering a session's 12 h
neighbourhood gives the same session. That holds for the decisions it
localises; classifyStill's window size, the overnight acceptance chain
and the off-wrist gap gate still read the whole window. The doc now
names them. Comments that still described the night window as ending at
the next midnight or at dayStart + 12 h describe the 66-hour read.
…a corrupt reading (W03-051)

LocalContext.baseline sized its per-call histogram by the largest bpm in
the read, so a single corrupt value would make every baseline call
allocate that many counters. The histogram now covers 0 to 511 and
anything outside it is counted from a sorted list, as negatives were;
the median is unchanged.
…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.
Analytics (W3): sleep detection reads only a night's own neighbourhood, and each night is banked once
Import (W4): FIT invalid heart rate, Apple Health night keying, one source per day, Shortcut merge
App data (W7): attributed strap-data delete, sleep edits on a re-added strap, launch-seed attribution
Review tracker: close Phase 1, Phase 2 review passes for W3, W4 and W7
@ayiskakov
ayiskakov merged commit 595a8c3 into main Oct 8, 2026
13 checks passed
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