Skip to content

fix: close data-loss, privacy and absent-vs-zero defects across the app - #158

Merged
abdulsaheel merged 5 commits into
mainfrom
fix/issues
Jul 26, 2026
Merged

fix: close data-loss, privacy and absent-vs-zero defects across the app#158
abdulsaheel merged 5 commits into
mainfrom
fix/issues

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

User description

A full-tree audit surfaced defects clustered in three seams the 886-test suite never reached: failure paths, absent-vs-zero coercion, and guards modelled with regexes and blocklists. Every fix carries a regression test that fails against the old code.

P0 — BLE: the safe-trim invariant held by ordering, but not by errors

DrainController.commit() cleared its buffer, then swallowed the durable commit's exception and returned void. The caller ACKed regardless, so the band trimmed flash for records that were never persisted — buffer cleared, transaction rolled back, cursor unadvanced, data gone permanently and silently. This is reachable: db.dart:853-860 documents the production OOM inside this exact transaction (Crashlytics 0.9.13) and asserts "every row is durable before the caller echoes the HISTORY_END trim token, or none is".

commit() now reports failure, re-buffers at the front of the queue, and the ACK is gated on a TrimAckPolicy verdict.

Also: _drainOffloadFrames/_handleSyncMarker were the only two async paths without the _session != session guard four siblings make, so a stale drainer could ACK an old token with a reused seq onto a new link. discardOpenChunk now poisons its burst so a straggler terminal can't trim discarded records. A failed connect no longer holds the band claim forever (which starved the headless drainer for the process lifetime), link-down tears the session down instead of leaking five timers and four subscriptions per drop, a corrupt far-future RTC is refused, and the Android boot wake goes through the headless gate.

P0 — Persistence: three ways a good day could be destroyed

  • _markDaySkipped REPLACEd a real day_result with a skip marker after one 90 s isolate timeout, finalized and unrecoverable once raw was pruned 3 days later.
  • Re-analyze on a day whose raw is gone derived an empty bundle with endSec = 0, so it always looked finalizable and NULLed every scalar in metric_series.
  • The per-sweep baseline snapshot was mutated by appendScalars, so each finished day polluted the window the next one read — the same duplicate-day → MAD=0 shape edge#108 fixed on the load path.

Plus: exportDaysDb passed onCreate with no version, so it threw ArgumentError on every call and had never worked; two unguarded ALTER TABLE ADD COLUMN collided with modernized CREATE helpers and permanently bricked upgrades from old schemas (rollback → rethrow → stuck on the loading screen every launch); the orphan guard missed the counter-PK eviction and decodedRrByCounterRange returned zero rows across a counter reset; deleteDays never cascaded workout_route; and local days were assumed to be exactly 86400 s.

P0 — Privacy

The coach SQL guard leaked GPS. Its regex ignored comma cross-joins and its blocklist was missing five real tables, so SELECT r.lat, r.lng FROM v_sessions s, workout_route r passed both gates and returned on-device-only coordinates into an LLM prompt bound for a third-party API. Replaced with a lexer + allow-list parser, plus a structural gate that derives the permitted btree root pages by EXPLAINing the coach views — so a table added tomorrow is closed by default. 49 adversarial tests, including that exact query.

Firebase collected by default. No collection flags existed in the plist or manifest, and the only gate ran after an async prefs read inside fire-and-forget init — so a fresh install auto-logged first_open/session_start and uploaded startup crashes before consent was read. Collection is now off at the platform level (verified in the merged manifest after a real build) and enabled only once consent resolves.

High — absent is not zero

  • "Wear stretches" read 0 on every day, on every device: _n(segments) coerced a List to null, then ?? 0.
  • TrendBoard discarded the has flag the repository emits precisely to mark absent days, so unworn days drew real, tappable bars under an InfoDot promising "empty periods stay empty" — and the oxygen board printed a literal "0" for an unmeasured night.
  • getDayWear collapsed "never measured" into worn_min: 0, so an imported day asserted "Not worn on this day".
  • DeltaChip was fed an absolute delta and rendered it as a percentage: sleeping 20 min more than last week displayed "▲ 20.0%".

Charts now take nullable values and render gaps as gaps. Native widget/watch sentinels were kept in sync with the Dart writer.

High — state

Unpair could be silently undone by a late engine-state callback re-creating the pairing from a stale serial. The once-per-day notification guard was consumed before the notification was presented, so a recovery alert suppressed by quiet hours never fired and never retried. A denied notification permission was cached process-wide with no invalidation (zero notifications until an app restart), and schedules added absolute Durations to TZDateTime and drifted an hour across DST.

Verification

  • flutter test --concurrency=1: 886 passing, 0 failing (was 623).
  • flutter analyze: No issues found.
  • flutter build apk --debug: builds; merged manifest carries the collection flags as false.
  • iOS was not built — CocoaPods is not resolvable in this environment. The Info.plist change is declarative keys only, but it needs an iOS build on a machine with pods before release.

⚠️ Merge order — please read

kAlgoVersion goes 47 → 48, and its changelog documents only the edge-local changes.

The sibling analytics fixes from the same sweep are deliberately not claimed, because pubspec.yaml still pins the previous analytics SHA (e3705bf), which does not contain them. Citing an analytics change that the pinned SHA never contained is exactly how the v43 changelog documented a readiness fix that stayed broken for three releases.

After OpenStrap/analytics#30 and OpenStrap/protocol#19 merge: repin both SHAs, verify with git show <sha>:<file> that the pinned commit really contains the cited change, and bump kAlgoVersion again.

Related: OpenStrap/protocol#19, OpenStrap/analytics#30


PR Type

Bug fix, Tests


Description

  • Closes P0 BLE data-loss: DrainController.commit() now returns bool and re-buffers on failure; ACK is gated by TrimAckPolicy so the band never trims flash for records that were never persisted

  • Fixes stale-session and band-claim leaks: failed/dropped connects now release the band claim via _failConnect(); _drainOffloadFrames and _handleSyncMarker gain session guards; link-down tears down timers/subscriptions immediately

  • Fixes DB migration ladder: _addColumnIfMissing replaces bare ALTER TABLE … ADD COLUMN (preventing "duplicate column name" app-stuck-forever); exportDaysDb gains mandatory version: (it had never produced a file); deleteDays now cascades workout_route, cycle_symptom, workout_suggestions, sleep_override; decodedRrByCounterRange fixed for counter-reset pages; _localDayWindow uses DST-correct next-midnight instead of +86400

  • Fixes absent-vs-zero coercions: wear/coverage, timeline events (now SQL-bounded by day), Records screen (payload-free SQL), cycle-screen clamp crash on short cycles, notification permission cache invalidated on foreground; adds 30+ regression tests covering all changed paths


Diagram Walkthrough

flowchart LR
  A["BLE drain receives HISTORY_END"]
  B["DrainController.commit()"]
  C["TrimAckPolicy.evaluate()"]
  D["_writeAckVerified(ack, session)"]
  E["Band trims flash"]
  F["Re-buffer + refuse ACK"]
  G["_failConnect() releases band claim"]
  H["_sessionIsStale() guard"]
  I["DB migration _addColumnIfMissing"]
  J["exportDaysDb version: schemaVersion"]
  K["_localDayWindow DST-correct"]
  L["eventsInRange SQL-bounded"]

  A -- "commit attempt" --> B
  B -- "returns bool durable" --> C
  C -- "send" --> D
  D -- "ACK confirmed" --> E
  C -- "blocked" --> F
  B -- "exception" --> F
  G -- "on connect failure / link-down" --> H
  H -- "stale → skip write" --> F
  I -- "idempotent ALTER" --> J
  K -- "next local midnight" --> L
Loading

File Walkthrough

Relevant files
Bug fix
5 files
ble_engine.dart
Safe-trim invariant, session guards, band-claim leak fixes
+443/-158
db.dart
Migration ladder hardening, export fix, DST-correct day windows
+504/-220
local_repository_impl.dart
Absent-vs-zero fixes for wear, events, records, day windows
+62/-41 
app.dart
Invalidate notification permission cache on foreground resume
+7/-0     
day_label.dart
DST-correct localDayEndSec exported for shared use             
+46/-0   
Tests
13 files
ble_safe_trim_test.dart
Regression tests for DrainController commit/re-buffer/ACK gate
+370/-0 
ble_transport_guards_test.dart
Tests for session-staleness and band-claim release paths 
+217/-0 
db_p0_fixes_test.dart
Tests for migration ladder, export version, deleteDays cascade
+471/-0 
db_migration_ladder_test.dart
Tests for _addColumnIfMissing idempotence across upgrade paths
+296/-0 
absent_not_zero_test.dart
Tests for absent-vs-zero coercion in wear and coverage fields
+548/-0 
local_repository_p0_test.dart
Tests for events-in-range, records screen, cycle clamp crash
+135/-0 
day_window_dst_test.dart
Tests for DST-correct local day window boundaries               
+198/-0 
derive_result_protection_test.dart
Tests that real day_result is never overwritten by skip marker
+304/-0 
derive_day_window_test.dart
Tests for derivation day-window DST correctness                   
+234/-0 
import_data_safety_test.dart
Tests for orphan guard on decoded_onehz import path           
+320/-0 
notification_permission_test.dart
Tests for permission cache invalidation on foreground       
+127/-0 
notification_day_guard_test.dart
Tests for notification fire-once day guard correctness     
+201/-0 
app_state_regressions_test.dart
Regression tests for app_state flag-reset and latch bugs 
+306/-0 
Enhancement
2 files
notification_ids.dart
Centralized notification ID constants                                       
+176/-0 
async_guards.dart
Shared async/mounted guard widgets for UI safety                 
+51/-0   
Additional files
57 files
AndroidManifest.xml +31/-0   
OpenStrapWidgetProvider.kt +4/-1     
WatchMetrics.swift +3/-3     
OpenStrapWidget.swift +3/-3     
Info.plist +25/-0   
ble_state.dart +193/-0 
cloud_import.dart +19/-5   
coach_db.dart +386/-55
coach_engine.dart +90/-4   
coach_prompt.dart +9/-4     
crossday_pipeline.dart +83/-13 
derivation_engine.dart +460/-103
derive_prepare.dart +67/-17 
onehz_pipeline.dart +24/-4   
substrate.dart +46/-3   
route_math.dart +8/-2     
route_tracker.dart +31/-4   
noop_import.dart +61/-7   
whoop_import.dart +126/-24
main.dart +11/-1   
notification_center.dart +46/-2   
notification_event.dart +6/-12   
notification_service.dart +106/-29
app_state.dart +220/-47
headless_boot.dart +46/-3   
sync_policy.dart +15/-0   
telemetry_service.dart +60/-0   
ai_breakdown_screen.dart +10/-0   
ai_settings_screen.dart +18/-6   
ai_coach_screen.dart +10/-4   
coach_chart.dart +4/-1     
recap_card.dart +10/-3   
charts.dart +119/-77
kit.dart +14/-1   
detail_cards.dart +127/-23
metric_screen.dart +119/-17
screens.dart +11/-5   
trend_screen.dart +26/-7   
widget_service.dart +6/-1     
pubspec.yaml +8/-0     
ai_screen_async_guards_test.dart +128/-0 
ble_engine_test.dart +0/-35   
coach_provider_response_test.dart +178/-0 
coach_sql_guard_adversarial_test.dart +147/-0 
coach_sql_structural_test.dart +109/-0 
crossday_pipeline_test.dart +121/-0 
derive_isolate_lifecycle_test.dart +237/-0 
headless_gate_test.dart +73/-5   
metric_trend_redesign_test.dart +5/-2     
notification_center_test.dart +19/-6   
notification_ids_test.dart +120/-0 
notification_schedule_dst_test.dart +139/-0 
readiness_baseline_pollution_test.dart +79/-0   
route_math_test.dart +51/-0   
route_tracker_test.dart +78/-0   
telemetry_consent_default_test.dart +149/-0 
widget_service_sentinels_test.dart +70/-0   

Summary by CodeRabbit

  • New Features
    • Telemetry collection now stays off by default and only turns on when consent is explicitly applied.
    • Notifications now use collision-free OS identifiers and include a persisted “emit once per day” helper.
  • Bug Fixes
    • Sleep rings/widgets no longer fabricate an 8-hour baseline when sleep-need data is missing.
    • Chart, recap, trend, and wear/oxygen displays preserve missing data as gaps instead of forcing zeros.
  • UI Improvements
    • Async UI flows are safer against stale/delayed responses.
  • Reliability
    • Improved BLE session safety and safer cross-day/DST handling; scheduled notifications are DST-correct.

An audit of the whole tree surfaced defects clustered in three seams the
886-test suite never reached: failure paths, absent-vs-zero coercion, and
guards modelled with regexes and blocklists. Every fix below carries a
regression test that fails against the old code.

BLE — the safe-trim invariant held by ordering but not by errors

DrainController.commit() cleared its buffer, then swallowed the durable
commit's exception and returned void; the caller ACKed regardless, so the
band trimmed flash for records that were never persisted. db.dart's own
comment documents the OOM that makes this reachable (Crashlytics 0.9.13).
commit() now reports failure, re-buffers at the front, and the ACK is
gated on a TrimAckPolicy verdict. The drain path also had no session
guard — the only two async paths lacking the check four siblings make —
so a stale drainer could ACK an old token onto a new link. discardOpenChunk
now poisons its burst, so a straggler terminal can't trim discarded
records. Also: a failed connect no longer holds the band claim forever
(starving the headless drainer), link-down tears the session down instead
of leaking five timers and four subscriptions per drop, a corrupt
far-future RTC is refused, and the Android boot wake goes through the
headless gate.

Persistence — three ways a good day could be destroyed

- _markDaySkipped REPLACEd a real day_result with a skip marker on one
  90 s isolate timeout, finalized, unrecoverable once raw was pruned.
- Re-analyzing a day whose raw is gone derived an empty bundle with
  endSec 0, so it always looked finalizable and NULLed every scalar.
- The per-sweep baseline snapshot was mutated by appendScalars, so each
  finished day polluted the window the next one read.
None of the three can now overwrite a real result.

Also: exportDaysDb passed onCreate with no version, so it threw
ArgumentError on every call and had never worked; two unguarded ALTER
TABLE ADD COLUMN collided with modernized CREATE helpers and bricked
upgrades from old schemas permanently; the orphan guard missed the
counter-PK eviction and decodedRrByCounterRange returned zero rows across
a counter reset; deleteDays never cascaded workout_route; and local days
were assumed to be exactly 86400 s.

Privacy

The coach SQL guard modelled table references with a regex that ignored
comma cross-joins, and its blocklist was missing five real tables, so
`SELECT r.lat, r.lng FROM v_sessions s, workout_route r` returned GPS
coordinates into an LLM prompt. Replaced with a lexer + allow-list parser
plus a structural gate that derives the permitted btree root pages by
EXPLAINing the coach views, so a table added later is closed by default.

Firebase collected by default: no collection flags existed in the plist
or manifest, and the only gate ran after an async prefs read, so a fresh
install auto-logged sessions and uploaded startup crashes before consent
was read. Collection is now off at the platform level and enabled only
after consent resolves.

Absent is not zero

"Wear stretches" read _n(segments) on a List and rendered 0 every day on
every device. TrendBoard discarded the `has` flag the repository emits to
mark absent days, so unworn days drew real bars — and the oxygen board
printed a literal "0" for an unmeasured night. getDayWear collapsed "never
measured" into worn_min 0, so an imported day asserted "not worn". Charts
now take nullable values and render gaps as gaps.

DeltaChip was also being fed an absolute delta and rendering it as a
percentage, so sleeping 20 minutes more than last week displayed "20.0%".

State

Unpair could be silently undone by a late engine-state callback
re-creating the pairing from a stale serial. The once-per-day notification
guard was consumed before the notification was presented, so a recovery
alert suppressed by quiet hours never fired and never retried. A denied
notification permission was cached process-wide with no invalidation, and
schedules added absolute Durations to TZDateTime and drifted across DST.

kAlgoVersion 47 -> 48. The sibling analytics fixes from the same sweep are
deliberately NOT claimed in that changelog: pubspec.yaml still pins the
previous analytics SHA, and citing changes a pinned SHA does not contain
is how v43 documented a readiness fix that stayed broken for three
releases. The pin must be updated, and the version bumped again, once the
analytics change merges.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b3e1808e-d81e-4c7e-ac45-21d65b8ec55c

📥 Commits

Reviewing files that changed from the base of the PR and between 9f53dd9 and b014c52.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • lib/compute/derivation_engine.dart
  • pubspec.yaml
🚧 Files skipped from review as they are similar to previous changes (2)
  • pubspec.yaml
  • lib/compute/derivation_engine.dart

📝 Walkthrough

Walkthrough

This PR updates BLE synchronization, derivation, SQLite storage, imports, notifications, telemetry consent, DST handling, and UI rendering. It adds regression coverage and replaces fabricated defaults with explicit absent-value semantics.

Changes

Application correctness and safety

Layer / File(s) Summary
BLE session and offload safety
lib/ble/*, lib/sync/sync_policy.dart, test/ble_*
Session ownership, band arbitration, frame routing, durable commit checks, trim guards, and corrupt clock rejection are added.
Derivation and time handling
lib/compute/*, lib/data/day_label.dart, test/derive_*, test/crossday_pipeline_test.dart
Derivation gains immutable baselines, cancellable isolates, protected results, DST-aware windows, dense load series, and non-imputed profile metrics.
Database and import integrity
lib/data/*, lib/import/*, lib/cloud/cloud_import.dart, test/db_*, test/import_data_safety_test.dart
Migrations become guarded and resumable; exports, deletes, RR ownership, event queries, and malformed import rows are handled safely.
Notifications and lifecycle
lib/notify/*, lib/state/app_state.dart, lib/sync/headless_boot.dart, test/notification_*, test/app_state_regressions_test.dart
Notification IDs, permission invalidation, DST scheduling, once-per-day emission, alarm cleanup, pairing healing, timer disposal, and boot synchronization gating are updated.
Telemetry and absent-value UI
lib/telemetry/*, lib/ui/*, native manifests, test/absent_not_zero_test.dart
Telemetry stays disabled until consent, while charts, wear, oxygen, trends, recap bars, and AI screens preserve missing values and guard asynchronous updates.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • OpenStrap/edge#101: Both changes modify derivation isolate handling and algorithm-version updates.
  • OpenStrap/edge#108: Both changes modify readiness baseline derivation from stored metric history.
  • OpenStrap/edge#137: Both changes modify notification emission and deduplication behavior.
🚥 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 is concise and accurately summarizes the PR’s broad focus on defect fixes across data loss, privacy, and absent-vs-zero handling.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issues

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

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit b014c52)

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

Stale-session drainer clears flag

In _drainOffloadFrames, the finally block now guards the _drainingOffloadFrames = false reset with if (_session == session). If the stale loop exits via the _sessionIsStale early-return INSIDE the try body (not via an exception), the finally still runs and the guard correctly leaves the flag alone — that part is fine. However, if the stale loop exits via an exception thrown after the new session's drainer has already set _drainingOffloadFrames = true and started, the finally of the stale loop sees _session != session and correctly skips the reset. The concern is the inverse: if the NEW session's drainer finishes and clears the flag, and THEN the stale loop's finally runs and sees _session == session (because the new session is still current), it will incorrectly clear _drainingOffloadFrames = false while no drainer is actually running — which is actually correct in that moment. The real risk is a race where _teardownSession nulls _session between the stale-loop check and the new drainer starting, leaving _drainingOffloadFrames permanently true. This is the same "sticky boolean latch" pattern (§4.3) that has wedged sync repeatedly. The fix is sound for the documented scenario but the interaction between _teardownSession clearing _drainingOffloadFrames directly and this guard needs a test that covers the teardown-during-drain path.

} finally {
  // Only the CURRENT session's loop may clear the flag. A stale loop
  // unwinding after the new session's drainer already started would
  // otherwise re-open the door to a second concurrent drainer.
  if (_session == session) _drainingOffloadFrames = false;
}
Btree-gate cache invalidation

_allowedRoots is a static field cached after the first call to _allowedRootPages. close() correctly nulls it. However, _allowedRootPages queries sqlite_master directly on the read-only handle to build the allowed-table set. If the schema changes between two open/close cycles (e.g. a migration adds a new view or renames a base table), the cached roots from the previous handle are stale and the gate either over-allows (a newly-added base table whose root page happens to collide with a previously-allowed one) or under-allows (a view rebuilt with a new root page is now blocked). The cache is keyed on nothing — not on the schema version, not on the handle identity. Since close() is the only invalidation path and the RW handle can migrate the schema without going through CoachDb.close(), a migration followed by a re-open of the RO handle will use stale roots. This is a correctness issue for layer 2's security guarantee, not just a performance concern.

static Future<Set<int>> _allowedRootPages(Database db) async {
  final cached = _allowedRoots;
  if (cached != null) return cached;
  final roots = <int>{};
  for (final v in allowedViews) {
    try {
      roots.addAll(await _btreeRoots(db, 'SELECT * FROM $v'));
    } catch (_) {/* a view that can't compile contributes nothing */}
  }
  // Indexes on an allowed base table are allowed too (the planner may pick
  // one for a WHERE the plain scan above never used).
  final schema = await db.rawQuery(
      'SELECT type, name, tbl_name, rootpage FROM sqlite_master');
  final tableOfRoot = <int, String>{};
  for (final r in schema) {
    final rp = (r['rootpage'] as num?)?.toInt();
    final tbl = r['tbl_name']?.toString();
    if (rp != null && rp > 0 && tbl != null) tableOfRoot[rp] = tbl;
  }
  final allowedTables = <String>{
    for (final rp in roots)
      if (tableOfRoot[rp] != null) tableOfRoot[rp]!,
  };
  for (final e in tableOfRoot.entries) {
    if (allowedTables.contains(e.value)) roots.add(e.key);
  }
  _allowedRoots = roots;
  return roots;
}
commit() re-buffer order under concurrent arrival

