mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
All PC enforcement (fallback ring, RTC self-wake, trigger, workday penalty write/retract, audit) now asks sickdays.stands_down(LOCKER_ALARM, day) via _sick.alarm_stands_down instead of is_sick_day, so a sick day that keeps the alarm behaves exactly as a normal stick day. Bump sickdays pin to v0.2.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
218 lines
11 KiB
Markdown
218 lines
11 KiB
Markdown
# 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 (that does not keep `alarm`, see below) 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** its `issued_date` is a day the
|
||
*alarm* stands down on (`sickdays.stands_down(sickdays.LOCKER_ALARM,
|
||
date.fromisoformat(issued_date))`) **or** its `penalty_date` is a day *their
|
||
own* locker stands down on (`stands_down(LOCKER_WORKOUT, ...)` for
|
||
screen-locker, `LOCKER_LEETCODE` for leetcode-guard). `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; the ring day belongs to the alarm switch, the penalty day to whatever
|
||
the penalty charges.
|
||
|
||
**Keeping the alarm (`sickdays` 0.2.0).** A sick day may keep lockers
|
||
(`sickdays add --keep alarm`). Everything in the table above is the single
|
||
`alarm` switch (`_sick.alarm_stands_down`): on a sick day that keeps `alarm`
|
||
the fallback ring, RTC self-wake, trigger, workday penalty (written, and not
|
||
retracted) and audit behave exactly as on a normal stick day.
|
||
|
||
**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.
|