Skip to content

offload data loss fixes - #235

Open
abdulsaheel wants to merge 15 commits into
mainfrom
integration/gen4-data-integrity
Open

offload data loss fixes#235
abdulsaheel wants to merge 15 commits into
mainfrom
integration/gen4-data-integrity

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

bunch of data loss fixes on the offload path, all in one branch.

  • decoded_onehz was keyed on counter, counter resets to ~0 on reboot so a new second could evict an older one. keyed on rec_ts now
  • raw_archive had the same problem, keyed it on hex
  • sync commit wasnt fsynced before we ack, so a power cut after the band trims loses it. FULL around that one commit only
  • burst shortfall logging, uses the received total not just the ones we bank
  • dont drain history when the phone clock looks wrong, we were dropping the bands records as "future" and then trimming them. just waits now

schema goes to 33.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented historical data drains and clock corrections when strap and phone clocks appear significantly out of sync.
    • Preserved RR data across counter resets, reboots, imports, and migrations.
    • Improved archived-frame deduplication while retaining distinct records that reuse counters.
  • Reliability

    • Added burst-traffic diagnostics to identify possible missing packets without changing acknowledgment behavior.
    • Strengthened database synchronization, migration integrity, and recovery from interrupted commits.
    • Added diagnostic status when history transfers are paused due to clock disagreement.

…ed counter

raw_archive is the durable dead-letter box for undecodable frames — its whole
purpose is to never lose a frame until we can decode it. But it was
counter INTEGER PRIMARY KEY with IGNORE-on-conflict, and the strap resets its
record counter to ~0 on every reboot. So a post-reboot frame that reused a
still-present pre-reboot counter was silently DROPPED, even though its bytes
were completely different data.

Re-key the table off the volatile counter onto frame hex (content identity),
exactly like events/band_events already do: an identical re-flood (missed-ACK
redelivery) still dedups, but two genuinely distinct frames survive a counter
collision. counter is retained as a plain forensic column.

v32 migration rebuilds the table preserving every existing row (their counters
are unique, so the content-keyed copy loses nothing). Guarded because
raw_archive is created lazily in onOpen, not the ladder, so an old DB may not
have it yet at migration time — then a fresh hex-keyed create is all that's
needed.

Adds a regression test: two distinct frames sharing a reused counter both
survive (previously the second was lost).
… received-total signal)

Emit an honest, observation-only frame-loss signal at HISTORY_END without
touching the commit/ACK decision. The band's num_packets counts every frame
it transmitted (all types); the correct completeness comparison is against
totalTrafficPacketCount — the all-types received total — not the banked R24
subset (which fabricates a shortfall whenever console/event frames ride along
un-banked). This is type-agnostic and interleaving-immune.

New pure helper burstPacketShortfall() = expected - (received_all_types +
dropped_this_burst): a POSITIVE result is frames the band sent that never
reached us (true loss); zero is complete; negative is retries/dupes, not loss.
Gate-dropped (RecordGate) records are added back so plausibility rejections
never read as radio loss.

At burst end we now log a "would-flag" line and stamp burst_shortfall into the
existing mismatch ledger entry — LOG-ONLY. Commit-before-ACK, the verbatim
token echo, and the OK/FAIL decision are all unchanged. This is groundwork so
we can SEE true frame loss in telemetry before ever wiring a field-validated
FAIL gate; a hard FAIL/re-flood path is deliberately NOT included here.

Rejected alternative: gating on the per-revision counter gap — the counter is
a GLOBAL flash-log index sliced per revision, so gaps are the normal state and
would false-positive constantly.

Adds pure unit tests covering benign interleaving (no false positive), true
loss, the gate-dropped add-back, negative/retry case, and shortfall==0 ==
burstPacketCountMatches.
The DB runs WAL + synchronous=NORMAL, under which a commit is durable only
at the next checkpoint, not at commit. commitSyncBatch persists the sync
batch (raw_archive + samples + decoded + trim cursor) and returns; the
caller then writes the BLE batch-ACK and the band trims its flash. A kernel
panic / battery-yank AFTER the ACK but BEFORE the -wal is checkpointed lost
those just-committed rows from the phone while they were already gone from
the band. The commit-before-ACK ordering held; the durability did not.

Raise durability to synchronous=FULL (fsync AT commit) for this one commit
only, leaving every other path at NORMAL — they are all recomputable and
FULL everywhere is brutally slow. synchronous is per-connection and cannot
change mid-transaction, so it is set BEFORE db.transaction opens and reset
to NORMAL in a finally (a leaked FULL would fsync every later write on the
connection forever). Both the main and background-isolate drains funnel
through commitSyncBatch, each on its own connection, so this single bracket
covers both. PRAGMA synchronous returns no rows -> execute(), kept non-fatal
like the open-time PRAGMAs.

Adds a focused test (spies the FULL/NORMAL SQL bracket and reads resting
PRAGMA synchronous) covering both a normal commit and a throwing one.
A positive burst shortfall means frames the band counted that we did not count
as valid received traffic. CRC-failed frames also never enter
currentBurstTrafficCount, so a positive shortfall can be missing OR corrupted
traffic — it cannot by itself prove a frame never arrived. Soften the helper
doc, the would-flag log text, and the test name accordingly. Wording-only;
no behavior change (still log-only).
The strap resets its per-record `counter` to ~0 on every reboot, and
`decoded_onehz` was `counter INTEGER PRIMARY KEY`. So a post-reboot record
(counter=c, rec_ts=T2) REPLACE-evicted a still-present pre-reboot row
(counter=c, rec_ts=T1), silently deleting T1's only decoded 1 Hz row. Because
`raw_records` is dropped (not a live ledger), the decoded store is the sole
system of record, making the eviction UNRECOVERABLE. No orphan-guard patch can
restore an evicted row — the key itself has to change.

Re-key both decoded tables onto record time:
- decoded_onehz PK -> rec_ts; `counter` demoted to a NOT NULL forensic column
  (+ index), still the keyset-cursor tiebreak (never fires now rec_ts is unique).
- decoded_rr PK -> (rec_ts, beat_index); rr_ts_ms kept as the beat timestamp.
- Write path per second: REPLACE decoded_onehz(rec_ts,...); DELETE decoded_rr
  by rec_ts; insert the beats. Parent and child now share the rec_ts key, so the
  counter-based orphan guard and the prune orphan-sweep are deleted — a shrinking
  beat count can no longer strand stale high-index beats.

Caller audit (every counter-identity query rewritten to rec_ts):
- decodedRrByCounterRange -> decodedRrByRecTsRange (a clean PK range read; drops
  the degraded counter-span fallback + truncation counter that only existed to
  paper over the reboot reset).