In DrainController.commit(), on failure the snapshot is restored with _raws.insertAll(0, raws). The comment says "records that arrived during the await are already appended behind it, so this preserves arrival order." This is correct for _raws. However _samples and _archives are restored the same way (insertAll(0, samples) / insertAll(0, archives)). If records arriving during the commit await added to _samples or _archives independently of _raws (e.g. an archive record with no corresponding raw), the restored prefix may not align with the suffix that arrived during the await. More critically, _lastAckedToken and lastTrimAdvanced are rolled back, but records (the total connection count) and recordsThisOffload are NOT rolled back — they were incremented during the snapshot phase. After a failed commit that re-buffers, these counters are permanently inflated by the number of records in the failed batch, which will cause the next successful commit's ledger entry to report an incorrect record count and could affect bufferedRecords / bufferedRecTsRange diagnostics used in the refuse-trim log path.

  } else if (onRecordsBatch != null && raws.isNotEmpty) {
    await onRecordsBatch!(raws, samples);
  }
  return true;
} catch (e) {
  // Put the snapshot back at the FRONT: records that arrived during the
  // await are already appended behind it, so this preserves arrival order.
  _raws.insertAll(0, raws);
  _samples.insertAll(0, samples);
  _archives.insertAll(0, archives);
  // Roll back the trim bookkeeping too — nothing advanced.
  _lastAckedToken = previousAckedToken;
  lastTrimAdvanced = previousTrimAdvanced;
  log('offload commit FAILED ($e) — ${raws.length} records + '
      '${archives.length} archived re-buffered; the caller MUST NOT ACK '
      'this chunk (the band still holds it).');
  return false;
}

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to b014c52

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Re-check session identity after async teardown before releasing band claim

The deferred teardown checks _session != session at the start, but by the time the
microtask runs a new connect() may have already set _session to a new session AND
called _claimBand() setting _bandOwner = this for the new engine. Calling
_releaseBand() after that check passes (because _session was replaced) would then
release the NEW session's band claim, not the stale one, permanently wedging all
future drains. The _releaseBand() call should also guard that _bandOwner is still
this (which _releaseBand already does via identical(_bandOwner, this)), but the
session check must be re-evaluated after _teardownSession completes since that await
can park for a significant duration.

lib/ble/ble_engine.dart [1427-1437]

 unawaited(
   Future<void>(() async {
     if (_session != session) return; // a connect already replaced us
     await _teardownSession(intentional: false);
+    // Re-check after the await: a connect() may have replaced the session
+    // (and re-claimed the band) while teardown was in flight.
+    if (_session != session) return;
     // A claim held with no link is a stale claim (see [BandClaimPolicy]);
     // releasing it here is what stops a failed foreground link from
     // wedging every later background drain. The caller's reconnect loop
     // re-claims through connect(), where foreground still preempts.
     _releaseBand();
   }),
 );
Suggestion importance[1-10]: 7

__

Why: This is a valid race condition: _teardownSession is async and can park for a significant duration, during which a new connect() could replace _session and re-claim the band. Without the re-check after the await, _releaseBand() could release the new session's band claim. Note that _releaseBand() uses identical(_bandOwner, this) which provides some protection, but the session re-check is still the correct guard for this specific scenario.

Medium
Fix double-counted batch ops for decoded_onehz import rows

The ops counter is incremented twice for decoded_onehz rows: once via ops +=
_queueOrphanGuard(...) (which returns 1) and once via if (++ops >= chunkOps). For
all other tables it is only incremented by the ++ops in the flush check. This means
the flush threshold is reached prematurely for decoded_onehz rows (every 2000 rows
instead of 4000), and the ops count drifts out of sync with the actual number of
batch operations queued, potentially causing either under-flushing (batch grows
beyond chunkOps) or over-flushing (empty commits). The _queueOrphanGuard return
value should be accumulated separately or the single ++ops should account for both
operations.

lib/data/db.dart [3304-3308]

-ops += _queueOrphanGuard(batch, counter: counter, recTs: recTs);
+if (t == 'decoded_onehz') {
+          final counter = (row['counter'] as num?)?.toInt();
+          final recTs = (row['rec_ts'] as num?)?.toInt();
+          if (counter == null || recTs == null) continue;
+          _queueOrphanGuard(batch, counter: counter, recTs: recTs);
+          ops++; // count the orphan-guard delete op
         }
         batch.insert(t, row, conflictAlgorithm: ConflictAlgorithm.replace);
         copied++;
         if (++ops >= chunkOps) await flush();
Suggestion importance[1-10]: 7

__

Why: This is a real bug: for decoded_onehz rows, ops is incremented by _queueOrphanGuard (returns 1) AND again by ++ops in the flush check, causing the flush to trigger at half the intended threshold and making ops drift out of sync with actual batch operations. The improved code correctly separates the orphan guard op count from the insert op count.

Medium
Guard onExit null branch against already-completed result

When the result completes with a Substrate but ready is still pending (which cannot
happen in normal flow but can happen if the worker sends a result before its
SendPort), the ready completer is never completed and await ready.future hangs. More
critically, when the onExit null message arrives after result has already completed
successfully, fail() is called unconditionally — result is already completed so that
call is a no-op, but ready may not be, causing a hang. The null branch should guard
with if (!result.isCompleted) just like the other failure branches.

lib/ble/ble_engine.dart [1198-1226]

 sub = port.listen((message) {
   if (message is SendPort) {
     if (!ready.isCompleted) ready.complete(message);
     return;
   }
   if (message is Map && message['type'] == 'result') {
     final kind = message['kind']?.toString();
     if (kind == 'substrate') {
       final payload = ((message['payload'] as Map?) ?? const {})
           .cast<String, dynamic>();
       if (!result.isCompleted) result.complete(Substrate.fromJson(payload));
     }
     return;
   }
   if (message is Map && message['type'] == 'error') {
     fail(Exception('prepare worker error: ${message['error']}'));
     return;
   }
   if (message is List) {
     // `onError` wire format ([error, stackTrace]) — an uncaught throw.
     fail(Exception('prepare worker crashed: '
         '${message.isNotEmpty ? message.first : "no detail"}'));
     return;
   }
   if (message == null) {
     // `onExit` — the isolate ended without ever sending a result.
-    fail(StateError('prepare worker exited without a result'));
+    if (!result.isCompleted) {
+      fail(StateError('prepare worker exited without a result'));
+    }
   }
 });
Suggestion importance[1-10]: 5

__

Why: The fail() function already guards both ready and result completers with isCompleted checks, so calling fail() when result is already completed is a no-op for result. However, the ready completer could theoretically still be pending when onExit fires after a successful result, causing a hang. The fix adds an extra guard that prevents this edge case, though it's an unlikely scenario in practice.

Low
Fix partial-eviction fallback in RR beat range query

When fromCounter == toCounter (single-row page), n == want requires exactly 1
matching row, but MIN(rec_ts) == MAX(rec_ts) so the range query rec_ts >= lo AND
rec_ts <= hi is correct. However, when both endpoints exist but one is missing from
decoded_onehz (evicted), n < want falls through to the counter-range fallback —
which is exactly the reboot-straddle case the new code is trying to fix. The
fallback should only be used when neither endpoint resolves; if one resolves, the
time-based path should still be preferred using whichever bound is available,
otherwise the fallback silently returns beats from the wrong counter range for a
partially-evicted page.

More critically, the n == want check uses COUNT(*) over counter IN (fromCounter,
toCounter) — if fromCounter == toCounter and that single counter exists, n=1 and
want=1 so it proceeds correctly. But if the two counters are different and only one
exists (n=1, want=2), the fallback fires and uses lo = min(fromCounter,toCounter) to
hi = max(fromCounter,toCounter) — the exact inverted-counter bug being fixed. Use
the resolved rec_ts bounds when at least one endpoint is found.

lib/data/db.dart [2615-2625]

