Skip to content

docs(review): Phase 1 review passes, AD-2/5/6 verdicts and the real-backup gate - #24

Merged
ayiskakov merged 31 commits into
mainfrom
review/phase-1
Sep 25, 2026
Merged

ayiskakov merged 31 commits into
mainfrom
review/phase-1

Conversation

@ayiskakov

Copy link
Copy Markdown
Owner

Phase 1 (Data integrity) of the whole-project review: the review passes, the decisions they settle, and the real-backup check the phase closes on. Tracker only, plus one env-gated test. The fixes are in three stacked PRs based on this branch: review/w02-fixes, review/w01-fixes and review/w06-fixes.

Review passes

Passes 1 Map, 2 Static sweep, 3 Deep read and 5 Adversarial ran as one workflow over W1 Protocol, W2 Storage and the safe-trim, backfill and live/historical-split part of W6: five reviewers, then one adversary per area that tried to refute every finding and to find what the first pass missed. Findings are recorded as rows in docs/review/workstreams/W01-protocol.md, W02-storage.md and W06-ble-collect.md; duplicates across slices are merged, and two claims the adversary refuted stay as Not a bug rows.

  • No S1. S2: W02-002, W02-003, W02-005, W06-002.
  • W01-001 (v18 R-R in ticks) is not a bug: checked against the strap's own per-second heart rate, the median R-R ratio is 1.005 where ticks would give 1.024. W01-002 was already fixed by earlier upstream picks.

Decisions

AD-2, AD-5 and AD-6 are Amend, with the evidence in docs/review/DECISIONS.md and a design row for each (W02-016, W06-023, W06-024). In short: one WhoopStore per process instead of two pools on one file; describe the live/historical split the 5/MG code actually has; and restate safe-trim as "every record the strap will free is in a durable lane or the archive before the ack".

Real-backup gate

StrandTests/RealBackupGateTests imports each .noopbak named in NOOP_GATE_BACKUPS through the app's restore code, migrates it to head, exports it and imports it again, comparing every table by an order-independent row digest and every whitelisted setting by value. It skips when the variable is unset, so CI never runs it:

TEST_RUNNER_NOOP_GATE_BACKUPS=/path/a.noopbak:/path/b.noopbak xcodebuild … test \
  -only-testing:StrandTests/RealBackupGateTests

On three real backups (schema v46, v48, v49; 1.7M to 3.5M rows) every table that existed before is unchanged after migrating, apart from the ECG purge v48 and v49 document, and the round trip reproduces every row and setting.

Owner decisions recorded

W02-004 deferred to Phase 5, W02-007 to Phase 4, W02-009 to Phase 3; W01-006 decided (a storage lane). A strap run is agreed for the BLE-path fixes.

Verification

StrandTests passes (1,966 tests, the gate skipped); the gate itself passes on three real backups; hygiene gates pass. No app behaviour changes in this PR.

ayiskakov and others added 30 commits September 25, 2026 11:53
RealBackupGateTests imports each .noopbak named in NOOP_GATE_BACKUPS
through DataBackup.restore, migrates it to head with WhoopStore(path:),
exports it with the app's writer and imports the export again. Every
table is compared by an order-independent row digest over every column,
and every whitelisted setting by value. Tables a migration rewrites on
purpose are listed with the migration that does it.

The test skips when the variable is unset, so CI never runs it. It is
the Phase 1 exit gate in docs/review/README.md.
… AD-6

Passes 1 Map, 2 Static sweep, 3 Deep read and 5 Adversarial ran as one
workflow over W1 Protocol, W2 Storage and the safe-trim, backfill and
live/historical-split part of W6 (5 reviewers, 3 adversaries).

