2fc4a15fa3
gates / gates (push) Successful in 16s
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.
251 lines
12 KiB
Python
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()
|