diff --git a/CLAUDE.md b/CLAUDE.md index 86119a7b..d8095753 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,10 +83,14 @@ box**. **Run `python3 scripts/repo_gates.py` after ANY change in this repo.** It runs every gate — `site_gates.py`, `hostinstall_gates.py`, `hub_confirm_gate.py`, `manifest_bearer_gate.py`, -`reuse_refs_check.py` and `instructions_gate.py` — streaming each gate's own output and exiting +`reuse_refs_check.py`, `instructions_gate.py`, `golden_currency_gate.py`, `wire_contract_gate.py`, +`hub_copy_gate.py` and `due_checks_gate.py` — streaming each gate's own output and exiting non-zero if any fails. `--fast` selects the gates that touch no network and no container runtime; today that is all of them. **A missing gate script is a FAILURE, never a skip.** +`due_checks_gate.py` refuses the push when a dated check in `OPEN-ITEMS.md`'s `DUE-CHECKS` block has +come due (R-341). **It is not a scheduler** — it fires on the next push, not on the date. + `site_gates.py` is a *gate*, not a runner — do not model new work on it; `app-catalog-felhom.eu/scripts/catalog_gates.py` is the canonical runner (R-161). diff --git a/CONTEXT.md b/CONTEXT.md index a57f6e97..bf85d4f3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,6 +15,26 @@ > would make one of the two audiences stop reading. `STATUS.md` is also a **view of `OPEN-ITEMS.md`** > and holds nothing of its own; this file does hold its own content, namely the standing rulings below. +## The managed floor now tracks the vouched golden — and that is rule 2 ARRIVING, not an exception (2026-08-18, R-343) + +Since 2026-08-18 12:36:58Z the global managed controller floor +(`hub_settings.min_controller_version`) reads **0.216.0**, equal to the vouched golden. Read it from +the store (`GetGlobalMinControllerVersion`, `hub/internal/store/store.go:1751`), never from the form. + +**Do not read the preceding gap as drift.** `publish-train-rules.md` rule 1 is *manifest before +floor* and rule 2 requires the floor field saved **LAST, in a separate save**, because the DB row +overrides the env floor and acts on the next report cycle. The floor therefore sits behind the newest +controller **by design** for as long as it takes to bake and vouch a golden — and it is raised +afterwards. Floor-equals-golden is the state that ordering is meant to arrive at; a session finding +the floor behind should check whether a vouch is outstanding before calling it a defect. + +**The raise is not free, and this is the part worth remembering.** Rule 2's "acts immediately" is +literal: nine seconds after the save, `demo-felhom` auto-updated 0.214.0 → 0.216.0 +(`controller_updated` 12:37:07Z, `controller_started` 12:37:12Z, no error events). So a floor raise is +a fleet action, not bookkeeping — plan it as one. `ResolveManagedFloor` +(`hub/internal/store/store.go:2068`) is what makes it safe: it holds the floor entirely when it +exceeds the golden (R-216), and per-box when that box's agent is below the manifest's `MinAgent`. + ## A name must differ from its neighbours on a stem, not on a noun (2026-08-13, R-323) The third near-homograph is renamed: the five-word phrase that proves an account owns the box being diff --git a/REPORT-register-and-floor.md b/REPORT-register-and-floor.md new file mode 100644 index 00000000..0f7f3797 --- /dev/null +++ b/REPORT-register-and-floor.md @@ -0,0 +1,317 @@ +# REPORT — dated checks that bite, the floor raise on the record, the snapshot that covers less (2026-08-18) + +**Three pieces of bookkeeping, no machine put at risk. The hub was READ ONLY throughout.** + +**The headline is that Part 3's premise was wrong.** The floor raise was *not* a no-op: it moved a +live customer box nine seconds after the save. That is the whole reason the task said to read it back +rather than assume it. **R-343 is therefore filed OPEN, not CLOSED**, per the task's own condition. + +--- + +## 1. Confirmed baselines + +| item | value | +|---|---| +| felhom.eu `main` @ start | `f267bc047f198de4cb600068fdd8bcef557cff20` — matches the sheet | +| clean tree at start | yes; `HEAD == origin/main` in felhom.eu, felhom-controller, felhom-agent | +| `scripts/` version IN | `felhom-host-install.sh v1.28.0` (CHANGELOG head) | +| `scripts/` version OUT | `due_checks_gate.py v1.0.0` (new head entry) | + +## 2. Files created / modified + +**Created:** `scripts/due_checks_gate.py`, `scripts/test_due_checks_gate.py`, +`REPORT-register-and-floor.md`. +**Modified:** `scripts/repo_gates.py` (registration + docstring), `scripts/CHANGELOG.md`, `CLAUDE.md`, +`CONTEXT.md`, `STATUS.md`, `documentation/backlog/OPEN-ITEMS.md` (block + R-342 + R-343), +`documentation/runbooks/publish-train-rules.md`. +Commit hashes are in §9. + +## 3. Tests — 37 assertions, and a red-proof that caught my own test + +`python3 scripts/test_due_checks_gate.py` → **passed: 37, failed: 0**, groups A–G. + +### Red-proof 1 — the boundary. **It failed usefully: it exposed a HOLLOW assertion of mine.** + +**Mutation:** `due_now = [... if r[1] <= today]` → `< today`. +**First run, before the fix:** Group C reported + +``` + PASS C: due TODAY exits 1 rc=1 <-- passed, and should NOT have + FAIL C: says DUE TODAY rather than overdue +``` + +**The `rc == 1` assertion passed for the wrong reason.** With `<`, a row dated exactly today falls +into neither `due_now` (`<`) nor `pending` (`>`), so `min(pending, …)` raised +`ValueError: min() iterable argument is empty` and the **traceback** exited 1. Confirmed directly: + +``` + File ".../due_checks_gate.py", line 232, in main + nearest = min(pending, key=lambda r: r[1]) +ValueError: min() iterable argument is empty +RC=1 +``` + +**An exit code alone cannot distinguish a verdict from a crash.** Two fixes, both kept: + +1. the test now asserts `DUE-CHECKS GATE FAILED` is in the output **and** `Traceback` is not, plus a + new `test_c_gate_never_ends_in_a_traceback` across overdue/future/empty inputs; +2. the gate returns **2 (INCONCLUSIVE)** with a message if the partition is ever broken again, + because a crash is never a verdict. + +**Re-run after the fix — the mutation now bites properly:** + +``` + FAIL C: due TODAY exits 1 (boundary is <=) rc=2 + FAIL C: exits 1 as a VERDICT, not a traceback + PASS C: did not crash + FAIL C: says DUE TODAY rather than overdue +passed: 34 failed: 3 +``` + +**Mutation reverted**, verified by `grep -n "MUTATED"` returning nothing and the `<=` line restored. + +### Red-proof 2 — the missing-block path + +**Mutation:** the missing-block branch `sys.exit(2)` → `sys.exit(0)`. +**Seen failing:** + +``` + FAIL E: missing block exits 2 (NOT 0) rc=0 +passed: 36 failed: 1 +``` + +**Reverted**, `sys.exit(2)` restored on that branch. + +## 4. The gate's real output in all three states + +**Overdue (fixture, today=2026-08-20):** + +``` +DUE-CHECKS GATE FAILED: 1 dated check(s) are due or overdue as of 2026-08-20 (UTC). + + R-341 due 2026-08-19 1 day(s) OVERDUE + measure: ep0 proxy fd count + ESTAB/CLOSE-WAIT split; PID must still be 551655 + the command and its preconditions are in the R-341 row of documentation/backlog/OPEN-ITEMS.md + +Take the measurement, record the result in that R-row, then remove the row from the +DUE-CHECKS block. Moving the date instead is allowed — state the reason in the R-row. +NOTE: this gate fires on a PUSH, not on the date; it may be later than the date. +``` + +**Pending / the LIVE run against the real register today (these are the same run):** + +``` +due-checks gate OK — 2 dated check(s) pending, none due yet. + today (UTC): 2026-08-18 + nearest: R-341 due 2026-08-19 (in 1 day(s)) — ep0 proxy fd count + ESTAB/CLOSE-WAIT split; PID must still be 551655 + (fires on the next PUSH after a date passes, not on the date itself — by design) +``` + +## 5. The runner's output + +``` + site OK (exit 0) + hostinstall OK (exit 0) + hub-confirm OK (exit 0) + manifest-bearer OK (exit 0) + reuse-refs OK (exit 0) + instructions OK (exit 0) + golden-currency OK (exit 0) + wire-contract OK (exit 0) + hub-copy OK (exit 0) + due-checks OK (exit 0) +all felhom.eu gates OK +``` + +Group G asserts registration by **running the runner** and matching `due-checks` in its output, never +by grepping `repo_gates.py`'s source — a commented-out entry still contains the string. + +## 6. Part 3's five reads — evidence, not summary + +### READ 1 — the live floor, from the store + +``` + artifact_agent_version = '0.129.0' (updated 2026-08-18 11:00:59) + artifact_golden_version = '0.216.0' (updated 2026-08-18 11:00:59) + artifact_min_agent = '0.129.0' (updated 2026-08-18 11:01:00) + min_controller_version = '0.216.0' (updated 2026-08-18 12:36:58) +``` + +**The raise landed**, so Part 3 proceeded. Read from `hub_settings`, not the form. + +### READ 2 — per-customer overrides + +``` + demo-felhom status=active override='' config_version=12 + demo-hp status=active override='' config_version=5 + drill-r50 status=blocked override='' config_version=1 + peti-felhom status=active override='' config_version=6 + tester-1 status=active override='' config_version=1 + -> 0 customer(s) carry a non-empty override +``` + +**Zero overrides**, so the global applies to everyone and no box hides behind a lower one. + +### READ 3 — every box's controller version. **Two are below the floor.** + +``` + demo-felhom controller='0.216.0' last_report=2026-08-18 13:07:07 + demo-hp controller='0.216.0' last_report=2026-08-18 13:01:34 + drill-r50 controller='0.213.0' last_report=2026-08-12 15:33:25 + peti-felhom controller='0.115.0' last_report=2026-07-15 08:39:00 +``` + +**The two REPORTING boxes are both at 0.216.0, at the floor.** The other two are below it and neither +is a reporting box: `drill-r50` is `status=blocked`, last heard from six days ago, powered off and +reverted; `peti-felhom`'s host row was deleted on 2026-07-15. Reported here rather than as a +footnote, per the task's edge-case rule. + +*(Note: `guests.controller_version` is empty for every guest — the hub carries the controller version +on `reports.controller_version`, not on the guest row. The first query I wrote read the guest field +and would have reported "unknown" for every box.)* + +### READ 4 — directives and holds + +``` +2026/08/18 14:36:58 [INFO] Global controller-version floor set to "0.216.0" +``` + +**No `managed floor HELD` line exists** — searched over 24 h of pod logs. (Hub log lines are CEST; +the DB stores UTC, hence 14:36:58 here and 12:36:58 above — the same instant.) + +### READ 5 — **THE FINDING: a controller DID auto-update after the raise** + +``` +2026-08-18 12:37:07 | demo-felhom | controller_updated | Controller frissítve: 0.214.0 → 0.216.0 +2026-08-18 12:37:12 | demo-felhom | controller_started | Controller elindult (0.216.0) +``` + +and the version trail confirms it: + +``` + 2026-08-18 11:14:55 controller=0.214.0 + 2026-08-18 12:37:12 controller=0.216.0 +``` + +**`demo-felhom` had been on 0.214.0 since 2026-08-12 16:44 and the floor raise pulled it to 0.216.0 +nine seconds after the save** — exactly the "acts immediately on the next report cycle" that +`publish-train-rules.md` rule 2 documents and that the 2026-07-11 incident was filed for. +`demo-hp` was already on 0.216.0 (hand-deployed 2026-08-14 08:31) and did not move. + +**No error, warning or critical event followed** — the update completed and the controller restarted. +So: harmless in outcome, but **not a no-op**. "Every reporting box is at or above the floor" is true +**because of** the raise, not independently of it. + +**R-343 is filed OPEN.** The task's closing condition was *all five reads clean and no directive +served*; read 5 shows a live box moved. It went well, and a record that called it inert would mislead +the next reader. + +## 7. `peti-felhom` — not contacted + +**The machine was not contacted in any way.** Sourced from the PETI register row, quoted: + +> *"a report from a deleted host 401s and is not persisted"* + +with its host row deleted `2026-07-15 08:56:22` (`host_deletions` id=1). It therefore cannot receive a +floor directive and the raise cannot reach it. Its `reports` row still shows controller 0.115.0 from +its last report on 2026-07-15 08:39:00 — a stale record, not a live box. + +## 8. The two `build-felhom-iso.sh` facts, confirmed in the script + +**(a) It is a BUILD-TIME gate.** `assert_golden_ge_floor()` is defined at **`:77`** and called at +**`:267`**, in the build flow. + +**(b) It FAILS OPEN with a warning when its inputs are absent** — `:78-82`: + +```bash + local golden="${FELHOM_ASSERT_GOLDEN:-}" floor="${FELHOM_ASSERT_FLOOR:-}" + if [[ -z "$golden" || -z "$floor" ]]; then + log_warn "R-71 golden>=floor gate UNENFORCED — pass FELHOM_ASSERT_GOLDEN + FELHOM_ASSERT_FLOOR to enforce (golden='${golden:-unset}' floor='${floor:-unset}')" + return 0 + fi +``` + +Both read as the task described. **No ISO rebuild is required:** the golden is fetched at first boot +from the hub's manifest (0.216.0 — at the floor, not below it), and this gate governs *future* builds. + +## 10. NOT yet validated + +**The gate has never fired on a real overdue date in the live register.** Every conviction shown here +is from a temp-file fixture or a `FELHOM_GATE_TODAY` override. Its first genuine firing will be the +next push on or after **2026-08-19**, when R-341's first check comes due. Until that happens, "it +refuses the push" is proven in fixtures and *inferred* in production — the registration test proves it +is wired into the runner, which is the part that could silently not be true. + +Also unvalidated: the block's own upkeep. Nothing checks that a row removed from the block was removed +because the measurement was *taken* rather than because it was inconvenient. + +## 11. Teardown + +**This task provisioned nothing.** No VM, no container, no machine touched. The hub was read-only — +snapshots of `hub.db` + `-wal` were taken into the session scratchpad for querying and are not +committed. + +## 12. Register rows + +**The block, verbatim as committed:** + +```markdown + +| item | due (UTC) | what to measure | +|---|---|---| +| R-341 | 2026-08-19 | ep0 proxy fd count + ESTAB/CLOSE-WAIT split; PID must still be 551655 | +| R-341 | 2026-08-25 | same, +7 d | + +``` + +**R-341's dates in the register matched the sheet exactly** — no disagreement to report. + +**R-343's verdict cell, verbatim:** + +> **OPEN — NEW 2026-08-18.** Deliberately NOT closed: the task's closing condition was *all five reads +> clean, no directive served*, and read 5 shows a live box updated. It went cleanly and is the floor +> working as designed — but a change recorded as a no-op when it moved a customer box is exactly the +> kind of record that misleads later + +**R-342** filed **READY (S)**, owner *Viktor decides; CC executes*, quoting `stop2-snapshot.txt` +verbatim on what the snapshot covers and does not. + +## 13. `unproven.py --summary` + +``` +where felhom stands — 55 claims, verified_on 2026-08-09 + walked 23 + partial 14 (6 cite evidence, 8 prose only) + built 14 (0 cite evidence, 14 prose only) + missing 4 (0 cite evidence, 4 prose only) + NOT WALKED: 32 of 55 +``` + +**No number moved**, correctly: this task added a gate and three register facts, and walked no claim +in the standing picture. + +## 14. Observations — noticed, deliberately not acted on + +- **`CLAUDE.md`'s gate list named only 6 of the 10 registered gates.** It was missing + `golden_currency_gate.py`, `wire_contract_gate.py` and `hub_copy_gate.py` — all registered weeks + ago. I completed the list rather than appending a 7th name to a list that was already wrong, since + the section's stated job is to name each gate. Effective line count 124 → 128 against a ceiling of + 200, so no trim was needed. +- **`documentation/architecture/00-capability-map.md` — no change, and this is the explicit + statement the task asked for.** No row's evidence citation names the floor or golden *version*: + line 153's publish-train row cites a runbook path, and line 44's golden literal is a dated + historical citation on the recovery-journey row. +- **`drill-r50` will be dragged 0.213.0 → 0.216.0 by this floor if it is ever booted and reports.** + Its agent (0.129.0) meets `MinAgent`, so the floor would be served, not held. That is the floor + doing its job; noted so it is not read as a surprise later. +- **The hub's log timestamps are CEST while its DB stores UTC.** Not a defect, but it makes a log + line and an events row for the same instant look two hours apart, which is worth knowing before + correlating them under pressure. +- **`min_controller_version` and `artifact_*` live in the same `hub_settings` table but are saved by + different actions**, two hours apart today (11:00:59 vouch, 12:36:58 floor). That separation is + rule 2 working, and is why reading only one of them would give a misleading picture. diff --git a/STATUS.md b/STATUS.md index ada7b398..886614f8 100644 --- a/STATUS.md +++ b/STATUS.md @@ -43,6 +43,16 @@ record with no machine** — created 13 August, no host, no backups, nothing to ## Shipped +- **The auto-update floor is current again** (R-343). You raised it to today's version this + afternoon. It was **not** left behind by accident — our own rule says the floor is raised *last*, + after the image is vouched, because it acts within seconds. It did: **one machine updated itself + nine seconds later** and came back up cleanly. Worth knowing that raising it is a fleet action, not + paperwork. +- **A dated check can no longer be quietly missed** (R-341). When we write "measure this again on the + 19th", that date is now read by the build system, and a push is refused once it passes. **It is not + a reminder service** — it speaks on the next push, not on the day — and that limit is written into + the check itself. + - **A new machine installed today finally gets today's software** (R-334, closed). The pre-built image had been two releases behind since the 14th — anyone installing would have received a version missing last week's disk-warning fix *and* the follow-up that corrected it. A fresh image was baked diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index 7dd9ee6f..ff8a5ccf 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -640,3 +640,18 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server` | **R-337** | **`/backup/status` lagged a completed backup by minutes on one box and not the other — and it RESOLVED ITSELF, which is why this is WATCHING and not a defect.** During the R-336 recovery on 2026-08-18, `demo-hp`'s snapshot landed on ep0 at **03:58:43Z** (complete manifest; the host's own task index says `OK`) — yet `GET /backup/status` was **still serving the superseded 03:27:00Z failure at ~04:03Z**, four-plus minutes later. `demo-felhom` showed its new result within ~40 s of completion. **The lag cleared on its own:** demo-hp's 04:07:35Z host report carries `felhom-pbs success=true, 4.29 GB`, and the hub is green for both boxes. **The first draft of this row claimed the success was "still reported as failed" — that was written before the next report arrived and it was wrong; the corrected claim is a several-minute skew between the two boxes, not a stuck value.** It is recorded because a status field that can trail its own artifact by minutes will, during an incident, be read as a second failure — this session nearly did — and because the asymmetry between the two boxes is unexplained | **WATCHING — NEW 2026-08-18** | another observation, ideally during an incident rather than constructed | **Do not open a fix on this as written.** First establish the intended refresh path for `/backup/status` after an out-of-schedule run; only if the skew is not simply collection cadence is there anything to pin. If it is cadence, close this row and say so | CC | | **R-338** | **`demo-hp` is not on the R-50 island at all, and `operations/nodes.md` states that it is.** The page records both fleet boxes as island-migrated 2026-07-25. True of `felhom-pve`; **false of `demo-hp`**, whose `agent.json` has `listen_addr: 192.168.0.87:8443` — the customer LAN address — and **no `island_bridge`/`island_guest_addr` keys at all**, whose guest 9201 has `net0` only (no `eth1`), and whose `vmbr9` exists with **zero members**. The controller's `controller.yaml` points at the LAN address, so the box works; this is inventory drift, not breakage. **Two costs.** A session trusting the page addresses the wrong endpoint — that happened on 2026-08-18 and the resulting timeout was briefly read as a fault. And the agent's local API is **bound to the customer LAN on this box** rather than to a point-to-point island, which is the exposure R-50 was built to remove — so a documented security property is claimed for a box that does not have it | **READY (S) — NEW 2026-08-18** | — | Decide which is true: migrate `demo-hp` to the island, or correct `nodes.md`. Leaving both is the one option that keeps the doc lying | Viktor decides; CC executes | | **R-341** | **Does the fd slope change after the PBS 4.2.5 upgrade? — two dated checks, and the answer is expected to be NO.** ep0 was upgraded 4.2.2-1 → 4.2.5-1 on 2026-08-18 09:51Z on the operator's ruling, **for rehearsal value, not as a fix**: the full changelog range was read (128 lines, all three entries) and swept for connection-handling vocabulary, and it contains **no mechanism** by which descriptor reaping would change — the single keyword hit was `S3 … honor the node's proxy settings`, HTTP-proxy config for S3, not the PBS proxy daemon. **The 32-minute post-upgrade window is indistinguishable from the before window** (+5 fd/1919 s = 225/day vs +4 fd/1885 s = 183/day; the two differ by ONE descriptor and Poisson uncertainty on such counts is ±2, so both are consistent with one unchanged rate — the higher after-figure is noise, not a regression). **Thirty minutes cannot settle it in either direction and this row exists so nobody pretends it did.** New `t0` = **fd 17 at 2026-08-18 09:51:22Z, proxy PID 551655**; before-rate to beat = **183–200/day**. **Interpretation fixed in advance** (`evidence-ep0-pbs-upgrade-2026-08-18/stop1-ruling.txt`, written before any numbers existed): unchanged = EXPECTED, not a failed upgrade; changed = a SURPRISE needing explanation, not a confirmation | **WATCHING — NEW 2026-08-18** | elapsed time only | **Two dated checks, both CC:** **+24 h — 2026-08-19 ~10:00Z** and **+7 d — 2026-08-25 ~10:00Z**. Command (the incident's own positive observable): `ssh root@ 'PID=$(systemctl show proxmox-backup-proxy -p MainPID --value); ls /proc/$PID/fd \| wc -l; ss -lnt "( sport = :8007 )"; ss -tn state all "( sport = :8007 )" \| awk "NR>1{print \$1}" \| sort \| uniq -c'`. **Record the ESTAB/CLOSE-WAIT split, not just the total** — the split is what says which leak it is. If PID ≠ 551655 the window is void: something restarted the proxy and the count began again | CC on both dates | + +| **R-342** | **The ep0 snapshot covers less than it looks like it covers, and the next person will assume otherwise.** Quoting `audits/evidence-ep0-pbs-upgrade-2026-08-18/stop2-snapshot.txt` verbatim: *"covers — the 38 GB system disk /dev/sda (root), i.e. the PBS packages, unit files, /etc/systemd drop-ins, nftables and wg config. DOES NOT — /mnt/pbs-datastore. That is /dev/sdb, a separate 100 GB VOLUME, and Hetzner server snapshots do not include attached volumes. The backup data is therefore NOT protected by this snapshot."* Snapshot **421440873** (`felhom-hetzner-20260818`, 15.06 GB, Available) was taken as the rollback for the 4.2.2→4.2.5 PBS upgrade. **Rolling it back restores software state, not the datastore.** That was *acceptable for that change* — a package install writes no datastore content — and the file says so. **The problem is what happens next:** this fact lives in an evidence file nobody will open again, and a snapshot named as "the rollback" reads as protecting everything on the box. ep0 holds the only off-premises copy of a real customer's data | **READY (S) — NEW 2026-08-18** | — | **Decide the safeguard for any future ep0 procedure that could touch `/mnt/pbs-datastore` — it does not exist and has not been designed.** Candidates: a Hetzner **Volume** snapshot (a different object from the server snapshot), a PBS-level sync to a second location, or an explicit written acceptance that the datastore is unprotected for the duration. **Nothing may be added to a runbook implying a safeguard exists until one does** | **Viktor decides; CC executes** — a risk-to-customer-data question | +| **R-343** | **The managed controller floor was raised 0.214.0 → 0.216.0 — and it was NOT the no-op it was expected to be: it moved a live box nine seconds later.** Raised by the operator 2026-08-18 **12:36:58Z**, in a **separate save after** the artifact vouch. **Read back from the store, not the form** (`hub_settings.min_controller_version`, `GetGlobalMinControllerVersion` — `hub/internal/store/store.go:1751`): `min_controller_version = 0.216.0`, updated 12:36:58. **WHY IT HAD BEEN BEHIND — this was NOT drift, and describing it as "two releases behind" without this context reads as a defect it was not.** `publish-train-rules.md` rule 1 is *manifest before floor*, and rule 2 requires the floor field to be filled **LAST, in a separate save**, because the DB row overrides the env floor and **acts immediately on the next report cycle**. That rule was earned: on the 2026-07-11 publish train the floor was saved together with the manifest, acted at once, and pushed controller 0.113.0 onto Peti's box **~9 minutes ahead of agent 0.81.0** — the exact forbidden skew, benign only because that box had no NAS shares. R-120's row records the same deliberate choice (*"Floor untouched per publish-train rule 2"*). So the floor sitting at 0.214.0 was **policy being followed**, not neglect. **What it was functionally while it sat there:** not a live problem — every reporting box was at or above it — but a **safety net set two versions low**. The floor is what drags a box forward if it ever falls behind (restored from an old backup, reinstalled, long offline), and at 0.214.0 it would have pulled such a box only to two versions back, missing R-328's severity fix and R-335's follow-up. **THE MEASURED BLAST RADIUS — five reads, and the third and fifth are the findings.** **(1) Floor:** `0.216.0` @ 12:36:58Z, from the store. **(2) Per-customer overrides:** **zero** — all five `customer_configs` rows carry an empty `min_controller_version`, so nothing hides behind a lower override and the global applies to everyone. **(3) Every box's controller version:** `demo-felhom` **0.216.0**, `demo-hp` **0.216.0** — both AT the floor **now**; `drill-r50` **0.213.0** (status `blocked`, last report 2026-08-12, powered off/reverted) and `peti-felhom` **0.115.0** (host row DELETED) are **below** it but are **not reporting boxes**. **(4) Directives/holds:** no `managed floor HELD` line exists; the hub logged `[INFO] Global controller-version floor set to "0.216.0"`. **(5) THE FINDING — a controller DID auto-update after the raise.** `demo-felhom` had been on **0.214.0** since 2026-08-12 16:44 and the hub recorded `controller_updated — Controller frissítve: 0.214.0 → 0.216.0` at **12:37:07Z**, then `controller_started (0.216.0)` at 12:37:12Z — **nine seconds after the raise**, exactly the immediate action rule 2 documents. `demo-hp` was already on 0.216.0 (hand-deployed 2026-08-14 08:31) and did not move. **No error, warning or critical event followed** — the update completed and the controller came back up. **So the change was real, not inert: "every reporting box is at or above the floor" is true BECAUSE of the raise, not independently of it.** **Why it is safe by construction**, cited rather than asserted: `ResolveManagedFloor` (`hub/internal/store/store.go:2068`) sets `Held` and clears the floor entirely when `Floor > GoldenVersion` (the R-216 shape) — floor 0.216.0 **equals** golden 0.216.0, so that guard does not trip — and holds per-box when the box's agent is below the manifest's `MinAgent`, or unknown, or unparseable; both boxes report agent 0.129.0 against `MinAgent` 0.129.0, so the floor was served rather than held. That second guard remains armed for any box reporting with an old agent. **No ISO rebuild is required:** the golden is fetched at first boot from the hub's manifest, which is 0.216.0 — at the floor, not below it — and rule 5's `assert_golden_ge_floor` is a **build-time** gate for *future* builds (`scripts/iso/build-felhom-iso.sh:77`, called at **:267**) which **fails open with a warning** when its inputs are absent (`:78-82`: `if [[ -z "$golden" \| \| -z "$floor" ]]` → `log_warn "… UNENFORCED …"` → `return 0`), both confirmed in the script. **`peti-felhom` was NOT contacted and needs no contact** — from the PETI row: its host row was deleted 2026-07-15 and *"a report from a deleted host 401s and is not persisted"*, so it cannot receive a floor directive at all and the raise cannot reach it | **OPEN — NEW 2026-08-18.** Deliberately NOT closed: the task's closing condition was *all five reads clean, no directive served*, and read 5 shows a live box updated. It went cleanly and is the floor working as designed — but a change recorded as a no-op when it moved a customer box is exactly the kind of record that misleads later | — | **Confirm the 0.216.0 update on `demo-felhom` is healthy in normal operation** (it reported and restarted clean, but it has not yet run a full backup cycle on 0.216.0 at the time of writing), then close. Separately: `drill-r50` at 0.213.0 will be dragged to 0.216.0 by this floor if it is ever booted and reports — that is the floor doing its job, noted so it is not read as a surprise | CC | + + +| item | due (UTC) | what to measure | +|---|---|---| +| R-341 | 2026-08-19 | ep0 proxy fd count + ESTAB/CLOSE-WAIT split; PID must still be 551655 | +| R-341 | 2026-08-25 | same, +7 d | + diff --git a/documentation/runbooks/publish-train-rules.md b/documentation/runbooks/publish-train-rules.md index 89a90039..38c6f910 100644 --- a/documentation/runbooks/publish-train-rules.md +++ b/documentation/runbooks/publish-train-rules.md @@ -81,3 +81,11 @@ so the gate is enforced, not skipped (an unset input warns LOUDLY and does not s fix for a tripped gate is never to lower the floor: **republish golden ≥ floor and vouch it** (rule 1), then rebuild. This gate composes with rule 1 — the manifest still leads the floor; this one stops an ISO from carrying a golden the floor has already outrun. + +> **Dated note, 2026-08-18 — the current pair is golden `0.216.0` / floor `0.216.0`.** Equal, so the +> R-71 gate passes. Recorded so the next build has numbers to hand, and deliberately **not** as a +> replacement for the instruction above: **resolve both LIVE from the hub before a build.** A +> snapshot that looks authoritative is exactly how `build-golden.sh`'s hand-bumped `CONTROLLER_IMAGE` +> default rotted twice. Treat these as a sanity check on what you read, never as a substitute for +> reading it. (Floor raised to 0.216.0 by the operator on 2026-08-18 in a separate save after the +> vouch, per rule 2 — see R-343, which records that the raise moved `demo-felhom` nine seconds later.) diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index 411b32c5..e8921d5c 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,36 @@ +## due_checks_gate.py v1.0.0 — a dated check becomes a thing that bites (2026-08-18, R-341) + +**New gate, registered as #10 in `repo_gates.py` (`--fast`).** R-341 booked two dated measurements — ++24 h and +7 d — as a sentence inside a register row. Nothing read those dates, and nothing would +have objected when they passed. That is the same shape as R-242 (a rule filed without a mechanism, +which recurred the next day) and as the R-29 census, where the checks nobody was told to run were the +ones failing for weeks. + +**The dates now live in a `DUE-CHECKS` block INSIDE `documentation/backlog/OPEN-ITEMS.md`** — inside, +so there is no sidecar to drift from the register. The block is an index; the command and the +preconditions stay in the R-row. + +**Exit-code semantics** (the runner's 0/1/2 taxonomy): + +| exit | when | +|---|---| +| **0** | nothing due — prints the pending count and the nearest date, because a passing run that says nothing teaches nobody what it watched. A well-formed EMPTY block is also 0, with its own message | +| **1** | a row is due or overdue (`due <= today`, UTC — **due today counts as due**), or a row names an item with no `\| **R-xxx** \|` row in the register | +| **2** | the block is absent, duplicated, or a row does not parse — INCONCLUSIVE, naming the exact line. **Never 0**: a gate that finds nothing to check and reports success is the inert-seam failure | + +**It REFUSES rather than warns**, deliberately — a warning is what gets scrolled past. + +**THE LIMITATION, stated in the docstring so it cannot be forgotten: this is NOT a scheduler.** It +fires on the next push, not on the date. A real scheduler was deliberately not built; the mitigation +is that CI runs the same entry point on every push and e-mails the operator on failure. + +Tests: `scripts/test_due_checks_gate.py`, 37 assertions. Two companion red-proofs were run and +reverted. **The first one found a hollow assertion in this suite rather than confirming it:** flipping +the boundary `<=` to `<` left the due-today row in neither bucket, `min()` raised on an empty list, +and the traceback exited 1 — so the exit-code check passed while the boundary was wrong. The test now +asserts the conviction banner and the absence of a traceback, and the gate returns 2 instead of +crashing if that partition is ever broken again. + ## felhom-host-install.sh v1.28.0 — the removal genuinely reverses the installation (2026-08-13, R-316) **v1.27.0's fix worked exactly once per machine, and this is the measurement.** Three full cycles on diff --git a/scripts/due_checks_gate.py b/scripts/due_checks_gate.py new file mode 100644 index 00000000..26aa8e82 --- /dev/null +++ b/scripts/due_checks_gate.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Due-checks gate — a dated check in the register becomes a thing that BITES. + +Run from the repo root: python3 scripts/due_checks_gate.py [path/to/OPEN-ITEMS.md] +Exit 0 clean · 1 convicted (something is due or overdue) · 2 inconclusive (cannot evaluate). + +WHY THIS EXISTS, and why the register alone was not enough. + +R-341 was filed on 2026-08-18 with two dated checks — *+24 h* and *+7 d* — written as prose inside +the row. **Nothing read those dates and nothing would have objected when they passed.** This +project's own standard, stated in the workspace CLAUDE.md, is that *a rule without a mechanism is a +wish*, and it has been earned repeatedly: R-242 was filed as a mechanism-less rule and recurred the +next day; the R-29 gate census found two checks nobody was told to run had been failing for weeks, +one since 14 July. A date sitting in a paragraph is exactly that shape. + +So the dates move into a machine-readable block **inside `OPEN-ITEMS.md`** and this gate reads them. +The block lives in the register rather than in a sidecar file deliberately: a sidecar is a second +source of truth, and the two drift the moment someone edits one. + +⚠ THE HONEST LIMITATION — read this before trusting it, and do not let it be forgotten. + +**This is NOT a scheduler.** It fires when someone next runs the gates — i.e. on the next push, via +`.githooks/pre-push` and CI — **not when the date arrives**. If nobody pushes for a week after a +check comes due, nothing speaks for that week. A real scheduler (systemd timer, cron, a hub job) was +deliberately NOT built here: it is a larger design with its own failure modes, and the push-triggered +version was accepted knowing this. **The mitigation is that CI runs the same entry point on every +push and e-mails the operator on failure**, so the first push after a due date turns into a message +rather than a silent pass. If the gap between pushes ever becomes the problem, that is the argument +for the scheduler, and this paragraph is the record that it was a choice. + +It also cannot tell you whether a check was done WELL — only that a row is still sitting there. The +row is cleared by hand when the measurement is taken and its result recorded in the R-row. + +THE RULES, stated so a boundary is not silently re-decided later: + + * **UTC, always.** `datetime.now(timezone.utc).date()`. A local-time comparison would make this + gate fire on a different day for the operator in CEST than for CI, and "it passed on my machine" + is not a property a gate may have. + * **Due TODAY counts as DUE** (`due <= today`, not `<`). A check scheduled for the 19th is meant to + happen on the 19th; letting the 19th pass silently and convicting only on the 20th would make the + gate a day late by design. + * **An orphaned R-number is a conviction, not a warning.** If the block cites an item that has no + row in the register, the coupling is broken — and an item whose row has vanished while its dated + check remains is precisely how an item gets lost, which is the failure the register exists to end. + * **FAIL-CLOSED, and 2 is not 0.** A missing, duplicated or unparseable block exits 2 + (INCONCLUSIVE). A gate that quietly finds nothing to check reads as coverage while providing + none — the inert-seam failure this project has shipped four times. The runner reports 2 + distinctly for exactly this reason. + * **A passing run still says what it looked at.** An absent log line is not evidence of correct + behaviour (workspace standing rule 3), so a clean run prints the pending count and the nearest + due date rather than going quiet. + +Stdlib only; no network, no subprocess — so it qualifies as `--fast` and therefore runs in BOTH the +pre-push hook and CI. A non-fast gate would run in neither, which is the R-29 failure the runner +ended. +""" +import os +import re +import sys +from datetime import date, datetime, timezone + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_REGISTER = os.path.join(ROOT, "documentation", "backlog", "OPEN-ITEMS.md") + +BEGIN = "DUE-CHECKS-BEGIN" +END = "DUE-CHECKS-END" + +# `| R-341 | 2026-08-19 | ep0 proxy fd count … |` — three cells, item / date / what. +ROW_RE = re.compile(r"^\|\s*(?P[A-Za-z0-9][A-Za-z0-9._-]*)\s*\|" + r"\s*(?P[^|]*?)\s*\|" + r"\s*(?P[^|]*?)\s*\|\s*$") +# The header and its separator, which are not data. +HEADER_RE = re.compile(r"^\|\s*item\s*\|", re.I) +SEP_RE = re.compile(r"^\|[\s:|-]+\|$") +# `| **R-341** | …` anywhere above — the register's own row shape. +def item_row_re(item): + return re.compile(r"^\|\s*\*\*" + re.escape(item) + r"\*\*\s*\|") + + +def today_utc(): + """Today's date in UTC. Injectable via FELHOM_GATE_TODAY for the test suite. + + The env override follows instructions_gate.py's existing convention rather than inventing a + second one — a test suite that cannot control 'today' can only test the boring branch. + """ + override = os.environ.get("FELHOM_GATE_TODAY") + if override: + try: + return datetime.strptime(override.strip(), "%Y-%m-%d").date() + except ValueError: + print("DUE-CHECKS GATE INCONCLUSIVE: FELHOM_GATE_TODAY=%r is not YYYY-MM-DD" % override) + sys.exit(2) + return datetime.now(timezone.utc).date() + + +def read_register(path): + if not os.path.isfile(path): + print("DUE-CHECKS GATE INCONCLUSIVE: register not found at %s" % path) + print(" A missing register is never a pass — the gate cannot know what is due.") + sys.exit(2) + with open(path, encoding="utf-8") as fh: + return fh.read().split("\n") + + +def extract_block(lines, path): + """Return (block_lines, first_line_no). Exits 2 on absent/duplicated markers.""" + begins = [i for i, l in enumerate(lines) if BEGIN in l] + ends = [i for i, l in enumerate(lines) if END in l] + if len(begins) == 0 or len(ends) == 0: + print("DUE-CHECKS GATE INCONCLUSIVE: no %s / %s block in %s" % (BEGIN, END, path)) + print(" Expected a machine-readable block; found begin=%d end=%d." % (len(begins), len(ends))) + print(" This is INCONCLUSIVE, not a pass: a gate that finds nothing to check and reports") + print(" success is the inert-seam failure. Restore the block or remove this gate.") + sys.exit(2) + if len(begins) > 1 or len(ends) > 1: + print("DUE-CHECKS GATE INCONCLUSIVE: the block appears more than once in %s" % path) + print(" begin markers on lines: %s" % ", ".join(str(i + 1) for i in begins)) + print(" end markers on lines: %s" % ", ".join(str(i + 1) for i in ends)) + print(" Two blocks are two sources of truth and the gate will not guess which one counts.") + sys.exit(2) + b, e = begins[0], ends[0] + if e <= b: + print("DUE-CHECKS GATE INCONCLUSIVE: %s (line %d) appears before %s (line %d) in %s" + % (END, e + 1, BEGIN, b + 1, path)) + sys.exit(2) + # The BEGIN marker lives INSIDE an HTML comment that documents the block, and that comment + # normally runs on for several lines. So the block does not start in neutral text — it starts + # mid-comment whenever the marker's own line has not closed it. Getting this wrong is what the + # first run of this gate did: it read the comment's second line as a malformed table row. + return lines[b + 1:e], b + 2, ("-->" not in lines[b]) + + +def parse_rows(block, first_no, path, in_comment=False): + """[(item, date, what, line_no)] — exits 2 naming the exact line on any unparseable row. + + `in_comment` carries whether the block opens inside the marker's own HTML comment. + """ + rows = [] + for off, raw in enumerate(block): + line_no = first_no + off + s = raw.strip() + if in_comment: + # Still inside a comment; it ends on the line carrying '-->'. + if "-->" in s: + in_comment = False + continue + if not s: + continue + if s.startswith("" not in s: + in_comment = True + continue + if HEADER_RE.match(s) or SEP_RE.match(s): + continue + if not s.startswith("|"): + # Prose inside the block is a malformed block, not a comment. Say which line. + print("DUE-CHECKS GATE INCONCLUSIVE: %s line %d is not a table row and not a comment:" + % (path, line_no)) + print(" %s" % s[:120]) + sys.exit(2) + m = ROW_RE.match(s) + if not m: + print("DUE-CHECKS GATE INCONCLUSIVE: %s line %d does not parse as " + "`| item | due | what |`:" % (path, line_no)) + print(" %s" % s[:120]) + sys.exit(2) + item = m.group("item").strip() + due_s = m.group("due").strip() + try: + due = datetime.strptime(due_s, "%Y-%m-%d").date() + except ValueError: + print("DUE-CHECKS GATE INCONCLUSIVE: %s line %d has due date %r, expected YYYY-MM-DD" + % (path, line_no, due_s)) + sys.exit(2) + rows.append((item, due, m.group("what").strip(), line_no)) + return rows + + +def main(argv): + path = argv[1] if len(argv) > 1 else DEFAULT_REGISTER + lines = read_register(path) + block_lines, first_no, opens_in_comment = extract_block(lines, path) + rows = parse_rows(block_lines, first_no, path, opens_in_comment) + today = today_utc() + + if not rows: + # Distinguishable from the missing-block case above, and deliberately so: a well-formed + # empty block means "nothing is pending", a missing one means "the gate lost its input". + print("due-checks gate OK — no dated checks pending (the block is present and empty).") + print(" today (UTC): %s register: %s" % (today.isoformat(), os.path.relpath(path, ROOT))) + return 0 + + # The coupling check runs over every row before any verdict: an orphan is a conviction even if + # its date is far away, because the breakage is the missing row, not the timing. + text = "\n".join(lines) + orphans = [] + for item, due, what, line_no in rows: + if not item_row_re(item).search(text, 0) and not any( + item_row_re(item).match(l) for l in lines): + orphans.append((item, due, line_no)) + if orphans: + print("DUE-CHECKS GATE FAILED: a dated check names an item with no row in the register.") + for item, due, line_no in orphans: + print(" %-8s due %s (block line %d) — no `| **%s** |` row found" + % (item, due.isoformat(), line_no, item)) + print("") + print("A dated check whose item does not exist is how an item gets lost, which is the") + print("failure the register exists to end. Restore the row, or remove the dated check.") + return 1 + + due_now = [r for r in rows if r[1] <= today] + pending = [r for r in rows if r[1] > today] + + if due_now: + print("DUE-CHECKS GATE FAILED: %d dated check(s) are due or overdue as of %s (UTC)." + % (len(due_now), today.isoformat())) + print("") + for item, due, what, line_no in sorted(due_now, key=lambda r: r[1]): + overdue = (today - due).days + when = "DUE TODAY" if overdue == 0 else "%d day(s) OVERDUE" % overdue + print(" %-8s due %s %s" % (item, due.isoformat(), when)) + print(" measure: %s" % what) + print(" the command and its preconditions are in the %s row of %s" + % (item, os.path.relpath(path, ROOT))) + print("") + print("Take the measurement, record the result in that R-row, then remove the row from the") + print("DUE-CHECKS block. Moving the date instead is allowed — state the reason in the R-row.") + print("NOTE: this gate fires on a PUSH, not on the date; it may be later than the date.") + return 1 + + if not pending: + # Unreachable while the boundary is `<=` / `>`, which partition the rows exactly — but a + # gate must never end in a traceback, and this branch is not theatre: the red-proof that + # flipped `<=` to `<` landed here and CRASHED with `min() iterable argument is empty`, + # exiting 1 for the wrong reason and making the boundary test pass on a lie. A crash is + # never a verdict; if the partition is ever broken again, say so as INCONCLUSIVE. + print("DUE-CHECKS GATE INCONCLUSIVE: %d row(s) parsed but none classified as due or " + "pending — the date comparison is broken." % len(rows)) + return 2 + nearest = min(pending, key=lambda r: r[1]) + print("due-checks gate OK — %d dated check(s) pending, none due yet." % len(pending)) + print(" today (UTC): %s" % today.isoformat()) + print(" nearest: %s due %s (in %d day(s)) — %s" + % (nearest[0], nearest[1].isoformat(), (nearest[1] - today).days, nearest[2][:70])) + print(" (fires on the next PUSH after a date passes, not on the date itself — by design)") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/repo_gates.py b/scripts/repo_gates.py index 816514a6..6dd27a66 100644 --- a/scripts/repo_gates.py +++ b/scripts/repo_gates.py @@ -15,6 +15,21 @@ Gates, in order (all must pass; **non-zero exit on any failure**): 5. reuse-refs every path cited by this repo's REUSE.md still resolves 6. instructions CLAUDE.md length/versions/TEMPORARY, rule-file scoping, workspace-copy identity 7. golden-currency a released controller has a golden carrying it (R-242) + 8. wire-contract every emitted field is decodable by its receiver (G-1) + 9. hub-copy the hub's customer-facing words, against the retired-name list (R-324) + 10. due-checks a dated check in OPEN-ITEMS.md that has come due (R-341) + +WHY 10 IS HERE (2026-08-18, R-341). R-341 booked two dated measurements — +24 h and +7 d — +as a sentence inside a register row. Nothing read those dates, and nothing would have said a word +when they passed; the row would simply have gone quiet and stayed that way. That is the same shape +as R-242 (a rule filed without a mechanism, which recurred the next day) and as the R-29 census +finding below, where the checks nobody was told to run were the ones that had been failing for +weeks. The dates now live in a machine-readable block INSIDE OPEN-ITEMS.md — inside, so there is no +sidecar to drift from the register — and this gate refuses the push once one comes due. It REFUSES +rather than warns, deliberately: a warning is the thing that gets scrolled past, and this repo has +the census to prove it. It is `--fast` (stdlib file read, no network) so it runs in both the +pre-push hook and CI. **It is not a scheduler and its docstring says so** — it fires on the next +push after a date passes, not on the date. WHY 7 IS HERE (2026-08-08, R-242). R-242 was filed as a rule with no mechanism — *a controller release is not finished until a golden carries it* — and RECURRED THE NEXT DAY: v0.206.0 shipped @@ -72,6 +87,8 @@ GATES = [ # R-324 — the hub composes every customer e-mail and renders the binding pages, and until # 2026-08-13 no guard in either repo had ever looked at them. Fast: pure file reads. ("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True), + # R-341 — dated checks in the register were prose that nothing read. Fast: stdlib file read. + ("due-checks", os.path.join(SCRIPTS, "due_checks_gate.py"), [], True), ] VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} diff --git a/scripts/test_due_checks_gate.py b/scripts/test_due_checks_gate.py new file mode 100644 index 00000000..faed0c83 --- /dev/null +++ b/scripts/test_due_checks_gate.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +"""Fixture tests for due_checks_gate.py. + +Run: python3 scripts/test_due_checks_gate.py + +Every test asserts the EFFECT — the exit code AND that the message names the item and the reason — +not merely that the gate ran. A gate that refuses a push without saying WHICH check is overdue sends +the reader back to the register to guess, which is the state this gate exists to end. + +Group G drives `repo_gates.py` itself and asserts the gate appears in the runner's output. It +deliberately does NOT grep repo_gates.py's source: a commented-out registration still contains the +string, so a source grep would pass on exactly the inert-seam failure this project has shipped +before. Only running the runner proves the gate is wired. + +Fixtures are temp files; nothing here touches the real register. +""" +import os +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +GATE = os.path.join(HERE, "due_checks_gate.py") +RUNNER = os.path.join(HERE, "repo_gates.py") + +PASSED = [] +FAILED = [] + + +def check(name, cond, detail=""): + (PASSED if cond else FAILED).append(name + ((" — " + detail) if detail else "")) + print((" PASS " if cond else " FAIL ") + name + ((" " + detail) if detail else "")) + + +def run_gate(register_path, today="2026-08-20"): + env = dict(os.environ, FELHOM_GATE_TODAY=today) + p = subprocess.run([sys.executable, GATE, register_path], + capture_output=True, text=True, env=env) + return p.returncode, p.stdout + p.stderr + + +HEADER = "| item | due (UTC) | what to measure |\n|---|---|---|\n" + + +def make_register(tmp, rows_block, item_rows=("R-341",), markers=1, name="OPEN-ITEMS.md"): + """Build a fixture register: some R-rows, then `markers` copies of the delimited block.""" + path = os.path.join(tmp, name) + body = ["# OPEN-ITEMS (fixture)", ""] + for it in item_rows: + body.append("| **%s** | some description | WATCHING | — | do a thing | CC |" % it) + body.append("") + for _ in range(markers): + body.append("") + body.append(rows_block.rstrip("\n")) + body.append("") + body.append("") + with open(path, "w", encoding="utf-8") as fh: + fh.write("\n".join(body) + "\n") + return path + + +# ── Group A — overdue refuses the push ─────────────────────────────────────────────────────── +def test_a_overdue_exits_1_and_names_item_and_days(): + with tempfile.TemporaryDirectory() as tmp: + reg = make_register(tmp, HEADER + "| R-341 | 2026-08-19 | fd count + split |\n") + rc, out = run_gate(reg, today="2026-08-20") + check("A: overdue exits 1", rc == 1, "rc=%d" % rc) + check("A: names the item", "R-341" in out) + check("A: names the due date", "2026-08-19" in out) + check("A: says how many days overdue", "1 day(s) OVERDUE" in out) + check("A: points at the R-row for the command", "R-341 row of" in out) + + +# ── Group B — a future check is silent but not mute ────────────────────────────────────────── +def test_b_future_exits_0_and_still_reports_what_it_saw(): + with tempfile.TemporaryDirectory() as tmp: + reg = make_register(tmp, HEADER + "| R-341 | 2026-08-25 | fd count + split |\n") + rc, out = run_gate(reg, today="2026-08-20") + check("B: future exits 0", rc == 0, "rc=%d" % rc) + check("B: prints the pending count", "1 dated check(s) pending" in out) + check("B: prints the nearest due date", "2026-08-25" in out) + check("B: a passing run is not mute", "nearest:" in out) + + +# ── Group C — due exactly today counts as due ──────────────────────────────────────────────── +def test_c_due_today_is_due(): + """The boundary is `due <= today`. Assert the CONVICTION, not merely a non-zero exit. + + The first version of this test asserted only `rc == 1`, and the red-proof caught it being + hollow: flipping `<=` to `<` sent the row into neither bucket, `min()` raised on an empty + list, and the traceback exited 1 — so the test passed while the boundary was wrong. An exit + code alone cannot distinguish a verdict from a crash, which is why the banner is asserted and + a traceback is explicitly ruled out. + """ + with tempfile.TemporaryDirectory() as tmp: + reg = make_register(tmp, HEADER + "| R-341 | 2026-08-19 | fd count + split |\n") + rc, out = run_gate(reg, today="2026-08-19") + check("C: due TODAY exits 1 (boundary is <=)", rc == 1, "rc=%d" % rc) + check("C: exits 1 as a VERDICT, not a traceback", "DUE-CHECKS GATE FAILED" in out) + check("C: did not crash", "Traceback" not in out) + check("C: says DUE TODAY rather than overdue", "DUE TODAY" in out) + + +def test_c_gate_never_ends_in_a_traceback(): + """Whatever the input, the gate exits 0/1/2 with a message — never a Python traceback.""" + cases = { + "overdue": "| R-341 | 2026-08-19 | x |\n", + "future": "| R-341 | 2099-01-01 | x |\n", + "empty": "", + } + with tempfile.TemporaryDirectory() as tmp: + for name, rows in cases.items(): + reg = make_register(tmp, HEADER + rows, name="OPEN-%s.md" % name) + rc, out = run_gate(reg, today="2026-08-20") + check("C: %s ends in a verdict not a traceback" % name, + "Traceback" not in out and rc in (0, 1, 2), "rc=%d" % rc) + + +# ── Group D — an orphaned row ──────────────────────────────────────────────────────────────── +def test_d_orphaned_item_is_a_conviction(): + with tempfile.TemporaryDirectory() as tmp: + reg = make_register(tmp, HEADER + "| R-999 | 2026-09-30 | something |\n", + item_rows=("R-341",)) + rc, out = run_gate(reg, today="2026-08-20") + check("D: orphan exits non-zero", rc != 0, "rc=%d" % rc) + check("D: names the orphaned item", "R-999" in out) + check("D: names the broken coupling", "no row in the register" in out + or "no `| **R-999** |` row found" in out) + + +def test_d_orphan_convicts_even_when_date_is_far_away(): + """The breakage is the missing row, not the timing — so a far-future orphan still convicts.""" + with tempfile.TemporaryDirectory() as tmp: + reg = make_register(tmp, HEADER + "| R-888 | 2099-01-01 | far future |\n") + rc, out = run_gate(reg, today="2026-08-20") + check("D: far-future orphan still convicts", rc != 0, "rc=%d" % rc) + + +# ── Group E — malformed or missing block is INCONCLUSIVE, never a pass ─────────────────────── +def test_e_missing_block_exits_2(): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "OPEN-ITEMS.md") + with open(path, "w", encoding="utf-8") as fh: + fh.write("# OPEN-ITEMS (fixture)\n\n| **R-341** | d | s | — | a | CC |\n") + rc, out = run_gate(path, today="2026-08-20") + check("E: missing block exits 2 (NOT 0)", rc == 2, "rc=%d" % rc) + check("E: says inconclusive", "INCONCLUSIVE" in out) + check("E: explains why 0 would be wrong", "inert-seam" in out or "not a pass" in out) + + +def test_e_missing_register_exits_2(): + with tempfile.TemporaryDirectory() as tmp: + rc, out = run_gate(os.path.join(tmp, "nope.md"), today="2026-08-20") + check("E: absent register exits 2", rc == 2, "rc=%d" % rc) + + +def test_e_malformed_row_exits_2_naming_the_line(): + with tempfile.TemporaryDirectory() as tmp: + reg = make_register(tmp, HEADER + "| R-341 | not-a-date | fd count |\n") + rc, out = run_gate(reg, today="2026-08-20") + check("E: bad date exits 2", rc == 2, "rc=%d" % rc) + check("E: names the offending value", "not-a-date" in out) + check("E: names the exact line", "line " in out) + + +def test_e_non_row_prose_in_block_exits_2_naming_the_line(): + with tempfile.TemporaryDirectory() as tmp: + reg = make_register(tmp, HEADER + "this is prose, not a row\n") + rc, out = run_gate(reg, today="2026-08-20") + check("E: prose inside the block exits 2", rc == 2, "rc=%d" % rc) + check("E: quotes the offending line", "this is prose" in out) + + +def test_e_duplicated_block_exits_2(): + with tempfile.TemporaryDirectory() as tmp: + reg = make_register(tmp, HEADER + "| R-341 | 2026-08-25 | fd |\n", markers=2) + rc, out = run_gate(reg, today="2026-08-20") + check("E: duplicated block exits 2", rc == 2, "rc=%d" % rc) + check("E: says two sources of truth", "more than once" in out) + + +# ── Group F — a well-formed empty block is a PASS, distinguishable from E ──────────────────── +def test_f_empty_block_exits_0_with_its_own_message(): + with tempfile.TemporaryDirectory() as tmp: + reg = make_register(tmp, HEADER) + rc, out = run_gate(reg, today="2026-08-20") + check("F: empty block exits 0", rc == 0, "rc=%d" % rc) + check("F: says no dated checks pending", "no dated checks pending" in out) + check("F: distinguishable from a missing block", "INCONCLUSIVE" not in out) + + +# ── Group G — registration, proven by DRIVING the runner (never a source grep) ──────────────── +def test_g_gate_is_registered_in_the_runner(): + """A gate that passes its own tests and is not wired into repo_gates.py is inert. + + This runs the runner and looks for the gate's label in its OUTPUT. A grep over + repo_gates.py's source would also match a commented-out line, which is precisely the + failure mode being guarded against. + """ + p = subprocess.run([sys.executable, RUNNER, "--fast"], + capture_output=True, text=True, cwd=ROOT) + out = p.stdout + p.stderr + check("G: runner names the due-checks gate in its output", "due-checks" in out) + check("G: runner reports a verdict line for it", + any(l.strip().startswith("due-checks") and ("OK" in l or "FAILED" in l + or "INCONCLUSIVE" in l) + for l in out.splitlines())) + check("G: runner invoked the gate script by name", + "due_checks_gate.py" in out) + + +def main(): + print("test_due_checks_gate") + for fn in sorted((v for k, v in globals().items() if k.startswith("test_")), + key=lambda f: f.__name__): + fn() + print("") + print("passed: %d failed: %d" % (len(PASSED), len(FAILED))) + if FAILED: + print("") + for f in FAILED: + print(" FAILED: %s" % f) + return 1 if FAILED else 0 + + +if __name__ == "__main__": + sys.exit(main())