- W1: W01-001 and W01-002 closed as not a bug (ms R-R confirmed against
  the strap's own HR; the v26 base was already fixed). W01-003 to
  W01-009 added; W01-009 refuted.
- W2: W02-001 (found while building the gate test) to W02-016; W02-015
  refuted. S2: restore snapshot misses the WAL, export can ship a torn
  file, deleting a device leaves its computed rows.
- W6: W06-001 lowered to S4; W06-002 to W06-024. S2: the 5/MG raw IMU
  session writer is unreachable.
- AD-2, AD-5 and AD-6: Amend, with evidence and a design row each.
- Exit gate, first half: RealBackupGateTests passes on three backups.

All rows stay Reported until V0 (pass 4).
W02-005. "Delete all of this device's data" and "Forget device" call
deleteAllData with the bare id, which left the device's computed
sibling (<id>-noop: scored days, sleeps, detected workouts, metric
series) on disk. The Repository kept reading it, so the deleted
recordings' scores stayed on screen, and no rescore could evict them
once their raw input was gone.

deleteAllData now clears <id>-noop in the same transaction.

With a re-added strap the engine writes every computed row under the
canonical my-whoop-noop, so splitting those needs per-day ownership;
recorded as W07-002 for Phase 2.

V0: testDeleteAllDataAlsoClearsTheComputedSibling failed on main (the
sibling row survived). V1: it passes, fails with the fix reverted, and
the WhoopStore suite passes (546 tests).
W02-003. An export checkpointed the WAL and then zipped the live main
file. Two things broke that:

- A TRUNCATE checkpoint that a reader on the other pool cuts short
  reports busy in its result row, not as an error, so checkpointWAL()
  returned normally. The main file it left was malformed: the
  checkpoint writes only the pages whose newest frame predates the
  reader's mark. The pre-zip quick_check read through the WAL and
  passed, so the export reported success.
- Rows committed after the checkpoint (a sync running while the macOS
  save panel is open, or the folder backup at launch overlapping the
  reconnect offload) were missing, and an autocheckpoint during the zip
  could tear the file.

Exports now zip a snapshot that WhoopStore.writeSnapshot(to:) writes
through SQLite's backup API from one read snapshot, staged in a temp
file and removed afterwards. The quick_check now reads the bytes the
archive carries. macOS takes the snapshot after the save panel, not
before. checkpointWAL() throws CheckpointBlocked when a reader stops
it. The failure message no longer claims recent changes are in the
WAL; the new string carries all nine translations.

V0: CheckpointBusyTests reproduced the malformed main file. V1: the
package tests pass and fail with the busy check reverted;
DataBackupLiveStoreTests restores every row from a folder backup taken
under a reader and a writer, and fails with the old path. WhoopStore
suite passes (546 tests); Strand builds.
W02-002. Before a restore replaces the live store, it copies it to a
whoop-replaced-<ts>.sqlite side file, and a failed copy or a failed
post-swap quick_check rolls back from that file. The copy took the
main file alone and the swap then deleted the live -wal, so every
commit not yet checkpointed was in neither place. A rollback put back
a store without them while telling the user their data was kept.
After a partial checkpoint the main file alone can even be malformed.

The side file is now a consistent snapshot written through SQLite's
backup API on a fresh connection, which reads through the WAL. A live
file too damaged to read that way is kept byte for byte with its WAL,
because a user restoring over a broken store still needs those bytes.
Rollbacks put back whichever was kept.

V0: with 50 committed rows still in the WAL the side file did not even
hold the hrSample table. V1: it now holds all 50.
…d a live restore

The gate now exports with DataBackup.writeBackup and a store open on
the file, as the app does, and restores over that open store to check
the side file it keeps. Settings still round-trip through the test
seam, because the app's writer carries this device's own settings.

On three real backups every table matches after the snapshot export,
and the side file matches the store it replaced (W02-002, W02-003 V3).
…amp heal

W02-006. The ryanbr#547 heal deleted every raw row dated before the
plausibility floor (2023-11-14) from every source. An imported FIT,
GPX or TCX activity stores its per-second heart rate under
activity-file with the activity's real dates, so a heal re-run (after
a bad-clock sync) stripped the heart rate from any workout older than
the floor. The same function already spared imported computed rows.

The far-past floor now spares WhoopStore.importedRawSourceIds; a
future-dated row is still removed whatever wrote it. A StrandImport
test pins the id to ActivityFileImporter.sourceId.

V0: testImportedActivityHeartRateBelowFloorSurvives failed on main.
V1: it passes and fails with the fix reverted; WhoopStore suite passes
(547 tests).
…ther

W02-008. BLEManager and Repository each hold a DatabasePool on the same
file. Write transactions were DEFERRED, and SQLite cannot use the busy
handler to upgrade a deferred transaction that has already read, so
any write that reads first (upsertSleepSessions, for one) failed at
once with SQLITE_BUSY while the other pool was writing. The 5 s busy
timeout never applied, and IntelligenceEngine swallowed the failure,
so that pass's rescored night was not stored.

