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
+5 -1
View File
@@ -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).
+20
View File
@@ -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
+317
View File
@@ -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
<!-- DUE-CHECKS-BEGIN — machine-readable. Parsed by scripts/due_checks_gate.py.
One row per dated check. The R-number must have a row above. Dates are UTC.
Clearing a row means the check was DONE and its result recorded in that R-row —
or the date was deliberately moved, with the reason stated in the R-row.
This block is an INDEX, not the detail: the command and the preconditions live in
the R-row. Duplicating them here would create the second source this design avoids. -->
| 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 |
<!-- DUE-CHECKS-END -->
```
**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.
+10
View File
@@ -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
File diff suppressed because one or more lines are too long
@@ -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.)
+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())