Files
felhom.eu/scripts/observations_gate.py
T
admin 2fc4a15fa3
gates / gates (push) Successful in 16s
R-389: key the operator cooldown per app for app_start_failed; gate 11 makes an unfiled observation refuse the push
The cooldown key was customerID:eventType plus the tier and run suffixes, and
none of them names an app, so every app going down inside the same hour
collapsed onto one key and only the first was mailed. Measured on demo-hp:
bookstack sent 09:27:51, privatebin suppressed 09:31:51 under
key=demo-hp:app_start_failed.

cooldownStackSuffix is the third sibling of cooldownTierSuffix and
cooldownRunSuffix, and separate for the reason the second one's docstring
already gives: the existing two keep byte-identical semantics for every type
that uses them.

It is ALLOW-LISTED to app_start_failed and takes the event type as well as the
details, unlike its siblings, and that asymmetry is the safety property. The
backup family's cooldown is coarse ON PURPOSE (R-97a, R-182) so one full disk
sends one digest rather than one mail per app - and crossdrive_failed is
severity error, reaches the operator leg, and carries stack_name through a
DIFFERENT struct, so a payload-shape rule would have split it silently. The
hour itself does not change.

Gate 11 refuses a push whose REPORT.md carries an observation with neither
`FILED: R-NNN` nor `NOT-A-FINDING: <reason>`. It deliberately does NOT accept a
passing mention of some other R-number: the lost item cited R-182 as an analogy,
so "cites a register row" would have passed the very item the gate exists to
catch. That discrepancy with the spec is recorded in the gate's docstring.

Registered here and in the controller and agent runners. NOT in the catalog
runner - it has no shared-gate mechanism and appends --all to every gate;
filed as R-391 rather than left as a sentence, which is this session's lesson.

PROMPT-TEMPLATE.md §15.9 corrected: "documented, NOT acted on" was the wording
that invited the gap, and it now names the markers and points at the gate.

R-390 filed for the golden-bake runbook's missing `pveam update`.
Hub tests 709 -> 716.
2026-08-23 13:53:01 +02:00

