diff --git a/screen_locker/_lock_invariant.py b/screen_locker/_lock_invariant.py new file mode 100644 index 0000000..60d33fe --- /dev/null +++ b/screen_locker/_lock_invariant.py @@ -0,0 +1,59 @@ +"""The one thing the enforce path must never do: lock over a logged workout. + +Every skip in ``_startup_checks._check_non_verify_exits`` is a rung of a +first-match-wins ladder, and the ladder's ORDER is what decides whether a +logged workout counts. That order has been wrong before: on 2026-09-18 an +expired early-bird marker sat one rung above ``workout_logged_today``, so a +walk logged on the lock screen at 09:32 closed the lock and the 09:35 tick +locked again -- and would have every five minutes for the rest of the day. +The DECISION line even printed ``also=workout_logged_today``; the evidence +was there, only the gate was missing. + +This module is that gate. It runs right before the ``enforced`` decision is +recorded and refuses to build a lock screen when a counted workout for today +is already in the log, whatever the ladder above it concluded. Reaching it +with a workout logged is a bug in the ladder, so it is reported at ERROR -- +the locker must fail safe (no lock) and say so, not fail closed on the user. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + +_logger = logging.getLogger(__name__) + +GUARD_REASON = "workout_logged_today_guard" +GUARD_DETAIL = ( + "A counted workout is already logged today but the exemption ladder fell " + "through to enforcement — refusing to lock (ladder bug, see journal ERROR)." +) + + +def refuse_to_lock_over_logged_workout( + has_logged_today: Callable[[], bool], + record_skip: Callable[[str, str], None], +) -> bool: + """Skip enforcement if today already holds a counted workout. + + Takes the locker's two relevant methods rather than the locker itself so + the guard stays a pure function of "is there a workout?" and "how do I + skip?". Returns True when the guard fired (the skip was recorded through + *record_skip*, which exits the process), False when the lock may go + ahead. The ERROR is deliberate: a fired guard means a rung above it is + misordered, and that has to be visible in the journal rather than quietly + absorbed as one more skip. + """ + if not has_logged_today(): + return False + _logger.error( + "Enforcement reached with a workout already logged today — the " + "early-exit ladder in _startup_checks/_auto_upgrade is misordered. " + "Refusing to lock; fix the ladder so workout_logged_today is reached." + ) + record_skip(GUARD_REASON, GUARD_DETAIL) + return True diff --git a/screen_locker/_startup_checks.py b/screen_locker/_startup_checks.py index 90f1d52..1ea7982 100644 --- a/screen_locker/_startup_checks.py +++ b/screen_locker/_startup_checks.py @@ -29,6 +29,7 @@ from screen_locker._decision_log import ( ) from screen_locker._degraded_sources import degraded_sources from screen_locker._extra_benefits import process_week_transition +from screen_locker._lock_invariant import refuse_to_lock_over_logged_workout from screen_locker._shutdown_base import ( apply_leetcode_bonus_if_new, reset_to_base_if_new_day, @@ -161,6 +162,9 @@ class StartupChecksMixin(SyncMixin): # "skip a workout" credit — that mechanic works against the goal of # maximizing weekly workouts, so it was removed in favor of a # shutdown-time-only reward (see _apply_weekly_shutdown_bonus). + # A logged workout outranks every rung above, whatever their order. + if refuse_to_lock_over_logged_workout(self.has_logged_today, self._record_skip): + return heat = self._check_heat_skip_exit() # Nothing excused today: falling through here means the lock WILL be # built. Recorded explicitly so the trail shows enforcement happening, diff --git a/screen_locker/tests/test_lock_invariant.py b/screen_locker/tests/test_lock_invariant.py new file mode 100644 index 0000000..0462960 --- /dev/null +++ b/screen_locker/tests/test_lock_invariant.py @@ -0,0 +1,117 @@ +"""Tests for the enforce-path guard in ``_lock_invariant``. + +The ladder above the guard is exercised rung-by-rung elsewhere +(``test_weekly_logic_part2``). These tests force the whole ladder to fall +through -- the shape of every ordering bug it could ever have -- and check +that a logged workout still cannot be locked over. +""" + +from __future__ import annotations + +from contextlib import ExitStack +import logging +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker._lock_invariant import ( + GUARD_DETAIL, + GUARD_REASON, + refuse_to_lock_over_logged_workout, +) +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +class TestRefuseToLockOverLoggedWorkout: + """The guard function on its own.""" + + def test_nothing_logged_lets_the_lock_proceed(self) -> None: + """No workout today: the guard is inert and records nothing.""" + record_skip = MagicMock() + assert refuse_to_lock_over_logged_workout(lambda: False, record_skip) is False + record_skip.assert_not_called() + + def test_logged_workout_records_the_guard_skip_and_logs_error( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """A logged workout fires the guard: skip recorded, ERROR in the journal.""" + record_skip = MagicMock() + with caplog.at_level(logging.ERROR, logger="screen_locker._lock_invariant"): + assert refuse_to_lock_over_logged_workout(lambda: True, record_skip) is True + record_skip.assert_called_once_with(GUARD_REASON, GUARD_DETAIL) + assert any( + record.levelno == logging.ERROR and "misordered" in record.getMessage() + for record in caplog.records + ) + + +class TestEnforcePathChokepoint: + """``_check_non_verify_exits`` with every rung above the guard falling through. + + ``_check_today_state_exits`` is stubbed to return False -- that is what an + expired early-bird marker did on 2026-09-18, and what any future + misordering of that ladder would do again -- so the only thing standing + between a logged workout and a lock screen is the guard. + """ + + @staticmethod + def _fall_through_the_ladder(locker: object) -> ExitStack: + stack = ExitStack() + for patcher in ( + patch.object(locker, "_is_scheduled_skip_today", return_value=False), + patch.object(locker, "_ingest_synced_manual_workouts"), + patch.object(locker, "_auto_fill_week_runnerup_bonus"), + patch.object(locker, "_check_today_state_exits", return_value=False), + patch.object(locker, "_check_heat_skip_exit", return_value="skipped"), + patch( + "screen_locker._startup_checks.process_week_transition", return_value=[] + ), + patch( + "screen_locker._startup_checks.reset_to_base_if_new_day", + return_value=False, + ), + patch("screen_locker._startup_checks.apply_leetcode_bonus_if_new"), + ): + stack.enter_context(patcher) + return stack + + def test_logged_workout_is_never_locked_over( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Ladder fell through with a workout logged: guard skip, no ``enforced``.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "has_logged_today", return_value=True), + patch("screen_locker._startup_checks.record_decision") as record, + self._fall_through_the_ladder(locker), + ): + locker._check_non_verify_exits() + reasons = [call.args[0].reason for call in record.call_args_list] + assert reasons == [GUARD_REASON] + assert record.call_args.args[0].locked is False + mock_sys_exit.assert_called_once_with(0) + + def test_no_workout_still_enforces( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Same fall-through with nothing logged: the lock goes ahead as before.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "has_logged_today", return_value=False), + patch("screen_locker._startup_checks.record_decision") as record, + self._fall_through_the_ladder(locker), + ): + locker._check_non_verify_exits() + assert [call.args[0].reason for call in record.call_args_list] == ["enforced"] + assert record.call_args.args[0].locked is True + mock_sys_exit.assert_not_called()