Write transactions are now IMMEDIATE, GRDB's documented setting for
several writers on one file; readers stay deferred.

V0: testReadFirstWriteWaitsForTheOtherInstancesWrite failed in 0.2 s
with database is locked. V1: it passes; WhoopStore suite passes (548
tests).
W02-012. StreamStore.insert counted a v18 aux row for every statement
it ran, while the comment beside the return promised rows accepted,
like the other eight channels. A reconnect that re-offloaded records
already on disk therefore printed v18aux=N as newly banked beside zeros
for every other channel, and spent the retention-sweep budget on rows
that were never written.

V0: testResyncReportsNoNewAuxRows reported 2 on the duplicate insert.
V1: it reports 0; WhoopStore suite passes (549 tests).
W02-011. Both migration comments justified deleting ecgCandidateSample
with "the strap re-offloads v16 records on the next sync". The strap
trims history once NOOP acks it, so the records behind those rows are
gone and the purge was permanent. The v49 comment's escape hatch, the
Test Centre export, only helps on the old build, before the migration
runs. Comment-only; no migration body changes.
W02-013. stepDataRevisionSignature was documented as process-local, but
its index lives on each WhoopStore instance, and the app opens two on
one file. Strap steps are recorded on BLEManager's instance while the
cycle cache reads Repository's, so for strap data the component never
moves; the cache's days witness is what invalidates it. The doc now
says so. Doc-only.
W02-010. WhoopStoreInfo.schemaVersion was the constant 18 from the
first commit while the schema grew to 49 migrations, so every .noopbak
manifest and every APP_VERSION_CHANGED event recorded 18 and could not
tell one schema from another. It is now the registered migration count.
Four tests that pinned 18 are gone; SchemaOracleTests pins the value to
the oracle's migration list, so the constant cannot fall behind again.

WhoopStore suite passes (545 tests).
W02-014. The activity-file import stored the file's per-second heart
rate with try?, so a failed insert was dropped while the import
reported success, and its comment said an identical timestamp
overwrites when the insert keeps the first row.

The insert now throws into the import's existing catch, which reports
the failure; a retry is idempotent. The comment states first-wins, and
InsertTests pins it.
W06-002. A Raw Data Collector session on a WHOOP 5/MG never recorded an
IMU sample. The only writer, ImuSessionFileStore.append, sits in
Collector.ingest(frame:parsed:), and nothing in the app calls that:
ryanbr#1709 moved IMU storage behind the ingest seam, which the 5/MG frame
loop does not enter. The session started the strap's 100 Hz stream,
then exported nothing.

The 5/MG loop now hands every frame to Collector.bankImuForSessions
before the offload branch, so a session takes buffers from the live
stream and from history sync, as ryanbr#1709 intended. Only intact 1244-byte
buffers are banked, because Whoop5RawImu decodes without checking the
CRC.

V1: CollectorImuBankingTests (an intact real buffer is banked, a
CRC-flipped copy is not); Strand and NOOPiOS (without the watch app)
build. Not yet tested on hardware.
…#2429)

The reassembler guarded a declared length below the family floor and above the
8 KB ceiling, and trusted everything between. indexOfSOF matches a bare 0xAA,
which occurs inside payload as readily as at a frame start, so a stream that
desyncs can put the read cursor on a payload byte whose next bytes read as a
PLAUSIBLE length: past the floor, under the ceiling, accepted by both guards.
feed() then waited for that many bytes and emitted them as one frame,
consuming whatever valid frames fell inside it. They never reached a parser
and nothing downstream could return them, because the CRC32 that rejects the
bad frame runs after the cursor has advanced past them.

The header checksum settles it without new protocol knowledge, because it
already covers the length field on both families: CRC-8 over the two length
bytes on WHOOP 4.0, CRC-16-Modbus over the first six on 5/MG. A real frame
always passes; a false SOF passes with probability 1/256 or 1/65536. The gate
sits AFTER the floor and ceiling checks so those keep their counters and their
tests, and it only sees lengths the old code accepted.

headerChecksumDrops counts the rejections and is folded into the connection's
reject tally, under HEADER_CHECKSUM_MISMATCH rather than the floor bucket: a
floor drop is a malformed frame, this is a read cursor that was not on a frame
boundary at all. Without that fold the drops would have vanished with no
trace, which is the failure the neighbouring absorbReassemblerDrops exists to
prevent. Both absorbed totals clear on the tally's reset, or the fold reads as
"already folded" against a total that never goes down and goes silent for
every later connection.

