Skip to content

Alarm readback: refuse to judge one that answered an earlier arm - #2322

Merged
ryanbr merged 1 commit into
mainfrom
fix/alarm-readback-stale
Sep 18, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix/alarm-readback-stale

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 18, 2026

Copy link
Copy Markdown
Owner

#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.

The gap

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 attached to #2302 carries one on a neighbouring frame:

Strap backlog pages behind: not decodable from this frame (#689 ...)

That verdict is not cosmetic on Apple: it drives alarm.rejectStreak, which raises a warning in SmartAlarmView at 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.lastArmAt and 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 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 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 Last arm: for <alarm time> · sent 14m ago.
  • The readback line now carries when it landed, so both halves show their provenance rather than the reader assuming they belong together.

Verification

  • Swift swift test --filter AlarmReadback: 12 tests, 0 failures.
  • Kotlin AlarmReadbackTest: 12 tests, 0 failures, XML mtime checked against the clock.
  • Full Android suite --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.
  • compileFullDebugKotlin clean, doc_comment_lint.py clean.
  • Mutation: deleting the staleness line turns exactly one test red (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.

…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
ryanbr force-pushed the fix/alarm-readback-stale branch from 4a257e8 to 8168234 Compare September 18, 2026 22:11
@ryanbr
ryanbr merged commit 40e63cf into main Sep 18, 2026
16 checks passed
@ryanbr
ryanbr deleted the fix/alarm-readback-stale branch September 18, 2026 22:25
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant