# earned_time Penalty-then-reward for gate apps: **one** table of what every gate earns in gaming time and shutdown time, read by both consumers. | consumer | applies | via | |---|---|---| | steam-backlog-enforcer | `Resolution.gaming_minutes` | `_budget_resolve.resolve_budget` | | screen-locker | `Resolution.shutdown_minutes` | `_shutdown_base.reset_to_base_if_new_day` + live pass | A gate (leetcode-guard, book-guard, anki-guard, screen-locker's workout credit, ...) never touches either. It publishes a fact — HMAC-signed `credit` rows in its ledger — and is registered here once. ## The model **Gaming** (unchanged): a base of 5 h, minus every earner's `gaming_minutes` from its `penalty_from` date; each earner done adds its `gaming_minutes` back. Capped at 8 h. **Shutdown** has two regimes, switched by date (`LADDER_FROM`, 2026-10-10 — never a same-day cut): - **Before `LADDER_FROM`:** base 20:00 minus the penalties in force, plus each earner's `shutdown_minutes` (a second workout +60), capped at 23:00. - **From `LADDER_FROM` — the sleep ladder.** The ceiling is eight hours of sleep before the alarm: `WAKE_MINUTES` (07:00) − 8 h = 23:00. The floor is that ceiling minus the sum of every registered earner's first-unit rung (`LADDER`, a `Rung` per earner name), so doing everything lands exactly on the ceiling: | done | shutdown | |---|---| | nothing | 19:00 | | workout (110) | 20:50 | | + LeetCode (50) | 21:40 | | + reading (30) | 22:10 | | + Anki (25) | 22:35 | | + Automation (25) | 23:00 | A second workout earns nothing more (`Rung.extra` defaults to 0). Move the alarm (`WAKE_MINUTES = 6 * 60`) and the whole ladder shifts an hour earlier on its own. Days before `LADDER_FROM` keep their frozen 23:00 ceiling, so the change never rewrites history. The two floors are derived separately (`_resolve._shutdown_floor`), so the ladder cannot move a gaming minute. ## API - `resolve(answers, day=None, earners=EARNERS) -> Resolution` — the one sum. - `base_for(day=None, earners=EARNERS) -> Base` — the day's floors. - `done_today(earner, ledger, key_file, *, now=None) -> bool | None` - `first_credit_at(earner, ledger, key_file, day) -> float | None` — unix time of the earliest verified credit that counts for `day`. The time is the stamp the earner's `match` decides on (`_match.CREDIT_STAMPS`: LeetCode `submitted_at`, reading `ended_at`, workout `completed_at`; Anki/Automation fall back to `created_at`). It can lie outside `day` when a gate's day is not the calendar day (Anki's rollover, a rest day declared the evening before). - `credit_units(earner, ledger, key_file, day) -> int | None` — how many verified rows count for `day`: a counted earner's units. - `shutdown_minutes_for(earner, day) -> int`, `extra_shutdown_minutes_for(earner, day) -> int`, `shutdown_ceiling_for(day) -> int`, `on_ladder(day) -> bool` — what a status view needs to list what is still left to earn. `Earner.shutdown_for(units, day=None)` is the day-aware sum (default today). Never read `earner.shutdown_minutes` directly: it is the pre-ladder value. - `day_window(day)`, `today_window(now=None)`, `entry_signature`, `verified`. ## The workout ledger `WORKOUT` is `kind="counted"` with a ledger at `~/.local/share/workout_locker/ledger.json`, written by screen-locker: one signed credit row per credited unit, `{"kind": "credit", "entry_id", "day", "detail": {"completed_at", "source"}}`. `workout_match` counts a `runnerup_tcx` row when `completed_at` falls in the window, and a `rest_day` row on the `day` it names — only if `detail.declared_at` is before that day's local midnight. A rest day declared on the day itself is logged and does not count. ## Adding a gate 1. The gate writes `{"entries": [...]}` to its ledger. Each row has `kind: "credit"`, a `detail` dict, and an `hmac` signed with `/etc/workout-locker/hmac.key` (`earned_time.entry_signature`, the same as gatelock's `log_integrity`). 2. Add a matcher to `earned_time/_match.py` and an `Earner` to `earned_time/_policy.py` and `EARNERS`: ```python PIANO = Earner( name="piano", # also screen-locker's "piano_bonus_date" stamp label="piano", gaming_minutes=30, shutdown_minutes=30, # pre-ladder value penalty_from=date(2026, 11, 1), # gaming base drops by 30 from then on ledger=".local/share/piano_guard/ledger.json", match=piano_match, # which verified credit rows count today missing_ledger_is_no=True, ) ``` Then give it a rung in `LADDER` (`PIANO.name: Rung(20)`), and, if its matcher decides on an event stamp, an entry in `_match.CREDIT_STAMPS`. On the ladder a new earner's rung comes out of the floor the day its pin lands (the ceiling is fixed): re-split the rungs and ship it the day the gate can actually be satisfied. 3. Bump the version, tag `earned-time-vX.Y.Z`, and bump the pin in both consumers. Neither needs code for a ledger-backed flat earner. ## Rules the consumers rely on - **Minutes everywhere.** Shutdown is minutes after local midnight. - **Paths are passed in.** No reader resolves a real path itself; consumers' test suites redirect their own constants. - **`None` is "could not check", never "no".** It earns nothing (fail closed), but consumers log it. A missing answer in `resolve()` is logged as well; an unknown earner name raises. - **The HMAC key is root-owned and 0644 on purpose.** The gates run as the user and must sign their rows, so the user can read the key; root owns it so a user process cannot replace or rotate it. The signature therefore stops casual edits and corruption, not a determined forger with a shell -- the accepted trade-off of user-run gates. - **Stdlib only.** The gaming daemon imports this as root, headless. ## Install ```console pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time" ``` The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports from the same user site-packages. ## Test ```console scripts/run_subproject_tests.sh earned_time ```