sickdays 0.2.0: per-locker keep set on a sick day
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Waiting to run
sickdays tests / test (push) Waiting to run

A sick day may keep some lockers active: a locker stands down iff the day
is sick and the locker is not in that day's keep set. New record field
`keep` = [sorted, de-duplicated locker ids, hlc].

- add/remove write state, actor and keep on one tick (keep=[] on a clear),
  so a stale keep cannot survive a clear + re-add.
- set_kept_lockers re-stamps only keep and actor; state keeps its clock, so
  a keep edit never re-asserts sick over a concurrent clear. Ticks are
  issued after every clock already on the record (clock-skew safe).
- Reading: no keep (0.1.0) or keep older than state -> keep nothing;
  malformed keep -> every locker kept (fail closed, logged); unknown ids
  ignored.
- API: stands_down, kept_lockers, set_kept_lockers, add_sick_day(keep=),
  SickDay.keep, LOCKER_* / LOCKERS. is_sick_day unchanged.
- CLI: add --keep, keep DAY [LOCKER ...], list shows keep, check --locker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 15:08:06 +02:00
parent 6f76ba7228
commit 0a51c9998d
15 changed files with 943 additions and 75 deletions

View File

@ -24,20 +24,48 @@ raises**. A missing file means "no sick days". An unreadable or corrupt file
is logged as a warning and also reads as *not sick*: it fails closed, so the
alarm rings and the locks apply.
### Keeping some lockers on a sick day (0.2.0)
A sick day may **keep** some lockers active. A locker *stands down* on a day
iff the day is sick **and** the locker is not in that day's keep set. A gate
that is one of the lockers asks the narrower question:
```python
if sickdays.stands_down(sickdays.LOCKER_LEETCODE): # default: today
... # this locker is off today
```
Locker ids (`LOCKER_*` constants): `workout`, `leetcode`, `book`, `diet`,
`alarm`. `alarm` is one switch for the phone ring, the PC fallback ring, the
RTC self-wake and the missed-alarm penalty.
`LOCKERS` is all five, in that order. `stands_down` never raises for a data
problem (only `ValueError` for an id not in `LOCKERS`). Every doubt answers
"the locks apply": an unreadable pool is not sick, a malformed keep set keeps
every locker. `is_sick_day` is unchanged, so a gate still on the 0.1.0 API
keeps working and treats every sick day as "everything stands down".
## For a person
```console
$ sickdays add # today
$ sickdays add 2026-10-20 # or 'tomorrow'
$ sickdays add tomorrow --keep leetcode --keep workout # those two stay on
$ sickdays keep 2026-10-20 diet # replace the kept set of a sick day
$ sickdays keep 2026-10-20 # keep nothing: every locker stands down again
$ sickdays remove 2026-10-20 # today or later only
$ sickdays list # every sick day, oldest first
2026-10-16 (today)
2026-10-20 keep: diet
$ sickdays check tomorrow # exit 0 if sick, 1 if not -- for scripts
$ sickdays check --locker alarm # exit 0 iff that locker stands down today
$ sickdays sync # pull and push now
```
`python -m sickdays ...` is the same command. Exit codes: `0` done, `1`
refused (past day, already sick, not sick, a free day, sync unavailable),
`2` input not understood.
`2` input not understood (a bad date, or a locker id not in `LOCKERS` --
`--keep`, `keep` and `--locker` only accept the five ids).
## API
@ -45,12 +73,19 @@ refused (past day, already sick, not sick, a free day, sync unavailable),
|---|---|
| `is_sick_day(day=None, *, log_path=None, now=None) -> bool` | the gate question; never raises |
| `sick_days(*, log_path=None) -> frozenset[date]` | every date currently sick |
| `add_sick_day(day, *, is_free=None, paths=None, now=None) -> SickDay` | today or later; refuses a free day |
| `stands_down(locker, day=None, *, log_path=None, now=None) -> bool` | the per-locker gate question; never raises for data, `ValueError` for an unknown id |
| `kept_lockers(day=None, *, log_path=None, now=None) -> frozenset[str]` | the day's kept lockers; empty when not sick |
| `add_sick_day(day, *, keep=(), is_free=None, paths=None, now=None) -> SickDay` | today or later; refuses a free day; `ValueError` on an unknown locker |
| `set_kept_lockers(day, keep, *, paths=None, now=None) -> SickDay` | replace an existing sick day's kept set; today or later |
| `remove_sick_day(day, *, paths=None, now=None) -> SickDay` | today or later |
| `import_legacy(days, *, paths=None) -> list[date]` | migration only: may write past dates; idempotent |
| `ensure_not_sick(day, *, paths=None)` | for freedays' callers, before `freedays.mark` |
| `sync()`, `sync_quietly()` | one pull/merge/push tick |
`SickDay` is `(day, is_sick, actor="", keep=frozenset())`. The locker ids are
`LOCKER_WORKOUT`, `LOCKER_LEETCODE`, `LOCKER_BOOK`, `LOCKER_DIET`,
`LOCKER_ALARM` and the tuple `LOCKERS`.
Refusals subclass `SickDayError`: `PastDateError`, `AlreadySickError`,
`NotSickError`, `FreeDayConflictError`, `SickDayConflictError`,
`ConflictCheckUnavailableError`, `UnreadablePoolError`. Writers refuse an
@ -81,7 +116,7 @@ import cycle:
|---|---|
| the pool | `~/.local/share/sickdays/sick_days.json` |
| device uuid (per install) | `~/.local/share/sickdays/device_id` |
| audit trail (HMAC-signed, append-only) | `~/.local/share/sickdays/audit.jsonl` + `hmac.key` |
| audit trail (HMAC-signed, append-only; actions `add`, `remove`, `keep`, `import`) | `~/.local/share/sickdays/audit.jsonl` + `hmac.key` |
| sync bookkeeping (local only) | `~/.local/share/sickdays/sync_state.json` |
Days are local calendar dates (`_day.today()`), the same as freedays.
@ -110,7 +145,8 @@ The log is a JSON object keyed by ISO date. Each value is a crdt-sync record:
"id": "2026-10-20",
"fields": {
"state": ["sick", "2026-10-16T08:12:03.517Z-0000-<uuid>"],
"actor": ["<uuid>", "2026-10-16T08:12:03.517Z-0000-<uuid>"]
"actor": ["<uuid>", "2026-10-16T08:12:03.517Z-0000-<uuid>"],
"keep": [["leetcode", "workout"], "2026-10-16T08:12:03.517Z-0000-<uuid>"]
},
"deleted": false,
"deleted_hlc": null
@ -134,11 +170,48 @@ The log is a JSON object keyed by ISO date. Each value is a crdt-sync record:
crdt-sync OR-merges tombstones, so a deleted date could never be marked
sick again. A tombstoned record reads as "not sick".
#### `keep` (0.2.0)
`keep` is `[<list of locker-id strings>, <hlc>]`. The list is **sorted**
(plain string order) and **de-duplicated**, always: the rev is a SHA-256 of
the exact JSON string, so two devices holding the same set must write the
same bytes. Ids: `workout`, `leetcode`, `book`, `diet`, `alarm`.
Writing:
- **add** (mark sick) writes `state: "sick"`, `actor` and `keep` (default
`[]`), all three with the **same** HLC tick, always. So a stale `keep` from
another device cannot survive a clear and a re-add.
- **remove** writes `state: "cleared"`, `actor` and `keep: []`, same tick.
- **change kept lockers** on an existing sick day **does not touch `state`**:
copy the record, stamp only `keep` and `actor` with one fresh tick, and
leave `state` (and its HLC) as it was. Re-stamping `state` would make a
keep edit re-assert "sick" over a concurrent clear. Only today or later,
only on a day that is currently sick.
- Every new tick is issued after both this device's last tick and the
greatest HLC already on that day's record, so an edit never comes out
older than the `state` it was made against.
Reading (a locker *stands down* iff the day is sick and the locker is not in
the keep set), checked in this order:
1. Day not sick (no record, tombstone, state not `"sick"`, unreadable pool)
-> nothing stands down; `keep` is not looked at.
2. No `keep` field (every 0.1.0 record) -> keep nothing: every locker stands
down.
3. `keep`'s HLC is **older** than `state`'s HLC -> stale (an old 0.1.0
client re-added the day) -> keep nothing. Equal HLCs are not stale.
4. `keep`'s value is not a list, or holds a non-string element -> malformed
-> fail closed: **every** locker is kept (nothing stands down), and a
warning is logged. (A stale malformed value is just stale: rule 3 wins.)
5. Otherwise the kept set is the list, with unknown string ids **ignored**
(forward compatibility), not an error.
Don't hand-roll this on Dart: use `crdt_sync_dart` (`syncLog`, `logToJson`
/ `logFromJson`), exactly as wake-alarm's `free_day_sync.dart` does for
`freedays-sync`. Only the prefix (`sickdays-sync/devices`), the filename
(`sick_days.json`) and the `"sick"` state value differ. There is no
`consumed` or `reason` field.
`consumed` or `reason` field. The phone must follow the `keep` rules above.
## Install

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sickdays"
version = "0.1.0"
version = "0.2.0"
description = "Shared sick-day pool: per-date, uncapped, synced; every gate respects it"
requires-python = ">=3.14"
# Deliberately NOT gatelock (its __init__ imports tkinter); the HMAC lives in