- derive_prepare.addDecodedPage groups RR by rec_ts, not counter (a counter reuse
  within a page had mis-joined two seconds' beats).
- deleteDays / pruneDecodedBeforeRecTs / export copyRawRange / importFromDb all
  select decoded_rr by rec_ts; import derives rec_ts from rr_ts_ms for legacy
  (counter-keyed, no rec_ts) backups.

Migration v33 (`_rekeyDecodedStoreByRecTs`): rebuilds BOTH decoded tables FROM
THE EXISTING decoded tables only (never from the dropped raw_records — that would
zero the store), rename-aside, deterministic newest-wins by rec_ts, idempotent,
pure INSERT..SELECT so the iOS 999-var limit never applies. The frozen v11/v17/v19
steps are made schema-adaptive so the ladder still completes.

NOTE: base is origin/main at schemaVersion 31; PR #231 (pending) bumps to 32, so
this uses 33 — a trivial schemaVersion rebase is expected when they merge.
The headless drain (background_sync.dart) is the iOS CoreBluetooth-restoration
recovery path and runs in the MAIN isolate on the same shared _db connection —
not a separate per-isolate connection as the prior comment claimed. The bracket
is safe not because of isolation but because BandOwnership + the single-flight
offload processor guarantee the two drains never overlap on one connection.
Document that as the load-bearing invariant so a future concurrent caller does
not silently defeat the FULL window.
…-PK DB

The v32 migration (RENAME → drop-index → hex-PK create → INSERT OR IGNORE
SELECT → drop-old) had no coverage — the archive test only exercises the
fresh onCreate schema, and the ladder test never touched raw_archive. Seed a
populated v31 counter-PK table, open it through the REAL ladder, and assert:
distinct frames survive, an exact-duplicate hex collapses (5 rows → 4), a
reused counter no longer drops a distinct frame (hex-PK proven end-to-end),
and an identical re-flood still dedups on content.
The v33 re-key touches frozen migration steps (v11/v17/v19), but no ladder
test seeded a genuinely OLD counter-keyed decoded store. The riskiest path is
a user installed at v19..31: raw_records is already dropped by then, so the
rekey is the SOLE copy of their 1 Hz data with no raw-backfill safety net.
Seed that exact origin/main schema at v31, run the real ladder, and assert
every second/beat survives, counter is preserved as the forensic column, the
PK moved to rec_ts, and no temp tables leak.
# Conflicts:
#	lib/data/db.dart
#	test/db_migration_ladder_test.dart
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds phone-clock-aware BLE history deferral and burst shortfall telemetry. It also migrates decoded and raw archive storage from counter-based identity to timestamp or frame-content identity, updates compute and database flows, and adds migration, durability, and integrity tests.

Changes

BLE reliability telemetry

Layer / File(s) Summary
Phone-clock suspicion and history gating
lib/sync/sync_policy.dart, lib/ble/ble_engine.dart, test/sync_policy_test.dart, test/ble_engine_test.dart
ClockPolicy.phoneClockSuspect detects future strap clocks. BLE setup and history refresh defer clock correction and historical drains until clocks agree or the grace period expires.
Burst shortfall diagnostics
lib/ble/ble_engine.dart, test/ble_engine_test.dart
Burst telemetry uses all received traffic and plausibility-gated drops. Positive shortfalls are logged and persisted without changing commits or ACKs.

Timestamp-keyed persistence

Layer / File(s) Summary
Schema keys and migrations
lib/data/db.dart, test/db_migration_ladder_test.dart, test/raw_archive_test.dart
Schema version 33 keys decoded rows by rec_ts. Raw archive rows use frame hex. Migrations preserve data across counter reuse and deduplicate identical frames.
Durable decoded and archive writes
lib/data/db.dart, pubspec.yaml, test/ack_commit_sync_full_test.dart
Decoded writes replace rows and RR beats by timestamp. Sync commits restore SQLite synchronous mode after success or failure.
Timestamp-based compute and database flows
lib/compute/*.dart, lib/data/db.dart, test/db_integrity_test.dart, test/db_p0_fixes_test.dart, test/db_paged_import_export_test.dart, test/db_storage_hygiene_test.dart, test/local_persistence_test.dart
RR derivation, reads, imports, exports, deletion, pruning, and integrity checks use rec_ts.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BleEngine
  participant Strap
  participant ClockPolicy
  BleEngine->>Strap: Read strap clock
  Strap-->>BleEngine: Return RTC timestamp
  BleEngine->>ClockPolicy: Evaluate phoneClockSuspect
  ClockPolicy-->>BleEngine: Return clock state
  BleEngine->>BleEngine: Defer or resume history offload
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the pull request's main purpose: fixing data loss in the offload path.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 997e149

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Clear stale beats before importing foreign decoded_rr rows

When a legacy decoded_rr row is imported (no rec_ts column), rec_ts is derived from
rr_ts_ms and inserted. However, the existing local decoded_rr rows for that rec_ts
are NOT deleted before the foreign beats are inserted. Because the new schema keys
decoded_rr on (rec_ts, beat_index), a foreign row with fewer beats than the local
row will leave stale high-index beats behind — the same shrinking-beat-count bug
that _queueDecodedOneHz now guards against with a DELETE FROM decoded_rr WHERE
rec_ts = ? before reinserting. The import path needs the same pre-delete for
decoded_rr rows when the foreign export wins the decoded_onehz collision on rec_ts.

lib/data/db.dart [4046-4051]

 if (t == 'decoded_rr' &&
               row['rec_ts'] == null &&
               row['rr_ts_ms'] != null) {
             row['rec_ts'] = ((row['rr_ts_ms'] as num).toInt()) ~/ 1000;
           }
+          // For decoded_rr, clear the existing beats for this second before
+          // inserting the foreign set — a shrinking beat count from the
+          // foreign export must not strand stale high-index local beats
+          // (mirrors the _queueDecodedOneHz DELETE before reinsert).
+          if (t == 'decoded_rr' && row['rec_ts'] != null) {
+            batch.rawDelete(
+              'DELETE FROM decoded_rr WHERE rec_ts = ? AND beat_index > ?',
+              [row['rec_ts'], row['beat_index']],
+            );
+            ops++;
+          }
           batch.insert(t, row, conflictAlgorithm: ConflictAlgorithm.replace);
Suggestion importance[1-10]: 6

__

Why: This identifies a real gap: the import path doesn't apply the same pre-delete guard that _queueDecodedOneHz uses to prevent stale high-index beats from surviving a shrinking beat count. However, the proposed fix using beat_index > ? per-row is awkward and would need to be applied once per rec_ts group rather than per row. The issue is real but the improved code is not quite correct as written.

Low
Reset offload latch on sendInit failure path

When drainOnInit is false, _setOffloadActive(false) is called but there is no
corresponding reset on the failure path inside the catch block. If sendInit(drain:
false) throws, _offloadActive was set to false before the throw, which is correct
for the deferred case — but the broader _setOffloadActive latch pattern in this
codebase (§4.3) requires that any flag set on the success path is also explicitly
cleared on the exception path. More critically, when drainOnInit is true,
_setOffloadActive(true) is called and then sendInit may throw, leaving
_offloadActive stuck true with no finally reset — the same sticky-latch pattern the
repo's own commit history flags as recurring.

lib/ble/ble_engine.dart [1461-1467]

 _setOffloadActive(drainOnInit);
   // Only a real drain spends the backfill floor; a deferred one leaves it
   // open so a foreground trigger can retry as soon as the phone corrects.
   if (drainOnInit) _lastBackfillAt = _wallSecs();
-  await sendInit(drain: drainOnInit); // seq4 triggers the offload flood
+  try {
+    await sendInit(drain: drainOnInit); // seq4 triggers the offload flood
+  } catch (e) {
+    _setOffloadActive(false);
+    rethrow;
+  }
   return true;
 } catch (e) {
   _log('connect setup failed: $e');
Suggestion importance[1-10]: 5

__

Why: The concern about _offloadActive being stuck true if sendInit throws is valid, but the outer catch (e) block already handles connect setup failures and the existing code structure suggests the connection would be torn down on any exception here. The improved code structure is reasonable but the actual risk depends on whether the outer catch properly resets state, making this a moderate concern.

Low
General
Guard v33 migration against already-migrated stores

_rekeyDecodedStoreByRecTs calls _createDecodedStore internally, which creates the
tables with the NEW rec_ts-keyed schema. If the DB is already at the new schema
(e.g. a fresh install that went through v19 with the updated
_rekeyDecodedStoreByRecTs), the CREATE TABLE IF NOT EXISTS is a no-op and the
subsequent INSERT OR REPLACE … SELECT copies from the already-correct table into the
temp table and back — harmless but wasteful. However, if the DB was created fresh at
v19 (after this PR) and then oldV < 33 fires on a same-version merged build, the
decoded tables are already rec_ts-keyed and _rekeyDecodedStoreByRecTs will still run
the full rename-aside-copy-rename cycle. The _rebuildCanonicalDecodedStore guard
pattern (checking for the counter column in decoded_rr before proceeding) should be
applied here too, so the v33 step is a true no-op on already-migrated stores and
avoids touching the sole copy of user data unnecessarily.

lib/data/db.dart [471-477]

 if (oldV < 33) {
       // RE-KEY the decoded ledger off the volatile record `counter` onto
-      // rec_ts. The counter resets to ~0 on every reboot, so counter-as-PK
-      // let a post-reboot second REPLACE-evict a pre-reboot one — silently,
-      // unrecoverably deleting a 1 Hz row (raw_records is dropped).
-      await _rekeyDecodedStoreByRecTs(db);
+      // rec_ts. Skip if already rec_ts-keyed (e.g. fresh install via the
+      // updated v19 path) — mirrors _rebuildCanonicalDecodedStore's guard.
+      final rrCols = await db.rawQuery('PRAGMA table_info(decoded_rr)');
+      final needsRekey = rrCols.isNotEmpty &&
+          rrCols.any((c) => c['name'] == 'counter');
+      if (needsRekey) await _rekeyDecodedStoreByRecTs(db);
     }
Suggestion importance[1-10]: 5

__

Why: The suggestion is logically sound — adding a guard mirrors the pattern already used in _rebuildCanonicalDecodedStore. However, _rekeyDecodedStoreByRecTs itself calls _createDecodedStore which uses CREATE TABLE IF NOT EXISTS, and the rename-aside-copy-rename cycle on an already-correct store is safe (just wasteful). The risk of data loss from running it unnecessarily is low, making this a minor optimization rather than a critical fix.

Low
Normalize timezones before computing grace window difference

DateTime.difference returns a Duration whose inSeconds is computed from the total
microseconds, which is correct. However, since and now may be in different time
zones (e.g., since captured as DateTime.now() local and now passed as UTC or vice
versa). If one is local and the other is UTC, difference will still be correct in
absolute terms, but if both are constructed inconsistently across call sites the
comparison could be off by the UTC offset. Ensure all call sites pass both arguments
in the same timezone (both local or both UTC) to avoid a DST-sized error in the
grace window check.

lib/sync/sync_policy.dart [166-168]

 static bool suspectGraceExpired(DateTime? since, DateTime now) =>
     since != null &&
-    now.difference(since).inSeconds >= suspectGraceSeconds;
+    now.toUtc().difference(since.toUtc()).inSeconds >= suspectGraceSeconds;
Suggestion importance[1-10]: 4

__

Why: The suggestion to call .toUtc() on both DateTime arguments before computing difference is a valid defensive practice to avoid timezone inconsistencies. However, DateTime.difference in Dart already accounts for timezone correctly in absolute terms, so this is a minor robustness improvement rather than a critical bug fix.

Low
Verify page boundaries don't double-count boundary beats

The page's decodedRows are ordered rec_ts ASC, so first gives the minimum and last
gives the maximum rec_ts for the page. However, decodedRrByRecTsRange is an
inclusive range query, meaning any RR beats whose rec_ts falls between two
different pages' boundaries could be included in both pages or missed entirely if
a page boundary splits a second. Since decoded_rr rows share the exact rec_ts of
their parent decoded_onehz row, the range query is correct only when the page
boundaries are tight. Verify that decodedRrByRecTsRange uses BETWEEN fromRecTs AND
toRecTs (inclusive) and that consecutive pages do not overlap on their boundary
rec_ts values — otherwise beats at the shared boundary second will be double-counted
into both pages' RR sets, corrupting HRV derivation.

lib/compute/derivation_engine.dart [1842-1849]

 final firstRecTs = (decodedRows.first['rec_ts'] as num?)?.toInt();
 final lastRecTs = (decodedRows.last['rec_ts'] as num?)?.toInt();
 final rrRows = firstRecTs == null || lastRecTs == null
     ? const <Map<String, dynamic>>[]
     : await LocalDb.decodedRrByRecTsRange(
         fromRecTs: firstRecTs,
         toRecTs: lastRecTs,
       );
+// Ensure pages are non-overlapping: the next page must start at lastRecTs + 1,
+// not at lastRecTs, to avoid double-counting beats at the boundary second.
Suggestion importance[1-10]: 3

__

Why: This suggestion asks the reviewer to verify behavior rather than proposing a concrete fix. The improved_code is essentially the same as existing_code with only a comment added, and the concern about double-counting is speculative without evidence from the PR diff that pages overlap on boundary rec_ts values.

Low

Previous suggestions

Suggestions up to commit 8573d7e
CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard against empty page before indexing

When decodedRows is empty, accessing .first and .last will throw a StateError at
runtime. The existing counter-based code had the same shape, but the new rec_ts path
is equally exposed. Guard against an empty page before indexing into it.

lib/compute/derivation_engine.dart [1842-1849]

-final firstRecTs = (decodedRows.first['rec_ts'] as num?)?.toInt();
-final lastRecTs = (decodedRows.last['rec_ts'] as num?)?.toInt();
+final firstRecTs = decodedRows.isEmpty ? null : (decodedRows.first['rec_ts'] as num?)?.toInt();
+final lastRecTs = decodedRows.isEmpty ? null : (decodedRows.last['rec_ts'] as num?)?.toInt();
 final rrRows = firstRecTs == null || lastRecTs == null
     ? const <Map<String, dynamic>>[]
     : await LocalDb.decodedRrByRecTsRange(
         fromRecTs: firstRecTs,
         toRecTs: lastRecTs,
       );
Suggestion importance[1-10]: 7

__

Why: Accessing .first and .last on an empty decodedRows list would throw a StateError at runtime. The suggestion correctly adds an isEmpty guard before indexing, which is a valid defensive improvement, though in practice the paging logic may guarantee non-empty pages.

Medium
Prevent double re-key on upgrade paths from before v19

_rekeyDecodedStoreByRecTs is called in both the oldV < 19 branch and the oldV < 33
branch. A database upgrading from v19–v31 will execute the oldV < 33 branch
(correct), but a database upgrading from v11–v18 will execute BOTH branches
sequentially — the first call converts the counter-keyed tables to rec_ts-keyed, and
the second call then runs _rekeyDecodedStoreByRecTs again on the already-converted
tables. While _rekeyDecodedStoreByRecTs calls _createDecodedStore (which is now a
no-op via IF NOT EXISTS on the new schema), the INSERT OR REPLACE copy from
decoded_onehz into _decoded_onehz_v33 will still execute and is wasteful and
potentially risky. The oldV < 33 branch should guard against re-running when the
store was already re-keyed in the same upgrade session (i.e., skip if oldV < 19).

lib/data/db.dart [471-477]

-if (oldV < 19) {
-      // The v17 step (or a v11-16 origin) may leave OLD counter-keyed decoded
-      // tables here; the backfill below writes through the rec_ts-keyed
-      // _queueDecodedOneHz, so convert to the current schema first (preserving
-      // any existing rows), then reconstruct the rest from raw_records.
-      await _rekeyDecodedStoreByRecTs(db);
-      await _backfillDecodedStore(db);
-      ...
-    }
-    ...
-    if (oldV < 33) {
+if (oldV < 33 && oldV >= 19) {
       // RE-KEY the decoded ledger off the volatile record `counter` onto
       // rec_ts. The counter resets to ~0 on every reboot, so counter-as-PK
       // let a post-reboot second REPLACE-evict a pre-reboot one — silently,
       // unrecoverably deleting a 1 Hz row (raw_records is dropped).
+      // (Databases upgrading from < v19 already ran _rekeyDecodedStoreByRecTs
+      // in the oldV < 19 branch above; skip to avoid a redundant double-rekey.)
       await _rekeyDecodedStoreByRecTs(db);
     }
Suggestion importance[1-10]: 6

__

Why: The concern is valid: a DB upgrading from v11–v18 runs _rekeyDecodedStoreByRecTs twice in the same session. However, _rekeyDecodedStoreByRecTs is designed to be idempotent (it calls _createDecodedStore with IF NOT EXISTS, drops temp tables up front, and uses INSERT OR REPLACE), so the double-run is safe though wasteful. The fix is reasonable but the risk is low given the idempotency guarantees.

Low
Reset offload latch when sendInit throws during connect

When drainOnInit is false, _setOffloadActive(false) is called but if sendInit
subsequently throws, the catch block at the end of _doConnect returns false without
clearing _offloadActive — however since it was set to false already that is fine.
The real issue is: when drainOnInit is true and sendInit throws, _offloadActive was
set to true by _setOffloadActive(true) but the catch path does not call
_setOffloadActive(false). This is the sticky boolean latch pattern documented in
AGENTS.md §4.3 — _offloadActive stays true after a failed sendInit, wedging future
sync attempts. The _setOffloadActive(true) call should be moved inside a try block
with a finally that resets it on failure, or the catch block should reset it.

lib/ble/ble_engine.dart [1445-1461]

-final drainOnInit = !_phoneClockSuspect;
-  if (!drainOnInit) {
-    _clockPausedOffloads++;
-    _log(
-      '[SYNC] INIT drain DEFERRED — phone clock appears wrong relative to '
-      'the strap RTC; not draining history until they agree '
-      '(deferred_total=$_clockPausedOffloads).',
-    );
-  }
-  _setOffloadActive(drainOnInit);
+_setOffloadActive(drainOnInit);
   // Only a real drain spends the backfill floor; a deferred one leaves it
   // open so a foreground trigger can retry as soon as the phone corrects.
   if (drainOnInit) _lastBackfillAt = _wallSecs();
-  await sendInit(drain: drainOnInit); // seq4 triggers the offload flood
+  try {
+    await sendInit(drain: drainOnInit); // seq4 triggers the offload flood
+  } catch (e) {
+    _setOffloadActive(false);
+    rethrow;
+  }
   return true;
 } catch (e) {
   _log('connect setup failed: $e');
Suggestion importance[1-10]: 5

__

Why: The concern about _offloadActive staying true if sendInit throws is legitimate — this could wedge future sync attempts. However, the outer catch (e) block in _doConnect likely handles cleanup and returns false, and the existing code structure may already handle this via other mechanisms. The suggested fix is reasonable but the improved_code introduces a nested try/catch that interacts awkwardly with the outer catch block structure shown.

Low
General
Align RR beat winner selection with onehz row winner during re-key

The comment says "Deterministic newest-wins: ORDER BY rec_ts, counter so INSERT OR
REPLACE on the rec_ts PK keeps the highest-counter (latest-offloaded) row per
second." However, INSERT OR REPLACE with ORDER BY rec_ts ASC, counter ASC means the
LAST row inserted for each rec_ts wins — which is the row with the HIGHEST counter
(ascending order, last inserted wins via REPLACE). This is correct. But the comment
says "highest-counter (latest-offloaded)" which is the intended semantic. The issue
is that counter ASC means lower counters are inserted first and higher counters
overwrite them — this is correct behavior. However, for the decoded_rr copy, ORDER
BY rr_ts_ms ASC, beat_index ASC is used, but the source table may have a counter
column (old schema) and beats from different counters for the same rec_ts could
interleave. The RR copy should also order by counter to ensure the same winning
counter's beats are kept last, matching the onehz winner selection.

lib/data/db.dart [2371-2374]

 await db.execute(
-  'INSERT OR REPLACE INTO _decoded_onehz_v33 '
-  '(rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw) '
-  'SELECT rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw '
-  'FROM decoded_onehz ORDER BY rec_ts ASC, counter ASC',
+  'INSERT OR REPLACE INTO _decoded_rr_v33 (rec_ts, beat_index, rr_ts_ms, rr_ms) '
+  'SELECT rr_ts_ms / 1000, beat_index, rr_ts_ms, rr_ms '
+  'FROM decoded_rr ORDER BY rr_ts_ms ASC, beat_index ASC, counter ASC',
 );
Suggestion importance[1-10]: 3

__

Why: The suggestion proposes adding counter ASC to the decoded_rr ORDER BY during re-key to align beat winner selection with the onehz winner. However, the old decoded_rr schema uses PRIMARY KEY (counter, beat_index) — beats from different counters for the same rec_ts are already distinct by beat_index, and the re-key derives rec_ts from rr_ts_ms / 1000. The counter column may not exist in all source schemas (the code guards for this), making the suggestion potentially incorrect for some upgrade paths.

Low
Suggestions up to commit 90f9588
CategorySuggestion                                                                                                                                    Impact
General
Guard missing counter column in legacy import path

When importing a legacy decoded_rr row (no rec_ts column), rec_ts is derived from
rr_ts_ms and then the row is inserted with ConflictAlgorithm.replace. However, the
row still carries the old counter column from the foreign export, but the new schema
has rec_ts as PRIMARY KEY and counter as a plain NOT NULL column. If the legacy row
has no counter field at all (schema predates it), the insert will fail the NOT NULL
constraint. Additionally, the row map may contain the old counter column but not
rec_ts, so the insert could also fail if the new schema's rec_ts NOT NULL PK is not
satisfied for non-legacy rows. A guard should ensure counter is present (defaulting
to 0 as a forensic placeholder) when importing legacy rows that lack it.

lib/data/db.dart [4046-4051]

 if (t == 'decoded_rr' &&
     row['rec_ts'] == null &&
     row['rr_ts_ms'] != null) {
   row['rec_ts'] = ((row['rr_ts_ms'] as num).toInt()) ~/ 1000;
+  row['counter'] ??= 0; // legacy export may lack counter; 0 is a forensic placeholder
 }
 batch.insert(t, row, conflictAlgorithm: ConflictAlgorithm.replace);
Suggestion importance[1-10]: 5

__

Why: The concern about a legacy decoded_rr row lacking a counter column is valid — if the old schema predates the counter column in decoded_rr, the insert into the new schema (which has counter NOT NULL in decoded_onehz but not in decoded_rr) could fail. However, decoded_rr in the new schema does NOT have a counter column, so this concern only applies to decoded_onehz. The suggestion's scope is slightly off but the underlying concern about missing columns in legacy imports is worth considering.

Low
Boolean latch lacks reset on all failure paths

_phoneClockSuspect is a boolean latch that is set in the clock_epoch handler and
cleared when a subsequent read agrees. However, if _startHistoricalRefresh returns
early here (deferred path), _setOffloadActive(false) is called but there is no
try/finally around the early-return block. If a future code path between
_setOffloadActive(true) (called by the caller of _startHistoricalRefresh) and this
early return throws before reaching _setOffloadActive(false), the latch stays set
and sync wedges — the same sticky-latch pattern the repo's own commit history flags
as recurring. The _setOffloadActive(false) call on the defer path should be in a
finally block (or the caller must guarantee it), consistent with the pattern used on
the normal drain path.

lib/ble/ble_engine.dart [1613-1622]

 if (_phoneClockSuspect) {
   _clockPausedOffloads++;
   _log(
     '[SYNC] refresh($reason) DEFERRED — phone clock appears wrong relative '
     'to the strap RTC; not draining history until they agree '
     '(deferred_total=$_clockPausedOffloads).',
   );
   _setOffloadActive(false);
   return;
 }
+// NOTE: wrap the remainder of _startHistoricalRefresh in try/finally
+// so _setOffloadActive(false) is guaranteed on every exit path, not
+// only the clock-defer branch — matching the pattern the repo requires
+// for every boolean latch (see AGENTS.md §4.3).
Suggestion importance[1-10]: 2

__

Why: The improved_code is essentially the same as the existing_code with only a comment added, which means no actual code change is proposed. The suggestion identifies a theoretical risk but the existing_code and improved_code are functionally identical, making this a documentation-only suggestion that doesn't resolve the stated concern.

Low
Possible issue
Migration excludes orphan RR beats via counter join

The comment says "newest-wins: ORDER BY rec_ts, counter so INSERT OR REPLACE keeps
the highest-counter row per second," but ORDER BY rec_ts ASC, counter ASC inserts in
ascending counter order, meaning the LAST inserted (highest counter) wins via
REPLACE — that is correct. However, the decoded_rr copy derives rec_ts from rr_ts_ms
/ 1000 and uses INSERT OR REPLACE ordered by rr_ts_ms ASC, beat_index ASC. If the
old schema had orphan beats (owning counter evicted, so rr_ts_ms maps to a rec_ts
that now belongs to a different counter's row), those beats will be silently
imported under the wrong second. The migration should join against the surviving
decoded_onehz rows to exclude orphan beats, exactly as _rebuildCanonicalDecodedStore
did with its counter-join.

lib/data/db.dart [2371-2374]

 await db.execute(
-  'INSERT OR REPLACE INTO _decoded_onehz_v33 '
-  '(rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw) '
-  'SELECT rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw '
-  'FROM decoded_onehz ORDER BY rec_ts ASC, counter ASC',
+  'INSERT OR REPLACE INTO _decoded_rr_v33 (rec_ts, beat_index, rr_ts_ms, rr_ms) '
+  'SELECT rr.rr_ts_ms / 1000, rr.beat_index, rr.rr_ts_ms, rr.rr_ms '
+  'FROM decoded_rr rr '
+  'JOIN decoded_onehz d ON d.counter = rr.counter '
+  'ORDER BY rr.rr_ts_ms ASC, rr.beat_index ASC',
 );
Suggestion importance[1-10]: 4

__

Why: The suggestion raises a valid concern about orphan beats in the old counter-keyed schema being imported under wrong seconds. However, the PR's comment explicitly states "Pre-fix orphan beats (owning row evicted) re-home onto their real second here" — the design intentionally re-homes them by rr_ts_ms / 1000 rather than excluding them. The join approach would silently drop those beats, which may be worse than re-homing them. The suggestion is debatable in correctness.

Low
Suggestions up to commit 25a75f4
CategorySuggestion                                                                                                                                    Impact
General
Guard re-key migration against already-migrated schema

The v32 migration runs _createRawArchive(db) which uses CREATE TABLE IF NOT EXISTS,
but the old raw_archive table was just renamed to _raw_archive_old — so the IF NOT
EXISTS guard will create a fresh hex-keyed table correctly. However,
_createRawArchive also creates an index named idx_raw_archive_captured; the comment
warns about the "leaked-_new-index footgun" but the DROP INDEX IF EXISTS
idx_raw_archive_captured only runs in the hasArchive branch before
_createRawArchive. In the else branch (no existing table), _createRawArchive is
called directly and will create the index fresh — that is fine. But if
_repairOpenSchema previously created a counter-keyed raw_archive with
idx_raw_archive_captured already present, and then this migration runs, the DROP
INDEX before _createRawArchive is correct. The real gap: counter is declared NOT
NULL in the new _createRawArchive schema per the diff (counter INTEGER, — actually
nullable), but the INSERT copies counter from the old table where it was INTEGER
PRIMARY KEY (always non-null). This is fine. The actual bug:
_rekeyDecodedStoreByRecTs calls _createDecodedStore(db) at its top, which uses
CREATE TABLE IF NOT EXISTS with the NEW rec_ts-keyed schema. If the old
counter-keyed tables already exist (the v33 path), IF NOT EXISTS is a no-op and the
old schema stays — the subsequent INSERT SELECT then reads from the old schema into
the new temp tables correctly. But after DROP TABLE decoded_onehz and RENAME
_decoded_onehz_v33 TO decoded_onehz, the _createDecodedStore call at the top already
created nothing (tables existed). This is correct. No runtime bug here either. The
genuine issue: _rekeyDecodedStoreByRecTs is called from BOTH oldV < 19 (the backfill
path) AND oldV < 33. On a fresh install going through oldV < 19, _createDecodedStore
is called first (new schema), then _rekeyDecodedStoreByRecTs is called — which calls
_createDecodedStore again (no-op), creates temp tables, copies from the (empty)
new-schema tables, drops and renames. This is a wasteful but harmless no-op on fresh
installs. On the oldV < 33 path it is the real migration. No critical bug, but the
double-call on the oldV < 19 path is non-idempotent in the sense that it runs the
full rename dance on empty tables unnecessarily. The _rebuildCanonicalDecodedStore
early-exit guard (checking for counter column absence) should also be applied to
_rekeyDecodedStoreByRecTs to skip the rename dance when the store is already
rec_ts-keyed (e.g. fresh install going through oldV < 19 then oldV < 33).

lib/data/db.dart [2329-2335]

-if (oldV < 32) {
-      ...
-      if (hasArchive) {
-        await db.execute('ALTER TABLE raw_archive RENAME TO _raw_archive_old');
-        await db.execute('DROP INDEX IF EXISTS idx_raw_archive_captured');
-        await _createRawArchive(db);
-        await db.execute(
-          'INSERT OR IGNORE INTO raw_archive '
-          '(hex, counter, packet_type, rec_ts, captured_at, reason) '
-          'SELECT hex, counter, packet_type, rec_ts, captured_at, reason '
-          'FROM _raw_archive_old',
-        );
-        await db.execute('DROP TABLE _raw_archive_old');
-      } else {
-        await _createRawArchive(db);
-      }
-    }
-    if (oldV < 33) {
-      await _rekeyDecodedStoreByRecTs(db);
-    }
+static Future<void> _rekeyDecodedStoreByRecTs(Database db) async {
+  // Skip if already rec_ts-keyed (fresh install or already migrated).
+  final rrCols = await db.rawQuery('PRAGMA table_info(decoded_rr)');
+  if (rrCols.isNotEmpty && !rrCols.any((c) => c['name'] == 'counter')) return;
+  await _createDecodedStore(db);
+  await db.execute('DROP TABLE IF EXISTS _decoded_onehz_v33');
+  await db.execute('DROP TABLE IF EXISTS _decoded_rr_v33');
+  // ... rest of the method unchanged
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that _rekeyDecodedStoreByRecTs is called from both the oldV < 19 and oldV < 33 paths, causing a wasteful rename dance on empty tables for fresh installs. Adding an early-exit guard (similar to _rebuildCanonicalDecodedStore) would make the function idempotent and avoid unnecessary operations, which is a meaningful correctness/efficiency improvement.

Low
Fix non-deterministic beat survivor on reboot-collision re-key

The comment says "newest-wins: ORDER BY rec_ts, counter so INSERT OR REPLACE keeps
the highest-counter row per second," but ORDER BY rec_ts ASC, counter ASC inserts in
ascending counter order, meaning the LAST inserted (highest counter) wins via
REPLACE — this is correct only because REPLACE on a PK overwrites the previous row.
However, if the intent is truly "highest counter wins," the ordering should be
counter ASC so the highest counter is inserted last and survives. With counter ASC
the highest counter is indeed last, so the logic is accidentally correct, but the
comment is misleading and the sort key should be explicit: ORDER BY rec_ts ASC,
counter ASC does produce highest-counter-wins via REPLACE, so no runtime bug exists
here. The real issue is in _rekeyDecodedStoreByRecTs for decoded_rr: beats are
re-homed via rr_ts_ms / 1000, but the old schema's decoded_rr is keyed by (counter,
beat_index) — if two counters share the same rr_ts_ms second (the exact
reboot-collision case being fixed), their beats collide on (rec_ts, beat_index) and
only one survives. The INSERT should use ORDER BY rr_ts_ms ASC, counter ASC,
beat_index ASC to make the winner deterministic (highest counter's beats survive),
matching the onehz table's newest-wins policy.

lib/data/db.dart [2371-2374]

 await db.execute(
-  'INSERT OR REPLACE INTO _decoded_onehz_v33 '
-  '(rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw) '
-  'SELECT rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw '
-  'FROM decoded_onehz ORDER BY rec_ts ASC, counter ASC',
+  'INSERT OR REPLACE INTO _decoded_rr_v33 (rec_ts, beat_index, rr_ts_ms, rr_ms) '
+  'SELECT rr_ts_ms / 1000, beat_index, rr_ts_ms, rr_ms '
+  'FROM decoded_rr ORDER BY rr_ts_ms ASC, beat_index ASC, counter ASC',
 );
Suggestion importance[1-10]: 4

__

Why: The suggestion identifies a potential non-determinism in beat ordering during the _rekeyDecodedStoreByRecTs migration when two counters share the same rr_ts_ms second. However, the improved_code adds counter ASC to the ORDER BY but the old decoded_rr schema (counter-keyed) does have a counter column available during migration, making this a valid concern. The impact is limited to the migration path and the current code is "accidentally correct" per the suggestion's own analysis, so the fix is a minor improvement for clarity and determinism.

Low
Ensure FULL pragma is inside the finally-guarded try block

If the PRAGMA synchronous=FULL call throws and is swallowed, the connection remains
at NORMAL — but the finally block still executes PRAGMA synchronous=NORMAL, which is
a harmless no-op. However, if PRAGMA synchronous=FULL succeeds and then the
db.transaction(...) call itself throws synchronously before entering the try body
(e.g. the db object is in a bad state), the finally correctly restores NORMAL. The
real gap: the PRAGMA synchronous=FULL is outside the try/finally that restores it.
If PRAGMA synchronous=FULL succeeds but the subsequent db.transaction(...) call
throws before the finally is established — this cannot happen in Dart since
try/finally is established before any code in the try block runs. The structure is
actually correct. The genuine issue is that if PRAGMA synchronous=FULL succeeds but
then db.transaction is never entered (impossible in this structure), FULL would
leak. The structure is sound. No critical bug here. However, the PRAGMA
synchronous=FULL should be inside the outer try so that if it succeeds, the finally
is guaranteed to run the restore — which is already the case since the finally is on
the outer try that wraps db.transaction. Move PRAGMA synchronous=FULL inside the
outer try block so the finally is guaranteed to restore NORMAL even if the FULL
pragma itself partially succeeds on some SQLite implementations.

lib/data/db.dart [1271-1361]

 try {
-  await db.execute('PRAGMA synchronous=FULL');
-} catch (_) {
-  /* durability upgrade is best-effort — NORMAL still commits correctly */
-}
-try {
+  try {
+    await db.execute('PRAGMA synchronous=FULL');
+  } catch (_) {
+    /* durability upgrade is best-effort — NORMAL still commits correctly */
+  }
   await db.transaction((txn) async {
-    ...
+    // ... unchanged transaction body
   });
 } finally {
-  // ALWAYS restore NORMAL — even if the commit threw — so a leaked FULL does
-  // not fsync every subsequent write on this connection. Non-fatal.
   try {
     await db.execute('PRAGMA synchronous=NORMAL');
   } catch (_) {
-    /* non-fatal — see open-time PRAGMA discipline */
+    /* non-fatal */
   }
 }
