wake_alarm: sick days stand the PC down (fallback ring, RTC wake, trigger, workday penalty)

A date in the new shared sickdays pool (not a free day) now means, on the PC:
no fallback ring (a ring in progress stops, reason sick_day), no RTC
self-wake for that morning (ours is disarmed), no morning-routine trigger,
and no workday_penalty.json; a penalty issued on a day later marked sick is
retracted (penalty_cancelled, cause sick_day) by the next session refresh or
RTC tick. The audit treats the day as one where nothing is expected.

All pool access goes through wake_alarm/_sick.py: fail closed (unreadable
pool or missing package = not sick), and act modes pull the pool from
Firebase first (RTC --arm on a stick-day target, in-window fallback ticks
and ring polls, the day's first penalty write) because the sickdays timer
pauses through hibernate. Dry runs never pull.

Pins: sickdays-v0.1.0, freedays-v0.4.0, crdt-sync-v0.9.2 (needed by both);
pre-commit 4.7.0, pylint 4.1.3, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 08:45:29 +02:00
parent 3b8cd21969
commit 8696f2d0bf
21 changed files with 675 additions and 71 deletions

View File

@ -106,8 +106,9 @@ repos:
# the manifest becomes an unresolvable hook environment rather
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays
# Test suites intentionally use patterns (protected-access, magic-value
# comparisons) that don't belong in the source-code 10/10 gate.
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)

View File

@ -71,7 +71,8 @@ clock, and everything that is a hard crash or a silent no-ring:
**Free days.** On a day in the shared pool (`~/src/utils/freedays`) the phone
does not ring at all: `AlarmReceiver` checks a native cache Dart rewrites
after every mark/release/sync. Picker, cache and its fail-closed contract:
**`DOCS-free-days.md`**.
**`DOCS-free-days.md`**. **Sick days** (`~/src/utils/sickdays`, never a free
day) stand the PC down too, via `_sick.py`: **`DOCS-wake-enforcement.md`**.
## Scheduling — a per-minute timer

View File

@ -21,7 +21,7 @@ lever is something that bites *today*. Two holes made skipping cheap:
### 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. Per-minute tick, `A-5min .. A+45min`:
trigger). Not on a free day, not on a sick day (below). Per-minute tick, `A-5min .. A+45min`:
| condition (first match wins) | action |
|---|---|
@ -76,6 +76,50 @@ 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.
@ -113,13 +157,14 @@ shown comes from Dart constants mirrored against the Python ones by
- `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,
and removed when an escape arrives for the day it was issued.
- `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` — only `daily` is the morning's ring),
`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`), `lockdown_sent` (data:
@ -135,7 +180,7 @@ 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`, `audit_written`.
`penalty_cancelled` (field `cause`: `escape` | `sick_day`), `audit_written`.
## Verification status (2026-10-01)

View File

@ -12,8 +12,11 @@ dependencies = [
# Firebase transport for the alarm read. _sync.py imports it lazily and
# falls back to its stdlib GitHub path if the import fails, so a broken
# install degrades the read instead of killing the per-minute trigger.
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
# Sick days stand the PC's enforcement down (_sick.py). Imported behind
# a guard: missing means "not sick", never a crashed tick.
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays",
]
# Package discovery must be explicit: phone_app/ is a Flutter app, not a Python

View File

@ -5,15 +5,16 @@
bandit==1.9.4
codespell==2.4.3
coverage==7.16.2
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
mypy==2.4.0
pre-commit==4.6.2
pylint==4.1.2
pre-commit==4.7.0
pylint==4.1.3
pytest==9.1.1
pytest-cov==7.1.0
pytest-randomly==5.0.0
pytest-sugar==1.1.1
pytest-xdist==3.8.0
ruff==0.16.10
ruff==0.17.1
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays

View File

