Files
felhom.eu/scripts/due_checks_gate.py
T
admin 0a5e9b14dc
gates / gates (push) Successful in 14s
due-checks gate (R-341), floor raise recorded (R-343), snapshot coverage (R-342)
PART 1+2 — dated checks stop being wishes. R-341 booked two measurements as
prose in a register row; nothing read those dates and nothing would have
objected when they passed. The dates now live in a DUE-CHECKS block INSIDE
OPEN-ITEMS.md (inside, so no sidecar can drift from it) and a new gate reads
them. Registered as #10 in repo_gates.py, --fast, so it runs in BOTH the
pre-push hook and CI.

  exit 0  nothing due (prints pending count + nearest date; empty block too)
  exit 1  a row is due/overdue (due <= today, UTC -- due TODAY counts), or a
          row names an item with no R-row
  exit 2  block absent/duplicated/unparseable -- INCONCLUSIVE, never 0

It REFUSES rather than warns, and its docstring states the limitation: it is
NOT a scheduler, it fires on the next push, not on the date.

37 tests. BOTH red-proofs run and reverted -- and the first one earned its
keep by catching a hollow assertion of MINE rather than confirming the gate:
flipping <= to < left a due-today row in neither bucket, min() raised on an
empty list, and the TRACEBACK exited 1, so "rc == 1" passed while the
boundary was wrong. An exit code cannot tell a verdict from a crash. The test
now asserts the conviction banner and the absence of a traceback, and the gate
returns 2 rather than crashing if that partition breaks again.

PART 3 — the floor raise, and the premise was WRONG. Read back from the store
(not the form): min_controller_version = 0.216.0 @ 12:36:58Z, zero
per-customer overrides, no "managed floor HELD" line. But read 5 shows the
raise was NOT a no-op: demo-felhom had been on 0.214.0 since 12 Aug and
auto-updated 0.214.0 -> 0.216.0 at 12:37:07Z -- NINE SECONDS after the save,
exactly the immediate action publish-train rule 2 documents. No error events
followed; it restarted clean.

R-343 is therefore filed OPEN, not CLOSED: the closing condition was all five
reads clean and no directive served. It went well, but a record calling it
inert when it moved a customer box is what misleads the next reader. The row
also states why the floor was behind -- rule 2 policy, not drift, earned by
the 2026-07-11 skew onto Peti's box -- and cites ResolveManagedFloor
(store.go:2068) plus the two build-felhom-iso.sh facts (build-time at :267,
fails open at :78-82) rather than asserting them.

Two boxes are below the floor and neither reports: drill-r50 (blocked,
powered off) and peti-felhom (host row deleted). peti-felhom was NOT
contacted -- its row records that a report from a deleted host 401s and is
not persisted, so the raise cannot reach it.

PART 4 — R-342 filed READY, quoting stop2-snapshot.txt verbatim: Hetzner
server snapshot 421440873 covers /dev/sda only; /mnt/pbs-datastore is a
separate Volume that snapshots exclude, so a rollback restores software state
and NOT the datastore. Fine for that upgrade; the safeguard for any future
procedure that could touch the datastore does not exist and is Viktor's call.

Also: CLAUDE.md's gate list named 6 of 10 registered gates -- completed
rather than appending a 7th to a wrong list (124 -> 128 effective, ceiling
200). Capability map deliberately unchanged; no row cites a floor or golden
version. repo_gates.py fully green, 10/10.
2026-08-18 15:16:51 +02:00

252 lines
13 KiB
Python

#!/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<item>[A-Za-z0-9][A-Za-z0-9._-]*)\s*\|"
r"\s*(?P<due>[^|]*?)\s*\|"
r"\s*(?P<what>[^|]*?)\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("<!--"):
if "-->" 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))