0a5e9b14dc
gates / gates (push) Successful in 14s
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.
252 lines
13 KiB
Python
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))
|