462ab4a5ff
gates / gates (push) Successful in 29s
THE REPAIR. Last night's append regex ate the state cells of R-446 and R-458, left them as a stray
fourth cell on duplicated copies of R-626 and R-625, and split the table with blank lines. The
register read 317 rows for 315 findings. Both cells restored from the cells that carried them, the
two duplicates deleted, and 15 blank lines that split the register into 12 separate markdown tables
removed. Every row's text is byte-identical afterwards, proven by diff; no row added or removed.
THE RED-PROOF FOUND AN OLDER INSTANCE: R-254 lost its state cell on 2026-08-08 (59527d0) and had
rendered without a State column for 45 days. Its own verdict sentence was the cell; it has it back.
THE GATE. scripts/register_shape_gate.py, gate 14 in repo_gates.py and reached by the pre-push
hook: a row that does not end with `|` (an eaten state cell), a duplicated id, or a blank line
splitting the table. Four decoys in test_gate_decoys.py — three convicting on the exact damage
shapes, one asserting a healthy register still passes. The red-proof corrected the gate twice: a
first draft counted CELLS and convicted 125 innocent rows (register cells carry literal `|` in
prose and shell snippets, so a row cannot be split on `|`), and it skipped malformed rows before
counting ids, reporting 5 duplicates where there were 2. It then immediately caught a blank line
left by this session's own next insert.
R-618's rank now reads P1-HIGH in both its title and its state cell.
HETZNER ANSWERED, AND I FIRST SAID THEY HAD NOT. The operator supplied ticket #2026090103040671.
Q1: with the MAIN account, files and directories can be downloaded from a snapshot (Storage Box
docs govern, not the Storage Share FAQ); a restore reverts the whole box. Q2: --append-only is
enforced by pinning `command="rclone serve restic --stdio --append-only path/to/repo"` to the key
in authorized_keys — so append-only IS expressible on a Storage Box, which is what R-95 was blocked
on. Neither is measured; R-436's due check now asks for the measurement, not the question.
My error is filed as R-628 and is precise: the query was fine — a control returns 201 threads and
reaches SENT and TRASH — and the thread genuinely is not in this mailbox. What was invented was the
step from "absent here" to "Hetzner has not answered". An absent record in one place cannot answer
what someone else did. The rule is now in the Gmail-access memory.
R-629: the drill repo inherited has_actions from the migrate call and mailed the operator 47 CI
failures overnight, into the mailbox that was carrying real off-site alarms. Actions disabled and
verified; 09 §6.5 now makes it a step of creating a drill repo. The 47 mails are the operator's to
clear: subject:"gates FAILED in admin/app-catalog-drill".
Gates: repo_gates.py --fast — all 16 OK, including the new one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
311 lines
18 KiB
Python
311 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""repo_gates.py — THE entry point for this repo's gates. Run from the repo root:
|
|
|
|
python3 scripts/repo_gates.py # every gate
|
|
python3 scripts/repo_gates.py --fast # only gates that touch no network and no container
|
|
# runtime (what .githooks/pre-push runs)
|
|
|
|
Gates, in order (all must pass; **non-zero exit on any failure**):
|
|
|
|
1. site website HTML: BOM, emoji, nav/footer, analytics, CDN, tokens, cache-busting
|
|
2. hostinstall felhom-host-install.sh's five drill-swept invariants (+ R-94's absent-version)
|
|
3. hub-confirm no native confirm()/prompt() in hub templates
|
|
4. manifest-bearer no bearer-shaped literal anywhere in manifests/
|
|
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)
|
|
9b. guide-quote the English volunteer guide, against the controller's English bundle (R-596)
|
|
10. due-checks a dated check in OPEN-ITEMS.md that has come due (R-341)
|
|
11. one-register open work living outside OPEN-ITEMS.md (R-369)
|
|
12. closed-register a CLOSED row whose verdict still reads open, or an id in both (R-405)
|
|
13. observations a REPORT.md observation with no register row behind it (R-389)
|
|
14. register-shape a register row whose state cell was eaten, a duplicated id, or a blank line
|
|
splitting the table — the register mis-stating how many findings exist (R-627)
|
|
|
|
**THE `GATES` TABLE BELOW IS THE LIST; THIS IS A POINTER TO IT.** It drifted once already —
|
|
it read eleven while thirteen were registered, from 2026-08-24 until 2026-09-01, so
|
|
`one-register` and `closed-register` ran on every push while being documented nowhere
|
|
(R-418). Add a gate here in the same commit, or delete this list rather than let it lie.
|
|
|
|
SCOPE (R-404, 2026-09-01). `--scope=docs` marks a push whose whole range touches documents.
|
|
It changes ONE thing: a CONVICTION by a gate whose fifth `exemptible` field is True — today
|
|
`golden-currency`, and only it — prints as ADVISORY and does not refuse the push. The gate
|
|
still RUNS and still CONVICTS; its verdict, exit codes and wording are untouched. Every
|
|
other gate refuses every push, in every scope. An unscoped run behaves exactly as before.
|
|
|
|
WHY 11 IS HERE (2026-08-24, R-389). On 2026-08-23 a session measured on live hardware that only the
|
|
FIRST broken app per hour reaches the operator — the notification cooldown keys on the event type,
|
|
not on the app. It was real, reproducible, and written under `## Observations` in `REPORT.md`. It was
|
|
written NOWHERE ELSE. `REPORT.md` is overwritten every session by this project's own convention, so
|
|
the finding had a lifetime of exactly one session and had to be re-derived the next day. That is
|
|
gate 10's shape one surface over — a commitment recorded in prose that nothing enforces — and the
|
|
instruction invited it: PROMPT-TEMPLATE.md §15 asked for observations "documented, not acted on", and
|
|
"documented" was satisfied by the paragraph. The template was corrected in the same session; **this
|
|
gate is the mechanism that correction points at.** It REFUSES rather than warns, for gate 10's
|
|
reason. It PASSES quietly when there is no observations section, deliberately — a gate that taxes
|
|
every push is one that gets disabled within a week. `--fast` (stdlib file reads only).
|
|
|
|
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
|
|
while the vouched golden still carried 0.205.0, so a machine installed that morning would have got
|
|
neither of the R-241 fixes. Two occurrences in two days, the first (R-239) invisible until a walk
|
|
measured it from the customer's side. It is `--fast` because both the pre-push hook and CI run only
|
|
`--fast`; a non-fast gate would run in neither, which is the R-29 failure this runner ended. That
|
|
constraint is why it checks the BAKE and not the vouch — the full reasoning is in its docstring.
|
|
|
|
WHY 6 IS HERE AND WAS NOT (2026-08-06, R-229 deferred leg). instructions_gate.py LIVES in this
|
|
repo's scripts/ and was registered in the controller and agent runners on the day it was written —
|
|
but not in this one, because this repo's own CLAUDE.md was still 27 lines over the ceiling and a
|
|
registered-but-failing gate refuses every push through .githooks/pre-push. The file was trimmed
|
|
(227 -> 115 effective lines, core + .claude/rules/) and the gate registered in the same session.
|
|
A check that does not run in the place it applies is the exact failure the R-29 gate census found.
|
|
|
|
WHY THIS FILE EXISTS (2026-08-02, closing R-29 leg (a) and half of leg (b)).
|
|
|
|
A census of all thirteen gate scripts across the four felhom repos found one clean correlation:
|
|
**every check a CLAUDE.md tells a person to run was passing, and two of the four nobody is told
|
|
to run were failing** — one since 14 July. Neither failure was harmful in effect, which was
|
|
checked line by line; nothing would have said so if they had been. The fix is not more gates, it
|
|
is one place to run them from. `app-catalog-felhom.eu/scripts/catalog_gates.py` is the canonical
|
|
shape (R-161) and this copies it deliberately rather than inventing a second one.
|
|
|
|
`site_gates.py` is a GATE — eight assertions in one file — and is NOT the model for this file. A
|
|
runner that invokes separate gates is the shape that survives; copying site_gates would just add
|
|
a ninth monolith.
|
|
|
|
FAIL-CLOSED. A gate script that is missing is a FAILURE, never a skip, and the exact path tried
|
|
is printed. A runner that quietly drops a gate is the inert-seam failure this project has shipped
|
|
four times.
|
|
|
|
EXIT CODES. Each gate returns 0 clean / 1 convicted / 2 inconclusive. This runner exits non-zero
|
|
if any gate is non-zero, and reports 2 distinctly as INCONCLUSIVE — an undetermined result is
|
|
never a pass, but it is not a conviction either, and the operator needs to know which they have.
|
|
"""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
SCRIPTS = os.path.join(ROOT, "scripts")
|
|
|
|
# (label, absolute script path, args, fast, exemptible)
|
|
#
|
|
# `exemptible` — R-404, 2026-09-01. TRUE means: on a DOCUMENTS-ONLY push this gate's CONVICTION is
|
|
# reported as ADVISORY and does not refuse the push. It is TRUE for exactly ONE gate and the list is
|
|
# meant to stay that length.
|
|
#
|
|
# WHY ONLY golden-currency. It is the only gate here that does not examine the push at all: it
|
|
# compares the controller's newest CHANGELOG heading against this repo's bake evidence and returns
|
|
# the same verdict whatever you are pushing. Every other gate convicts on something IN the change,
|
|
# so a documents push that trips one has a broken document and must be refused.
|
|
#
|
|
# The gate's own verdict is UNCHANGED — it still runs on every push and still says the same thing.
|
|
# What changed is who is refused. See scripts/push_scope.py and OPEN-ITEMS.md R-404.
|
|
GATES = [
|
|
("site", os.path.join(SCRIPTS, "site_gates.py"), [], True, False),
|
|
("hostinstall", os.path.join(SCRIPTS, "hostinstall_gates.py"), [], True, False),
|
|
("hub-confirm", os.path.join(SCRIPTS, "hub_confirm_gate.py"), [], True, False),
|
|
("manifest-bearer", os.path.join(SCRIPTS, "manifest_bearer_gate.py"), [], True, False),
|
|
("reuse-refs", os.path.join(SCRIPTS, "reuse_refs_check.py"), [ROOT], True, False),
|
|
("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True, False),
|
|
("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True, True),
|
|
("wire-contract", os.path.join(SCRIPTS, "wire_contract_gate.py"), [], True, False),
|
|
# 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, False),
|
|
# R-596/R-598 — the English guide QUOTES three dashboard messages that live in the controller's
|
|
# bundle, in another repo. Nothing bound them, so the guide quoted Hungarian for as long as the
|
|
# Hungarian shipped, and would have quoted it after the fix too. INCONCLUSIVE (exit 2, reported
|
|
# not swallowed) when the controller clone is absent. Fast: two file reads.
|
|
("guide-quote", os.path.join(SCRIPTS, "guide_quote_gate.py"), [], True, False),
|
|
# 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, False),
|
|
# R-369 — two files held open work and only one called itself the source of truth, so a READY
|
|
# finding sat in ROADMAP.md for 25 days invisible to every "grep the register" rule and was
|
|
# rediscovered by an overnight drill. Fast: two file reads.
|
|
("one-register", os.path.join(SCRIPTS, "one_register_gate.py"), [], True, False),
|
|
# R-405 — the 2026-08-22 compression sweep moved R-87 into CLOSED-ITEMS.md while its own state
|
|
# cell read READY; R-378 caught six siblings in the same session and missed this one, so it sat
|
|
# in the wrong file for nine days while the ranking paragraph pointed at nothing. Fast: two
|
|
# file reads.
|
|
("closed-register", os.path.join(SCRIPTS, "closed_register_gate.py"), [], True, False),
|
|
# R-389 — a live finding lived in a REPORT.md observations paragraph and nowhere else, and
|
|
# REPORT.md is overwritten every session. Fast: stdlib file reads.
|
|
("observations", os.path.join(SCRIPTS, "observations_gate.py"), [ROOT], True, False),
|
|
# R-627 — on 2026-09-21 an append regex ate two rows' state cells, duplicated two others and
|
|
# split the table with blank lines; the register then reported 317 rows for 315 findings and
|
|
# nothing noticed until a person read the file. R-254 had been missing its state cell since
|
|
# 2026-08-08 — 45 days — for the same reason. Fast: one file read.
|
|
("register-shape", os.path.join(SCRIPTS, "register_shape_gate.py"), [], True, False),
|
|
# R-421 — every registered gate across all four repos ships with a decoy test, or is
|
|
# named in the exemption list with its row. Registered LAST, after every runner was green:
|
|
# a failing gate refuses every push, which is what instructions_gate learned the hard way.
|
|
("decoy-coverage", os.path.join(SCRIPTS, "decoy_coverage_gate.py"),
|
|
[ROOT, os.path.join(os.path.dirname(ROOT), "felhom-controller"),
|
|
os.path.join(os.path.dirname(ROOT), "felhom-agent"),
|
|
os.path.join(os.path.dirname(ROOT), "app-catalog-felhom.eu")], True, False),
|
|
]
|
|
|
|
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
|
|
ADVISORY = "ADVISORY" # R-404: a conviction that is reported, loudly, and does not refuse
|
|
|
|
|
|
def hooks_armed_note(root):
|
|
"""Print a WARNING (never a failure) when this clone's pre-push hook is not switched on.
|
|
|
|
core.hooksPath is local config and a clone does not carry it, so an unarmed clone is silent
|
|
by construction — this is the only place it becomes visible.
|
|
"""
|
|
try:
|
|
val = subprocess.check_output(["git", "config", "--get", "core.hooksPath"],
|
|
cwd=root, stderr=subprocess.DEVNULL).decode().strip()
|
|
except Exception:
|
|
val = ""
|
|
norm = val.replace("\\", "/").rstrip("/")
|
|
if norm == ".githooks" or norm.endswith("/.githooks"):
|
|
return
|
|
print("WARNING: this clone is UNARMED — core.hooksPath is %s, so the pre-push hook will not\n"
|
|
" run here. Switch it on once with: git config core.hooksPath .githooks"
|
|
% (("'" + val + "'") if val else "unset"))
|
|
|
|
|
|
def run_gate(label, path, args):
|
|
if not os.path.exists(path):
|
|
print("\nFAIL: gate '%s' is MISSING — tried %s" % (label, path))
|
|
print(" A missing gate is a failure, never a skip (fail-closed).")
|
|
return 1, ""
|
|
print("\n" + "=" * 78)
|
|
print("== gate: %s (%s%s)" % (label, os.path.basename(path),
|
|
(" " + " ".join(args)) if args else ""))
|
|
print("=" * 78, flush=True)
|
|
# Stream the gate's own output rather than capturing it — its diagnostics are the point, and a
|
|
# runner that swallows them makes a conviction unreadable. It is ALSO collected, so the advisory
|
|
# block below can quote the gate's own words instead of re-deriving the version itself. Printing
|
|
# happens line by line as it arrives, so this is a tee and not a capture.
|
|
proc = subprocess.Popen([sys.executable, path] + args, cwd=ROOT,
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
lines = []
|
|
for raw in iter(proc.stdout.readline, b""):
|
|
line = raw.decode("utf-8", "replace").rstrip("\n")
|
|
lines.append(line)
|
|
print(line, flush=True)
|
|
proc.stdout.close()
|
|
return proc.wait(), "\n".join(lines)
|
|
|
|
|
|
def main(argv):
|
|
fast = "--fast" in argv
|
|
scope = "code"
|
|
rest = []
|
|
for a in argv:
|
|
if a == "--fast":
|
|
continue
|
|
if a.startswith("--scope="):
|
|
scope = a.split("=", 1)[1].strip()
|
|
continue
|
|
rest.append(a)
|
|
if rest:
|
|
print("unknown argument(s): %s" % " ".join(rest))
|
|
print("usage: python3 scripts/repo_gates.py [--fast] [--scope=code|docs]")
|
|
return 2
|
|
if scope not in ("code", "docs"):
|
|
# Fail closed: an unrecognised scope is never quietly treated as 'docs'.
|
|
print("unrecognised --scope=%s — expected 'code' or 'docs'." % scope)
|
|
print("An unrecognised scope is REFUSED rather than assumed, because the only assumption")
|
|
print("that could be wrong in a dangerous direction is 'docs'.")
|
|
return 2
|
|
|
|
selected = [g for g in GATES if g[3] or not fast]
|
|
skipped = [g[0] for g in GATES if not (g[3] or not fast)]
|
|
print("repo_gates (felhom.eu) — %d gate(s)%s%s"
|
|
% (len(selected), " [--fast]" if fast else "",
|
|
" [scope=docs]" if scope == "docs" else ""))
|
|
if skipped:
|
|
print(" --fast SKIPPED (deliberate periodic runs, never in a hook): %s" % ", ".join(skipped))
|
|
hooks_armed_note(ROOT)
|
|
|
|
results = []
|
|
for label, path, args, _f, exemptible in selected:
|
|
rc, text = run_gate(label, path, args)
|
|
results.append((label, rc, exemptible, text))
|
|
|
|
print("\n" + "=" * 78)
|
|
print("== summary")
|
|
print("=" * 78)
|
|
worst = 0
|
|
advisories = []
|
|
for label, rc, exemptible, text in results:
|
|
# An ADVISORY is a CONVICTION (rc == 1) on a documents-only push, for a gate registered as
|
|
# exemptible. INCONCLUSIVE (rc == 2) is deliberately NOT exemptible: it was never a
|
|
# conviction, and treating "we could not tell" as "we forgive it" is a different decision
|
|
# that nobody made.
|
|
advisory = (scope == "docs" and rc == 1 and exemptible)
|
|
if advisory:
|
|
advisories.append((label, text))
|
|
print(" %-18s %-13s (exit %d)" % (label, ADVISORY, rc))
|
|
continue
|
|
print(" %-18s %-13s (exit %d)" % (label, VERDICT.get(rc, "ERROR"), rc))
|
|
if rc != 0:
|
|
worst = 1 if rc == 1 or worst == 1 else 2
|
|
|
|
if advisories:
|
|
_print_advisory_block(advisories)
|
|
|
|
if worst == 0:
|
|
print("\nall felhom.eu gates OK" + (" (with %d advisory — see above)" % len(advisories)
|
|
if advisories else ""))
|
|
return 0
|
|
convicted = [l for l, rc, _e, _t in results if rc == 1 and not (scope == "docs" and _e)]
|
|
undecided = [l for l, rc, _e, _t in results if rc not in (0, 1)]
|
|
if convicted:
|
|
print("\nCONVICTED: %s" % ", ".join(convicted))
|
|
if undecided:
|
|
print("UNDETERMINED (never a pass): %s" % ", ".join(undecided))
|
|
return worst
|
|
|
|
|
|
def _print_advisory_block(advisories):
|
|
"""Its own block, after the table, because a line inside a table is easy to miss.
|
|
|
|
R-404's whole premise is that the warning must NOT go quiet — only its consequence changes. If
|
|
this block ever stops printing, the change has become a silencing and Scenario A has failed.
|
|
"""
|
|
print("\n" + "!" * 78)
|
|
for label, text in advisories:
|
|
print("!! ADVISORY — %s convicted, and this push is NOT refused for it." % label)
|
|
# Quote the gate's own numbers rather than re-deriving them; a second implementation of
|
|
# "which version owes a golden" is a second thing that can be wrong.
|
|
for line in text.splitlines():
|
|
t = line.strip()
|
|
if t.startswith("newest released controller") or t.startswith("newest golden baked"):
|
|
print("!! %s" % t)
|
|
print("!!")
|
|
print("!! This push touches DOCUMENTS ONLY, so it can neither create this debt nor clear")
|
|
print("!! it — and the push that DOES clear it (a bake record under documentation/tests/)")
|
|
print("!! is itself documents-only. Blocking here blocked the cure.")
|
|
print("!!")
|
|
print("!! WHAT CLEARS IT: bake a golden per documentation/runbooks/RUNBOOK-manual-build.md")
|
|
print("!! section 4.1, then vouch it (a THREE-field change: golden_version + agent_version")
|
|
print("!! + min_agent). The debt stays visible in STATUS.md and in the controller repo's")
|
|
print("!! own golden-notice until then.")
|
|
print("!" * 78)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|