-if (n == want) {
+if (n >= 1) {
+  // At least one endpoint resolved — use the time window we have.
+  // If only one resolved, lo==hi and we get exactly that second's beats,
+  // which is better than a counter-range that may be inverted.
   return db.rawQuery(
     'SELECT rr.counter AS counter, rr.beat_index AS beat_index, '
     '       rr.rr_ts_ms AS rr_ts_ms, rr.rr_ms AS rr_ms '
     'FROM decoded_rr rr '
     'JOIN decoded_onehz d ON d.counter = rr.counter '
     'WHERE d.rec_ts >= ? AND d.rec_ts <= ? '
     'ORDER BY d.rec_ts ASC, rr.beat_index ASC',
     [bounds['lo'], bounds['hi']],
   );
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion raises a valid edge case where one of two endpoints is evicted from decoded_onehz, causing the fallback to use a potentially inverted counter range. However, the PR's design intent is to use the time-based path only when BOTH endpoints are confirmed present (ensuring the page boundaries are correct), and fall back to counter range otherwise. Changing to n >= 1 could return beats from an incomplete time window when only one endpoint exists, which may be equally wrong. The suggestion has merit but the tradeoff is debatable.

Low
Error path must always clear loading flag for current token

On the error path in _go(), the LatestRequestGate check correctly guards setState,
but _loading is left true for any superseded request that errors. If the superseding
request also errors and its token is current, _loading will be set to false
correctly — however if the superseding request is still in-flight when the earlier
one errors, the widget stays in a permanent loading state because neither path
clears _loading. The gate check should only suppress the state write when the token
is genuinely stale; an error on the current token must always clear _loading.

lib/ui/screens/detail_cards.dart [145-148]

 } catch (_) {
-  if (!mounted || !_gate.isCurrent(token)) return;
+  if (!mounted) return;
+  if (!_gate.isCurrent(token)) return;
   setState(() => _loading = false);
 }
Suggestion importance[1-10]: 2

__

Why: The suggestion claims a permanent loading state is possible, but the logic is actually correct as written — the !_gate.isCurrent(token) check only suppresses the setState for stale tokens, and the current token's error path does clear _loading. The improved_code is functionally identical to the existing_code (just reformatted), making this suggestion essentially a no-op.

Low
General
Prevent caching empty allowed-roots set across view-compile failures

*_allowedRootPages reads from sqlite_master directly on the read-only coach handle.
The EXPLAIN SELECT * FROM path only covers the query plan for a full table scan — a
user query with a WHERE clause may cause the planner to choose a different index
whose root page was not seen during the EXPLAIN of the bare SELECT . The
index-expansion loop below does handle this, but it reads sqlite_master which is
itself a sensitive internal. More critically, if all allowed views fail to compile
(e.g. on a fresh DB before views are created), roots is empty and
_assertAllowedBtrees throws 'Query surface unavailable.' — blocking every coach
query. This empty-roots guard should be checked after the index expansion, not
before, since the expansion may add index roots even when no view scan roots were
found.

Additionally, _allowedRoots is a static field that caches across close() calls —
close() correctly nulls it, but if a new handle is opened to a different database
file (e.g. the export DB), the stale cache from the previous handle would be used.
This is already handled by nulling in close(), so this is fine as-is.

lib/coach/coach_db.dart [390-403]

 static Future<Set<int>> _allowedRootPages(Database db) async {
   final cached = _allowedRoots;
   if (cached != null) return cached;
   final roots = <int>{};
   for (final v in allowedViews) {
     try {
       roots.addAll(await _btreeRoots(db, 'SELECT * FROM $v'));
     } catch (_) {/* a view that can't compile contributes nothing */}
   }
   // Indexes on an allowed base table are allowed too (the planner may pick
   // one for a WHERE the plain scan above never used).
   final schema = await db.rawQuery(
       'SELECT type, name, tbl_name, rootpage FROM sqlite_master');
+  final tableOfRoot = <int, String>{};
+  for (final r in schema) {
+    final rp = (r['rootpage'] as num?)?.toInt();
+    final tbl = r['tbl_name']?.toString();
+    if (rp != null && rp > 0 && tbl != null) tableOfRoot[rp] = tbl;
+  }
+  final allowedTables = <String>{
+    for (final rp in roots)
+      if (tableOfRoot[rp] != null) tableOfRoot[rp]!,
+  };
+  for (final e in tableOfRoot.entries) {
+    if (allowedTables.contains(e.value)) roots.add(e.key);
+  }
+  // Only cache (and only allow queries) when the surface is non-empty.
+  // An empty set means no view compiled — fail open on cache, closed on query.
+  if (roots.isEmpty) return roots;
+  _allowedRoots = roots;
+  return roots;
+}
Suggestion importance[1-10]: 4

__

Why: The suggestion correctly identifies that caching an empty _allowedRoots set would cause every subsequent call to return the cached empty set, making _assertAllowedBtrees always throw 'Query surface unavailable.' rather than retrying. However, the existing code already has a guard in _assertAllowedBtrees that throws on empty allowed roots, and the scenario (all views failing to compile) is unlikely in production. The fix is valid but low-impact.

Low
Use canonical day-label helper instead of hand-rolling

This helper constructs a local day label by hand using DateTime.now(), duplicating
the logic that todayLabel() from data/day_label.dart already owns. Per AGENTS.md §3
invariant 7, day labels must always come from todayLabel() / dayLabelOf() — a
hand-rolled UTC-vs-local mistake was already reintroduced in the same file once
before. Replace this with a call to todayLabel() so the test exercises the same code
path the production app uses.

test/absent_not_zero_test.dart [39-44]

-String _today() {
-  final d = DateTime.now();
-  return '${d.year.toString().padLeft(4, '0')}-'
-      '${d.month.toString().padLeft(2, '0')}-'
-      '${d.day.toString().padLeft(2, '0')}';
-}
+String _today() => todayLabel();
Suggestion importance[1-10]: 3

__

Why: While using a canonical todayLabel() helper is a good practice, this is a test file and the hand-rolled implementation is straightforward and correct. The suggestion references AGENTS.md which is not visible in the PR, and the improved code assumes todayLabel() is importable in the test context without showing the import. The impact is low as this is test-only code.

Low
Strengthen rollback assertion to verify cursor token identity

After the failed commit of _tokenB, the test asserts lastTrimAdvanced is true with
the reason "rolled back to the tokenA state". This is ambiguous: lastTrimAdvanced
being true after a failed commit could mean either "correctly rolled back to the
previous advanced state" or "the flag was never cleared". The test should also
assert that lastTrimAdvanced was false at some intermediate point (e.g. immediately
after the failed commit before the retry), or add a separate assertion that the
internal cursor token was not updated to _tokenB, to distinguish a genuine rollback
from a latch that simply never reset.

test/ble_safe_trim_test.dart [144-163]

-test('a failed commit rolls back the trim-advance bookkeeping', () async {
-  var fail = false;
-  final d = DrainController(
-    onRecord: (sample, raw) async {},
-    onRecordsBatch: null,
-    onCommit: (raws, samples, token, {archives}) async {
-      if (fail) throw StateError('rollback');
-    },
-    onArchive: null,
-    log: (_) {},
-  );
+fail = true;
+d.onHistoricalRecord(_raw(1), _sample(1));
+expect(await d.commit(_tokenB), isFalse);
+// The cursor did NOT move to tokenB — the bookkeeping rolled back.
+expect(d.lastAckedToken, _tokenA,
+    reason: 'cursor must not advance on a failed commit');
+expect(d.lastTrimAdvanced, isTrue, reason: 'rolled back to the tokenA state');
 
-  expect(await d.commit(_tokenA), isTrue);
-  expect(d.lastTrimAdvanced, isTrue);
-
-  fail = true;
-  d.onHistoricalRecord(_raw(1), _sample(1));
-  expect(await d.commit(_tokenB), isFalse);
-  // The cursor did NOT move to tokenB, so nothing may claim it did.
-  expect(d.lastTrimAdvanced, isTrue, reason: 'rolled back to the tokenA state');
-
Suggestion importance[1-10]: 2

__

Why: The suggestion to add a lastAckedToken assertion would strengthen the test, but lastAckedToken is not shown to exist as a public property on DrainController in the PR diff, making the improved_code potentially invalid. The suggestion is speculative about the API surface.

Low
Document pre-commit gate's unconditional commitDurable argument

The pre-commit gate passes commitDurable: true unconditionally, but
TrimAckVerdict.blockedCommitFailed can never be returned at this point since no
commit has happened yet. This is harmless for the pre-commit check itself, but if
TrimAckPolicy.evaluate uses commitDurable: false to signal blockedCommitFailed,
passing true here means a poisoned-burst verdict could be masked if the policy logic
combines the two conditions. More importantly, the pre-commit call should explicitly
pass commitDurable: true only to signal "commit hasn't failed yet" — this is correct
— but should be documented clearly to avoid a future maintainer passing the actual
(not-yet-known) durability, which would always be false before the commit runs and
would incorrectly block every ACK.

lib/ble/ble_engine.dart [2262-2266]

+// Pre-commit gate: commit has not run yet, so durability is not in question.
+// Only stale-session and poisoned-burst verdicts are possible here.
 final preVerdict = TrimAckPolicy.evaluate(
   sessionCurrent: !_sessionIsStale(session),
   burstDiscarded: d.burstDiscarded,
-  commitDurable: true,
+  commitDurable: true, // not yet attempted — only session/poison checked
 );
Suggestion importance[1-10]: 1

__

Why: This suggestion only adds a comment to clarify why commitDurable: true is passed unconditionally at the pre-commit gate. The existing code is correct and the improved_code is functionally identical to existing_code — only a comment is added, which warrants a very low score.

Low

Previous suggestions

Suggestions up to commit 9f53dd9
CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard band claim release against replacement session

The deferred teardown checks _session != session to avoid killing a replacement
session, but _releaseBand() is called unconditionally after that guard — if a new
session was already installed between the guard check and _releaseBand(), the new
session's band claim is released, leaving _bandOwner null while the new session is
live. This is the exact stale-claim problem the PR is trying to fix, reintroduced on
the teardown path. The release should be guarded by the same identity check.

lib/ble/ble_engine.dart [1427-1437]

 unawaited(
   Future<void>(() async {
     if (_session != session) return; // a connect already replaced us
     await _teardownSession(intentional: false);
-    // A claim held with no link is a stale claim (see [BandClaimPolicy]);
-    // releasing it here is what stops a failed foreground link from
-    // wedging every later background drain. The caller's reconnect loop
-    // re-claims through connect(), where foreground still preempts.
-    _releaseBand();
+    // Only release the claim if we are still the owner — a new connect
+    // may have already re-claimed between the teardown await and here.
+    if (identical(_bandOwner, this)) _releaseBand();
   }),
 );
Suggestion importance[1-10]: 7

__

Why: This is a valid race condition: after _teardownSession awaits, a new connect() could have already set _bandOwner to the new engine, and the unconditional _releaseBand() would null it out. The fix — checking identical(_bandOwner, this) before releasing — is exactly what _releaseBand() already does internally, so the suggestion is correct and addresses a real TOCTOU issue, though _releaseBand() itself already contains if (identical(_bandOwner, this)) _bandOwner = null, making the outer guard redundant but harmless.

Medium
Fix timezone-dependent epoch in test fixture

DateTime.parse('2026-03-05 08:30:00') is parsed as local time by Dart, but
localDateLabel expects a UTC epoch second and converts it to local midnight. On a CI
machine running in UTC this produces '2026-03-05', but on a machine in UTC+N the
same wall string parsed as local time yields a different epoch, potentially
producing '2026-03-04' or '2026-03-05' depending on timezone — making the test
non-deterministic. Use DateTime.utc(2026, 3, 5, 8, 30) or pin the timezone
explicitly so the epoch is stable across environments.

test/import_data_safety_test.dart [28-31]

 /// Local-time wall clock → the day label the importer will file it under.
 const _wake = '2026-03-05 08:30:00';
-final _wakeSec = DateTime.parse(_wake).millisecondsSinceEpoch ~/ 1000;
+final _wakeSec = DateTime.utc(2026, 3, 5, 8, 30).millisecondsSinceEpoch ~/ 1000;
 final _day = localDateLabel(_wakeSec);
Suggestion importance[1-10]: 6

__

Why: This is a valid concern — DateTime.parse('2026-03-05 08:30:00') is parsed as local time in Dart, making _wakeSec timezone-dependent and potentially causing non-deterministic test behavior on CI machines in different timezones. Using DateTime.utc(2026, 3, 5, 8, 30) would make the epoch stable across environments.

Low
Guard against null aggregate bounds before binding query args

When fromCounter == toCounter (a single-row page), n == want requires exactly 1
matching row, but MIN(rec_ts) == MAX(rec_ts) so the range query d.rec_ts >= lo AND
d.rec_ts <= hi is correct. However, when both endpoints exist but one of them is not
a real row in decoded_onehz (e.g. the caller passed sentinel values like 0 and 1<<30), n
will be 0 or 1 instead of 2, and the code falls through to the counter-range
fallback — which is the intended behavior. The real defect is that bounds['lo'] and
bounds['hi'] are dynamic from rawQuery and are passed directly as bind args; if
either is null (no rows matched at all, which can't happen here since n == want
guards it, but MIN/MAX return NULL on an empty set), SQLite binds NULL and the WHERE
becomes unsatisfiable, silently returning zero beats. Add a null guard before using
them.

lib/data/db.dart [2615-2625]

 if (n == want) {
+  final lo = (bounds['lo'] as num?)?.toInt();
+  final hi = (bounds['hi'] as num?)?.toInt();
+  if (lo == null || hi == null) {
+    // Shouldn't happen (n == want > 0), but guard against NULL aggregates.
+    return [];
+  }
   return db.rawQuery(
     'SELECT rr.counter AS counter, rr.beat_index AS beat_index, '
     '       rr.rr_ts_ms AS rr_ts_ms, rr.rr_ms AS rr_ms '
     'FROM decoded_rr rr '
     'JOIN decoded_onehz d ON d.counter = rr.counter '
     'WHERE d.rec_ts >= ? AND d.rec_ts <= ? '
     'ORDER BY d.rec_ts ASC, rr.beat_index ASC',
-    [bounds['lo'], bounds['hi']],
+    [lo, hi],
   );
 }
Suggestion importance[1-10]: 4

__

Why: The null guard is technically correct but practically unreachable since n == want already guarantees at least one row matched, making MIN/MAX non-null. The suggestion adds defensive code for an impossible state, offering minimal real-world impact.

Low
Use canonical day-label helper instead of manual UTC construction

This helper constructs a local day label manually using DateTime.now() instead of
using the canonical todayLabel() from data/day_label.dart. Per AGENTS.md §3
invariant 7, day labels must always come from todayLabel() / dayLabelOf() — a second
construction path is the bug the invariant exists to prevent, and this one will
silently disagree with the production code on DST transitions.

test/absent_not_zero_test.dart [39-44]

-String _today() {
-  final d = DateTime.now();
-  return '${d.year.toString().padLeft(4, '0')}-'
-      '${d.month.toString().padLeft(2, '0')}-'
-      '${d.day.toString().padLeft(2, '0')}';
-}
+String _today() => todayLabel();
Suggestion importance[1-10]: 4

__

Why: The suggestion raises a valid concern about consistency with the canonical todayLabel() helper, but the _today() function in the test file constructs a local date label manually. The improved code assumes todayLabel() is importable in the test context, which may not be verified. The concern about DST transitions is real but minor in a test context.

Low
Fix non-sendable closure passed to Isolate.spawn

The compute closure is captured from the caller's isolate and passed as part of the
message tuple to Isolate.spawn. Dart's Isolate.spawn requires its message argument
to be sendable across isolate boundaries, but a FutureOr<Object?> Function() closure that
closes over caller-isolate state is not sendable — this will throw a runtime error
when the closure captures any non-primitive. The existing _dayBlocksIsolateEntry
pattern avoids this by accepting only a plain _DayBlocksInput data class. The
sleep-staging and crossday sites pass lambdas that call analytics functions with
captured state, which will fail at the Isolate.spawn call. Use Isolate.run (which
handles the closure transfer internally) or restructure to pass only sendable data,
as the existing _runDayBlocksCancellable does.

lib/compute/derivation_engine.dart [3427-3440]

+// Use Isolate.run which correctly transfers the closure, then enforce
+// kill-on-timeout via a separate Isolate reference.
 static Future<R> _runIsolateCancellable<R>(
   FutureOr<R> Function() compute,
   Duration timeout, {
   required String label,
 }) async {
-  final port = ReceivePort();
-  final (SendPort, FutureOr<Object?> Function()) message =
-      (port.sendPort, compute);
-  final isolate = await Isolate.spawn(
-    _cancellableIsolateEntry,
-    message,
-    onError: port.sendPort,
-    onExit: port.sendPort,
-  );
+  // Isolate.run handles closure transfer across the isolate boundary safely.
+  // We wrap it with a timeout that kills the isolate on expiry.
+  try {
+    return await Isolate.run(compute).timeout(
+      timeout,
+      onTimeout: () =>
+          throw TimeoutException('$label timed out after $timeout'),
+    );
+  } on TimeoutException {
+    rethrow;
+  }
+}
Suggestion importance[1-10]: 3

__

Why: The concern about closures not being sendable across isolate boundaries is a known Dart limitation, but Isolate.run itself uses Isolate.spawn internally with the same closure-transfer mechanism — so the suggested fix doesn't actually solve the problem differently. Furthermore, the PR's _cancellableIsolateEntry receives the closure as part of the message tuple, which Dart does support for top-level/static closures. The suggestion's improved code also loses the kill-on-timeout behavior that is the primary motivation for _runIsolateCancellable.

Low
Reset loading latch when superseded request errors out

On the error path in _go(), when the gate check fails the method returns without
resetting _loading to false. If a superseded request errors out, _loading stays true
permanently — a sticky boolean latch with no reset on the failure path, exactly the
pattern flagged in AGENTS.md §4.3. The same issue exists in
_OxygenRecentStripState._load().

lib/ui/screens/detail_cards.dart [146-148]

-if (!mounted || !_gate.isCurrent(token)) return;
-  setState(() => _loading = false);
+if (!mounted) return;
+if (!_gate.isCurrent(token)) return; // superseded — leave loading state to the winner
+setState(() => _loading = false);
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion claims that a superseded erroring request leaves _loading permanently true, but looking at the PR code, when !_gate.isCurrent(token) is true on the error path, the method returns early — the loading state is intentionally left to the "winning" (current) request to resolve. The improved code is essentially the same behavior with a comment, offering minimal improvement.

Low
Poison burst trim guard on commit failure

The buffer is snapshotted and cleared before the await inside the try block, but the
_raws, _samples, and _archives lists are cleared (_raws.clear() etc.) before the
async call. If records arrive during the await and are appended to the now-empty
lists, the rollback path (insertAll(0, raws)) correctly prepends the snapshot — but
the _trimGuard poison state is not rolled back on failure. If commit fails for a
non-discarded burst, the trim guard state remains unchanged, which is correct, but
burstDiscarded could be stale if discardOpenChunk was called concurrently. More
critically, the _trimGuard.discardOpenChunk() is not called on commit failure, so
the guard correctly stays un-poisoned — however, the TrimAckPolicy post-commit
re-gate checks d.burstDiscarded which reflects the guard state set before commit was
called, not after the failure. Ensure the post-commit
TrimAckVerdict.blockedCommitFailed path in _handleSyncMarker is reached by verifying
TrimAckPolicy.evaluate is called with commitDurable: durable (which it is), so the
blockedCommitFailed branch fires and the re-buffer is not lost to a subsequent
beginBurst() reset before the next HISTORY_END arrives.

lib/ble/ble_engine.dart [1542-1554]

 Future<bool> commit(List<int>? token) async {
   final tokenHex = token
       ?.map((b) => b.toRadixString(16).padLeft(2, '0'))
       .join();
   final previousAckedToken = _lastAckedToken;
   final previousTrimAdvanced = lastTrimAdvanced;
   lastTrimAdvanced = tokenHex != null && tokenHex != _lastAckedToken;
   if (tokenHex != null) _lastAckedToken = tokenHex;
   final raws = List<RawRecord>.from(_raws);
-  ...
+  _raws.clear();
+  final samples = List<SampleRecord>.from(_samples);
+  _samples.clear();
+  final archives = List<ArchiveRecord>.from(_archives);
+  _archives.clear();
+  try {
+    if (onCommit != null) {
+      await onCommit!(raws, samples, archives, token);
+    } else if (onRecordsBatch != null && raws.isNotEmpty) {
+      await onRecordsBatch!(raws, samples);
+    }
     return true;
   } catch (e) {
     _raws.insertAll(0, raws);
     _samples.insertAll(0, samples);
     _archives.insertAll(0, archives);
     _lastAckedToken = previousAckedToken;
     lastTrimAdvanced = previousTrimAdvanced;
+    // Also poison the open burst: a failed commit means these records are
+    // not durable, so the HISTORY_END for this burst must not be echoed.
+    _trimGuard.discardOpenChunk();
     log('offload commit FAILED ($e) — ${raws.length} records + '
         '${archives.length} archived re-buffered; the caller MUST NOT ACK '
         'this chunk (the band still holds it).');
     return false;
   }
 }
Suggestion importance[1-10]: 2

__

Why: The existing_code snippet references a commit method that is not visible in the PR diff — it appears to be in a DrainController class outside the shown diff. The suggestion reasons about code not present in the diff, making it impossible to validate. Additionally, the improved code adds _trimGuard.discardOpenChunk() on commit failure, but the PR's TrimAckPolicy already handles commitDurable: false via blockedCommitFailed, so the suggested change may be redundant or incorrect without seeing the full DrainController implementation.

Low
General
Use canonical day-label helper instead of manual UTC-unsafe formatting

The day label is computed from window.startTs using
DateTime.fromMillisecondsSinceEpoch (local time), which is correct, but the manual
string formatting duplicates dayLabelOf() from data/day_label.dart — the single
canonical helper for local day labels (AGENTS.md §3 invariant 7). If dayLabelOf ever
changes its boundary logic (e.g. for DST), this copy will diverge silently,
mis-attributing steps to the wrong day.

lib/state/app_state.dart [1896-1902]

 if (window != null) {
-  final d = DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000);
-  final day =
-      '${d.year.toString().padLeft(4, '0')}-'
-      '${d.month.toString().padLeft(2, '0')}-'
-      '${d.day.toString().padLeft(2, '0')}';
+  final day = dayLabelOf(
+    DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000),
+  );
   await LocalDb.addLiveCoverage(window.startTs, window.endTs, steps, day);
 }
Suggestion importance[1-10]: 5

__

Why: The manual date formatting duplicates logic that should use the canonical dayLabelOf() helper, which could diverge on DST boundaries. This is a valid maintainability concern, though the immediate functional impact is low since DateTime.fromMillisecondsSinceEpoch uses local time correctly.

Low
Apply layer-1 guard before EXPLAIN in the test seam entry point

_allowedRoots is a static field cached after the first call, but close() nulls it
out correctly. However, _allowedRootPages queries sqlite_master on the read-only
handle to build the index-to-table map — and sqlite_master is in reservedTableNames
/ the banned set, so if _assertAllowedBtrees were ever called recursively or the
EXPLAIN rows themselves triggered a guard check, it would fail. More critically, the
EXPLAIN $sql call inside _btreeRoots is issued on the read-only handle with no
guard, meaning a malformed or adversarial sql string that somehow passed layer 1
could still cause rawQuery to execute arbitrary SQL at this point. The EXPLAIN
opcode is already in _banned for layer 1, but layer 2 calls _btreeRoots(db, sql)
with the already-validated sql — the issue is that _btreeRoots is also exposed via
debugAssertAllowedBtrees which takes raw SQL. Wrap the EXPLAIN call in a try/catch
that converts any exception to a SqlGuardError (already done in
_assertAllowedBtrees), but also ensure _btreeRoots itself never receives unvalidated
input from the public test seam without going through layer 1 first.

lib/coach/coach_db.dart [459-461]

-static Future<Set<int>> _allowedRootPages(Database db) async {
-final cached = _allowedRoots;
-if (cached != null) return cached;
-...
-_allowedRoots = roots;
-return roots;
+@visibleForTesting
+static Future<void> debugAssertAllowedBtrees(String sql) async {
+  // Run layer-1 guard first so the test seam never feeds raw SQL to EXPLAIN.
+  guardAndPrepare(sql);
+  return _assertAllowedBtrees(await _readonly(), sql);
 }
Suggestion importance[1-10]: 5

__

Why: The debugAssertAllowedBtrees test seam accepts raw SQL and passes it directly to EXPLAIN without layer-1 validation, which is a valid security concern for the test path. Adding guardAndPrepare as a first step would close this gap, though the @visibleForTesting annotation limits exposure to test code only.

Low
Suggestions up to commit b5c3352
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix double-counting of ops in import batch chunking

The ops counter is incremented twice for decoded_onehz rows: once via ops +=
_queueOrphanGuard(...) (which returns 1) and once via if (++ops >= chunkOps). For
every other table, ops is only incremented by the ++ops in the flush check. This
means the orphan-guard op is counted but the insert op is double-counted for
decoded_onehz, causing premature flushes and incorrect chunk sizing. The
_queueOrphanGuard return value should be added to ops separately from the insert's
++ops.

lib/data/db.dart [3304-3308]

-ops += _queueOrphanGuard(batch, counter: counter, recTs: recTs);
-        }
-        batch.insert(t, row, conflictAlgorithm: ConflictAlgorithm.replace);
-        copied++;
-        if (++ops >= chunkOps) await flush();
+if (t == 'decoded_onehz') {
+  final counter = (row['counter'] as num?)?.toInt();
+  final recTs = (row['rec_ts'] as num?)?.toInt();
+  if (counter == null || recTs == null) continue;
+  ops += _queueOrphanGuard(batch, counter: counter, recTs: recTs);
+}
+batch.insert(t, row, conflictAlgorithm: ConflictAlgorithm.replace);
+copied++;
+ops++; // count the insert op
+if (ops >= chunkOps) await flush();
Suggestion importance[1-10]: 7

__

Why: This is a genuine bug: for decoded_onehz rows, ops is incremented by _queueOrphanGuard (returns 1) AND then again by ++ops in the flush check, causing the insert op itself to be counted twice. The improved code correctly separates the guard op count from the insert op count using a plain ops++ after the insert.

Medium
Avoid permanently caching an empty allowed-roots set

_allowedRoots is a static field cached across the lifetime of the process, but
close() nulls it when the read-only handle is closed. If _allowedRootPages is called
concurrently (e.g. two coach queries racing at startup), both see _allowedRoots ==
null, both compute the set, and the second assignment wins — this is a benign race.
However, if _allowedRoots is empty after computing (all views fail to compile, e.g.
the DB is freshly opened before _ensureCoachViews runs), _assertAllowedBtrees throws
'Query surface unavailable' and blocks ALL coach queries permanently for the process
lifetime, because the empty set is cached and never recomputed. The guard should not
cache an empty result.

lib/coach/coach_db.dart [390-397]

 static Future<Set<int>> _allowedRootPages(Database db) async {
   final cached = _allowedRoots;
-  if (cached != null) return cached;
+  if (cached != null && cached.isNotEmpty) return cached;
   final roots = <int>{};
   for (final v in allowedViews) {
     try {
       roots.addAll(await _btreeRoots(db, 'SELECT * FROM $v'));
     } catch (_) {/* a view that can't compile contributes nothing */}
   }
+  // … rest of method unchanged …
+  if (roots.isNotEmpty) _allowedRoots = roots;
+  return roots;
+}
Suggestion importance[1-10]: 6

__

Why: The concern is valid: if all views fail to compile (e.g. views not yet created), an empty set gets cached and _assertAllowedBtrees permanently blocks all coach queries for the process lifetime. The fix to skip caching when roots.isEmpty is sound, though the improved code snippet is incomplete (shows // … rest of method unchanged …), making it slightly harder to evaluate fully.

Low
Reset drain latch on deferred link-down teardown

The deferred teardown in _onLinkDown checks _session != session to guard against a
concurrent connect having replaced the session, but _drainingOffloadFrames is only
cleared inside _drainOffloadFrames's finally block when _session == session. If the
deferred teardown runs after a new session has already started its own drain loop,
_drainingOffloadFrames may remain true from the old loop (since the old loop's
finally now sees _session != session and skips the reset), permanently blocking
_enqueueOffloadFrame from starting a new drain on the new session. The deferred
teardown should explicitly clear _drainingOffloadFrames when it owns the teardown.

lib/ble/ble_engine.dart [1427-1437]

-Future<void> _onLinkDown(_Session session) {
-    if (LinkDownPolicy.evaluate(sessionIsCurrent: _session == session) ==
-        LinkDownAction.ignoreStaleSession) {
-      return; // a stale session's stream
-    }
-    ...
-    _setOffloadActive(false);
-    _setPhase(BleConnState.idle);
-    unawaited(
-      Future<void>(() async {
-        if (_session != session) return; // a connect already replaced us
-        await _teardownSession(intentional: false);
-        _releaseBand();
-      }),
-    );
-  }
+unawaited(
+  Future<void>(() async {
+    if (_session != session) return; // a connect already replaced us
+    await _teardownSession(intentional: false);
+    _drainingOffloadFrames = false; // ensure no stale latch blocks the next session
+    _releaseBand();
+  }),
+);
Suggestion importance[1-10]: 5

__

Why: The concern about _drainingOffloadFrames remaining latched is valid in theory, but the PR already handles this in _drainOffloadFrames's finally block with if (_session == session) _drainingOffloadFrames = false;. The deferred teardown runs after _teardownSession, which should clear the session reference, so the old loop's finally would see _session != session and skip the reset — but _teardownSession itself likely clears _drainingOffloadFrames. The suggestion identifies a real edge case but may be partially addressed elsewhere.

Low
Fix ambiguous epoch derivation for local day label

DateTime.parse('2026-03-05 08:30:00') is parsed as a local-time DateTime, but
millisecondsSinceEpoch returns the UTC-based epoch. On a machine whose local
timezone is behind UTC (e.g. UTC-5), this epoch second corresponds to a different
UTC date than the intended local date, so localDateLabel(_wakeSec) will produce the
wrong day label and every assertion keyed on _day will silently test the wrong date.
The epoch should be derived from a DateTime that is unambiguously local, or the test
should use a fixed UTC offset.

test/import_data_safety_test.dart [28-31]

 /// Local-time wall clock → the day label the importer will file it under.
 const _wake = '2026-03-05 08:30:00';
-final _wakeSec = DateTime.parse(_wake).millisecondsSinceEpoch ~/ 1000;
+final _wakeLocal = DateTime(2026, 3, 5, 8, 30);
+final _wakeSec = _wakeLocal.millisecondsSinceEpoch ~/ 1000;
 final _day = localDateLabel(_wakeSec);
Suggestion importance[1-10]: 5

__

Why: The concern about DateTime.parse treating the string as local time while millisecondsSinceEpoch is UTC-based is real and could cause test failures on machines in non-UTC timezones. However, the improved code using DateTime(2026, 3, 5, 8, 30) has the same behavior as DateTime.parse('2026-03-05 08:30:00') — both produce a local DateTime — so the fix doesn't actually resolve the underlying timezone ambiguity.

Low
Use time-window RR query whenever any endpoint resolves

When fromCounter == toCounter (single-row page), n == want requires exactly 1
matching row in decoded_onehz. But if that single counter is absent (evicted), n is
0, the condition fails, and the code falls through to the counter-range fallback
with lo == hi == fromCounter — which is correct. However, when fromCounter !=
toCounter and only one of the two endpoint counters is present (n == 1), want is 2
so the time-window path is skipped and the fallback uses a potentially inverted or
reboot-straddling counter range, silently returning wrong beats. The fix is to use
the time-window path whenever both endpoints resolve (n == want) OR when at least
one resolves and the other is absent due to eviction — but more critically, when n
== 1 and fromCounter != toCounter, the single resolved endpoint's rec_ts should
still bound the query rather than falling back to a raw counter range.

lib/data/db.dart [2615-2626]

-if (n == want) {
+if (n > 0) {
   return db.rawQuery(
     'SELECT rr.counter AS counter, rr.beat_index AS beat_index, '
     '       rr.rr_ts_ms AS rr_ts_ms, rr.rr_ms AS rr_ms '
     'FROM decoded_rr rr '
     'JOIN decoded_onehz d ON d.counter = rr.counter '
     'WHERE d.rec_ts >= ? AND d.rec_ts <= ? '
     'ORDER BY d.rec_ts ASC, rr.beat_index ASC',
     [bounds['lo'], bounds['hi']],
   );
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion raises a valid edge case where n == 1 and fromCounter != toCounter (one endpoint evicted), but the proposed fix if (n > 0) could return incorrect results when only one endpoint is present — bounds['lo'] and bounds['hi'] would be the same value (MIN==MAX of a single row), potentially missing beats from the full page range. The current n == want logic is a deliberate conservative choice, and the fallback handles the partial-eviction case. The concern is real but the proposed fix may introduce its own correctness issue.

Low
Verify isolate closures capture only sendable values

The closure compute captured from the caller may close over non-sendable objects
(e.g. this, day, bundle, or other heap objects from the calling isolate). Passing it
as part of the message tuple to Isolate.spawn will throw a IsolateSpawnException at
runtime if the closure is not sendable. The existing _DayBlocksInput pattern was
introduced precisely to avoid this — the closure must be a static/top-level function
or a closure that captures only sendable primitives. The new _runIsolateCancellable
call sites (sleep-staging, crossday, crossday-input) pass lambdas that close over
ana globals and local variables; these need to be verified as sendable or
restructured like _dayBlocksIsolateEntry.

lib/compute/derivation_engine.dart [3423-3436]

-static Future<R> _runIsolateCancellable<R>(
-    FutureOr<R> Function() compute,
-    Duration timeout, {
-    required String label,
-  }) async {
-    final port = ReceivePort();
-    final (SendPort, FutureOr<Object?> Function()) message =
-        (port.sendPort, compute);
-    final isolate = await Isolate.spawn(
-      _cancellableIsolateEntry,
-      message,
-      onError: port.sendPort,
-      onExit: port.sendPort,
-    );
+// Each call site of _runIsolateCancellable must pass a closure that is
+// sendable across isolate boundaries — i.e. it must not close over any
+// non-primitive heap object (no `this`, no `day`, no `bundle`, no
+// ChangeNotifier, etc.). Capture only plain-data locals (String, int,
+// double, List<primitive>, Map<String,primitive>) before the call, and
+// reference them by value inside the lambda, exactly as _DayBlocksInput
+// does for the day-blocks isolate. Verify each call site:
+//
+//   sleep-staging: captures `profileJson` (String?), `override` (plain data),
+//     `daySub`/`sleepSub` (Substrate = int/double lists) — OK if Substrate
+//     is sendable.
+//   crossday: captures `days` (List<Map<String,dynamic>>) and `profileMap`
+//     (Map<String,dynamic>) — OK.
+//   crossday-input: captures `rows` (List<Map<String,dynamic>>) — OK.
+//
+// If any capture is non-sendable, extract it into a plain-data struct and
+// pass that struct as the isolate entry's argument instead.
Suggestion importance[1-10]: 3

__

Why: This is a valid concern about isolate sendability, but the suggestion only asks the user to verify rather than providing a concrete fix. The improved_code is just a comment block, not actual code changes, and the PR's existing comments already note the sendability requirement for _DayBlocksInput.

Low
Use canonical day-label helper instead of raw DateTime

This helper constructs a local day label using DateTime.now() directly instead of
the project's canonical todayLabel() from data/day_label.dart. Per AGENTS.md §3
invariant 7, day labels must always use todayLabel() / dayLabelOf() — using raw
DateTime.now() string formatting is the exact anti-pattern the rule prohibits, and
it will produce wrong labels on DST boundaries or in non-UTC timezones where the
local date differs from UTC.

test/absent_not_zero_test.dart [39-44]

-String _today() {
-  final d = DateTime.now();
-  return '${d.year.toString().padLeft(4, '0')}-'
-      '${d.month.toString().padLeft(2, '0')}-'
-      '${d.day.toString().padLeft(2, '0')}';
-}
+String _today() => todayLabel();
Suggestion importance[1-10]: 3

__

Why: The suggestion is valid in principle — using a canonical todayLabel() helper is better practice. However, in a test file this is a minor style concern; the raw DateTime.now() formatting produces the same result in most environments, and the improved code assumes todayLabel() is importable without showing the import. The impact is low.

Low
Close async window in commit buffer snapshot-and-clear

The _archives list is cleared before the try block containing await calls. If an
archive record arrives during the await (appended to the now-empty _archives), the
catch block's insertAll(0, archives) will correctly prepend the snapshot — but the
newly-arrived archive records will be silently lost from the restored buffer since
they were appended after the clear and before the restore. Move the buffer
snapshot-and-clear inside the try block, immediately before the async call, to close
this window.

lib/ble/ble_engine.dart [1542-1553]

 Future<bool> commit(List<int>? token) async {
-    ...
+    final tokenHex = token
+        ?.map((b) => b.toRadixString(16).padLeft(2, '0'))
+        .join();
+    final previousAckedToken = _lastAckedToken;
+    final previousTrimAdvanced = lastTrimAdvanced;
     lastTrimAdvanced = tokenHex != null && tokenHex != _lastAckedToken;
     if (tokenHex != null) _lastAckedToken = tokenHex;
-    final raws = List<RawRecord>.from(_raws);
-    ...
-    _raws.clear();
-    _samples.clear();
-    _archives.clear();
+    // Snapshot and clear INSIDE the try so any records arriving during the
+    // await are appended after the snapshot and are preserved on failure.
     try {
-      ...
-      return true;
+      final raws = List<RawRecord>.from(_raws);
+      final samples = List<SampleRecord>.from(_samples);
+      final archives = List<ArchiveRecord>.from(_archives);
+      _raws.clear();
+      _samples.clear();
+      _archives.clear();
+      try {
+        if (onCommit != null) {
+          await onCommit!(raws, samples, archives, token);
+        } else if (onRecordsBatch != null && raws.isNotEmpty) {
+          await onRecordsBatch!(raws, samples);
+        }
+        return true;
+      } catch (e) {
+        _raws.insertAll(0, raws);
+        _samples.insertAll(0, samples);
+        _archives.insertAll(0, archives);
+        _lastAckedToken = previousAckedToken;
+        lastTrimAdvanced = previousTrimAdvanced;
+        log('offload commit FAILED ($e) — ${raws.length} records + '
+            '${archives.length} archived re-buffered; the caller MUST NOT ACK '
+            'this chunk (the band still holds it).');
+        return false;
+      }
     } catch (e) {
-      _raws.insertAll(0, raws);
-      _samples.insertAll(0, samples);
-      _archives.insertAll(0, archives);
-      _lastAckedToken = previousAckedToken;
-      lastTrimAdvanced = previousTrimAdvanced;
-      ...
       return false;
     }
   }
Suggestion importance[1-10]: 2

__

Why: The suggestion references code (commit method with _raws, _samples, _archives) that is not visible in the PR diff shown — it appears to be in a DrainController class outside the diff scope. The existing_code snippet cannot be validated against the __new hunk__ sections, making this suggestion unverifiable from the provided diff.

Low
General
Simplify absent-wear guard to the correct null check

The guard checks d.isEmpty || worn == null, but d is typed as Map<String, dynamic>
and a cloud-imported day payload is non-empty (it contains first_on, last_on,
hourly, etc.) while still having no worn_min key. The d.isEmpty branch is therefore
never reached for the real absent-wear case; only the worn == null branch matters.
The d.isEmpty check is harmless but misleading — more critically, the _wearDay(worn:
null, cov: null) test helper does include other keys, so the test actually exercises
the worn == null path, which is correct. No runtime regression here, but the comment
and guard should reflect that d.isEmpty is a degenerate fallback, not the primary
absent-wear signal, to avoid a future reader removing the worn == null branch as
"redundant".

lib/ui/screens/detail_cards.dart [1977-1986]

-if (d.isEmpty || worn == null) {
+// A cloud-imported day carries no wear block: `worn_min` is absent (null),
+// not zero. `d.isEmpty` is a degenerate safety net; the real absent-wear
+// signal is `worn == null`.
+if (worn == null) {
   return const _QuietState(
     icon: OsIcon.wear,
     title: 'Wear time wasn't recorded',
     message:
         'This day has no wear measurement stored — imported days don't '
         'carry one. That is not the same as the strap being off, so '
         'nothing is claimed either way.',
   );
 }
Suggestion importance[1-10]: 4

_...

@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: 12

Caution

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

⚠️ Outside diff range comments (1)
lib/ui/screens/detail_cards.dart (1)

1659-1702: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Oxygen strip drops missing nights instead of leaving a gap, contradicting the new methodNote.

present filters out every bucket without has == true, and values/labels are built only from present — so a missing night is removed entirely rather than rendered as a gap. But the new methodNote text (added in this same change) states "Nights with no imported value are left blank." That isn't what the code does: the chart silently shows fewer bars for that week with no visual indication a night is missing, the same absent-as-zero-adjacent compaction issue this PR fixes elsewhere for RecapCard/MiniBars (day-shift from stripping nulls).

Build values/labels from the full buckets list (nulling value when has != true) and pass the nullable list straight into LabeledBars, consistent with CoachChart's fix.

🐛 Proposed fix to preserve gaps
-    final present = buckets.where((b) => b['has'] == true).toList();
-    if (present.isEmpty) {
+    final present = buckets.where((b) => b['has'] == true).toList();
+    if (present.isEmpty) {
       return const _QuietState(
         icon: OsIcon.hydration,
         title: 'No recent oxygen trend yet',
         message: 'A few more nights of wear build the 7-night pattern.',
       );
     }
-    final values = [
-      for (final b in present) ((b['value'] as num?)?.toDouble() ?? 0),
-    ];
-    final labels = [
-      for (final b in present) _label((b['t_start'] as num?)?.toInt() ?? 0),
-    ];
+    final values = [
+      for (final b in buckets)
+        b['has'] == true ? (b['value'] as num?)?.toDouble() : null,
+    ];
+    final labels = [
+      for (final b in buckets) _label((b['t_start'] as num?)?.toInt() ?? 0),
+    ];
🤖 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/ui/screens/detail_cards.dart` around lines 1659 - 1702, Update the oxygen
trend chart logic around `buckets`, `values`, and `labels` to preserve every
bucket, including nights where `has != true`, instead of filtering through
`present`. Build nullable values from the full `buckets` list, use the
corresponding full labels, and pass that nullable list directly to `LabeledBars`
so missing nights render as gaps while retaining the existing empty-state
behavior when no bucket has data.
🧹 Nitpick comments (25)
lib/coach/coach_db.dart (3)

328-383: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

_clauseKeywords is incomplete; unlisted keywords get swallowed as an alias.

when, then, else, end, is, in, not, like, between, exists, asc, desc, all, distinct, lateral are absent, so the bare-alias branch at Line 368 consumes them. This only produces false rejections of otherwise valid coach queries (fail-closed), but it will confuse the model. Worth extending the set.

🤖 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/coach/coach_db.dart` around lines 328 - 383, The bare-alias check in
_tableRefs must not consume SQL keywords as aliases. Extend _clauseKeywords with
when, then, else, end, is, in, not, like, between, exists, asc, desc, all,
distinct, and lateral, preserving existing alias handling for actual
identifiers.

465-496: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

'Query failed: $e' forwards raw SQLite exception text into the LLM prompt.

The exception message can embed statement text and, for constraint/type errors, column values. It is returned to the model and shipped to the third-party endpoint. Consider a fixed message and logging the detail locally.

🤖 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/coach/coach_db.dart` around lines 465 - 496, Update the catch-all error
handling in runCoachSql to stop returning the raw exception text in the JSON
response. Log the detailed exception locally using the existing logging
mechanism, then return a fixed generic query-failure message to the caller while
preserving the current SqlGuardError handling.

424-435: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Reject non-main cursors in _btreeRoots (lib/coach/coach_db.dart:424-435)

OpenRead/ReopenIdx use P2 for the root page and P3 for the database index (0 = main). Checking only P2 lets a temp/attached cursor alias an allowed main root number, so this gate should reject non-main cursors before adding roots.

🤖 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/coach/coach_db.dart` around lines 424 - 435, The _btreeRoots method must
only collect roots for cursors targeting the main database. For OpenRead,
OpenWrite, and ReopenIdx rows, inspect the EXPLAIN P3 value and skip entries
whose database index is nonzero before evaluating or adding P2 roots; preserve
the existing positive-root filtering.
test/coach_sql_structural_test.dart (1)

32-56: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Seeding inside a test() makes the rest of the file order- and filter-dependent.

runCoachSql still serves the allowed views asserts row_count == 1, which only holds if this test ran first. Running a single test with --plain-name (or any future reordering) breaks it. Move the inserts into setUpAll and keep only the assertion here.

🤖 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/coach_sql_structural_test.dart` around lines 32 - 56, Move the
metric_series, sessions, and route-point setup from the test named “seeds a
session + a route + derived scalars” into the file’s setUpAll callback, ensuring
it runs before the coach SQL assertions regardless of test order. Leave this
test focused only on verifying the workout_route count, and preserve the
existing seed values and assertion.
lib/coach/coach_engine.dart (1)

219-237: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

_trimHistory can silently drop the turn the user just asked about.

Once everything older is gone, the loop keeps removing until _history.length == 1, so a session that is already at the ceiling can discard the current user message before postChat ever sees it, and the model answers with no question. Since postChat already fails closed on oversize, consider stopping the trim at the newest user turn and letting the request ceiling surface the actionable error.

🤖 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/coach/coach_engine.dart` around lines 219 - 237, Update _trimHistory so
trimming never removes the newest user turn: stop dropping history once the
oldest remaining entry is the current user message, even if the history still
exceeds kMaxHistoryChars. Let postChat’s existing oversize-request handling
surface the error instead of sending a request without the user’s question.
lib/notify/notification_center.dart (1)

138-151: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Consider prefs.reload() before reading the day guard.

NotificationIds._slotFor reloads for exactly this reason: derivation also runs in the WorkManager isolate, whose SharedPreferences cache won't see a guard written by the other isolate. Worst case here is a redundant emit that FiredKeyStore.claim absorbs, so this is cheap insurance rather than a live defect.

♻️ Proposed refactor
     SharedPreferences? prefs;
     try {
       prefs = await SharedPreferences.getInstance();
+      await prefs.reload(); // the OTHER isolate may have consumed the guard
     } catch (_) {
       prefs = null; // no store — [emit]'s own FiredKeyStore still dedupes
     }
🤖 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/notify/notification_center.dart` around lines 138 - 151, Update the
SharedPreferences guard flow around the local prefs variable to call
prefs.reload() before reading prefsKey, preserving the existing failure handling
and emit/FiredKeyStore behavior if reload fails.
lib/state/app_state.dart (1)

2322-2338: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: hoist 'alarm_epoch' into a named constant.

The literal now appears in _init, setAlarm, disableAlarm and here; every other persisted key in this class already uses a static const String _kX.

🤖 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/state/app_state.dart` around lines 2322 - 2338, Define a static const
String for the persisted alarm epoch key alongside the other key constants in
the app-state class, then replace the repeated 'alarm_epoch' literals in _init,
setAlarm, disableAlarm, and _clearArmedAlarmState with that constant.
test/ble_safe_trim_test.dart (1)

92-107: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

d2 doesn't prove d's records survived — re-commit on d itself.

The comment claims this proves the re-buffered records are still there, but d2 is a fresh controller that never had a failing commit; it only shows the happy path. Committing again on d would actually assert the restored buffer's contents.

♻️ Suggested: assert against the controller that failed
-      // Proof they are really still there: a later successful commit ships
-      // every record AND the archived one.
-      final seenRaws = <String>[];
-      final seenArchives = <String>[];
-      final d2 = _drainWith((raws, samples, token, {archives}) async {
-        seenRaws.addAll(raws.map((r) => r.hex));
-        seenArchives.addAll((archives ?? const []).map((a) => a.hex));
-      });
-      // (rebuild the same state on a controller whose commit succeeds)
-      d2.onHistoricalRecord(_raw(1), _sample(1));
-      d2.onHistoricalRecord(_raw(2), _sample(2));
-      d2.onUndecodableRecord(_archive(3));
-      expect(await d2.commit(_tokenA), isTrue);
-      expect(seenRaws, [_hex(1), _hex(2)]);
-      expect(seenArchives, [_hex(3)]);
+      // Proof they are really still there: the NEXT commit on the SAME
+      // controller ships every re-buffered record AND the archived one.
+      final seenRaws = <String>[];
+      final seenArchives = <String>[];
+      shouldFail = false;
+      onCommitSpy = (raws, samples, token, {archives}) async {
+        seenRaws.addAll(raws.map((r) => r.hex));
+        seenArchives.addAll((archives ?? const []).map((a) => a.hex));
+      };
+      expect(await d.commit(_tokenA), isTrue);
+      expect(seenRaws, [_hex(1), _hex(2)]);
+      expect(seenArchives, [_hex(3)]);

(build d with a mutable shouldFail flag + swappable sink, as the ordering test at Line 109 already does)

🤖 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/ble_safe_trim_test.dart` around lines 92 - 107, Update the
failing-commit test to verify the restored records on the original controller d
rather than a fresh d2. Use a mutable shouldFail flag and swappable sink as in
the nearby ordering test, make d’s first commit fail, then switch the sink to
capture output and commit d again, asserting that both buffered raw records and
the archived record are emitted.
lib/ble/ble_engine.dart (1)

1973-1980: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Rejecting the read also drops the bounded SET_CLOCK correction for exactly the band that needs it.

Before this gate, a far-future clock_epoch fell into shouldSetClock (drift > 86400) and got up to 3 bounded SET_CLOCK re-issues. Now the reject branch only counts and logs, so a band whose RTC really is stuck far-future never gets a corrective re-issue for the rest of the session — the periodic re-verify just re-reads and lands here again. Not correlating is right; not correcting looks unintended.

♻️ Suggested: keep the bounded correction on the reject path
       if (!ClockPolicy.acceptsClockRead(dev, wall)) {
         _corruptClockReadCount++;
         _log(
           '[SYNC] GET_CLOCK clock_epoch=$dev is implausibly far in the future '
           '— treating as a corrupt strap RTC read; NOT correlating the strap '
           'clock (alarms fall back to the raw wall epoch) '
           '(corrupt_clock_reads_total=$_corruptClockReadCount).',
         );
+        // The read is untrustworthy for CORRELATION, but a far-future RTC is
+        // still an RTC that needs setting. Re-issue under the same bounded
+        // budget so a genuinely-wrong clock can still be corrected.
+        if (_clockCorrectTries < 3) {
+          _clockCorrectTries++;
+          _log('Corrupt RTC read — re-issuing SET_CLOCK '
+              '(attempt $_clockCorrectTries/3).');
+          unawaited(setClock());
+        }
       } else {
🤖 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 1973 - 1980, Update the
ClockPolicy.acceptsClockRead rejection branch in the clock-read handling flow to
retain the existing bounded SET_CLOCK correction behavior for implausibly future
RTC values, while still excluding the read from strap-clock correlation. Reuse
the existing shouldSetClock correction and retry-limit mechanisms so corrections
remain capped at three re-issues and subsequent periodic verification preserves
the current logging and fallback behavior.
test/ble_transport_guards_test.dart (1)

38-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This test can pass vacuously — assert the precondition.

The only assertion is that no log line contains yielding, which is also satisfied if the drainer never reached _claimBand at all (the test depends on how flutter_blue_plus fails on the host). Pinning the state the scenario is actually about makes it non-vacuous.

🧪 Suggested: assert the stale-claim precondition
         await foreground.connectToRemoteId('AA:BB:CC:DD:EE:FF');
+        // Precondition: the foreground connect failed and left no live link —
+        // this is the "stale claim" the drainer must be able to take.
+        expect(foreground.holdsBandLink, isFalse);
 
         final drainerLogs = <String>[];
@@
         expect(
           drainerLogs.where((l) => l.contains('yielding')),
           isEmpty,
           reason: 'the drainer must actually attempt the band',
         );
+        expect(drainer.holdsBandLink, isFalse);
+        expect(BleEngine.bandClaimed, isFalse,
+            reason: 'the drainer\'s own failed connect must release too');
🤖 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/ble_transport_guards_test.dart` around lines 38 - 66, Make the test
non-vacuous by asserting, after the foreground connection attempt, that the
expected stale foreground claim/precondition exists before creating the
background drainer. Use the relevant observable claim state or established test
hook used by BleEngine, then retain the existing yielding-log assertion to
verify the drainer proceeds.
lib/compute/crossday_pipeline.dart (1)

393-419: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Normalize the map key through _dateKey so lookup can't silently miss.

byDate is keyed by the raw dates[i] string, but lookup at Line 416 uses _dateKey(cursor) (YYYY-MM-DD). Any accepted-but-differently-formatted date (2026-03-08T00:00:00Z, or a +00:00 suffix) parses fine, gets stored under the raw string, and then never matches — the whole series degrades to zeros with no error, which is exactly the CTL/ATL/TSB corruption this change is fixing.

♻️ Key both sides through the same formatter
   for (var i = 0; i < dates.length && i < trimps.length; i++) {
     final date = dates[i];
-    if (date.isEmpty || DateTime.tryParse(date) == null) continue;
-    byDate[date] = trimps[i] ?? 0.0;
-    first ??= date;
-    if (last == null || date.compareTo(last) > 0) last = date;
-    if (date.compareTo(first) < 0) first = date;
+    final parsed = date.isEmpty ? null : DateTime.tryParse(date);
+    if (parsed == null) continue;
+    final key = _dateKey(parsed);
+    byDate[key] = trimps[i] ?? 0.0;
+    first ??= key;
+    if (last == null || key.compareTo(last) > 0) last = key;
+    if (key.compareTo(first) < 0) first = key;
   }
🤖 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/crossday_pipeline.dart` around lines 393 - 419, Normalize each
valid date through _dateKey before storing it in byDate within _denseDailyTrimp,
so its keys match the _dateKey(cursor) lookups. Preserve the existing parsing,
duplicate handling, and densification behavior while ensuring ISO timestamps and
timezone-suffixed dates map to their calendar day.
test/day_window_dst_test.dart (2)

80-96: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

setUpAll/tearDownAll call POSIX-only FFI unguarded while the skip lives on each test.

Every test carries skip: Platform.isWindows, but the fixture itself doesn't: _setProcessTz looks up setenv/tzset via DynamicLibrary.process(), which won't resolve on Windows. Today this is only safe because the framework skips setUpAll when no test in the group runs — add one non-skipped test to this file and Windows breaks. Cheap to make explicit.

♻️ Guard the fixture too
   setUpAll(() async {
+    if (Platform.isWindows) return;
     _setProcessTz('America/New_York');
   tearDownAll(() async {
     await LocalDb.close();
     await databaseFactory.deleteDatabase(
       p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName),
     );
-    _setProcessTz(originalTz);
+    if (!Platform.isWindows) _setProcessTz(originalTz);
   });
🤖 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/day_window_dst_test.dart` around lines 80 - 96, Guard the entire DST
fixture setup and teardown in setUpAll and tearDownAll with the same Windows
check used by the tests, so _setProcessTz and database initialization never run
on Windows. Preserve the existing setup and cleanup behavior on supported
platforms.

36-47: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use malloc.free for both UTF-8 buffers toNativeUtf8() defaults to malloc, so freeing key and value with malloc.free(...) keeps the allocation/free pair explicit.

🤖 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/day_window_dst_test.dart` around lines 36 - 47, Update the environment
setup block around the `key` and `value` buffers to free both `toNativeUtf8()`
allocations with `malloc.free(...)` instead of `calloc.free(...)`, including the
`finally` cleanup for `key`.
test/db_p0_fixes_test.dart (1)

224-232: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Tests share one database and wipe tables mid-file, so they're order-coupled.

fix 11, fix 10, fix 8 and fix 1 each start by deleting whole tables (events, day_result, decoded_onehz, decoded_rr). The earlier fix 3/fix 4 cases only pass because they're declared first. Moving the cleanup into a setUp (or scoping counters/day ids per test) makes the file order-independent.

Also applies to: 319-325, 418-425

🤖 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 224 - 232, Make the database tests
order-independent by moving full-table cleanup for events, day_result,
decoded_onehz, and decoded_rr into shared setUp logic, or otherwise isolating
each test’s data with unique counters/day IDs. Update the tests identified by
fix 11, fix 10, fix 8, and fix 1 while preserving their existing assertions and
setup behavior.
test/telemetry_consent_default_test.dart (1)

30-36: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Both config assertions are attribute/element-order sensitive.

plistValueAfter takes the first tag after the key (so a <string>false</string> form reads back as 'string'), and the manifest regex requires android:name to precede android:value with no intervening attributes. Either is a legitimate way to express the same config, and both would fail the guard rather than the thing it's guarding. Loosening the manifest match to allow either attribute order would make this less likely to break on an unrelated manifest edit.

Also applies to: 62-70

🤖 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/telemetry_consent_default_test.dart` around lines 30 - 36, Update
plistValueAfter to parse both self-closing values and paired element content,
returning the actual value rather than the tag name. Relax the manifest
assertion regex so android:name and android:value may appear in either order
with intervening attributes, while preserving validation of the expected
configuration.
test/derive_result_protection_test.dart (2)

286-287: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Test name contradicts itself.

"does not overwrite ... with a worse one — but is allowed to replace it" reads as two opposite claims; the body only asserts that a skip marker replaces another skip marker. Something like 'a skip marker may replace another skip marker (no user data to lose)' matches what is verified.

🤖 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/derive_result_protection_test.dart` around lines 286 - 287, Rename the
test case describing skip-marker replacement to remove the contradictory “does
not overwrite” versus “is allowed to replace” wording. Use a name that clearly
states a skip marker may replace another skip marker, matching the behavior
asserted by the test body.

162-167: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Conditional assertion can pass vacuously.

If runDays writes nothing, row is null and the test asserts nothing. With the new producedNothing path a fresh day does get an unfinalized row, so this can be tightened to expect(row, isNotNull) followed by the finalized == 0 check — otherwise a future regression that stops writing the row entirely would go unnoticed here.

🤖 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/derive_result_protection_test.dart` around lines 162 - 167, Update the
assertion around LocalDb.dayResult(freshDay) so the test first requires row to
be non-null, then asserts row['finalized'] equals 0 without a conditional guard.
Preserve the existing reason text and ensure a missing result causes the test to
fail.
test/derive_day_window_test.dart (1)

108-116: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

This assertion restates the implementation rather than testing it.

tzOffsetSecondsAt(t) is literally DateTime.fromMillisecondsSinceEpoch(t * 1000, isUtc: false).timeZoneOffset.inSeconds, so this loop can never fail regardless of correctness. The jan/jul divergence check below (lines 119-124) is the part that carries signal. Consider dropping the tautology, or replacing it with a fixed-zone expectation guarded on DateTime.now().timeZoneName so it asserts something the implementation could get wrong.

🤖 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/derive_day_window_test.dart` around lines 108 - 116, Remove the
tautological loop comparing tzOffsetSecondsAt(t) with the identical DateTime
expression; retain the jan/jul divergence assertion below as the meaningful
coverage for seasonal offset behavior.
lib/compute/derivation_engine.dart (3)

1892-1895: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Dangling dartdoc reference to the removed appendScalars.

appendScalars is gone, but _refreshBaselines' doc comment (around line 2376) still says the artifact is rebuilt "rather than persisting the in-memory cache the sweep mutated via [_BaselineHistoryCache.appendScalars]". That square-bracket reference no longer resolves and the paragraph now describes behavior that cannot happen. Worth updating in the same change so the comment doesn't outlive the bug it describes.

🤖 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 1892 - 1895, Update the
_refreshBaselines documentation to remove the obsolete
[_BaselineHistoryCache.appendScalars] reference and the outdated claim that the
sweep mutates the in-memory cache. Keep the documentation aligned with the
current frozen-snapshot and rebuilt-artifact behavior described near the sweep
logic.

3745-3761: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate day-window math — reuse data/day_label.dart.

This PR adds localDayStartSec / localDayEndSec in lib/data/day_label.dart with the same DST-correct DateTime(y, m, d + 1) logic, plus strict label parsing. _localDayLabelToSec / _localNextDayLabelToSec here duplicate it and degrade a malformed label to 0 instead of null, so the two implementations can drift. Delegating keeps one source for the local-day window concern.

As per coding guidelines, "Maintain one source per concern" and "Use todayLabel() or dayLabelOf() from data/day_label.dart for local day labels".

Also applies to: 3786-3790

🤖 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 3745 - 3761, Remove the
duplicate _localDayLabelToSec and _localNextDayLabelToSec implementations from
the derivation engine and reuse localDayStartSec and localDayEndSec from
data/day_label.dart at their call sites. Preserve strict malformed-label
handling by propagating the shared helpers’ null result rather than converting
invalid labels to 0, and use the existing day-label utilities as the single
source for local-day windows.

Source: Coding guidelines


1111-1126: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

port leaks if Isolate.spawn throws.

ReceivePort is created before the await Isolate.spawn(...); if spawn fails (e.g. OOM), the try/finally below hasn't been entered yet, so the port is never closed and keeps the isolate's event loop referenced. Same shape applies in _runIsolateCancellable (line 3330) and _runDayBlocksCancellable.

♻️ Guard the spawn
-    final isolate = await Isolate.spawn(
-      derivationPrepareWorker,
-      port.sendPort,
-      onError: port.sendPort,
-      onExit: port.sendPort,
-    );
+    final Isolate isolate;
+    try {
+      isolate = await Isolate.spawn(
+        derivationPrepareWorker,
+        port.sendPort,
+        onError: port.sendPort,
+        onExit: port.sendPort,
+      );
+    } catch (_) {
+      port.close();
+      rethrow;
+    }
🤖 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 1111 - 1126, Guard
ReceivePort cleanup around Isolate.spawn in the affected derivation worker
methods, including the flow containing derivationPrepareWorker,
_runIsolateCancellable, and _runDayBlocksCancellable. Ensure each port is closed
if spawning throws before the existing cleanup scope is entered, while
preserving normal post-spawn handling and avoiding double-close issues.
lib/import/whoop_import.dart (1)

123-144: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Two divergent "is this a real day result?" predicates.

DerivationEngine._isRealDayResult (lib/compute/derivation_engine.dart around line 2024) guards the exact same destructive putDayResult replace, but uses different rules: it requires at least one non-null scalar and ignores the imported marker, while this one accepts any non-skipped decodable payload and treats imported: true as replaceable. The differing behavior looks intentional (the importer may replace a vendor snapshot; the engine may not blank one), but nothing in either place records that, so a future edit to one will silently desynchronize the protection.

Worth extracting a single shared helper with an explicit allowVendorSnapshots flag, or at minimum cross-referencing the two doc comments.

As per coding guidelines, "Maintain one source per concern".

🤖 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/import/whoop_import.dart` around lines 123 - 144, Unify the real-day
protection logic used by _isRealDerivedDay and DerivationEngine._isRealDayResult
into one shared helper with an explicit allowVendorSnapshots option, preserving
each caller’s current behavior: the importer may replace imported vendor
snapshots, while the derivation engine must retain them and require a non-null
scalar. Update both doc comments to document this distinction and prevent future
predicate drift.

Source: Coding guidelines

test/crossday_pipeline_test.dart (1)

192-211: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Nit: + i * 86400 shifts local clock time across a DST boundary.

onset/wake are built from local DateTimes, then advanced by fixed 86 400 s steps over 2024-03-04 → 03-17 — which spans the US spring-forward. In a DST-observing zone the later nights land at 00:30/01:30 local instead of 23:30/00:30, so some of them stop crossing midnight and the test's premise ("the only segment of each night crosses midnight") weakens. Building each night with DateTime(2024, 3, 4 + i, 23, 30) keeps the wall-clock intent in every zone.

🤖 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/crossday_pipeline_test.dart` around lines 192 - 211, Update the nights
helper to construct each night's onset and wake timestamps from local DateTime
values using the calendar day 4 + i, rather than adding fixed i * 86400 seconds
to the initial epochs. Apply the same per-night timestamps to onset_sec,
wake_sec, and hypnogram start/end so every entry preserves the intended
23:30-to-00:30 wall-clock crossing across DST.
lib/compute/derive_prepare.dart (2)

188-197: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse localDayEndSec from data/day_label.dart instead of a second next-midnight implementation.

lib/data/db.dart (_localDayWindow) and lib/data/local_repository_impl.dart (_localDayEndSec) both resolve "next local midnight" through localDayEndSec(dayId). This adds a third implementation with slightly different input handling (DateTime.tryParse accepts full ISO strings, not just YYYY-MM-DD), so the day-boundary semantics can drift between the derive path and the storage/UI paths.

As per coding guidelines, "Maintain one source per concern" and "Use todayLabel() or dayLabelOf() from data/day_label.dart for local day labels".

♻️ Delegate to the shared day-window helper
-int localNextMidnightSecForDayLabel(String dayId) {
-  final d = DateTime.tryParse(dayId);
-  if (d == null) return 0;
-  return DateTime(d.year, d.month, d.day + 1).millisecondsSinceEpoch ~/ 1000;
-}
+int localNextMidnightSecForDayLabel(String dayId) =>
+    localDayEndSec(dayId) ?? 0;
🤖 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/derive_prepare.dart` around lines 188 - 197, Remove the duplicate
localNextMidnightSecForDayLabel implementation and reuse localDayEndSec from
data/day_label.dart in the derive path. Ensure callers use the shared day-label
semantics, converting inputs through todayLabel() or dayLabelOf() where needed
rather than accepting arbitrary ISO strings.

Source: Coding guidelines


365-370: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

_num rejects the exact case the docstring cites.

The comment says an older/importing path "can hand back a String … where an INTEGER is declared", but v is num ? v : null maps '72' to null — so rec_ts rows get skipped and hr/accel silently become 0 rather than being recovered. If string storage-class drift is real, parse it.

♻️ Accept numeric strings too
-  static num? _num(Object? v) => v is num ? v : null;
+  static num? _num(Object? v) =>
+      v is num ? v : (v is String ? num.tryParse(v) : null);
🤖 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/derive_prepare.dart` around lines 365 - 370, Update the _num
helper to preserve numeric values and also parse valid numeric strings,
including integer and decimal representations; return null only for null or
non-numeric inputs so rec_ts, hr, and acceleration values are recovered from
SQLite storage-class drift.
🤖 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/coach/coach_db.dart`:
- Around line 390-418: The _allowedRootPages cache must be invalidated when the
SQLite schema changes. Update _allowedRootPages to read PRAGMA schema_version
through rawQuery and associate _allowedRoots with that version, recomputing
roots whenever the version differs; preserve the existing fast path when the
cached version still matches.

In `@lib/coach/coach_engine.dart`:
- Around line 212-213: Update the request-size enforcement around
kMaxRequestBytes to measure the UTF-8 encoded payload length rather than
payload.length, and use that same byte count for the user-facing KB value.
Preserve the existing limit and rejection behavior, and send the encoded bytes
via the request body or otherwise ensure the transmitted body uses the measured
UTF-8 representation.

In `@lib/data/day_label.dart`:
- Around line 26-37: Update _partsOf to validate that the parsed year, month,
and day form the exact requested calendar date, rejecting out-of-range values
such as invalid months or days instead of allowing DateTime normalization.
Preserve the existing null result for malformed labels and return the parsed
components only when they represent a valid date.

In `@lib/data/db.dart`:
- Around line 88-93: Update the app-database open call in _open() to pass the
shared schemaVersion constant instead of the literal 26, ensuring all database
opens in the class use the single schema-version source.
- Around line 2591-2605: Update the endpoint-resolution condition in the
query-selection logic to use the timestamp window whenever at least one endpoint
resolves, changing the requirement from n == want to n >= 1. Preserve the
existing bounds-based rawQuery and leave the normalized counter-range fallback
only for cases where no endpoint resolves.
- Around line 1417-1476: Update _migrateLegacyTable to accept and operate on the
ambient DatabaseExecutor when invoked from onUpgrade, avoiding a nested
db.transaction; retain an explicit transaction only for the onOpen repair path.
Preserve the existing migration, copy, skipCopy, and legacy-table cleanup
behavior while routing both paths through the shared logic.

In `@lib/import/noop_import.dart`:
- Line 200: Update AppState.importNoopCsv() and the import screen to propagate
and display the lateRows value from NoopImportResult, rather than returning or
consuming only days. Preserve the existing days and totalRows handling while
ensuring a non-zero late-row count reaches the user-facing UI.

In `@lib/import/whoop_import.dart`:
- Around line 255-258: Update the finalized assignment in the WHOOP import loop
to finalize a date only when it is older than the device’s raw-data retention
boundary, rather than when it is merely absent from the snapshotted rawDays set.
Reuse the existing lastDecodedRecTs() and raw-retention calculation used by
_pruneOldDecoded, ensuring dates still able to receive future raw data remain
unfinalized.

In `@lib/state/app_state.dart`:
- Around line 750-779: Update AppState.dispose() to tear down the owned
_routeTracker before super.dispose(), canceling or disposing it through the
RouteTracker lifecycle API and clearing the field if applicable. Ensure its GPS
subscription and buffered-point flush cannot continue writing after AppState
disposal, while preserving the existing cleanup order for the other owned
resources.

In `@lib/sync/headless_boot.dart`:
- Around line 113-124: Add an onError handler to the fire-and-forget Future
chain around runBootSyncThroughGate(lease) in the headless boot flow, ensuring
failures are handled rather than escaping as unhandled async errors. Preserve
the existing then callback and its logging behavior, and report the caught error
through the established debugging or logging mechanism.
- Around line 47-62: Update runBootSyncThroughGate to catch failures from
HeadlessSyncGate.tryRun, release the acquired BandLease, and rethrow the
original error. Also update the unawaited(...) chain at
lib/sync/headless_boot.dart lines 113-124 to handle errors with onError or
catchError and log them, preventing unhandled async failures; both locations
require changes.

In `@lib/telemetry/telemetry_service.dart`:
- Around line 46-50: Update the enabled setter in the telemetry service to
handle revocation immediately: when value is false, clear any unsent Crashlytics
reports, disable collection for the remainder of the process, and preserve the
restart-required behavior until the next cold start. Keep the existing enable
path and consent state updates intact.

---

Outside diff comments:
In `@lib/ui/screens/detail_cards.dart`:
- Around line 1659-1702: Update the oxygen trend chart logic around `buckets`,
`values`, and `labels` to preserve every bucket, including nights where `has !=
true`, instead of filtering through `present`. Build nullable values from the
full `buckets` list, use the corresponding full labels, and pass that nullable
list directly to `LabeledBars` so missing nights render as gaps while retaining
the existing empty-state behavior when no bucket has data.

---

Nitpick comments:
In `@lib/ble/ble_engine.dart`:
- Around line 1973-1980: Update the ClockPolicy.acceptsClockRead rejection
branch in the clock-read handling flow to retain the existing bounded SET_CLOCK
correction behavior for implausibly future RTC values, while still excluding the
read from strap-clock correlation. Reuse the existing shouldSetClock correction
and retry-limit mechanisms so corrections remain capped at three re-issues and
subsequent periodic verification preserves the current logging and fallback
behavior.

In `@lib/coach/coach_db.dart`:
- Around line 328-383: The bare-alias check in _tableRefs must not consume SQL
keywords as aliases. Extend _clauseKeywords with when, then, else, end, is, in,
not, like, between, exists, asc, desc, all, distinct, and lateral, preserving
existing alias handling for actual identifiers.
- Around line 465-496: Update the catch-all error handling in runCoachSql to
stop returning the raw exception text in the JSON response. Log the detailed
exception locally using the existing logging mechanism, then return a fixed
generic query-failure message to the caller while preserving the current
SqlGuardError handling.
- Around line 424-435: The _btreeRoots method must only collect roots for
cursors targeting the main database. For OpenRead, OpenWrite, and ReopenIdx
rows, inspect the EXPLAIN P3 value and skip entries whose database index is
nonzero before evaluating or adding P2 roots; preserve the existing
positive-root filtering.

In `@lib/coach/coach_engine.dart`:
- Around line 219-237: Update _trimHistory so trimming never removes the newest
user turn: stop dropping history once the oldest remaining entry is the current
user message, even if the history still exceeds kMaxHistoryChars. Let postChat’s
existing oversize-request handling surface the error instead of sending a
request without the user’s question.

In `@lib/compute/crossday_pipeline.dart`:
- Around line 393-419: Normalize each valid date through _dateKey before storing
it in byDate within _denseDailyTrimp, so its keys match the _dateKey(cursor)
lookups. Preserve the existing parsing, duplicate handling, and densification
behavior while ensuring ISO timestamps and timezone-suffixed dates map to their
calendar day.

In `@lib/compute/derivation_engine.dart`:
- Around line 1892-1895: Update the _refreshBaselines documentation to remove
the obsolete [_BaselineHistoryCache.appendScalars] reference and the outdated
claim that the sweep mutates the in-memory cache. Keep the documentation aligned
with the current frozen-snapshot and rebuilt-artifact behavior described near
the sweep logic.
- Around line 3745-3761: Remove the duplicate _localDayLabelToSec and
_localNextDayLabelToSec implementations from the derivation engine and reuse
localDayStartSec and localDayEndSec from data/day_label.dart at their call
sites. Preserve strict malformed-label handling by propagating the shared
helpers’ null result rather than converting invalid labels to 0, and use the
existing day-label utilities as the single source for local-day windows.
- Around line 1111-1126: Guard ReceivePort cleanup around Isolate.spawn in the
affected derivation worker methods, including the flow containing
derivationPrepareWorker, _runIsolateCancellable, and _runDayBlocksCancellable.
Ensure each port is closed if spawning throws before the existing cleanup scope
is entered, while preserving normal post-spawn handling and avoiding
double-close issues.

In `@lib/compute/derive_prepare.dart`:
- Around line 188-197: Remove the duplicate localNextMidnightSecForDayLabel
implementation and reuse localDayEndSec from data/day_label.dart in the derive
path. Ensure callers use the shared day-label semantics, converting inputs
through todayLabel() or dayLabelOf() where needed rather than accepting
arbitrary ISO strings.
- Around line 365-370: Update the _num helper to preserve numeric values and
also parse valid numeric strings, including integer and decimal representations;
return null only for null or non-numeric inputs so rec_ts, hr, and acceleration
values are recovered from SQLite storage-class drift.

In `@lib/import/whoop_import.dart`:
- Around line 123-144: Unify the real-day protection logic used by
_isRealDerivedDay and DerivationEngine._isRealDayResult into one shared helper
with an explicit allowVendorSnapshots option, preserving each caller’s current
behavior: the importer may replace imported vendor snapshots, while the
derivation engine must retain them and require a non-null scalar. Update both
doc comments to document this distinction and prevent future predicate drift.

In `@lib/notify/notification_center.dart`:
- Around line 138-151: Update the SharedPreferences guard flow around the local
prefs variable to call prefs.reload() before reading prefsKey, preserving the
existing failure handling and emit/FiredKeyStore behavior if reload fails.

In `@lib/state/app_state.dart`:
- Around line 2322-2338: Define a static const String for the persisted alarm
epoch key alongside the other key constants in the app-state class, then replace
the repeated 'alarm_epoch' literals in _init, setAlarm, disableAlarm, and
_clearArmedAlarmState with that constant.

In `@test/ble_safe_trim_test.dart`:
- Around line 92-107: Update the failing-commit test to verify the restored
records on the original controller d rather than a fresh d2. Use a mutable
shouldFail flag and swappable sink as in the nearby ordering test, make d’s
first commit fail, then switch the sink to capture output and commit d again,
asserting that both buffered raw records and the archived record are emitted.

In `@test/ble_transport_guards_test.dart`:
- Around line 38-66: Make the test non-vacuous by asserting, after the
foreground connection attempt, that the expected stale foreground
claim/precondition exists before creating the background drainer. Use the
relevant observable claim state or established test hook used by BleEngine, then
retain the existing yielding-log assertion to verify the drainer proceeds.

In `@test/coach_sql_structural_test.dart`:
- Around line 32-56: Move the metric_series, sessions, and route-point setup
from the test named “seeds a session + a route + derived scalars” into the
file’s setUpAll callback, ensuring it runs before the coach SQL assertions
regardless of test order. Leave this test focused only on verifying the
workout_route count, and preserve the existing seed values and assertion.

In `@test/crossday_pipeline_test.dart`:
- Around line 192-211: Update the nights helper to construct each night's onset
and wake timestamps from local DateTime values using the calendar day 4 + i,
rather than adding fixed i * 86400 seconds to the initial epochs. Apply the same
per-night timestamps to onset_sec, wake_sec, and hypnogram start/end so every
entry preserves the intended 23:30-to-00:30 wall-clock crossing across DST.

In `@test/day_window_dst_test.dart`:
- Around line 80-96: Guard the entire DST fixture setup and teardown in setUpAll
and tearDownAll with the same Windows check used by the tests, so _setProcessTz
and database initialization never run on Windows. Preserve the existing setup
and cleanup behavior on supported platforms.
- Around line 36-47: Update the environment setup block around the `key` and
`value` buffers to free both `toNativeUtf8()` allocations with
`malloc.free(...)` instead of `calloc.free(...)`, including the `finally`
cleanup for `key`.

In `@test/db_p0_fixes_test.dart`:
- Around line 224-232: Make the database tests order-independent by moving
full-table cleanup for events, day_result, decoded_onehz, and decoded_rr into
shared setUp logic, or otherwise isolating each test’s data with unique
counters/day IDs. Update the tests identified by fix 11, fix 10, fix 8, and fix
1 while preserving their existing assertions and setup behavior.

In `@test/derive_day_window_test.dart`:
- Around line 108-116: Remove the tautological loop comparing
tzOffsetSecondsAt(t) with the identical DateTime expression; retain the jan/jul
divergence assertion below as the meaningful coverage for seasonal offset
behavior.

In `@test/derive_result_protection_test.dart`:
- Around line 286-287: Rename the test case describing skip-marker replacement
to remove the contradictory “does not overwrite” versus “is allowed to replace”
wording. Use a name that clearly states a skip marker may replace another skip
marker, matching the behavior asserted by the test body.
- Around line 162-167: Update the assertion around LocalDb.dayResult(freshDay)
so the test first requires row to be non-null, then asserts row['finalized']
equals 0 without a conditional guard. Preserve the existing reason text and
ensure a missing result causes the test to fail.

In `@test/telemetry_consent_default_test.dart`:
- Around line 30-36: Update plistValueAfter to parse both self-closing values
and paired element content, returning the actual value rather than the tag name.
Relax the manifest assertion regex so android:name and android:value may appear
in either order with intervening attributes, while preserving validation of the
expected configuration.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1b5d7715-986d-42cf-a958-4a605c206bcb

📥 Commits

Reviewing files that changed from the base of the PR and between d7682e4 and 533e3b4.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (77)
  • android/app/src/main/AndroidManifest.xml
  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OpenStrapWidgetProvider.kt
  • ios/OpenStrapWatch Watch App/WatchMetrics.swift
  • ios/OpenStrapWidget/OpenStrapWidget.swift
  • ios/Runner/Info.plist
  • lib/app.dart
  • lib/ble/ble_engine.dart
  • lib/ble/ble_state.dart
  • lib/cloud/cloud_import.dart
  • lib/coach/coach_db.dart
  • lib/coach/coach_engine.dart
  • lib/coach/coach_prompt.dart
  • lib/compute/crossday_pipeline.dart
  • lib/compute/derivation_engine.dart
  • lib/compute/derive_prepare.dart
  • lib/compute/onehz_pipeline.dart
  • lib/compute/substrate.dart
  • lib/data/day_label.dart
  • lib/data/db.dart
  • lib/data/local_repository_impl.dart
  • lib/gps/route_math.dart
  • lib/gps/route_tracker.dart
  • lib/import/noop_import.dart
  • lib/import/whoop_import.dart
  • lib/main.dart
  • lib/notify/notification_center.dart
  • lib/notify/notification_event.dart
  • lib/notify/notification_ids.dart
  • lib/notify/notification_service.dart
  • lib/state/app_state.dart
  • lib/sync/headless_boot.dart
  • lib/sync/sync_policy.dart
  • lib/telemetry/telemetry_service.dart
  • lib/ui/ai/ai_breakdown_screen.dart
  • lib/ui/ai/ai_settings_screen.dart
  • lib/ui/coach/ai_coach_screen.dart
  • lib/ui/coach/coach_chart.dart
  • lib/ui/design/recap_card.dart
  • lib/ui/kit/charts.dart
  • lib/ui/kit/kit.dart
  • lib/ui/screens/detail_cards.dart
  • lib/ui/screens/metric_screen.dart
  • lib/ui/screens/screens.dart
  • lib/ui/screens/trend_screen.dart
  • lib/ui/widgets/async_guards.dart
  • lib/widget/widget_service.dart
  • pubspec.yaml
  • test/absent_not_zero_test.dart
  • test/ai_screen_async_guards_test.dart
  • test/app_state_regressions_test.dart
  • test/ble_engine_test.dart
  • test/ble_safe_trim_test.dart
  • test/ble_transport_guards_test.dart
  • test/coach_provider_response_test.dart
  • test/coach_sql_guard_adversarial_test.dart
  • test/coach_sql_structural_test.dart
  • test/crossday_pipeline_test.dart
  • test/day_window_dst_test.dart
  • test/db_migration_ladder_test.dart
  • test/db_p0_fixes_test.dart
  • test/derive_day_window_test.dart
  • test/derive_isolate_lifecycle_test.dart
  • test/derive_result_protection_test.dart
  • test/headless_gate_test.dart
  • test/import_data_safety_test.dart
  • test/local_repository_p0_test.dart
  • test/metric_trend_redesign_test.dart
  • test/notification_center_test.dart
  • test/notification_day_guard_test.dart
  • test/notification_ids_test.dart
  • test/notification_permission_test.dart
  • test/notification_schedule_dst_test.dart
  • test/readiness_baseline_pollution_test.dart
  • test/route_math_test.dart
  • test/route_tracker_test.dart
  • test/telemetry_consent_default_test.dart
  • test/widget_service_sentinels_test.dart
💤 Files with no reviewable changes (1)
  • test/ble_engine_test.dart

Comment thread lib/coach/coach_db.dart
Comment on lines +390 to +418
static Future<Set<int>> _allowedRootPages(Database db) async {
final cached = _allowedRoots;
if (cached != null) return cached;
final roots = <int>{};
for (final v in allowedViews) {
try {
roots.addAll(await _btreeRoots(db, 'SELECT * FROM $v'));
} catch (_) {/* a view that can't compile contributes nothing */}
}
// Indexes on an allowed base table are allowed too (the planner may pick
// one for a WHERE the plain scan above never used).
final schema = await db.rawQuery(
'SELECT type, name, tbl_name, rootpage FROM sqlite_master');
final tableOfRoot = <int, String>{};
for (final r in schema) {
final rp = (r['rootpage'] as num?)?.toInt();
final tbl = r['tbl_name']?.toString();
if (rp != null && rp > 0 && tbl != null) tableOfRoot[rp] = tbl;
}
final allowedTables = <String>{
for (final rp in roots)
if (tableOfRoot[rp] != null) tableOfRoot[rp]!,
};
for (final e in tableOfRoot.entries) {
if (allowedTables.contains(e.value)) roots.add(e.key);
}
_allowedRoots = roots;
return roots;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Root-page cache is not invalidated when the schema changes.

_allowedRoots is computed once per process and only cleared in close(). A VACUUM, migration, or index create/drop on the RW handle (LocalDb) renumbers root pages, after which the cached set no longer describes the allowed views — stale entries could allow a btree that now belongs to a different table, and legitimate view queries would be rejected. Consider keying the cache on PRAGMA schema_version (read through rawQuery) or clearing it whenever LocalDb runs a migration.

🤖 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/coach/coach_db.dart` around lines 390 - 418, The _allowedRootPages cache
must be invalidated when the SQLite schema changes. Update _allowedRootPages to
read PRAGMA schema_version through rawQuery and associate _allowedRoots with
that version, recomputing roots whenever the version differs; preserve the
existing fast path when the cached version still matches.

Comment on lines +212 to +213
/// Hard ceiling on one serialized provider request body.
static const int kMaxRequestBytes = 400 * 1024;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

kMaxRequestBytes is compared against a UTF-16 code-unit count, not bytes.

payload.length counts Dart string code units; the request is sent as UTF-8. Non-ASCII content (journal notes, emoji, non-Latin text) can be up to 3× larger on the wire, so the "hard ceiling on bytes leaving the device" can be exceeded. The user-facing message also reports payload.length ~/ 1024 as "KB".

🔒️ Measure the encoded payload
-    final payload = jsonEncode(body);
-    if (payload.length > kMaxRequestBytes) {
+    final payload = jsonEncode(body);
+    final payloadBytes = utf8.encode(payload).length;
+    if (payloadBytes > kMaxRequestBytes) {
       throw CoachException(
-          'That request grew to ${payload.length ~/ 1024} KB, over the '
+          'That request grew to ${payloadBytes ~/ 1024} KB, over the '
           '${kMaxRequestBytes ~/ 1024} KB safety limit for data leaving this '
           'device. Start a new chat or ask a narrower question (aggregate with '
           'AVG/MIN/MAX/COUNT instead of selecting every row).');
     }

Then send body: utf8.encode(payload) (or keep payload; http encodes as UTF-8 by default) — the check is the part that must match the wire size.

Also applies to: 590-597

🤖 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/coach/coach_engine.dart` around lines 212 - 213, Update the request-size
enforcement around kMaxRequestBytes to measure the UTF-8 encoded payload length
rather than payload.length, and use that same byte count for the user-facing KB
value. Preserve the existing limit and rejection behavior, and send the encoded
bytes via the request body or otherwise ensure the transmitted body uses the
measured UTF-8 representation.

Comment thread lib/data/day_label.dart
Comment on lines +26 to +37
/// Parse a 'YYYY-MM-DD' label into its (year, month, day) parts, or null if it
/// isn't one. Deliberately strict — a malformed label must not silently become
/// epoch 0 and delete/export the wrong window.
List<int>? _partsOf(String dayId) {
final p = dayId.split('-');
if (p.length != 3) return null;
final y = int.tryParse(p[0]);
final m = int.tryParse(p[1]);
final d = int.tryParse(p[2]);
if (y == null || m == null || d == null) return null;
return [y, m, d];
}

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

_partsOf accepts out-of-range components, so "strict" isn't strict.

Shape and numeric parsing are checked, but ranges are not: '2026-13-45' or '2026-02-31' pass and DateTime silently normalizes them to a different calendar day. Since localDayStartSec/localDayEndSec bound day-window deletes and exports (see lib/data/db.dart around lines 2916-2917), a normalized label targets the wrong window — the exact failure mode the doc comment says this guards against.

🛡️ Reject out-of-range components
   final y = int.tryParse(p[0]);
   final m = int.tryParse(p[1]);
   final d = int.tryParse(p[2]);
   if (y == null || m == null || d == null) return null;
+  if (m < 1 || m > 12 || d < 1 || d > 31) return null;
+  // Reject a day that does not exist in that month (DateTime would silently
+  // roll it into the next one).
+  if (DateTime(y, m, d).day != d) return null;
   return [y, m, d];
📝 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
/// Parse a 'YYYY-MM-DD' label into its (year, month, day) parts, or null if it
/// isn't one. Deliberately strict — a malformed label must not silently become
/// epoch 0 and delete/export the wrong window.
List<int>? _partsOf(String dayId) {
final p = dayId.split('-');
if (p.length != 3) return null;
final y = int.tryParse(p[0]);
final m = int.tryParse(p[1]);
final d = int.tryParse(p[2]);
if (y == null || m == null || d == null) return null;
return [y, m, d];
}
/// Parse a 'YYYY-MM-DD' label into its (year, month, day) parts, or null if it
/// isn't one. Deliberately strict — a malformed label must not silently become
/// epoch 0 and delete/export the wrong window.
List<int>? _partsOf(String dayId) {
final p = dayId.split('-');
if (p.length != 3) return null;
final y = int.tryParse(p[0]);
final m = int.tryParse(p[1]);
final d = int.tryParse(p[2]);
if (y == null || m == null || d == null) return null;
if (m < 1 || m > 12 || d < 1 || d > 31) return null;
// Reject a day that does not exist in that month (DateTime would silently
// roll it into the next one).
if (DateTime(y, m, d).day != d) return null;
return [y, m, d];
}
🤖 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/day_label.dart` around lines 26 - 37, Update _partsOf to validate
that the parsed year, month, and day form the exact requested calendar date,
rejecting out-of-range values such as invalid months or days instead of allowing
DateTime normalization. Preserve the existing null result for malformed labels
and return the parsed components only when they represent a valid date.

Comment thread lib/data/db.dart
Comment on lines +88 to +93
/// The live schema version — the ONE place it is declared. Every
/// `openDatabase` this class performs (the app DB and the day-export DB) must
/// pass it: sqflite throws `ArgumentError('onCreate must be null if no
/// version is specified')` BEFORE opening anything when `onCreate` is given
/// without `version` (sqflite_common database_mixin.dart).
static const int schemaVersion = 26;

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 | 🟠 Major | ⚡ Quick win

schemaVersion isn't yet the single source — _open() still passes the literal 26.

The doc promises "Every openDatabase this class performs … must pass it", but the app-DB open at Line 397 still hardcodes version: 26. The next bump has to be made in two places, and a missed one silently skips the whole onUpgrade ladder.

♻️ Use the constant at the app-DB open too (Line 397)
-      version: 26,
+      version: schemaVersion,
🤖 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 88 - 93, Update the app-database open call in
_open() to pass the shared schemaVersion constant instead of the literal 26,
ensuring all database opens in the class use the single schema-version source.

Source: Coding guidelines

Comment thread lib/data/db.dart
Comment on lines +1417 to 1476
static Future<void> _migrateLegacyTable(
Database db, {
required String table,
required bool Function(Set<String> columns) isCurrent,
required Future<void> Function(DatabaseExecutor ex) create,
required Future<void> Function(
DatabaseExecutor ex,
List<Map<String, Object?>> legacyRows,
int nowMs,
)
copy,
bool copyOnlyIntoEmpty = false,
}) async {
final legacy = '${table}_legacy';
await db.transaction((txn) async {
Future<bool> exists(String t) async =>
(await txn.rawQuery(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
[t],
)).isNotEmpty;
Future<Set<String>> columnsOf(String t) async {
final info = await txn.rawQuery('PRAGMA table_info($t)');
return {
for (final c in info)
if (c['name'] is String) c['name'] as String,
};
}

await db.execute('ALTER TABLE sync_ledger RENAME TO sync_ledger_legacy');
await _createSyncState(db);
final legacyRows = await db.query('sync_ledger_legacy');
final now = DateTime.now().millisecondsSinceEpoch;
for (final row in legacyRows) {
final meta = <String, dynamic>{
'last_batch_token': row['last_batch_token'],
'last_batch_id': row['last_batch_id'],
'last_batch_records': row['last_batch_records'],
'last_history_complete_at': row['last_history_complete_at'],
'last_trim_cutoff_ms': row['last_trim_cutoff_ms'],
'last_trimmed_at': row['last_trimmed_at'],
if (row['note'] != null) 'legacy_note': row['note'],
};
await db.insert('sync_ledger', {
'chunk_id': (row['id'] as String?) ?? 'capture',
'kind': 'historical',
'status': row['last_history_complete_at'] != null
? 'complete'
: row['last_batch_acked_at'] != null
? 'acknowledged'
: 'legacy',
'created_at': (row['updated_at'] as num?)?.toInt() ?? now,
'updated_at': (row['updated_at'] as num?)?.toInt() ?? now,
'acked_at': (row['last_batch_acked_at'] as num?)?.toInt(),
'last_error': null,
'meta_json': jsonEncode(meta),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
await db.execute('DROP TABLE sync_ledger_legacy');
if (!await exists(legacy)) {
// Normal path.
if (!await exists(table)) {
await create(txn);
return;
}
if (isCurrent(await columnsOf(table))) return;
await txn.execute('ALTER TABLE $table RENAME TO $legacy');
}
// From here on `<table>_legacy` holds the rows of record. `<table>` is
// either absent (crash between RENAME and CREATE) or the new shape
// (possibly half-copied, or fully copied by an older build that then
// died before the DROP) — create it if needed and re-copy; the copy is
// idempotent, so a repeat is a no-op rather than a duplication.
if (!await exists(table)) await create(txn);
final skipCopy =
copyOnlyIntoEmpty &&
(Sqflite.firstIntValue(
await txn.rawQuery('SELECT COUNT(*) FROM $table'),
) ??
0) >
0;
if (!skipCopy) {
await copy(
txn,
await txn.query(legacy),
DateTime.now().millisecondsSinceEpoch,
);
}
await txn.execute('DROP TABLE $legacy');
});
}

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 | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

sqflite nested db.transaction inside onUpgrade onOpen deadlock behavior

💡 Result:

In sqflite, callbacks such as onCreate, onUpgrade, and onDowngrade are already executed within an internal transaction [1][2][3]. Attempting to open a nested transaction (by calling database.transaction(...) inside these callbacks) is strongly discouraged, as sqflite and underlying SQLite implementations generally do not support true nested transactions, which frequently leads to deadlocks or "database locked" errors [2][3][4]. Key points regarding this behavior: 1. Automatic Transaction Wrapping: You do not need to wrap your migration logic in a transaction [2][3]. All SQL statements executed directly on the database object within onUpgrade or onCreate are automatically part of the same transaction [5][1]. 2. Deadlock Mechanism: A deadlock occurs when a transaction is already active and you attempt to perform operations using the main database object instead of the provided transaction object [6][2][7]. Because these callbacks are already inside a transaction, any attempt to initiate a new transaction or use the database object for conflicting operations will block, as sqflite synchronizes access to the database to ensure exclusive write operations [2][3][8]. 3. Proper Usage: Always use the database object directly for migrations within onUpgrade/onCreate [5][1]. If you need to perform multiple operations, you can use the database object (which is already transactional) or a batch for better performance [5]. 4. onOpen: Unlike onUpgrade or onCreate, the onOpen callback is called after the database version is set and is generally intended for non-transactional database setup tasks, such as enabling foreign key support or printing version information [1]. If you must perform transactional operations during onOpen, you must explicitly start a transaction and ensure all operations within that block use the provided Transaction object, not the main database object [6][2][7]. Using the main database object inside a transaction will cause a deadlock [2][7][9].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the migration helper and its callers, then inspect the surrounding lines.
file="lib/data/db.dart"

echo "== Outline =="
ast-grep outline "$file" --view expanded || true

echo
echo "== Relevant line ranges =="
sed -n '1360,1585p' "$file" | cat -n

echo
echo "== Call sites =="
rg -n "_migrateLegacyTable|_ensureSyncStateSchema|_repairOpenSchema|onUpgrade|onOpen" "$file"

Repository: OpenStrap/edge

Length of output: 12482


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="lib/data/db.dart"
sed -n '1478,1575p' "$file" | cat -n

Repository: OpenStrap/edge

Length of output: 4734


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="lib/data/db.dart"
# Show the caller code for _ensureSyncStateSchema / _repairOpenSchema and the db.open callbacks.
sed -n '1478,1605p' "$file" | cat -n

Repository: OpenStrap/edge

Length of output: 6098


🌐 Web query:

sqflite transaction inside onUpgrade same database object deadlock docs nested transaction

💡 Result:

In sqflite, you should not manually start a transaction within onUpgrade, onCreate, or onDowngrade [1][2]. These callbacks are already internally wrapped in a transaction managed by the library [3][1][2]. If you attempt to perform database operations inside these callbacks, you must use the Database object (the db parameter passed to the callback) directly, not a transaction object [4][3]. Because these lifecycle methods already operate within a transaction, any attempt to call db.transaction(...) inside them or to use the main database object instead of the provided transaction-specific interface within an existing transaction context is a primary cause of deadlocks [5][6][1]. Key Technical Points: - Avoid Nested Transactions: SQLite itself does not support true nested transactions via BEGIN...COMMIT [7]. While you can use SAVEPOINT and RELEASE, sqflite simplifies this by managing the lifecycle transaction for you [3][1]. - Use the Correct Object: The fundamental rule to avoid deadlocks in sqflite is to use the transaction object (typically named txn) for all operations inside a transaction block [5][6][8]. However, since onUpgrade/onCreate/onDowngrade are already executing inside a library-managed transaction, simply use the provided Database instance [4][3][1]. - Deadlock Prevention: A deadlock occurs when you use the main database object to perform an operation while a transaction is already active [5][1]. Since the library has already locked the database to perform the migration, attempting to start another transaction—or using the main db object to interact with the database from within that context—will lead to the classic "database has been locked" timeout error [5][6][1]. In summary, treat the db parameter in onUpgrade as your sole interface for all migrations and refrain from wrapping your logic in additional db.transaction calls [4][3][1].

Citations:


Avoid nested db.transaction in _migrateLegacyTable (lib/data/db.dart:1417-1476)
onUpgrade already runs inside sqflite’s migration transaction, so wrapping it again here can deadlock or stall database open. Use the ambient executor on the upgrade path and keep the explicit transaction only for the onOpen repair 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/data/db.dart` around lines 1417 - 1476, Update _migrateLegacyTable to
accept and operate on the ambient DatabaseExecutor when invoked from onUpgrade,
avoiding a nested db.transaction; retain an explicit transaction only for the
onOpen repair path. Preserve the existing migration, copy, skipCopy, and
legacy-table cleanup behavior while routing both paths through the shared logic.

Comment on lines +255 to +258
// Finalizing locks a day out of DerivationEngine permanently. Only safe
// when there is no raw substrate left to re-derive from; a day that still
// has 1 Hz raw stays open so the real signal supersedes WHOOP's numbers.
finalized: !rawDays.contains(date),

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

rawDays is "raw on device now", not "raw that will ever exist" — this can still permanently lock a day.

rawDays is snapshotted from LocalDb.decodedRecTsMaxByDay() before the loop, i.e. only days already drained from the band. The import is reachable from onboarding, which typically runs before the first sync: at that point rawDays is empty (or nearly so), every exported day is written finalized: true, and when the band's flash is subsequently drained for those same dates DerivationEngine can never derive them — finalizedDayIds(kAlgoVersion) removes them from todoDays, and per putDayResult's own contract in lib/data/db.dart a finalized row is never revisited on a version bump either.

The comment's premise ("no raw substrate left to re-derive from") only holds for dates older than the band's retention window; for anything newer, absence of raw today is not evidence.

🛡️ Only finalize dates that can no longer receive raw
-      // Finalizing locks a day out of DerivationEngine permanently. Only safe
-      // when there is no raw substrate left to re-derive from; a day that still
-      // has 1 Hz raw stays open so the real signal supersedes WHOOP's numbers.
-      finalized: !rawDays.contains(date),
+      // Finalizing locks a day out of DerivationEngine permanently, so never
+      // do it for a date the band could still deliver raw for. `rawDays` only
+      // knows what has ALREADY been drained — on the onboarding path that is
+      // empty, so a bare `!rawDays.contains(date)` would lock every imported
+      // day before the first sync. Leave imports open; the `producedNothing`
+      // guard in DerivationEngine already stops an empty re-derive from
+      // blanking them.
+      finalized: false,

If keeping finalization for genuinely historical dates is preferred, gate it on the data edge instead — e.g. only when date is older than lastDecodedRecTs() - rawRetentionDays, which is the same rule _pruneOldDecoded uses.

📝 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
// Finalizing locks a day out of DerivationEngine permanently. Only safe
// when there is no raw substrate left to re-derive from; a day that still
// has 1 Hz raw stays open so the real signal supersedes WHOOP's numbers.
finalized: !rawDays.contains(date),
// Finalizing locks a day out of DerivationEngine permanently, so never
// do it for a date the band could still deliver raw for. `rawDays` only
// knows what has ALREADY been drained — on the onboarding path that is
// empty, so a bare `!rawDays.contains(date)` would lock every imported
// day before the first sync. Leave imports open; the `producedNothing`
// guard in DerivationEngine already stops an empty re-derive from
// blanking them.
finalized: false,
🤖 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/import/whoop_import.dart` around lines 255 - 258, Update the finalized
assignment in the WHOOP import loop to finalize a date only when it is older
than the device’s raw-data retention boundary, rather than when it is merely
absent from the snapshotted rawDays set. Reuse the existing lastDecodedRecTs()
and raw-retention calculation used by _pruneOldDecoded, ensuring dates still
able to receive future raw data remain unfinalized.

Comment thread lib/state/app_state.dart
Comment on lines 750 to 779
@override
void dispose() {
// EVERY timer this object owns, not just three of them. _spotTimer,
// _breathingRecomputeTimer and _workoutTimer used to survive dispose, and
// each of their callbacks ends in notifyListeners() on a disposed
// ChangeNotifier (which throws in release).
_tapSub?.cancel();
_stopBackfillTimer();
_alarmGraceTimer?.cancel();
_alarmGraceTimer = null;
_spotTimer?.cancel();
_spotTimer = null;
_breathingRecomputeTimer?.cancel();
_breathingRecomputeTimer = null;
_workoutTimer?.cancel();
_workoutTimer = null;
BandOwnership.markForegroundIntent(false);
_releaseForegroundLease();
_deriveScheduler.dispose();
_waterBuzzer.dispose();
// Owned notifiers/observers. notificationRelay in particular holds a
// WidgetsBindingObserver, a 120 s Timer.periodic and a StreamSubscription —
// its observer accumulated on the binding across every hot restart.
notificationRelay.dispose();
gestureSettings.dispose();
navRequest.dispose();
screenRequest.dispose();
insightsRevision.dispose();
super.dispose();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

_routeTracker isn't torn down here.

The stated goal of this hunk is "EVERY timer this object owns", but a live RouteTracker (GPS stream subscription + buffered points flushed via LocalDb.appendRoutePoints) survives dispose() when the app is disposed mid-route-workout — same leak shape as the timers you just fixed, and its sink writes against a dead AppState.

🛡️ Proposed fix
     _workoutTimer?.cancel();
     _workoutTimer = null;
+    final rt = _routeTracker;
+    _routeTracker = null;
+    if (rt != null) unawaited(rt.stop().catchError((_) {}));
     BandOwnership.markForegroundIntent(false);
📝 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
@override
void dispose() {
// EVERY timer this object owns, not just three of them. _spotTimer,
// _breathingRecomputeTimer and _workoutTimer used to survive dispose, and
// each of their callbacks ends in notifyListeners() on a disposed
// ChangeNotifier (which throws in release).
_tapSub?.cancel();
_stopBackfillTimer();
_alarmGraceTimer?.cancel();
_alarmGraceTimer = null;
_spotTimer?.cancel();
_spotTimer = null;
_breathingRecomputeTimer?.cancel();
_breathingRecomputeTimer = null;
_workoutTimer?.cancel();
_workoutTimer = null;
BandOwnership.markForegroundIntent(false);
_releaseForegroundLease();
_deriveScheduler.dispose();
_waterBuzzer.dispose();
// Owned notifiers/observers. notificationRelay in particular holds a
// WidgetsBindingObserver, a 120 s Timer.periodic and a StreamSubscription —
// its observer accumulated on the binding across every hot restart.
notificationRelay.dispose();
gestureSettings.dispose();
navRequest.dispose();
screenRequest.dispose();
insightsRevision.dispose();
super.dispose();
}
`@override`
void dispose() {
// EVERY timer this object owns, not just three of them. _spotTimer,
// _breathingRecomputeTimer and _workoutTimer used to survive dispose, and
// each of their callbacks ends in notifyListeners() on a disposed
// ChangeNotifier (which throws in release).
_tapSub?.cancel();
_stopBackfillTimer();
_alarmGraceTimer?.cancel();
_alarmGraceTimer = null;
_spotTimer?.cancel();
_spotTimer = null;
_breathingRecomputeTimer?.cancel();
_breathingRecomputeTimer = null;
_workoutTimer?.cancel();
_workoutTimer = null;
final rt = _routeTracker;
_routeTracker = null;
if (rt != null) unawaited(rt.stop().catchError((_) {}));
BandOwnership.markForegroundIntent(false);
_releaseForegroundLease();
_deriveScheduler.dispose();
_waterBuzzer.dispose();
// Owned notifiers/observers. notificationRelay in particular holds a
// WidgetsBindingObserver, a 120 s Timer.periodic and a StreamSubscription —
// its observer accumulated on the binding across every hot restart.
notificationRelay.dispose();
gestureSettings.dispose();
navRequest.dispose();
screenRequest.dispose();
insightsRevision.dispose();
super.dispose();
}
🤖 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/state/app_state.dart` around lines 750 - 779, Update AppState.dispose()
to tear down the owned _routeTracker before super.dispose(), canceling or
disposing it through the RouteTracker lifecycle API and clearing the field if
applicable. Ensure its GPS subscription and buffered-point flush cannot continue
writing after AppState disposal, while preserving the existing cleanup order for
the other owned resources.

Comment on lines +47 to +62
Future<bool?> runBootSyncThroughGate(
BandLease lease, {
Future<bool> Function(BandLease lease)? runner,
}) async {
final run = runner ?? ((l) => runHeadlessSync(lease: l));
final result = await HeadlessSyncGate.tryRun<bool>(
kBootWakeGateOwner,
() => run(lease),
);
if (result == null) {
// Skipped — release the lease we acquired up front, or the band stays
// owned by a headless run that never happened.
BandOwnership.release(lease);
}
return result;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Boot-wake failure path leaves the band lease held and the error unhandled. Both sites stem from one root cause: runBootSyncThroughGate only releases the BandLease on the gate-busy (null) path, so a throwing run strands ownership and escapes the fire-and-forget chain.

  • lib/sync/headless_boot.dart#L47-L62: wrap the HeadlessSyncGate.tryRun call in try/catch, release the lease, and rethrow.
  • lib/sync/headless_boot.dart#L113-L124: add an onError (or catchError) to the unawaited(...) chain so a failure logs instead of becoming an unhandled async error in the headless isolate.
📍 Affects 1 file
  • lib/sync/headless_boot.dart#L47-L62 (this comment)
  • lib/sync/headless_boot.dart#L113-L124
🤖 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/sync/headless_boot.dart` around lines 47 - 62, Update
runBootSyncThroughGate to catch failures from HeadlessSyncGate.tryRun, release
the acquired BandLease, and rethrow the original error. Also update the
unawaited(...) chain at lib/sync/headless_boot.dart lines 113-124 to handle
errors with onError or catchError and log them, preventing unhandled async
failures; both locations require changes.

Comment on lines +113 to +124
// Fire-and-forget (see the doc above) but SERIALISED through the shared
// headless gate, so `HeadlessSyncGate.busy` is true for the whole boot drain.
unawaited(
runBootSyncThroughGate(lease).then((ran) {
debugPrint(
ran == null
? '[headless-boot] boot wake skipped — another headless sync '
'holds the gate; lease released.'
: '[headless-boot] headless sync complete',
);
}),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add an onError to the fire-and-forget chain.

.then(...) alone lets any failure from runBootSyncThroughGate escape as an unhandled async error in a headless isolate (root cause covered in the comment on Lines 47-62).

🤖 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/sync/headless_boot.dart` around lines 113 - 124, Add an onError handler
to the fire-and-forget Future chain around runBootSyncThroughGate(lease) in the
headless boot flow, ensuring failures are handled rather than escaping as
unhandled async errors. Preserve the existing then callback and its logging
behavior, and report the caught error through the established debugging or
logging mechanism.

Comment on lines 46 to +50
set enabled(bool value) {
_enabled = value;
_consentResolved = true;
_applyFirebaseCollection(value);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 \
  'setCrashlyticsCollectionEnabled|applyConsent|enforceCollectionOffUntilConsent|deleteUnsentReports' \
  lib android ios test

rg -n \
  'firebase_crashlytics|firebase_core' \
  pubspec.yaml pubspec.lock 2>/dev/null || true

Repository: OpenStrap/edge

Length of output: 9437


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== lib/telemetry/telemetry_service.dart ==\n'
sed -n '1,180p' lib/telemetry/telemetry_service.dart

printf '\n== lib/main.dart ==\n'
sed -n '35,70p' lib/main.dart

printf '\n== lib/state/app_state.dart ==\n'
sed -n '425,490p' lib/state/app_state.dart

printf '\n== test/telemetry_consent_default_test.dart ==\n'
sed -n '1,180p' test/telemetry_consent_default_test.dart

Repository: OpenStrap/edge

Length of output: 18681


🌐 Web query:

Firebase Crashlytics setCrashlyticsCollectionEnabled takes effect next app launch documentation

💡 Result:

Yes, the setCrashlyticsCollectionEnabled method in the Firebase Crashlytics SDK takes effect on the next launch of the app [1][2][3]. When you call this method, the new setting is persisted and will influence data collection behavior in subsequent sessions [4][3][5]. If you call this method to disable data collection (set to false) and cause a crash before the app is relaunched, the crash report may still be processed or sent [6]. To ensure the setting is respected, it is necessary to restart the application after the call [6][7]. Key details regarding this behavior: - Persistence: The value set via this method persists across all subsequent app launches for the user [4][3][5]. - Overriding: This runtime setting overrides other configurations, such as the manifest flag (Android) or the Info.plist flag (iOS) [1][3][5]. - Clearing the override: Passing a null value (on platforms where supported) will clear the runtime override, allowing the SDK to revert to your manifest or Info.plist configuration [1][2]. - Unsent reports: If you are disabling collection, you may also want to use deleteUnsentReports to remove any previously stored reports from the device if you do not intend to send them [1][7].

Citations:


Revocation still needs a restart path. setCrashlyticsCollectionEnabled(false) only takes effect on the next app launch, so a user who opts out mid-session can still have crash data captured or uploaded for the rest of that process. Clear unsent reports on opt-out and block collection until the next cold start.

🤖 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/telemetry/telemetry_service.dart` around lines 46 - 50, Update the
enabled setter in the telemetry service to handle revocation immediately: when
value is false, clear any unsent Crashlytics reports, disable collection for the
remainder of the process, and preserve the restart-required behavior until the
next cold start. Keep the existing enable path and consent state updates intact.

CI failures (both real, neither caught locally):

- `_denseDailyTrimp` turned "no observed load at all" into a synthetic
  all-zero series, so a user with no workouts got a confident CTL/ATL/TSB
  of 0 instead of an absent load model — the exact fabrication this sweep
  removes elsewhere. It surfaced only on CI because CI resolves the
  PINNED analytics (where ctlAtlTsb has no minimum-history gate) while
  local dev resolves the working copy through pubspec_overrides. Now
  abstains, which is correct against BOTH the pinned and the updated
  analytics, so the test no longer depends on which one is resolved.
- The tsb/ctl-atl equality assertion used a 1e-6 tolerance. The three
  values round-trip through JSON independently, so the reconstructed
  difference can differ from the stored tsb by a ULP, and which way it
  lands is architecture-dependent: it passed on arm64 macOS and failed on
  x64 Linux by exactly 1e-6. Loosened to 1e-4, which still pins the
  relationship without asserting bit-identical floats across platforms.

Review feedback (PR Agent, edge#158):

- `_trimHistory` promised in its docstring that a `tool` message never
  outlives the assistant turn whose `tool_calls` it answers, but enforced
  it only as a side effect of the loop bounds. Both loops stop at
  `length > 1`, so a single turn larger than the whole byte budget walked
  the history down to one element: [assistant(tool_calls), tool] dropped
  the assistant and left the orphan. Providers 400 on that, and because
  the history is persisted the rejection repeats every turn — the session
  is bricked, not just one reply. Now enforced unconditionally.
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 23aa39b

…cord

     real live-pedometer coverage

Diagnosed against a real user database, where the app reported 39,384 steps
for a day whose true value was ~2,000 (yesterday: 46).

The 1 Hz estimator keyed off ENMO, `mean(max(0, |a| - gRef))`, with gRef
auto-calibrated per day from the stillest samples — which are the long
sleep block, where the wrist sits in a different orientation than it does
awake. That day gRef came out at 0.9797 against ~1.032 on every other day
(hourly median |a| was 0.94-0.99 asleep vs 1.03-1.06 awake). ENMO subtracts
gRef from every sample, so a reference 0.05 g low adds 0.05 g to every
minute of the day — exactly the 0.05 g walking floor. Ordinary sitting
cleared the gate for hours: 368 "ambulatory" minutes on a partial day.

Sweeping gRef over the identical samples gave 42,155 steps at 0.97 and 0 at
1.02. The signal and the calibration error are the same size, so no
threshold could have fixed it; the analytics package now decides activity
from a per-axis high-passed dynamic amplitude, where any per-axis offset or
gain error cancels exactly.

This is the edge half.

PERSONAL FLOOR. The new feature needs to know what "moving" means for this
wearer, and analytics is pure so it cannot read history. Each day now
persists `dyn_p90` — its own high quantile of the dynamic amplitude — and
the next day's derive anchors on the MEDIAN across trailing days, with the
same strict self-exclusion every other baseline uses. Validated on the real
database: per-day values sit at 0.40-0.47 across ten days and the resulting
floor moves only between 0.4493 and 0.4575, absorbing a day whose own value
fell to 0.3148. A same-day floor was measured to fail in the opposite
direction (it collapses on a quiet day and passes everything), which is why
the anchor is multi-day and a median rather than a pooled quantile.

Below the minimum history there is NO floor and the estimator abstains, so
a new user sees the real 100 Hz count and no invented remainder. That
matters here: on this database Tier B contributed exactly 0 on every normal
day and 39,368 on the one drifted day — the stored total equalled the live
count exactly (46=46, 119+18+12=149, 105+201+103=409). It has never once
produced a useful number.

ACTIVE MINUTES. Persisted as a first-class series and surfaced as the
primary quantity. Gait is 1.4-2.5 Hz and 120 spm aliases to DC at 1 Hz, so
steps are not resolvable from this substrate; minutes are, and minutes of
moderate activity is the unit public activity guidance uses. Steps are now
reported as a range over the free-living cadence band.

LIVE COVERAGE. `live_coverage` windows were being written with
end_ts == start_ts — 22 of 24 rows in the real database, including one
claiming 1,657 real steps over 0 seconds. The window was derived purely
from the band record timestamp carried on live frames, which does not
advance across most sessions. Two consequences: the no-double-count
exclusion excluded ~1 minute instead of the streamed period, and there was
no way to align real 100 Hz counts to 1 Hz minutes — destroying the only
ground truth available to calibrate the estimator.

The window is now derived by a pure policy: anchored in the band record-time
base (what coverageWindowsOverlapping compares against), with the duration
taken from the 100 Hz sample count and the ingest wall hull, and raised to
the time the claimed steps could physically span. It can never be zero. The
persistence guard repairs a degenerate window rather than rejecting it, so
a real count is never lost. Historical rows are left alone — their true
durations were never recorded and inventing them would swap one wrong
extent for another.

kAlgoVersion 48 -> 49. The sibling analytics fixes are still NOT pinned;
pubspec.yaml must be repinned and this bumped again once that PR merges.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Added: personal ambulatory baseline + real live-pedometer coverage

Pushed b5c3352, the edge half of OpenStrap/analytics#30. Found by running the shipped pipeline against a real 152 MB user database, where it reported 39,384 steps for a day whose true value was ~2,000 — with 368 "ambulatory minutes" (6.1 h of walking) on a partial day, against 46 steps the day before.

Root cause is in analytics (the ENMO gravity-reference drift — details on that PR). Two things belong here:

1. Personal floor from trailing days

The new calibration-invariant feature needs to know what "moving" means for this wearer, and analytics is pure so it cannot read history. Each day now persists dyn_p90 — its own high quantile of the dynamic amplitude — and the next day's derive anchors on the median across trailing days, with the same strict self-exclusion every other baseline uses.

Validated against the real database:

per-day dyn_p90 derived floor
range over 10 days 0.40 – 0.47 0.4493 – 0.4575

The floor barely moves even though 07-26's own value fell to 0.3148 — the median absorbs the anomalous day. That's the whole design goal: a same-day floor was measured to fail in the opposite direction (it collapses on a quiet day and passes everything).

Below the minimum history there is no floor and the estimator abstains — a new user sees the real 100 Hz count and no invented remainder.

That matters more than it sounds. On this database Tier B contributed exactly 0 on every normal day and 39,368 on the one drifted day — the stored total equalled the live count exactly (46=46, 119+18+12=149, 105+201+103=409). It has never once produced a useful number.

active_min is now persisted as a first-class series and is the primary quantity: 1 Hz cannot count steps (120 spm aliases to DC), but it can resolve minutes, and minutes of moderate activity is the unit public activity guidance uses. Steps are a range over the free-living cadence band.

2. live_coverage windows were zero-duration

22 of 24 rows in the real database have end_ts == start_ts — including one claiming 1,657 real steps over 0 seconds. The window was derived purely from the band record timestamp on live frames, which doesn't advance across most sessions.

Two consequences: the no-double-count exclusion excluded ~1 minute instead of the streamed period, and there was no way to align real 100 Hz counts to 1 Hz minutes — which destroyed the only ground truth available to calibrate the estimator. (It's why the analytics-side vigorous-ceiling constant is still reasoned rather than measured.)

Now derived by a pure policy: anchored in the band record-time base (what coverageWindowsOverlapping compares against), duration from the 100 Hz sample count and the ingest wall hull, raised to the time the claimed steps could physically span. Never zero. The persistence guard repairs a degenerate window rather than rejecting it, so a real count is never lost. Historical rows are deliberately left alone — their true durations were never recorded, and inventing them would swap one wrong extent for another.

Verification

flutter test --concurrency=1: 914 passing (was 890). flutter analyze: clean.

kAlgoVersion 48 → 49. The sibling analytics fixes remain unpinned — pubspec.yaml still points at the pre-fix SHA, and must be repinned with another bump once OpenStrap/analytics#30 merges. Same discipline as the v48 note: I'm not claiming an analytics change the pinned SHA doesn't contain.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit b5c3352

@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: 4

Caution

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

⚠️ Outside diff range comments (2)
lib/compute/derivation_engine.dart (1)

1152-1165: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the worker-ready handshake too.

Line 1208 awaits ready.future without a timeout. If startup/handshake stalls before the worker sends its SendPort, this pass can still hang indefinitely. Apply one _perDayTimeout deadline across both ready.future and result.future.

🤖 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 1152 - 1165, The worker
startup path around derivationPrepareWorker must apply the same _perDayTimeout
deadline to the ready.future handshake as to result.future. Update the await
sequence so time spent waiting for the worker SendPort counts against one shared
timeout budget, ensuring a stalled startup cannot hang indefinitely while
preserving the existing result wait behavior.
lib/state/app_state.dart (1)

3350-3359: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Re-check inside the reconciliation loop.

A stale-row await LocalDb.putSession(...) can yield to startWorkout(). A later valid row then overwrites the newly started workout and cancels its timer.

Proposed fix
       var resumed = false;
       for (final row in rows) {
+        if (activeWorkout != null) break;
         final startSec = (row['start_ts'] as num?)?.toInt();
🤖 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/state/app_state.dart` around lines 3350 - 3359, Add the activeWorkout
guard inside the reconciliation loop, immediately after each awaited
LocalDb.putSession(...) operation and before applying the next database row.
Return from the reconciliation method when activeWorkout is no longer null so
startWorkout() remains authoritative and no stale row or timer replaces it.
🧹 Nitpick comments (4)
test/coach_history_trim_test.dart (1)

74-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The tool_calls: const [] fixtures don't model a real assistant→tool pair.

An empty tool_calls list means the assistant issued no calls, so the following tool message with tool_call_id: 'call_1' is technically already an orphan. These fixtures only pass because the current sweep pairs by adjacency/role; if pairing is ever tightened to match tool_call_id, they'd start failing for the wrong reason. Populate the id so the fixture is well-formed. Same pattern at Lines 94-95.

♻️ Proposed fix
-        {'role': 'assistant', 'content': null, 'tool_calls': const []},
+        {
+          'role': 'assistant',
+          'content': null,
+          'tool_calls': const [
+            {'id': 'call_1', 'type': 'function'},
+          ],
+        },
🤖 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/coach_history_trim_test.dart` around lines 74 - 79, Update the assistant
fixtures in the intact history case and the matching fixtures around the second
occurrence to populate tool_calls with the call_1 identifier, so each subsequent
tool message is associated with an explicitly issued call. Preserve the existing
roles, content, and tool_call_id values.
test/live_coverage_window_test.dart (1)

249-256: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

This assertion can pass vacuously.

'1970-01-01' is almost certainly not the day label the finalizer would use, so the query returns empty regardless of whether a window was written. Assert on the whole table instead.

♻️ Proposed fix
-      final rows = await db.query(
-        'live_coverage',
-        where: 'day = ?',
-        whereArgs: ['1970-01-01'],
-      );
-      expect(rows, isEmpty);
+      final rows = await db.query('live_coverage');
+      expect(rows, isEmpty);

Note this requires the table to be empty at that point; if ordering with other groups is a concern, capture the row count before debugFinalizeLivePedometer() and assert it is unchanged.

🤖 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/live_coverage_window_test.dart` around lines 249 - 256, Update the
assertion after debugFinalizeLivePedometer() to validate the entire
live_coverage table rather than querying the unlikely '1970-01-01' day label.
Ensure the test verifies no rows were added, or capture the table row count
before finalization and assert it remains unchanged if other test groups may
already have data.
test/step_personal_floor_test.dart (1)

86-92: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Weak assertion for the median-stability property.

lessThan(0.02) on the delta only proves the two floors are close; with 2 outliers appended to 7 samples the median genuinely shifts by one element. Assert the polluted floor stays within the normal range (e.g. lessThan(0.5)) as well, so a mean-based regression is caught even when the deltas happen to be small.

🤖 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/step_personal_floor_test.dart` around lines 86 - 92, The
median-stability test should also enforce that the polluted floor remains within
the normal input range. In the test for personalDynFloorFromDailySummaries,
retain the existing delta assertion and add an assertion that polluted is below
the chosen upper bound (for example, lessThan(0.5)) to catch mean-based
regressions.
lib/state/app_state.dart (1)

1897-1902: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the shared local-day label helper.

Persist the coverage day with dayLabelOf(DateTime.fromMillisecondsSinceEpoch(...)) rather than manually formatting it.

Proposed fix
-      final d = DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000);
-      final day =
-          '${d.year.toString().padLeft(4, '0')}-'
-          '${d.month.toString().padLeft(2, '0')}-'
-          '${d.day.toString().padLeft(2, '0')}';
+      final day = dayLabelOf(
+        DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000),
+      );

As per coding guidelines, “Use todayLabel() or dayLabelOf() from data/day_label.dart for local day labels; do not derive labels from UTC strings.”

🤖 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/state/app_state.dart` around lines 1897 - 1902, Replace the manual day
string construction in the coverage persistence flow with the shared dayLabelOf
helper, passing the DateTime created from window.startTs. Remove the local
year/month/day formatting while keeping the existing LocalDb.addLiveCoverage
arguments unchanged.

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/compute/derivation_engine.dart`:
- Around line 2624-2663: The dailyStepEstimate abstention path must not expose
stale active minutes. In lib/compute/derivation_engine.dart lines 2624-2663,
clear or null scMap['active_min'] when v is null and only persist
estimator-derived active minutes when v is present; in lines 3550-3553, update
the wake synchronization to copy active_min alongside steps and calories,
including the absent/null case.

In `@lib/data/live_coverage_policy.dart`:
- Around line 121-130: The coverage calculation around duty-based selection must
not use aggregate sampled duration to anchor a sparse window at the session
start. Update the logic producing the sampled coverage interval so exact sampled
intervals are persisted and used when available; otherwise conservatively retain
hullS when duty is below kCoverageDutyFloor, preserving the existing hull
ceiling and zero-duration handling.

In `@test/coach_history_trim_test.dart`:
- Around line 66-68: Update the debugHistory assertions in the coach history
test to first require that engine.debugHistory is not empty, then
unconditionally verify its first entry’s role is not 'tool'. Remove the
conditional wrapper so an empty history causes the test to fail.

In `@test/step_personal_floor_test.dart`:
- Around line 22-31: The test depends on analytics APIs absent from the pinned
openstrap_analytics revision. Repin openstrap_analytics to the revision
containing personalDynFloorFromDailySummaries, personalDynFloorMinDays,
dailyDynSummary, personalDynFloorG, DailyStepEstimate.activeMinutes, and the
six-argument MotionMinute constructor; if unavailable immediately, gate or
disable this suite so flutter analyze and CI remain green.

---

Outside diff comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 1152-1165: The worker startup path around derivationPrepareWorker
must apply the same _perDayTimeout deadline to the ready.future handshake as to
result.future. Update the await sequence so time spent waiting for the worker
SendPort counts against one shared timeout budget, ensuring a stalled startup
cannot hang indefinitely while preserving the existing result wait behavior.

In `@lib/state/app_state.dart`:
- Around line 3350-3359: Add the activeWorkout guard inside the reconciliation
loop, immediately after each awaited LocalDb.putSession(...) operation and
before applying the next database row. Return from the reconciliation method
when activeWorkout is no longer null so startWorkout() remains authoritative and
no stale row or timer replaces it.

---

Nitpick comments:
In `@lib/state/app_state.dart`:
- Around line 1897-1902: Replace the manual day string construction in the
coverage persistence flow with the shared dayLabelOf helper, passing the
DateTime created from window.startTs. Remove the local year/month/day formatting
while keeping the existing LocalDb.addLiveCoverage arguments unchanged.

In `@test/coach_history_trim_test.dart`:
- Around line 74-79: Update the assistant fixtures in the intact history case
and the matching fixtures around the second occurrence to populate tool_calls
with the call_1 identifier, so each subsequent tool message is associated with
an explicitly issued call. Preserve the existing roles, content, and
tool_call_id values.

In `@test/live_coverage_window_test.dart`:
- Around line 249-256: Update the assertion after debugFinalizeLivePedometer()
to validate the entire live_coverage table rather than querying the unlikely
'1970-01-01' day label. Ensure the test verifies no rows were added, or capture
the table row count before finalization and assert it remains unchanged if other
test groups may already have data.

In `@test/step_personal_floor_test.dart`:
- Around line 86-92: The median-stability test should also enforce that the
polluted floor remains within the normal input range. In the test for
personalDynFloorFromDailySummaries, retain the existing delta assertion and add
an assertion that polluted is below the chosen upper bound (for example,
lessThan(0.5)) to catch mean-based regressions.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c29bb031-9882-416d-9cc2-4159d14d9a26

📥 Commits

Reviewing files that changed from the base of the PR and between 533e3b4 and b5c3352.

📒 Files selected for processing (10)
  • lib/coach/coach_engine.dart
  • lib/compute/crossday_pipeline.dart
  • lib/compute/derivation_engine.dart
  • lib/data/db.dart
  • lib/data/live_coverage_policy.dart
  • lib/state/app_state.dart
  • test/coach_history_trim_test.dart
  • test/crossday_pipeline_test.dart
  • test/live_coverage_window_test.dart
  • test/step_personal_floor_test.dart
🚧 Files skipped from review as they are similar to previous changes (4)
  • lib/coach/coach_engine.dart
  • test/crossday_pipeline_test.dart
  • lib/compute/crossday_pipeline.dart
  • lib/data/db.dart

Comment on lines +2624 to +2663
final v = est.present ? est.value : null;
final estSteps = v?.steps ?? 0;
final daySteps = liveStepsReal + estSteps;
scMap?['steps'] = daySteps.toDouble();
// ACTIVE MINUTES is the primary, honest quantity here: 1 Hz cannot count
// steps (gait is 1.4-2.5 Hz and 120 spm aliases to DC at this rate), but
// it can resolve ambulatory MINUTES, which is also the unit public
// activity guidance is written in. The step figures are a RANGE over the
// free-living cadence band, and are absent entirely when the personal
// floor has not been established yet.
bundle['steps'] = <String, dynamic>{
'value': daySteps,
'real_100hz': liveStepsReal, // AN-2554 over live windows (real count)
'estimated_1hz':
estSteps, // walking-min × cadence for uncovered minutes
'ambulatory_min': est.present ? est.value!.ambulatoryMinutes : 0,
'cadence_used_spm': est.present ? est.value!.cadenceUsed : 0,
'estimated_1hz': estSteps, // midpoint of the 1 Hz range
'estimated_1hz_low': v?.stepsLow,
'estimated_1hz_high': v?.stepsHigh,
'active_min': v?.activeMinutes ?? 0,
'cadence_low_spm': v?.cadenceLowSpm,
'cadence_high_spm': v?.cadenceHighSpm,
'dyn_floor_g': v?.dynFloorG,
'estimate_present': v != null,
'confidence': liveStepsReal > 0
? 0.7
: (est.present ? est.confidence : 0.2),
'tier': liveStepsReal > 0 && estSteps == 0 ? 'HIGH' : 'ESTIMATE',
'inputs_used': const ['live_100hz_pedometer', 'enmo_1hz', 'hr_1hz'],
'note':
'real 100 Hz count for streamed time + 1 Hz walking estimate for '
'the rest (1 Hz cannot count steps directly)',
'inputs_used': const [
'live_100hz_pedometer',
'dyn_amp_1hz',
'hr_1hz',
'personal_dyn_floor',
],
'note': v == null
? 'real 100 Hz count only — the 1 Hz activity estimate needs a '
'personal movement baseline from several days of wear '
'(${est.note ?? 'need_baseline'})'
: 'real 100 Hz count for streamed time + ${v.activeMinutes} active '
'minutes estimated from 1 Hz for the rest (1 Hz cannot count '
'steps directly, so the step figure is a range)',
};
if (v != null) scMap?['active_min'] = v.activeMinutes.toDouble();

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

Preserve abstention for active_min across both persisted outputs.

When dailyStepEstimate abstains, active_min written earlier by _applyWakeDayFeatures remains in scMap; the early-read wake map also retains its older value. This exposes an activity estimate despite the missing personal baseline.

  • lib/compute/derivation_engine.dart#L2624-L2663: remove or null scMap['active_min'] when v == null; only persist estimator-derived active minutes when present.
  • lib/compute/derivation_engine.dart#L3550-L3553: synchronize active_min into wake alongside steps/calories, including the absent case.
🧰 Tools
🪛 GitHub Actions: test / 0_test.txt

[error] 2638-2638: flutter analyze: The getter 'stepsLow' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2639-2639: flutter analyze: The getter 'stepsHigh' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2640-2640: flutter analyze: The getter 'activeMinutes' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2641-2641: flutter analyze: The getter 'cadenceLowSpm' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2642-2642: flutter analyze: The getter 'cadenceHighSpm' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2643-2643: flutter analyze: The getter 'dynFloorG' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2659-2659: flutter analyze: The getter 'activeMinutes' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2663-2663: flutter analyze: The getter 'activeMinutes' isn't defined for type 'DailyStepEstimate'. (undefined_getter)

🪛 GitHub Actions: test / test

[error] 2638-2638: Getter 'stepsLow' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2639-2639: Getter 'stepsHigh' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2640-2640: Getter 'activeMinutes' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2641-2641: Getter 'cadenceLowSpm' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2642-2642: Getter 'cadenceHighSpm' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2643-2643: Getter 'dynFloorG' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2659-2659: Getter 'activeMinutes' isn't defined for type 'DailyStepEstimate'. (undefined_getter)


[error] 2663-2663: Getter 'activeMinutes' isn't defined for type 'DailyStepEstimate'. (undefined_getter)

📍 Affects 1 file
  • lib/compute/derivation_engine.dart#L2624-L2663 (this comment)
  • lib/compute/derivation_engine.dart#L3550-L3553
🤖 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 2624 - 2663, The
dailyStepEstimate abstention path must not expose stale active minutes. In
lib/compute/derivation_engine.dart lines 2624-2663, clear or null
scMap['active_min'] when v is null and only persist estimator-derived active
minutes when v is present; in lines 3550-3553, update the wake synchronization
to copy active_min alongside steps and calories, including the absent/null case.

Comment on lines +121 to +130
if (hullS <= 0) {
coveredS = sampledS; // no hull observed — the sampled time is all we have
} else if (sampledS <= 0) {
coveredS = hullS; // steps without sample accounting — hull is the evidence
} else {
final duty = sampledS / hullS;
coveredS = duty >= kCoverageDutyFloor ? hullS : sampledS;
// The sampled time can exceed the hull (duplicate/backlogged frames). The
// hull is wall truth, so it also acts as the ceiling.
if (coveredS > hullS) coveredS = hullS;

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 | 🏗️ Heavy lift

Do not anchor sparse sampled duration at the session start.

When duty is below the floor, sampledS has no placement information. For example, samples concentrated at the end of a one-hour hull produce a short window starting at the first frame, so the actual live-step minute remains eligible for the 1 Hz estimate. Persist exact sampled intervals, or conservatively retain the hull when only aggregate timestamps are available.

🤖 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/live_coverage_policy.dart` around lines 121 - 130, The coverage
calculation around duty-based selection must not use aggregate sampled duration
to anchor a sparse window at the session start. Update the logic producing the
sampled coverage interval so exact sampled intervals are persisted and used when
available; otherwise conservatively retain hullS when duty is below
kCoverageDutyFloor, preserving the existing hull ceiling and zero-duration
handling.

Source: Coding guidelines

Comment on lines +66 to +68
if (engine.debugHistory.isNotEmpty) {
expect(engine.debugHistory.first['role'], isNot('tool'));
}

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 | 🟡 Minor | ⚡ Quick win

Conditional expect makes this test pass vacuously.

If a regression trims the history to empty, the body never runs and the test still passes. Assert the history survived, then assert the head role.

💚 Proposed fix
-      if (engine.debugHistory.isNotEmpty) {
-        expect(engine.debugHistory.first['role'], isNot('tool'));
-      }
+      expect(engine.debugHistory, isNotEmpty);
+      expect(engine.debugHistory.first['role'], isNot('tool'));
📝 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
if (engine.debugHistory.isNotEmpty) {
expect(engine.debugHistory.first['role'], isNot('tool'));
}
expect(engine.debugHistory, isNotEmpty);
expect(engine.debugHistory.first['role'], isNot('tool'));
🤖 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/coach_history_trim_test.dart` around lines 66 - 68, Update the
debugHistory assertions in the coach history test to first require that
engine.debugHistory is not empty, then unconditionally verify its first entry’s
role is not 'tool'. Remove the conditional wrapper so an empty history causes
the test to fail.

Comment on lines +22 to +31
import 'package:flutter_test/flutter_test.dart';
import 'package:openstrap_analytics/onehz.dart' as ana;

/// Per-minute rows carrying DELIBERATELY MISLEADING ENMO: every sedentary
/// minute sits at 0.055 g, just above the absolute 0.05 g floor the old
/// estimator used. If an ENMO-based decision path is ever reintroduced these
/// tests fail loudly instead of shipping tens of thousands of phantom steps.
List<ana.MotionMinute> rows(List<double> dyn) => [
for (var i = 0; i < dyn.length; i++)
ana.MotionMinute(i * 60000.0, 60, 0.055, 0.02, 1.055, dyn[i]),

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 | 🔴 Critical | ⚡ Quick win

Blocking: the entire file fails flutter analyzeopenstrap_analytics is not repinned.

Every symbol this suite depends on is undefined in the currently pinned analytics revision: personalDynFloorFromDailySummaries, personalDynFloorMinDays, dailyDynSummary, the personalDynFloorG named parameter, DailyStepEstimate.activeMinutes, and MotionMinute's 6th positional argument (line 31: 5 expected, 6 given). This fails the whole test job, not just this file, so nothing else in the PR is being verified by CI.

Repin the openstrap_analytics dependency to the revision containing the personal-floor API before merging (the PR notes this is pending the analytics merge). If the merge is not imminent, gate the suite so CI stays green in the meantime.

🧰 Tools
🪛 GitHub Actions: test / 0_test.txt

[error] 31-31: flutter analyze: Too many positional arguments: 5 expected, but 6 found. (extra_positional_arguments)

🪛 GitHub Actions: test / test

[error] 31-31: Too many positional arguments: 5 expected, but 6 found when calling a function. (extra_positional_arguments)

🤖 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/step_personal_floor_test.dart` around lines 22 - 31, The test depends on
analytics APIs absent from the pinned openstrap_analytics revision. Repin
openstrap_analytics to the revision containing
personalDynFloorFromDailySummaries, personalDynFloorMinDays, dailyDynSummary,
personalDynFloorG, DailyStepEstimate.activeMinutes, and the six-argument
MotionMinute constructor; if unavailable immediately, gate or disable this suite
so flutter analyze and CI remain green.

Source: Pipeline failures

Edge CI was failing to COMPILE, and the failure is the whole reason the
v48 note existed:

  error • The function 'personalDynFloorFromDailySummaries' isn't defined
  error • The named parameter 'personalDynFloorG' isn't defined
  error • The getter 'activeMinutes' isn't defined for DailyStepEstimate

lib/compute/ calls analytics API that only exists on the sibling PR, but
pubspec.yaml still pointed at the pre-fix SHA. It compiled locally only
because the gitignored pubspec_overrides.yaml redirects to ../analytics —
so the green local run was green-by-omission, and CI was the only thing
telling the truth.

This is the same drift class as v43, caught in its loud form: a missing
symbol rather than a silently different number. The v43 version of this
shipped a readiness fix that the pinned SHA never contained and stayed
broken for three releases.

Both pins now point at the sibling PR heads — still immutable commit SHAs,
never floating refs:
  protocol  5675b2f  (OpenStrap/protocol#19)
  analytics 8b1aa4e  (OpenStrap/analytics#30)

Verified, rather than assumed, that those SHAs contain what the changelog
claims: `git show 8b1aa4e:lib/src/onehz/motion/steps.dart` carries the new
step API, rr_correction.dart has the signed-dRR `seg.add(x[k])`, and
`git show 5675b2f:lib/src/live.dart` has kKnownRecordVersions.

Also corrected the kAlgoVersion changelog. It still said the sibling fixes
were NOT pinned, which stopped being true with this commit — a changelog
that misdescribes what is in the build is the exact failure being guarded
against, in either direction.

pubspec.lock now records the real git provenance (resolved-ref) instead of
the local path: entries a previous `pub get` wrote while the overrides file
was present.

Reproduced CI locally to confirm: with pubspec_overrides.yaml moved aside,
`flutter pub get` resolves ref == resolved-ref == 8b1aa4e, `flutter analyze`
is clean, and the suite is 914 passing.

ON MERGE: move both pins to the resulting main commits and bump
kAlgoVersion again — a PR-branch SHA can become unreachable if the branch
is deleted after a squash merge.
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9f53dd9

OpenStrap/protocol#19 and OpenStrap/analytics#30 are merged, so pubspec.yaml
moves off the PR-branch heads v49 pointed at and onto the resulting main
commits:

  protocol  5675b2f -> a98cd70
  analytics 8b1aa4e -> f5ccae6

A PR-branch SHA stops being the canonical location of a change once it
merges, and can be orphaned outright if the branch is later deleted.

kAlgoVersion 49 -> 50 even though no edge source line changed alongside it.
The constant identifies the code that PRODUCED a day_result, and that code
includes the pinned siblings — a device holding v49 rows built against the
PR-branch SHAs must re-derive rather than serve them as equivalent. Treating
"same content, different commit" as beneath a bump is the assumption that
lets a stale bundle outlive a dependency change.

Verified at the merge commits themselves rather than inferred from the PRs
being green — the check v43 skipped:
  analytics f5ccae6  steps.dart has the new step API (9 symbol hits)
                     rr_correction.dart:233 is `seg.add(x[k])` (signed dRR)
                     advanced_stager.dart has maxAccelCarryForwardSec
  protocol  a98cd70  live.dart has kKnownRecordVersions

Reproduced CI locally with pubspec_overrides.yaml moved aside:
resolved-ref matches both pins exactly, flutter analyze clean, 914 passing.
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit b014c52

@abdulsaheel
abdulsaheel merged commit 5160bc0 into main Jul 26, 2026
3 checks passed
@abdulsaheel
abdulsaheel deleted the fix/issues branch July 26, 2026 14:19
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