Both platforms start a connection with the counters and the absorbed totals at
zero, so the fold cannot be silenced by a lifecycle mismatch: Apple reassigns
the reassembler and sets router.family on adjacent lines, and its didSet
clears the tally; Android resets the tally in STATE_CONNECTED, before service
discovery reassigns the reassembler.

trigger, "a bit-flip, or a spurious 0xAA injected mid-frame". This is the
in-range half of the same problem.

Mutation-checked on both platforms independently. With the gate removed,
feed() returns nothing where two valid frames should be: the false SOF eats
them both while waiting for 104 bytes. With the reset line removed, only the
post-reset assertion fails. Every pre-existing framing test stays green under
both mutations, which is what separates a gate that catches a false SOF from
one that rejects real frames. Swift 791 tests, Kotlin 17 and 39, parity clean,
16 of 16 in CI.

Not reproduced on hardware: the trigger needs a desync, and the reassembler is
rebuilt per connection, so the residual window is a subscribe landing
mid-frame.

Found by an AI code review, which reported it as corruption under the ceiling.
The mechanism is a false SOF after a desync rather than corruption, since BLE
hands up only CRC-valid PDUs, and the severity is lower than it judged. The
gap it pointed at is real, and the header-checksum resync it recommended is
what this implements.

(cherry picked from commit f15360d)