View File

@ -12,6 +12,12 @@ It reads one local file, never the network, and never raises: an unreadable
pool is logged and reads as "not sick", so the failure mode is "the normal
rules apply", never "everything is switched off".
Since 0.2.0 a sick day may keep some lockers active, so a gate that is one
of :data:`LOCKERS` asks the narrower question instead::
if sickdays.stands_down(sickdays.LOCKER_LEETCODE):
... # this locker is off today
Sick days are not free days. They are a separate pool, with a separate file
and a separate remote node, and a date is never in both
(:mod:`sickdays._conflict`). There is no cap and no justification: every day
@ -35,6 +41,14 @@ from sickdays._conflict import (
ensure_not_sick,
free_day_predicate,
)
from sickdays._constants import (
LOCKER_ALARM,
LOCKER_BOOK,
LOCKER_DIET,
LOCKER_LEETCODE,
LOCKER_WORKOUT,
LOCKERS,
)
from sickdays._day import parse_iso, resolve, to_iso, today
from sickdays._errors import (
AlreadySickError,
@ -46,11 +60,18 @@ from sickdays._errors import (
SickDayError,
UnreadablePoolError,
)
from sickdays._keep_api import kept_lockers, set_kept_lockers, stands_down
from sickdays._model import SickDay
from sickdays._paths import Paths
from sickdays._sync import SyncUnavailableError, sync, sync_quietly
__all__ = [
"LOCKERS",
"LOCKER_ALARM",
"LOCKER_BOOK",
"LOCKER_DIET",
"LOCKER_LEETCODE",
"LOCKER_WORKOUT",
"AlreadySickError",
"ConflictCheckUnavailableError",
"FreeDayConflictError",
@ -70,11 +91,14 @@ __all__ = [
"free_day_predicate",
"import_legacy",
"is_sick_day",
"kept_lockers",
"load",
"parse_iso",
"remove_sick_day",
"resolve",
"set_kept_lockers",
"sick_days",
"stands_down",
"sync",
"sync_quietly",
"to_iso",

View File

@ -15,21 +15,17 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from crdt_sync import Hlc, load_device_identity
from crdt_sync import load_device_identity
from sickdays._audit import record_event
from sickdays._conflict import ensure_not_free, free_day_predicate
from sickdays._day import to_iso, today
from sickdays._errors import AlreadySickError, NotSickError, PastDateError
from sickdays._model import (
SickDay,
build_record,
is_sick_record,
latest_hlc,
sick_dates,
)
from sickdays._errors import AlreadySickError, NotSickError
from sickdays._keep import normalise_keep
from sickdays._model import SickDay, is_sick_record, sick_dates
from sickdays._paths import Paths, resolve_paths
from sickdays._store import read_pool, read_pool_strict, write_pool
from sickdays._store import read_pool, read_pool_strict
from sickdays._writes import refuse_past, write_entry
if TYPE_CHECKING:
from collections.abc import Iterable
@ -68,25 +64,10 @@ def sick_days(*, log_path: Path | None = None) -> frozenset[date]:
return sick_dates(load(log_path))
def _refuse_past(day: date, reference: date, verb: str) -> None:
if day < reference:
msg = (
f"{to_iso(day)} has already passed -- sick days can only be "
f"{verb} for today or later"
)
raise PastDateError(msg)
def _write(log: Log, entry: SickDay, resolved: Paths) -> None:
"""Stamp ``entry`` with this node's next tick and persist the pool."""
tick = Hlc.new_tick(entry.actor, latest_hlc(log, entry.actor))
log[to_iso(entry.day)] = build_record(entry, tick)
write_pool(resolved.log, log)
def add_sick_day(
day: date,
*,
keep: Iterable[str] = (),
is_free: FreePredicate | None = None,
paths: Paths | None = None,
now: date | None = None,
@ -95,6 +76,8 @@ def add_sick_day(
Args:
day: The day. Today or later.
keep: Lockers that stay active on the day (:data:`sickdays.LOCKERS`);
every other locker stands down. Default: none -- all stand down.
is_free: "Is this a free day?"; ``None`` asks freedays, imported
lazily (:func:`sickdays._conflict.free_day_predicate`).
paths: Where the pool, uuid and audit trail live.
@ -104,23 +87,25 @@ def add_sick_day(
The stored entry.
Raises:
ValueError: ``keep`` names an unknown locker.
PastDateError: ``day`` has already passed.
AlreadySickError: ``day`` is already a sick day.
FreeDayConflictError: ``day`` is a free day.
ConflictCheckUnavailableError: The free-day pool cannot be checked.
UnreadablePoolError: The local pool exists but cannot be parsed.
"""
kept = normalise_keep(keep)
resolved = resolve_paths(paths)
_refuse_past(day, today(now), "added")
refuse_past(day, today(now), "added")
log = read_pool_strict(resolved.log)
existing = log.get(to_iso(day))
if existing is not None and is_sick_record(existing):
msg = f"{to_iso(day)} is already a sick day"
raise AlreadySickError(msg)
ensure_not_free(day, is_free if is_free is not None else free_day_predicate())
entry = SickDay(day=day, is_sick=True, actor=actor_id(resolved))
_write(log, entry, resolved)
record_event("add", day, actor=entry.actor, paths=resolved)
entry = SickDay(day=day, is_sick=True, actor=actor_id(resolved), keep=kept)
write_entry(log, entry, resolved)
record_event("add", day, actor=entry.actor, keep=kept, paths=resolved)
return entry
@ -138,14 +123,14 @@ def remove_sick_day(
UnreadablePoolError: The local pool exists but cannot be parsed.
"""
resolved = resolve_paths(paths)
_refuse_past(day, today(now), "removed")
refuse_past(day, today(now), "removed")
log = read_pool_strict(resolved.log)
existing = log.get(to_iso(day))
if existing is None or not is_sick_record(existing):
msg = f"{to_iso(day)} is not a sick day"
raise NotSickError(msg)
entry = SickDay(day=day, is_sick=False, actor=actor_id(resolved))
_write(log, entry, resolved)
write_entry(log, entry, resolved)
record_event("remove", day, actor=entry.actor, paths=resolved)
return entry
@ -172,7 +157,7 @@ def import_legacy(days: Iterable[date], *, paths: Paths | None = None) -> list[d
existing = log.get(to_iso(day))
if existing is not None and is_sick_record(existing):
continue
_write(log, SickDay(day=day, is_sick=True, actor=node), resolved)
write_entry(log, SickDay(day=day, is_sick=True, actor=node), resolved)
record_event("import", day, actor=node, paths=resolved)
written.append(day)
return written

View File

@ -1,5 +1,5 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Append-only, HMAC-signed trail of every add, remove and legacy import.
"""Append-only, HMAC-signed trail of every add, remove, keep edit and import.
Nothing reads this to make a decision -- the CRDT log is the truth. It
exists so "when did I mark that day, and from which device" is answerable
@ -32,14 +32,17 @@ def record_event(
day: date,
*,
actor: str,
keep: frozenset[str] | None = None,
paths: Paths | None = None,
) -> None:
"""Append one signed line describing a change to the pool.
Args:
action: ``"add"``, ``"remove"`` or ``"import"``.
action: ``"add"``, ``"remove"``, ``"keep"`` or ``"import"``.
day: The day that changed.
actor: The device id that made the change.
keep: The day's kept lockers after the change, recorded (sorted)
for an add or a keep edit; omitted from the line when ``None``.
paths: Where the trail and its signing key live.
"""
resolved = resolve_paths(paths)
@ -49,6 +52,8 @@ def record_event(
"actor": actor,
"at": datetime.now(tz=UTC).isoformat(),
}
if keep is not None:
entry["keep"] = sorted(keep)
key_target = resolved.hmac_key
if not key_target.exists():
# First run on this machine: generating the key here means the trail

View File

@ -5,6 +5,10 @@ Every day is a normal day until marked here (or on the phone). There is no
budget and no reason to give; the one rule is the calendar -- today or later
only -- plus the cross-pool check: a free day cannot also be a sick day.
A sick day stands every locker down unless it *keeps* some (``--keep`` on
``add``, or ``keep`` afterwards). Locker ids are argparse ``choices``, so a
typo exits 2 instead of silently keeping nothing.
``sync-quiet`` is what the timer runs: it reports success even with no
network, because a unit that goes red whenever the laptop is offline is a
unit you learn to ignore.
@ -16,19 +20,28 @@ import argparse
import sys
from typing import TYPE_CHECKING
from sickdays._api import add_sick_day, is_sick_day, remove_sick_day, sick_days
from sickdays._api import add_sick_day, is_sick_day, load, remove_sick_day
from sickdays._cli_output import emit, emit_error
from sickdays._constants import LOCKERS
from sickdays._day import resolve, to_iso, today
from sickdays._errors import SickDayError
from sickdays._keep_api import set_kept_lockers, stands_down
from sickdays._model import sick_entries
from sickdays._sync import SyncUnavailableError, sync, sync_quietly
if TYPE_CHECKING:
from collections.abc import Sequence
from collections.abc import Callable, Sequence
_EXIT_OK = 0
_EXIT_REFUSED = 1
_EXIT_BAD_INPUT = 2
_DAY_HELP = "YYYY-MM-DD, or 'today'/'tomorrow' (default: today)"
_LOCKER_METAVAR = "LOCKER"
def _keep_suffix(keep: frozenset[str]) -> str:
"""``" keep: a, b"`` for a non-empty keep set, else nothing."""
return f" keep: {', '.join(sorted(keep))}" if keep else ""
def _build_parser() -> argparse.ArgumentParser:
@ -42,13 +55,42 @@ def _build_parser() -> argparse.ArgumentParser:
sub = parser.add_subparsers(dest="command", required=True)
add = sub.add_parser("add", help="mark a day sick (today or later)")
add.add_argument("day", nargs="?", default="today", help=_DAY_HELP)
add.add_argument(
"--keep",
action="append",
default=[],
choices=LOCKERS,
metavar=_LOCKER_METAVAR,
help=f"keep this locker active (repeatable; one of {', '.join(LOCKERS)})",
)
keep = sub.add_parser(
"keep", help="replace the lockers an existing sick day keeps active"
)
keep.add_argument("day", help="YYYY-MM-DD, or 'today'/'tomorrow'")
keep.add_argument(
"lockers",
nargs="*",
choices=LOCKERS,
metavar=_LOCKER_METAVAR,
help="lockers to keep; none stands every locker down again",
)
remove = sub.add_parser("remove", help="make a day normal again")
remove.add_argument("day", nargs="?", default="today", help=_DAY_HELP)
sub.add_parser("list", help="every sick day, oldest first")
check = sub.add_parser(
"check", help="exit 0 if the day is sick, 1 if not (for scripts)"
"check",
help=(
"exit 0 if the day is sick (with --locker: if that locker stands "
"down), 1 if not -- for scripts"
),
)
check.add_argument("day", nargs="?", default="today", help=_DAY_HELP)
check.add_argument(
"--locker",
choices=LOCKERS,
metavar=_LOCKER_METAVAR,
help="instead: exit 0 iff this locker stands down on the day, 1 if not",
)
sub.add_parser("sync", help="pull and push the pool now")
sub.add_parser(
"sync-quiet",
@ -57,9 +99,18 @@ def _build_parser() -> argparse.ArgumentParser:
return parser
def _do_add(raw_day: str) -> int:
entry = add_sick_day(resolve(raw_day))
emit(f"{to_iso(entry.day)} is now a sick day")
def _do_add(raw_day: str, keep: list[str]) -> int:
entry = add_sick_day(resolve(raw_day), keep=keep)
emit(f"{to_iso(entry.day)} is now a sick day{_keep_suffix(entry.keep)}")
return _EXIT_OK
def _do_keep(raw_day: str, lockers: list[str]) -> int:
entry = set_kept_lockers(resolve(raw_day), lockers)
if entry.keep:
emit(f"{to_iso(entry.day)}{_keep_suffix(entry.keep)}")
else:
emit(f"{to_iso(entry.day)}: every locker stands down")
return _EXIT_OK
@ -70,18 +121,23 @@ def _do_remove(raw_day: str) -> int:
def _do_list() -> int:
days = sorted(sick_days())
if not days:
entries = sick_entries(load())
if not entries:
emit("no sick days")
return _EXIT_OK
reference = today()
for day in days:
emit(f"{to_iso(day)}{' (today)' if day == reference else ''}")
for entry in entries:
marker = " (today)" if entry.day == reference else ""
emit(f"{to_iso(entry.day)}{marker}{_keep_suffix(entry.keep)}")
return _EXIT_OK
def _do_check(raw_day: str) -> int:
def _do_check(raw_day: str, locker: str | None) -> int:
day = resolve(raw_day)
if locker is not None:
off = stands_down(locker, day)
emit(f"{to_iso(day)}: {locker} {'stands down' if off else 'applies'}")
return _EXIT_OK if off else _EXIT_REFUSED
sick = is_sick_day(day)
emit(f"{to_iso(day)}: {'sick day' if sick else 'normal day'}")
return _EXIT_OK if sick else _EXIT_REFUSED
@ -98,18 +154,15 @@ def _do_sync_quiet() -> int:
return _EXIT_OK
def _dispatch(args: argparse.Namespace) -> int:
if args.command == "add":
return _do_add(args.day)
if args.command == "remove":
return _do_remove(args.day)
if args.command == "list":
return _do_list()
if args.command == "check":
return _do_check(args.day)
if args.command == "sync":
return _do_sync()
return _do_sync_quiet()
_HANDLERS: dict[str, Callable[[argparse.Namespace], int]] = {
"add": lambda args: _do_add(args.day, args.keep),
"keep": lambda args: _do_keep(args.day, args.lockers),
"remove": lambda args: _do_remove(args.day),
"list": lambda _args: _do_list(),
"check": lambda args: _do_check(args.day, args.locker),
"sync": lambda _args: _do_sync(),
"sync-quiet": lambda _args: _do_sync_quiet(),
}
def main(argv: Sequence[str] | None = None) -> int:
@ -121,7 +174,7 @@ def main(argv: Sequence[str] | None = None) -> int:
"""
args = _build_parser().parse_args(argv)
try:
return _dispatch(args)
return _HANDLERS[args.command](args)
except ValueError as exc:
emit_error(f"error: {exc}")
return _EXIT_BAD_INPUT

View File

@ -48,9 +48,30 @@ SYNC_FILENAME: Final = "sick_days.json"
#: Field names inside a Record.
FIELD_STATE: Final = "state"
FIELD_ACTOR: Final = "actor"
#: The sorted, de-duplicated list of locker ids that stay active on the day
#: (0.2.0). Absent on every 0.1.0 record, which reads as "keep nothing".
FIELD_KEEP: Final = "keep"
#: Values of FIELD_STATE. Removing flips the field rather than tombstoning
#: the record: crdt-sync deletes are monotonic (OR-merged), so a tombstoned
#: date could never be marked sick again.
STATE_SICK: Final = "sick"
STATE_CLEARED: Final = "cleared"
#: Locker ids a sick day may keep active (``FIELD_KEEP``). A locker *stands
#: down* on a day iff the day is sick and the locker is not in its keep set.
#: These strings are wire format: the phone writes the same ones.
LOCKER_WORKOUT: Final = "workout"
LOCKER_LEETCODE: Final = "leetcode"
LOCKER_BOOK: Final = "book"
LOCKER_DIET: Final = "diet"
#: The phone ring, the PC fallback ring, the RTC self-wake and the
#: missed-alarm penalty -- one switch, because they are one alarm.
LOCKER_ALARM: Final = "alarm"
LOCKERS: Final = (
LOCKER_WORKOUT,
LOCKER_LEETCODE,
LOCKER_BOOK,
LOCKER_DIET,
LOCKER_ALARM,
)

110
sickdays/sickdays/_keep.py Normal file
View File

@ -0,0 +1,110 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The per-day keep set: which lockers stay active on a sick day.
The keep set is one more last-writer-wins field on the day's record
(``FIELD_KEEP``), holding a **sorted, de-duplicated** JSON list of locker
ids. Sorting is wire format, not tidiness: the RTDB rev is a SHA-256 of the
exact serialised string, so two devices holding the same set must write the
same bytes.
Reading is where the safety lives, and each rule is pinned by a test:
- no ``keep`` field (every 0.1.0 record) -- keep nothing: every locker
stands down, exactly as before 0.2.0;
- a ``keep`` stamped *older* than ``state`` -- stale (an old client re-added
the day without writing it), so keep nothing;
- a ``keep`` that is not a list of strings -- fail closed: every locker is
kept, i.e. the locks apply, and a warning is logged;
- an id this version does not know, inside an otherwise valid list -- ignored,
so a newer phone can add a locker without breaking this reader.
Staleness is decided first: a superseded value is ignored whatever it holds.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from crdt_sync import Record
from sickdays._constants import FIELD_ACTOR, FIELD_KEEP, FIELD_STATE, LOCKERS
if TYPE_CHECKING:
from collections.abc import Iterable
from crdt_sync import Hlc
_logger = logging.getLogger(__name__)
_ALL: frozenset[str] = frozenset(LOCKERS)
def validate_locker(locker: str) -> str:
"""Return ``locker`` if it is a known id.
Raises:
ValueError: ``locker`` is not one of :data:`sickdays.LOCKERS`.
"""
if locker not in _ALL:
msg = f"unknown locker {locker!r}; expected one of {', '.join(LOCKERS)}"
raise ValueError(msg)
return locker
def normalise_keep(keep: Iterable[str]) -> frozenset[str]:
"""Validate every id in ``keep`` and return them as a set.
A bare string is refused rather than iterated character by character.
Raises:
ValueError: An id is not one of :data:`sickdays.LOCKERS`.
"""
if isinstance(keep, str):
msg = f"keep must be a collection of locker ids, not the string {keep!r}"
raise ValueError(msg)
return frozenset(validate_locker(locker) for locker in keep)
def keep_wire(keep: frozenset[str]) -> list[str]:
"""The on-the-wire value of ``keep``: sorted, so the bytes are stable."""
return sorted(keep)
def read_keep(record: Record) -> frozenset[str]:
"""Which known lockers ``record``'s day keeps active (the rules above).
Only meaningful while the day is sick; callers check that first.
"""
field = record.fields.get(FIELD_KEEP)
if field is None:
return frozenset()
value, hlc = field
state = record.fields.get(FIELD_STATE)
if state is not None and hlc < state[1]:
return frozenset()
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
_logger.warning(
"sick day %s has a malformed keep field %r -- every locker applies",
record.id,
value,
)
return _ALL
return frozenset(value) & _ALL
def with_keep(record: Record, keep: frozenset[str], *, actor: str, tick: Hlc) -> Record:
"""Return ``record`` with only ``keep`` and ``actor`` re-stamped ``tick``.
``state`` keeps its own clock on purpose: re-stamping it would make a
keep edit re-assert "sick" over a concurrent clear from another device.
"""
fields = dict(record.fields)
fields[FIELD_KEEP] = (keep_wire(keep), tick)
fields[FIELD_ACTOR] = (actor, tick)
return Record(
id=record.id,
fields=fields,
deleted=record.deleted,
deleted_hlc=record.deleted_hlc,
)

View File

@ -0,0 +1,108 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Per-locker questions and the keep edit (0.2.0).
A sick day may keep some lockers active. A locker *stands down* on a day iff
the day is sick **and** the locker is not in that day's keep set.
:func:`stands_down` is what a gate asks; like :func:`sickdays.is_sick_day` it
reads one local file and never raises for a data problem. An unreadable pool
reads as "not sick" and a malformed keep set as "every locker kept" -- both
mean the locks apply.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from sickdays._api import actor_id, load
from sickdays._audit import record_event
from sickdays._day import to_iso, today
from sickdays._errors import NotSickError
from sickdays._keep import normalise_keep, read_keep, validate_locker
from sickdays._model import SickDay, is_sick_record
from sickdays._paths import resolve_paths
from sickdays._store import read_pool_strict
from sickdays._writes import refuse_past, write_keep
if TYPE_CHECKING:
from collections.abc import Iterable
from datetime import date
from pathlib import Path
from sickdays._paths import Paths
def kept_lockers(
day: date | None = None,
*,
log_path: Path | None = None,
now: date | None = None,
) -> frozenset[str]:
"""The lockers ``day`` (default: today) keeps active; empty when not sick.
Never raises: a malformed keep set is logged and reads as every locker.
"""
target = day if day is not None else today(now)
record = load(log_path).get(to_iso(target))
if record is None or not is_sick_record(record):
return frozenset()
return read_keep(record)
def stands_down(
locker: str,
day: date | None = None,
*,
log_path: Path | None = None,
now: date | None = None,
) -> bool:
"""Whether ``locker`` is switched off on ``day`` (default: today).
True iff the day is sick and ``locker`` is not in its keep set. Never
raises for a data problem -- every one of them answers ``False``.
Raises:
ValueError: ``locker`` is not one of :data:`sickdays.LOCKERS` (a
programmer error, not a data problem).
"""
validate_locker(locker)
target = day if day is not None else today(now)
record = load(log_path).get(to_iso(target))
if record is None or not is_sick_record(record):
return False
return locker not in read_keep(record)
def set_kept_lockers(
day: date,
keep: Iterable[str],
*,
paths: Paths | None = None,
now: date | None = None,
) -> SickDay:
"""Replace the kept lockers of an existing sick day. Today or later only.
Only ``keep`` and ``actor`` are re-stamped; ``state`` keeps its clock,
so this edit can never re-assert "sick" over a concurrent clear. An empty
``keep`` stands every locker down again.
Returns:
The stored entry.
Raises:
ValueError: ``keep`` names an unknown locker.
PastDateError: ``day`` has already passed.
NotSickError: ``day`` is not a sick day.
UnreadablePoolError: The local pool exists but cannot be parsed.
"""
kept = normalise_keep(keep)
resolved = resolve_paths(paths)
refuse_past(day, today(now), "changed")
log = read_pool_strict(resolved.log)
existing = log.get(to_iso(day))
if existing is None or not is_sick_record(existing):
msg = f"{to_iso(day)} is not a sick day"
raise NotSickError(msg)
actor = actor_id(resolved)
write_keep(log, existing, kept, actor=actor, resolved=resolved)
record_event("keep", day, actor=actor, keep=kept, paths=resolved)
return SickDay(day=day, is_sick=True, actor=actor, keep=kept)

View File

@ -21,8 +21,15 @@ from typing import TYPE_CHECKING
from crdt_sync import Hlc, Record
from sickdays._constants import FIELD_ACTOR, FIELD_STATE, STATE_CLEARED, STATE_SICK
from sickdays._constants import (
FIELD_ACTOR,
FIELD_KEEP,
FIELD_STATE,
STATE_CLEARED,
STATE_SICK,
)
from sickdays._day import parse_iso, to_iso
from sickdays._keep import keep_wire, read_keep
if TYPE_CHECKING:
from datetime import date
@ -38,11 +45,13 @@ class SickDay:
day: The calendar date.
is_sick: Whether the day is currently a sick day.
actor: Which device last changed it.
keep: The lockers that stay active on the day (empty unless sick).
"""
day: date
is_sick: bool
actor: str = ""
keep: frozenset[str] = frozenset()
def _field_str(record: Record, name: str) -> str:
@ -70,21 +79,34 @@ def to_sick_day(record: Record) -> SickDay | None:
day = parse_iso(record.id)
except ValueError:
return None
sick = is_sick_record(record)
return SickDay(
day=day,
is_sick=is_sick_record(record),
is_sick=sick,
actor=_field_str(record, FIELD_ACTOR),
keep=read_keep(record) if sick else frozenset(),
)
def sick_entries(log: Log) -> list[SickDay]:
"""Every day ``log`` currently marks sick, oldest first (junk ids skipped)."""
entries = (to_sick_day(record) for record in log.values())
return sorted(
(entry for entry in entries if entry is not None and entry.is_sick),
key=lambda entry: entry.day,
)
def sick_dates(log: Log) -> frozenset[date]:
"""Every date ``log`` currently marks sick (junk ids skipped)."""
days: set[date] = set()
for record in log.values():
entry = to_sick_day(record)
if entry is not None and entry.is_sick:
days.add(entry.day)
return frozenset(days)
return frozenset(entry.day for entry in sick_entries(log))
def record_hlc(record: Record | None) -> Hlc | None:
"""Return the greatest clock on any field of ``record`` (any node)."""
if record is None or not record.fields:
return None
return max(hlc for _, hlc in record.fields.values())
def latest_hlc(log: Log, node_id: str) -> Hlc | None:
@ -108,13 +130,16 @@ def build_record(entry: SickDay, hlc: Hlc) -> Record:
One tick for every field, so a concurrent add and remove of one day
resolve together rather than interleaving state from one device and the
actor from the other.
actor from the other. ``keep`` is always written -- ``[]`` on a clear --
so a keep set from before a clear can never survive a later re-add.
"""
state = STATE_SICK if entry.is_sick else STATE_CLEARED
keep = keep_wire(entry.keep) if entry.is_sick else []
return Record(
id=to_iso(entry.day),
fields={
FIELD_STATE: (state, hlc),
FIELD_ACTOR: (entry.actor, hlc),
FIELD_KEEP: (keep, hlc),
},
)

View File

@ -0,0 +1,71 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The two ways a write reaches the pool: a whole record, or one field update.
A whole-record write (add, remove, import) stamps every field with one tick.
A keep edit must not: it re-stamps only ``keep`` and ``actor``, leaving
``state`` on its own clock (:func:`sickdays._keep.with_keep`).
Every tick is issued *after* both this node's last tick and every clock
already on the day's record, so a write always beats what it was looking at,
even when another device's wall clock runs ahead of this one's. Without that,
a keep edit made under clock skew could be born older than ``state`` -- and a
keep older than ``state`` reads as stale.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from crdt_sync import Hlc
from sickdays._day import to_iso
from sickdays._errors import PastDateError
from sickdays._keep import with_keep
from sickdays._model import build_record, latest_hlc, record_hlc
from sickdays._store import write_pool
if TYPE_CHECKING:
from datetime import date
from crdt_sync import Log, Record
from sickdays._model import SickDay
from sickdays._paths import Paths
def refuse_past(day: date, reference: date, verb: str) -> None:
"""Raise :class:`PastDateError` when ``day`` is before ``reference``."""
if day < reference:
msg = (
f"{to_iso(day)} has already passed -- sick days can only be "
f"{verb} for today or later"
)
raise PastDateError(msg)
def next_tick(log: Log, actor: str, existing: Record | None) -> Hlc:
"""This node's next clock, later than its own and ``existing``'s ticks."""
seen = [t for t in (latest_hlc(log, actor), record_hlc(existing)) if t is not None]
return Hlc.new_tick(actor, max(seen) if seen else None)
def write_entry(log: Log, entry: SickDay, resolved: Paths) -> None:
"""Replace the day's whole record with ``entry`` and persist the pool."""
key = to_iso(entry.day)
tick = next_tick(log, entry.actor, log.get(key))
log[key] = build_record(entry, tick)
write_pool(resolved.log, log)
def write_keep(
log: Log,
existing: Record,
keep: frozenset[str],
*,
actor: str,
resolved: Paths,
) -> None:
"""Re-stamp only ``keep`` and ``actor`` on ``existing`` and persist."""
tick = next_tick(log, actor, existing)
log[existing.id] = with_keep(existing, keep, actor=actor, tick=tick)
write_pool(resolved.log, log)

View File

@ -22,7 +22,13 @@ if TYPE_CHECKING:
from datetime import date
from pathlib import Path
_MODULES = ("sickdays._api", "sickdays._audit", "sickdays._conflict", "sickdays._sync")
_MODULES = (
"sickdays._api",
"sickdays._audit",
"sickdays._conflict",
"sickdays._keep_api",
"sickdays._sync",
)
def _never_free(_day: date) -> bool:

View File

@ -115,3 +115,56 @@ def test_python_dash_m_runs_the_cli(
runpy.run_module("sickdays", run_name="__main__")
assert exited.value.code == 0
assert "no sick days" in capsys.readouterr().out
def test_add_keeps_lockers_and_list_shows_them(
capsys: pytest.CaptureFixture[str],
) -> None:
assert main(["add", "today"]) == 0
assert main(["add", _future(), "--keep", "workout", "--keep", "leetcode"]) == 0
assert "sick day keep: leetcode, workout" in capsys.readouterr().out
assert main(["list"]) == 0
assert capsys.readouterr().out.splitlines() == [
f"{to_iso(today())} (today)",
f"{_future()} keep: leetcode, workout",
]
def test_an_unknown_locker_exits_two(capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as exited:
main(["add", "--keep", "leetcod"])
assert exited.value.code == 2
assert "invalid choice" in capsys.readouterr().err
with pytest.raises(SystemExit) as exited:
main(["keep", _future(), "nope"])
assert exited.value.code == 2
def test_keep_replaces_the_set(capsys: pytest.CaptureFixture[str]) -> None:
main(["add", _future(), "--keep", "book"])
assert main(["keep", _future(), "diet", "alarm"]) == 0
assert f"{_future()} keep: alarm, diet" in capsys.readouterr().out
assert main(["check", _future(), "--locker", "book"]) == 0
assert "book stands down" in capsys.readouterr().out
assert main(["check", _future(), "--locker", "diet"]) == 1
assert "diet applies" in capsys.readouterr().out
assert main(["keep", _future()]) == 0
assert "every locker stands down" in capsys.readouterr().out
assert main(["check", _future(), "--locker", "diet"]) == 0
def test_keep_on_a_normal_or_past_day_is_refused(
capsys: pytest.CaptureFixture[str],
) -> None:
assert main(["keep", _future(), "book"]) == 1
assert "not a sick day" in capsys.readouterr().err
yesterday = to_iso(today() - timedelta(days=1))
assert main(["keep", yesterday]) == 1
assert "already passed" in capsys.readouterr().err
def test_check_with_a_locker_on_a_normal_day_exits_one(
capsys: pytest.CaptureFixture[str],
) -> None:
assert main(["check", "--locker", "alarm"]) == 1
assert "alarm applies" in capsys.readouterr().out

View File

@ -0,0 +1,132 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Reading a day's keep set: each wire-format rule, pinned."""
from __future__ import annotations
from datetime import date
import logging
from typing import TYPE_CHECKING
from crdt_sync import Hlc, Record
import pytest
from sickdays._constants import LOCKERS
from sickdays._keep import (
keep_wire,
normalise_keep,
read_keep,
validate_locker,
with_keep,
)
from sickdays._model import SickDay, build_record, record_hlc, to_sick_day
if TYPE_CHECKING:
from typing import Any
DAY = date(2026, 10, 20)
def _tick(wall_ms: int, node: str = "pc") -> Hlc:
return Hlc(wall_time_ms=wall_ms, counter=0, node_id=node)
def _sick(keep: tuple[Any, Hlc] | None, state_at: int = 5) -> Record:
fields: dict[str, tuple[Any, Hlc]] = {
"state": ("sick", _tick(state_at)),
"actor": ("pc", _tick(state_at)),
}
if keep is not None:
fields["keep"] = keep
return Record(id="2026-10-20", fields=fields)
def test_a_0_1_0_record_without_keep_keeps_nothing() -> None:
assert read_keep(_sick(None)) == frozenset()
def test_a_keep_older_than_state_is_stale() -> None:
assert read_keep(_sick((["leetcode"], _tick(4)))) == frozenset()
def test_a_keep_from_the_same_tick_counts() -> None:
assert read_keep(_sick((["leetcode"], _tick(5)))) == {"leetcode"}
def test_a_later_keep_counts() -> None:
assert read_keep(_sick((["book", "diet"], _tick(9)))) == {"book", "diet"}
@pytest.mark.parametrize("value", ["leetcode", {"leetcode": True}, ["diet", 7], None])
def test_a_malformed_keep_fails_closed_to_every_locker(
value: object, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING):
assert read_keep(_sick((value, _tick(5)))) == frozenset(LOCKERS)
assert "malformed keep field" in caplog.text
def test_a_stale_malformed_keep_is_still_just_stale() -> None:
assert read_keep(_sick(("junk", _tick(1)))) == frozenset()
def test_unknown_ids_are_ignored() -> None:
record = _sick((["hologram", "leetcode"], _tick(5)))
assert read_keep(record) == {"leetcode"}
def test_a_keep_without_a_state_is_read_as_is() -> None:
record = Record(id="2026-10-20", fields={"keep": (["book"], _tick(1))})
assert read_keep(record) == {"book"}
def test_validate_locker_names_the_choices() -> None:
assert validate_locker("alarm") == "alarm"
with pytest.raises(ValueError, match="workout, leetcode, book, diet, alarm"):
validate_locker("leetcod")
def test_normalise_deduplicates_and_refuses_junk() -> None:
assert normalise_keep(["diet", "diet", "book"]) == {"book", "diet"}
assert normalise_keep(()) == frozenset()
with pytest.raises(ValueError, match="not the string"):
normalise_keep("diet")
with pytest.raises(ValueError, match="unknown locker"):
normalise_keep(["diet", "nope"])
def test_the_wire_value_is_sorted() -> None:
assert keep_wire(frozenset({"workout", "alarm", "leetcode"})) == [
"alarm",
"leetcode",
"workout",
]
def test_an_add_writes_keep_on_the_state_tick() -> None:
entry = SickDay(DAY, is_sick=True, actor="pc", keep=frozenset({"diet", "book"}))
record = build_record(entry, _tick(3))
assert record.fields["keep"] == (["book", "diet"], _tick(3))
assert record.fields["state"] == ("sick", _tick(3))
assert to_sick_day(record) == entry
def test_a_clear_writes_an_empty_keep_on_the_state_tick() -> None:
entry = SickDay(DAY, is_sick=False, actor="pc", keep=frozenset({"diet"}))
record = build_record(entry, _tick(3))
assert record.fields["keep"] == ([], _tick(3))
assert to_sick_day(record) == SickDay(DAY, is_sick=False, actor="pc")
def test_with_keep_restamps_only_keep_and_actor() -> None:
record = _sick((["book"], _tick(5)))
edited = with_keep(record, frozenset({"alarm"}), actor="phone", tick=_tick(8))
assert edited.fields["state"] == ("sick", _tick(5))
assert edited.fields["keep"] == (["alarm"], _tick(8))
assert edited.fields["actor"] == ("phone", _tick(8))
assert record.fields["keep"] == (["book"], _tick(5))
def test_record_hlc_is_the_greatest_field_clock() -> None:
assert record_hlc(None) is None
assert record_hlc(Record(id="x", fields={})) is None
assert record_hlc(_sick((["book"], _tick(9, "phone")))) == _tick(9, "phone")

View File

@ -0,0 +1,202 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""stands_down, kept_lockers, add --keep and the keep edit, end to end."""
from __future__ import annotations
from datetime import date
import json
from typing import TYPE_CHECKING
from crdt_sync import Hlc, Record, merge_logs
import pytest
from sickdays._api import add_sick_day, is_sick_day, load, remove_sick_day
from sickdays._constants import LOCKERS
from sickdays._errors import NotSickError, PastDateError
from sickdays._keep_api import kept_lockers, set_kept_lockers, stands_down
from sickdays._store import write_pool
if TYPE_CHECKING:
from pathlib import Path
TODAY = date(2026, 10, 16)
TOMORROW = date(2026, 10, 17)
KEY = "2026-10-17"
#: A record exactly as 0.1.0 wrote the live 2026-10-16..20 days: no keep.
V010_RECORD = {
"id": KEY,
"fields": {
"state": ["sick", "2026-10-16T08:12:03.517Z-0000-phone"],
"actor": ["phone", "2026-10-16T08:12:03.517Z-0000-phone"],
},
"deleted": False,
"deleted_hlc": None,
}
def _pool(state_dir: Path) -> Path:
return state_dir / "sick_days.json"
def _raw(state_dir: Path, record: dict[str, object]) -> None:
_pool(state_dir).write_text(json.dumps({KEY: record}), encoding="utf-8")
def test_a_normal_day_stands_nothing_down() -> None:
assert not any(stands_down(locker, TOMORROW) for locker in LOCKERS)
assert kept_lockers(TOMORROW) == frozenset()
def test_a_sick_day_with_no_keep_stands_everything_down() -> None:
add_sick_day(TOMORROW, now=TODAY)
assert all(stands_down(locker, TOMORROW) for locker in LOCKERS)
assert kept_lockers(TOMORROW) == frozenset()
def test_a_kept_locker_applies_and_the_rest_stand_down() -> None:
entry = add_sick_day(TOMORROW, keep=["leetcode", "leetcode"], now=TODAY)
assert entry.keep == {"leetcode"}
assert not stands_down("leetcode", TOMORROW)
assert stands_down("workout", TOMORROW)
assert kept_lockers(TOMORROW) == {"leetcode"}
assert is_sick_day(TOMORROW)
def test_today_is_the_default_day() -> None:
add_sick_day(TODAY, keep=["diet"], now=TODAY)
assert stands_down("book", now=TODAY)
assert not stands_down("diet", now=TODAY)
assert kept_lockers(now=TODAY) == {"diet"}
def test_the_add_writes_a_sorted_keep_on_one_tick(state_dir: Path) -> None:
add_sick_day(TOMORROW, keep=["workout", "alarm"], now=TODAY)
fields = json.loads(_pool(state_dir).read_text(encoding="utf-8"))[KEY]["fields"]
assert fields["keep"][0] == ["alarm", "workout"]
assert fields["keep"][1] == fields["state"][1] == fields["actor"][1]
def test_an_unknown_locker_on_add_writes_nothing(state_dir: Path) -> None:
with pytest.raises(ValueError, match="unknown locker"):
add_sick_day(TOMORROW, keep=["leetcod"], now=TODAY)
assert not _pool(state_dir).exists()
def test_an_unknown_locker_question_is_a_programmer_error() -> None:
with pytest.raises(ValueError, match="unknown locker"):
stands_down("leetcod", TOMORROW)
def test_a_0_1_0_record_stands_every_locker_down(state_dir: Path) -> None:
_raw(state_dir, V010_RECORD)
assert all(stands_down(locker, TOMORROW) for locker in LOCKERS)
assert kept_lockers(TOMORROW) == frozenset()
def test_a_malformed_keep_means_the_locks_apply(state_dir: Path) -> None:
record = json.loads(json.dumps(V010_RECORD))
record["fields"]["keep"] = ["leetcode", "2026-10-16T08:12:03.517Z-0000-phone"]
_raw(state_dir, record)
assert not any(stands_down(locker, TOMORROW) for locker in LOCKERS)
assert kept_lockers(TOMORROW) == frozenset(LOCKERS)
def test_an_unreadable_pool_stands_nothing_down(state_dir: Path) -> None:
_pool(state_dir).write_text("{not json", encoding="utf-8")
assert not stands_down("alarm", TOMORROW)
assert kept_lockers(TOMORROW) == frozenset()
def test_set_kept_lockers_replaces_the_set(state_dir: Path) -> None:
add_sick_day(TOMORROW, keep=["book"], now=TODAY)
entry = set_kept_lockers(TOMORROW, ["alarm", "diet"], now=TODAY)
assert entry.keep == {"alarm", "diet"}
assert kept_lockers(TOMORROW) == {"alarm", "diet"}
assert stands_down("book", TOMORROW)
set_kept_lockers(TOMORROW, [], now=TODAY)
assert all(stands_down(locker, TOMORROW) for locker in LOCKERS)
lines = (state_dir / "audit.jsonl").read_text(encoding="utf-8").splitlines()
events = [json.loads(line) for line in lines]
assert [(e["action"], e["keep"]) for e in events] == [
("add", ["book"]),
("keep", ["alarm", "diet"]),
("keep", []),
]
def test_a_keep_edit_does_not_restamp_state(state_dir: Path) -> None:
add_sick_day(TOMORROW, now=TODAY)
before = load()[KEY].fields["state"]
set_kept_lockers(TOMORROW, ["leetcode"], now=TODAY)
after = load()[KEY]
assert after.fields["state"] == before
assert after.fields["keep"][1] > before[1]
assert after.fields["actor"][1] == after.fields["keep"][1]
def test_a_keep_edit_never_reasserts_sick_over_a_concurrent_clear() -> None:
add_sick_day(TOMORROW, now=TODAY)
added = load()
set_kept_lockers(TOMORROW, ["leetcode"], now=TODAY)
edited = load()
state_tick = added[KEY].fields["state"][1]
# "!" sorts before any uuid, so this clear is concurrent with, and
# older than, the keep edit -- the case a re-stamped state would win.
clear_tick = Hlc(state_tick.wall_time_ms, state_tick.counter + 1, "!")
assert edited[KEY].fields["keep"][1] > clear_tick
cleared = Record(
id=KEY,
fields={
"state": ("cleared", clear_tick),
"actor": ("phone", clear_tick),
"keep": ([], clear_tick),
},
)
merged = merge_logs(edited, {KEY: cleared})
assert merged[KEY].fields["state"][0] == "cleared"
def test_a_keep_edit_beats_a_state_from_a_clock_that_runs_ahead(
state_dir: Path,
) -> None:
future = "2099-01-01T00:00:00.000Z-0000-phone"
record = json.loads(json.dumps(V010_RECORD))
record["fields"]["state"][1] = future
record["fields"]["actor"][1] = future
_raw(state_dir, record)
set_kept_lockers(TOMORROW, ["workout"], now=TODAY)
assert kept_lockers(TOMORROW) == {"workout"}
assert not stands_down("workout", TOMORROW)
def test_a_clear_and_re_add_drops_a_stale_keep_from_another_node() -> None:
add_sick_day(TOMORROW, keep=["leetcode"], now=TODAY)
old_copy = load()
remove_sick_day(TOMORROW, now=TODAY)
add_sick_day(TOMORROW, now=TODAY)
merged = merge_logs(load(), old_copy)
assert merged[KEY].fields["keep"][0] == []
def test_a_0_1_0_re_add_makes_an_older_keep_stale(state_dir: Path) -> None:
add_sick_day(TOMORROW, keep=["leetcode"], now=TODAY)
newer = "2099-01-01T00:00:00.000Z-0000-oldphone"
record = json.loads(json.dumps(V010_RECORD))
record["fields"]["state"] = ["sick", newer]
merged = merge_logs(load(), {KEY: Record.from_dict(record)})
write_pool(_pool(state_dir), merged)
assert stands_down("leetcode", TOMORROW)
def test_the_keep_edit_refusals() -> None:
with pytest.raises(NotSickError):
set_kept_lockers(TOMORROW, ["book"], now=TODAY)
with pytest.raises(PastDateError, match="changed"):
set_kept_lockers(date(2026, 10, 15), ["book"], now=TODAY)
add_sick_day(TOMORROW, now=TODAY)
with pytest.raises(ValueError, match="unknown locker"):
set_kept_lockers(TOMORROW, ["nope"], now=TODAY)
remove_sick_day(TOMORROW, now=TODAY)
with pytest.raises(NotSickError):
set_kept_lockers(TOMORROW, ["book"], now=TODAY)