screen-locker/screen_locker/_decision_log.py
Krzysztof kuhy Rudnicki e5db25468d
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Re-arm the locker, and make a disarmed enforcer impossible to miss
The screen locker stopped locking on 2026-08-04 and nobody could tell. Not a
logic bug: every predicate worked the whole time. systemd logged

  timers.target: Found ordering cycle: early-bird-workout-check.timer/start
    after graphical-session.target/start after basic.target/start after
    timers.target/start - after early-bird-workout-check.timer
  timers.target: Job early-bird-workout-check.timer/start deleted to break
    ordering cycle starting with timers.target/start

and left the timer disabled. A timer that is WantedBy=timers.target must not be
ordered After= a target that itself comes after timers.target; systemd resolves
that loop by deleting the job, which is an absence, and absences page nobody.

That timer is the ONLY thing that closes the early-bird window. Each morning a
run inside the window banked early_bird_pending.json, logged "will re-check at
08:30", and exited 0. The re-check never came, so the marker stood all day,
every day, for thirteen days.

It stayed invisible because logging.basicConfig was called only in the
--sync-only branch, never for --production -- the mode systemd actually runs.
Journal proof: zero INFO lines from workout-locker.service since June. Every
early exit logged at INFO into a logger with no handler. Meanwhile the healthy
15-minute sync kept the system looking alive.

The 2026-08-15 Python split is NOT implicated: it was pure code motion, and
basicConfig already sat in that branch before it.

Fixes:

* Drop the After= that closes the cycle; Persistent=true so a machine asleep at
  08:30 still re-checks after resume.
* Add workout-locker.timer (*:00,30). The service is WantedBy=
  graphical-session.target, i.e. one decision per login, so the 08-16 -> 08-17
  day and ISO-week rollover inside one long session was never re-evaluated.
* arm.sh: install units, enable timers, verify -- one command, because
  re-arming was a four-command sequence that was easy to half-do, and
  `systemctl enable` returns 0 while systemd deletes the timer job.
* check_enforcement_armed.py asks systemd what it actually scheduled (enabled
  AND has a next trigger) and fails loudly otherwise. Wired as ExecStartPost on
  workout-sync.service, so a disarmed locker marks that unit failed every 15
  minutes instead of scrolling past. install_systemd.sh now fails on it too.

Logging (the reason this went unnoticed, so treated as a defect in its own
right):

* basicConfig for every mode, never one branch.
* Every enforcement-abandoning exit records a decision instead of an invisible
  INFO: one greppable line -- DECISION lock=no reason=early_bird_window_active
  weekly=0/5 -- at WARNING, since per CLAUDE.md an INFO explanation of a
  non-action is a silent failure with extra steps. The enforced case is logged
  too: "no line at all" was the outage's whole signature.
* A durable JSONL trail outside the repo (~/.local/share/screen_locker/), so
  "when did this last actually enforce?" survives journal rotation. --status
  and --sync-only record lock=n/a; "did not decide" must never look like
  "decided not to lock".
* check_silent_failures.py now also catches sys.exit(0) that explains nothing.
  Verified both directions: it flags 3/3 exits in the pre-fix _auto_upgrade.py
  and passes the repo clean.

Sync noise: four records replayed as WARNINGs every cycle (~384/day), which is
how the actionable lines got tuned out. manual_workout:2026-08-15 is an empty
stub ({date, kind, type}) -- no workout to recover, and that day's credit was
already counted under a different sync_record_id, so no credit was ever lost.
The three "collided" records are dedup working. Both now report at info; a
WARNING from this logger again means something real. 4 -> 0 per cycle.

Verified on the live system: the periodic timer fired at 16:45 and the journal
shows `DECISION lock=no reason=scheduled_skip_day weekly=0/5` -- the line that
did not exist for thirteen days. Both timers report enabled and scheduled, and
no ordering cycle recurs. Today is a scheduled skip so the machine was not
locked mid-session; enforcement resumes tomorrow 08:30. 1007 tests, 100%
coverage, all hooks green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2b6ZjBcdrkSYnMujx4mWd
2026-08-17 16:47:25 +02:00

228 lines
8.6 KiB
Python