Fork note (W01-003, docs/review/workstreams/W01-protocol.md): the fork
has only the 5/MG family, so the header gate keeps the CRC-16 branch
alone, the two WHOOP 4.0 tests are rewritten on 5/MG frames, and the
Android half is dropped. Added: real-frame tests pinning the review's
oracle (3 of 12 and 1 of 10 records intact before the gate; 12 and 10
after) and a Swift test for the reject-tally fold. WhoopProtocol suite
passes (743 tests); Strand builds. Not tested on hardware.
W01-005. PROTOCOL_SENSORS.md, which the project treats as the
authority, told readers that packet-40 and R18 R-R words are 1/1024 s
ticks to be converted with (ticks * 1000 + 512) // 1024. The decoder
reads them as milliseconds (ryanbr#2195), and the inspector note beside every
R-R field said "ms from 1/1024 s ticks". A contributor following the doc
would shorten every stored R-R interval and HRV value by 2.4 %.

The doc rows and text now say milliseconds and why, PROTOCOL_CONCEPTS
notes that WHOOP 5/MG departs from the BLE spec's unit, the tick
arithmetic leaves validate_examples.py, and the note says the word is
already milliseconds. Whoop5RRTests fails if an R-R note mentions 1024
again. The rr_raw_ticks key is kept: tests pin it.
…tact grade

W01-008. R18 byte 63 is published twice, as motion_wear_quality and as
activity_class. The first note read "0=still/good, 1, 2=poor contact"
beside the second's "0=still, 1=walk, 2=run", so the inspector and
whoop-decode gave two contradictory meanings for one byte, and
PROTOCOL_SENSORS.md defines it as the activity class. The note now
names motion_wear_quality as the older name of activity_class; the key
and its value are unchanged. Nothing stores the older name.
W01-007. PROTOCOL_TRANSPORT.md said format-1 acceptance requires a
frame longer than 15 bytes with (declared length - 4) divisible by
four, a rule nothing implements. verifyFrame accepts 13-byte and larger
frames without an alignment check (FrameLimits records the 13-byte
floor as a deliberate, empirical policy), and PROTOCOL_IMPLEMENTATION.md
already says 13. Both checksums still gate every frame.

The doc now keeps the construction rule and states the acceptance NOOP
enforces. FrameIntegrityTests pins 15-, 16- and 17-byte frames as
accepted, so the doc and the code cannot drift apart unnoticed.
W01-004. The ryanbr#547 gate in extractHistoricalStreams drops a record whose
own timestamp is implausible (before 2023-11 or more than a day ahead)
so it cannot be filed on the wrong day. rejectedHistoricalRecords, the
screen that archives every record NOOP cannot turn into rows before the
trim ack, did not know about that gate: an intact v18 record passed it
because it has a timestamp and heart rate, and an intact v16 or v26
record is skipped because its lane normally stores it. So the record
was neither stored nor archived, and the ack freed the strap's only
copy, although its bytes were fine and a clock fix could re-date it.

rejectedHistoricalRecords now takes the same gate inputs (wall time and
the session markers) and archives an intact record the gate refuses.
The Backfiller passes the values its extraction used, and its drop log
says the raw records go to the archive.

V1: RejectedHistoryTests archives a far-past and a future-dated record
and not a plausible one, and fails with the check disabled. WhoopProtocol
suite passes (747 tests); the Backfiller app tests pass.
…evice

W02-005 follow-up, from the V2 review of a617df3. That fix cleared
<id>-noop with a device's recordings. But the engine writes every day
it scores under the canonical my-whoop-noop, whichever registered
source supplied it: a re-added strap (whoop-<uuid>), an activity file,
Apple Health. So Forget on an archived my-whoop after a re-add, the
very case Forget exists for, deleted every computed day, including
days scored from the active strap, and every hand-edited night. Only
the last 21 days rescore, and edits never come back.

deleteAllData now clears the sibling only when it is the device's alone:
a non-canonical id, or my-whoop with no other device registered. User-
edited and hand-added nights are never cleared this way. With other
devices registered, the canonical namespace stays, which leaves the
W02-005 symptom in multi-device installs; that remainder is W07-002 and
needs per-day attribution.

Tests: the V2 scenario keeps the shared rows, and fails with the old
rule; the single-device and non-canonical cases clear their own rows
and keep edited nights. WhoopStore suite passes (549 tests).
… partial copy

W02-002 follow-up, from the V2 review of b2d18a5.

- A snapshot that failed part way (a full disk, an I/O error) left an
  empty side file behind. The restore's byte-copy fallback then failed
  with "already exists" and the whole restore aborted. The copy helper
  now removes a partial destination on any failure, and the fallback
  clears the side file before copying.
- The snapshot's source connection was read-write, so when nothing else
  had the file open, closing it checkpointed the live WAL into the main
  file. The source is now read-only and never changes the live file;
  when it cannot open read-only, the byte-copy fallback keeps the files
  as they are.

V0: under a file-size limit the copy failed and left a partial file.
V1: CheckpointBusyTests.testAFailedSnapshotLeavesNoPartialCopy passes,
and fails without the cleanup; a live file that cannot be snapshotted is
kept byte for byte (DataBackupLiveStoreTests).
…iled

W02-003 follow-up, from the V2 review of cf63992. A snapshot is a full
physical copy of the store: the old export zipped the live file, and
the old pre-restore copy was an APFS clone, so neither needed the
space. On a nearly full volume the backup API filled the disk and only
then failed; while the disk was full the strap sync's own writes could
fail too. The export then said "Try again in a moment", a transient
cause the code could not back, and folder auto-backup retried on every
launch.

The snapshot now checks that the volume can take the copy plus a 64 MB
margin before writing anything, and throws SnapshotNoSpace otherwise.
A restore falls back to the byte copy, which clones and needs no space.
The snapshot closure now throws, and the export message carries the
reason (not enough space, no store open yet, a SQLite error). The new
string replaces the unreleased one, with all nine translations; the
i18n gate passes.
W02-017 to W02-020, all pre-existing: a rollback that reports data kept
without checking its copy, an iOS export left in tmp, the staging
import copying another app's live database file by file, and no
dashboard refresh after a device delete.
Owner decision, 2026-09-25: W02-004 goes to Phase 5 with the single
store owner (W02-016), W02-007 to Phase 4 with AD-9, and W02-009 to
Phase 3 with AD-14. The W2 log records this batch.
W01-006 gets a storage lane: a nullable rawRecord BLOB on the ECG and
PPG tables holding the full intact record. The reject archive is not
used, because it is a rolling 5 MB file that v20 and v21 records
already saturate (W06-003).
BLE (W6): record IMU in 5/MG Raw Data Collector sessions
Protocol (W1): reassembler header gate, archive what the timestamp gate refuses, and doc truths
Storage (W2): backups that restore, deletes that keep what they should, and smaller fixes
The watch app compiles WhoopStore, and volumeAvailableCapacityForImportantUsageKey
is unavailable on watchOS, so the NOOPiOS build failed. On watchOS the check now
reads the plain available capacity. Verified by cross-compiling StrandAnalytics
and its dependencies for the watchOS 10 simulator: the two CI errors without the
change, a clean build with it.
@ayiskakov
ayiskakov merged commit 9127dae into main Sep 25, 2026
15 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.

2 participants