wake-alarm/DOCS-wake-enforcement.md
2026-10-16 13:26:09 +02:00

209 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Wake enforcement v2 — PC fallback alarm, immediate stick, escape hatch
Agreed with kuhy on 2026-10-01. This file is the **contract** between the
phone app, the PC package, focus-owner (`~/src/phone-focus-mode`) and the
daily audit. Change a name here and in every reader in the same commit.
## Why
The carrot (`DOCS-morning-session-pc.md`) pulls only on days the PC is used
in the morning — Fri–Mon. On Tue/Wed/Thu kuhy leaves for work, so the only
lever is something that bites *today*. Two holes made skipping cheap:
1. **A phone switched off before the alarm escaped everything.**
`RingWatch.markFired` never ran, so `WorkdayStick` saw no abandoned ring.
2. **Nothing rang if the phone was off.** The PC's waking role was deleted on
2026-08-30; this brings back exactly one piece of it because the phone
*cannot* ring while powered off — the reason AGENTS.md asks for.
## Behaviour
### PC fallback alarm (every ringing day)
Let `A` be today's alarm (`_sync.get_alarm()`, clamped ≤ 08:00 like the
trigger). Not on a free day, not on a sick day (below). Per-minute tick, `A-5min .. A+45min`:
| condition (first match wins) | action |
|---|---|
| escape recorded for today, or a session started today | stop / do not ring |
| ring already ran 30 min today (`FALLBACK_RING_CAP`) | never again today |
| `now ≥ A + 2 min` and no `ring_started` for today | ring — `phone_silent` |
| `now ≥ A + 15 min` (`SESSION_NONE_GRACE`) | ring — `no_session` |
A ring stops on: session start, escape, or the 30-min cap. Firebase
unreachable counts as "no ping, no session" (fail toward ringing) and is
logged as `firebase_unreachable`. While ringing, stop conditions are polled
every 20 s.
**Sound.** 100 %, unmuted, a tone that rises over the first 60 s.
- Active graphical session on seat0 → user PipeWire (`wpctl` + `pw-play`).
- Otherwise (night lockdown: lightdm masked, nobody logged in; kuhy is not in
`audio`) → root system unit `wake-alarm-ring-alsa.service` playing straight
to the speakers' card (`ALC897 Analog`, card name `Generic_1`) via `aplay`.
### PC wakes itself (nights before Tue/Wed/Thu)
`wake-alarm-rtc.timer` (evenings + on boot) arms the RTC for
`A − 10 min` of the next stick-day morning with
`sudo -n /usr/sbin/rtcwake -m no -t <epoch>` (already NOPASSWD). The alarm
lives in CMOS, so it survives however the PC goes down.
`~/.config/wake_alarm/pc_wake.json` `{"mode": "hibernate" | "poweroff"}`:
- `hibernate` (default): a logind drop-in makes the **power key** hibernate.
Verified on the host 2026-10-01: two `rtcwake -m disk` cycles resumed
29–44 s after the alarm (FADT: "RTC can wake system from S4 = 1").
A typed `shutdown` stays a real poweroff.
- `poweroff`: no logind change; relies on the firmware waking from S5.
Unverified until the host S5 test runs.
### Phone stick (Tue/Wed/Thu, not free days)
A **miss** = no dismiss by `A + 15 min` and no escape for today. Detected by
an exact alarm at `A + 15` *and* at boot (stored alarm `A` passed by ≥ 15 min,
not dismissed today) — the boot path is what closes hole 1.
Lockdown length stacks over **consecutive stick-day misses** (free days are
skipped, they neither break nor extend the streak; a dismissed or escaped
stick day resets it to 0):
| streak after this miss | locked through |
|---|---|
| 1 | today + 1 day |
| 2 | today + 2 days |
| n | today + min(n, 4) days (5 calendar days max) |
During a workday lockdown focus-owner applies its **lockdown allowlist**
(calls, SMS, Signal, maps, transit/ticket, banking + authenticator,
wake-alarm, todo) — narrower than the night list.
### Sick days (PC side, 2026-10-16)
A sick day is a date in the shared `sickdays` pool (`~/src/utils/sickdays`):
uncapped, no reason, today or later, and **not a free day** -- its own pool,
its own reason strings (`sick_day` / `sick day`). On a sick day `D` (the
alarm's day, so the RTC asks about *tomorrow* the evening before):
| piece | on a sick day |
|---|---|
| fallback ring | never starts; a ring in progress stops (`stop_reason` `sick_day`) |
| RTC self-wake | not armed for `D`; an alarm we armed for `D` is disarmed (`rtc_skip` / `rtc_disarmed`, reason `sick day`) |
| trigger | `morning-routine.service` is not started (`sick day`), like a free day |
| workday penalty | never written for `D`; one *issued* on `D` before `D` was marked is deleted (`penalty_cancelled`, `cause: sick_day`) by the next `_session --refresh` or `_rtc --arm` |
| `morning_session.json` | unchanged: it only *grants* exemptions, and every locker stands down on a sick day by its own check |
| audit | RTC / up-by-A / ring / lockdown checks are n/a; a penalty for `D` on disk or a `lockdown_sent` for a miss on `D` is flagged |
All of it lives behind `wake_alarm/_sick.py`. **Fail closed:** an
unreadable pool, or `sickdays` not importable under `/usr/bin/python`, is
"not sick" -- enforcement runs.
**Sync.** The decisions read the local pool, and the act modes pull it from
Firebase first (`sickdays.sync_quietly()`), because the sickdays sync timer
alone cannot be trusted at the moment that matters: it is a monotonic
15-minute timer, paused during hibernate and with no `Persistent` catch-up,
so right after the RTC resume the pool can be an interval stale. Pulled:
`_rtc --arm` when the target is a stick day that is not free; the fallback
tick whenever it already goes to the network (`A-5 .. A+45`, and every poll
of a ring in progress, so a day marked sick mid-ring stops it); the day's
first penalty write. The trigger reads only the local file -- the fallback
has pulled from `A-5`. `--dry-run` / `--show` never pull (a pull rewrites
the pool file).
**Other readers of `workday_penalty.json`** (screen-locker, leetcode-guard)
must treat the file as void when **either** `issued_date` **or**
`penalty_date` is a sick day (`sickdays.is_sick_day(date.fromisoformat(...))`).
`issued_date` covers a miss at 08:15 marked sick at 14:00 -- the deletion
runs on the next RTC tick (<= 15 min) or the next morning's refresh, and a
reader must not depend on that; `penalty_date` is a sick day on which every
locker stands down anyway.
**Phone, assumed name:** the phone's `alarm_fired` on a sick day carries
`data.reason = "sick_day"` (by analogy with `free_day`); the audit treats
it as not the morning's ring.
### Escape hatch (until 2026-10-15 inclusive)
Home screen button "That was a bug". Needs a typed reason ≥ 40 characters.
It lifts the phone lockdown, clears the streak, cancels today's PC penalty,
stops a ringing PC alarm, and writes everything into the incident report.
Unlimited uses — every use is listed at the top of that day's report.
After `escapeHatchLastDay` the button is gone (constant, not a setting).
### Stakes shown in the app
Home card: today's stakes (stick day or carrot day), the carrot cutoff, any
pending lockdown/penalty and the streak. Ring screen: one line. Every number
shown comes from Dart constants mirrored against the Python ones by
`wake_alarm/tests/test_stakes_mirrors.py`.
## Data contract
### Firebase — fields on the phone's `alarm.json` (`stageField`)
| field | value |
|---|---|
| `ring_started` | `{"date": "YYYY-MM-DD", "at": "<ISO-8601 with offset>"}` — set when the ring screen opens (Dart). |
| `escapes` | list, newest first, ≤ 30: `{"id", "date", "at", "reason"}` — `date` is the morning being excused. |
| `stick_state` | `{"streak": n, "locked_through": "YYYY-MM-DD" \| null, "last_miss": "YYYY-MM-DD" \| null}` |
| `phone_events` | list, newest first, ≤ 400 entries and ≤ 14 days: `{"at", "kind", "data": {…}}` |
### focus-owner broadcasts (signature permission `SIGNAL_LOCKDOWN`)
- `com.kuhy.focus_owner.action.LOCKDOWN_UNTIL_EOD` — extra `until`
(`YYYY-MM-DD`, inclusive). Missing extra = today (old senders keep working).
- `com.kuhy.focus_owner.action.LIFT_LOCKDOWN` — clears any lockdown now.
### PC files (`~/.local/state/wake_alarm/`)
- `events.jsonl` — every PC event (`_log_sink`), `event` key names below.
- `fallback_ring.json` — today's latch: `{"date", "started_at", "stopped_at",
"reason", "stop_reason"}`.
- `workday_penalty.json` — unchanged shape; not written for an escaped day
or a sick day, and removed when an escape arrives for the day it was
issued or that day is marked sick.
### Event names
Phone (`phone_events.kind`): `alarm_fired` (data: `reason` =
`daily`|`rering`|`free_day`|`sick_day` — only `daily` is the morning's ring),
`ring_screen_opened`, `ring_started_published` (data: `date`, `source` =
`alarm`|`ring_screen`), `dismissed`, `stick_check` (data: `trigger` =
`exact`|`boot`|`watchdog`, `date` judged, `verdict`, incl. `free_day`|`sick_day`), `lockdown_sent` (data:
`until`, `streak`), `lift_sent`, `escape_used`, `boot`.
`ring_started` is published twice over: a headless Dart engine started by
`AlarmReceiver` at the fire (so a full-screen intent downgraded to a heads-up
on an unlocked phone still counts) and the ring screen; first of the day wins.
The A+15 check uses `setExactAndAllowWhileIdle` (falls back to
`setAlarmClock` only when exact alarms are refused), so it never shows as the
system's next alarm. Boot/watchdog catch up on up to 5 unchecked past days.
PC (`events.jsonl` `event`): `fallback_tick` (only on decision change),
`fallback_ring_start`, `fallback_ring_stop`, `ring_backend`, `rtc_armed`,
`rtc_skip`, `firebase_unreachable`, `escape_seen`, `penalty_written`,
`penalty_cancelled` (field `cause`: `escape` | `sick_day`), `audit_written`.
## Verification status (2026-10-01)
- vmbox gate (`scripts/vmbox/harness.sh`, `guest_cases_v2.sh`): install,
timers, root ring unit on `snd-dummy`, sudoers merge, power-key drop-in,
a real `rtcwake` arm, uninstall — all pass. Passed in vmbox, then installed
on the host.
- Host: S4 RTC wake verified twice; both ring backends start and stop. The
root ALSA ring got `Device or resource busy` while PipeWire still held the
card seconds after a PipeWire ring — at night the card is idle and opens
(a silent root `aplay` passed), but a stream left open overnight would
keep it busy. **Open:** what the root ring does about a held card is
kuhy's call (killing the holder was refused by the auto-mode classifier).
- Emulator (`wake_alarm_api36`): exact A+15 check hidden from the system
next-alarm, headless `ring_started` on a heads-up ring, boot-path miss
(phone off before the alarm) → lockdown through tomorrow, escape hatch.
Not yet on the Pixel: the real focus-owner reaction and Firebase publish.
## Evidence for Claude
`python -m wake_alarm._audit [YYYY-MM-DD]` and the 11:05 timer write
`~/data/wake-alarm/incidents/<date>.md`: a merged phone + PC timeline, then
expected-vs-observed checks (alarm known, RTC armed the night before, PC up
by `A`, ring/fallback, penalty, lockdown), mismatches and escape uses first.
Outside the repo on purpose: it holds personal data.