55274d5ef3
gates / gates (push) Successful in 17s
The gate failed only on `released > baked`, so it could catch a forgotten bake and nothing else. A golden AHEAD of the record passed silently - and that is how controller 0.221.1 was built, baked AND vouched while the newest CHANGELOG heading still read v0.221.0, with every gate green. Reproduced on the real history: newest released 0.221.0 / newest golden baked 0.221.1 -> exit 0. The gate now asks whether the version being shipped is WRITTEN DOWN: the baked version must have its own `## vX.Y.Z` heading anywhere in the CHANGELOG. Membership rather than `baked > released` deliberately - a comparison against the newest heading alone goes green the moment any later entry is written, leaving the unrecorded version permanently unrecorded. INCONCLUSIVE (exit 2) preserved; every refusal names a reason and a route. Red-proofed both directions: old gate/old record exit 0, new gate/old record exit 1, new gate/fixed record exit 0, absent clone exit 2, post-bake exit 0. 08-alarm-ladder.md is new, and its absence was itself the finding: no document owned "when does a broken app raise an alarm?". The rules lived as comments in four packages, each locally correct, with the ordering between them legible only by reading one function top to bottom - which is how R-384 survived review. R-383 and R-384 closed into CLOSED-ITEMS with their rules kept. R-385 filed closed. R-386 filed OPEN: a single-container app stopped out of band raises no alarm, and a comment claims the opposite - measured live, 9 scans, 0 events, against a positive control from the same box 17 minutes earlier. Not fixed here. Golden 0.222.0 baked and published; vouching is the operator's act.
188 lines
10 KiB
Python
188 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Golden-currency gate (R-242) — a controller release is not DELIVERED until a golden carries it.
|
|
|
|
Run from the repo root: python3 scripts/golden_currency_gate.py
|
|
Exit 0 clean · 1 convicted (a released controller has no golden) · 2 inconclusive.
|
|
|
|
WHY THIS EXISTS, and why a rule was not enough.
|
|
|
|
R-242 was filed on 2026-08-07 as a mechanism-less rule: *a controller release that changes
|
|
customer-visible behaviour is not finished until a golden carries it, and nothing enforces that.*
|
|
It was deliberately recorded and not built. **It recurred the next day** — controller v0.206.0 shipped
|
|
the R-241 fixes while the vouched golden still carried 0.205.0, so a machine installed that morning
|
|
would have received neither. That is the second occurrence in two days (the first, R-239, went
|
|
unnoticed until a walk measured it from the customer's side), and it is what a rule without a
|
|
mechanism does.
|
|
|
|
The failure mode is FORGETTING, not lying — nobody ever decided to ship a stale golden. So this gate
|
|
is built to catch a missed step, and it is not, and does not pretend to be, an adversarial control.
|
|
|
|
⚠ WHAT IT CHECKS, AND WHAT IT DELIBERATELY DOES NOT — read this before trusting a green.
|
|
|
|
It checks that a golden has been **BAKED** for the newest released controller, by looking for that
|
|
version's bake-evidence directory in this repo. It does **NOT** check that the golden was **VOUCHED**,
|
|
because the vouched version lives ONLY in the hub's `hub_settings` table — there is no copy in git.
|
|
|
|
That limit is forced, not chosen, and the reasoning is recorded so nobody re-derives it:
|
|
|
|
* Both the pre-push hook AND CI run `repo_gates.py --fast`, which by contract selects only gates
|
|
that touch **no network**. A hub-reading gate could therefore be registered as non-fast and would
|
|
then run in NEITHER place — a check that does not run where it applies is precisely the R-29
|
|
census failure this repo's runner was built to end. A gate nobody runs is worse than no gate,
|
|
because it reads as coverage.
|
|
* Recording the vouched version in a tracked file instead would create a second source of truth
|
|
that can drift from the hub, and a green gate over a false claim is the worst outcome available.
|
|
|
|
**So a bake without a vouch still passes this gate.** The bake is the step that happens in this repo
|
|
and is therefore the step this repo can see; the vouch is an operator act against the hub and needs a
|
|
different mechanism. That gap is real and is recorded as R-242's remaining half, NOT papered over
|
|
here. In practice the two are minutes apart in the same session, and the recurrence this gate is
|
|
built for was a missing BAKE.
|
|
|
|
WHY VERSION AND NOT BEHAVIOUR. It compares version numbers, so a controller release that changed
|
|
nothing a customer can see also trips it. That is accepted deliberately: deciding "customer-visible"
|
|
mechanically is not possible, judging it by hand is what already failed twice, and the cost of a
|
|
false trip is one bake — which is the operation the project wants to be routine anyway. **A gate that
|
|
cries wolf is one people learn to bypass, and `--no-verify` exists**, so the tolerance is stated
|
|
rather than assumed: if this ever fires on a release nobody wants a golden for, the honest fix is a
|
|
recorded waiver in the register, never a habit of bypassing.
|
|
|
|
FAIL-CLOSED, BUT HONEST ABOUT NOT KNOWING. An absent controller clone, or a CHANGELOG whose top
|
|
header cannot be parsed, exits **2 (INCONCLUSIVE)** — never 0. The runner reports 2 distinctly for
|
|
exactly this reason: an undetermined result is not a pass, and it is not a conviction either.
|
|
|
|
── THE SECOND BLINDNESS, R-385 (added 2026-08-23) ────────────────────────────────────────────
|
|
|
|
Until this change the gate asked ONE question — *is the golden BEHIND the record?* — and so it could
|
|
only ever catch a forgotten bake. It said nothing when the golden was **AHEAD** of the record, and
|
|
that is not a harmless direction: a golden ahead of every CHANGELOG heading was built from something
|
|
**never written down**.
|
|
|
|
That is not a hypothetical. Controller **0.221.1** was built, baked AND vouched on 2026-08-23 while
|
|
the newest heading in the controller CHANGELOG still read v0.221.0 — the fix had been written inside
|
|
the v0.221.0 entry instead of getting its own. Every gate was green throughout, including this one,
|
|
measured: `newest released 0.221.0 / newest golden baked 0.221.1 → OK`. The fleet ran a version the
|
|
record did not name.
|
|
|
|
So the test is no longer "behind?" but "**is the version we are shipping WRITTEN DOWN?**". The gate
|
|
now looks for the baked version's own `## vX.Y.Z` heading anywhere in the CHANGELOG — not merely at
|
|
the top, because an entry may legitimately be overtaken by later ones; what may never happen is that
|
|
it is absent. An unrecorded golden is convicted (exit 1) exactly like a stale one.
|
|
|
|
**Why membership and not `baked > released`.** A comparison against the newest heading alone would go
|
|
green again the moment ANY later entry was written, leaving 0.221.1 permanently unrecorded and the
|
|
gate permanently silent about it. Membership cannot be satisfied by an unrelated later release.
|
|
"""
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
# The controller clone sits beside this one. The same sibling assumption reuse_refs_check.py and
|
|
# instructions_gate.py already make — an absent sibling is INCONCLUSIVE, never a silent pass.
|
|
CONTROLLER_CHANGELOG = os.path.join(os.path.dirname(ROOT), "felhom-controller", "CHANGELOG.md")
|
|
EVIDENCE_DIR = os.path.join(ROOT, "documentation", "tests")
|
|
|
|
# `## v0.206.0 — …` on the FIRST such line: the CHANGELOG is newest-first by convention.
|
|
RELEASED_RE = re.compile(r"^##\s+v(\d+)\.(\d+)\.(\d+)\b")
|
|
# `golden-0.205.0-2026-08-07/` — the bake-evidence directory the runbook's §4.1 produces.
|
|
EVIDENCE_RE = re.compile(r"^golden-(\d+)\.(\d+)\.(\d+)-\d{4}-\d{2}-\d{2}$")
|
|
|
|
|
|
def released_versions():
|
|
"""(newest_tuple, note, set_of_all_tuples) of the controller releases, or (None, reason, set()).
|
|
|
|
R-385: the whole set is returned, not only the newest. The newest answers "is the golden behind?";
|
|
membership answers "is the version we are shipping written down at all?" — and only the second
|
|
question could have caught 0.221.1, whose heading was missing while a NEWER heading existed.
|
|
"""
|
|
if not os.path.isfile(CONTROLLER_CHANGELOG):
|
|
return None, "controller clone not found at %s" % CONTROLLER_CHANGELOG, set()
|
|
newest = None
|
|
note = ""
|
|
every = set()
|
|
with open(CONTROLLER_CHANGELOG, encoding="utf-8") as fh:
|
|
for line in fh:
|
|
m = RELEASED_RE.match(line)
|
|
if m:
|
|
v = tuple(int(g) for g in m.groups())
|
|
every.add(v)
|
|
if newest is None:
|
|
# newest-first by convention: the FIRST heading is the newest release.
|
|
newest, note = v, line.strip()[:90]
|
|
if newest is None:
|
|
return None, "no '## vX.Y.Z' header found in %s" % CONTROLLER_CHANGELOG, set()
|
|
return newest, note, every
|
|
|
|
|
|
def newest_baked():
|
|
"""(tuple, str) of the newest golden bake recorded here, or (None, reason)."""
|
|
if not os.path.isdir(EVIDENCE_DIR):
|
|
return None, "bake-evidence directory not found at %s" % EVIDENCE_DIR
|
|
found = []
|
|
for name in os.listdir(EVIDENCE_DIR):
|
|
m = EVIDENCE_RE.match(name)
|
|
if m:
|
|
found.append((tuple(int(g) for g in m.groups()), name))
|
|
if not found:
|
|
return None, "no golden-<VER>-<DATE>/ evidence directory under %s" % EVIDENCE_DIR
|
|
found.sort()
|
|
return found[-1]
|
|
|
|
|
|
def vstr(v):
|
|
return ".".join(str(p) for p in v)
|
|
|
|
|
|
def main():
|
|
released, rel_note, every_released = released_versions()
|
|
if released is None:
|
|
print("GOLDEN CURRENCY GATE INCONCLUSIVE: %s" % rel_note)
|
|
sys.exit(2)
|
|
baked, bake_note = newest_baked()
|
|
if baked is None:
|
|
print("GOLDEN CURRENCY GATE INCONCLUSIVE: %s" % bake_note)
|
|
sys.exit(2)
|
|
|
|
# Print the evidence unconditionally — a gate that only speaks when it fails teaches nobody what
|
|
# it is watching, and this one is watching the thing two releases already slipped through.
|
|
print(" newest released controller : %s (%s)" % (vstr(released), rel_note))
|
|
print(" newest golden baked : %s (documentation/tests/%s)" % (vstr(baked), bake_note))
|
|
|
|
# R-385 — UNRECORDED, checked before "behind". A golden whose version has no heading of its own
|
|
# was built from something never written down, and that is a different (worse) fault than a
|
|
# forgotten bake: there is nothing to read to find out what the fleet is running.
|
|
if baked not in every_released:
|
|
print("")
|
|
print("GOLDEN CURRENCY GATE FAILED: golden %s is baked but UNRECORDED — the controller "
|
|
"CHANGELOG has no '## v%s' heading." % (vstr(baked), vstr(baked)))
|
|
print("The newest heading is %s. A golden ahead of the record was built from a version "
|
|
"nobody wrote down, so no one can read what the fleet is running." % vstr(released))
|
|
print("Fix: give v%s its own '## v%s — <what changed>' heading in "
|
|
"felhom-controller/CHANGELOG.md, above the entries it supersedes. If its fix is "
|
|
"currently described inside another version's entry, MOVE that text — do not "
|
|
"duplicate it, and do not delete the reasoning." % (vstr(baked), vstr(baked)))
|
|
print("If this bake was a throwaway that must never be delivered, delete its "
|
|
"documentation/tests/golden-<VER>-<DATE>/ directory — never leave it to read as "
|
|
"shipped.")
|
|
sys.exit(1)
|
|
|
|
if released > baked:
|
|
print("")
|
|
print("GOLDEN CURRENCY GATE FAILED: controller v%s is released and NO golden carries it "
|
|
"(newest bake is %s)." % (vstr(released), vstr(baked)))
|
|
print("A machine installed right now would receive v%s — the release is written, tested and "
|
|
"pushed, and NOT delivered." % vstr(baked))
|
|
print("Fix: bake a golden per documentation/runbooks/RUNBOOK-manual-build.md §4.1, then vouch "
|
|
"it (a THREE-field change: golden_version + agent_version + min_agent).")
|
|
print("If this release deliberately needs no golden, record a waiver in "
|
|
"documentation/backlog/OPEN-ITEMS.md — never a bypass.")
|
|
sys.exit(1)
|
|
|
|
print("golden currency gate OK — the newest released controller has a golden "
|
|
"(NOTE: this checks the BAKE, not the vouch — see the module docstring)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|