Suggestion importance[1-10]: 2

__

Why: The suggestion's own analysis concludes the existing structure is sound — the PRAGMA synchronous=FULL being outside the try/finally doesn't create a leak risk in Dart's execution model. The improved_code restructures the code but provides no actual safety improvement, and the suggestion itself acknowledges there is no critical bug here.

Low

…clock-skew P1)

The plausibility gate used the phone wall clock as ground truth. If the phone
clock ran >1 day slow (dead-battery reboot, bad NTP, manual set-back), the
strap's correctly-stamped records read as 'implausibly future', got dropped,
and a mixed-burst ACK then TRIMMED them off the band — silent, permanent loss.
Option A (trust the strap's GET_DATA_RANGE window instead) can't work: that
window is itself discarded via isCorruptFutureRtc against the same wrong phone
clock, so it's unavailable exactly when needed.

Fix (option D): don't drain-and-trim under an untrustworthy clock. Before each
history refresh, read the strap RTC and compare; if it reads a PLAUSIBLE time
but >1 day ahead of the phone (ClockPolicy.phoneClockSuspect), the phone clock
is likely slow, so DEFER the offload — the strap retains every record until the
clocks agree (the phone almost always self-corrects via NTP within minutes).
SET_CLOCK is deliberately NOT issued in this case: pushing the strap back to the
slow phone would corrupt a correct RTC. The strap-behind and unset-RTC cases are
unchanged (still corrected forward by shouldSetClock); only the future-skew case
defers. Exposes historyPausedForClock for the UI so the pause is visible.

Adds ClockPolicy.phoneClockSuspect unit coverage (agree / future-skew / behind /
unset boundaries).
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 90f9588

@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/db_p0_fixes_test.dart (1)

396-432: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add a shrinking-beat-count case to this import fixture.

The foreign export supplies three beats for collideTs and the local row also has three, so every beat_index is replaced and the assertion on line 414 passes.

The import path merges decoded_rr with INSERT OR REPLACE per row and performs no delete for the second, unlike _queueDecodedOneHz. A foreign export with fewer beats for a colliding second would leave the local high-index beats in place.

See the consolidated comment on lib/data/db.dart for the root cause.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/db_p0_fixes_test.dart` around lines 396 - 432, Extend the import fixture
around the collideTs case to cover a foreign export with fewer beats than the
local second, while retaining the existing collision assertions. Assert that the
imported beat set exactly matches the foreign beats and that no higher-index
local beats remain, then keep the orphan and timestamp consistency checks
intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/ble/ble_engine.dart`:
- Around line 1602-1622: Gate every history-start path on a session-bound,
completed GET_CLOCK response rather than the fixed delay and cached
_phoneClockSuspect flag: update _startHistoricalRefresh and the initial
connection flow around setClock/sendInit to await and apply the response before
any SET_CLOCK or historical-data trigger. Ensure delayed or missing responses
cannot proceed, preserve the defer behavior for a suspect phone clock, and add
regressions covering responses arriving after 120 ms and the first-connection
path.

In `@lib/compute/derivation_engine.dart`:
- Around line 1839-1849: Update the algorithm version constant kAlgoVersion from
62 to the next version, and add a changelog entry documenting the decoded RR
lookup change in the derivation engine so finalized days are recalculated with
RR data.

In `@lib/data/db.dart`:
- Around line 4041-4050: The import path in lib/data/db.dart lines 4041-4050
must replace each collided decoded_rr beat set rather than patching it: queue a
DELETE for every rec_ts represented by the page before its inserts, using the
same batch and transaction, and revise the comment to reflect that guard. Extend
test/db_p0_fixes_test.dart lines 396-432 so collideTs has fewer foreign beats
than local beats and assert only the foreign beat set remains.
- Around line 4041-4050: Update the decoded_rr legacy rec_ts derivation to
validate rr_ts_ms with the existing numeric-conversion approach used by
_PrepareAccumulator._num before converting it; only derive row['rec_ts'] for
values that are safely numeric, and avoid throwing for non-numeric strings
during the transaction.
- Around line 2537-2554: Update _queueDecodedOneHz to resolve recTs through the
existing _recTsFor fallback instead of using raw.recTs ?? decoded.tsEpoch, so an
explicit raw.recTs value of 0 falls back to decoded.tsEpoch before insertion
into decoded_onehz. Preserve nonzero stored timestamps unchanged.

In `@pubspec.yaml`:
- Around line 263-266: Update the sqflite_common dependency declaration used by
the ACK commit sync test to pin an exact version whose experimental
SqfliteDatabaseFactoryLogger constructor has been tested, or replace that
constructor usage with a stable logging mechanism. Keep the existing logger
symbols and test behavior otherwise unchanged.

In `@test/db_storage_hygiene_test.dart`:
- Around line 42-65: Update the test `rec_ts-range reads on decoded_rr are
served by the PK auto-index` to remove the assertion for the internal
`sqlite_autoindex_decoded_rr_1` name. Assert that the uppercased query-plan
detail contains `SEARCH`, does not contain `USE TEMP B-TREE`, and does not match
`SCAN TABLE DECODED_RR`, while preserving the existing planner-fallback
diagnostics.

---

Outside diff comments:
In `@test/db_p0_fixes_test.dart`:
- Around line 396-432: Extend the import fixture around the collideTs case to
cover a foreign export with fewer beats than the local second, while retaining
the existing collision assertions. Assert that the imported beat set exactly
matches the foreign beats and that no higher-index local beats remain, then keep
the orphan and timestamp consistency checks intact.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8e60a82a-5d6e-44d5-99e5-580abc10e1b7

📥 Commits

Reviewing files that changed from the base of the PR and between 6bddec4 and 90f9588.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (16)
  • lib/ble/ble_engine.dart
  • lib/compute/derivation_engine.dart
  • lib/compute/derive_prepare.dart
  • lib/data/db.dart
  • lib/sync/sync_policy.dart
  • pubspec.yaml
  • test/ack_commit_sync_full_test.dart
  • test/ble_engine_test.dart
  • test/db_integrity_test.dart
  • test/db_migration_ladder_test.dart
  • test/db_p0_fixes_test.dart
  • test/db_paged_import_export_test.dart
  • test/db_storage_hygiene_test.dart
  • test/local_persistence_test.dart
  • test/raw_archive_test.dart
  • test/sync_policy_test.dart

Comment thread lib/ble/ble_engine.dart
Comment on lines +1839 to 1849
// The page is ordered rec_ts ASC, so first = min second, last = max.
// decoded_rr shares the rec_ts key, so this pulls exactly the page's
// beats — no counter span (which broke across the strap's reboot reset).
final firstRecTs = (decodedRows.first['rec_ts'] as num?)?.toInt();
final lastRecTs = (decodedRows.last['rec_ts'] as num?)?.toInt();
final rrRows = firstRecTs == null || lastRecTs == null
? const <Map<String, dynamic>>[]
: await LocalDb.decodedRrByCounterRange(
fromCounter: firstCounter,
toCounter: lastCounter,
: await LocalDb.decodedRrByRecTsRange(
fromRecTs: firstRecTs,
toRecTs: lastRecTs,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether kAlgoVersion changed in this PR and whether a changelog entry accompanies it.
set -euo pipefail

echo "== kAlgoVersion declaration =="
rg -nP --type=dart -C4 '\bkAlgoVersion\s*=' || echo "not found"

echo "== kAlgoVersion changes in this branch vs base =="
git diff origin/HEAD... -- '*.dart' 2>/dev/null | rg -n -C4 'kAlgoVersion' || echo "no diff hunks touching kAlgoVersion"

echo "== changelog files =="
fd -i -t f 'changelog' | head -20

echo "== changelog diff =="
git diff origin/HEAD... -- '*CHANGELOG*' 2>/dev/null | head -60 || echo "no changelog diff"

Repository: OpenStrap/edge

Length of output: 193


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate analytics/version files =="
git ls-files | rg '(^|/)(derivation_engine\.dart|.*CHANGELOG.*|changelog.*)$|(^|/)pubspec\.yaml$'

echo "== Version declarations and changelog entries =="
rg -n -i -C3 'kAlgoVersion|algo.?version|changelog|change log' --glob '*.dart' --glob '*.md' --glob '*.yaml' . || true

echo "== Repository refs and diff summary =="
git branch --all --no-color
git diff --stat
git diff --stat origin/HEAD...HEAD 2>/dev/null || true

echo "== Relevant derivation-engine diff =="
git diff -- lib/compute/derivation_engine.dart

Repository: OpenStrap/edge

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Revisions =="
git rev-parse --show-toplevel
git rev-parse HEAD
git rev-parse origin/HEAD 2>/dev/null || true

echo "== Version/changelog section =="
sed -n '250,710p' lib/compute/derivation_engine.dart

echo "== Current change around the reviewed code =="
sed -n '1800,1870p' lib/compute/derivation_engine.dart

echo "== Version-related diff only =="
git diff --unified=3 origin/HEAD...HEAD -- lib/compute/derivation_engine.dart 2>/dev/null \
  | rg -n -C5 'kAlgoVersion|^[-+].*// v[0-9]+|^[-+].*version' || true

echo "== Reviewed-code diff only =="
git diff --unified=8 origin/HEAD...HEAD -- lib/compute/derivation_engine.dart 2>/dev/null \
  | rg -n -C12 'decodedRrByRecTsRange|counter|decodedRows' || true

Repository: OpenStrap/edge

Length of output: 36432


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import re
import subprocess

path = "lib/compute/derivation_engine.dart"
for label, rev in (("base", "origin/HEAD"), ("head", "HEAD")):
    text = subprocess.check_output(["git", "show", f"{rev}:{path}"], text=True)
    m = re.search(r"const\s+int\s+kAlgoVersion\s*=\s*(\d+)\s*;", text)
    print(f"{label}: kAlgoVersion={m.group(1) if m else 'not found'}")
PY

echo "== Derivation gate around finalized-day selection =="
sed -n '1100,1160p' lib/compute/derivation_engine.dart
sed -n '1310,1360p' lib/compute/derivation_engine.dart
sed -n '1900,1950p' lib/compute/derivation_engine.dart

echo "== Finalized-day lookup implementation =="
rg -n -C8 'finalizedDayIds|dayResultIds' lib/data/db.dart lib/compute/derivation_engine.dart

echo "== RR range implementations =="
rg -n -C12 'decodedRrByRecTsRange|decodedRrByCounterRange' lib/data/db.dart lib/compute/derivation_engine.dart

Repository: OpenStrap/edge

Length of output: 22826


Bump kAlgoVersion and add a changelog entry for the RR lookup change. Both base and head remain at version 62. Finalized days at version 62 will retain RR-less RMSSD, HRV, and readiness results.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 1839 - 1849, Update the
algorithm version constant kAlgoVersion from 62 to the next version, and add a
changelog entry documenting the decoded RR lookup change in the derivation
engine so finalized days are recalculated with RR data.

Source: Coding guidelines

Comment thread lib/data/db.dart
Comment on lines 2537 to +2554
static int _queueDecodedOneHz(Batch batch, RawRecord raw, Sample? sample) {
final decoded = _decodeOneHzSample(raw, preferred: sample);
if (decoded == null) return 0;
final recTs = raw.recTs ?? decoded.tsEpoch;
// TIME-KEYED, NEWEST-WINS (noop/WHOOP-4 model: dedupe records by their
// embedded timestamp, not by a counter). decoded_onehz has a UNIQUE(rec_ts)
// index and decoded_rr a UNIQUE(rr_ts_ms, beat_index). We use REPLACE, not
// IGNORE: the strap's record `counter` RESETS to ~0 on every reboot, so a
// post-reboot record whose second already had a row would be SILENTLY DROPPED
// under IGNORE — quarantining everything after a reboot (observed: whole days
// present in raw_records but absent from the decoded substrate the engine
// reads → "not worn / metrics still computing / strain –"). REPLACE lets the
// freshly-offloaded record for a given second win, which is what we want.
//
// ORPHAN GUARD: decoded_rr rows are keyed by their record's own counter. When
// the REPLACE below evicts a DIFFERENT counter's row for this second, that
// loser's RR beats would stay behind under a counter with no decoded_onehz
// row — invisible to the counter-joined prune (permanent leak). The winner's
// REPLACE on UNIQUE(rr_ts_ms, beat_index) only overwrites overlapping beat
// indexes, so delete the evicted counter's beats explicitly, in the same
// batch/transaction (mirrors the v17 rebuild's decoded_onehz join).
// embedded timestamp, not by the volatile counter). decoded_onehz is keyed
// by rec_ts and decoded_rr by (rec_ts, beat_index). We use REPLACE, not
// IGNORE: a freshly-offloaded record for a given second should win over a
// stale one. Because rec_ts is the key, the strap's per-reboot counter reset
// can no longer make one second's record evict another's (the pre-fix
// counter-PK eviction that silently, unrecoverably deleted 1 Hz rows).
//
// …AND the COUNTER-PK eviction, which the guard used to miss entirely.
// `decoded_onehz` is `counter INTEGER PRIMARY KEY` as well as
// UNIQUE(rec_ts), and (per the comment above) the strap's counter RESETS to
// ~0 on every reboot — so this same REPLACE also silently DELETES the row
// of an OLDER SECOND that happened to reuse this counter. That older
// second's beats live under OUR counter carrying ITS rr_ts_ms, and only the
// overlapping beat_indexes get overwritten below: any beat at an index past
// the new record's beat count SURVIVES, still stamped days earlier. Neither
// prune path can ever see it (the counter-join finds a fresh rec_ts; the
// orphan sweep finds the counter present), so a later page's RR series was
// polluted with beats from another day — silently wrecking RMSSD/HRV.
// Drop every beat under this counter that is not stamped with THIS second.
var ops = _queueOrphanGuard(batch, counter: raw.counter, recTs: recTs);
// Clear this second's RR beats before reinserting so a SHRINKING beat count
// can't strand stale high-index beats — the parent+child share the rec_ts
// key, so this single DELETE replaces the old counter-based orphan guard.
batch.insert('decoded_onehz', {
'counter': raw.counter,
'rec_ts': recTs,
'counter': raw.counter,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Guard rec_ts against an explicit 0 before it becomes the primary key.

Line 2540 uses raw.recTs ?? decoded.tsEpoch, which substitutes only on null. _backfillDecodedStore (line 2600) builds RawRecord.recTs from the stored raw_records.rec_ts column, which is NOT NULL DEFAULT 0 for legacy rows. Every such row now writes rec_ts = 0.

Under the previous counter primary key those rows coexisted. Under the rec_ts primary key they REPLACE each other, so the backfill keeps only the last one. firstAndLastRecordTs and rawStats already filter rec_ts > 0, which documents that 0 is a real stored value.

Reuse the existing _recTsFor fallback so a 0 resolves to the decoded timestamp.

🐛 Proposed fix
-    final recTs = raw.recTs ?? decoded.tsEpoch;
+    // `?? ` substitutes on null only; an explicit 0 (legacy raw_records rows
+    // carry `rec_ts NOT NULL DEFAULT 0`) would become the rec_ts PRIMARY KEY
+    // and REPLACE-evict every other undated row.
+    final rawRecTs = raw.recTs;
+    final recTs =
+        (rawRecTs != null && rawRecTs > 0) ? rawRecTs : decoded.tsEpoch;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static int _queueDecodedOneHz(Batch batch, RawRecord raw, Sample? sample) {
final decoded = _decodeOneHzSample(raw, preferred: sample);
if (decoded == null) return 0;
final recTs = raw.recTs ?? decoded.tsEpoch;
// TIME-KEYED, NEWEST-WINS (noop/WHOOP-4 model: dedupe records by their
// embedded timestamp, not by a counter). decoded_onehz has a UNIQUE(rec_ts)
// index and decoded_rr a UNIQUE(rr_ts_ms, beat_index). We use REPLACE, not
// IGNORE: the strap's record `counter` RESETS to ~0 on every reboot, so a
// post-reboot record whose second already had a row would be SILENTLY DROPPED
// under IGNORE — quarantining everything after a reboot (observed: whole days
// present in raw_records but absent from the decoded substrate the engine
// reads → "not worn / metrics still computing / strain –"). REPLACE lets the
// freshly-offloaded record for a given second win, which is what we want.
//
// ORPHAN GUARD: decoded_rr rows are keyed by their record's own counter. When
// the REPLACE below evicts a DIFFERENT counter's row for this second, that
// loser's RR beats would stay behind under a counter with no decoded_onehz
// row — invisible to the counter-joined prune (permanent leak). The winner's
// REPLACE on UNIQUE(rr_ts_ms, beat_index) only overwrites overlapping beat
// indexes, so delete the evicted counter's beats explicitly, in the same
// batch/transaction (mirrors the v17 rebuild's decoded_onehz join).
// embedded timestamp, not by the volatile counter). decoded_onehz is keyed
// by rec_ts and decoded_rr by (rec_ts, beat_index). We use REPLACE, not
// IGNORE: a freshly-offloaded record for a given second should win over a
// stale one. Because rec_ts is the key, the strap's per-reboot counter reset
// can no longer make one second's record evict another's (the pre-fix
// counter-PK eviction that silently, unrecoverably deleted 1 Hz rows).
//
// …AND the COUNTER-PK eviction, which the guard used to miss entirely.
// `decoded_onehz` is `counter INTEGER PRIMARY KEY` as well as
// UNIQUE(rec_ts), and (per the comment above) the strap's counter RESETS to
// ~0 on every reboot — so this same REPLACE also silently DELETES the row
// of an OLDER SECOND that happened to reuse this counter. That older
// second's beats live under OUR counter carrying ITS rr_ts_ms, and only the
// overlapping beat_indexes get overwritten below: any beat at an index past
// the new record's beat count SURVIVES, still stamped days earlier. Neither
// prune path can ever see it (the counter-join finds a fresh rec_ts; the
// orphan sweep finds the counter present), so a later page's RR series was
// polluted with beats from another day — silently wrecking RMSSD/HRV.
// Drop every beat under this counter that is not stamped with THIS second.
var ops = _queueOrphanGuard(batch, counter: raw.counter, recTs: recTs);
// Clear this second's RR beats before reinserting so a SHRINKING beat count
// can't strand stale high-index beats — the parent+child share the rec_ts
// key, so this single DELETE replaces the old counter-based orphan guard.
batch.insert('decoded_onehz', {
'counter': raw.counter,
'rec_ts': recTs,
'counter': raw.counter,
static int _queueDecodedOneHz(Batch batch, RawRecord raw, Sample? sample) {
final decoded = _decodeOneHzSample(raw, preferred: sample);
if (decoded == null) return 0;
// `?? ` substitutes on null only; an explicit 0 (legacy raw_records rows
// carry `rec_ts NOT NULL DEFAULT 0`) would become the rec_ts PRIMARY KEY
// and REPLACE-evict every other undated row.
final rawRecTs = raw.recTs;
final recTs =
(rawRecTs != null && rawRecTs > 0) ? rawRecTs : decoded.tsEpoch;
// TIME-KEYED, NEWEST-WINS (noop/WHOOP-4 model: dedupe records by their
// embedded timestamp, not by the volatile counter). decoded_onehz is keyed
// by rec_ts and decoded_rr by (rec_ts, beat_index). We use REPLACE, not
// IGNORE: a freshly-offloaded record for a given second should win over a
// stale one. Because rec_ts is the key, the strap's per-reboot counter reset
// can no longer make one second's record evict another's (the pre-fix
// counter-PK eviction that silently, unrecoverably deleted 1 Hz rows).
//
// Clear this second's RR beats before reinserting so a SHRINKING beat count
// can't strand stale high-index beats — the parent+child share the rec_ts
// key, so this single DELETE replaces the old counter-based orphan guard.
batch.insert('decoded_onehz', {
'rec_ts': recTs,
'counter': raw.counter,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/data/db.dart` around lines 2537 - 2554, Update _queueDecodedOneHz to
resolve recTs through the existing _recTsFor fallback instead of using raw.recTs
?? decoded.tsEpoch, so an explicit raw.recTs value of 0 falls back to
decoded.tsEpoch before insertion into decoded_onehz. Preserve nonzero stored
timestamps unchanged.

Comment thread lib/data/db.dart
Comment on lines +4041 to 4050
// Both decoded tables are now keyed by rec_ts, so a plain
// replace-insert merges cleanly (foreign-wins on a rec_ts
// collision) — no orphan guard needed. A LEGACY export's
// decoded_rr carries no rec_ts column; derive it from rr_ts_ms
// (= rec_ts*1000) so the NOT NULL PK column is always populated.
if (t == 'decoded_rr' &&
row['rec_ts'] == null &&
row['rr_ts_ms'] != null) {
row['rec_ts'] = ((row['rr_ts_ms'] as num).toInt()) ~/ 1000;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

A shrinking foreign beat set leaves stale local beats on import. The comment on lib/data/db.dart line 4041 states that a plain replace-insert merges cleanly and that no orphan guard is needed. That holds only when the foreign export supplies at least as many beats for a colliding rec_ts as the local database already has. The import writes decoded_rr row by row with ConflictAlgorithm.replace keyed on (rec_ts, beat_index), so it never removes a local beat whose beat_index the foreign export does not reach. _queueDecodedOneHz guards the same hazard on the write path with DELETE FROM decoded_rr WHERE rec_ts = ? before reinserting. The import path has no equivalent, so a restore can produce one second holding a mix of foreign and stale local beats, which corrupts RMSSD for that second.

  • lib/data/db.dart#L4041-L4050: before inserting a page's decoded_rr rows, delete the existing beats for each rec_ts the page carries, queued into the same batch and the same transaction as the inserts, so the second's beat set is replaced rather than patched. Then correct the comment, which currently asserts that no guard is needed.
  • test/db_p0_fixes_test.dart#L396-L432: extend the fixture so the foreign export supplies fewer beats for collideTs than the local row has (for example foreign [500] against local [700, 710, 720]), and assert that the collided second ends with exactly the foreign beat set.
📍 Affects 2 files
  • lib/data/db.dart#L4041-L4050 (this comment)
  • test/db_p0_fixes_test.dart#L396-L432
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/data/db.dart` around lines 4041 - 4050, The import path in
lib/data/db.dart lines 4041-4050 must replace each collided decoded_rr beat set
rather than patching it: queue a DELETE for every rec_ts represented by the page
before its inserts, using the same batch and transaction, and revise the comment
to reflect that guard. Extend test/db_p0_fixes_test.dart lines 396-432 so
collideTs has fewer foreign beats than local beats and assert only the foreign
beat set remains.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Type-check rr_ts_ms before the as num cast.

SQLite storage class is per value, not per column, so a foreign or older export can return a String where rr_ts_ms is declared INTEGER. The guard on line 4048 tests for null only. A non-numeric value then throws inside db.transaction and aborts the whole restore.

This file already documents the same hazard for decoded-page reads (_PrepareAccumulator._num in lib/compute/derive_prepare.dart). Apply the same defence here.

🛡️ Proposed fix
-              if (t == 'decoded_rr' &&
-                  row['rec_ts'] == null &&
-                  row['rr_ts_ms'] != null) {
-                row['rec_ts'] = ((row['rr_ts_ms'] as num).toInt()) ~/ 1000;
-              }
+              if (t == 'decoded_rr' && row['rec_ts'] == null) {
+                // Storage class is per-VALUE in SQLite: a foreign export can
+                // hand back a String where INTEGER is declared. Skip the row
+                // rather than throwing out of the whole import transaction.
+                final ms = row['rr_ts_ms'];
+                if (ms is! num) continue;
+                row['rec_ts'] = ms.toInt() ~/ 1000;
+              }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Both decoded tables are now keyed by rec_ts, so a plain
// replace-insert merges cleanly (foreign-wins on a rec_ts
// collision) — no orphan guard needed. A LEGACY export's
// decoded_rr carries no rec_ts column; derive it from rr_ts_ms
// (= rec_ts*1000) so the NOT NULL PK column is always populated.
if (t == 'decoded_rr' &&
row['rec_ts'] == null &&
row['rr_ts_ms'] != null) {
row['rec_ts'] = ((row['rr_ts_ms'] as num).toInt()) ~/ 1000;
}
if (t == 'decoded_rr' && row['rec_ts'] == null) {
// Storage class is per-VALUE in SQLite: a foreign export can
// hand back a String where INTEGER is declared. Skip the row
// rather than throwing out of the whole import transaction.
final ms = row['rr_ts_ms'];
if (ms is! num) continue;
row['rec_ts'] = ms.toInt() ~/ 1000;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/data/db.dart` around lines 4041 - 4050, Update the decoded_rr legacy
rec_ts derivation to validate rr_ts_ms with the existing numeric-conversion
approach used by _PrepareAccumulator._num before converting it; only derive
row['rec_ts'] for values that are safely numeric, and avoid throwing for
non-numeric strings during the transaction.

Comment thread pubspec.yaml
Comment on lines +263 to +266
# ack_commit_sync_full_test wraps the ffi factory in SqfliteDatabaseFactoryLogger
# to spy the PRAGMA synchronous=FULL/NORMAL bracket around the ACK-gating commit
# (transitive via sqflite_common_ffi; declared directly since test/ now imports it).
sqflite_common: ^2.5.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Is SqfliteDatabaseFactoryLogger in sqflite_common sqflite_logger.dart still experimental?

💡 Result:

Yes, SqfliteDatabaseFactoryLogger in the sqflite_common package is still considered an experimental feature [1][2]. While the logger is fully functional and documented for use, the developer of the package has explicitly categorized it as experimental in the official documentation and package issues [1][2]. It remains a standard way to implement logging for database activities by wrapping your existing database factory, but users should be aware of this experimental status when integrating it into their projects [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- relevant files ---'
git ls-files | rg '(^|/)(pubspec\.yaml|pubspec\.lock|ack_commit_sync_full_test\.dart)$|sqflite'
printf '%s\n' '--- dependency declaration ---'
rg -n -C 6 'sqflite_common|kAlgoVersion' pubspec.yaml
printf '%s\n' '--- test API usage ---'
rg -n -C 5 'sqflite_common|SqfliteDatabaseFactoryLogger|SqfliteLoggerOptions|SqfliteLoggerSqlEvent|experimental_member_use' test
printf '%s\n' '--- lockfile package entry ---'
if [ -f pubspec.lock ]; then
  rg -n -A 12 -B 2 '^  sqflite_common:' pubspec.lock || true
fi
printf '%s\n' '--- repository documentation or analyzer configuration ---'
rg -n -C 3 'experimental_member_use|SqfliteDatabaseFactoryLogger|SqfliteLoggerOptions|SqfliteLoggerSqlEvent' . --glob '!build/**' --glob '!dist/**' --glob '!node_modules/**' || true

Repository: OpenStrap/edge

Length of output: 45674


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import io
import json
import tarfile
import urllib.request

package = "sqflite_common"
versions = ["2.5.0", "2.5.8"]

def fetch(url):
    request = urllib.request.Request(url, headers={"User-Agent": "review-evidence"})
    with urllib.request.urlopen(request, timeout=20) as response:
        return response.read()

metadata = json.loads(fetch(f"https://pub.dev/api/packages/{package}"))
listed = [v["version"] for v in metadata["versions"]]
print("latest:", metadata["latest"]["version"])
print("available requested versions:", {v: v in listed for v in versions})

for version in versions:
    archive = fetch(f"https://pub.dev/api/archives/{package}-{version}.tar.gz")
    with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tf:
        names = tf.getnames()
        print(f"\n--- {package} {version} ---")
        print("logger file present:", any(n.endswith("/lib/sqflite_logger.dart") for n in names))
        for name in names:
            if name.endswith("/pubspec.yaml") or name.endswith("/lib/sqflite_logger.dart"):
                text = tf.extractfile(name).read().decode("utf-8", "replace")
                if name.endswith("pubspec.yaml"):
                    print("[pubspec.yaml]")
                    print(text)
                else:
                    print("[lib/sqflite_logger.dart: declarations/annotations]")
                    lines = text.splitlines()
                    for i, line in enumerate(lines, 1):
                        if any(symbol in line for symbol in (
                            "SqfliteDatabaseFactoryLogger",
                            "SqfliteLoggerOptions",
                            "SqfliteLoggerSqlEvent",
                            "`@experimental`",
                            "experimental",
                            "library ",
                            "export ",
                        )):
                            start = max(1, i - 2)
                            end = min(len(lines), i + 3)
                            print("\n".join(f"{j}: {lines[j-1]}" for j in range(start, end + 1)))
                            print()
PY

Repository: OpenStrap/edge

Length of output: 2475


🏁 Script executed:

#!/bin/bash
set -eu

for version in 2.5.0 2.5.8; do
  archive="/tmp/sqflite_common-${version}.tar.gz"
  curl -ksSfL "https://pub.dev/api/archives/sqflite_common-${version}.tar.gz" -o "$archive"
  printf '\n--- sqflite_common %s files ---\n' "$version"
  tar -tzf "$archive" | rg '(^|/)(pubspec\.yaml|lib/sqflite_logger\.dart)$'
  printf '%s\n' '--- declarations and annotations ---'
  tar -xOzf "$archive" "sqflite_common-${version}/lib/sqflite_logger.dart" |
    nl -ba |
    rg -n -C 3 'SqfliteDatabaseFactoryLogger|SqfliteLoggerOptions|SqfliteLoggerSqlEvent|`@experimental`|experimental|library |export '
done

Repository: OpenStrap/edge

Length of output: 427


🏁 Script executed:

#!/bin/bash
set -eu

for version in 2.5.0 2.5.8; do
  archive="/tmp/sqflite_common-${version}.tar.gz"
  [ -s "$archive" ] || curl -ksSfL "https://pub.dev/api/archives/sqflite_common-${version}.tar.gz" -o "$archive"
  member="$(tar -tzf "$archive" | rg '/lib/sqflite_logger\.dart$|^lib/sqflite_logger\.dart$' | head -n 1)"
  printf '\n--- sqflite_common %s member: %s ---\n' "$version" "$member"
  printf '%s\n' '--- declarations and annotations ---'
  tar -xOzf "$archive" "$member" |
    awk '{ printf "%d:%s\n", NR, $0 }' |
    rg -n -C 3 'SqfliteDatabaseFactoryLogger|SqfliteLoggerOptions|SqfliteLoggerSqlEvent|`@experimental`|experimental|library |export '
done

Repository: OpenStrap/edge

Length of output: 1358


🏁 Script executed:

#!/bin/bash
set -eu

for version in 2.5.0 2.5.8; do
  archive="/tmp/sqflite_common-${version}.tar.gz"
  member="$(tar -tzf "$archive" | rg '/src/logger/sqflite_logger\.dart$|^src/logger/sqflite_logger\.dart$' | head -n 1)"
  printf '\n--- sqflite_common %s member: %s ---\n' "$version" "$member"
  tar -xOzf "$archive" "$member" |
    awk '{ printf "%d:%s\n", NR, $0 }' |
    rg -n -C 5 'import |`@experimental`|experimental|class SqfliteDatabaseFactoryLogger|class SqfliteLoggerOptions|class SqfliteLoggerSqlEvent|typedef SqfliteDatabaseFactoryLogger|SqfliteDatabaseFactoryLogger\(|SqfliteLoggerOptions\('
done

Repository: OpenStrap/edge

Length of output: 7959


Do not treat the logger constructor as stable.

SqfliteDatabaseFactoryLogger, SqfliteLoggerOptions, and SqfliteLoggerSqlEvent are public exports in sqflite_common 2.5.0 and 2.5.8. The SqfliteDatabaseFactoryLogger constructor is marked @experimental in both versions. Pin an exact tested version or use a stable logging mechanism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pubspec.yaml` around lines 263 - 266, Update the sqflite_common dependency
declaration used by the ACK commit sync test to pin an exact version whose
experimental SqfliteDatabaseFactoryLogger constructor has been tested, or
replace that constructor usage with a stable logging mechanism. Keep the
existing logger symbols and test behavior otherwise unchanged.

Comment on lines +42 to 65
test('rec_ts-range reads on decoded_rr are served by the PK auto-index', () async {
// decoded_rr shares the rec_ts key with decoded_onehz, so the derive read
// path (decodedRrByRecTsRange) is a PK range scan — never a full-table read.
final db = await LocalDb.instance;
for (final sql in const [
'EXPLAIN QUERY PLAN SELECT * FROM decoded_rr WHERE counter = 42 '
'ORDER BY beat_index',
'EXPLAIN QUERY PLAN SELECT * FROM decoded_rr WHERE counter BETWEEN 1 AND 9',
]) {
final detail = (await db.rawQuery(
sql,
)).map((r) => r['detail'].toString()).join(' | ');
expect(
detail.toUpperCase(),
contains('USING'),
reason: 'planner fell back to a full scan: $detail',
);
expect(
detail,
contains('sqlite_autoindex_decoded_rr_1'),
reason: 'expected the primary key auto-index: $detail',
);
}
});

test('rr_ts_ms range scans are still served by an index', () async {
final db = await LocalDb.instance;
final plan = await db.rawQuery(
'EXPLAIN QUERY PLAN SELECT * FROM decoded_rr WHERE rr_ts_ms < 1000 '
'ORDER BY rr_ts_ms ASC, beat_index ASC',
final detail = (await db.rawQuery(
'EXPLAIN QUERY PLAN SELECT * FROM decoded_rr WHERE rec_ts BETWEEN 1 AND 9 '
'ORDER BY rec_ts ASC, beat_index ASC',
)).map((r) => r['detail'].toString()).join(' | ');
expect(
detail.toUpperCase(),
contains('USING'),
reason: 'planner fell back to a full scan: $detail',
);
final detail = plan.map((r) => r['detail'].toString()).join(' | ');
expect(
detail,
contains('idx_decoded_rr_ts_beat_unique'),
reason: 'planner fell back to a scan: $detail',
contains('sqlite_autoindex_decoded_rr_1'),
reason: 'expected the primary key auto-index: $detail',
);
expect(
detail.toUpperCase(),
isNot(contains('USE TEMP B-TREE')),
reason: 'ordering should come from the index: $detail',
reason: 'ordering should come from the PK: $detail',
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Report the SQLite version used by the test backend and the EQP text for the asserted query.
set -euo pipefail

echo "== sqlite3 CLI version (sandbox) =="
sqlite3 --version 2>/dev/null || echo "sqlite3 CLI unavailable"

echo "== EQP for the same shape =="
sqlite3 ":memory:" <<'SQL' 2>/dev/null || echo "could not run"
CREATE TABLE decoded_rr (
  rec_ts INTEGER NOT NULL,
  beat_index INTEGER NOT NULL,
  rr_ts_ms INTEGER NOT NULL,
  rr_ms INTEGER NOT NULL,
  PRIMARY KEY (rec_ts, beat_index)
);
EXPLAIN QUERY PLAN
SELECT * FROM decoded_rr WHERE rec_ts BETWEEN 1 AND 9
ORDER BY rec_ts ASC, beat_index ASC;
SQL

echo "== pinned ffi version =="
rg -nP 'sqflite_common_ffi|sqlite3_flutter_libs|sqlite3:' pubspec.yaml pubspec.lock 2>/dev/null || echo "not found"

Repository: OpenStrap/edge

Length of output: 638


🏁 Script executed:

set -euo pipefail

echo "== test/db_storage_hygiene_test.dart =="
sed -n '1,110p' test/db_storage_hygiene_test.dart

echo "== dependency versions =="
sed -n '1328,1370p' pubspec.lock
sed -n '240,275p' pubspec.yaml

echo "== SQLite EQP across available Python SQLite builds =="
python3 - <<'PY'
import sqlite3
print("python sqlite version:", sqlite3.sqlite_version)
db = sqlite3.connect(":memory:")
db.execute("""
CREATE TABLE decoded_rr (
  rec_ts INTEGER NOT NULL,
  beat_index INTEGER NOT NULL,
  rr_ts_ms INTEGER NOT NULL,
  rr_ms INTEGER NOT NULL,
  PRIMARY KEY (rec_ts, beat_index)
)
""")
query = """
SELECT * FROM decoded_rr WHERE rec_ts BETWEEN 1 AND 9
ORDER BY rec_ts ASC, beat_index ASC
"""
for row in db.execute("EXPLAIN QUERY PLAN " + query):
    print(row)
PY

Repository: OpenStrap/edge

Length of output: 7269


Reduce coupling to SQLite query-plan text

SQLite emits the expected SEARCH ... USING INDEX plan, but sqlite_autoindex_decoded_rr_1 is an internal name. Replace the index-name assertion with SEARCH and the absence of USE TEMP B-TREE. Avoid SCAN TABLE DECODED_RR; SQLite versions can emit different SCAN wording.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/db_storage_hygiene_test.dart` around lines 42 - 65, Update the test
`rec_ts-range reads on decoded_rr are served by the PK auto-index` to remove the
assertion for the internal `sqlite_autoindex_decoded_rr_1` name. Assert that the
uppercased query-plan detail contains `SEARCH`, does not contain `USE TEMP
B-TREE`, and does not match `SCAN TABLE DECODED_RR`, while preserving the
existing planner-fallback diagnostics.

@OpenStrap OpenStrap deleted a comment from github-actions Bot Aug 12, 2026
@abdulsaheel abdulsaheel changed the title Gen4 data-integrity: stop silent BLE-offload data loss (4 fixes) offload data loss fixes Aug 12, 2026
init seq4 is send_historical so every fresh connect drained + trimmed under the
bad clock anyway, and the unconditional set_clock before it clobbered the strap
rtc and made the gate always see agreeing clocks. read first, skip both if suspect.
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 997e149)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Migration idempotence broken

_rekeyDecodedStoreByRecTs begins by calling _createDecodedStore(db), which uses CREATE TABLE IF NOT EXISTS with the NEW rec_ts-keyed schema. On a v19–v31 database the OLD counter-keyed tables already exist, so IF NOT EXISTS is a no-op and the source tables retain their old schema — that is correct and intentional. However, if the migration is interrupted after the DROP TABLE decoded_onehz / DROP TABLE decoded_rr lines but before the RENAME completes (e.g. process kill), a re-run will find no decoded_onehz table, call _createDecodedStore which creates the NEW empty table, then attempt INSERT OR REPLACE … SELECT … FROM decoded_onehz — succeeding with zero rows, silently losing all data. The temp tables (_decoded_onehz_v33, _decoded_rr_v33) are dropped at the top of the function, so a re-run after the DROP-but-before-RENAME window produces an empty store. The function's own docstring claims idempotence, but the DROP-before-RENAME ordering breaks it for the crash-in-the-middle case. The fix is to RENAME aside first, then create the new tables, then copy, then drop the aside — the same shape used by _rebuildCanonicalDecodedStore.

static Future<void> _rekeyDecodedStoreByRecTs(Database db) async {
  // The source tables may not exist on a pre-decoded-store upgrade path; a
  // create (new schema, IF NOT EXISTS) makes the copy a safe no-op there. On a
  // normal path the OLD-schema tables already exist and this is a no-op — the
  // columns we SELECT (rec_ts, counter, hr, …; beat_index, rr_ts_ms, rr_ms)
  // are present in both the old and new decoded schemas.
  await _createDecodedStore(db);
  await db.execute('DROP TABLE IF EXISTS _decoded_onehz_v33');
  await db.execute('DROP TABLE IF EXISTS _decoded_rr_v33');
  await db.execute('''
    CREATE TABLE _decoded_onehz_v33 (
      rec_ts INTEGER PRIMARY KEY,
      counter INTEGER NOT NULL,
      hr INTEGER NOT NULL,
      ax REAL NOT NULL,
      ay REAL NOT NULL,
      az REAL NOT NULL,
      spo2_red_raw INTEGER NOT NULL,
      spo2_ir_raw INTEGER NOT NULL,
      skin_temp_raw INTEGER NOT NULL
    )
  ''');
  await db.execute('''
    CREATE TABLE _decoded_rr_v33 (
      rec_ts INTEGER NOT NULL,
      beat_index INTEGER NOT NULL,
      rr_ts_ms INTEGER NOT NULL,
      rr_ms INTEGER NOT NULL,
      PRIMARY KEY (rec_ts, beat_index)
    )
  ''');
  // Deterministic newest-wins: ORDER BY rec_ts, counter so INSERT OR REPLACE on
  // the rec_ts PK keeps the highest-counter (latest-offloaded) row per second.
  await db.execute(
    'INSERT OR REPLACE INTO _decoded_onehz_v33 '
    '(rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw) '
    'SELECT rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw '
    'FROM decoded_onehz ORDER BY rec_ts ASC, counter ASC',
  );
  // rec_ts derived from rr_ts_ms (= rec_ts*1000 by construction). Pre-fix orphan
  // beats (owning row evicted) re-home onto their real second here.
  await db.execute(
    'INSERT OR REPLACE INTO _decoded_rr_v33 (rec_ts, beat_index, rr_ts_ms, rr_ms) '
    'SELECT rr_ts_ms / 1000, beat_index, rr_ts_ms, rr_ms '
    'FROM decoded_rr ORDER BY rr_ts_ms ASC, beat_index ASC',
  );
  await db.execute('DROP TABLE IF EXISTS decoded_rr');
  await db.execute('DROP TABLE IF EXISTS decoded_onehz');
  await db.execute('ALTER TABLE _decoded_onehz_v33 RENAME TO decoded_onehz');
  await db.execute('ALTER TABLE _decoded_rr_v33 RENAME TO decoded_rr');
  // The rec_ts PK auto-indexes; add back the forensic counter index (the temp
  // tables carried no named secondary indexes, so nothing leaked onto rename).
  await db.execute(
    'CREATE INDEX IF NOT EXISTS idx_decoded_onehz_counter ON decoded_onehz(counter)',
  );
}
PRAGMA synchronous outside transaction

The PRAGMA synchronous=FULL is set on the connection before db.transaction(...) opens, and reset in a finally after it commits. The docstring correctly notes this is safe only because the offload processor is single-flight. However, if db.transaction itself throws before any work is done (e.g. sqflite internal error opening the transaction), the finally block still resets to NORMAL — that part is fine. The real risk is the opposite direction: if the PRAGMA synchronous=FULL call succeeds but the subsequent db.transaction call throws synchronously (before the try body's finally runs), the connection is left at FULL permanently until the process dies, fsyncing every subsequent write. The outer try/finally does cover this — the finally always runs. On closer inspection this is handled correctly. No bug here; withdrawing this concern.

try {
  await db.execute('PRAGMA synchronous=FULL');
} catch (_) {
  /* durability upgrade is best-effort — NORMAL still commits correctly */
}
try {
  await db.transaction((txn) async {
    // Read the existing high-water THROUGH the txn — never via the global db
    // handle, which would deadlock against this same open transaction.
    var maxCounter = await _cursorIntVia(txn, 'counter_hw') ?? 0;
    var maxRecTs = await _cursorIntVia(txn, 'rec_ts_hw') ?? 0;
    // CHUNKED BATCH: sqflite serialises an ENTIRE batch's operations+args into
    // ONE platform-channel message, and the native side builds a single
    // ArrayList of every argument. A large backlog offload (raws in the
    // hundreds-of-thousands) blew the native heap in SqlCommand.getSqlArguments
    // → OutOfMemoryError (Crashlytics 0.9.13). Committing in bounded chunks
    // flushes and frees each message's args. These commits all happen INSIDE
    // the single `db.transaction` below, so the safe-trim invariant holds: the
    // whole offload (raw_archive + samples + decoded_onehz + decoded_rr +
    // cursor) is still one atomic transaction — every row is durable before the
    // caller echoes the HISTORY_END trim token, or none is.
    const chunkOps = 4000;
    var batch = txn.batch();
    var ops = 0;
    Future<void> flushChunk() async {
      if (ops == 0) return;
      await batch.commit(noResult: true);
      batch = txn.batch();
      ops = 0;
    }

    // SAFE-TRIM INVARIANT: archive the undecodable records in the SAME
    // transaction as the raw records + trim cursor, so they are durably set
    // aside BEFORE the caller writes the batch-ACK that lets the band trim.
    if (archives != null) {
      for (final a in archives) {
        batch.insert('raw_archive', {
          'counter': a.counter,
          'hex': a.hex,
          'packet_type': a.packetType,
          'rec_ts': a.recTs,
          'captured_at': a.capturedAt,
          'reason': a.reason,
        }, conflictAlgorithm: ConflictAlgorithm.ignore);
        if (++ops >= chunkOps) await flushChunk();
      }
    }
    for (var i = 0; i < raws.length; i++) {
      final raw = raws[i];
      final recTs = _recTsFor(raw);
      final sample = samples[i];
      if (sample != null) {
        batch.insert('samples', {
          'counter': raw.counter,
          ...sample.toDbMap(),
        }, conflictAlgorithm: ConflictAlgorithm.ignore);
        ops++;
      }
      ops += _queueDecodedOneHz(batch, raw, sample);
      if (raw.counter > maxCounter) maxCounter = raw.counter;
      if (recTs > maxRecTs) maxRecTs = recTs;
      if (ops >= chunkOps) await flushChunk();
    }
    checkpoint(
      'decoded_archive_queued raws=${raws.length} '
      'archives=${archives?.length ?? 0}',
    );
    await flushChunk();
    checkpoint('decoded_archive_committed');
    await setCursor('counter_hw', '$maxCounter', txn: txn);
    await setCursor('rec_ts_hw', '$maxRecTs', txn: txn);
    if (trimToken != null) await setCursor('strap_trim', trimToken, txn: txn);
    if (extraCursors != null) {
      for (final e in extraCursors.entries) {
        await setCursor(e.key, e.value, txn: txn);
      }
    }
    checkpoint(
      'cursor_advanced counter_hw=$maxCounter rec_ts_hw=$maxRecTs '
      'trim=${trimToken != null}',
    );
  });
} finally {
  // ALWAYS restore NORMAL — even if the commit threw — so a leaked FULL does
  // not fsync every subsequent write on this connection. Non-fatal.
  try {
    await db.execute('PRAGMA synchronous=NORMAL');
  } catch (_) {
    /* non-fatal — see open-time PRAGMA discipline */
  }
}
_phoneClockSuspect latch not reset on disconnect

_phoneClockSuspect and _phoneClockSuspectSince are instance fields set in the clock_epoch handler and read by _deferForClock. Per §4.3 (sticky boolean latches), the flag must be cleared on every failure, timeout, and give-up path. There is no visible reset of _phoneClockSuspect in the disconnect, session-close, or reconnect paths shown in the diff. If a session ends while the flag is true (e.g. BLE drops mid-suspect-window), the next reconnect inherits a stale _phoneClockSuspect = true and _phoneClockSuspectSince from the previous session. _deferForClock then defers the init drain for up to the grace window duration even though the new session's getClock has not yet been read — potentially deferring a legitimate drain unnecessarily. More critically, if _phoneClockSuspectSince is old enough that suspectGraceExpired returns true, the defer is skipped and the drain proceeds, which is the correct behavior, but the stale flag is never explicitly cleared. The flag should be reset (to false, _phoneClockSuspectSince = null) at session start alongside _clockRef = null.

// True when the last GET_CLOCK showed a plausible strap RTC reading > 1 day in
// the FUTURE relative to the phone — the phone clock is likely wrong (slow), so
// history offload is DEFERRED (not drained-and-trimmed) until the clocks agree.
// See ClockPolicy.phoneClockSuspect and _startHistoricalRefresh.
bool _phoneClockSuspect = false;
DateTime? _phoneClockSuspectSince;
bool get historyPausedForClock => _deferForClock;
/// Defer history only while the disagreement is still young. A slow phone
/// re-syncs over NTP in minutes; one that persists past the grace window is a
/// strap RTC running fast, and deferring forever would stall sync for good.
bool get _deferForClock =>
    _phoneClockSuspect &&
    !ClockPolicy.suspectGraceExpired(_phoneClockSuspectSince, DateTime.now());
int _clockPausedOffloads = 0; // diagnostics: offloads deferred for this reason
v19 migration calls _rekeyDecodedStoreByRecTs before _backfillDecodedStore

In the oldV < 19 branch, _rekeyDecodedStoreByRecTs is now called BEFORE _backfillDecodedStore. _rekeyDecodedStoreByRecTs calls _createDecodedStore internally (new rec_ts-keyed schema). If the decoded tables do not yet exist (pre-v19 path where they were never created), _createDecodedStore creates empty new-schema tables. Then _backfillDecodedStore runs and presumably inserts into decoded_onehz. If _backfillDecodedStore was written expecting the OLD counter-keyed schema (inserting a counter PRIMARY KEY column), it will now fail or silently misbehave against the new rec_ts-keyed schema. The interaction between these two functions in the v19 path needs verification — specifically whether _backfillDecodedStore inserts rows compatible with the new schema that _rekeyDecodedStoreByRecTs just created.

if (oldV < 19) {
  // The v17 step (or a v11-16 origin) may leave OLD counter-keyed decoded
  // tables here; the backfill below writes through the rec_ts-keyed
  // _queueDecodedOneHz, so convert to the current schema first (preserving
  // any existing rows), then reconstruct the rest from raw_records.
  await _rekeyDecodedStoreByRecTs(db);
  await _backfillDecodedStore(db);
  await _dropRawStore(db);

⚠️ Review coverage: The following files were not included in this review because of the token budget:

  • test/db_integrity_test.dart
  • test/db_storage_hygiene_test.dart
  • test/ble_engine_test.dart
  • test/ack_commit_sync_full_test.dart
  • lib/compute/derive_prepare.dart
  • lib/sync/sync_policy.dart
  • test/sync_policy_test.dart
  • lib/compute/derivation_engine.dart
  • test/db_paged_import_export_test.dart
  • test/local_persistence_test.dart
  • pubspec.yaml

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/ble/ble_engine.dart (2)

1639-1647: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not consume the backfill floor for a deferred refresh.

_triggerBackfill sets _lastBackfillAt before this method runs. This return path sends no historical request, but it leaves that timestamp set and makes _triggerBackfill return true. A corrected phone clock can then remain blocked by the backfill floor.

Make _startHistoricalRefresh report whether it sent SEND_HISTORICAL_DATA. Update _lastBackfillAt only after that result is true. Add a regression for a deferred refresh followed by an immediate successful retry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ble/ble_engine.dart` around lines 1639 - 1647, Update
_startHistoricalRefresh to return whether SEND_HISTORICAL_DATA was actually
sent, returning false for the _phoneClockSuspect deferred path and true after
dispatch. In _triggerBackfill, assign _lastBackfillAt only when
_startHistoricalRefresh returns true, and add a regression covering a deferred
refresh followed immediately by a successful retry.

2293-2307: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Do not correct the strap clock when the phone clock is suspect.

When Line 2300 sets _phoneClockSuspect to true, ClockPolicy.shouldSetClock(dev, wall) is also true for the same drift. Line 2340 then calls setClock() and writes the bad phone time to a plausible strap RTC. Its readback can clear the flag before INIT starts history draining.

Guard the automatic correction with !_phoneClockSuspect. Add a connection regression that verifies a plausible strap clock more than one day ahead sends neither SET_CLOCK nor SEND_HISTORICAL_DATA.

Proposed fix
-        if (ClockPolicy.shouldSetClock(dev, wall)) {
+        if (!_phoneClockSuspect && ClockPolicy.shouldSetClock(dev, wall)) {

As per coding guidelines, “When adding or changing a capability, cover every call path.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ble/ble_engine.dart` around lines 2293 - 2307, Guard the automatic
strap-clock correction in the connection flow with !_phoneClockSuspect so a
plausible strap RTC more than one day ahead of the phone is not overwritten;
keep normal correction behavior when the phone clock is trusted. Add a
connection regression covering this drift case and assert that neither SET_CLOCK
nor SEND_HISTORICAL_DATA is sent, exercising the _phoneClockSuspect,
ClockPolicy.shouldSetClock, and history-start paths.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@lib/ble/ble_engine.dart`:
- Around line 1639-1647: Update _startHistoricalRefresh to return whether
SEND_HISTORICAL_DATA was actually sent, returning false for the
_phoneClockSuspect deferred path and true after dispatch. In _triggerBackfill,
assign _lastBackfillAt only when _startHistoricalRefresh returns true, and add a
regression covering a deferred refresh followed immediately by a successful
retry.
- Around line 2293-2307: Guard the automatic strap-clock correction in the
connection flow with !_phoneClockSuspect so a plausible strap RTC more than one
day ahead of the phone is not overwritten; keep normal correction behavior when
the phone clock is trusted. Add a connection regression covering this drift case
and assert that neither SET_CLOCK nor SEND_HISTORICAL_DATA is sent, exercising
the _phoneClockSuspect, ClockPolicy.shouldSetClock, and history-start paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 64d2fd1a-2f6d-41dd-bd9d-37e4b144d3f1

📥 Commits

Reviewing files that changed from the base of the PR and between 90f9588 and 8573d7e.

📒 Files selected for processing (2)
  • lib/ble/ble_engine.dart
  • test/ble_engine_test.dart

a slow phone fixes itself over ntp in minutes, so if we're still
disagreeing 12h later its the strap rtc thats off. stop deferring at
that point and let the normal set_clock fix it.
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 997e149

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/ble/ble_engine.dart (2)

850-862: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use monotonic time for the suspicion grace period.

_phoneClockSuspectSince and suspectGraceExpired use DateTime.now(). If the phone clock moves forward but remains more than one day behind the strap, the 12-hour grace period can expire early. The next refresh can then drain and trim data under an untrusted phone clock. Store the start time from the existing _monotonic stopwatch, or pass an elapsed Duration into ClockPolicy.

Add a regression test for a forward wall-clock jump.

As per coding guidelines, “Behavior changes, especially regressions involving readiness, abstention, idempotence, synchronization, migrations, and lifecycle safety, must include regression tests.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ble/ble_engine.dart` around lines 850 - 862, Update the clock-suspicion
grace-period flow centered on _phoneClockSuspectSince, _deferForClock, and
ClockPolicy.suspectGraceExpired to use elapsed time from the existing _monotonic
stopwatch rather than DateTime.now(), preserving deferral until the monotonic
grace duration expires. Add a regression test that advances the wall clock
forward while the monotonic elapsed duration remains within the grace period and
verifies history remains deferred.

Source: Coding guidelines


3080-3088: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep the history gate inside the INIT API.

The production caller passes drainOnInit, but sendInit() remains public and defaults drain to true. Require a validated clock decision inside sendInit(), or make the method private. Add direct-call coverage for the deferred-clock case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ble/ble_engine.dart` around lines 3080 - 3088, Update sendInit so every
invocation enforces the validated clock decision before including the
historical-data packet, rather than relying on callers such as the drainOnInit
path. Either require a validated clock-policy argument/decision in this public
API or make sendInit private, preserving deferred history when the phone clock
is suspect; add direct-call coverage for that deferred-clock case.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/ble/ble_engine.dart`:
- Around line 2306-2311: Update the response-driven SET_CLOCK retry condition
near ClockPolicy.shouldSetClock so setClock() is invoked only when
!_deferForClock is true; preserve retries after the grace period expires or when
the reading is non-suspect, and add regression coverage for the plausible strap
RTC case during the 12-hour grace period.
- Around line 1366-1368: Make GET_CLOCK completion session-bound across
lib/ble/ble_engine.dart:1366-1368 by awaiting and validating the current
session’s clock response before deciding whether to call setClock(); at
lib/ble/ble_engine.dart:1643-1655, reuse that completed result and await any
required correction before SEND_HISTORICAL_DATA; at
lib/ble/ble_engine.dart:1452-1465, derive drainOnInit from the validated result
before sendInit, preventing stale _deferForClock state from advancing either
flow.

---

Outside diff comments:
In `@lib/ble/ble_engine.dart`:
- Around line 850-862: Update the clock-suspicion grace-period flow centered on
_phoneClockSuspectSince, _deferForClock, and ClockPolicy.suspectGraceExpired to
use elapsed time from the existing _monotonic stopwatch rather than
DateTime.now(), preserving deferral until the monotonic grace duration expires.
Add a regression test that advances the wall clock forward while the monotonic
elapsed duration remains within the grace period and verifies history remains
deferred.
- Around line 3080-3088: Update sendInit so every invocation enforces the
validated clock decision before including the historical-data packet, rather
than relying on callers such as the drainOnInit path. Either require a validated
clock-policy argument/decision in this public API or make sendInit private,
preserving deferred history when the phone clock is suspect; add direct-call
coverage for that deferred-clock case.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 99b71ad1-41c3-4c9e-ba85-a30b635765f4

📥 Commits

Reviewing files that changed from the base of the PR and between 8573d7e and 997e149.

📒 Files selected for processing (3)
  • lib/ble/ble_engine.dart
  • lib/sync/sync_policy.dart
  • test/sync_policy_test.dart

Comment thread lib/ble/ble_engine.dart
Comment on lines +1366 to +1368
await getClock();
await Future.delayed(const Duration(milliseconds: 120));
if (!_deferForClock) await setClock();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical

Make GET_CLOCK completion session-bound before clock correction or history draining. Both paths await only the command write and then sleep 120 ms. A late or missing clock_epoch leaves _deferForClock stale, allowing history or clock correction to proceed before the current session is classified.

  • lib/ble/ble_engine.dart#L1366-L1368: await the current session's clock response before deciding whether to call setClock().
  • lib/ble/ble_engine.dart#L1643-L1655: reuse the completed clock result and wait for any required correction before sending SEND_HISTORICAL_DATA.
  • lib/ble/ble_engine.dart#L1452-L1465: derive drainOnInit from that validated result before calling sendInit.
📍 Affects 1 file
  • lib/ble/ble_engine.dart#L1366-L1368 (this comment)
  • lib/ble/ble_engine.dart#L1643-L1655
  • lib/ble/ble_engine.dart#L1452-L1465
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ble/ble_engine.dart` around lines 1366 - 1368, Make GET_CLOCK completion
session-bound across lib/ble/ble_engine.dart:1366-1368 by awaiting and
validating the current session’s clock response before deciding whether to call
setClock(); at lib/ble/ble_engine.dart:1643-1655, reuse that completed result
and await any required correction before SEND_HISTORICAL_DATA; at
lib/ble/ble_engine.dart:1452-1465, derive drainOnInit from the validated result
before sendInit, preventing stale _deferForClock state from advancing either
flow.

Comment thread lib/ble/ble_engine.dart
Comment on lines +2306 to +2311
final wasSuspect = _phoneClockSuspect;
_phoneClockSuspect = ClockPolicy.phoneClockSuspect(dev, wall);
if (_phoneClockSuspect && !wasSuspect) {
_phoneClockSuspectSince = DateTime.now();
} else if (!_phoneClockSuspect) {
_phoneClockSuspectSince = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical

Gate response-driven SET_CLOCK retries on the grace policy.

After these lines set _phoneClockSuspect to true, the handler still reaches ClockPolicy.shouldSetClock and calls unawaited(setClock()) at Line 2352. For a plausible strap RTC more than one day ahead, this can write the phone's slow wall clock back to a correct strap RTC during the 12-hour grace period.

Require !_deferForClock before the retry. Allow correction after grace expiry or a non-suspect reading.

Suggested guard
-        if (ClockPolicy.shouldSetClock(dev, wall)) {
+        if (!_deferForClock && ClockPolicy.shouldSetClock(dev, wall)) {

As per coding guidelines, “Behavior changes, especially regressions involving readiness, abstention, idempotence, synchronization, migrations, and lifecycle safety, must include regression tests.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ble/ble_engine.dart` around lines 2306 - 2311, Update the response-driven
SET_CLOCK retry condition near ClockPolicy.shouldSetClock so setClock() is
invoked only when !_deferForClock is true; preserve retries after the grace
period expires or when the reading is non-suspect, and add regression coverage
for the plausible strap RTC case during the 12-hour grace period.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant