Repository navigation
Description
NOOP version
9.3.1
Platform
iOS
OS version
26.6
WHOOP strap model
Not sure
Where the data is coming from
Live Bluetooth (strap connected)
Test Centre profile
Not a test-mode bug
What happens
Affects main. Verified present at main@ce466dde:
| what | main anchor |
|---|---|
heal reads under computedId |
Strand/Data/IntelligenceEngine.swift:1617 (delete at :1624) |
| Kotlin twin heal | android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt:1360 |
| ring hypnogram banked under the ring's id | Strand/BLE/OuraLiveSource.swift:138 |
codes laid backward from the 0x49 end |
Strand/BLE/OuraLiveSource.swift:591 (persistHypnogramBurst), clamp at :636 |
Observed on: iPhone build of integration/oura-full@7bfc7a07, app 9.3.1, Oura Gen3 (oura-2H3B…),
night of 2026-08-10 → 08-11 (noop-backup-20260811-055100.noopbak).
Severity: user-visible. Sleep → Naps reads "NAP(S) 43 h 52 m · TOTAL 52 h 32 m" for one night.
What the user sees
Sleep → Daytime Sleep lists nine overlapping "naps" for a single night, several of them 8 h 40 m long and
all starting within 43 minutes of each other:
MAIN SLEEP 8h40m NAP(S) 43h52m TOTAL 52h32m
22:09-06:41 8H32M 22:09-02:17 4H8M 22:17-02:37 4H20M
22:32-07:12 8H40M 22:32-02:52 4H20M 22:50-07:30 8H40M
22:52-03:12 4H20M 01:51-02:17 26M 02:26-02:52 26M
report.txt states it plainly: sleep day=2026-08-11 totalSleepMin=186 … eff=0.36 matched=9.
Every other night in the same log reads matched=1…matched=3.
What is in the database
14 rows in sleepSession overlap the night, carrying only 5 distinct hypnograms. Four rows share one
byte-identical 520-minute stage list; four more share a 260-minute one:
| deviceId | start (local) | end | min | eff | shape |
|---|---|---|---|---|---|
oura-2H3B… |
22:09:18 | 06:41:18 | 512 | 0.349 | A |
oura-2H3B… |
22:09:40 | 22:49:40 | 40 | 0.125 | B |
my-whoop-noop |
22:09:40 | 22:49:40 | 40 | 0.125 | B |
oura-2H3B… |
22:09:47 | 02:17:47 | 248 | 0.046 | C |
oura-2H3B… |
22:16:47 | 06:56:47 | 520 | 0.358 | D ← the one the UI shows |
oura-2H3B… |
22:17:46 | 02:37:46 | 260 | 0.044 | C |
oura-2H3B… |
22:23:40 | 22:49:40 | 26 | 0.192 | B |
oura-2H3B… |
22:32:02 | 07:12:02 | 520 | 0.358 | D |
my-whoop-noop |
22:32:02 | 07:12:02 | 520 | 0.358 | D |
oura-2H3B… |
22:32:46 | 02:52:46 | 260 | 0.044 | C |
oura-2H3B… |
22:50:49 | 07:30:49 | 520 | 0.358 | D |
oura-2H3B… |
22:52:47 | 03:12:47 | 260 | 0.044 | C |
oura-2H3B… |
01:51:47 | 02:17:47 | 26 | 0.058 | E |
oura-2H3B… |
02:26:46 | 02:52:46 | 26 | 0.058 | E |
Shape D's copies are the same block list translated by +915 s and +1127 s; shape C's by
+479 / +900 / +1201 s. Those are inter-drain intervals, not clock drift.
It reaches the raw event stream too: OURA_SLEEP_PHASE runs at 523 epochs/hour on this night against
106–166 on every other night in the DB — one row per 6 s instead of per 30 s, because the shifted copies
land on different ts and so never collide on the (deviceId, ts, kind) primary key.
Root cause
Two independent facts combine.
1. Every re-decode invents a new start, and a new start means a new row. sleepSession's primary key
is (deviceId, startTs). persistHypnogramBurst lays the codes backward from the burst's anchored
end, and that end comes from the matching 0x49 window — which on this night reported (event-561/-0 min),
i.e. "sleep ends now", while the wearer was still in bed. So the end moves with each drain, the whole
axis translates with it, and the upsert lands a second row instead of replacing the first. The 0x5A
envelope ring-times are the analysis write moment (consecutive across a burst — d5, d6, d7…, 20 ticks),
not epoch times, so each re-analysis lands past the resume cursor and is re-served. ⇒ one new row per
overnight drain.
2. The heal that exists for exactly this cannot see those rows. IntelligenceEngine.swift:1617:
let storedSessions = (try? await store.sleepSessions(deviceId: computedId, from: windowStart,
to: now, limit: 4000)) ?? []
…
let healDropped = SleepSessionDedup.dedupe(healable, freshStarts: keptStarts).dropped
for stale in healDropped {
_ = try? await store.deleteSleepSession(deviceId: computedId, startTs: stale.startTs)
}computedId = deviceId + "-noop" = my-whoop-noop. But the ring-provided hypnogram is banked under
the ring's own id — OuraLiveSource.swift:138, "upsert the ring-PROVIDED reconstructed hypnogram as a
CachedSleepSession (banked under the ring's own deviceId …)" — so oura-2H3B2405003655 is never read
and never deleted from. Row counts in this DB: my-whoop-noop 18, oura-2H3B2405003655 34.
The heal did run and did work, on the wrong device:
Dedup(#899): removed 15 overlapping duplicate sleep session(s) re-banked under a shifted strap timebase
— and the very next re-score still reported matched=9.
Not new, and not specific to one build
All 34 ring-banked sleepSession rows in this DB, by wake date:
| wake date | rows |
|---|---|
| 2026-07-29 | 3 |
| 2026-07-31 | 5 — all start 07-30 23:03; ends 00:04 / 03:14 / 03:14 / 04:54 / 08:56 |
| 2026-08-01 | 2 |
| 2026-08-06 | 2 |
| 2026-08-09 | 2 |
| 2026-08-10 | 3 |
| 2026-08-11 | 10 |
📌 The shape changed between July and August, which is a useful fingerprint: on 07-31 the start is
FIXED and the end grows (codes laid forward from a stable onset); on 08-11 the duration is fixed at
520 min and the start slides (laid backward from a moving 0x49 end). Consistent with the 0x49
start-clamp (OuraLiveSource.swift:636) landing in between — worth checking that the clamp did not turn a
bounded duplication into an unbounded one.
Proposed fix
Run the same overlap dedup over every deviceId that banks sleep for the day, not just computedId.
The obvious shape: resolve the day's owner (IntelligenceEngine.resolveDayOwner, :1724, already returns
readId=oura-2H3B2405003655 for these days) and heal {computedId, dayOwnerId}.
Worth deciding at the same time — these are why the duplicates keep being generated; dedup only cleans
up after them:
- Should
persistHypnogramBurstquantise the start (the codes are on a 30 s grid; the starts differ
by odd second counts), or key the session on the ring's0x49window rather than the reconstructed
start? The0x49window was stable all night at22:09:49 → 07:30:49, so it is a better identity than
a backward-laid start. - Should a re-decode of an already-banked burst persist at all?
IntelligenceEngine.kt:1360 carries the twin heal and needs the same scope change
in the same PR.
them, or affected users keep seeing 43 h of naps.
Reproduce
unzip -oq noop-backup-20260811-055100.noopbak -d /tmp/db
sqlite3 -header -column /tmp/db/noop-backup.sqlite "
SELECT deviceId, datetime(startTs,'unixepoch','+2 hours') start_loc,
ROUND((endTs-startTs)/60.0) min, ROUND(efficiency,3) eff, length(stagesJSON) len
FROM sleepSession WHERE endTs>1786390000 AND startTs<1786440000 ORDER BY startTs;"Related
The same night also renders 36 % efficiency / 3 h 06 m asleep against WHOOP's 8 h 26 m. That is a
separate defect — 0xFF hypnogram padding decoded as awake — filed as #1246. The two are
independent: the 201.5-minute wake block is byte-identical in all five stored copies.
What you expected instead
see above
Steps to reproduce
- see above
Strap log
Before you submit
- I attached my exported NOOP .zip (not a screenshot of the Live screen). If my bug is a clearly reproducible UI issue I described exactly how to see it instead.
- I attached or pasted a strap log above — or my bug is a clearly reproducible UI issue that doesn't need one (e.g. a typo, a button that does nothing) and I've described exactly how to see it.
- I checked the Troubleshooting guide — especially "history syncs but no data" (most often the strap isn't banking history to flash; charge it to 100% and reconnect).
- I checked the FAQ and — if I'm on a WHOOP 5.0 / MG — the 5.0/MG status page, and this isn't an already-known limit (e.g. SpO₂, or recovery/sleep on the 5/MG).
- I've left out anything personal (strap serial, account email, raw export).
Activity
fix(oura): heal banked-sleep duplicates across every device, not just…
fix(oura): decode unwritten 0xFF hypnogram pages as a gap, not awake (#…
fix(oura): decode unwritten 0xFF hypnogram pages as a gap, not awake (#…
pipiche38 commentedon Aug 12, 2026
Hardware-validated on the night of 2026-08-11/12 — first build carrying
18fc6563(91812d93).sleepSessionrows overlapping the night, ring idreport.txtsleep day=… matched=OURA_SLEEP_PHASEepochs/hourIt also healed retroactively, better than the PR's own caveat suggested. The 08-10/11 night — the one
this issue was filed on — collapsed from 14 rows to 2 in the same backup, so that day was inside the
[oldestDay, newestDay]reconcile window after all. Row counts by start date now show no date above 2 oneither device id.
Generation is still untouched, exactly as the PR says. One extra row was minted this night:
The PK is still
(deviceId, startTs)andpersistHypnogramBurststill lays codes backward from a moving0x49end, so an earlier drain banks a short session before the full burst arrives and the two coexist.14 → 2 is a big improvement; 2 → 1 needs the generation side. The nested row sits wholly inside the main
session and will render as a phantom nap.
📌
0x49remains the better session identity — it was stable and correct on both nights(
22:09:49 → 07:30:49, then22:26:14 → 07:06:14, the latter matching WHOOP to 1–2 minutes). Quantisingthe start, or keying the session on the
0x49window, would close the generation half. Filed separatelyas <NEW ISSUE #> so this one can close.
One small thing while in the area:
healDeviceIdslogs nothing. It demonstrably collapsed 14 rows → 2in this backup, but there is no
Dedup(#899): removed N …line anywhere in the strap log, so from the logalone a working heal is indistinguishable from one that never ran. One line would have saved a diagnosis.
fix(oura): heal banked-sleep duplicates across every device, not just…
health-connect: retract sleep records the export no longer produces (#…
fix(sleep): hand the ryanbr#899 heal's bank-recency witness to the co…
fix(sleep): hand the ryanbr#899 heal's bank-recency witness to the co…
sleep: hand the #899 heal's bank-recency witness to the computed id o…
fix(android): mirror the heal and stage writes into the core's input …