diff --git a/CLAUDE.md b/CLAUDE.md index ce6686e9..d18728b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,14 @@ today that is all of them. **A missing gate script is a FAILURE, never a skip.** `due_checks_gate.py` refuses the push when a dated check in `OPEN-ITEMS.md`'s `DUE-CHECKS` block has come due (R-341). **It is not a scheduler** — it fires on the next push, not on the date. +**A gate ships with a decoy test that has been seen to fail (R-421).** A decoy is the LABEL without +the FACT — a directory with the right name and no bake log, a note whose prose mentions the marker it +lacks. `scripts/decoy_coverage_gate.py` refuses a new gate that has neither a decoy nor a named +exemption carrying its row. The four shapes, the 2026-09-01 sweep that fooled 16 of 29 gates, and the +decoys withdrawn as illegitimate: `documentation/audits/AUDIT-gate-decoys-2026-09-01.md` and +`felhom-controller/.claude/rules/gates.md`. **Scope is a fact too** — prefer `os.walk` over +`os.listdir`, and a glob over a hand-maintained list. + `site_gates.py` is a *gate*, not a runner — do not model new work on it; `app-catalog-felhom.eu/scripts/catalog_gates.py` is the canonical runner (R-161). diff --git a/CONTEXT.md b/CONTEXT.md index 870896f4..0935f633 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,6 +14,44 @@ > language, one screen, no identifiers in the prose. Same subjects, different readers; merging them > would make one of the two audiences stop reading. `STATUS.md` is also a **view of `OPEN-ITEMS.md`** > and holds nothing of its own; this file does hold its own content, namely the standing rulings below. +## The checks were the one thing nothing checked — 16 of 29 could be fooled by a label (2026-09-01, R-421) + +**THE CLASS: an instrument that matches a LABEL rather than the fact it names.** Five instances, and +every single one was found by accident, by someone looking at something else: R-410 (a `mkdir` turned +the release gate green), R-400 (seven debug controls answering nothing), R-378 (a status word inside a +longer sentence), R-419 (a phrase inside prose — including prose stating the marker was absent), R-94 +(a test comparing a constant to itself). **The gates enforce every other rule in this project, +including the rule that findings must be written down. Nothing had ever checked the gates.** + +**The method, and it is the transferable part: write the decoy.** For each gate, construct the label +without the fact — a directory with the right name and no bake log, a handler case that exists only +in a comment, a note whose prose mentions the marker it lacks — run the gate, and record what it +says. **A last column answered from reading is worth nothing**; that is precisely how all five hid. + +**Result: 29 scripts, 19 sound, 4 holes left open with rows, 6 that no plausible decoy could be built +for and are named as UNTESTED rather than called sound.** Sixteen were fooled; ten were fixed the +same day. + +**The largest single cause was mundane and worth remembering: SCOPE IS A FACT TOO.** Eight gates +decided what to look at with `os.listdir` — one directory level. Every one was green *and correct*, +because no subdirectory exists today; every one would have gone blind the moment anyone added +`templates/partials/`, which is an ordinary act. `mojibake` and `docker-v` already used `os.walk`, +caught the identical planted file, and are the control that proves the cause was the listing and not +the decoy. **Prefer `os.walk` over `os.listdir`, and a glob over a hand-maintained list of files** — +three of the four remaining holes (R-423 `PAGES`, R-425 `FILES`, and R-410 itself) are hand-maintained +lists that silently narrowed as the thing they described grew. + +**A decoy nobody would write proves nothing, and saying so is a result.** Five of mine were withdrawn +as illegitimate rather than counted — an inert Compose `x-` field, a CHANGELOG heading that did not +actually hide the release, a half-built decoy that left the real call in place, a malformed table row +that convicted for a structural reason, and several planted files carrying nothing the gate hunts for. +Manufacturing a finding to fill a row is worse than an honest NO. + +**The rule that keeps the answer true:** a gate ships with a decoy test that has been seen to fail. +`scripts/decoy_coverage_gate.py` refuses a new gate that has neither one nor a named exemption +carrying its row — and it convicted *itself* the moment it was registered, which is how it got one. +The 20 gates not yet covered are listed by name (R-426), so the debt is visible and shrinks. + ## A guard aimed at the wrong repository trains everyone to bypass it (2026-09-01, R-404 / R-417) **The ruling was neither option as framed.** The question on the table was whether a documents-only diff --git a/documentation/audits/AUDIT-gate-decoys-2026-09-01.md b/documentation/audits/AUDIT-gate-decoys-2026-09-01.md new file mode 100644 index 00000000..f3a15214 --- /dev/null +++ b/documentation/audits/AUDIT-gate-decoys-2026-09-01.md @@ -0,0 +1,81 @@ +# AUDIT — can this check be fooled by a label? The decoy sweep (2026-09-01, R-421) + +**Question asked of every gate:** *could I satisfy this with a convincing label instead of the real +thing?* Answered by construction — a decoy is written, the gate is run, and the verdict recorded. +**No row's last column was answered from reading.** + +## The survey + +**29 distinct scripts, 35 registrations** (`reuse-refs`, `instructions`, `observations` are one +script each, registered in three runners). This matches the task's count of 29. + +| gate | runner(s) | meant to prove | actually matches | shape | decoy passed **before**? | +|---|---|---|---|---|---| +| emoji | ctrl | no emoji in UI copy | codepoints, in `listdir` scope | 1 | **YES** | +| native-confirm | ctrl | no OS-modal dialogs | JS call regex, `listdir` scope | 1 | **YES** | +| app-row-dedup | ctrl | one row markup | regex + `'…' not in src`, `listdir` | 1, 2 | **YES** (×2) | +| template-id | ctrl | JS ids resolve | id sets, `listdir` scope | 1 | **YES** | +| secret-markup | ctrl | no secret in markup | template actions, `listdir` | 1 | **YES** | +| retrieval-promise | ctrl | promises are registered | stems, `listdir` scope | 1 | **YES** | +| hub-confirm | eu | no OS-modal dialogs | JS call regex, `listdir` scope | 1 | **YES** | +| manifest-bearer | eu | no bearer literals | 64-hex regex, `listdir` scope | 1 | **YES** | +| observations | eu, ctrl, agent | a finding is filed | `FILED:` anywhere in the body | 2 | **YES** (R-419) | +| debug-routes | ctrl | controls resolve | raw text, comments included | 2, 3 | **YES** | +| closed-register | eu | closed rows are closed | verdict cell — **but skipped unparseable rows** | 2 | **YES** | +| site | eu | pages are well-formed | a 7-entry `PAGES` list | 1 | **YES → R-423** | +| one-register | eu | open work is registered | the state cell; `idea` escapes | 2 | **YES → R-424** | +| offbox-rename | ctrl | branding is retired | a fixed 3-entry `FILES` list | 1 | **YES → R-425** | +| reuse-refs | eu, ctrl, agent | citations resolve | paths — only 7 extensions | 1 | **YES → R-422** | +| mojibake | ctrl | no double-encoded UTF-8 | bytes, via `os.walk` | 5 | NO — **control** | +| docker-v | ctrl | `-v` mounts are safe | argv, via `os.walk` | 5 | NO | +| image-pins | catalog | no floating tags | real `image:` refs | 5 | NO | +| golden-currency | eu | a golden was baked | `GOLDEN_SHA256` in the bake log | 5 | NO (R-410 fixed) | +| golden-notice | ctrl | ditto, other direction | imports the gate above | 5 | NO | +| instructions | eu, ctrl, agent | instruction files stay sane | effective text | 5 | NO | +| hub-copy | eu | retired names are gone | `os.walk` over hub/internal | 5 | NO | +| release-complete | agent | a release is complete | git tag + ancestry + HTTP HEAD | 5 | NO | +| hostinstall | eu | installer invariants | — | ? | **UNKNOWN** | +| wire-contract | eu | emitted fields decode | — | ? | **UNKNOWN** | +| due-checks | eu | dated checks fire | — | ? | **UNKNOWN** | +| published | agent | versions are published | — | ? | **UNKNOWN** | +| image-resolvable | catalog | images exist | `docker manifest inspect` | 5 | **UNKNOWN** | +| volume-persistence | catalog | data survives | runs containers, diffs | 5 | **UNKNOWN** | + +**19 sound · 4 holes left open with rows · 6 unknown.** Ten holes were fixed in this session. + +## The one cause behind eight of them + +Eight gates decided their **scope** with `os.listdir`, one directory level. No template or manifest +subdirectory exists today, so every one was green **and correct** — and would have stayed green the +moment anyone added `templates/partials/`, which is an ordinary act. `mojibake` and `docker-v` +already used `os.walk`, caught the identical planted file, and are the **control that proves the +cause was the listing and not the decoy.** + +## Holes left open, with rows + +| row | gate | why not fixed here | +|---|---|---| +| **R-422** | reuse-refs | `PATH_RE` matches 7 extensions; a rotted `.md` citation is invisible. Widening it needs a false-positive pass over 4 repos. | +| **R-423** | site | `PAGES` is a hardcoded list of 7. Fix is to glob `website/*.html`, which needs the per-page exemptions rethought. | +| **R-424** | one-register | a defect parked under state `idea` escapes. Declared in the gate's own docstring as residual hole 1. | +| **R-425** | offbox-rename | fixed 3-entry `FILES` list; a new offbox file is unscanned. | +| **R-426** | — | owns the meta-gate's 20-name exemption list. | + +Each is asserted **as it behaves today** where a test could hold it, so the day it is fixed the +assertion fails and is updated deliberately. A hole nothing asserts is a hole nobody remembers. + +## Decoys withdrawn as illegitimate — mine, and named + +§2.1 sets the standard: a decoy nobody would write proves nothing. These were withdrawn rather than +counted, because counting them would have manufactured findings. + +1. **image-pins / `x-image: nginx:latest`** — `x-` fields are inert in Compose. The label had no fact + behind it *either way*. Replaced with a positive control (a real untagged `image:` line → convicted). +2. **release-complete / a non-version heading on top of CHANGELOG.md** — `HEAD_RE.search` scans the + whole file, so the real release is still found and named `v0.130.0`. The gate is sound. +3. **app-row-dedup / one commented-out partial call** — `dashboard.html` has **two**; replacing one + left the other live. The gate was right and my decoy was half-built. +4. **one-register / a 3-column decoy row** — it convicted for a *structural* reason, not the one under + test. Rebuilt at the real 5-column shape, and the hole then reproduced. +5. **template-id, secret-markup, retrieval-promise, hub-copy / wrong content** — my first planted + files carried nothing those gates hunt for, so "passed" meant nothing. Rebuilt with real triggers. diff --git a/documentation/backlog/CLOSED-ITEMS.md b/documentation/backlog/CLOSED-ITEMS.md index f5348ef6..0224e621 100644 --- a/documentation/backlog/CLOSED-ITEMS.md +++ b/documentation/backlog/CLOSED-ITEMS.md @@ -26,8 +26,9 @@ --- -| **R-404** | **DECISION — should a documents-only push be subject to the golden-currency gate? RULED 2026-09-01: NEITHER option as framed. Block the push that can create the debt; notify the push that cannot.** The two options on the table were *narrow the gate* and *leave it and build a waiver*, and both were wrong for the same reason: they argued about the GATE, and the gate was never the problem. **The DIAGNOSIS, measured from live source, is that the check was aimed at the wrong repository.** `golden_currency_gate.py` never looks at 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, which is correct for a standing invariant and wrong as a push gate. Meanwhile `controller_gates.py` had NO golden-currency entry, so **the repo where a release happens never checked, and the repo that cannot create the debt enforced it on every push.** 18 of the last 24 pushes here touched no code — MEASURED, and the classifier agrees exactly — most of them for a structural reason: the controller's code is 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.** Six of those 18 were bake records — **the push that PAYS the debt is itself documents-only, so the gate was blocking its own cure.** **WHY NOT THE WAIVER** the gate's own docstring prescribes: that clause was written for *a release nobody wants a golden for*. The case that actually occurred (R-417) was *a release we did want a golden for, on a night the runbook forbade baking*. A waiver would have recorded a lie. **SHIPPED:** `scripts/push_scope.py` (allow-list; every uncertainty answers `code`), a fifth `exemptible` field in `repo_gates.py` + `--scope`, a new **ADVISORY** verdict printed in its own block, the pre-push hook reading git's stdin, the same rule in CI from the push event payload, and `felhom-controller/controller/scripts/golden_notice.py` — a NON-BLOCKING notice at the moment a release is committed. **The gate's own logic, exit codes and wording are byte-identical**; only the consequence changed. The exemption is ONE gate wide and `TestR3`/Scenario C pins it, red-proved by widening it. Proven live on the real hook: docs+debt → ADVISORY, pushed; code+debt → refused; docs+debt+a second gate → refused for that gate alone | **CLOSED 2026-09-01** — ruled and shipped | -| **R-417** | **A drill night that forbids baking a golden made `golden_currency_gate.py` red, so pushing the drill's own evidence needed `--no-verify` — the very signal CI e-mails about.** Measured 2026-09-01: five consecutive felhom.eu CI runs red (jobs 469/470/471/473/476), all mine, all on step 3 `Run the gate entry point`; job 478 green the moment the golden-0.232.0 evidence was committed. Cause confirmed by isolation — moving that directory aside reproduces exit=1, restoring it gives exit=0. **The gate was right every time**: 0.231.0 and 0.232.0 were released with no golden carrying them. **CAUSE REMOVED, not worked around** (R-404): a drill's pushes are documents-only, so the conviction now prints as a loud ADVISORY and the push proceeds — in the hook AND in CI, so a drill night no longer produces red runs indistinguishable from real ones. The expectation is now written where the next drill author reads it (`documentation/runbooks/target-selection.md`), which is the half I had left out. | **CLOSED 2026-09-01** — by R-404 | +| **R-419** | **`observations_gate.py` accepted an observation whose body merely CONTAINED the string `NOT-A-FINDING`, even in prose disclaiming it.** Found by accident on 2026-09-01 when a planted test observation reading *"it carries no `FILED:` and no `NOT-A-FINDING:` marker"* was reported `OK 1. NOT-A-FINDING` and a real push went green over an unfiled finding. The gate's whole job is to force an explicit choice, and a sentence disclaiming the choice counted as making it. **FIXED 2026-09-01:** a marker must now start a line or follow a sentence boundary, and inline code spans are stripped before matching — a marker inside backticks is being talked about, never used. | **CLOSED — FIXED + PINNED** (2026-09-01, R-421 sweep) | verified in BOTH directions: the decoy and a backticked mention are convicted; a real `**FILED: R-419**` and a real `**NOT-A-FINDING: ...**` still pass. Decoy kept in `felhom.eu/scripts/test_gate_decoys.py` | +| **R-404** | **DECISION — should a documents-only push be subject to the golden-currency gate? RULED 2026-09-01: NEITHER option as framed. Block the push that can create the debt; notify the push that cannot.** The two options on the table were *narrow the gate* and *leave it and build a waiver*, and both were wrong for the same reason: they argued about the GATE, and the gate was never the problem. **The DIAGNOSIS, measured from live source, is that the check was aimed at the wrong repository.** `golden_currency_gate.py` never looks at 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, which is correct for a standing invariant and wrong as a push gate. Meanwhile `controller_gates.py` had NO golden-currency entry, so **the repo where a release happens never checked, and the repo that cannot create the debt enforced it on every push.** 18 of the last 24 pushes here touched no code — MEASURED, and the classifier agrees exactly — most of them for a structural reason: the controller's code is 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.** Six of those 18 were bake records — **the push that PAYS the debt is itself documents-only, so the gate was blocking its own cure.** **WHY NOT THE WAIVER** the gate's own docstring prescribes: that clause was written for *a release nobody wants a golden for*. The case that actually occurred (R-417) was *a release we did want a golden for, on a night the runbook forbade baking*. A waiver would have recorded a lie. **SHIPPED:** `scripts/push_scope.py` (allow-list; every uncertainty answers `code`), a fifth `exemptible` field in `repo_gates.py` + `--scope`, a new **ADVISORY** verdict printed in its own block, the pre-push hook reading git's stdin, the same rule in CI from the push event payload, and `felhom-controller/controller/scripts/golden_notice.py` — a NON-BLOCKING notice at the moment a release is committed. **The gate's own logic, exit codes and wording are byte-identical**; only the consequence changed. The exemption is ONE gate wide and `TestR3`/Scenario C pins it, red-proved by widening it. Proven live on the real hook: docs+debt → ADVISORY, pushed; code+debt → refused; docs+debt+a second gate → refused for that gate alone | **CLOSED 2026-09-01** — ruled and shipped | full text and the ruling: `git show 1e6c387:documentation/backlog/CLOSED-ITEMS.md`; the diagnosis is in `CONTEXT.md` and `scripts/CHANGELOG.md`; `scripts/push_scope.py` + `test_repo_gates_scope.py` | +| **R-417** | **A drill night that forbids baking a golden made `golden_currency_gate.py` red, so pushing the drill's own evidence needed `--no-verify` — the very signal CI e-mails about.** Measured 2026-09-01: five consecutive felhom.eu CI runs red (jobs 469/470/471/473/476), all mine, all on step 3 `Run the gate entry point`; job 478 green the moment the golden-0.232.0 evidence was committed. Cause confirmed by isolation — moving that directory aside reproduces exit=1, restoring it gives exit=0. **The gate was right every time**: 0.231.0 and 0.232.0 were released with no golden carrying them. **CAUSE REMOVED, not worked around** (R-404): a drill's pushes are documents-only, so the conviction now prints as a loud ADVISORY and the push proceeds — in the hook AND in CI, so a drill night no longer produces red runs indistinguishable from real ones. The expectation is now written where the next drill author reads it (`documentation/runbooks/target-selection.md`), which is the half I had left out. | **CLOSED 2026-09-01** — by R-404 | five red CI jobs 469/470/471/473/476, green at 478; reproduced by isolation (`documentation/audits/AUDIT-gate-decoys-2026-09-01.md` records the technique) | | **R-361** | **The pre-restore safety dump overwrote the app's own DB dump, and the comment beside it said it could not.** Shipped in controller v0.221.0 (+v0.221.1). Evidence: `audits/DRILL-r361-2026-08-22/evidence/`. **Reasoning kept:** *`DumpOne` writes `-.sql` — the app's canonical dump, the name the replay loop matches EXACTLY — so nothing else may ever be written to it.* The fix is a DESTINATION, not a rename: `DumpOneTo` takes the final path and derives its own `.tmp` from it, so neither the destination nor the scratch file can collide with a nightly dump running beside it. **`DumpOne`'s signature did not move** — it has callers outside this concern. **The manifest no longer lists the undo copies:** every consumer of `Manifest.DBDumps` was grepped and named — three, all inside `recovery_unit.go`, none reading it for recovery. **AND THAT CHANGE MADE ANOTHER UNREACHABLE:** a stable `db_dumps` let `CaptureRecoveryUnit`'s already-current early return fire, and the undo-copy prune sat after it — four copies on disk against a cap of three, counted live. The prune now runs ABOVE the check; it is housekeeping on the dump directory and is independent of whether the manifest needs rewriting. **PROVEN LIVE the only way it can be:** the canonical dump's sha256, unchanged across a restore — `docmost` `5d35678349bb…`, `bookstack` `7837aa5de295…`, both byte-identical before and after. A test asserting merely that the undo copy exists passes just as well when the app's backup was destroyed. | **CLOSED — SHIPPED + PROVEN-LIVE** (controller v0.221.1, 2026-08-23) | full text: `git show a8caa0fdde7c:documentation/backlog/OPEN-ITEMS.md` | | **R-379** | **The pre-restore undo copy was valid, was named to the customer, and no product action could apply it.** Shipped in controller v0.220.0 (+v0.220.1, v0.220.2). Evidence: `audits/DRILL-r379-rollback-2026-08-22/evidence/`. **Reasoning kept:** *R-379 and R-380 were ONE failure with ONE fix — both ended with a half-restored database and the only difference was whether it looked broken.* **The undo set is matched on THE RUN'S OWN STAMP, never on the `pre-restore-` prefix** (four copies coexisted on one app in one afternoon; a prefix match replays an arbitrary older state) **and never just the first file** (a two-database app would have had one restored and the other left half-written). **The rollback RE-DISCOVERS the container** — the undo file is stable, the container is not: the DB-only start re-creates it, and v0.220.0's own first live run held an app for 30 s of `waitDBReady` against a dead id while its data was recoverable. **No unit test saw that: they all inject the import seam and never look at container identity.** | **CLOSED — SHIPPED + PROVEN-LIVE** (controller v0.220.1, 2026-08-22; docmost and bookstack both rolled back to byte-identical prior state) | full text: `git show 4e488321bfd1:documentation/backlog/OPEN-ITEMS.md` | | **R-380** | **A failed MariaDB replay left a partially-applied database behind an app reporting `health=healthy`.** Shipped in controller v0.220.0. Evidence: `audits/DRILL-r379-rollback-2026-08-22/evidence/13-step2-verify.txt`. **Reasoning kept:** **no engine flag closes this** — `--single-transaction` was added to the Postgres import and does make it all-or-nothing, but **MariaDB's DDL is not transactional**, so a partial apply there is unavoidable at the engine. The flag is a belt; the rollback is the fix, and this row must not be read as saying otherwise. Proven live: `bookstack`'s `migrations` table back at **102 rows**, the exact cell the defect was measured in. | **CLOSED — SHIPPED + PROVEN-LIVE** (controller v0.220.0, 2026-08-22) | full text: `git show 4e488321bfd1:documentation/backlog/OPEN-ITEMS.md` | @@ -235,8 +236,8 @@ Compressed here to title, shipping version, evidence, and the sentences that sta hub, never in a doc. - **A doc comment claiming a guard exists is why nobody looks for the missing guard** (R-360). Correct such a sentence in place; do not delete it. -| **R-399** | **How deep should the off-site integrity check go — Viktor ruled full depth.** Shipped in controller **v0.228.0**, 2026-08-31. Evidence: `felhom-controller/REPORT.md` (v0.228.0) — restic argv observed from the guest at both depths on `demo-hp`. **Reasoning kept:** *the structure check does not detect a size-preserving pack corruption — measured 2026-08-30, plain `restic check` reported `no errors were found` and exited 0 over a damaged pack that every read-data form caught. That is the reason for the default and it is what should stop anyone turning it back down to save four seconds.* *An empty value means "not configured", therefore the default; `off` is the off token, because a setting with no off switch is not a setting.* *A malformed value falls back to the DEFAULT, never to structure — falling back to structure would silently remove the protection on a typo, which is R-357's shape.* **Superseded by R-401** for anything about a large store. Original text: `git show 300d7e8:documentation/backlog/OPEN-ITEMS.md` | -| **R-400** | **A third of the debug page posted to endpoints that did not exist — and three of the seven fetched on page LOAD.** Shipped in controller **v0.228.0**, 2026-08-31. 24 referenced / 17 dispatched became 18 / 18. `backup/crossdrive` implemented (proven live: real Tier-2 copies for three apps); `backup/infra`, `hub/infra-push`, `dr/infra-status`, `storage/watchdog-status` and both `storage/simulate-*` deleted with their panels and JavaScript. **Reasoning kept:** *implement or delete FIRST, register the gate SECOND — a registered-but-failing gate refuses every push.* *Keep `handleDebugAPI`'s exact-match switch with its `NotFound` default; a prefix match would have made the defect invisible instead of merely silent.* *A panel left behind renders nothing forever, which is how this class hides.* *A debug control that simulates or mutates storage state is deleted unless a live need can be shown — that is where drives get unenrolled and data gets stranded.* Enforced by `controller/scripts/debug_route_gate.py`, both directions, red-proofed. Original text: `git show 300d7e8:documentation/backlog/OPEN-ITEMS.md` | +| **R-399** | **How deep should the off-site integrity check go — Viktor ruled full depth.** Shipped in controller **v0.228.0**, 2026-08-31. Evidence: `felhom-controller/REPORT.md` (v0.228.0) — restic argv observed from the guest at both depths on `demo-hp`. **Reasoning kept:** *the structure check does not detect a size-preserving pack corruption — measured 2026-08-30, plain `restic check` reported `no errors were found` and exited 0 over a damaged pack that every read-data form caught. That is the reason for the default and it is what should stop anyone turning it back down to save four seconds.* *An empty value means "not configured", therefore the default; `off` is the off token, because a setting with no off switch is not a setting.* *A malformed value falls back to the DEFAULT, never to structure — falling back to structure would silently remove the protection on a typo, which is R-357's shape.* **Superseded by R-401** for anything about a large store. Original text: `git show 300d7e8:documentation/backlog/OPEN-ITEMS.md` | **CLOSED — SHIPPED** (controller v0.228.0, 2026-08-30) | controller v0.228.0; `documentation/tests/r359-integrity-2026-08-30/` | +| **R-400** | **A third of the debug page posted to endpoints that did not exist — and three of the seven fetched on page LOAD.** Shipped in controller **v0.228.0**, 2026-08-31. 24 referenced / 17 dispatched became 18 / 18. `backup/crossdrive` implemented (proven live: real Tier-2 copies for three apps); `backup/infra`, `hub/infra-push`, `dr/infra-status`, `storage/watchdog-status` and both `storage/simulate-*` deleted with their panels and JavaScript. **Reasoning kept:** *implement or delete FIRST, register the gate SECOND — a registered-but-failing gate refuses every push.* *Keep `handleDebugAPI`'s exact-match switch with its `NotFound` default; a prefix match would have made the defect invisible instead of merely silent.* *A panel left behind renders nothing forever, which is how this class hides.* *A debug control that simulates or mutates storage state is deleted unless a live need can be shown — that is where drives get unenrolled and data gets stranded.* Enforced by `controller/scripts/debug_route_gate.py`, both directions, red-proofed. Original text: `git show 300d7e8:documentation/backlog/OPEN-ITEMS.md` | **CLOSED — SHIPPED** (controller v0.228.0, 2026-08-30) | controller v0.228.0; `controller/scripts/debug_route_gate.py` + its decoy in `test_gate_decoys.py` | | **R-102** (was **C9-F4**) | **Tier-2 wrote a full `recovery-unit/` mirror on every run and no code path read it** - `RecoveryUnitPath` joined a hard-coded `backups/primary/`, so in the one failure Tier-2 exists for the surviving copy was unopenable. Shipped in controller **v0.229.0**: four unit-directory-relative path primitives in `appbackup`, `RestoreFromRecoveryUnitAt(stack, unitDir)`, `RestoreTier2Unit`. Evidence: `audits/DRILL-r102-tier2-unit-2026-08-31/`. **Reasoning kept:** *THE SOURCE MOVES; THE DESTINATION DOES NOT* - `unitDir` changes only where a unit is READ from; data still lands in the live volumes and the live database container, resolved by `GetAppDrivePath` exactly as the capture is, because a restore that also relocated an app's data would be a migration wearing a restore's label. And: *a directory that exists is not a package* - the Tier-2 route refuses fail-closed unless the mirror carries a parseable manifest. | **CLOSED 2026-08-31 - controller v0.229.0, PROVEN-LIVE with the primary unit moved aside** (`07` §8 row 3b -> PROVEN, 28.65 s; row 4 stays PARTIAL - the drive-loss JOURNEY is still unexercised) | full text: `git show 1623a4d5b5d5:documentation/backlog/OPEN-ITEMS.md` | | **R-103** (was **C9-F1b**) | **The Tier-2 no-coverage refusal named the working action but did not route to it** - it sent the customer to a button on another page for data that R-102 made restorable on the page they were already looking at. Shipped in controller **v0.229.0**: `POST /backup/tier2/unit-restore` and „Teljes visszaállítás a másolatból” on the Tier-2 row. Evidence: `audits/DRILL-r102-tier2-unit-2026-08-31/`. **Reasoning kept:** *a destructive operation reached from a non-destructive surface must carry the difference in the CONFIRM, not in the label* - the two actions stay two buttons because they are two promises, and the confirm names the copy's date, differently when that date is only an attempt clock (R-101). And: *two questions, two predicates* - `CanRestore()` was NOT widened to cover the unit; one predicate answering two questions is R-356, which refused 40 running apps for months. And: `tier2UnitNotCoveredMsg` was NOT deleted, because it is appended where the FILE restore ran and is still exactly true of it. | **CLOSED 2026-08-31 - controller v0.229.0, PROVEN-LIVE** (the refusal now carries `tier2UnitAvailableMsg`, verified at the endpoint) | full text: `git show 1623a4d5b5d5:documentation/backlog/OPEN-ITEMS.md` | | **R-87** | **The restic tier was never restore-tested — RE-SCOPED by its own spike to "prove the off-site snapshot still CONTAINS a recoverable unit".** Shipped in controller **v0.231.0** + hub **v0.110.0**. Evidence: `tests/r87-offsite-proof-2026-08-31/`; reasoning: `audits/SPIKE-restic-restore-test-2026-08-31.md`. **Reasoning kept:** *The weekly check proves the stored bytes are the bytes we stored; it cannot tell us we stored the WRONG thing.* *The acceptance rule has TWO parts and the obvious one is a trap — "everything declared is present" passes a hollow unit, which is the shape it exists to catch.* *The expectation comes from INSIDE the unit, never the live box: the snapshot may predate the app's shape.* *The volume half is an EXISTENCE check and not a name match — the naming held on all eight real units, but "held on eight" is not "derivable" (R-355), and half a rule that is true beats a whole rule that is invented.* *THREE outcomes: pass, fail, and cannot-judge — collapsing the third hides a gap in one direction and alarms on our own blind spot in the other.* *It proves the snapshot CONTAINS a recoverable unit; it does NOT prove a restore puts data back into a running app — §8 matrix row 4 was deliberately NOT moved.* *The proof's scratch is a SEPARATE root because the job deletes on every path, and sharing the customer's root would mean a nightly job deleting a copy the customer is looking at.* | **CLOSED 2026-08-31 — SHIPPED + PROVEN-LIVE** (controller v0.231.0, hub v0.110.0) | full text: `git show 303129e:documentation/backlog/OPEN-ITEMS.md` | diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index cdf50856..6dc2d2ef 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -593,8 +593,14 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server` | **R-413** | **R-87's proof caught a naturally-produced hollow snapshot, end to end, unattended — the validation yesterday's session could only do with a declared hand-built fixture.** 2026-08-31 soak, demo-hp. After R-412's chain left `opengist`'s newest off-site snapshot hollow, the nightly proof rotated to it and returned **`verdict:"fail"`, `reason:"volumes_expected_none_captured"`, missing `opengist_data`**, logged *"READABLE AND EMPTY — the store is not damaged; the backup does not contain this app's data"*, and pushed **one** `offsite_proof_empty` at severity `error`. The four apps ahead of it in the rotation all passed, so the discrimination is real and not a constant fail. **This is recorded as a row rather than only as a report line because it upgrades a claim:** the capability map's R-87 row cites a CONSTRUCTED failing case; it can now cite a natural one. | **CLOSED 2026-08-31 — the claim it upgrades is recorded** | R-87, R-412 | Nothing to build. When the capability map is next touched, cite this instead of the constructed case. | CC | | **R-416** | **`closed_register_gate.py` still has no within-register duplicate-id rule.** R-406 closed by renumbering the only collision (R-133 → R-415), so the register is clean today and nothing stops the next one. The rule was deliberately NOT added in the same commit: with its only real subject removed, the red-proof would have had to be a planted fixture rather than the live defect, and this project's own standard is that a guard ships with a proof against something real. **Now that the register is clean it can be added safely** — a fresh duplicate would be the first thing it ever sees. | **OPEN — LOW** | R-406, R-405 | Add a third rule to `closed_register_gate.py`: no `R-` id may appear twice within either register. Ship it with a planted red-proof, and note that suffixed ids (R-88a/R-88b, R-209/R-209a) are distinct and must NOT be convicted. | CC | | **R-418** | **`repo_gates.py`'s docstring listed ELEVEN gates while THIRTEEN were registered** — `one-register` (R-369) and `closed-register` (R-405) ran on every push from 2026-08-24 to 2026-09-01 while being documented nowhere. Found while adding the `exemptible` field. This is the drift `felhom-controller/.claude/rules/gates.md` already warns about in its own runner (*"it has already drifted once — it said seven while nine were registered"*), recurring in the sibling repo that the warning does not load for. FIXED in the same commit: all thirteen listed, plus a line saying the table is the list and this is a pointer to it. **Nothing enforces the correspondence** — a gate added tomorrow drifts again, and the fix is a test that compares the docstring list against `len(GATES)`. | **OPEN — the enumeration is fixed, the mechanism is not** | -| **R-419** | **`observations_gate.py` accepts an observation whose body merely CONTAINS the string `NOT-A-FINDING`, even in prose explaining that it carries no marker.** MEASURED 2026-09-01 and by accident: a deliberately unmarked observation planted to make the gate convict during Scenario C's live validation was reported `OK 1. NOT-A-FINDING`, because the sentence *"it carries no `FILED:` and no `NOT-A-FINDING:` marker"* satisfied the substring test. **The gate's whole job is to force an explicit choice, and a sentence disclaiming the marker counted as the marker.** This is the `strings.Contains` class the AST-walk lesson (R-408) is about, one repo over. Not a hypothetical: it silently passed a real push of mine before I noticed. Fix: require the marker at the START of a line or item, not anywhere in the body. | **OPEN** | | **R-420** | **`controller_gates.py` could not express a NON-BLOCKING gate before 2026-09-01** — every registered gate's non-zero exit failed the run, so the only way to add a check was to give it the power to refuse a push. That is the wrong trade for a notice that must fire at the moment a release is committed, when the golden legitimately cannot exist yet. **The capability was added rather than the notice compromised** (a fifth `blocking` field, False for exactly one gate; the felhom.eu runner already had the shape from its `--scope` work). Recorded because the ABSENCE was invisible: nobody had wanted a non-blocking gate before, so nothing said it was impossible. `felhom.eu/scripts/repo_gates.py` still has no `blocking` field — it has `exemptible`, which is a different idea (scope-dependent, not permanent). If a permanently-advisory gate is ever wanted there, it needs the same addition. | **OPEN — noted, not needed yet** | +| **R-421** | **THE CLASS: an instrument that matches a LABEL rather than the fact it names — five instances, every one found by accident.** R-410 (a `mkdir` turned the release gate green), R-400 (seven debug controls answering nothing), R-378 (a status word inside a sentence), R-419 (a phrase inside prose, including prose saying the marker was ABSENT), R-94 (a test comparing a constant to itself). **The gates are the machinery that enforces everything else in this project, and they were the one part nothing had ever checked.** The 2026-09-01 decoy sweep read all 29 scripts and fooled **16**. Ten were fixed the same day; four remain with rows (R-422..R-425); six could not be given a plausible decoy and are named. **The shapes, so the next one is cheap to recognise:** (1) name-for-fact — it matches a path or directory NAME while the fact lives inside the file; (2) substring-for-field — it matches a token anywhere in a body instead of in the field that carries it; (3) declaration-for-reachability — it checks a thing is declared, not that it RESOLVES; (4) constant-for-measurement — it compares a value against itself. **The single largest cause was mundane:** eight gates set their SCOPE with `os.listdir` (one level), so every one was green and correct today and would have gone blind the moment anyone added a subdirectory. `decoy_coverage_gate.py` now refuses a new gate that ships without a decoy. | **OPEN — the class row; it stays open as the place the next instance is recorded** | +| **R-422** | **`reuse_refs_check.py` only checks citations whose extension is one of `go py html css yml yaml sh`.** A cited `.md` path that does not exist is invisible — MEASURED 2026-09-01: `documentation/architecture/99-does-not-exist.md` added to `REUSE.md` passed, while the `.go` control was correctly convicted. REUSE.md and the CLAUDE.md files cite `.md` paths routinely, so this is the common case, not an exotic one. Fix: widen `PATH_RE`, then walk the false positives it produces across all four repos — that pass is the work, not the regex. The decoy is kept in `scripts/test_gate_decoys.py` asserting TODAY's behaviour, so the day this is fixed the test fails and is updated deliberately. | **OPEN** | +| **R-423** | **`site_gates.py` checks a hardcoded `PAGES` list of seven files; a new page is not scanned at all.** MEASURED 2026-09-01: a new `website/decoy-page.html` carrying an emoji and no nav or analytics passed. `felhom.eu/CLAUDE.md` already tells the author to add new pages to the list by hand — which is the R-410 shape written down as a procedure. Fix: glob `website/*.html` and rethink the per-page exemptions (`ANALYTICS_EXEMPT` and friends) so the list becomes a list of EXCEPTIONS rather than a list of what is checked. | **OPEN** | +| **R-424** | **`one_register_gate.py`: a real defect parked under the roadmap state `idea` is invisible to it.** MEASURED 2026-09-01 with a correctly-shaped 5-column row. This is **declared** in the gate's own docstring as residual hole 1 — *"the state column is a human judgement, and a defect written under `idea` looks exactly like a proposal to this gate"* — so it is honest, not hidden. Recorded here because a hole declared only in a docstring is not in the register, which is this project's own standing rule. No cheap fix: distinguishing a defect from a proposal mechanically is the thing the gate cannot do. | **OPEN — declared, not hidden; recorded so it is not re-derived** | +| **R-425** | **`offbox_rename_gate.py` scans a fixed three-entry `FILES` list.** MEASURED 2026-09-01: `NAS-mentés` in a new `backups_offbox_extra.html` passed. The scope was correct when written and silently narrows every time the feature grows a file. Fix: scan the offbox feature's files by pattern, or assert the FILES list against a discovered set so a new file fails until it is classified. | **OPEN** | +| **R-426** | **The decoy-coverage exemption list — 20 registered gates that ship WITHOUT a decoy test, each named.** `scripts/decoy_coverage_gate.py`'s `EXEMPT` map is debt, and this row owns it so it lives in the register and not only in a Python literal. **Four kinds:** (a) genuinely covered in the 2026-09-01 sweep but not yet moved into a suite — `hub-copy`, `instructions`, `docker-v`, `image-pins`; (b) blocked by an open hole and therefore un-assertable as rejecting — `site` (R-423), `one-register` (R-424), `offbox-rename` (R-425); (c) shared scripts whose decoy lives in `felhom.eu` and is counted there — `reuse-refs`, `instructions`, `observations` in the controller and agent runners; (d) **no plausible decoy constructed yet** — `hostinstall`, `wire-contract`, `due-checks`, `published`, `image-resolvable`, `volume-persistence`. Group (d) is the honest unknown: six gates whose soundness is UNTESTED, not established. **The list is green today and shrinks; a NEW gate with no decoy fails immediately.** | **OPEN — 20 names; group (d) is six untested gates** | +| **R-427** | **`closed_register_gate.py` checks ONE direction only: an open word in a CLOSED row. The mirror — a CLOSED verdict on a row still sitting in `OPEN-ITEMS.md` — is unchecked, and there are TWELVE.** MEASURED 2026-09-01 during the decoy sweep, by reading the leading verdict of every open row with the gate's own predicate: **R-385, R-387, R-341, R-378, R-405, R-88a, R-88b** read unambiguously closed; **R-123, R-190, R-352** read `PARTLY CLOSED` / `MITIGATION SHIPPED` and almost certainly belong where they are. **The rows were NOT moved by this session** — telling a finished row from a partly-finished one is a judgement, and R-378 is itself the record of what happens when a machine makes that judgement on a substring (six still-open rows moved out of the register). **This is R-405's finding mirrored:** that row exists because R-87 sat in the CLOSED file while its state read READY, and the gate written for it looks only the way it was bitten. Fix: the same leading-verdict predicate applied to `OPEN-ITEMS.md`, reporting rather than convicting until the twelve are adjudicated by a person — a gate registered while twelve rows fail it would refuse every push. | **OPEN — 12 rows named; the adjudication is Viktor's, the gate is mine** |