due-checks gate (R-341), floor raise recorded (R-343), snapshot coverage (R-342)
gates / gates (push) Successful in 14s
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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"}
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user