- Sick days screen (own name, sick icon, cat-2 fill): tap today or a later
day to mark it sick, tap again to clear; past days inert. Writes the
sickdays wire format (state sick/cleared + actor, one HLC per write) to
sickdays-sync/devices via crdt_sync syncLog.
- Mutual exclusion in both pickers: each syncs the other pool on open and
refuses a date it holds, naming the pool; an unreadable pool refuses all.
- Native SickDays cache (fail closed via DayCache); AlarmReceiver skips the
ring with reason "sick_day" (no markFired, no ring_started); StickCheck
verdict sick_day, streak skips sick days; stakes card says so.
- Pre-alarm pool sync: allow-while-idle alarm at 21:00 and A-30 (request
code 1006) runs a headless engine (poolSyncMain) that pulls both pools
from RTDB and rewrites both native caches, so a PC mark arrives with the
app closed. The ring decision stays a cache read.
- JVM unit tests (JUnit 4.13.2) for DayCache, RingDecision,
PoolSyncSchedule, StickCheck; Dart tests; Dart->Python interop check.
pylint hook skipped: verify.py imports sickdays, not in the hook env until
the sickdays-v0.1.0 tag exists; run by hand in a sickdays venv (10.00/10).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
The dismiss gate was an OR — NFC tag *or* QR *or* (25 steps + 2 min) —
followed by one challenge. QR never worked at ring time (no scanner
package, no CAMERA permission, `onQrScanned` had no caller), so the real
bar was "walk 25 steps, wait 2 minutes, answer one question" and the NFC
tag was already fully bypassable.
It is now a strict, latched sequence owned by `DismissGate`:
tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges
Every stage is required, in that order, and a stage once passed never
reverts. That last part also fixes a real bug: `_onNfcTag` and `_trySteps`
both assigned the room-proof result straight onto `_inRoom`, so a wrong
tag tapped after the gate had passed sent the user back to the start.
Two *distinct* tags are required; the same id in both slots is refused at
registration, since one tag satisfying both stages removes the walk that
is the whole point. The existing single-tag registration migrates into
slot 1 rather than being stranded.
Answers are cumulative, not consecutive: a wrong one costs the attempt and
nothing else.
The bypass is the only fallback and is deliberately narrow. After 5 minutes
of ringing, or immediately when NFC is off or fewer than two distinct tags
are registered, the two *tag* stages are skipped and the count rises to 6.
The walk is never skipped — jumping straight to the challenges would let
someone lie in bed for five minutes and answer questions without standing
up, which is weaker than the gate this replaces. Without some such hatch a
dead tag at 06:00 leaves an alarm that cannot be switched off at all.
Also deletes the `flash` challenge outright, along with the now-orphaned
`generateCode`/`kDismissCodeLength`/`_dismissChars`. It was the only kind
that removed its prompt from the screen, which is unsolvable for someone
who has just walked in from another room.
The ring screen takes an injectable clock: `Stopwatch` reads the wall
clock, which `tester.pump()` does not advance, so without that seam no
widget test could reach the two-minute floor or the five-minute bypass.
Verified on the Pixel 6a: the existing tag migrated into slot 1 and both
rows render. 102 Dart tests pass; analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
Measured on the Pixel via the muted-stream mask in `dumpsys audio`:
STREAM_ALARM is unmuted under priority DND (zen_mode=1, mask 0x1a6) and
MUTED under total silence (zen_mode=2, mask 0x9be). The verified ring was
priority DND, so "STREAM_ALARM ignores DND" was true but overstated — no
audio flag escapes total silence, and the fix if that gap ever matters is a
zen-policy check at arm time, not an audio change.
Also records that raiseAlarmVolume() genuinely runs on this hardware
(observed raising oldIndex:0 -> 7, no SecurityException), so it recovers a
stream turned down to zero but not one muted by total silence.
Two probe-harness traps kept: --build-number produces a non-running APK, and
the arm probe can register for TOMORROW because main.dart's rearm()
recomputes nextOccurrence after the armed minute has passed.
Folded in from scratch notes left mid-session (.dnd-findings/,
TODO-verify-dnd-ring.md), both now deleted — the verification they asked for
has since been done.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
The gate runs --all-files at push time, so it caught two files this session
pushed over: AGENTS.md (244 -> 330) and home_screen.dart (238 -> 269). Both
are mine; the pre-existing Python overages are untouched, per the standing
rule not to split a file this change does not already have to edit.
- The phone section moves to DOCS-phone-alarm.md, following the repo's own
pattern (DOCS-architecture.md, DOCS-vmbox.md), leaving a pointer.
- Alarm arming moves out of home_screen.dart into AlarmArming — "what it
takes to arm an alarm" was never the picker's concern, and the extraction
also repaired a doc comment my earlier edit had orphaned onto the wrong
method.
flutter test: 69 pass. dart analyze lib/ test/: clean. APK builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh