Repository navigation
Alarm readback: refuse to judge one that answered an earlier arm - #2322
Merged
Merged
Conversation
…r arm #1706 taught AlarmReadback to refuse a verdict unless both halves came from the same STRAP. They can also come from different ARMS, and it still judges those. `alarm.lastReportedEpoch` is written ONLY when the readback frame decodes. The decode-failure branch logs raw hex and leaves the previous value standing. So an arm whose readback never decoded leaves a FRESH sent epoch beside a STALE reported one, and the debug export prints a confident "strap didn't accept the time" assembled from two different arms. Decode failures are not hypothetical: the log on #2302 carries one on a neighbouring frame. Adds a fifth verdict for it, alongside the two that already decline to judge. It is ordered AFTER strap attribution on purpose: a pair that is both cross-strap and stale should still name the strap problem, because that is the one that sends the reader to the right device. Like its neighbours it neither advances nor clears alarm.rejectStreak, which raises a UI warning at two on Apple. The evidence was already on disk. Both halves have recorded their arrival time since #1706 (alarm.lastArmAt, alarm.lastReportedAt) and nothing ever read them; comparing the two is the whole guard. Only their ORDER is used, never their difference, so each platform can keep its own unit (millis on Android, seconds on Apple). Absent stamps mean an install predating the keys, and staleness is then not judged rather than guessed, so those installs behave exactly as before. Two reporting changes in the same block, both of which misled me while reading #2302: - "Last arm: sent <alarm time> · 14m ago" put two different clocks on one line and reads as "we sent that time, that long ago". The stamp is the FUTURE instant armed; the relative time is when the command went out. Now "for <alarm time> · sent 14m ago". - The readback line now carries when it landed, so both halves show their provenance instead of the reader having to assume they belong together. Both platforms, since AlarmReadback is a declared twin. The new parameters are appended AFTER toleranceS rather than before it. Kotlin permits positional calls, so inserting them earlier would silently rebind a `verdict(a, b, c, d, 300)` written against the old signature from a tolerance to a timestamp, with no compiler complaint and a plausible-looking verdict. No caller does that today; the ordering is what stops one from doing it later. Swift matches so the two signatures read the same, though labels make it moot there. Markers in the source cite this change, not #2302. That issue is about HRV, and the readback was an incidental observation in a log attached to it, so a reader following it from AlarmReadback would land somewhere that never mentions alarms. One pointer to it survives per twin, where it names the evidence and says so.
ryanbr
force-pushed
the
fix/alarm-readback-stale
branch
from
September 18, 2026 22:11
4a257e8 to
8168234
Compare
ryanbr
added a commit
that referenced
this pull request
Sep 19, 2026
…product commits
`parity_ratchet.py` has failed on clean main since the twin map was last written:
ERROR: Tools/parity_twin_map.json: base authority cannot be reproduced with the
current derivation; migration required (see --migrate-authority)
The message points at the derivation. Measured, the derivation is innocent: running
the CURRENT scanner against the tree the map was written from (b6e4e3e) reproduces
the stored manifest EXACTLY, +0 across all twelve sets. So the two scanner changes
that landed since (#2259 stopped the scan walking into build output, #2267 stopped
an unparseable argument erasing a callsite) alter what the scanner would find in
general, but change nothing this repository actually contains.
What moved is the TREE. Three in-scope commits landed after the map: #2322
(AlarmReadback), #2307 (the Oura auth handshake) and #2260 (the WHOOP sports).
Re-derived with the guarded workflow, `--refresh-derived --migrate-authority`, which
writes the candidates, re-runs the ratchet against them, and restores the originals
unless it accepts. Migration waives only the stored manifest's reproducibility, never
a semantic finding, so undeclared one-sided debt would still have failed here.
Every count now reconciles against those commits rather than being asserted:
files 500 -> 502 the two new OuraAuthWatchdog files (#2307)
file_pairs 66 -> 67 OuraDriver is now resolvable as a twin pair
functions 4449 -> 4453 +1 pair (2) + 2 unpaired = 4
function_pairs 173 -> 174
unpaired_funcs 4109 -> 4111
constants 1943 -> 1945 both unpaired
unpaired_consts 591 -> 593
unpaired_files 386 -> 386 count held, membership changed
That last one is the interesting row. OuraDriver.swift and OuraDriver.kt LEFT the
unpaired set (hence the extra file pair) while OuraAuthWatchdog.swift and
OuraAuthWatchdog.kt JOINED it: #2307 added a twin on both platforms without a
declared twin reference, so the ledger does not yet know the two mirror each other.
Not debt by the scanner's own reckoning, and left as-is here rather than bundled
into a re-derivation, but worth a look.
`parity_ledger_baseline.json` is untouched. The 300 known findings are identical, so
nothing was added or forgiven.
This is the drift mode parity-governance.yml's own header documents, including that
it has landed on an outside contributor's PR three times. The daily schedule on main
exists to catch it; this is the maintainer action it asks for.
ryanbr
added a commit
that referenced
this pull request
Sep 19, 2026
… commits (#2325) chore(parity): re-derive the twin-map authority after three in-scope product commits `parity_ratchet.py` has failed on clean main since the twin map was last written: ERROR: Tools/parity_twin_map.json: base authority cannot be reproduced with the current derivation; migration required (see --migrate-authority) The message points at the derivation. Measured, the derivation is innocent: running the CURRENT scanner against the tree the map was written from (b6e4e3e) reproduces the stored manifest EXACTLY, +0 across all twelve sets. So the two scanner changes that landed since (#2259 stopped the scan walking into build output, #2267 stopped an unparseable argument erasing a callsite) alter what the scanner would find in general, but change nothing this repository actually contains. What moved is the TREE. Three in-scope commits landed after the map: #2322 (AlarmReadback), #2307 (the Oura auth handshake) and #2260 (the WHOOP sports). Re-derived with the guarded workflow, `--refresh-derived --migrate-authority`, which writes the candidates, re-runs the ratchet against them, and restores the originals unless it accepts. Migration waives only the stored manifest's reproducibility, never a semantic finding, so undeclared one-sided debt would still have failed here. Every count now reconciles against those commits rather than being asserted: files 500 -> 502 the two new OuraAuthWatchdog files (#2307) file_pairs 66 -> 67 OuraDriver is now resolvable as a twin pair functions 4449 -> 4453 +1 pair (2) + 2 unpaired = 4 function_pairs 173 -> 174 unpaired_funcs 4109 -> 4111 constants 1943 -> 1945 both unpaired unpaired_consts 591 -> 593 unpaired_files 386 -> 386 count held, membership changed That last one is the interesting row. OuraDriver.swift and OuraDriver.kt LEFT the unpaired set (hence the extra file pair) while OuraAuthWatchdog.swift and OuraAuthWatchdog.kt JOINED it: #2307 added a twin on both platforms without a declared twin reference, so the ledger does not yet know the two mirror each other. Not debt by the scanner's own reckoning, and left as-is here rather than bundled into a re-derivation, but worth a look. `parity_ledger_baseline.json` is untouched. The 300 known findings are identical, so nothing was added or forgiven. This is the drift mode parity-governance.yml's own header documents, including that it has landed on an outside contributor's PR three times. The daily schedule on main exists to catch it; this is the maintainer action it asks for.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#1706 taught
AlarmReadbackto refuse a verdict unless both halves came from the same strap. They can also come from different arms, and it still judges those.The gap
alarm.lastReportedEpochis written ONLY when the readback frame decodes. The decode-failure branch logs raw hex and leaves the previous value standing. So an arm whose readback never decoded leaves a FRESH sent epoch beside a STALE reported one, and the debug export prints a confident "strap didn't accept the time" assembled from two different arms.Decode failures are not hypothetical. The log attached to #2302 carries one on a neighbouring frame:
That verdict is not cosmetic on Apple: it drives
alarm.rejectStreak, which raises a warning inSmartAlarmViewat two.The change
A fifth verdict,
staleReadback/STALE_READBACK, alongside the two that already decline to judge.Ordered after strap attribution on purpose: a pair that is both cross-strap and stale should still name the strap problem, because that is the one that sends the reader to the right device. Like its neighbours it neither advances nor clears the streak.
The evidence was already on disk. Both halves have recorded their arrival time since #1706, in
alarm.lastArmAtandalarm.lastReportedAt, and nothing ever read them. Comparing the two is the whole guard. Only their ORDER is used, never their difference, so each platform keeps its own unit (millis on Android, seconds on Apple) and neither has to convert. Absent stamps mean an install predating the keys, and staleness is then not judged rather than guessed, so those installs behave exactly as before.Two reporting changes in the same block
Both of these misled me while reading #2302, which is how the PR started.
Last arm: sent <alarm time> · 14m agoput two different clocks on one line and reads as "we sent that time, that long ago". The stamp is the FUTURE instant armed; the relative time is when the command went out. NowLast arm: for <alarm time> · sent 14m ago.Verification
swift test --filter AlarmReadback: 12 tests, 0 failures.AlarmReadbackTest: 12 tests, 0 failures, XML mtime checked against the clock.--no-build-cache --rerun-tasks: 733 classes, 6264 tests, 0 failures. Main's baseline is 733/6259, so the delta is exactly the five new Kotlin cases.compileFullDebugKotlinclean,doc_comment_lint.pyclean.aReadbackOlderThanTheArmIsNotComparable) and leaves the other eleven green, including the cross-strap ordering case, which exercises the branch ahead of it.Not covered locally: the two app-target Swift call sites (
FrameRouter,DebugDataDiagnostics) only compile on the macOS leg, so CI is the first thing that type-checks them.What it does NOT claim about #2302
That reporter's readback decodes cleanly to a round local 07:00, against an arm of 06:50 that NOOP made deliberately (the smart window's start, via
earliestStrapAlarmEpochSec). So their strap may genuinely be holding a different time. This PR does not decide that; it makes the export show which arm the readback answered, so the question can be settled instead of assumed.