wake-alarm/wake_alarm/_audit_stick.py
Krzysztof kuhy Rudnicki 8696f2d0bf 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
2026-10-16 08:45:29 +02:00

147 lines
5.1 KiB
Python

"""The stick-day checks: penalty and lockdown, keyed by the day *judged*.
The phone's ``stick_check`` carries ``data.date`` -- the morning it judged.
A boot or watchdog check catches up to five past days, so a Tuesday can be
judged on Thursday; keying by the event's ``at`` would pin that verdict on
the wrong morning. Verdict ``miss`` is the phone's miss; with no
``stick_check`` for the day, ``stick_state.last_miss`` stands in.
A lockdown follows the check that found the miss, so it is expected on the
*judging* day. A miss caught up after its lockdown would already have ended
sends nothing (the phone's own rule), so a late judgement without a
``lockdown_sent`` is noted, not flagged.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from wake_alarm._audit_evidence import (
bad,
na,
ok,
pc_on_day,
phone_kinds,
phone_on_day,
)
from wake_alarm._enforcement_payload import is_honoured
from wake_alarm._enforcement_records import parse_instant
if TYPE_CHECKING:
from datetime import date
from wake_alarm._audit_evidence import Check, Evidence
from wake_alarm._enforcement_records import PhoneEvent
MISS = "miss"
_PROVISIONAL = "too_early"
def verdicts(ev: Evidence) -> list[PhoneEvent]:
"""``stick_check`` events that judged the audited day, final ones only."""
day = ev.day.isoformat()
return [
e
for e in ev.phone
if e.kind == "stick_check"
and e.data.get("date") == day
and e.data.get("verdict") != _PROVISIONAL
]
def miss_judged_on(ev: Evidence) -> date | None:
"""The day the phone judged this morning a miss, if it did.
Falls back to ``stick_state.last_miss`` (judged that same day, as far as
can be told) when no ``stick_check`` for the day was published.
"""
for event in verdicts(ev):
if event.data.get("verdict") == MISS:
at = parse_instant(event.at)
return at.date() if at is not None else ev.day
state = ev.stick_state
if state is not None and state.last_miss == ev.day.isoformat():
return ev.day
return None
def _escaped(ev: Evidence) -> bool:
"""Whether an honoured escape excuses the audited day."""
return any(is_honoured(e) for e in ev.escapes)
def check_penalty(ev: Evidence) -> Check:
"""The PC penalty agrees with the outcome and any escape."""
name = "penalty consistent"
escaped = _escaped(ev)
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")
if miss_judged_on(ev) is not None and not written:
return bad(name, "phone counted a miss, PC wrote none")
return ok(name, "penalty written" if written else "no miss, no penalty")
def _sent_on(ev: Evidence, judged: date) -> list[PhoneEvent]:
"""``lockdown_sent`` events stamped on the judging day."""
sent: list[PhoneEvent] = []
for event in ev.phone:
at = parse_instant(event.at)
if event.kind == "lockdown_sent" and at is not None and at.date() == judged:
sent.append(event)
return sent
def _lockdown_for_miss(ev: Evidence, judged: date) -> Check:
"""The lockdown the miss judged on ``judged`` should have sent."""
name = "lockdown consistent"
sent = _sent_on(ev, judged)
if not sent:
if judged > ev.day:
return ok(name, f"miss caught up on {judged}; lockdown may have expired")
return bad(name, "phone judged a miss, no lockdown_sent")
state = ev.stick_state
until = sent[-1].data.get("until")
if (
state is not None
and state.last_miss == ev.day.isoformat()
and until != state.locked_through
):
return bad(
name,
f"lockdown_sent until {until}, "
f"stick_state locked_through {state.locked_through}",
)
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"
judged = miss_judged_on(ev)
sent_today = [e for e in phone_on_day(ev) if e.kind == "lockdown_sent"]
if _escaped(ev) and sent_today:
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):
return bad(name, "lockdown_sent without a recorded miss")
return na(name, "no miss")