"""One structured, durable record of every lock decision the locker makes.
This module exists because of a multi-day outage that produced *no evidence*.
Between 2026-08-04 and 2026-08-17 the locker stopped enforcing, and the only
trace in the journal was its absence: ``workout-locker.service`` started, ran
for six seconds, exited ``0/SUCCESS``, and said nothing about why. Every
early-exit reason was logged at ``INFO``, and ``logging.basicConfig`` was only
ever called on the ``--sync-only`` path, so ``--production`` emitted no INFO at
all -- zero such lines since June.
Two things follow from that, and this module provides both:
* **A single grep-able line per run.** ``DECISION lock=no reason=... weekly=0/5``
is one line a human or an agent can find without knowing the code, and it is
emitted at ``WARNING`` when enforcement was skipped, because per CLAUDE.md an
``INFO``-level explanation of a non-action is a silent failure with extra
steps.
* **A durable JSONL trail.** The journal rotates and is per-boot; a decision
history that outlives it is what makes "when did this last actually enforce?"
answerable in one command instead of a forensic session.
The file is deliberately outside the repo (see ``DECISION_LOG_FILE``): the
2026-07 privacy incident purged workout state JSONs from git history, and they
must never be re-tracked.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
import json
import logging
from pathlib import Path
from typing import Final
__all__ = [
"DECISION_LOG_FILE",
"DECISION_LOG_MAX_ENTRIES",
"LockDecision",
"read_decisions",
"record_decision",
]
_logger = logging.getLogger(__name__)
# Outside the repo on purpose -- workout state is private and git-untracked.
DECISION_LOG_FILE: Path = (
Path.home() / ".local" / "share" / "screen_locker" / "decisions.jsonl"
)
# Trimmed on write so the recurring timers cannot grow this without bound.
# Writers are the 30-minute locker timer plus the 15-minute sync run (which
# records "made no decision"), i.e. ~100 lines/day -> roughly a month of
# history. That comfortably covers the 13-day blind spot this file exists to
# make impossible.
DECISION_LOG_MAX_ENTRIES: Final[int] = 3000
@dataclass(frozen=True)
class LockDecision:
"""Why the locker did or did not enforce on one run.
``reason`` is a stable machine-readable slug (``early_bird_pending``,
``weekly_minimum_met``, ``enforced``...), not prose, so it can be grepped
and counted across runs. ``detail`` carries the human sentence.
"""
locked: bool
reason: str
detail: str = ""
weekly_count: int | None = None
weekly_required: int | None = None
extra: dict[str, object] = field(default_factory=dict)
def as_line(self) -> str:
"""Render the single grep-able summary line.
Keys are ``key=value`` pairs so the line stays readable by eye and
parseable by ``awk``/``grep -o`` without a JSON decoder.
"""
parts = [
"DECISION",
f"lock={'yes' if self.locked else 'no'}",
f"reason={self.reason}",
]
if self.weekly_required is not None:
parts.append(f"weekly={self.weekly_count}/{self.weekly_required}")
for key, value in self.extra.items():
parts.append(f"{key}={value}")
if self.detail:
parts.append(f"detail={self.detail!r}")
return " ".join(parts)
def as_record(self) -> dict[str, object]:
"""Render the durable JSON object written to the trail."""
record: dict[str, object] = {
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"locked": self.locked,
"reason": self.reason,
}
if self.detail:
record["detail"] = self.detail
if self.weekly_required is not None:
record["weekly_count"] = self.weekly_count
record["weekly_required"] = self.weekly_required
record.update(self.extra)
return record
def _trimmed(lines: list[str], new_line: str) -> list[str]:
"""Return the retained history with ``new_line`` appended."""
if len(lines) >= DECISION_LOG_MAX_ENTRIES:
lines = lines[-(DECISION_LOG_MAX_ENTRIES - 1) :]
return [*lines, new_line]
def record_decision(decision: LockDecision, *, log_file: Path | None = None) -> None:
"""Log ``decision`` to the journal and append it to the durable trail.
Never raises: a failure to *record* why the locker acted must not stop the
locker from acting. A write failure is reported at ``warning`` rather than
swallowed, so the recording gap is itself visible.
"""
line = decision.as_line()
# Not-enforcing is the state that hid the outage, so it is the state that
# gets the louder level. An enforced lock is self-evident on screen.
if decision.locked:
_logger.info("%s", line)
else:
_logger.warning("%s", line)
_append_record(decision.as_record(), log_file=log_file)
def _append_record(record: dict[str, object], *, log_file: Path | None = None) -> None:
"""Append one record to the durable trail, trimming old history.
Never raises: failing to *record* why the locker acted must not stop the
locker from acting. A write failure is reported at ``warning`` rather than
swallowed, so the gap in the trail is itself visible.
"""
target = DECISION_LOG_FILE if log_file is None else log_file
try:
target.parent.mkdir(parents=True, exist_ok=True)
existing = (
target.read_text(encoding="utf-8").splitlines() if target.exists() else []
)
kept = _trimmed(
[entry for entry in existing if entry.strip()],
json.dumps(record),
)
target.write_text("\n".join(kept) + "\n", encoding="utf-8")
except OSError as exc:
_logger.warning(
"Could not append to the decision log at %s (%s) — this run's "
"decision is in the journal only, so the durable trail now has a "
"gap",
target,
exc,
)
def record_no_decision(mode: str, *, log_file: Path | None = None) -> None:
"""Note that a run finished WITHOUT evaluating the lock at all.
``--sync-only`` and ``--status`` never reach the decision ladder. Recording
that explicitly is what keeps the trail honest: during the outage the
journal was full of healthy 15-minute sync runs, which made the system look
alive while no enforcement decision had been made for days. "This run did
not decide" and "this run decided not to lock" must never look alike.
Written at ``info`` because these modes run constantly and are expected;
the durable trail is what makes them auditable, not the log level.
"""
_logger.info(
"DECISION lock=n/a reason=mode_makes_no_decision mode=%s — this mode "
"never evaluates the lock",
mode,
)
_append_record(
{
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"locked": None,
"reason": "mode_makes_no_decision",
"mode": mode,
},
log_file=log_file,
)
def read_decisions(*, log_file: Path | None = None) -> list[dict[str, object]]:
"""Return the recorded decisions, oldest first.
Corrupt lines are skipped with a warning rather than aborting the read: a
partially damaged trail is still worth far more than no trail.
"""
target = DECISION_LOG_FILE if log_file is None else log_file
try:
raw = target.read_text(encoding="utf-8")
except FileNotFoundError:
# Genuinely benign and expected before the first run ever records a
# decision, but still said out loud: "no history" and "history I could
# not read" must never look the same to whoever is investigating.
_logger.warning(
"No decision log at %s yet — no lock decision has been recorded "
"on this machine (this is normal before the first locker run)",
target,
)
return []
except OSError as exc:
_logger.warning("Could not read the decision log at %s (%s)", target, exc)
return []
records: list[dict[str, object]] = []
for line in raw.splitlines():
if not line.strip():
continue
try:
parsed = json.loads(line)
except json.JSONDecodeError as exc:
_logger.warning("Skipping a corrupt decision-log line (%s)", exc)
continue
if isinstance(parsed, dict):
records.append(parsed)
else:
_logger.warning("Skipping a decision-log line that is not an object")
return records