R-404: block the push that can create the golden debt, notify the one that cannot

push_scope.py classifies a push as code or documents from an ALLOW-LIST of document paths -
everything else, including any new top-level directory, is code. Every uncertainty (first push,
force-push, merge commit, empty range, unreadable stdin) answers code: guessing 'documents' would
hand out the exemption by accident.

repo_gates.py gains a fifth GATES field and --scope=code|docs. On a documents-only push a
golden-currency CONVICTION prints as ADVISORY in its own block and does not refuse; every other
gate still refuses every push, and golden-currency still refuses a push touching code. The gate
itself is UNCHANGED - its verdict, exit codes and wording are byte-identical. What changed is who
is refused.

Measured on git 2.47.3: a pre-push hook receives <local ref> <local sha> <remote ref> <remote sha>
on stdin, one line per ref; a first push carries an all-zero remote sha and a deletion an all-zero
local sha. Both land on code.
This commit is contained in:
2026-09-01 11:53:18 +02:00
parent a91c0580eb
commit 1c00af607c
6 changed files with 773 additions and 31 deletions
+67 -1
View File
@@ -92,11 +92,77 @@ jobs:
git checkout -q FETCH_HEAD git checkout -q FETCH_HEAD
echo "controller CHANGELOG at $(git rev-parse --short=12 HEAD): $(head -1 CHANGELOG.md)" echo "controller CHANGELOG at $(git rev-parse --short=12 HEAD): $(head -1 CHANGELOG.md)"
- name: Classify the push - code or documents (R-404)
# ONE RULE, NOT TWO. The pre-push hook exempts a golden-currency CONVICTION on a
# documents-only push; if CI did not do the same, a drill night would still produce red CI
# runs indistinguishable from real ones, which is R-417 exactly and is half the reason this
# change exists.
#
# CI CANNOT USE A COMMIT RANGE. The checkout above is `--depth 1` of a single SHA, so there
# is no history here to diff against — `git diff before..after` would fail, and deepening
# the fetch to make it work would slow every run to solve a problem the push event has
# already answered. So the file list comes from the push event payload instead, and is fed
# to the SAME classifier the hook uses (`--files-from`), so there is one implementation of
# "what counts as a document" and not two.
#
# FAIL CLOSED, EVERY PATH. No payload, no `commits` array, an empty array, unreadable JSON,
# a missing classifier — all write `code`, which is exactly today's behaviour. This step can
# therefore only ever make CI as strict as it is now, never looser. That is also why it is
# safe to ship before it has been observed on a real push: the untested direction is the
# safe one.
run: |
set -u
python3 - > /tmp/pushed-files.txt <<'PY' || : > /tmp/pushed-files.txt
import json, os, sys
path = os.environ.get("GITHUB_EVENT_PATH", "")
if not path or not os.path.isfile(path):
sys.stderr.write("no GITHUB_EVENT_PATH - the file list is unknown\n")
raise SystemExit(0)
try:
ev = json.load(open(path))
except Exception as e:
sys.stderr.write("event payload unreadable: %s\n" % e)
raise SystemExit(0)
commits = ev.get("commits") or []
if not commits:
sys.stderr.write("the payload carries no commits array - unknown\n")
raise SystemExit(0)
seen = []
for c in commits:
for key in ("added", "modified", "removed"):
for f in (c.get(key) or []):
if f not in seen:
seen.append(f)
sys.stderr.write("%d commit(s), %d distinct path(s) in the payload\n"
% (len(commits), len(seen)))
for f in seen:
print(f)
PY
echo "--- paths the push event reported ---"
cat /tmp/pushed-files.txt
echo "-------------------------------------"
if [ -s /tmp/pushed-files.txt ] && [ -f scripts/push_scope.py ]; then
SCOPE=$(python3 scripts/push_scope.py --files-from /tmp/pushed-files.txt) || SCOPE=code
else
echo "no usable file list - treating this push as CODE (fail-closed)"
SCOPE=code
fi
[ "$SCOPE" = "docs" ] || SCOPE=code
echo "PUSH_SCOPE=$SCOPE" >> "$GITHUB_ENV"
echo "scope: $SCOPE"
- name: Run the gate entry point - name: Run the gate entry point
# The ONLY thing CI runs. No go build, no go test, no linting, no deploy — those are either # The ONLY thing CI runs. No go build, no go test, no linting, no deploy — those are either
# already reliably run by a person or none of CI's business. The exit code IS the result: # already reliably run by a person or none of CI's business. The exit code IS the result:
# no `|| true`, no pipe that could swallow it. # no `|| true`, no pipe that could swallow it.
run: python3 scripts/repo_gates.py --fast #
# A documents-only run that convicts ONLY on golden-currency prints the advisory and stays
# green. THE DEBT IS NOT HIDDEN WHEN THAT HAPPENS — three things still carry it: the
# advisory block in this run's own log, `STATUS.md`, and the controller repo's golden-notice,
# which prints at the moment a release is committed, where someone can actually act on it.
# Those are the compensating controls that make this green honest. Every other gate still
# fails this job on any push, and golden-currency still fails it on a push touching code.
run: python3 scripts/repo_gates.py --fast --scope="${PUSH_SCOPE:-code}"
- name: Alarm on failure - name: Alarm on failure
# THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO # THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO
+48 -2
View File
@@ -16,11 +16,35 @@
# * skippable — `git push --no-verify` bypasses this entirely. That is on purpose: an escape # * skippable — `git push --no-verify` bypasses this entirely. That is on purpose: an escape
# hatch that cannot be reached is one that gets removed the first time it is # hatch that cannot be reached is one that gets removed the first time it is
# inconvenient. USING IT MUST BE STATED IN THE SESSION REPORT. # inconvenient. USING IT MUST BE STATED IN THE SESSION REPORT.
# * ONE GATE IS ADVISORY ON A DOCUMENTS-ONLY PUSH (R-404, 2026-09-01) — golden-currency, and
# only it. On a push whose whole range touches documents, the register, STATUS,
# reports or drill evidence, a golden-currency CONVICTION is printed loudly as
# ADVISORY and does not refuse the push. Every other gate still refuses every
# push, and golden-currency still refuses a push that touches code.
# WHY: the gate never looks at the push — it compares the controller's newest
# CHANGELOG heading against this repo's bake evidence, so it returns the same
# verdict whatever you are pushing. The controller's code is in one repo and its
# register lives here, so EVERY controller change produces a documents-only push
# here; and the push that PAYS the debt (a bake record under documentation/tests/)
# is itself documents-only, so blocking here blocked the cure. `--no-verify` had
# been used thirteen times, each with a recorded reason. This removes the reason,
# not the hatch.
# The scope is decided by scripts/push_scope.py, which fails closed to `code`.
# The half that is neither per-clone nor skippable is CI — felhom.eu OPEN-ITEMS.md R-168. # The half that is neither per-clone nor skippable is CI — felhom.eu OPEN-ITEMS.md R-168.
# #
# Measured 2026-08-02 (git 2.47.3): a relative core.hooksPath resolves correctly and the hook's cwd # Measured 2026-08-02 (git 2.47.3): a relative core.hooksPath resolves correctly and the hook's cwd
# is the repo root whether `git push` is issued from the root or from any subdirectory. The # is the repo root whether `git push` is issued from the root or from any subdirectory. The
# explicit rev-parse below does not depend on that. # explicit rev-parse below does not depend on that.
#
# Measured 2026-09-01 (git 2.47.3, throwaway local remote, probe removed): git hands this hook its
# ref updates on STDIN as `<local ref> <local sha> <remote ref> <remote sha>`, one line per ref,
# four whitespace-separated fields. Observed directly:
# ordinary push refs/heads/master <new> refs/heads/master <old>
# FIRST push refs/heads/master <new> refs/heads/master 0000000000000000000000000000000000000000
# two refs two lines, one per ref
# deletion (delete) 0000000000000000000000000000000000000000 refs/heads/side <old>
# The all-zero cases are exactly why the classifier fails closed: a first push has no range to diff
# and a deletion has no content, so neither can be exempted.
set -u set -u
root=$(git rev-parse --show-toplevel 2>/dev/null) || { root=$(git rev-parse --show-toplevel 2>/dev/null) || {
@@ -70,8 +94,30 @@ if ! command -v python3 >/dev/null 2>&1; then
exit 1 exit 1
fi fi
echo "pre-push [felhom.eu]: running scripts/repo_gates.py --fast ..." # ── SCOPE (R-404) ────────────────────────────────────────────────────────────────────────────────
python3 "scripts/repo_gates.py" --fast # Read git's ref updates from stdin and ask the classifier what kind of push this is. EVERY failure
# path here answers `code`, which is today's behaviour — this can make the hook stricter than
# intended, never looser. The classifier prints its reasoning on stderr, so a surprising verdict is
# arguable rather than mysterious.
#
# STDIN IS CONSUMED EXACTLY ONCE, here, into a variable. A second reader would get nothing and the
# classifier would answer `code` for a reason that has nothing to do with the push.
refs=$(cat)
scope=code
if [ ! -f "scripts/push_scope.py" ]; then
echo "pre-push [felhom.eu]: scripts/push_scope.py is ABSENT - treating this push as CODE." >&2
else
# stdout is the verdict word; the classifier's reasoning goes to stderr and is left visible on
# purpose, so a surprising verdict can be argued with instead of guessed at.
scope=$(printf '%s\n' "$refs" | python3 "scripts/push_scope.py" --prepush-stdin) || scope=code
fi
# Anything that is not exactly "docs" takes the strict path. This is the fail-closed hinge: an empty
# variable, a crashed classifier, a typo and an unexpected word all land on `code`.
[ "$scope" = "docs" ] || scope=code
echo "pre-push [felhom.eu]: running scripts/repo_gates.py --fast --scope=$scope ..."
python3 "scripts/repo_gates.py" --fast --scope="$scope"
rc=$? rc=$?
if [ "$rc" -ne 0 ]; then if [ "$rc" -ne 0 ]; then
echo "pre-push [felhom.eu]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2 echo "pre-push [felhom.eu]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""push_scope.py — is this push CODE or DOCUMENTS? (R-404, R-417)
python3 scripts/push_scope.py --range <A>..<B> # what the pre-push hook uses
python3 scripts/push_scope.py --files-from - # a newline path list on stdin (CI)
python3 scripts/push_scope.py --range HEAD~5..HEAD --explain
The verdict word — `code` or `docs` — goes to STDOUT and nothing else does, so a caller can write
`scope=$(python3 scripts/push_scope.py --range "$r")`. The REASONING goes to stderr, always, because
a classifier that prints only a verdict is the kind nobody can argue with at two in the morning.
WHY THIS EXISTS. `golden_currency_gate.py` never looks at the push: it compares the controller's
newest CHANGELOG heading against the bake evidence in this repo and returns the same verdict whatever
you are pushing. That is correct for a standing invariant and wrong as a push gate, because the
controller's code lives in one repo and its register, architecture and status live in this one — so
**every controller change produces a documents-only push here by construction**, and drills and
spikes add more. Measured 2026-08-31: `--no-verify` had been used eight times, each with a recorded
reason. Five more followed on the night of 2026-09-01 (R-417). A guard correctly bypassed thirteen
times has taught everyone to bypass it.
The ruling (R-404) is NOT to narrow the gate — its verdict is true and must stay loud. It is to
change **who is refused**: block the push that can create the debt, notify the push that cannot.
This file answers only the question *which kind of push is this?*
⚠ ALLOW-LIST, NEVER A DENY-LIST — and the red-proof P3 pins it.
A deny-list of code paths says "documents" for anything it has not heard of, so the first new
top-level directory silently inherits the exemption. This lists what is a document and calls
EVERYTHING else code. A new directory is therefore code until someone deliberately adds it here.
⚠ FAIL CLOSED. Every uncertainty answers `code`: an unreadable range, a first push with an all-zero
remote sha, a force-push, a merge commit, an empty range, a git error, absent stdin. A classifier
that guesses "documents" when it does not know hands out the exemption by accident, which is the
one outcome worse than the status quo.
WHY `scripts/` IS CODE. The gates are code — including this file and the change that introduced it.
This task's own push is therefore blocked-eligible, which is the intended shape: a session that
changes the gates does not get to exempt itself.
"""
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ZERO = "0" * 40
# ── THE ALLOW-LIST ───────────────────────────────────────────────────────────────────────────────
# Directory prefixes whose whole subtree is documents.
DOC_PREFIXES = (
"documentation/", # architecture, runbooks, audits, backlog, operations, tests (bake evidence)
".claude/", # path-scoped rule files — instructions, not product
)
# Exact basenames that are documents wherever they sit. These are instruction and record files by
# this project's own convention; none of them is read by any running program.
DOC_BASENAMES = frozenset((
"CLAUDE.md", # instructions
"REUSE.md", # the reuse map
"STATUS.md", # the operator view
"CONTEXT.md", # current state
"MEMORY.md", # the memory index
))
# Basename prefixes, for the session reports (REPORT.md, REPORT-<topic>.md).
DOC_BASENAME_PREFIXES = ("REPORT",)
def classify_path(path):
"""(is_doc, reason). The reason is printed for code paths — that is the debuggable half."""
p = path.replace("\\", "/")
# NOT lstrip("./") — lstrip takes a SET of characters, so it eats the leading dot of
# ".claude/rules/hub.md" and the whole rules tree silently becomes code. Caught by P1.
while p.startswith("./"):
p = p[2:]
for pref in DOC_PREFIXES:
if p.startswith(pref):
return True, "under %s" % pref
base = p.rsplit("/", 1)[-1]
if base in DOC_BASENAMES:
return True, "%s is an instruction/record file" % base
for pref in DOC_BASENAME_PREFIXES:
if base.startswith(pref) and base.endswith(".md"):
return True, "%s is a session report" % base
return False, "not on the document allow-list"
def classify_files(paths):
"""(scope, doc_paths, code_paths). Any single code path makes the whole push code."""
docs, code = [], []
for p in paths:
if not p:
continue
is_doc, _why = classify_path(p)
(docs if is_doc else code).append(p)
return ("code" if code else "docs"), docs, code
def _git(args):
return subprocess.check_output(["git"] + args, cwd=ROOT,
stderr=subprocess.STDOUT).decode("utf-8", "replace")
def files_in_range(rng, log):
"""Paths changed in `rng`, or None when the range cannot be trusted. None means CODE."""
if ".." not in rng:
log("range %r has no '..' — cannot be trusted" % rng)
return None
old, new = rng.split("..", 1)
old, new = old.strip(), new.strip()
if not old or not new:
log("range %r is missing an end" % rng)
return None
if set(old) == {"0"}:
log("the remote sha is all zeros — this is a FIRST PUSH of this ref, so there is no "
"range to diff and nothing can be exempted")
return None
if set(new) == {"0"}:
log("the local sha is all zeros — this is a ref DELETION")
return None
try:
_git(["cat-file", "-e", old + "^{commit}"])
_git(["cat-file", "-e", new + "^{commit}"])
except Exception as e:
log("one end of the range is not an object in this clone (%s)" % _short(e))
return None
# A force-push rewrites history, so the range is not what will land.
try:
subprocess.check_call(["git", "merge-base", "--is-ancestor", old, new],
cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
log("the remote sha is NOT an ancestor of the local sha — a force-push or a rewritten "
"history; the range does not describe what will land")
return None
try:
revs = _git(["rev-list", "%s..%s" % (old, new)]).split()
except Exception as e:
log("git rev-list failed (%s)" % _short(e))
return None
if not revs:
log("the range is EMPTY — nothing to classify")
return None
for rev in revs:
try:
parents = _git(["rev-list", "--parents", "-n", "1", rev]).split()[1:]
except Exception as e:
log("cannot read parents of %s (%s)" % (rev[:8], _short(e)))
return None
if len(parents) > 1:
log("%s is a MERGE commit — a merge's diff depends on which parent you pick, so the "
"range is not a reliable description of what changed" % rev[:8])
return None
try:
out = _git(["diff", "--name-only", "%s..%s" % (old, new)])
except Exception as e:
log("git diff failed (%s)" % _short(e))
return None
return [l for l in out.splitlines() if l.strip()]
def _short(e):
s = str(e)
return s[:120].replace("\n", " ")
def ranges_from_prepush_stdin(text, log):
"""Git hands a pre-push hook lines of `<local ref> <local sha> <remote ref> <remote sha>`.
MEASURED against git 2.47.3 on DooPlex 2026-09-01 — see the hook header. A line whose local sha
is all zeros is a deletion; a line whose remote sha is all zeros is a new ref.
"""
out = []
for raw in text.splitlines():
parts = raw.split()
if len(parts) != 4:
if raw.strip():
log("unparseable pre-push stdin line %r (expected 4 fields, got %d)"
% (raw[:80], len(parts)))
return None
continue
_lref, lsha, _rref, rsha = parts
out.append("%s..%s" % (rsha, lsha))
if not out:
log("pre-push stdin carried no ref updates")
return None
return out
def main(argv):
explain = "--explain" in argv
argv = [a for a in argv if a != "--explain"]
notes = []
def log(msg):
notes.append(msg)
paths = None
if "--files-from" in argv:
i = argv.index("--files-from")
src = argv[i + 1] if len(argv) > i + 1 else "-"
try:
text = sys.stdin.read() if src == "-" else open(src).read()
paths = [l.strip() for l in text.splitlines() if l.strip()]
if not paths:
log("the file list was EMPTY")
paths = None
except Exception as e:
log("cannot read the file list (%s)" % _short(e))
paths = None
elif "--range" in argv:
i = argv.index("--range")
if len(argv) > i + 1:
paths = files_in_range(argv[i + 1], log)
else:
log("--range given with no value")
elif "--prepush-stdin" in argv:
try:
text = sys.stdin.read()
except Exception as e:
log("cannot read stdin (%s)" % _short(e))
text = ""
rngs = ranges_from_prepush_stdin(text, log)
if rngs is None:
paths = None
else:
paths = []
for r in rngs:
got = files_in_range(r, log)
if got is None:
paths = None
break
paths.extend(got)
else:
sys.stderr.write(__doc__.split("WHY THIS EXISTS")[0])
sys.stdout.write("code\n")
return 0
if paths is None:
sys.stderr.write("push_scope: CODE (fail-closed)\n")
for n in notes:
sys.stderr.write(" reason: %s\n" % n)
sys.stderr.write(" A push whose contents cannot be established is treated as CODE. "
"Guessing 'documents' would hand out the exemption by accident.\n")
sys.stdout.write("code\n")
return 0
scope, docs, code = classify_files(paths)
sys.stderr.write("push_scope: %s (%d file(s): %d document, %d code)\n"
% (scope.upper(), len(paths), len(docs), len(code)))
for n in notes:
sys.stderr.write(" note: %s\n" % n)
if code:
sys.stderr.write(" CODE because these are not on the document allow-list:\n")
for p in code[:20]:
sys.stderr.write(" %s\n" % p)
if len(code) > 20:
sys.stderr.write(" ... and %d more\n" % (len(code) - 20))
if explain and docs:
sys.stderr.write(" documents:\n")
for p in docs[:40]:
sys.stderr.write(" %s (%s)\n" % (p, classify_path(p)[1]))
sys.stdout.write(scope + "\n")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+112 -28
View File
@@ -87,36 +87,49 @@ import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS = os.path.join(ROOT, "scripts") SCRIPTS = os.path.join(ROOT, "scripts")
# (label, absolute script path, args, fast) # (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 = [ GATES = [
("site", os.path.join(SCRIPTS, "site_gates.py"), [], True), ("site", os.path.join(SCRIPTS, "site_gates.py"), [], True, False),
("hostinstall", os.path.join(SCRIPTS, "hostinstall_gates.py"), [], True), ("hostinstall", os.path.join(SCRIPTS, "hostinstall_gates.py"), [], True, False),
("hub-confirm", os.path.join(SCRIPTS, "hub_confirm_gate.py"), [], True), ("hub-confirm", os.path.join(SCRIPTS, "hub_confirm_gate.py"), [], True, False),
("manifest-bearer", os.path.join(SCRIPTS, "manifest_bearer_gate.py"), [], True), ("manifest-bearer", os.path.join(SCRIPTS, "manifest_bearer_gate.py"), [], True, False),
("reuse-refs", os.path.join(SCRIPTS, "reuse_refs_check.py"), [ROOT], True), ("reuse-refs", os.path.join(SCRIPTS, "reuse_refs_check.py"), [ROOT], True, False),
("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True), ("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True, False),
("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True), ("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True, True),
("wire-contract", os.path.join(SCRIPTS, "wire_contract_gate.py"), [], 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 # 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. # 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), ("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True, False),
# R-341 — dated checks in the register were prose that nothing read. Fast: stdlib file read. # 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), ("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 # 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 # 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. # rediscovered by an overnight drill. Fast: two file reads.
("one-register", os.path.join(SCRIPTS, "one_register_gate.py"), [], True), ("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 # 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 # 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 # in the wrong file for nine days while the ranking paragraph pointed at nothing. Fast: two
# file reads. # file reads.
("closed-register", os.path.join(SCRIPTS, "closed_register_gate.py"), [], True), ("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 # 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. # REPORT.md is overwritten every session. Fast: stdlib file reads.
("observations", os.path.join(SCRIPTS, "observations_gate.py"), [ROOT], True), ("observations", os.path.join(SCRIPTS, "observations_gate.py"), [ROOT], True, False),
] ]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} 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): def hooks_armed_note(root):
@@ -142,46 +155,90 @@ def run_gate(label, path, args):
if not os.path.exists(path): if not os.path.exists(path):
print("\nFAIL: gate '%s' is MISSING — tried %s" % (label, path)) print("\nFAIL: gate '%s' is MISSING — tried %s" % (label, path))
print(" A missing gate is a failure, never a skip (fail-closed).") print(" A missing gate is a failure, never a skip (fail-closed).")
return 1 return 1, ""
print("\n" + "=" * 78) print("\n" + "=" * 78)
print("== gate: %s (%s%s)" % (label, os.path.basename(path), print("== gate: %s (%s%s)" % (label, os.path.basename(path),
(" " + " ".join(args)) if args else "")) (" " + " ".join(args)) if args else ""))
print("=" * 78, flush=True) print("=" * 78, flush=True)
# stream the gate's own output rather than capturing it — its diagnostics are the point, # Stream the gate's own output rather than capturing it — its diagnostics are the point, and a
# and a runner that swallows them makes a conviction unreadable. # runner that swallows them makes a conviction unreadable. It is ALSO collected, so the advisory
return subprocess.call([sys.executable, path] + args, cwd=ROOT) # 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): def main(argv):
fast = "--fast" in argv fast = "--fast" in argv
unknown = [a for a in argv if a != "--fast"] scope = "code"
if unknown: rest = []
print("unknown argument(s): %s" % " ".join(unknown)) for a in argv:
print("usage: python3 scripts/repo_gates.py [--fast]") 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 return 2
selected = [g for g in GATES if g[3] or not fast] 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)] skipped = [g[0] for g in GATES if not (g[3] or not fast)]
print("repo_gates (felhom.eu) — %d gate(s)%s" % (len(selected), " [--fast]" if fast else "")) print("repo_gates (felhom.eu) — %d gate(s)%s%s"
% (len(selected), " [--fast]" if fast else "",
" [scope=docs]" if scope == "docs" else ""))
if skipped: if skipped:
print(" --fast SKIPPED (deliberate periodic runs, never in a hook): %s" % ", ".join(skipped)) print(" --fast SKIPPED (deliberate periodic runs, never in a hook): %s" % ", ".join(skipped))
hooks_armed_note(ROOT) hooks_armed_note(ROOT)
results = [(label, run_gate(label, path, args)) for label, path, args, _f in selected] 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("\n" + "=" * 78)
print("== summary") print("== summary")
print("=" * 78) print("=" * 78)
worst = 0 worst = 0
for label, rc in results: 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)) print(" %-18s %-13s (exit %d)" % (label, VERDICT.get(rc, "ERROR"), rc))
if rc != 0: if rc != 0:
worst = 1 if rc == 1 or worst == 1 else 2 worst = 1 if rc == 1 or worst == 1 else 2
if advisories:
_print_advisory_block(advisories)
if worst == 0: if worst == 0:
print("\nall felhom.eu gates OK") print("\nall felhom.eu gates OK" + (" (with %d advisory — see above)" % len(advisories)
if advisories else ""))
return 0 return 0
convicted = [l for l, rc in results if rc == 1] convicted = [l for l, rc, _e, _t in results if rc == 1 and not (scope == "docs" and _e)]
undecided = [l for l, rc in results if rc not in (0, 1)] undecided = [l for l, rc, _e, _t in results if rc not in (0, 1)]
if convicted: if convicted:
print("\nCONVICTED: %s" % ", ".join(convicted)) print("\nCONVICTED: %s" % ", ".join(convicted))
if undecided: if undecided:
@@ -189,5 +246,32 @@ def main(argv):
return worst 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__": if __name__ == "__main__":
sys.exit(main(sys.argv[1:])) sys.exit(main(sys.argv[1:]))
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""test_push_scope.py — the classifier, and the allow-list property that makes it safe (R-404).
P3 IS THE LOAD-BEARING CASE. A deny-list of code paths answers "documents" for anything it has not
heard of, so the first new top-level directory inherits the golden-currency exemption silently. Its
red-proof is written out below and was run.
Run: python3 scripts/test_push_scope.py Exit 0 all pass · 1 a case failed.
"""
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import push_scope # noqa: E402
DOCS = [
"documentation/backlog/OPEN-ITEMS.md",
"documentation/architecture/07-backup-architecture.md",
"documentation/tests/golden-0.232.0-2026-09-01/bake.log", # the push that CLEARS the debt
"documentation/runbooks/RUNBOOK-manual-build.md",
"STATUS.md", "CONTEXT.md", "CLAUDE.md", "REUSE.md",
"REPORT.md", "REPORT-soak.md",
".claude/rules/hub.md",
]
CODE = [
"hub/internal/api/handler.go",
"website/index.html",
"manifests/hub.yaml",
"scripts/repo_gates.py", # a gate change is code
"scripts/push_scope.py", # including this task's own
"scripts/felhom-host-install.sh",
".gitea/workflows/gates.yml",
"hub/CHANGELOG.md", # a CHANGELOG under a code tree is part of that tree
]
def main():
fails = []
# --- P1: every document path classifies as a document ------------------------------------
for p in DOCS:
is_doc, why = push_scope.classify_path(p)
if not is_doc:
fails.append("P1: %s should be a DOCUMENT (%s)" % (p, why))
if not [f for f in fails if f.startswith("P1")]:
print("P1 ok: %d document paths classify as documents" % len(DOCS))
# --- P2: every code path classifies as code ------------------------------------------------
for p in CODE:
is_doc, why = push_scope.classify_path(p)
if is_doc:
fails.append("P2: %s should be CODE (%s)" % (p, why))
if not [f for f in fails if f.startswith("P2")]:
print("P2 ok: %d code paths classify as code" % len(CODE))
# --- P3: a NEW, UNKNOWN top-level path is CODE <- the allow-list property ----------------
# RED-PROOF (run 2026-09-01, recorded in REPORT.md): replacing classify_path's allow-list with a
# deny-list — `return (not p.startswith(("hub/","website/","manifests/","scripts/"))), "..."` —
# makes every one of these classify as a DOCUMENT and P3 fails naming them.
unknown = ["terraform/main.tf", "cmd/newthing/main.go", "Makefile",
"docs/readme.md", "documentation-old/x.md", "src/app.py", ".github/workflows/ci.yml"]
for p in unknown:
is_doc, _ = push_scope.classify_path(p)
if is_doc:
fails.append("P3: %s is NOT on the allow-list and must be CODE. An allow-list that "
"lets an unknown path through is a deny-list wearing a hat." % p)
if not [f for f in fails if f.startswith("P3")]:
print("P3 ok: %d unknown paths default to code (allow-list, not deny-list)" % len(unknown))
# POSITIVE CONTROL for P3: the check can distinguish. If classify_path said "code" to
# everything, P1 would already have failed — but assert it here too so P3 cannot pass vacuously.
if push_scope.classify_path("documentation/x.md")[0] is not True:
fails.append("P3 CONTROL: classify_path says code to everything; P3 proves nothing")
# --- P4: a mixed range is code -------------------------------------------------------------
scope, docs, code = push_scope.classify_files(
["documentation/backlog/OPEN-ITEMS.md", "STATUS.md", "hub/internal/api/handler.go"])
if scope != "code":
fails.append("P4: a range containing ONE code file must be CODE; got %r" % scope)
elif len(docs) != 2 or len(code) != 1:
fails.append("P4: the split is wrong: docs=%r code=%r" % (docs, code))
else:
print("P4 ok: 2 documents + 1 code file -> code")
# --- P5: uncertainty is code ---------------------------------------------------------------
notes = []
cases = {
"no '..'": "abcdef",
"all-zero remote": push_scope.ZERO + "..abcdef",
"all-zero local": "abcdef.." + push_scope.ZERO,
"missing end": "..abcdef",
"unknown objects": "dead" * 10 + ".." + "beef" * 10,
}
for name, rng in cases.items():
got = push_scope.files_in_range(rng, notes.append)
if got is not None:
fails.append("P5 (%s): %r must be untrustworthy (None -> code); got %r" % (name, rng, got))
if not [f for f in fails if f.startswith("P5")]:
print("P5 ok: %d untrustworthy ranges all return None (-> code)" % len(cases))
if not notes:
fails.append("P5: not one reason was logged. A fail-closed verdict with no stated reason "
"is the thing this classifier exists to avoid")
# --- P6: an empty file list is code, not an empty 'docs' -----------------------------------
scope, _, _ = push_scope.classify_files([])
if scope != "docs":
pass # classify_files on [] is 'docs' by construction; the EMPTY-list guard lives in main()
print("P6 note: classify_files([]) == %r; the empty-input guard is in main(), covered by P5" % scope)
if fails:
print()
for f in fails:
print("FAIL: %s" % f)
return 1
print("\npush_scope tests OK — allow-list holds, uncertainty answers code")
return 0
sys.exit(main())
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""test_repo_gates_scope.py — R-404's acceptance test: the exemption is ONE GATE WIDE.
R3 IS THE ONE THAT MATTERS AND IT WAS WRITTEN FIRST. Everything else here is scaffolding around
it. The change this file guards makes `repo_gates.py` let a documents-only push through when
`golden-currency` convicts. The danger is not that the exemption exists — it is that it quietly
becomes general, at which point a documents push stops being checked for anything and nobody finds
out until a broken document ships.
WHY STUB GATES AND NOT THE REAL ONES. The runner's DECISION is the thing under test: given a set of
exit codes and a scope, what does it exit and what does it print? Driving that with the real gates
would make the test depend on the repo being in a particular state — the R-410 lesson, where a gate
that read a directory NAME passed with no bake behind it. Stubs make the input exact.
Run: python3 scripts/test_repo_gates_scope.py
Exit 0 all pass · 1 a case failed.
"""
import io
import os
import shutil
import sys
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import repo_gates # noqa: E402
class Capture(object):
"""Collect stdout while the runner writes to it."""
def __enter__(self):
self._real = sys.stdout
self.buf = io.StringIO()
sys.stdout = self.buf
return self
def __exit__(self, *a):
sys.stdout = self._real
self.text = self.buf.getvalue()
return False
def stub(tmp, name, rc):
"""A gate that does nothing but exit with `rc`, and says so, so output is readable."""
p = os.path.join(tmp, name)
with io.open(p, "w", encoding="utf-8") as fh:
fh.write(u"import sys\nprint('stub %s exiting %d')\nsys.exit(%d)\n" % (name, rc, rc))
return p
def run(gates, argv):
"""Run the runner's main() with GATES swapped for `gates`. Returns (exit code, output)."""
saved = repo_gates.GATES
repo_gates.GATES = gates
try:
with Capture() as cap:
rc = repo_gates.main(argv)
return rc, cap.text
finally:
repo_gates.GATES = saved
def main():
tmp = tempfile.mkdtemp(prefix="gatescope-")
fails = []
try:
ok = stub(tmp, "ok.py", 0)
bad = stub(tmp, "bad.py", 1)
inc = stub(tmp, "inc.py", 2)
# The real table's shape, with the fifth field: exemptible on a documents-only push.
def table(golden_rc, other_rc=0):
return [
("site", {0: ok, 1: bad, 2: inc}[other_rc], [], True, False),
("golden-currency", {0: ok, 1: bad, 2: inc}[golden_rc], [], True, True),
]
# --- R1: docs scope, golden-currency convicting -> ADVISORY, exit 0 -------------------
rc, out = run(table(1), ["--fast", "--scope=docs"])
if rc != 0:
fails.append("R1: a documents-only push with only golden-currency convicting must be "
"ALLOWED; got exit %d" % rc)
elif "ADVISORY" not in out:
fails.append("R1: the word ADVISORY never appeared. A silent pass is a SILENCING, "
"not a re-aim — the whole point is that it still says so:\n%s" % out)
else:
print("R1 ok: docs + golden-currency convicting -> exit 0, ADVISORY printed")
# --- R2: code scope, same conviction -> refused ---------------------------------------
rc, out = run(table(1), ["--fast", "--scope=code"])
if rc == 0:
fails.append("R2: a CODE push with golden-currency convicting must still be REFUSED")
elif "ADVISORY" in out:
fails.append("R2: a code push must not print ADVISORY — it is refused, not advised")
else:
print("R2 ok: code + golden-currency convicting -> exit %d" % rc)
# --- R3: SCENARIO C -- the exemption is ONE GATE WIDE ---------------------------------
# A documents push with a NON-exemptible gate convicting must still be refused.
# RED-PROOF: mark every gate exemptible (change the 5th field of 'site' to True) and this
# fails, which is exactly what a general exemption would look like in production.
rc, out = run(table(0, other_rc=1), ["--fast", "--scope=docs"])
if rc == 0:
fails.append("R3 (SCENARIO C): a documents-only push with the SITE gate convicting was "
"ALLOWED. The exemption has become general — every other gate must still "
"block every push:\n%s" % out)
else:
print("R3 ok: docs + a NON-exemptible gate convicting -> exit %d (refused)" % rc)
# --- R4: inconclusive is not a conviction, in either scope -----------------------------
rc_docs, out_docs = run(table(2), ["--fast", "--scope=docs"])
rc_code, _ = run(table(2), ["--fast", "--scope=code"])
if rc_docs != rc_code:
fails.append("R4: an INCONCLUSIVE golden-currency must behave identically in both "
"scopes (docs=%d code=%d) — the exemption is for CONVICTIONS, and an "
"undetermined result was never a conviction" % (rc_docs, rc_code))
elif rc_docs == 0:
fails.append("R4: INCONCLUSIVE must not pass in either scope; got exit 0")
elif "ADVISORY" in out_docs:
fails.append("R4: an inconclusive gate must not be rendered as ADVISORY")
else:
print("R4 ok: INCONCLUSIVE unchanged in both scopes (exit %d)" % rc_docs)
# --- R5: no --scope behaves exactly like today ----------------------------------------
rc_none, out_none = run(table(1), ["--fast"])
rc_code2, out_code2 = run(table(1), ["--fast", "--scope=code"])
if rc_none != rc_code2:
fails.append("R5: an unscoped run must be IDENTICAL to --scope=code (a human typing "
"the command must see today's behaviour); got %d vs %d" % (rc_none, rc_code2))
else:
# compare the summary lines, ignoring the header that names the scope
a = [l for l in out_none.splitlines() if l.startswith(" ") and "exit" in l]
b = [l for l in out_code2.splitlines() if l.startswith(" ") and "exit" in l]
if a != b:
fails.append("R5: unscoped and --scope=code printed different verdicts:\n%s\n%s"
% (a, b))
else:
print("R5 ok: unscoped == --scope=code, exit %d" % rc_none)
# --- R6: a bad scope value is refused, never silently treated as docs ------------------
rc, out = run(table(1), ["--fast", "--scope=banana"])
if rc == 0:
fails.append("R6: an unrecognised --scope value must NOT pass. Fail closed.")
else:
print("R6 ok: --scope=banana refused (exit %d)" % rc)
finally:
shutil.rmtree(tmp, ignore_errors=True)
if fails:
print()
for f in fails:
print("FAIL: %s" % f)
return 1
print("\nrepo_gates scope tests OK — the exemption is one gate wide (R3 is the acceptance)")
return 0
sys.exit(main())