due-checks gate (R-341), floor raise recorded (R-343), snapshot coverage (R-342)
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.
This commit is contained in:
2026-08-18 15:16:51 +02:00
parent f267bc047f
commit 0a5e9b14dc
10 changed files with 905 additions and 1 deletions
+33
View File
@@ -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
+251
View File
@@ -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<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))
+17
View File
@@ -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"}
+229
View File
@@ -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("<!-- DUE-CHECKS-BEGIN — machine-readable. Parsed by scripts/due_checks_gate.py.")
body.append(" One row per dated check. Dates are UTC. -->")
body.append(rows_block.rstrip("\n"))
body.append("<!-- DUE-CHECKS-END -->")
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())