From e8a7afa8845e259f812d04c1a29a703c362f940f Mon Sep 17 00:00:00 2001 From: Bucci <149576997+BucciMobile@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:30:53 +0100 Subject: [PATCH 1/9] .coderabbit.yaml aktualisieren --- .coderabbit.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 770ba8d19..849ea7d60 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -25,3 +25,5 @@ reviews: # and they are the least likely place for a shipped bug. Re-include them # for a PR that is mostly test work. - "!test/**" + auto_review: + drafts: true \ No newline at end of file From 3796d7e49022de329c3afd8f4f891a63fe38b8ee Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 5 Nov 2026 21:32:04 +0000 Subject: [PATCH 2/9] feat: carry gen5 v18 SpO2 candidate byte as raw diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sample gains nullable spo2CandidateRaw (protocol inner[74]), mapped in sampleFromGen5Historical and logged nonzero in the gen5 history drain. Raw passthrough only — never stored as or displayed as a percentage. Co-authored-by: BucciMobile --- lib/ble/ble_engine.dart | 11 ++++++++ lib/data/models.dart | 13 ++++++++++ test/gen5_spo2_candidate_test.dart | 41 ++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 test/gen5_spo2_candidate_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 3d7f07476..3a5e990e6 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -307,6 +307,10 @@ Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { // enum: a stored name freezes a meaning, a stored 2-bit code does not. // Corroboration only, never a stage — see Sample.bandSleepState. bandSleepState: g.sleepStateRawNibble, + // Raw candidate byte, never a percentage — the encoding is not pinned and + // no ground truth exists, so this is carried for diagnostics only and is + // not persisted (see Sample.spo2CandidateRaw). + spo2CandidateRaw: g.spo2CandidateRaw, ); } @@ -5778,6 +5782,13 @@ class BleEngine { // about a vessel: beat-detection quality, motion-artifact rejection, // corroborating the band's own RR intervals. build those, not a number. sample = decodeGen5HistoricalSample(frame.inner); + // Diagnostic only: the gen5 SpO2 candidate byte is raw and unvalidated + // (nonzero only inside band-declared sleep bursts), so it is logged, + // never stored or displayed as a percentage. + final spo2Cand = sample?.spo2CandidateRaw ?? 0; + if (spo2Cand != 0) { + _log('[SPO2] gen5 candidate raw=$spo2Cand sleep=${sample?.bandSleepState} (not a validated %)'); + } } else if (recType != Record.r25 && kKnownRecordVersions.contains(recType)) { // Every gen4 layout version protocol has a field map for — not just diff --git a/lib/data/models.dart b/lib/data/models.dart index 95f7f1a46..558e2cfcb 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -152,6 +152,17 @@ class Sample { /// (analytics `band_offset.dart`) — never to create, extend or stage one. final int? bandSleepState; + /// The gen5 v18 SpO2 candidate byte (protocol `spo2CandidateRaw`, + /// inner[74]) — GEN5/MG ONLY, raw and unvalidated. + /// + /// Zero in ~99% of records, nonzero only during band-declared sleep + /// (community-observed bursts of 30 s every 1,200 s), encoding not fully + /// pinned (values above 128 decompose as 128 + flag). Carried as the raw + /// byte for capture correlation; NEVER displayed or stored as a + /// percentage — see `Spo2Data` in `models/payloads.dart` for the refusal. + /// Not persisted (no column); diagnostic/log use only. + final int? spo2CandidateRaw; + Sample({ required this.tsEpoch, required this.counter, @@ -177,6 +188,7 @@ class Sample { this.dynAccelG, this.tsSubsec, this.bandSleepState, + this.spo2CandidateRaw, }); /// Copy with an overridden [tsEpoch] — used by the clock-offset salvage path @@ -208,6 +220,7 @@ class Sample { dynAccelG: dynAccelG, tsSubsec: tsSubsec, bandSleepState: bandSleepState, + spo2CandidateRaw: spo2CandidateRaw, ); bool get hasDecodedOneHz => diff --git a/test/gen5_spo2_candidate_test.dart b/test/gen5_spo2_candidate_test.dart new file mode 100644 index 000000000..05b2740a8 --- /dev/null +++ b/test/gen5_spo2_candidate_test.dart @@ -0,0 +1,41 @@ +// The gen5 v18 SpO2 candidate byte (protocol `spo2CandidateRaw`, inner[74]) +// must be carried through `decodeGen5HistoricalSample` as a RAW byte — never +// gated, never converted to a percentage. The byte is unvalidated community +// knowledge (nonzero only in band-declared sleep bursts), so the contract +// under test is passthrough + null on gen4-shaped input, not a value claim. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// A minimal decodable v18 inner: length 112, version byte 18 at inner[1]. +Uint8List v18Inner({int spo2Candidate = 0}) { + final b = Uint8List(kGen5V18InnerLen); + b[1] = 18; + final v = ByteData.sublistView(b); + v.setUint32(7, 1780916150, Endian.little); // unix + b[73] = 2; // sleepStateByte: sleep + b[74] = spo2Candidate; + return b; +} + +void main() { + test('gen5 v18 spo2CandidateRaw is carried through as the raw byte', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97)); + expect(s, isNotNull); + expect(s!.spo2CandidateRaw, 97); + expect(s.bandSleepState, isNotNull); + }); + + test('zero candidate stays zero — absent measurement, not a gated null', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 0)); + expect(s!.spo2CandidateRaw, 0); + }); + + test('flag-style values above 128 are passed through unmasked', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 128 + 97)); + expect(s!.spo2CandidateRaw, 128 + 97); + }); +} From 29b73bf8fccdcef00c2c4719c16b03304ca33a24 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 00:56:54 +0000 Subject: [PATCH 3/9] fix: include record ts/counter in the gen5 SpO2 candidate diagnostic log Co-authored-by: BucciMobile --- lib/ble/ble_engine.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 3a5e990e6..25814b8b2 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -5787,7 +5787,9 @@ class BleEngine { // never stored or displayed as a percentage. final spo2Cand = sample?.spo2CandidateRaw ?? 0; if (spo2Cand != 0) { - _log('[SPO2] gen5 candidate raw=$spo2Cand sleep=${sample?.bandSleepState} (not a validated %)'); + _log('[SPO2] gen5 candidate raw=$spo2Cand ts=${sample?.tsEpoch} ' + 'counter=${sample?.counter} sleep=${sample?.bandSleepState} ' + '(not a validated %)'); } } else if (recType != Record.r25 && kKnownRecordVersions.contains(recType)) { From fb23c04047e77714b33400ddad0b94aa67eac1ce Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 08:43:13 +0000 Subject: [PATCH 4/9] fix: drop the gen5 SpO2 candidate log line entirely FileLog writes to Android external storage (file_log.dart), so a timestamped candidate/sleep line is CWE-532 sensitive-data exposure. The raw byte stays in memory only (Sample.spo2CandidateRaw). Co-authored-by: BucciMobile --- lib/ble/ble_engine.dart | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 25814b8b2..0d6265c5e 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -5782,15 +5782,10 @@ class BleEngine { // about a vessel: beat-detection quality, motion-artifact rejection, // corroborating the band's own RR intervals. build those, not a number. sample = decodeGen5HistoricalSample(frame.inner); - // Diagnostic only: the gen5 SpO2 candidate byte is raw and unvalidated - // (nonzero only inside band-declared sleep bursts), so it is logged, - // never stored or displayed as a percentage. - final spo2Cand = sample?.spo2CandidateRaw ?? 0; - if (spo2Cand != 0) { - _log('[SPO2] gen5 candidate raw=$spo2Cand ts=${sample?.tsEpoch} ' - 'counter=${sample?.counter} sleep=${sample?.bandSleepState} ' - '(not a validated %)'); - } + // The gen5 SpO2 candidate byte stays IN MEMORY ONLY (Sample.spo2CandidateRaw): + // raw and unvalidated, never stored, never displayed as a percentage, and + // NEVER written to _log — FileLog lives in Android external storage, so a + // timestamped candidate/sleep line there would be readable by other apps. } else if (recType != Record.r25 && kKnownRecordVersions.contains(recType)) { // Every gen4 layout version protocol has a field map for — not just From e85d816cd825c91ed4ec3cbaa862486b28db84aa Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 09:48:22 +0000 Subject: [PATCH 5/9] =?UTF-8?q?docs:=20qualify=20the=20SpO2=20candidate=20?= =?UTF-8?q?comment=20=E2=80=94=20raw=5Farchive=20may=20hold=20the=20byte?= =?UTF-8?q?=20in=20undecodable=20frames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- lib/ble/ble_engine.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 0d6265c5e..a14f61d17 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -5782,10 +5782,12 @@ class BleEngine { // about a vessel: beat-detection quality, motion-artifact rejection, // corroborating the band's own RR intervals. build those, not a number. sample = decodeGen5HistoricalSample(frame.inner); - // The gen5 SpO2 candidate byte stays IN MEMORY ONLY (Sample.spo2CandidateRaw): - // raw and unvalidated, never stored, never displayed as a percentage, and - // NEVER written to _log — FileLog lives in Android external storage, so a - // timestamped candidate/sleep line there would be readable by other apps. + // The gen5 SpO2 candidate byte is never written to _log — FileLog lives in + // Android external storage, so a timestamped candidate/sleep line there + // would be readable by other apps (CWE-532). It is not persisted as a + // decoded column either; only whole UNDECODABLE frames reach raw_archive, + // which can contain the byte (kept for the commit-before-ACK invariant). + // In-memory carrier: Sample.spo2CandidateRaw — raw, never a percentage. } else if (recType != Record.r25 && kKnownRecordVersions.contains(recType)) { // Every gen4 layout version protocol has a field map for — not just From f5bbfbb8dc064470ae117b10e849b2385237106e Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 21:58:51 +0000 Subject: [PATCH 6/9] test: pin spo2CandidateRaw preservation through Sample.copyWith (nonzero + null) Co-authored-by: BucciMobile --- test/gen5_spo2_candidate_test.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/gen5_spo2_candidate_test.dart b/test/gen5_spo2_candidate_test.dart index 05b2740a8..f84f476e7 100644 --- a/test/gen5_spo2_candidate_test.dart +++ b/test/gen5_spo2_candidate_test.dart @@ -38,4 +38,18 @@ void main() { final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 128 + 97)); expect(s!.spo2CandidateRaw, 128 + 97); }); + + // copyWith must carry the candidate along — a regression that drops + // spo2CandidateRaw from copyWith would silently lose it on every copy. + test('copyWith preserves a nonzero spo2CandidateRaw', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; + expect(s.copyWith(heartRate: 60).spo2CandidateRaw, 97); + }); + + test('copyWith preserves an explicitly cleared (null) spo2CandidateRaw', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; + final cleared = s.copyWith(spo2CandidateRaw: null); + expect(cleared.spo2CandidateRaw, isNull); + expect(cleared.copyWith(heartRate: 55).spo2CandidateRaw, isNull); + }); } From f72b4d76b5c3baa0b8da31d9bd9dcf6b0bf7b147 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 9 Nov 2026 15:40:53 +0000 Subject: [PATCH 7/9] =?UTF-8?q?test+docs:=20pin=20the=20#472=20seam=20?= =?UTF-8?q?=E2=80=94=20captured=20candidate=20stays=20unread=20(Spo2Data?= =?UTF-8?q?=20refusal=20intact);=20field=20doc=20points=20to=20Spo2Data=20?= =?UTF-8?q?as=20the=20single=20publication=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- lib/data/models.dart | 5 ++++- test/gen5_spo2_candidate_test.dart | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/data/models.dart b/lib/data/models.dart index 558e2cfcb..99fd084c2 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -160,7 +160,10 @@ class Sample { /// pinned (values above 128 decompose as 128 + flag). Carried as the raw /// byte for capture correlation; NEVER displayed or stored as a /// percentage — see `Spo2Data` in `models/payloads.dart` for the refusal. - /// Not persisted (no column); diagnostic/log use only. + /// Not persisted (no column); in-memory diagnostic only, never logged + /// (FileLog is external storage — CWE-532). After the validation gate in + /// docs/proposals/whoop-spo2.md passes, this byte is the raw input that + /// feeds `Spo2Data` — the single publication path. final int? spo2CandidateRaw; Sample({ diff --git a/test/gen5_spo2_candidate_test.dart b/test/gen5_spo2_candidate_test.dart index f84f476e7..81aaf27e3 100644 --- a/test/gen5_spo2_candidate_test.dart +++ b/test/gen5_spo2_candidate_test.dart @@ -8,6 +8,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/models/payloads.dart'; import 'package:openstrap_protocol/openstrap_protocol.dart'; /// A minimal decodable v18 inner: length 112, version byte 18 at inner[1]. @@ -52,4 +53,14 @@ void main() { expect(cleared.spo2CandidateRaw, isNull); expect(cleared.copyWith(heartRate: 55).spo2CandidateRaw, isNull); }); + + // The seam to PR #472: a captured candidate byte must NOT unlock the + // publication path. `Spo2Data` (payloads.dart) stays unread until the + // validation gate passes — capturing is not displaying. + test('captured candidate stays unread: Spo2Data refusal is intact', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; + expect(s.spo2CandidateRaw, 97); + final payload = TodayData.fromJson({'spo2': const {}}); + expect(payload.spo2, isNull, reason: 'no odi_per_hour -> Spo2Data unread'); + }); } From 97ea16483c6b6f9c5b174380ca8433c51fa0850b Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 9 Nov 2026 20:07:09 +0000 Subject: [PATCH 8/9] docs: point to the validation-gate proposal (PR #17 / upstream #675) from the spo2CandidateRaw field doc Co-authored-by: BucciMobile --- lib/data/models.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/data/models.dart b/lib/data/models.dart index 99fd084c2..6b127b382 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -163,7 +163,9 @@ class Sample { /// Not persisted (no column); in-memory diagnostic only, never logged /// (FileLog is external storage — CWE-532). After the validation gate in /// docs/proposals/whoop-spo2.md passes, this byte is the raw input that - /// feeds `Spo2Data` — the single publication path. + /// feeds `Spo2Data` — the single publication path. The validation gate this + /// refers to lives in docs/proposals/whoop-spo2.md, added by PR #17 + /// (upstream OpenStrap/edge#675) — not part of this branch. final int? spo2CandidateRaw; Sample({ From f1c5110f5f28b9cd14fb2d5550a8eb8661e382a3 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 10 Nov 2026 04:36:09 +0000 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20copyWith=20tests=20use=20the=20real?= =?UTF-8?q?=20signature=20(tsEpoch=20only)=20=E2=80=94=20Sample=20has=20no?= =?UTF-8?q?=20heartRate/spo2CandidateRaw=20override=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- test/gen5_spo2_candidate_test.dart | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test/gen5_spo2_candidate_test.dart b/test/gen5_spo2_candidate_test.dart index 81aaf27e3..4f7311017 100644 --- a/test/gen5_spo2_candidate_test.dart +++ b/test/gen5_spo2_candidate_test.dart @@ -8,6 +8,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/data/models.dart'; import 'package:openstrap_edge/models/payloads.dart'; import 'package:openstrap_protocol/openstrap_protocol.dart'; @@ -42,16 +43,17 @@ void main() { // copyWith must carry the candidate along — a regression that drops // spo2CandidateRaw from copyWith would silently lose it on every copy. + // (copyWith only overrides tsEpoch; the field must pass through unchanged.) test('copyWith preserves a nonzero spo2CandidateRaw', () { final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; - expect(s.copyWith(heartRate: 60).spo2CandidateRaw, 97); + expect(s.copyWith(tsEpoch: s.tsEpoch + 1).spo2CandidateRaw, 97); }); - test('copyWith preserves an explicitly cleared (null) spo2CandidateRaw', () { - final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; - final cleared = s.copyWith(spo2CandidateRaw: null); - expect(cleared.spo2CandidateRaw, isNull); - expect(cleared.copyWith(heartRate: 55).spo2CandidateRaw, isNull); + test('copyWith preserves an absent (null) spo2CandidateRaw', () { + // A gen4-shaped sample carries no candidate byte at all. + final s = Sample(tsEpoch: 1000, counter: 0, hr: 60); + expect(s.spo2CandidateRaw, isNull); + expect(s.copyWith(tsEpoch: 1001).spo2CandidateRaw, isNull); }); // The seam to PR #472: a captured candidate byte must NOT unlock the