251 lines
12 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Observations gate — an observation with no row behind it refuses the push.
Run from the repo root: python3 scripts/observations_gate.py [path/to/REPORT.md]
Exit 0 clean · 1 convicted (an observation is neither filed nor declared) · 2 inconclusive.
WHY THIS EXISTS, and what it cost to learn.
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 and not on the app. It was real, it
was reproducible, and it was written in a numbered item under `## Observations` in `REPORT.md`. **It
was written nowhere else.** There was no register row. `REPORT.md` is overwritten every session by
this project's own convention, so the finding had a lifetime of exactly one session.
That is R-341's shape one surface over — a commitment recorded in prose that nothing enforces — and
it is why gate 10 exists. The same argument applies here and nothing was reading this section.
**The instruction invited it.** `documentation/PROMPT-TEMPLATE.md` §15 asked for "observations:
out-of-scope items noticed, documented, not acted on". "Documented" was satisfied by the paragraph.
The template was corrected in the same session that added this gate; **this gate is the mechanism
that correction points at**, because a rule without a mechanism is a wish.
── THE RULE ─────────────────────────────────────────────────────────────────────────────────
Every numbered item in a `REPORT.md` observations section must carry exactly one explicit marker:
* ``FILED: R-NNN`` — this observation is filed, as that row. The row MUST resolve in
`OPEN-ITEMS.md` or `CLOSED-ITEMS.md`.
* ``NOT-A-FINDING:`` — deliberately not filed, followed by a reason on the same item.
── WHY A MARKER AND NOT "MENTIONS AN R-NUMBER", WHICH IS WHAT WAS ASKED FOR ──────────────────
The specification for this gate said an item may "cite an R-NNN that resolves". **That rule would
have passed the exact item this gate was built to catch**, and the discrepancy is recorded here
rather than quietly resolved:
"This is R-182's known cooldown-key shape; it was harmless while app_start_failed was
undeliverable and is not any more. Not fixed here."
`R-182` resolves. It is cited as an **analogy** — the family the defect belongs to — not as the row
that files it. No parser can tell a citation-as-precedent from a citation-as-filing by reading prose,
and a gate that guesses would either miss this item or convict every item that mentions history.
So the marker is explicit and the burden is one token. **The cost is a format requirement on one
section of one file; the benefit is that the failure mode which produced this gate cannot recur
silently.** This is the same trade `due_checks_gate.py` made: dates moved out of prose and into a
machine-readable block, inside the register so no sidecar can drift.
── BOUNDARIES, stated so they are not silently re-decided ────────────────────────────────────
* **No observations section → PASS, quietly.** Most pushes do not touch `REPORT.md`, and a gate
that taxes every push is one that gets disabled within a week. Absence of the section is not
evidence of a hidden finding.
* **No `REPORT.md` at all → INCONCLUSIVE.** This gate is registered per-repo precisely because the
repo has one; if it has vanished, that is worth a word rather than a silent pass.
* **A section present but with no parseable items → INCONCLUSIVE, naming what it could not read.**
Fail closed on ambiguity in the RULE, fail open on ambiguity in the PARSE — but INCONCLUSIVE is
never a silent pass, and the runner reports 2 distinctly for exactly that reason.
* **REFUSES, does not warn.** Gate 10's reasoning applies unchanged: a warning is the thing that
gets scrolled past, and this repo has the census to prove it.
* **Both markers, or two of one, on a single item → conviction.** An item that is both filed and
declared-not-a-finding is not a parse problem, it is an undecided author.
* **A passing run still says what it looked at** (workspace standing rule 3): it prints the item
count and how each was satisfied, so a gate that silently examined nothing is visible.
Stdlib only; no network, no subprocess — `--fast`, so it 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
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# `## 10. Observations — recorded, not acted on`, `### Observations`, `## Observations:` …
HEADING_RE = re.compile(r"^(#+)\s*(?:\d+[.)]\s*)?observations\b", re.IGNORECASE)
ITEM_RE = re.compile(r"^\s*(\d+)[.)]\s+(.*)$")
FILED_RE = re.compile(r"\bFILED:\s*(R-\d+)", re.IGNORECASE)
NOT_A_FINDING_RE = re.compile(r"\bNOT-A-FINDING:\s*(\S.*)$", re.IGNORECASE | re.MULTILINE)
REGISTERS = [
os.path.join(ROOT, "documentation", "backlog", "OPEN-ITEMS.md"),
os.path.join(ROOT, "documentation", "backlog", "CLOSED-ITEMS.md"),
]
def find_registers():
"""Locate the registers. They live in felhom.eu; sibling repos reach across, as other gates do."""
found = [p for p in REGISTERS if os.path.isfile(p)]
if found:
return found
sibling = os.path.join(os.path.dirname(ROOT), "felhom.eu", "documentation", "backlog")
return [p for p in (os.path.join(sibling, "OPEN-ITEMS.md"),
os.path.join(sibling, "CLOSED-ITEMS.md")) if os.path.isfile(p)]
def known_rows(paths):
"""Every R-number that has a row in the registers."""
rows = set()
for p in paths:
with open(p, encoding="utf-8") as fh:
for line in fh:
m = re.match(r"^\|\s*\*\*(R-\d+)\*\*\s*\|", line)
if m:
rows.add(m.group(1).upper())
return rows
def observation_items(text):
"""(items, heading) — each item is (number, its full text). heading is None when absent."""
lines = text.split("\n")
start = None
depth = 0
for i, line in enumerate(lines):
m = HEADING_RE.match(line)
if m:
start, depth = i, len(m.group(1))
break
if start is None:
return None, None
body = []
for line in lines[start + 1:]:
hm = re.match(r"^(#+)\s", line)
if hm and len(hm.group(1)) <= depth:
break
body.append(line)
items, cur = [], None
for line in body:
m = ITEM_RE.match(line)
if m:
if cur:
items.append(cur)
cur = [m.group(1), m.group(2)]
elif cur is not None:
if line.strip() == "" and cur[1].endswith("\n\n"):
continue
cur[1] += "\n" + line
if cur:
items.append(cur)
return items, lines[start].strip()
def main():
# The argument is a REPO ROOT (how the sibling runners invoke every shared gate) or, for tests
# and one-off checks, a REPORT.md path directly. Both are accepted so the controls in this
# session's evidence and the runner call the same code.
report = os.path.join(ROOT, "REPORT.md")
if len(sys.argv) > 1:
arg = sys.argv[1]
report = os.path.join(arg, "REPORT.md") if os.path.isdir(arg) else arg
if not os.path.isfile(report):
print("OBSERVATIONS GATE INCONCLUSIVE: no REPORT.md at %s" % report)
print(" This gate is registered here because this repo keeps one. If it was removed "
"deliberately, unregister the gate in the runner rather than leaving it unreadable.")
sys.exit(2)
with open(report, encoding="utf-8") as fh:
text = fh.read()
items, heading = observation_items(text)
if items is None:
print("observations gate OK — %s has no observations section (nothing to check)"
% os.path.basename(report))
sys.exit(0)
if not items:
print("OBSERVATIONS GATE INCONCLUSIVE: found the section %r but no numbered items under it."
% heading)
print(" Items must be a numbered list (`1.`, `2.` …). If the section is deliberately empty, "
"remove the heading — an empty section reads as coverage while providing none.")
sys.exit(2)
rows = known_rows(find_registers())
if not rows:
print("OBSERVATIONS GATE INCONCLUSIVE: could not read any register rows from "
"OPEN-ITEMS.md / CLOSED-ITEMS.md — cannot verify that a cited R-number resolves.")
sys.exit(2)
convictions = []
satisfied = []
for num, body in items:
filed = FILED_RE.findall(body)
declared = NOT_A_FINDING_RE.findall(body)
first_line = body.split("\n")[0].strip()
short = (first_line[:78] + "…") if len(first_line) > 78 else first_line
if filed and declared:
convictions.append((num, short,
"carries BOTH `FILED:` and `NOT-A-FINDING:` — decide which it is"))
continue
if len(filed) > 1:
convictions.append((num, short,
"carries %d `FILED:` markers (%s) — one observation, one row"
% (len(filed), ", ".join(filed))))
continue
if filed:
r = filed[0].upper()
if r not in rows:
convictions.append((num, short,
"`FILED: %s` does not resolve — no row for %s in OPEN-ITEMS.md "
"or CLOSED-ITEMS.md" % (r, r)))
else:
satisfied.append("%s. FILED %s" % (num, r))
continue
if declared:
reason = declared[0].strip()
if len(reason) < 12:
convictions.append((num, short,
"`NOT-A-FINDING:` carries no reason — the reason is the whole "
"point of the marker"))
else:
satisfied.append("%s. NOT-A-FINDING" % num)
continue
convictions.append((num, short, "neither `FILED: R-NNN` nor `NOT-A-FINDING: <reason>`"))
# Print the evidence unconditionally — a gate that only speaks when it fails teaches nobody what
# it is watching (workspace standing rule 3).
print(" report : %s" % report)
print(" section : %s" % heading)
print(" observation items : %d" % len(items))
for s in satisfied:
print(" OK %s" % s)
if convictions:
print("")
print("OBSERVATIONS GATE FAILED: %d observation(s) with nothing behind them." % len(convictions))
for num, short, why in convictions:
print("")
print(" item %s: %s" % (num, short))
print(" %s" % why)
print("")
print("An observation that lives only in REPORT.md has a lifetime of ONE SESSION — this file "
"is overwritten every time. That is how the cooldown-grain finding was lost on "
"2026-08-23 and had to be re-derived the next day.")
print("Fix: add `FILED: R-NNN` naming the row you opened for it, or `NOT-A-FINDING: <why "
"this is not worth a row>`. Opening the row is the default; declaring is the exception "
"and needs its reason stated.")
sys.exit(1)
print("observations gate OK — every observation is either filed or explicitly declared")
if __name__ == "__main__":
main()