@ -36,6 +36,7 @@ from wake_alarm._enforcement_records import parse_instant
from wake_alarm._events import emit
from wake_alarm._fallback_policy import alarm_instant
from wake_alarm._logging_setup import configure_logging
from wake_alarm._sick import is_sick
from wake_alarm._sync import get_alarm
from wake_alarm._sync_transport import fetch_firebase_logs
from wake_alarm._workday_penalty import issued_date
@ -64,6 +65,7 @@ def gather(day: date) -> tuple[Evidence, bool]:
day=day,
stick_day=freedays.is_workday(day),
free_day=freedays.is_free_day(day),
sick_day=is_sick(day),
alarm_at=alarm_at,
alarm_source=source,
pc_side=PcSide(
@ -117,7 +119,15 @@ def _timeline(ev: Evidence) -> list[str]:
def render(ev: Evidence, checks: list[Check], *, reachable: bool) -> str:
"""The whole report."""
kind = "free day" if ev.free_day else "stick day" if ev.stick_day else "carrot day"
kind = (
"sick day"
if ev.sick_day
else "free day"
if ev.free_day
else "stick day"
if ev.stick_day
else "carrot day"
)
lines = [f"# Wake audit {ev.day} ({ev.day:%a}, {kind})", "", "## Flags", ""]
flagged = flags(ev, checks)
if not reachable:

View File

@ -29,11 +29,12 @@ if TYPE_CHECKING:
from wake_alarm._audit_sources import PcEvent
_FINAL_STOPS = frozenset({"escape", "session_started", "cap", "free_day"})
_FINAL_STOPS = frozenset({"escape", "session_started", "cap", "free_day", "sick_day"})
_RING_KINDS = frozenset({"ring_screen_opened", "ring_started_published"})
# alarm_fired.data.reason: only the daily ring is the morning's ring -- a
# free_day firing rings nothing, and a rering presupposes an earlier ring.
_NOT_THE_MORNING_RING = frozenset({"free_day", "rering"})
# free_day / sick_day firing rings nothing, and a rering presupposes an
# earlier ring.
_NOT_THE_MORNING_RING = frozenset({"free_day", "sick_day", "rering"})
def alarm_from_events(pc: list[PcEvent], day: date) -> datetime | None:
@ -58,6 +59,8 @@ def check_rtc(ev: Evidence) -> Check:
"""On a stick day the RTC was armed the night before."""
if not ev.stick_day or ev.free_day:
return na("RTC armed the night before", "not a stick day")
if ev.sick_day:
return na("RTC armed the night before", "sick day")
armed = [
e
for e in ev.pc
@ -75,6 +78,8 @@ def check_up_by_alarm(ev: Evidence) -> Check:
"""On a stick day the PC was up by ``A``."""
if not ev.stick_day or ev.free_day or ev.alarm_at is None:
return na("PC up by A", "not a stick day or no alarm")
if ev.sick_day:
return na("PC up by A", "sick day")
alarm = ev.alarm_at
lead = alarm - RTC_WAKE_LEAD - timedelta(minutes=5)
ups = [u for u in ev.ups if u.at.date() == ev.day and u.at <= alarm]
@ -90,6 +95,8 @@ def check_ring(ev: Evidence) -> Check:
"""Something rang: the phone's ring screen, or the PC fallback."""
if ev.free_day:
return na("something rang", "free day")
if ev.sick_day:
return na("something rang", "sick day")
fallback = pc_on_day(ev, "fallback_ring_start")
phone = ev.ring_started or any(
e.kind in _RING_KINDS

View File

@ -51,6 +51,7 @@ class Evidence:
alarm_source: str
pc_side: PcSide = field(default_factory=PcSide)
phone_side: PhoneSide = field(default_factory=PhoneSide)
sick_day: bool = False
@property
def pc(self) -> list[PcEvent]:

View File

@ -74,10 +74,11 @@ def check_penalty(ev: Evidence) -> Check:
"""The PC penalty agrees with the outcome and any escape."""
name = "penalty consistent"
escaped = _escaped(ev)
if escaped and ev.penalty_issued == ev.day.isoformat():
return bad(name, "escaped, but the penalty is on disk")
if escaped:
return ok(name, "escaped: no penalty")
if escaped or ev.sick_day:
why = "escaped" if escaped else "sick day"
if ev.penalty_issued == ev.day.isoformat():
return bad(name, f"{why}, but the penalty is on disk")
return ok(name, f"{why}: no penalty")
if not ev.stick_day or ev.free_day:
return na(name, "not a stick day")
written = pc_on_day(ev, "penalty_written")
@ -119,6 +120,14 @@ def _lockdown_for_miss(ev: Evidence, judged: date) -> Check:
return ok(name, f"until {until}")
def _sick_lockdown(ev: Evidence, judged: date | None) -> Check:
"""A sick day expects no lockdown; one sent for its miss is flagged."""
name = "lockdown consistent"
if judged is not None and _sent_on(ev, judged):
return bad(name, "sick day, but lockdown_sent")
return na(name, "sick day")
def check_lockdown(ev: Evidence) -> Check:
"""``lockdown_sent`` agrees with the verdict, and escapes lift it."""
name = "lockdown consistent"
@ -128,6 +137,8 @@ def check_lockdown(ev: Evidence) -> Check:
if "lift_sent" not in phone_kinds(ev):
return bad(name, "escaped, but no lift_sent")
return ok(name, "lifted after the escape")
if ev.sick_day:
return _sick_lockdown(ev, judged)
if judged is not None:
return _lockdown_for_miss(ev, judged)
if sent_today and not _escaped(ev):

View File

@ -1,7 +1,7 @@
"""The PC fallback alarm: one per-minute tick, 04:00-09:59.
``wake-alarm-fallback.timer`` runs ``--tick`` every minute. Outside
``A - 5 min .. A + 45 min`` (or on a free day) a tick decides from local
``A - 5 min .. A + 45 min`` (or on a free or sick day) a tick decides from local
state alone and never touches the network. Inside, it reads every device's
``alarm.json`` once, decides (``_fallback_policy.decide``) and, when the
decision is to ring, rings *in this process* until a stop condition holds
@ -44,6 +44,7 @@ from wake_alarm._fallback_policy import (
from wake_alarm._fallback_ring import ring
from wake_alarm._logging_setup import configure_logging
from wake_alarm._ring_wav import SYSTEM_WAV_DIR, USER_WAV_DIR, ensure_pair
from wake_alarm._sick import is_sick
from wake_alarm._sync import get_alarm
if TYPE_CHECKING:
@ -71,7 +72,7 @@ def _close(latch: Latch, reason: str) -> None:
def _poll(day: str) -> str | None:
"""Re-observe while ringing; the reason to stop, or None."""
seen = observe(_now())
seen = observe(_now(), sync=True)
record(seen)
reason = stop_reason(replace(seen.facts, latch=read_latch(day)))
if reason is not None:
@ -105,7 +106,7 @@ def ring_now(facts: Facts, reason: str) -> str:
def tick(now: datetime) -> Decision:
"""One timer tick: observe, log a changed decision, ring if due."""
seen = observe(now)
seen = observe(now, sync=True)
record(seen)
facts, day = seen.facts, now.date().isoformat()
decision = decide(facts)
@ -134,6 +135,7 @@ def describe(seen: Observation, decision: Decision) -> str:
return (
f"{facts.now:%Y-%m-%d %H:%M} — {verdict}: {decision.reason}\n"
f" alarm A={facts.alarm_at:%H:%M} free_day={facts.free_day} "
f"sick_day={facts.sick_day} "
f"firebase={'ok' if seen.reachable else 'UNREACHABLE'}\n"
f" escaped={facts.escaped} session_started={facts.session_started} "
f"ring_started={facts.ring_started} settled={facts.settled}\n"
@ -147,7 +149,8 @@ def show(now: datetime) -> str:
alarm_at = alarm_instant(get_alarm(), now.date())
return (
f"today {day}: A={alarm_at:%H:%M} "
f"free_day={freedays.is_free_day(now.date())}\n"
f"free_day={freedays.is_free_day(now.date())} "
f"sick_day={is_sick(now.date())}\n"
f" latch={read_latch(day)}\n"
f" last fallback_tick={last_value('fallback_tick')}\n"
f" settled={settled_reason(day, read_latch(day))}\n"
@ -166,7 +169,7 @@ def main(argv: list[str] | None = None) -> int:
if args.show:
sys.stdout.write(show(now))
elif args.dry_run:
seen = observe(now)
seen = observe(now, sync=False)
sys.stdout.write(describe(seen, decide(seen.facts)))
else:
configure_logging(component="fallback")

View File

@ -1,8 +1,8 @@
"""One look at the world for the fallback alarm, and its bookkeeping.
Split from ``_fallback`` (tick, ring lifecycle, CLI): this is "what is true
right now" -- today's alarm, the latch, the free-day pool and, only when the
decision needs it, every device's ``alarm.json``.
right now" -- today's alarm, the latch, the free-day and sick-day pools
and, only when the decision needs it, every device's ``alarm.json``.
"""
from __future__ import annotations
@ -23,6 +23,7 @@ from wake_alarm._fallback_policy import (
needs_facts,
)
from wake_alarm._session_payload import newest_session
from wake_alarm._sick import is_sick, is_sick_synced
from wake_alarm._sync import get_alarm
from wake_alarm._sync_transport import fetch_firebase_logs
@ -49,18 +50,37 @@ def settled_reason(day: str, latch: Latch | None) -> str | None:
return remembered[2] if remembered[0] == day and remembered[2] else None
def observe(now: datetime) -> Observation:
"""Gather the facts; the network only when the decision needs it."""
def _sick_today(now: datetime, *, online: bool, sync: bool) -> bool:
"""Whether today is a sick day; pulls the pool first when acting online.
Synced only on the ticks that already go to the network (the window, or
a ring in progress), so a day marked sick on the phone mid-ring stops
the ring at the next poll, and an off-window tick stays local.
"""
if online and sync:
return is_sick_synced(now.date())
return is_sick(now.date())
def observe(now: datetime, *, sync: bool) -> Observation:
"""Gather the facts; the network only when the decision needs it.
``sync`` is False for ``--dry-run``: pulling the sick-day pool rewrites
its local file, and a dry run writes nothing.
"""
day = now.date().isoformat()
latch = read_latch(day)
alarm_at = alarm_instant(get_alarm(), now.date())
online = needs_facts(now, alarm_at, latch)
facts = Facts(
now=now,
alarm_at=alarm_instant(get_alarm(), now.date()),
alarm_at=alarm_at,
free_day=freedays.is_free_day(now.date()),
sick_day=_sick_today(now, online=online, sync=sync),
latch=latch,
settled=settled_reason(day, latch),
)
if facts.free_day or not needs_facts(now, facts.alarm_at, latch):
if facts.free_day or facts.sick_day or not online:
return Observation(facts)
published = fetch_firebase_logs()
if published is None:

View File

@ -1,7 +1,7 @@
"""The PC fallback alarm's rule, as one pure function.
``DOCS-wake-enforcement.md``'s table, first match wins, inside
``A - 5 min .. A + 45 min`` on a day that is not free:
``A - 5 min .. A + 45 min`` on a day that is neither free nor sick:
========================================================= ==================
escape recorded for today, or a session started today do not ring / stop
@ -45,6 +45,7 @@ FALLBACK_POLL_SECONDS: float = 20.0
# Reasons, as they appear in events.jsonl and fallback_ring.json.
FREE_DAY = "free_day"
SICK_DAY = "sick_day"
OUTSIDE_WINDOW = "outside_window"
ESCAPE = "escape"
SESSION_STARTED = "session_started"
@ -69,6 +70,7 @@ class Facts:
now: datetime
alarm_at: datetime
free_day: bool
sick_day: bool = False
escaped: bool = False
session_started: bool = False
ring_started: bool = False
@ -121,15 +123,26 @@ def is_capped(facts: Facts) -> bool:
return started is not None and facts.now >= started + FALLBACK_RING_CAP
def day_off(facts: Facts) -> str | None:
"""``free_day`` / ``sick_day`` when today rings nothing at all."""
if facts.free_day:
return FREE_DAY
if facts.sick_day:
return SICK_DAY
return None
def stop_reason(facts: Facts) -> str | None:
"""Why a ring in progress must stop now, or None to keep ringing.
Only the contract's three stop conditions (plus a day that turned free):
Only the contract's three stop conditions (plus a day that turned free
or sick):
a phone that comes back on mid-ring does not silence the PC -- getting up
does.
"""
if facts.free_day:
return FREE_DAY
off = day_off(facts)
if off is not None:
return off
if facts.settled in FINAL_REASONS:
return facts.settled
if facts.escaped:
@ -143,8 +156,9 @@ def stop_reason(facts: Facts) -> str | None:
def decide(facts: Facts) -> Decision:
"""The table above; first match wins."""
if facts.free_day:
return Decision(ring=False, reason=FREE_DAY)
off = day_off(facts)
if off is not None:
return Decision(ring=False, reason=off)
if not needs_facts(facts.now, facts.alarm_at, facts.latch):
return Decision(ring=False, reason=OUTSIDE_WINDOW)
stop = stop_reason(facts)

View File

@ -6,18 +6,26 @@ resume, so a PC hibernated at 16:00 on Monday is armed for Tuesday the
moment it next runs at all. The target is the first morning whose wake instant
(``A - RTC_WAKE_LEAD``, ``A`` clamped to 08:00) is still ahead -- so a
02:00 tick on a Tuesday targets Tuesday, not Wednesday. Only a stick day
(``freedays.is_workday``: Tue/Wed/Thu) that is not a free day is armed, via
(``freedays.is_workday``: Tue/Wed/Thu) that is neither a free day nor a sick
day (``_sick``) is armed, via
``sudo -n /usr/sbin/rtcwake -m no -t <epoch>``. The alarm lives in CMOS, so
it survives however the PC goes down.
When the next morning is not one to wake for (a carrot day, or a stick day
that became free), an alarm *we* armed is cleared (``rtc_disarmed``); one
that became free or sick), an alarm *we* armed is cleared (``rtc_disarmed``); one
someone else set is left alone (``_rtc_hw``). A changed alarm time needs no
disarm: arming overwrites the single RTC alarm.
``rtc_armed`` / ``rtc_skip`` are logged only when they change; the audit
matches ``rtc_armed`` on its ``target_date``.
``--arm`` pulls the sick-day pool before judging a stick-day target (a day
marked sick on the phone at 23:00 must disarm before the PC goes down);
``--dry-run`` / ``--show`` read the local pool and write nothing. ``--arm``
also retracts a workday penalty issued on a day since marked sick
(``_workday_penalty.cancel_for_sick_day``): this is the one wake_alarm job
that runs all day, and a day can be marked sick until midnight.
python -m wake_alarm._rtc --arm # what the timer runs
python -m wake_alarm._rtc --dry-run # the plan, nothing armed
python -m wake_alarm._rtc --show # the plan + the RTC's current alarm
@ -28,6 +36,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
import sys
from typing import TYPE_CHECKING
import freedays
@ -38,7 +47,12 @@ from wake_alarm._fallback_policy import alarm_instant
from wake_alarm._logging_setup import configure_logging
from wake_alarm._pc_wake_mode import read_mode
from wake_alarm._rtc_hw import arm, current_alarm, disarm_ours, read_record
from wake_alarm._sick import is_sick, is_sick_synced
from wake_alarm._sync import Alarm, get_alarm
from wake_alarm._workday_penalty import cancel_for_sick_day
if TYPE_CHECKING:
from collections.abc import Callable
@dataclass(frozen=True)
@ -61,8 +75,14 @@ def _now() -> datetime:
return datetime.now(tz=UTC).astimezone()
def plan(now: datetime, alarm: Alarm) -> Plan:
"""Which morning is next, and whether it is one to wake the PC for."""
def plan(
now: datetime, alarm: Alarm, *, sick: Callable[[date], bool] = is_sick
) -> Plan:
"""Which morning is next, and whether it is one to wake the PC for.
``sick`` is asked last, so only a stick-day target that is not free ever
pays for the synced variant ``--arm`` passes.
"""
target = now.date()
wake_at = alarm_instant(alarm, target) - RTC_WAKE_LEAD
if wake_at <= now:
@ -74,6 +94,8 @@ def plan(now: datetime, alarm: Alarm) -> Plan:
)
if freedays.is_free_day(target):
return Plan(target, wake_at, arm=False, reason="free day")
if sick(target):
return Plan(target, wake_at, arm=False, reason="sick day")
return Plan(target, wake_at, arm=True, reason="stick day")
@ -139,10 +161,12 @@ def main(argv: list[str] | None = None) -> int:
"""Entry point."""
parser = mode_parser("Arm the RTC for a stick day", "arm", "arm (the timer)")
args, now = parse_with_now(parser, argv, _now)
chosen = plan(now, get_alarm())
if args.arm:
configure_logging(component="rtc")
cancel_for_sick_day()
chosen = plan(now, get_alarm(), sick=is_sick_synced)
return 0 if apply(chosen) or not chosen.arm else 1
chosen = plan(now, get_alarm())
sys.stdout.write(describe(chosen))
record = read_record()
if not chosen.arm and record is not None and current_alarm() == record.epoch:

View File

@ -42,6 +42,7 @@ from wake_alarm._session_payload import newest_session
from wake_alarm._session_policy import to_entry
from wake_alarm._sync import get_alarm
from wake_alarm._sync_transport import fetch_firebase_logs
from wake_alarm._workday_penalty import cancel_for_sick_day as _cancel_for_sick_day
from wake_alarm._workday_penalty import maybe_apply as _maybe_apply_workday_penalty
_logger = logging.getLogger(__name__)
@ -84,8 +85,11 @@ def refresh(now: datetime) -> dict[str, object] | None:
"""Fetch, select, and rewrite the file; None when the backend was unreadable.
An unreadable backend leaves the file alone: whatever it says is still
the newest thing known, and a consumer judges freshness by its date.
the newest thing known, and a consumer judges freshness by its date. A
penalty issued on a day since marked sick is retracted first, from the
local pool alone, so it does not wait on Firebase.
"""
_cancel_for_sick_day()
published = fetch_firebase_logs()
if published is None:
_logger.info("Morning session not refreshed: backend unreadable")

54
wake_alarm/_sick.py Normal file
View File

@ -0,0 +1,54 @@
"""Sick days: the one place wake_alarm asks the shared ``sickdays`` pool.
A sick day (``~/src/utils/sickdays``) is not a free day -- a separate pool,
no cap, no reason -- but on the PC it stands down the same things: no
fallback ring, no RTC self-wake, no workday penalty, no morning routine.
Two rules this module exists to keep in one spot:
* **Fail closed.** ``sickdays.is_sick_day`` never raises and reads a corrupt
pool as "not sick". The import is guarded as well: these jobs run under
``/usr/bin/python``, and a package that failed to install must leave
enforcement *on* (not sick), never crash the per-minute tick -- a crashed
tick rings nothing.
* **Sync only where something is about to happen, never in a read-only
mode.** ``sync_quietly`` rewrites the local pool, so ``--dry-run`` and
``--show`` never call it. The sickdays timer alone is not enough: it is a
monotonic 15-minute timer, which pauses during hibernate and has no
``Persistent`` catch-up, so right after the RTC resume the pool can be a
whole interval stale -- exactly when a day marked sick from bed matters.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
_logger = logging.getLogger(__name__)
try:
import sickdays
_AVAILABLE = True
except ImportError:
_AVAILABLE = False
_logger.warning("sickdays is not installed; no day counts as sick")
if TYPE_CHECKING:
from datetime import date
def is_sick(day: date) -> bool:
"""Whether ``day`` is a sick day; local file only, never raises."""
return _AVAILABLE and sickdays.is_sick_day(day)
def sync() -> bool:
"""Pull the pool from Firebase now; False when it could not (silently)."""
return _AVAILABLE and sickdays.sync_quietly()
def is_sick_synced(day: date) -> bool:
""":func:`is_sick` after a fresh :func:`sync` -- for act modes only."""
sync()
return is_sick(day)

View File

@ -35,6 +35,7 @@ from wake_alarm._constants import (
TRIGGER_TARGET_UNIT,
)
from wake_alarm._logging_setup import configure_logging
from wake_alarm._sick import is_sick
from wake_alarm._state import (
save_trigger_fired,
was_trigger_fired_today,
@ -84,12 +85,13 @@ def _skip_reason(now: datetime) -> str | None:
"""Return why the alarm must not fire right now, or None if it may.
Checks only the gates that are independent of the clock: the shared
free-day pool, the day gate and the once-per-day latch.
free-day and sick-day pools, the day gate and the once-per-day latch.
A free day means the alarm does not ring at all -- the orchestrator is
never started, so neither the alarm nor the workout lock behind it
appears. The pool is one local file read, never the network, so this
stays a millisecond-scale tick.
appears. A sick day is the same. Each pool is one local file read, never
the network, so this stays a millisecond-scale tick; the fallback tick
pulls the sick-day pool from ``A - 5 min``, so it is fresh by the fire.
Args:
now: Current local time.
@ -99,6 +101,8 @@ def _skip_reason(now: datetime) -> str | None:
"""
if freedays.is_free_day(now.date()):
return "free day"
if is_sick(now.date()):
return "sick day"
if now.weekday() not in ALARM_DAYS:
return "not an alarm day"
if was_trigger_fired_today():

View File

@ -15,7 +15,11 @@ today == penalty_date means "no leniency today," the same shape as
``morning_session.json``'s own ``exempt_until`` predicate.
An escape (``DOCS-wake-enforcement.md``) for a morning means no penalty is
written for it, and one already issued that day is deleted.
written for it, and one already issued that day is deleted. A sick day
(``_sick``) is the same, for its own reason: nothing is written on a sick
day, and a penalty *issued* on a day marked sick afterwards (a miss at 08:15,
"sick" at 14:00) is deleted by the next ``--refresh`` or RTC tick. Other
readers must not wait for that: see ``DOCS-wake-enforcement.md``.
"""
from __future__ import annotations
@ -30,6 +34,7 @@ from gatelock.log_integrity import compute_entry_hmac
from wake_alarm._constants import WORKDAY_PENALTY_FILE
from wake_alarm._events import emit
from wake_alarm._session_policy import NONE_OUTCOME
from wake_alarm._sick import is_sick, is_sick_synced
_logger = logging.getLogger(__name__)
@ -95,6 +100,22 @@ def issued_date() -> str | None:
return issued if isinstance(issued, str) else None
def _cancel(issued: str, cause: str) -> bool:
"""Delete the penalty issued on ``issued``; log ``penalty_cancelled``."""
try:
WORKDAY_PENALTY_FILE.unlink()
except OSError as exc:
_logger.warning("Could not cancel %s: %s", WORKDAY_PENALTY_FILE, exc)
return False
emit(
"penalty_cancelled",
f"{cause.replace('_', ' ')} for {issued}",
issued_date=issued,
cause=cause,
)
return True
def cancel_for_escapes(escaped_dates: frozenset[str]) -> bool:
"""Delete the penalty when an escape excuses the morning it was issued.
@ -104,13 +125,47 @@ def cancel_for_escapes(escaped_dates: frozenset[str]) -> bool:
issued = issued_date()
if issued is None or issued not in escaped_dates:
return False
try:
WORKDAY_PENALTY_FILE.unlink()
except OSError as exc:
_logger.warning("Could not cancel %s: %s", WORKDAY_PENALTY_FILE, exc)
return _cancel(issued, "escape")
def cancel_for_sick_day() -> bool:
"""Delete the penalty when the morning it was issued is now a sick day.
Local pool only: callers on a timer keep it fresh. Fail closed -- an
unreadable pool or date keeps the penalty.
Returns:
True when a penalty was removed.
"""
issued = issued_date()
if issued is None:
return False
emit("penalty_cancelled", f"escape for {issued}", issued_date=issued)
return True
try:
day = date.fromisoformat(issued)
except ValueError:
return False
return is_sick(day) and _cancel(issued, "sick_day")
def skip_reason(
entry: dict[str, object],
now: datetime,
*,
escaped_dates: frozenset[str] = frozenset(),
) -> str | None:
"""Why today earns no penalty, or None when it does. Pure and local."""
today = now.date()
if today.isoformat() in escaped_dates:
return "escaped"
if freedays.is_free_day(today):
return "free day"
if not is_stick_day(today):
return "not a stick day"
if is_sick(today):
return "sick day"
if not is_workday_miss(entry, now):
return "no miss"
return None
def maybe_apply(
@ -126,19 +181,19 @@ def maybe_apply(
would otherwise have rung the alarm -- the pool is fail-closed the other
direction (a corrupt read is "not free"), which is exactly what is
wanted here too: an unreadable pool means no penalty, never a spurious
one. An escaped morning is never penalised either.
one. An escaped morning or a sick day is never penalised either; the
sick-day pool is pulled once more before the day's *first* write, so a
day marked sick on the phone minutes ago still counts.
``penalty_written`` is logged once per issued day, not on every
two-minute refresh that rewrites the same entry.
"""
if skip_reason(entry, now, escaped_dates=escaped_dates) is not None:
return False
today = now.date()
if today.isoformat() in escaped_dates:
return False
if freedays.is_free_day(today) or not is_stick_day(today):
return False
if not is_workday_miss(entry, now):
return False
previously = issued_date()
if previously != today.isoformat() and is_sick_synced(today):
return False
entry_body = penalty_entry(today=today)
if not write_penalty(entry_body):
return False

View File

@ -19,6 +19,8 @@ an inhibitor:
``~/.local/state/wake_alarm/events.jsonl``. Unredirected, every suite run
appended fixture warnings ("Synced alarm out of range: 24:0") to the log a
failed wake-up is reconstructed from.
* The free-day and sick-day pools live under ``~/.local/share``; both are
redirected, and the sick-day sync is stubbed offline.
* Wake enforcement v2 writes more state (the fallback latch, the event
state, incident reports) and reaches real hardware (``pw-play``,
``sudo systemctl``, ``rtcwake``, ``journalctl``). Every path is redirected
@ -34,6 +36,7 @@ from unittest.mock import patch
import freedays
import pytest
import sickdays
if TYPE_CHECKING:
from collections.abc import Generator
@ -100,6 +103,15 @@ def _hermetic(tmp_path: Path) -> Generator[None]:
"freedays._api.resolve_paths",
lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"),
),
# Same for the sick-day pool. Its sync is stubbed to "offline": the
# real ~/.config/crdt-sync credential exists on this machine, so an
# unstubbed act-mode tick would talk to live Firebase and rewrite the
# real pool.
patch(
"sickdays._api.resolve_paths",
lambda paths: paths or sickdays.Paths.under(tmp_path / "sickdays"),
),
patch("sickdays.sync_quietly", lambda **_: False),
]
with ExitStack() as stack:
for redirect in patches:

