screen-locker/screen_locker/_compliance_predicates.py
Krzysztof kuhy Rudnicki 967e3cd322 Key every workout day on the LOCAL calendar day, not UTC
A manual workout logged at 00:02 CEST on 2026-09-13 was filed under
2026-09-12: every day-key derivation used datetime.now(tz=UTC), and at
that instant UTC was still yesterday. The 09:18 lock check looked for
2026-09-13, found nothing, and locked the PC — for a workout that was on
disk. The budget counter (a trailing window) counted it fine, which is
why the status showed 1/2 used while the locker still locked. Any log
between local midnight and 02:00 CEST hit this; it had happened before.

- New screen_locker/_day.py: today_str() is the one definition of "what
  day is it" — the local calendar day the user sees on the clock, which
  is also what the phone app, RunnerUp verification, the status window
  and the freedays pool already used. All 14 UTC sites (log writer, lock
  predicates, explain chain, early-bird, wake state, shutdown/sick state,
  sick budget, manual CLI, phone verification) now go through it, writers
  and readers together so no new midnight window opens between them.
- write_signed_entry dedups by workout_id across ALL days: a workout id is
  globally unique, and an entry moved to its correct day must not let its
  synced twin (still stamped with the old day) re-ingest as a duplicate.
- scripts/rekey_log_entry.py: move a wrongly-filed entry to its real day
  (the HMAC never covered the day key, so it still verifies).
- test_local_day_key.py freezes the clock at the exact failing instant.

Replayed the real timeline (log at 00:02:35, evaluate at 09:18:53, weekly
3/5) against HEAD in a throwaway worktree vs this tree:
  HEAD  -> logged under 2026-09-12, LOCK
  fixed -> logged under 2026-09-13, no lock (already_logged)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcU5dCcUNHMAMzUNChdrrJ
2026-09-13 09:50:06 +02:00

157 lines
6.0 KiB
Python

"""Pure, read-only leaf predicates for the lock-decision chain.
Split out of :mod:`screen_locker._compliance_state` to keep every file under
the 250-line cap. That module re-exports everything here, so existing
importers are unaffected.
Nothing in this module touches ADB, sudo, subprocess, or the network, and
nothing writes to disk.
Note for tests: the ``gatelock.log_integrity`` helpers are imported *here*
now, so patch them at ``screen_locker._compliance_predicates.verify_entry_hmac``
rather than at the old ``_compliance_state`` path.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
import freedays
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
from screen_locker._constants import (
EARLY_BIRD_END_HOUR,
EARLY_BIRD_END_MINUTE,
EARLY_BIRD_START_HOUR,
)
from screen_locker._day import today_str
from screen_locker._log_io import load_workout_log
from screen_locker._sick_tracker import is_sick_day as _is_sick_day
from screen_locker._weekly_check import RELAXED_DAY_SKIP_TYPE
if TYPE_CHECKING:
from datetime import date
from pathlib import Path
from screen_locker._sick_tracker import SickHistory
_logger = logging.getLogger(__name__)
def _today_str() -> str:
"""Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``)."""
return today_str()
def is_scheduled_skip_today(today: date | None = None) -> bool:
"""Return True if *today* is in the shared free-day pool.
The source moved from this app's own ``scheduled_skips.json`` to the
fleet-wide pool (``~/src/utils/freedays``), so one marked day now stands down
every gate app at once instead of only this one.
The date is **local**, not UTC. The file-backed check this replaces
compared against a UTC day while ``add_scheduled_skip.py`` wrote a local
one, so a skip added near midnight could name a date the chain never
matched. freedays owns "what is today" for all of them now.
Still fails closed: an unreadable pool reads as "not free", so the lock
chain continues rather than the lock silently switching itself off.
"""
return freedays.is_free_day(today)
def has_logged_today(log_file: Path, *, today: str | None = None) -> bool:
"""Return True if a validly-signed workout is logged for *today*.
The day may hold multiple workouts; return True if ANY of today's entries
verifies (or is acceptably unsigned when no HMAC key is configured).
A ``relaxed_day_skip`` entry does NOT count — it is a UI-dismissal marker,
not a workout, and must not be mistaken for one by ``workout_logged_today``
(which would otherwise mask the more accurate
``relaxed_day_already_skipped`` reason in the decision trail).
"""
entries = [
entry
for entry in load_workout_log(log_file).get(today or _today_str(), [])
if entry.get("workout_data", {}).get("type") != RELAXED_DAY_SKIP_TYPE
]
if not entries:
return False
key_unavailable = compute_entry_hmac({"_probe": True}) is None
for entry in entries:
if verify_entry_hmac(entry):
return True
if key_unavailable and "hmac" not in entry:
_logger.info("HMAC key unavailable — accepting unsigned entry")
return True
_logger.warning("HMAC verification failed for today's log entries")
return False
def is_relaxed_day_skipped_today(log_file: Path, *, today: str | None = None) -> bool:
"""Return True if today's relaxed-day prompt was already dismissed.
Written by ``UIFlowsRelaxedMixin._skip_relaxed_day`` when the user clicks
"Skip — No Penalty". Without this, the recurring 30-minute
``workout-locker.timer`` re-derives ``relaxed_day`` from scratch every
tick and re-shows the popup, since ``is_relaxed_day`` is a pure date
check with no memory of a same-day dismissal on its own.
"""
entries = load_workout_log(log_file).get(today or _today_str(), [])
return any(
entry.get("workout_data", {}).get("type") == RELAXED_DAY_SKIP_TYPE
for entry in entries
)
def is_early_bird_pending(pending_file: Path, *, today: str | None = None) -> bool:
"""Return True if today has an unresolved early-bird pending marker."""
if not pending_file.exists():
return False
try:
with pending_file.open() as f:
state = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
_logger.warning(
"Could not read the early-bird pending marker at %s: %s — treating "
"it as not pending, so the lock chain proceeds as if no early bird "
"was claimed today",
pending_file,
exc,
)
return False
if not isinstance(state, dict) or state.get("date") != (today or _today_str()):
return False
if verify_entry_hmac(state):
return True
if compute_entry_hmac({"_probe": True}) is None and "hmac" not in state:
_logger.info("HMAC key unavailable — accepting unsigned pending marker")
return True
_logger.warning("HMAC verification failed for early-bird pending marker")
return False
def is_sick_day_today(history: SickHistory, *, today: str | None = None) -> bool:
"""Return True if today is recorded as a sick day.
Thin wrapper around ``_sick_tracker.is_sick_day`` kept here so callers of
the status layer have one predicate module to import from.
"""
return _is_sick_day(history, today=today)
def _early_bird_window_open(*, extended: bool, local_minutes: int) -> bool:
"""Deliberate, independent reimplementation of ``_is_early_bird_time``.
Not shared with ``EarlyBirdMixin._is_early_bird_time`` because too many
existing tests are pinned to its ``_get_local_time_minutes`` patch point.
Kept here as a small, separate, pure re-implementation for the status
layer only — see the module docstring in ``_early_bird.py``.
"""
start = EARLY_BIRD_START_HOUR * 60
end = 9 * 60 if extended else EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
return start <= local_minutes < end