docs+gate: instruction files cannot silently regrow (R-229)
gates / gates (push) Successful in 7s

New shared scripts/instructions_gate.py, registered in controller_gates.py and
agent_gates.py, never copied into a sibling repo (the reuse_refs_check.py
precedent). 20 fixture tests, all asserting the effect: exit code AND that the
message names the file and the reason.

It is a consistency gate, not a budget gate, and the failure message says so. A
/context reading measured the instruction files at 15k tokens against 869k free in
a 1M window -- space is not the constraint, and a future reader must not re-derive
the wrong reason. The 200-line ceiling is adherence guidance; a file nobody can
hold in their head is where contradictions hide, and five were found here.

Checks run against effective text (HTML comments stripped, because they are
stripped before injection): the line ceiling; every .claude/rules/*.md declares
paths: or an explicit unconditional: true; no component version literal; no
TEMPORARY block carrying a past date; and the workspace-root CLAUDE.md is
byte-identical to its versioned copy -- the live file sits outside any git repo,
so that copy is its only version-controlled record.

Two traps recorded so they are not reintroduced: a bare \d+\.\d+\.\d+ matches the
first three octets of every IPv4 (the gate excludes dotted quads, or it fails on
192.168.0.180 in the agent's own file); and unconditional: true is NOT a Claude
Code feature but this project's own marker.

Workspace-root CLAUDE.md 208 -> 182 lines (142 effective), copy kept identical.
The nine-instance invariant table moved into the felhom-testing skill, which
triggers when writing or reviewing a test; all three directive bullets stayed in
the core. felhom.eu/CLAUDE.md got surgical corrections only and is knowingly still
over the ceiling at 227 effective lines -- closing it needs the restructure R-229
defers, said plainly rather than quietly absorbed.

CONTEXT.md gains standing ruling S-35. OPEN-ITEMS.md gains R-229.

Docs only -- no Go, no version bump, nothing built or deployed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJc8sAGRWmavP3rMtdpkr2
This commit is contained in:
2026-08-06 09:38:52 +02:00
parent d30c2a51ed
commit c21bcf84f7
10 changed files with 1125 additions and 333 deletions
+155 -146
View File
@@ -1,160 +1,169 @@
# REPORT — R-196 / R-204 item 2 (hub v0.95.0), 2026-08-05
# REPORT — instruction-file rightsizing (core + path-scoped rules), 2026-08-06
**A re-issue no longer marks a healthy escrow stale.** One behaviour change, one register closed, and
the coverage claim proved rather than assumed. The controller's half of R-204 (items 1 and 3) is
`felhom-controller` v0.198.0.
**Docs and gate only. No Go changed, no version bumped, no image built, nothing deployed, no
customer machine touched.** One read-only command ran against live hardware (`ssh demo-hp "qm list"`),
permitted by the task for exactly one purpose.
## 1. Baselines, re-read on arrival
**The headline is the contradiction count, not a token saving.** A `/context` reading measured the
instruction files at **15k tokens against 869k free** in a 1M window. Space was never the constraint;
five stale or conflicting facts were.
| Repo | Expected | Found |
|---|---|---|
| `felhom.eu` | `2a7ac03c4726` / hub v0.94.0 (deployed `felhom-hub:0.94.0`) | **exact match**, tree clean, `HEAD == origin/main` |
---
**§3.2's landmark had DRIFTED, and the drift changed the work.** The task described
`offsite.go:222-231` under a known-consequence comment saying the mark was made on a false premise.
That comment had already been rewritten by the R-196 comment-correction commit, and the version on
`main` gave a **non-false** ground for the mark: *"the box's re-apply may mint a fresh repository
password (it does exactly that whenever `<DataDir>/offbox/repo_password` is absent — the
guest-rebuild shape)"*. So the question was no longer "delete a comment's lie" but "is the shape it
guards actually covered elsewhere?" — which is Scenario D, and §8.2 says to stop and report if it is
not. It is; §3 below is the evidence.
## 1. Baselines
## 2. What changed
`offsite.ReissueCredentials` no longer calls `MarkEscrowStale` and no longer emits the `escrow_stale`
event. **`offsite_reissued` is untouched** and still fires on every re-issue. The known-consequence
comment is rewritten to record what was done, when, and why — with the disagreement below stated in
it rather than absorbed.
**What the mark actually cost, established mechanically rather than asserted** (this is why it was a
blocker and not a nit):
1. `stale_at` set → `GetEscrowStatusForCustomer` **withholds** `restic_pw_sha256` from the report ACK.
2. With no hash, the controller's SLICE-3 auto-confirm returns early and cannot flip
`pending → escrowed`.
3. `OffboxRunnable() = OffboxConfigured() && EscrowState == "escrowed"` → **every off-site backup
refused**, indefinitely, on a box whose repository key was never in doubt.
4. The customer is told to re-run the recovery ceremony — which mints a new recovery code and
supersedes the sealed blob. **During a recovery that is the one act that would have destroyed the
key just recovered.**
## 3. Scenario D — the evidence that the removed marking is covered
The mark was precautionary and aimed at ONE shape: a re-issue followed by a box that mints a fresh
repository password (the guest-rebuild shape, where `offbox/repo_password` is absent). That shape is
measured in two independent places, and **the mark was blinding one of them**:
- **Continuous, box-side — the real coverage.** `report.EscrowAutoConfirmer.reconcileEscrowed`
(controller) compares the ACK's sealed `restic_pw_sha256` against the box's CURRENT local repo
password on **every report ACK**, raising the stale flag, the customer card and the
„create a new recovery code" CTA on a mismatch. That is a **measurement**, not a guess, and it is
continuous rather than edge-triggered. Pinned by the controller's
`TestEscrowStale_MismatchWarnsOnceAndFlags` — re-run green this session.
**And step 1 above was blinding it:** a stale flag empties the very hash that comparison needs, so
the box could only reach the hash-LESS branch and report *"the hub's current blob carries NO
password hash"* — which is false. Removing the mark restores the true signal.
- **Edge-triggered, hub-side.** R-197's `offsite_repo_key_changed` fires from
`api.maybeEmitRepoKeyChanged` on a proven hash difference across a supersession and pages the
operator. **Red-proved:** removing the `maybeEmitRepoKeyChanged` call from `handleHostEscrowPut`
made `TestEscrowPut_ChangedRepoKey_RaisesSignal` fail with *"the repository key demonstrably changed
and NO signal was raised"*, while the two silence tests stayed green.
**Disagreement recorded, per the R-96 standing rule.** Scenario D as written asks that a real key
change also *"mark the escrow stale"*. **It must not, and nothing was changed to make it.** The hub
learns of a real change at the instant a supersession **seals the new password** — i.e. when the
escrow is at its freshest. Marking it stale there would ask the customer for a ceremony to fix the
ceremony that just ran. The correct consequence at that instant is the operator alarm, which is
exactly what R-197 already does. This is recorded in the code comment, the CHANGELOG and OPEN-ITEMS,
not only here.
## 4. `MarkEscrowStale` is kept with no caller — deliberately
Per task §5 it was not to be modified, and it is not deleted either. The `stale_at` flag remains live
and correct — read by the ACK, the operator config card and the PBS-DR view — and the right way to
set it is a **future EVIDENTIAL caller** that has measured a key change rather than guessed at one.
Its doc comment now says so plainly instead of naming a caller that no longer exists, and
`TestEscrowStaleMechanism_StillWithholdsAndClears` keeps the mechanism from decaying to inert while
nothing writes it (the seam-built-but-never-wired shape, in reverse).
The schema comment and `EscrowStatus.Stale`'s comment were corrected the same way — each of the three
previously asserted a writer that is now gone.
## 5. Files modified
| File | Change |
|---|---|
| `hub/internal/offsite/offsite.go` | the pessimistic `MarkEscrowStale` + `escrow_stale` event removed; comment rewritten to record the change, the coverage and the disagreement |
| `hub/internal/offsite/offsite_test.go` | `TestReissue_InvalidatesEscrow` **replaced in place by its exact inverse** `TestReissue_DoesNotMarkAHealthyEscrowStale`; new `TestEscrowStaleMechanism_StillWithholdsAndClears` |
| `hub/internal/store/store.go` | three comments corrected (`MarkEscrowStale`, the `stale_at` schema note, `EscrowStatus.Stale`) — each had named a writer that no longer exists |
| `manifests/hub.yaml` | image tag `0.94.0``0.95.0` |
| `hub/CHANGELOG.md`, `CONTEXT.md`, `STATUS.md`, `documentation/…` | v0.95.0 entry; ruling **S-32**; the register and architecture updates below |
**Commits on `main`:** `d1a8edb` (behaviour + tests + comments) · `5c7d671` (CHANGELOG) ·
`975a690` (manifest bump).
**Deploy:** built + pushed `felhom-hub:0.95.0`, bumped `manifests/hub.yaml`, pushed, then a
**deliberate ArgoCD hard-refresh + sync** (auto-sync stays off; no `kubectl set image` anywhere).
Result: app `felhom` **Synced / Healthy**, `deploy/hub` rolled out, running image
`gitea.dooplex.hu/admin/felhom-hub:0.95.0`, startup log clean (offsite provisioning, pool-box checker,
PBS-DR reconciler and all six host checkers initialised; `Listening on :8080`).
## 6. Tests and red-proofs
Green gate: `cd hub && go build ./... && go vet ./... && go test ./...`**rc=0**.
`python3 scripts/repo_gates.py --fast`**all five gates OK**.
| Test | Result | Red-proof — what was mutated | Outcome |
| Repo | `main` @ start | Clean | Note |
|---|---|---|---|
| `TestReissue_DoesNotMarkAHealthyEscrowStale` (C) | PASS | restored the pessimistic `MarkEscrowStale` block in `ReissueCredentials`, exactly as it was | **FAILED***"a re-issue marked a HEALTHY escrow stale…"* |
| `TestEscrowStaleMechanism_StillWithholdsAndClears` | PASS | same mutation | **stayed GREEN** — correctly: the mutation restores a *caller*, not a break in the mechanism. That split is the evidence Scenario C's assertion is about the caller and not the flag. |
| `TestEscrowPut_ChangedRepoKey_RaisesSignal` (D) | PASS | removed the `maybeEmitRepoKeyChanged` call from `handleHostEscrowPut` | **FAILED***"the repository key demonstrably changed and NO signal was raised"* |
| `TestEscrowPut_UnchangedRepoKey_Silent`, `TestEscrowPut_HashlessSupersession_NoSignal` | PASS | same | stayed green — the detector's silence branches are independent |
| controller `TestEscrowStale_MismatchWarnsOnceAndFlags` | PASS | — (cited as the continuous-coverage pin) | — |
| felhom-controller | `a62bb3874b25` | yes | matched spec |
| felhom-agent | `a2e914f683bd` | yes | matched spec |
| felhom.eu | `d30c2a51ed2a` | yes | matched spec |
| app-catalog-felhom.eu | `ee2c8102016a` | yes | **untouched** — 79 lines, already the target shape, cited as the model |
Scenario C asserts the **consequence** (the ACK still carries the hash, so auto-confirm can proceed)
rather than the mechanism (that a function was not called), because the hash is what the drill's
blockage actually turned on. It also asserts that `offsite_reissued` still fires — removing a false
alarm must not remove the true notice.
**One correction:** the workspace-root `CLAUDE.md` measured **16,642 B / 208 lines**, not the spec's
15,431 / 207 — it was edited at 08:32 that morning, after the spec was written. The other five files
matched exactly. Also structural: `/mnt/5_hdd/felhom.eu/git` **is not a git repository**, so the live
root `CLAUDE.md` is untracked; only its copy under `felhom.eu/` is version-controlled.
## 7. Live validation
## 2. Contradictions: 5 before → 0 after
**Per task §12 point 5, a live re-issue was NOT run, and must not have been on demo-hp** — it would
have been a credential rotation on the box holding the drill's evidence. Part 2 is proved by test and
by the deployment being live and healthy. The controller-side halves of R-204 were validated live and
are reported in `felhom-controller/REPORT.md`.
| # | What conflicted | Resolution |
|---|---|---|
| 1 | agent said demo-hp hosts drill VM `300`; controller said none was provisioned | **Measured live:** `qm list``300 drill-r50 stopped`. **felhom-agent was right.** `nodes.md:96` already said so correctly — both `CLAUDE.md` blocks became pointers, no new text needed |
| 2 | agent's `TEMPORARY` block (expired 2026-08-02) said felhom-pve was remote; controller said it was back on the LAN | both deleted; the audit holds the record. The gate now fails any past-dated TEMPORARY block |
| 3 | controller pinned `agent 0.93.0`, against the root file's own no-versions rule | every version literal removed from effective text in all four files |
| 4 | controller gave `demo-felhom` as the LAN *fallback* address as if it were the route | host tables removed from all three `CLAUDE.md` files → `nodes.md` |
| 5 | root file said memory held `(119 files)`; it holds 157 + the index | parenthetical deleted, not corrected — derivable, and it would go stale again |
## 8. Register and documentation
The sweep found **none beyond the five**.
- **`OPEN-ITEMS.md`** — **R-196 → CLOSED (hub v0.95.0)**; **R-204 → items 13 CLOSED, item 4 OPEN
(→ R-193)** with its dependency named. The header block is updated and states explicitly that
**R-202**, **the ~1.2 GB orphaned-ciphertext deletion** and **R-198's retention (still UNIT-PROVEN
ONLY — the second deliberate wipe is the next item)** all **remain open**, so nothing is presumed
closed by association. R-201 is recorded as PASSED. **R-204 is still the highest ID; nothing new
was minted.**
- **`architecture/00-capability-map.md`** — the recovery row now says three of the four crutches are
gone, names the fixes and their evidence, and states that **item 4 (R-193) is the one that remains**
and is why the row **keeps its "with a person present" qualifier**. The **R-199 back-pointer was
already present** on the adjacent key-recovery row (added when that row was last corrected), so it
needed no further action — verified, not assumed.
- **`architecture/07-backup-architecture.md`** — **new §7.0, "What a customer can and cannot do
ALONE"**: the four steps in a table with what each cost and its status, plus the honest current
answer. This is the section a future reader will use to answer the question.
- **`documentation/backlog/ROADMAP.md`** — R-196 and R-204 collapsed per the coupling rule.
- **`CONTEXT.md`** — new standing ruling **S-32**, which supersedes S-31's steps 25 and carries the
blinding mechanism, the fail-closed rule and the "no TTL" reasoning forward.
- **`STATUS.md`** — rewritten to **one screen** (191 → ~90 lines) per its own header. It also had a
corrupted, half-overwritten "What we're working on" section left from the drill session, which is
now gone. Next item stated as the retention drill.
## 3. Before / after (effective = HTML comments stripped, i.e. what the model receives)
**CI:** felhom.eu runs **154** (`5c7d671`, code) and **155** (`975a690`, manifest) — both success.
`--no-verify` was **not** used; the pre-push gate ran and passed on every push.
| File | before | after raw | after effective | gate |
|---|---|---|---|---|
| workspace-root `CLAUDE.md` | 208 ln / 16,642 B | 182 / 11,280 | **142 / 8,105** | pass |
| versioned copy | 208 / 16,642 | 182 / 11,280 | **142 / 8,105** | pass — `cmp` identical |
| `felhom-controller/CLAUDE.md` | 215 / 14,775 | 110 / 6,341 | **92 / 4,881** | pass |
| `felhom-agent/CLAUDE.md` | 216 / 15,554 | 205 / 13,619 | **173 / 11,491** | pass |
| `felhom.eu/CLAUDE.md` | 241 / 17,471 | 235 / 17,003 | **227 / 16,286** | **over — deferred** |
## 9. Observations — noticed, NOT acted on
**A controller session's instruction load: 31,417 → 12,986 effective bytes (59%).**
- **`allowedEventTypes` still lists `escrow_stale`**, which after this change has **no producer** in
either repo. It is inert rather than harmful; removing an allowlist entry is a behaviour change and
is out of this session's scope.
- `MarkEscrowStale` is now dead code by call-graph. Kept on purpose (§4 above) — but if a future
session's linter or cleanup pass proposes deleting it, the reason it exists is in its doc comment
and in the test that exercises it.
- `/` on DooPlex is at **86%** used — under the 90% abort line, but worth watching before large builds.
Figures are bytes, deliberately. The measured `/context` ratio shows a bytes/4 token estimate
understates the true cost by **1.591.90×**, so byte counts are the honest unit here.
**`felhom.eu/CLAUDE.md` is knowingly left over the ceiling.** Getting it under 200 needs the
core+rules restructure the spec explicitly forbade for reviewability, and the gate is registered only
in the controller and agent runners. Deferred as **R-229**, said plainly rather than quietly absorbed.
## 4. Files created / modified
**Created:** `felhom-controller/.claude/rules/{gates,ui-hungarian,backup-paths,agent-coupling}.md` ·
`felhom-agent/.claude/rules/health-checks.md` · `felhom.eu/scripts/instructions_gate.py` ·
`felhom.eu/scripts/test_instructions_gate.py` ·
`felhom.eu/documentation/audits/LEDGER-instruction-trim-2026-08-06.md`
**Modified:** the four `CLAUDE.md` files + the versioned copy · `controller_gates.py` ·
`agent_gates.py` · `skills/felhom-testing/SKILL.md` (gained the nine-instance table) ·
three `CHANGELOG.md` · `CONTEXT.md` (S-35) · `OPEN-ITEMS.md` (R-229)
## 5. Mechanism verification — done before relying on it
The whole design rests on three claims. Two were confirmed, one was **false**:
| Claim | Verdict |
|---|---|
| `.claude/rules/*.md` + `paths:` loads only on a matching file read | confirmed against the installed 2.1.222 build |
| HTML comments are stripped before injection | **confirmed empirically** — control (both markers plain → both reported) vs treatment (one commented → not reported, twice) |
| `unconditional: true` frontmatter | **NOT a product feature.** It is this project's own marker; the gate's docstring says so, so nobody hunts for it in the docs |
The HTML-comment test **failed its first red-proof** — an open-ended prompt returned one marker in
both arms, so the instrument was dropping a result silently. Rewritten as forced yes/no questions
with a both-plain control, it discriminated. Worth recording: this project's own rule ("an instrument
that can drop results silently is not a measurement") caught a measurement made *for* this task.
## 6. Amnesty list (the list to review)
Deliberately short — **one** item qualified:
- Three generic code-quality bullets in `felhom.eu/CLAUDE.md` ("always double-check generated code",
"handle edge cases without overcomplicating", "add debug capabilities"). No recorded production
violation, recoverable worst case, and duplicated in the workspace root. **"ask first — don't
guess" was kept** — that one is a real behavioural directive.
**Nothing on the protected list was touched.** The nine-row invariant table, "presence is not
success", the four R-96 rules, the F9 fence, secrets handling, trunk-based, the DooPlex protections,
the destructive-target fences with their permitted targets, and the clean-tree gate all survive —
moved or compressed, never dropped, each compressed fence keeping its target and its reason.
## 7. Gate results
- `controller_gates.py --fast`**all 9 gates OK**, including the new `instructions`.
- `agent_gates.py --fast`**both gates OK**.
- `test_instructions_gate.py`**20 passed, 0 failed**.
- `go build ./... && go vet ./... && go test ./...`**green in all three modules**
(`felhom-controller/controller`, `felhom.eu/hub`, `felhom-agent`) — and unchanged, as required: no
Go was touched.
- **Registration seam asserted by parsing each runner's `GATES` list from the AST**, not by grepping
the source — a commented-out registration would not satisfy it.
**Red-proof** (ceiling temporarily 100, real trimmed files):
```
CLAUDE.md effective lines : 92 (ceiling 100) [felhom-controller]
CLAUDE.md effective lines : 173 (ceiling 100) [felhom-agent]
instructions_gate: 1 FAILURE(S)
- .../felhom-agent/CLAUDE.md: 173 effective lines, ceiling 100. This is an ADHERENCE limit,
not a space limit — long instruction files get followed less reliably and hide contradictions.
```
It discriminated: agent failed and was named, controller still passed. Threshold restored, suite
re-run green.
## 8. `MEMORY.md` — measured only, unchanged
146 lines / 17,688 B (17.3 KB) against the 200-line / 25 KB limits: **within both, nothing
truncated.** The expected finding is absent; a different one is present — the index references **113**
memory files while **157** exist on disk, and **zero** referenced files are missing. So 44 memory
files are unindexed. Recorded, not acted on (the ruling deferred this).
## 9. Delegation
**No subagent was used.** All search, inventory and verification was done in-session. Nothing was
delegated, so nothing needs the read-only caveat.
## 10. Which files actually load
Settled from the supplied `/context`, not assumed: at the workspace root exactly **two** memory files
load — the root `CLAUDE.md` (6.6k tokens) and `MEMORY.md` (8.4k). Per-repo `CLAUDE.md` files are
**not** loaded at launch; they load on demand when a file in that directory is read. This is why the
irreversible fences were kept in the root file.
**Still outstanding (HUMAN):** `/context all` from **inside `felhom-controller`** after this trim, to
give the measured after-figure and confirm which rule files a repo session actually pulls in. Claude
Code cannot invoke a slash command on itself. The after-figures above are byte counts from disk, not
measured tokens, and are labelled as such.
## 11. Register
`OPEN-ITEMS.md` row taken: **R-229** (READY, owner Viktor) — covering `felhom.eu/CLAUDE.md`'s
restructure, `felhom-agent`'s remaining headroom, the auto-memory decision, and the
spec-as-failing-test pilot.
## 12. Observations — not acted on
1. `target-selection.md`'s known t740 off-site-tier error is still there (out of scope).
2. **The root `CLAUDE.md` could be a symlink** to its versioned copy, removing the divergence class
entirely — same filesystem, and Claude Code reads through symlinks (the four skills already are).
Not done, per the spec. If adopted, the gate's copy-identity check should become a symlink-target
check.
3. **A blanket version-literal ban has a false-positive class the spec did not anticipate:** a bare
`\d+\.\d+\.\d+` matches the first three octets of every IPv4. Without the dotted-quad exclusion the
gate fails on `192.168.0.180` in the agent's own file.
4. The spec calls the invariant table "the eight-invariant table"; **it has nine rows** and its own
text says "Nine instances". Flagged so the protected list is not later applied to eight of nine.
5. `demo-hp` also hosts VM `321 c11-appliance`, **running** — seen in the same `qm list`, not
mentioned in `nodes.md`. Not investigated.
6. Two unused Claude Code plugins (`typescript-lsp`, `context7`) and a broad user-scope allowlist
(`Bash(python3:*)`, `Bash(curl:*)`, `Bash(scp:*)` — standing arbitrary execution and network
egress in every project) were found by the earlier setup audit. **They live in
`~/.claude/settings.json`, not in any repo, and are deliberately out of scope.** Recorded only.