R-389: key the operator cooldown per app for app_start_failed; gate 11 makes an unfiled observation refuse the push
gates / gates (push) Successful in 16s
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.
This commit is contained in:
@@ -111,10 +111,16 @@ produced **no** warning — the guard does not fire on the normal path.
|
||||
R-329 and R-386 compressed into CLOSED with their rules kept; **R-387** (closed) and **R-388** (the
|
||||
notification-model product decision, open, operator's call) filed.
|
||||
|
||||
## 10. Observations — recorded, not acted on
|
||||
## 10. Observations
|
||||
|
||||
> **Markers added 2026-08-24 (gate 11, R-389).** Item 1 is the finding that had no row; adding its
|
||||
> marker is the first thing the gate ever asked for. The observations' text is unchanged.
|
||||
|
||||
1. **The operator cooldown key carries no app identifier.** PrivateBin's alarm four minutes after
|
||||
BookStack's was logged `suppressed — operator cooldown 1h, key=demo-hp:app_start_failed`, so **only
|
||||
the first app-down per hour reaches the operator by e-mail**. R-182's known shape; harmless while
|
||||
the event was undeliverable, and no longer. Not fixed here.
|
||||
FILED: R-389
|
||||
2. Two probe events remain as rows for `demo-hp` from Scenario H — inert, and named rather than left.
|
||||
NOT-A-FINDING: two inert event rows on a Tier 0 demo box, created deliberately as a live
|
||||
control and named in that session's teardown; they carry no state and nothing reads them.
|
||||
|
||||
@@ -256,7 +256,8 @@ Then: [exact refusal — HTTP status, error, and the proven non-effect, e.g. "m
|
||||
`go build ./... && go vet ./... && go test ./...` — all green before proceeding. The build IS the
|
||||
typecheck; do not accumulate compile errors.
|
||||
2. **Minimal changes:** build only what's listed. No "while I'm here" refactors. Note anything worth
|
||||
fixing under "Observations" (§15) — don't act on it.
|
||||
fixing under "Observations" (§15) — don't act on it, but **do file it**: §15.9's marker rule means
|
||||
"not acted on" never means "not recorded".
|
||||
3. **No silent failures:** never swallow a parse/exec error — log it. Check a subprocess's **own** exit
|
||||
code; never pipe in a way that hides a 127. (The silent `.felhom.yml` quoting bug + the spike's
|
||||
exit-swallow lesson.)
|
||||
@@ -553,7 +554,22 @@ Report MUST include:
|
||||
`pvesm status` before/after with the space returned, and the **hub-side record's disposition named**
|
||||
(deleted / retained-with-reason / gate-blocked-with-the-command). A run that provisioned nothing says
|
||||
so. "Teardown clean" without layer 3 is not a report — it is the `sess-c` failure.
|
||||
9. **Observations:** out-of-scope items noticed — documented, NOT acted on.
|
||||
9. **Observations:** out-of-scope items noticed. **Every item carries `FILED: R-NNN` naming the
|
||||
register row opened for it in THIS session, or `NOT-A-FINDING: <reason>` declaring plainly that it
|
||||
does not warrant one.** Opening the row is the default; declaring is the exception and its reason
|
||||
is the whole of the marker.
|
||||
|
||||
**This wording replaces "documented, NOT acted on" (2026-08-24, R-389), and the old wording was
|
||||
the defect.** "Documented" was satisfied by a paragraph — and `REPORT.md` is overwritten every
|
||||
session, so a paragraph has a lifetime of one session. On 2026-08-23 a live, reproducible finding
|
||||
(only the first broken app per hour reaches the operator) was written under Observations and
|
||||
nowhere else; it had no register row and had to be re-derived the next day. That is the same shape
|
||||
as R-341, and this project's own standard says **a rule without a mechanism is a wish**.
|
||||
|
||||
**The mechanism is gate 11** (`scripts/observations_gate.py`, registered in the repo runners),
|
||||
which refuses a push whose `REPORT.md` carries an observation with neither marker. Note what it
|
||||
deliberately does NOT accept: a passing mention of some other `R-NNN`. 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ the fault was real. Full observables: `tests/campaign11-evidence-2026-08-05/jour
|
||||
|---|---|---|
|
||||
| **R-385** | **A controller was built, baked AND vouched with no CHANGELOG entry of its own, and every gate stayed green.** Controller **0.221.1** shipped on 2026-08-23 while the newest heading in `felhom-controller/CHANGELOG.md` still read `v0.221.0` — the prune-ordering fix (commit `810b18a`) had been written INSIDE the v0.221.0 entry instead of getting its own. The image was never in question; the RECORD was, and the fleet ran a version the record did not name. **`scripts/golden_currency_gate.py` could not catch it by construction:** it failed only on `released > baked`, so a golden AHEAD of the record passed silently. Measured on the real history: `newest released 0.221.0 / newest golden baked 0.221.1 → OK, exit 0`. | **CLOSED — 2026-08-23** | — | **Both halves fixed, both directions red-proofed.** The record: `v0.221.1` has its own heading carrying the MOVED (not duplicated, not deleted) reasoning — commit `da75603`, pushed alone before anything else. The gate now asks *"is the baked version WRITTEN DOWN?"* — the baked version must have its own `## vX.Y.Z` heading **anywhere** in the CHANGELOG. **Membership, not `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 and the gate permanently silent about it. INCONCLUSIVE (exit 2) preserved. Evidence: `audits/DRILL-r384-dead-db-alarm-2026-08-23/evidence/gate-0*.txt` — old gate/old record `exit 0`, new gate/old record `exit 1`, new gate/fixed record `exit 0`. | CC |
|
||||
| **R-387** | **The hub REWRITES an unknown severity and says nothing, and the guard built to catch that sits downstream of the rewrite.** One handler, two fields, opposite discipline: an unknown `event_type` is rejected with a loud `400`, while an unknown `severity` was silently coerced to `info` — after which `severityNotifies` drops it and NEITHER delivery leg runs. **Two shipped features went out that way**: `DiskAlertKind.Severity` emitted `"warn"` until controller v0.215.0, `app_start_failed` until v0.223.0. **Measured on the live hub DB 2026-08-23: 91 `app_start_failed` events stored all-time and ZERO `notification_log` rows before that day** — not one, on any channel, while every POST returned 200. **The dispatcher's `unrecognized severity` line could never execute** for an API event, because the coercion one line upstream guarantees the value it looks for cannot arrive. | **CLOSED — hub v0.107.0, 2026-08-23** | — | **The coercion STAYS; only the silence is fixed** — a rejected event is a LOST event, and losing an alarm is worse than mis-routing one. A `WARN` now names the customer, the event type, the rejected value and the consequence. **The dispatcher branch was KEPT, on evidence not caution:** `cmd/hub/main.go` wires `dispatcher.ProcessEvent` DIRECTLY as the `monitor.EventNotifyFunc` for the staleness, host-staleness and offsite-box checkers, which never pass through the handler — for them it is the only severity guard there is; deleting it as "dead" would have removed the live half while the dead half supplied the justification. All 90 severity literals in `internal/monitor` verified already valid. Proven live: `[WARN] [api] Event from demo-hp: severity "warn" is not in {info,warning,error,critical}…`, with an `error` control silent. Evidence: `audits/DRILL-r329-r386-2026-08-23/evidence/live-19-scenarioH-after.txt`. | CC |
|
||||
| **R-391** | **Gate 11 (observations) is registered in three of the four runners; `app-catalog-felhom.eu` is the exception.** The controller and agent runners already carried a shared-gate mechanism (`SHARED_REUSE`, `SHARED_INSTRUCTIONS` pointing into `felhom.eu/scripts/`), so registering there was one constant and one `GATES` line each. **`catalog_gates.py` has no such mechanism:** its `run_gate` joins every entry against its OWN `scripts/` directory, so it cannot invoke a sibling repo's script at all; and its loop appends `--all` to every gate unconditionally, which the observations gate would read as a path. Registering there therefore needs `run_gate`'s contract widened AND the argument handling changed — a refactor of a runner whose shape is deliberately different (per-app scoping, network/runtime gates excluded from `--fast`), in a repo this task marked out of scope. **The exposure today is nil** — `app-catalog-felhom.eu/REPORT.md` has no observations section, and the gate passes quietly on that — but a future catalog session could write one and nothing would read it. **Filed rather than left as a sentence in a report, which is the exact failure R-389 records.** | **OPEN — LOW** | — | Either give `catalog_gates.py` the `SHARED_*` absolute-path mechanism the other two runners already have and stop appending `--all` to gates that do not take it, or state in that repo's CLAUDE.md that its REPORT.md carries no observations section by convention. **Do not copy the gate script** — the shared checker lives in ONE place (`felhom.eu/scripts/`) and copying it is the drift the shared pattern exists to prevent. | CC |
|
||||
| **R-390** | **The golden-bake runbook omits `pveam update`, and the failure it produces names the wrong cause.** `documentation/runbooks/RUNBOOK-manual-build.md` §4.1 step 2 says to list the current Debian template because "the exact point release rots" — but on the drill VM's `virgin` snapshot **the `pveam` INDEX is stale too**, so `pveam available` offers an old point release and `pveam download local <that>` fails with **`400 Parameter verification failed. template: no such template`**. That reads as a typo or a bad argument, not as an old index, and it costs a diagnosis every time. **Hit on two consecutive bakes** (golden 0.222.0 and 0.223.0, both 2026-08-23). The runbook is otherwise correct verbatim — the qemu launch line, the token-read-inside-the-VM pattern and the acceptance markers all worked unchanged. | **OPEN — LOW** | — | Add `pveam update` as its own numbered step before the listing, and say WHY: a snapshot that never changes carries an index that never updates, so the rot warning already in the step applies to the index as well as to the release. Recorded meanwhile in the workspace memory `golden-bake-needs-pveam-update` and in `documentation/tests/golden-0.223.0-2026-08-23/README.md`. | CC |
|
||||
| **R-389** | **Only the FIRST broken app per hour reaches the operator — the cooldown key names the event type, not the app.** `dispatcher.go:337` builds the operator key as `customerID + ":" + eventType + cooldownTierSuffix(details) + cooldownRunSuffix(details)`, and **neither suffix reads an app name**. So every app that goes down inside the same hour collapses onto one key and only the first is mailed. **Measured live on `demo-hp` 2026-08-23:** `bookstack` alarmed at 09:27:51 and was `sent`; `privatebin` alarmed at 09:31:51, four minutes later, and was logged `suppressed — operator cooldown 1h, key=demo-hp:app_start_failed`. Three apps down together tonight would produce one mail. **The app's identity is already on the wire** — `AppDetails{StackName, DisplayName}` serialises as `stack_name` (`felhom-controller/internal/notify/notifier.go:146-149`), and the hub already makes exactly this kind of distinction twice, with `cooldownTierSuffix` and `cooldownRunSuffix`. **This was latent for as long as the cooldown has existed and only became reachable when R-329 made `app_start_failed` deliverable at all** — the same "a known-broken thing moves from unreachable to load-bearing" shape as R-329 itself. **AND IT WAS NEVER FILED:** it was written in a REPORT.md observations paragraph on 2026-08-23 and nowhere else — the register had no row for it until now, which is R-341's shape one surface over and is why gate 11 exists. | **OPEN — MEDIUM** | — | A third sibling suffix, `cooldownStackSuffix`, **allow-listed to `app_start_failed` and nothing else**. **Do NOT apply it globally:** the backup family's cooldown is coarse ON PURPOSE (R-97a, R-182) so that one full disk sends one digest rather than one mail per app — and `crossdrive_failed` (severity `error`) carries `stack_name` through a *different* struct (`CrossDriveDetails`), so a global suffix would silently split it per-app. The hour itself does not change; the grain is the complaint, not the length. Evidence: `audits/DRILL-cooldown-grain-2026-08-23/`. | CC |
|
||||
| **R-388** | **PRODUCT DECISION (not a defect): the customer notification model is the wrong shape, and the settings page grows by one toggle per detector.** The operator's framing, recorded verbatim 2026-08-23: *"A customer should be notified only about things they can act on or are responsible for — the drive they unplugged, the storage they filled. **A failed backup is our incident, not theirs.** The intended shape is that we detect it, we tell them we noticed and are dealing with it, and they are not handed an error they cannot solve. The subscription should feel like being looked after, not like being on call."* Today's page is the opposite shape — one switch per detector, and it **grew from 12 to 15 in a single session** (one new alarm plus two compound toggles split into four). That growth is the argument, not an aside: a page that grows per detector keeps asking a household to make engineering decisions. | **OPEN — DIRECTION, operator's call** | a decision on scope; nothing here is a bug | Recorded as a dated **[DESIGN — DIRECTION]** entry at `documentation/architecture/08-alarm-ladder.md` §8, marked plainly as *not current behaviour*. **Deliberately NOT implemented in the session that recorded it.** `app_start_failed` defaulting OFF is consistent with the direction and reversible either way, but was ruled on its own merits and does not pre-judge the redesign. | Viktor |
|
||||
| **R-229** | **The instruction-file rightsizing landed for `felhom-controller` and the workspace root; three pieces were deliberately deferred.** Done 2026-08-06: controller split into a 92-effective-line core plus four `paths:`-scoped `.claude/rules/*.md`; workspace root 208→142 effective lines with its versioned copy kept byte-identical; surgical corrections to `felhom-agent` and `felhom.eu` (expired TEMPORARY block, every version literal, the Legacy-Windows copies, the duplicated health-check rule); five contradictions resolved — including a drill-VM claim **measured live** (`qm list` on demo-hp shows VM 300 `drill-r50`; `felhom-agent` was right, `felhom-controller` was wrong); new shared `felhom.eu/scripts/instructions_gate.py` registered in `controller_gates.py` and `agent_gates.py`, 20 fixture tests + red-proof. **Leg (a) CLOSED 2026-08-06 (part 2):** `felhom.eu/CLAUDE.md` **227 → 115 effective lines**, split into a core plus `.claude/rules/{hub,website,manifests,docs}.md`; `instructions_gate` **registered in `scripts/repo_gates.py`** (six gates, all OK) in the required order — trim first, register second, because a registered-but-failing gate refuses every push. Scoping proven from the `InstructionsLoaded` hook log in two fresh sessions, not from frontmatter. **Still deferred:** (b) **CLOSED 2026-08-06 (close-out)** — `felhom-agent/CLAUDE.md` **175 → 99 effective lines** (measured 175, not 173: the CI correction added two), split into a core plus `.claude/rules/{proxmox,localapi,backup,storage}.md` beside the existing `health-checks.md`. The release section now points at the `felhom-build-deploy` skill instead of restating a table that drifts from the script. **Every `CLAUDE.md` in the workspace is now ≤120 effective lines except the workspace root at 142, which is deliberate — it is the only file re-injected after `/compact`.** (c) **CLOSED 2026-08-06 (part 2)** — all 44 orphans resolved with **zero deletions** (file count 158 before and after): 4 durable `reference`-type files indexed, 40 dated episode records moved to `.claude-memory/archive/`. `MEMORY.md` 145 → **150 lines / 17,977 bytes**, and `instructions_gate` check 6 now watches it (over-limit FAILS, orphan WARNS, absent store PASSES *printing its reason*). (d) **The spec-as-failing-test pilot** — moved to R-230. Full accounting: `audits/LEDGER-instruction-trim-2026-08-06.md` + `audits/LEDGER-instruction-trim-part2-2026-08-06.md` | **READY** — owner Viktor |
|
||||
|
||||
@@ -329,12 +329,83 @@ func cooldownRunSuffix(detailsJSON string) string {
|
||||
return ":" + d.RunID
|
||||
}
|
||||
|
||||
// perAppCooldownEvents is the register of event types whose operator cooldown is keyed PER APP.
|
||||
//
|
||||
// R-389. **This is a named allow-list and not a behaviour inferred from the payload, deliberately.**
|
||||
// Several event types carry `stack_name` and must NOT be split per app — see the fence below — so a
|
||||
// rule of the form "if it has a stack_name, split it" would silently change them. The register makes
|
||||
// the decision reviewable one line at a time, exactly as `operatorOnlyEvents` does.
|
||||
//
|
||||
// ── THE FENCE, RECORDED SO IT CAN BE NARROWED LATER IF IT IS EVER WRONG ──────────────────────
|
||||
//
|
||||
// The backup family's cooldown is coarse **on purpose**. R-97a and R-182 exist precisely so that one
|
||||
// full disk produces ONE mail listing every affected app, rather than one mail per app. Adding
|
||||
// `stack_name` to the key for those types would undo both, and it would do it silently — the code
|
||||
// would look more precise while the operator's inbox got twenty times louder.
|
||||
//
|
||||
// It is not hypothetical: **`crossdrive_failed` is severity `error`, reaches the operator leg, and
|
||||
// carries `stack_name`** through a different struct (`CrossDriveDetails`, not `AppDetails`). A
|
||||
// payload-shape rule would have caught it and split it. This register does not.
|
||||
//
|
||||
// The fenced ACT is *adding an entry here for a type whose family has a digest or a coarse-by-design
|
||||
// cooldown*. Adding one for a type that genuinely has no digest and alarms per app is the intended
|
||||
// use.
|
||||
var perAppCooldownEvents = map[string]bool{
|
||||
// The ONLY member as of hub v0.108.0. An app going down is a per-app fault with no digest: there
|
||||
// is no `apps_down_run` summarising a scan the way `backup_run_failures` summarises a run, so
|
||||
// per-app is the only grain available that does not lose alarms. Measured 2026-08-23: two apps
|
||||
// four minutes apart produced one mail and one suppression.
|
||||
"app_start_failed": true,
|
||||
}
|
||||
|
||||
// cooldownStackSuffix returns ":"+stack_name when the event's details carry a non-empty `stack_name`
|
||||
// AND the event type is in `perAppCooldownEvents`, else "".
|
||||
//
|
||||
// R-389. The third sibling of `cooldownTierSuffix` and `cooldownRunSuffix`, and deliberately a
|
||||
// SEPARATE function for the reason `cooldownRunSuffix`'s docstring already gives: the existing two
|
||||
// keep byte-identical semantics for every type that uses them, so R-97a's and R-182's behaviour and
|
||||
// their tests are untouched by this.
|
||||
//
|
||||
// WHY AN APP NEEDS ONE. The operator cooldown collapses everything sharing a key for an hour. Until
|
||||
// v0.108.0 the key named the event TYPE and not the app, so a second app going down inside that hour
|
||||
// was recorded `suppressed` and never mailed. Measured live 2026-08-23: `bookstack` sent at 09:27:51,
|
||||
// `privatebin` suppressed at 09:31:51 under `key=demo-hp:app_start_failed`. **Three apps dying
|
||||
// together produced one mail.**
|
||||
//
|
||||
// IT TAKES THE EVENT TYPE AS WELL AS THE DETAILS, unlike its two siblings, and that asymmetry is the
|
||||
// whole safety property — see `perAppCooldownEvents`. The siblings can be payload-driven because
|
||||
// `tier` and `run_id` appear only on types that want that grain; `stack_name` does not have that
|
||||
// property.
|
||||
//
|
||||
// NARROW AND FAIL-SOFT, like its siblings: empty on an absent, malformed or empty value, so a
|
||||
// degraded payload falls back to today's key and **the mail still goes**. Losing an alarm is worse
|
||||
// than mis-routing one.
|
||||
func cooldownStackSuffix(eventType, detailsJSON string) string {
|
||||
if !perAppCooldownEvents[eventType] {
|
||||
return ""
|
||||
}
|
||||
if detailsJSON == "" || !strings.Contains(detailsJSON, "\"stack_name\"") {
|
||||
return ""
|
||||
}
|
||||
var d struct {
|
||||
StackName string `json:"stack_name"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(detailsJSON), &d); err != nil || d.StackName == "" {
|
||||
return ""
|
||||
}
|
||||
return ":" + d.StackName
|
||||
}
|
||||
|
||||
func (d *Dispatcher) processOperator(customerID, eventType, severity, message, detailsJSON, source string) {
|
||||
if !d.operatorOn || d.operatorEmail == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cooldownKey := customerID + ":" + eventType + cooldownTierSuffix(detailsJSON) + cooldownRunSuffix(detailsJSON)
|
||||
// R-389 added the third suffix. It is allow-listed to one event type, so every other type's key
|
||||
// is byte-identical to v0.107.0's — pinned by TestR389_NoOtherEventTypeKeyChanges.
|
||||
cooldownKey := customerID + ":" + eventType +
|
||||
cooldownTierSuffix(detailsJSON) + cooldownRunSuffix(detailsJSON) +
|
||||
cooldownStackSuffix(eventType, detailsJSON)
|
||||
d.mu.Lock()
|
||||
if last, ok := d.opCooldowns[cooldownKey]; ok && time.Since(last) < 1*time.Hour {
|
||||
d.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-389 — the operator cooldown named the event TYPE and not the APP, so only the first broken app
|
||||
// per hour was ever mailed.
|
||||
//
|
||||
// THE DEFECT. `processOperator` keys the 1-hour cooldown on
|
||||
// `customerID:eventType[:tier][:run_id]`. None of those name an app. Measured live on `demo-hp`
|
||||
// 2026-08-23: `bookstack` alarmed at 09:27:51 and was `sent`; `privatebin` alarmed four minutes
|
||||
// later and was logged `suppressed — operator cooldown 1h, key=demo-hp:app_start_failed`. Three apps
|
||||
// dying together produce one mail.
|
||||
//
|
||||
// THE LAYER. These sit at the key builder and at `processOperator`. The key is where the collapse
|
||||
// happens, and the stored notification rows are where it is visible — asserting only that the suffix
|
||||
// function returns a string would repeat the "mechanism pinned, consequence unpinned" mistake.
|
||||
//
|
||||
// RED-PROOF (observed, see REPORT.md): drop `cooldownStackSuffix` from the key expression in
|
||||
// `processOperator` and TestR389_TwoAppsInsideTheHourBothReachTheOperator fails with
|
||||
// `2 apps down inside the hour produced 1 operator mail(s), want 2`.
|
||||
|
||||
// --- the suffix itself ---------------------------------------------------------------------------
|
||||
|
||||
func TestR389_StackSuffixIsAllowListedAndFailSoft(t *testing.T) {
|
||||
const appDetails = `{"stack_name":"bookstack","display_name":"BookStack"}`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
eventType string
|
||||
details string
|
||||
want string
|
||||
}{
|
||||
{"the allow-listed type gets the app", "app_start_failed", appDetails, ":bookstack"},
|
||||
|
||||
// THE FENCE. crossdrive_failed is severity `error`, reaches the operator leg, and carries
|
||||
// stack_name through CrossDriveDetails — a payload-shape rule would have split it per app and
|
||||
// silently undone R-97a/R-182.
|
||||
{"crossdrive_failed is NOT split per app", "crossdrive_failed",
|
||||
`{"stack_name":"bookstack","method":"rsync"}`, ""},
|
||||
{"app_deployed is not in the register", "app_deployed", appDetails, ""},
|
||||
{"app_removed is not in the register", "app_removed", appDetails, ""},
|
||||
{"backup_failed is not in the register", "backup_failed", appDetails, ""},
|
||||
|
||||
// Fail-soft: a degraded payload must fall back to today's key, never panic, never drop.
|
||||
{"empty details", "app_start_failed", "", ""},
|
||||
{"no stack_name key", "app_start_failed", `{"display_name":"BookStack"}`, ""},
|
||||
{"empty stack_name", "app_start_failed", `{"stack_name":""}`, ""},
|
||||
{"malformed JSON that still contains the token", "app_start_failed", `{"stack_name":`, ""},
|
||||
{"null details", "app_start_failed", "null", ""},
|
||||
{"array instead of object", "app_start_failed", `["stack_name"]`, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := cooldownStackSuffix(tc.eventType, tc.details); got != tc.want {
|
||||
t.Fatalf("cooldownStackSuffix(%q, %q) = %q, want %q", tc.eventType, tc.details, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The register must stay narrow. A second entry is a deliberate act and should fail this until
|
||||
// someone changes it on purpose, having read the fence.
|
||||
func TestR389_TheAllowListHasExactlyOneMember(t *testing.T) {
|
||||
if len(perAppCooldownEvents) != 1 || !perAppCooldownEvents["app_start_failed"] {
|
||||
var got []string
|
||||
for k := range perAppCooldownEvents {
|
||||
got = append(got, k)
|
||||
}
|
||||
t.Fatalf("perAppCooldownEvents = %v, want exactly [app_start_failed]. Adding a member is the "+
|
||||
"fenced act: the backup family's cooldown is coarse ON PURPOSE (R-97a, R-182) so one full "+
|
||||
"disk sends one digest, not one mail per app. Read the fence before widening this.", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scenario C: the absence claim, WITH its positive control ------------------------------------
|
||||
//
|
||||
// "No other event type's key changed" is an absence claim. The control below proves this test can
|
||||
// SEE a key change first — otherwise a broken key builder would make every row look unchanged and
|
||||
// the test would pass forever.
|
||||
func TestR389_NoOtherEventTypeKeyChanges(t *testing.T) {
|
||||
// The v0.107.0 key expression, modelled inline. This is the BEFORE value, and modelling it here
|
||||
// rather than reading it from git is deliberate: the comparison must survive the file moving.
|
||||
oldKey := func(customerID, eventType, details string) string {
|
||||
return customerID + ":" + eventType + cooldownTierSuffix(details) + cooldownRunSuffix(details)
|
||||
}
|
||||
newKey := func(customerID, eventType, details string) string {
|
||||
return customerID + ":" + eventType + cooldownTierSuffix(details) + cooldownRunSuffix(details) +
|
||||
cooldownStackSuffix(eventType, details)
|
||||
}
|
||||
|
||||
// POSITIVE CONTROL FIRST: the pair must be able to differ at all.
|
||||
if oldKey("c1", "app_start_failed", `{"stack_name":"bookstack"}`) ==
|
||||
newKey("c1", "app_start_failed", `{"stack_name":"bookstack"}`) {
|
||||
t.Fatal("the control failed: old and new key agree even for the allow-listed type, so this " +
|
||||
"test cannot see a key change and its 'unchanged' verdicts below would be worthless")
|
||||
}
|
||||
|
||||
// Every other type — including the ones that carry stack_name — must be byte-identical.
|
||||
for _, tc := range []struct{ eventType, details string }{
|
||||
{"crossdrive_failed", `{"stack_name":"bookstack","method":"rsync"}`},
|
||||
{"crossdrive_completed", `{"stack_name":"docmost"}`},
|
||||
{"app_deployed", `{"stack_name":"bookstack","display_name":"BookStack"}`},
|
||||
{"app_removed", `{"stack_name":"bookstack"}`},
|
||||
{"backup_failed", `{"error":"boom"}`},
|
||||
{"backup_run_failures", `{"run_id":"r-42"}`},
|
||||
{"whole_guest_backup_failed", `{"tier":"felhom-pbs"}`},
|
||||
{"db_dump_failed", `{"stack_name":"docmost"}`},
|
||||
{"disk_warning", `{"path":"/mnt/data"}`},
|
||||
{"expected_backup_missed", `{"tier":"offsite","run_id":"r-9"}`},
|
||||
{"storage_disconnected", `{}`},
|
||||
{"health_critical", ""},
|
||||
} {
|
||||
o := oldKey("demo-hp", tc.eventType, tc.details)
|
||||
n := newKey("demo-hp", tc.eventType, tc.details)
|
||||
if o != n {
|
||||
t.Errorf("%s: cooldown key CHANGED\n v0.107.0: %s\n v0.108.0: %s\n"+
|
||||
"Only app_start_failed may move. Splitting a backup-family key per app undoes R-97a "+
|
||||
"and R-182 — twenty mails where one digest belongs.", tc.eventType, o, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the CONSEQUENCE, asserted from the stored notification rows --------------------------------
|
||||
|
||||
// appEvent builds the details payload the controller actually sends for app_start_failed.
|
||||
func appEvent(stack string) string {
|
||||
return `{"stack_name":"` + stack + `","display_name":"` + strings.ToUpper(stack[:1]) + stack[1:] + `"}`
|
||||
}
|
||||
|
||||
// operatorMails returns the operator-channel rows for a customer, newest first.
|
||||
func operatorMails(t *testing.T, d *Dispatcher, customerID string) (sent, suppressed int, rows []string) {
|
||||
t.Helper()
|
||||
entries, err := d.store.GetRecentNotifications(customerID, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Channel != "operator" || e.EventType != "app_start_failed" {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, e.Status+" | "+e.Message+" | "+e.ErrorMessage)
|
||||
switch e.Status {
|
||||
case "sent":
|
||||
sent++
|
||||
case "suppressed":
|
||||
suppressed++
|
||||
}
|
||||
}
|
||||
return sent, suppressed, rows
|
||||
}
|
||||
|
||||
// SCENARIO A — two apps go down inside the hour. Both must reach the operator.
|
||||
//
|
||||
// This is R-389's whole point, and it asserts the STORED rows, not a return value.
|
||||
func TestR389_TwoAppsInsideTheHourBothReachTheOperator(t *testing.T) {
|
||||
st := opOnlyStore(t)
|
||||
rec := &sentTo{}
|
||||
d := opOnlyDispatcher(t, st, rec)
|
||||
|
||||
// POSITIVE CONTROL: the operator leg must be able to deliver at all in this configuration,
|
||||
// before any count below means anything. (Yesterday's live run could not prove the customer leg
|
||||
// because no address was set — do not repeat that shape.)
|
||||
d.ProcessEvent("c1", "app_start_failed", "warning",
|
||||
"Telepített alkalmazás nem fut: BookStack", appEvent("bookstack"), "controller")
|
||||
if sent, _, rows := operatorMails(t, d, "c1"); sent != 1 {
|
||||
t.Fatalf("control failed: the FIRST app produced %d sent operator row(s), want 1 — the "+
|
||||
"operator leg is not delivering here, so the counts below would be meaningless. rows=%v",
|
||||
sent, rows)
|
||||
}
|
||||
|
||||
// A different app, same hour, same event type.
|
||||
d.ProcessEvent("c1", "app_start_failed", "warning",
|
||||
"Telepített alkalmazás nem fut: PrivateBin", appEvent("privatebin"), "controller")
|
||||
|
||||
sent, suppressed, rows := operatorMails(t, d, "c1")
|
||||
if sent != 2 {
|
||||
t.Fatalf("2 apps down inside the hour produced %d operator mail(s), want 2 "+
|
||||
"(suppressed=%d). This is R-389: the second app's alarm took the first app's cooldown "+
|
||||
"slot.\nrows: %v", sent, suppressed, rows)
|
||||
}
|
||||
if suppressed != 0 {
|
||||
t.Errorf("a DIFFERENT app was suppressed: %v", rows)
|
||||
}
|
||||
|
||||
// And the addresses actually attempted — two distinct deliveries, not one row written twice.
|
||||
opCount := 0
|
||||
for _, to := range rec.to {
|
||||
if to == "operator@felhom.eu" {
|
||||
opCount++
|
||||
}
|
||||
}
|
||||
if opCount != 2 {
|
||||
t.Errorf("the dispatcher attempted %d operator send(s), want 2 — a stored row without a send "+
|
||||
"attempt would be a record of something that did not happen", opCount)
|
||||
}
|
||||
}
|
||||
|
||||
// SCENARIO B — the SAME app twice inside the hour. The hour is unchanged: one mail, one suppression.
|
||||
func TestR389_SameAppTwiceInsideTheHourIsStillSuppressed(t *testing.T) {
|
||||
st := opOnlyStore(t)
|
||||
rec := &sentTo{}
|
||||
d := opOnlyDispatcher(t, st, rec)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
d.ProcessEvent("c1", "app_start_failed", "warning",
|
||||
"Telepített alkalmazás nem fut: BookStack", appEvent("bookstack"), "controller")
|
||||
}
|
||||
|
||||
sent, suppressed, rows := operatorMails(t, d, "c1")
|
||||
if sent != 1 || suppressed != 1 {
|
||||
t.Fatalf("the same app twice gave sent=%d suppressed=%d, want 1 and 1 — R-389 changes the "+
|
||||
"GRAIN, not the hour, and re-alarming the same app is the flood the cooldown exists to "+
|
||||
"stop.\nrows: %v", sent, suppressed, rows)
|
||||
}
|
||||
// The suppression must still name the key, which is what made R-389 findable at all.
|
||||
if !strings.Contains(rows[0]+rows[1], "bookstack") {
|
||||
t.Errorf("the suppression row does not name the app in its key — that visibility is R-182's "+
|
||||
"contribution and is how this defect was found: %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// SCENARIO D — details missing or malformed. The key degrades to today's and the mail STILL GOES.
|
||||
func TestR389_DegradedDetailsStillDeliver(t *testing.T) {
|
||||
for _, details := range []string{"", "null", `{}`, `{"stack_name":""}`, `{"stack_name":`} {
|
||||
st := opOnlyStore(t)
|
||||
rec := &sentTo{}
|
||||
d := opOnlyDispatcher(t, st, rec)
|
||||
|
||||
d.ProcessEvent("c1", "app_start_failed", "warning", "Telepített alkalmazás nem fut", details, "controller")
|
||||
|
||||
sent, _, rows := operatorMails(t, d, "c1")
|
||||
if sent != 1 {
|
||||
t.Errorf("details %q: %d operator mail(s), want 1 — a degraded payload must fall back to "+
|
||||
"the old key and still deliver. Losing an alarm is worse than mis-routing one. rows=%v",
|
||||
details, sent, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A backup-family event carrying stack_name must still collapse — the coarse grain is the design.
|
||||
func TestR389_CrossdriveStillCollapsesPerHour(t *testing.T) {
|
||||
st := opOnlyStore(t)
|
||||
rec := &sentTo{}
|
||||
d := opOnlyDispatcher(t, st, rec)
|
||||
|
||||
for _, stack := range []string{"bookstack", "docmost", "privatebin"} {
|
||||
d.ProcessEvent("c1", "crossdrive_failed", "error",
|
||||
"Másodlagos mentés sikertelen: "+stack,
|
||||
`{"stack_name":"`+stack+`","method":"rsync"}`, "controller")
|
||||
}
|
||||
|
||||
entries, err := d.store.GetRecentNotifications("c1", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := 0
|
||||
for _, e := range entries {
|
||||
if e.Channel == "operator" && e.EventType == "crossdrive_failed" && e.Status == "sent" {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
if sent != 1 {
|
||||
t.Fatalf("three crossdrive_failed events produced %d operator mail(s), want 1 — this family's "+
|
||||
"cooldown is coarse ON PURPOSE (R-97a, R-182), and it carries stack_name, so a global "+
|
||||
"suffix would have split it into three", sent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/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()
|
||||
@@ -18,6 +18,19 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
|
||||
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)
|
||||
10. due-checks a dated check in OPEN-ITEMS.md that has come due (R-341)
|
||||
11. observations a REPORT.md observation with no register row behind it (R-389)
|
||||
|
||||
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
|
||||
@@ -93,6 +106,9 @@ GATES = [
|
||||
# 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),
|
||||
# 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),
|
||||
]
|
||||
|
||||
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
|
||||
|
||||
Reference in New Issue
Block a user