View File

@ -45,7 +45,7 @@ def _alarm() -> Generator[None]:
def test_outside_the_window_never_fetches() -> None:
fetch = MagicMock()
with patch.object(_fallback_observe, "fetch_firebase_logs", fetch):
seen = observe(datetime(2026, 10, 1, 5, 0).astimezone())
seen = observe(datetime(2026, 10, 1, 5, 0).astimezone(), sync=True)
fetch.assert_not_called()
assert seen.reachable
assert seen.view is None
@ -57,13 +57,13 @@ def test_a_free_day_never_fetches() -> None:
patch.object(_fallback_observe, "fetch_firebase_logs", fetch),
patch.object(_fallback_observe.freedays, "is_free_day", return_value=True),
):
assert observe(NOW).facts.free_day
assert observe(NOW, sync=False).facts.free_day
fetch.assert_not_called()
def test_unreachable_firebase_is_no_ping_no_session() -> None:
with patch.object(_fallback_observe, "fetch_firebase_logs", return_value=None):
seen = observe(NOW)
seen = observe(NOW, sync=False)
assert not seen.reachable
facts = seen.facts
assert not (facts.escaped)
@ -81,7 +81,7 @@ def test_facts_from_the_phone() -> None:
with patch.object(
_fallback_observe, "fetch_firebase_logs", return_value=[("p", raw)]
):
seen = observe(NOW)
seen = observe(NOW, sync=False)
assert seen.facts.session_started
assert seen.facts.ring_started
assert seen.facts.escaped
@ -95,7 +95,7 @@ def test_yesterdays_session_does_not_count() -> None:
"fetch_firebase_logs",
return_value=[("p", _log(morning_sessions=[session]))],
):
assert not observe(NOW).facts.session_started
assert not observe(NOW, sync=False).facts.session_started
def test_settled_from_latch_or_event_state() -> None:
@ -123,12 +123,12 @@ def test_record_logs_unreachable_once_and_resets(
patch.object(_fallback_observe, "fetch_firebase_logs", return_value=None),
caplog.at_level(logging.INFO),
):
seen = observe(NOW)
seen = observe(NOW, sync=False)
record(seen)
record(seen)
assert caplog.text.count("firebase_unreachable:") == 1
with patch.object(_fallback_observe, "fetch_firebase_logs", return_value=[]):
record(observe(NOW))
record(observe(NOW, sync=False))
assert last_value("firebase_unreachable") is None
@ -137,5 +137,5 @@ def test_record_notes_escapes() -> None:
with patch.object(
_fallback_observe, "fetch_firebase_logs", return_value=[("p", raw)]
):
record(observe(NOW))
record(observe(NOW, sync=False))
assert last_value("escape_seen") == "e"

View File

@ -0,0 +1,159 @@
"""Sick days stand the PC down: no fallback ring, no RTC wake, no trigger.
The pool is the real ``sickdays`` package writing into ``tmp_path``
(``conftest`` redirects it); only the network sync is stubbed.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
import importlib
import sys
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
import sickdays
from wake_alarm import _fallback, _fallback_observe, _rtc, _sick, _trigger
from wake_alarm._fallback_latch import mark_started
from wake_alarm._fallback_policy import SICK_DAY, Decision, Facts, decide, stop_reason
from wake_alarm._sync import Alarm
if TYPE_CHECKING:
from collections.abc import Generator
TUESDAY = date(2026, 10, 6)
A = datetime(2026, 10, 6, 7, 0).astimezone()
MONDAY_EVENING = datetime(2026, 10, 5, 22, 0).astimezone()
def mark_sick(day: date) -> None:
"""Put ``day`` in the (redirected) pool; may be in the past."""
sickdays.import_legacy([day])
def pull_marks_tuesday() -> bool:
"""A sync that brings TUESDAY in from another device."""
mark_sick(TUESDAY)
return True
@pytest.fixture
def sync() -> Generator[MagicMock]:
"""The network pull, observed; reports success."""
with patch("sickdays.sync_quietly", return_value=True) as pulled:
yield pulled
class TestSickModule:
def test_reads_the_pool(self) -> None:
assert not _sick.is_sick(TUESDAY)
mark_sick(TUESDAY)
assert _sick.is_sick(TUESDAY)
def test_synced_pulls_first(self, sync: MagicMock) -> None:
sync.side_effect = pull_marks_tuesday
assert _sick.is_sick_synced(TUESDAY)
sync.assert_called_once_with()
def test_missing_package_is_never_sick(self) -> None:
try:
with patch.dict(sys.modules, {"sickdays": None}):
reloaded = importlib.reload(_sick)
assert reloaded._AVAILABLE is False
assert not reloaded.is_sick(TUESDAY)
assert not reloaded.sync()
finally:
importlib.reload(_sick)
assert _sick._AVAILABLE is True
class TestFallbackPolicy:
def test_a_sick_day_never_rings(self) -> None:
facts = Facts(now=A + timedelta(minutes=20), alarm_at=A, free_day=False)
assert decide(facts).ring
sick = Facts(
now=A + timedelta(minutes=20), alarm_at=A, free_day=False, sick_day=True
)
assert decide(sick) == Decision(ring=False, reason=SICK_DAY)
assert stop_reason(sick) == SICK_DAY
@pytest.fixture
def alarm_seven() -> Generator[None]:
with (
patch.object(_fallback_observe, "get_alarm", return_value=Alarm(7, 0, "c")),
patch.object(_fallback, "get_alarm", return_value=Alarm(7, 0, "c")),
):
yield
@pytest.mark.usefixtures("alarm_seven")
class TestFallback:
def test_in_window_act_pulls_and_stays_offline(self, sync: MagicMock) -> None:
sync.side_effect = pull_marks_tuesday
fetch = MagicMock()
with patch.object(_fallback_observe, "fetch_firebase_logs", fetch):
seen = _fallback_observe.observe(A + timedelta(minutes=3), sync=True)
assert seen.facts.sick_day
sync.assert_called_once_with()
fetch.assert_not_called()
def test_dry_run_never_pulls(self, sync: MagicMock) -> None:
mark_sick(TUESDAY)
seen = _fallback_observe.observe(A + timedelta(minutes=3), sync=False)
assert seen.facts.sick_day
sync.assert_not_called()
def test_off_window_tick_never_pulls(self, sync: MagicMock) -> None:
_fallback_observe.observe(A - timedelta(hours=2), sync=True)
sync.assert_not_called()
def test_a_ring_in_progress_stops(self) -> None:
mark_sick(TUESDAY)
mark_started(None, day=TUESDAY.isoformat(), now=A, reason="phone_silent")
with patch.object(_fallback, "ring_now") as ring_now:
decision = _fallback.tick(A + timedelta(minutes=5))
assert decision == Decision(ring=False, reason=SICK_DAY)
ring_now.assert_not_called()
latch = _fallback.read_latch(TUESDAY.isoformat())
assert latch is not None
assert latch.stop_reason == SICK_DAY
def test_reports_say_sick(self, capsys: pytest.CaptureFixture[str]) -> None:
mark_sick(TUESDAY)
_fallback.main(["--dry-run", "--at", "2026-10-06T07:05:00"])
assert "quiet: sick_day" in capsys.readouterr().out
with patch.object(_fallback, "_now", return_value=A):
_fallback.main(["--show"])
assert "sick_day=True" in capsys.readouterr().out
class TestRtc:
def test_a_sick_stick_day_is_skipped(self) -> None:
assert _rtc.plan(MONDAY_EVENING, Alarm(7, 0, "c")).arm
mark_sick(TUESDAY)
chosen = _rtc.plan(MONDAY_EVENING, Alarm(7, 0, "c"))
assert (chosen.arm, chosen.reason) == (False, "sick day")
def test_arm_pulls_dry_run_does_not(self, sync: MagicMock) -> None:
sync.side_effect = pull_marks_tuesday
with (
patch.object(_rtc, "get_alarm", return_value=Alarm(7, 0, "c")),
patch.object(_rtc, "_now", return_value=MONDAY_EVENING),
patch.object(_rtc, "configure_logging"),
patch.object(_rtc, "cancel_for_sick_day") as retract,
):
assert _rtc.main(["--dry-run"]) == 0
sync.assert_not_called()
retract.assert_not_called()
assert _rtc.main(["--arm"]) == 0 # a skip is not a failure
sync.assert_called_once_with()
retract.assert_called_once_with()
def test_the_trigger_skips_a_sick_day() -> None:
assert _trigger._skip_reason(A) is None
mark_sick(TUESDAY)
assert _trigger._skip_reason(A) == "sick day"

View File

@ -0,0 +1,175 @@
"""Sick days and the workday penalty, the session refresh and the audit."""
from __future__ import annotations
from dataclasses import replace
from datetime import date, datetime
import json
import logging
from pathlib import Path
from unittest.mock import patch
import pytest
import sickdays
from wake_alarm import _audit, _session, _workday_penalty
from wake_alarm._audit_checks import (
check_lockdown,
check_penalty,
check_ring,
check_rtc,
check_up_by_alarm,
)
from wake_alarm._audit_evidence import Evidence, PcSide, PhoneSide
from wake_alarm._enforcement_records import PhoneEvent
from wake_alarm._sync import Alarm
from wake_alarm._workday_penalty import (
cancel_for_sick_day,
issued_date,
maybe_apply,
penalty_entry,
skip_reason,
write_penalty,
)
TUESDAY = date(2026, 9, 22)
AFTER_GRACE = datetime(2026, 9, 22, 9, 0).astimezone()
MISS: dict[str, object] = {
"outcome": "none",
"exempt_until": datetime(2026, 9, 22, 8, 15).astimezone().isoformat(),
}
def mark_sick(day: date) -> None:
"""Put ``day`` in the (redirected) pool; may be in the past."""
sickdays.import_legacy([day])
def pull_marks_tuesday() -> bool:
"""A sync that brings TUESDAY in from another device."""
mark_sick(TUESDAY)
return True
@pytest.fixture
def issued() -> None:
"""A penalty issued on TUESDAY, on disk."""
assert write_penalty(penalty_entry(today=TUESDAY))
class TestWriting:
def test_a_sick_day_is_never_penalised(self) -> None:
assert skip_reason(MISS, AFTER_GRACE) is None
mark_sick(TUESDAY)
assert skip_reason(MISS, AFTER_GRACE) == "sick day"
assert not maybe_apply(MISS, AFTER_GRACE)
assert issued_date() is None
def test_the_first_write_pulls_the_pool(self) -> None:
with patch("sickdays.sync_quietly", side_effect=pull_marks_tuesday) as pulled:
assert not maybe_apply(MISS, AFTER_GRACE)
pulled.assert_called_once_with()
assert issued_date() is None
def test_a_rewrite_does_not_pull_again(self) -> None:
assert maybe_apply(MISS, AFTER_GRACE)
with patch("sickdays.sync_quietly") as pulled:
assert maybe_apply(MISS, AFTER_GRACE)
pulled.assert_not_called()
def test_other_skip_reasons(self) -> None:
assert skip_reason(MISS, AFTER_GRACE, escaped_dates=frozenset({"2026-09-22"}))
monday = datetime(2026, 9, 21, 9, 0).astimezone()
assert skip_reason(MISS, monday) == "not a stick day"
assert skip_reason({"outcome": "completed"}, AFTER_GRACE) == "no miss"
with patch.object(_workday_penalty.freedays, "is_free_day", return_value=True):
assert skip_reason(MISS, AFTER_GRACE) == "free day"
@pytest.mark.usefixtures("issued")
class TestRetraction:
def test_kept_while_not_sick(self) -> None:
assert not cancel_for_sick_day()
assert issued_date() == TUESDAY.isoformat()
def test_retracted_once_the_issuing_day_is_sick(
self, caplog: pytest.LogCaptureFixture
) -> None:
mark_sick(TUESDAY)
with caplog.at_level(logging.INFO):
assert cancel_for_sick_day()
assert issued_date() is None
assert "penalty_cancelled: sick day for 2026-09-22" in caplog.text
def test_a_sick_penalty_date_alone_does_not_retract(self) -> None:
mark_sick(date(2026, 9, 23))
assert not cancel_for_sick_day()
def test_unlink_failure_keeps_it(self) -> None:
mark_sick(TUESDAY)
with patch.object(Path, "unlink", side_effect=OSError("ro")):
assert not cancel_for_sick_day()
def test_garbage_issued_date_is_kept(self) -> None:
_workday_penalty.WORKDAY_PENALTY_FILE.write_text(
json.dumps({"issued_date": "soon"})
)
assert not cancel_for_sick_day()
def test_refresh_retracts_even_offline(self) -> None:
mark_sick(TUESDAY)
with patch.object(_session, "fetch_firebase_logs", return_value=None):
assert _session.refresh(AFTER_GRACE) is None
assert issued_date() is None
def test_no_penalty_file_nothing_to_retract() -> None:
assert not cancel_for_sick_day()
def _ev(**overrides: object) -> Evidence:
pc = PcSide(penalty_issued=overrides.pop("penalty_issued", None))
phone = PhoneSide(events=overrides.pop("phone", []))
base = Evidence(
day=TUESDAY,
stick_day=True,
free_day=False,
alarm_at=AFTER_GRACE,
alarm_source="t",
pc_side=pc,
phone_side=phone,
sick_day=True,
)
return replace(base, **overrides)
class TestAudit:
def test_nothing_is_expected(self) -> None:
for check in (check_rtc, check_up_by_alarm, check_ring, check_lockdown):
result = check(_ev())
assert (result.ok, result.detail) == (None, "sick day")
def test_penalty(self) -> None:
assert check_penalty(_ev()).ok is True
assert check_penalty(_ev(penalty_issued="2026-09-22")).ok is False
def test_a_lockdown_for_a_sick_miss_is_flagged(self) -> None:
at = AFTER_GRACE.isoformat()
phone = [
PhoneEvent(at, "stick_check", {"date": "2026-09-22", "verdict": "miss"}),
PhoneEvent(at, "lockdown_sent", {"until": "2026-09-23"}),
]
assert check_lockdown(_ev(phone=phone)).ok is False
assert check_lockdown(_ev(phone=phone[:1])).ok is None
def test_the_report_names_the_day(self) -> None:
mark_sick(TUESDAY)
with (
patch.object(_audit, "fetch_firebase_logs", return_value=[]),
patch.object(_audit, "get_alarm", return_value=Alarm(7, 0, "cache")),
patch.object(_audit, "boots", return_value=[]),
patch.object(_audit, "resumes", return_value=[]),
):
evidence, _ = _audit.gather(TUESDAY)
assert evidence.sick_day
assert "(Tue, sick day)" in _audit.render(evidence, [], reachable=True)