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
+62 -51
View File
@@ -16,10 +16,8 @@ This repo contains:
- **Architecture docs** (`documentation/`) — the **authoritative design home for the whole Felhom
system**: `architecture/01..05-*.md`, `proxmox-platform.md`, `tests/phase*-findings.md`,
runbooks, audits. Read these before designing.
- **Skills** (`skills/`) — the versioned source of the Claude Code skills
(`felhom-build-deploy`, `felhom-ui-design`, `felhom-testing`, `felhom-app-catalog`);
install/update with `python3 scripts/install_skills.py` (symlink into `~/.claude/skills/` on
POSIX, junction on Windows — either way repo edits are live immediately).
- **Skills** (`skills/`) — the versioned source of the Claude Code skills; install/update with
`python3 scripts/install_skills.py` (symlink — repo edits are live immediately).
See `README.md` for full architecture/DNS/email/SEO docs. See `TASK.md` for the current task (if any).
See `REUSE.md` before writing new code.
@@ -46,32 +44,40 @@ UI. Package map, helpers, seams, extension points: **`REUSE.md`** (e.g. new even
## Code quality rules
- Always double-check generated code for bugs, logic issues, syntax errors.
- Handle edge cases without overcomplicating.
- Add debug capabilities (logging, verbose output).
- If you need more input or troubleshooting output, **ask first — don't guess**.
- Testing doctrine (non-hollow tests, red-proofs, seams): use the `felhom-testing` skill.
- **Seam-wiring rule — and it covers TEMPLATE GATES (fourth inert seam, hub v0.70.1):** a feature
is not shipped until its entry point is reachable. For UI, any conditional affordance
(`{{if .Flag}}` around a button/form/script) ships with a render test per branch of the gate
handler tests that POST directly prove nothing about reachability. The v0.70.0 ghost-delete was
fully implemented server-side and fully dead UI because the button sat inside the wrong gate.
- **A `go test -run` pattern that matches no test prints `ok` and exits 0.** Found 2026-08-02 while
red-proofing: `-run TestCustomerUnified` matched nothing in the target file and reported
`ok … 0.062s`, which was read as a passing red-proof. **A red-proof that uses `-run` must first
prove the filter matched something** (`-v` and look for `=== RUN`). This is the "an absent line is
not evidence" rule aimed at the one place a false green costs most — the proof itself. The same
class bit twice that day: a `| tail -5` inside a census query silently dropped rows and looked
exactly like a real finding. **An instrument that can drop results silently is not a measurement.**
- **Seam-wiring rule — it covers TEMPLATE GATES:** a feature is not shipped until its entry point is
reachable. For UI, any conditional affordance (`{{if .Flag}}` around a button/form/script) ships
with a render test **per branch of the gate** — handler tests that POST directly prove nothing
about reachability.
- **A `go test -run` pattern that matches no test prints `ok` and exits 0.** A red-proof that uses
`-run` must first prove the filter matched something (`-v`, look for `=== RUN`). Generally: **an
instrument that can drop results silently is not a measurement.**
- **A health check issues no block I/O.** A probe that touches a wedged device enters uninterruptible
sleep, survives `SIGKILL`, and cannot be recovered until the device returns or the host reboots — so
`systemctl restart` hangs too. A timeout protects the caller's control flow and nothing else: the
blocked thread remains. Liveness is decided from `/proc` and the kernel's own state, never by reading
or writing the filesystem. Measured, R-117 spike §6.3
(`documentation/audits/SPIKE-r117-bind-liveness-2026-07-30.md`): a probe stayed in `D` state 3m50s
after `kill -9`; a buffered write with no `fsync` blocked too (`O_CREAT` needs journal access); and
`statfs`/`getdents` returned **healthy** on a namespace that `EIO`s every byte — fast, and wrong.
or writing the filesystem.
- UI/design work (tokens, gates, copy rules): use the `felhom-ui-design` skill.
<!--
CODE-QUALITY RULE CITATIONS — history, not directives.
Seam-wiring / template gates: the fourth inert seam, hub v0.70.1. The v0.70.0 ghost-delete was fully
implemented server-side and fully dead UI because the button sat inside the wrong gate.
go test -run: found 2026-08-02 while red-proofing — `-run TestCustomerUnified` matched nothing in the
target file and reported `ok ... 0.062s`, which was read as a passing red-proof. This is the "an
absent line is not evidence" rule aimed at the one place a false green costs most: the proof itself.
The same class bit twice that day — a `| tail -5` inside a census query silently dropped rows and
looked exactly like a real finding.
Health check / block I/O: measured, R-117 spike §6.3
(documentation/audits/SPIKE-r117-bind-liveness-2026-07-30.md): a probe stayed in D state 3m50s after
kill -9; a buffered write with no fsync blocked too (O_CREAT needs journal access); and
statfs/getdents returned HEALTHY on a namespace that EIOs every byte — fast, and wrong.
-->
- **Logging**: levels/English/no-secrets rules per `documentation/runbooks/logging-conventions.md`
(DEBUG = flow detail, INFO = state change + duration; logs are operator-tier English; keys never
values — the hub's bundle secret-gate blocks violating pulls fail-closed).
@@ -104,11 +110,13 @@ pushes; **you (Claude Code) implement**. A file being open in the editor is NOT
> **Never write secrets** into any committed file — reference them as "stored out-of-band".
- Update `REUSE.md` if you added/changed/deprecated a shared helper or pattern (same commit).
- **Never `git add -A` in this repo** — parallel sessions share the clone and it sweeps foreign
WIP (the v0.47.0 `146d165` incident: a red-proof-mutated guard got swept to `main`). Stage
explicit paths only, `git pull --rebase` before every push, and do not run two writing
- **Never `git add -A` in this repo** — parallel sessions share the clone and it sweeps foreign WIP.
Stage explicit paths only, `git pull --rebase` before every push, and do not run two writing
sessions on one clone (use `git worktree` if truly needed).
<!-- The sweep incident: v0.47.0, commit 146d165 — a red-proof-mutated guard got swept to main. -->
## End-of-session checklist
- **`CHANGELOG.md` + `REPORT.md`** per the rule above, in every repo touched.
@@ -128,13 +136,13 @@ pushes; **you (Claude Code) implement**. A file being open in the editor is NOT
`curl -s "https://gitea.dooplex.hu/api/v1/repos/admin/<repo>/actions/tasks?limit=3"` → match the
`head_sha` to your commit. An unchecked green is an assumption, not an observation.
## Tech stack (Hub)
## Hub stack — the two constraints
- **Language:** Go (stdlib `net/http` + `html/template`, no frameworks). **DB:** SQLite via
`modernc.org/sqlite` (pure Go). **Auth:** bcrypt + Bearer tokens + session cookies + CSRF.
- **Deploy:** Docker on k3s (`felhom-system` ns). **Storage:** Longhorn PVC at `/data/` (SQLite DB).
- **Config:** YAML via ConfigMap at `/etc/felhom-hub/hub.yaml`. Secrets via out-of-band
`secretKeyRef` (never inline stringDataREUSE.md §3).
The dependency list is `hub/go.mod`'s business and the deploy shape is `manifests/`. Only the two
rules that the code cannot tell you belong here:
- **No web frameworks.** Go stdlib `net/http` + `html/template`, and it stays that way.
- **Secrets via out-of-band `secretKeyRef` never inline `stringData`** (REUSE.md §3).
## Environment & access
@@ -142,23 +150,22 @@ Claude Code runs **on DooPlex (192.168.0.180, Debian 13, user `kisfenyo`)** —
Repos in `/mnt/5_hdd/felhom.eu/git/`, build dirs in `/mnt/5_hdd/felhom.eu/build/`. `kubectl` and the
image build/push are local commands; felhom-pve is one SSH hop.
| Host | Access | Role | Blast radius |
|------|--------|------|--------------|
| **DooPlex (this host)** | local — `/mnt/5_hdd/felhom.eu/{git,build}/` | Build + push images, `sudo kubectl` | **Tier 2 — precious.** It *is* the recovery chain (hub, Gitea, registry, PBS, k3s+Longhorn). **Never a drill target** |
| Demo Proxmox host (N100) | `ssh felhom-pve` — via Tailscale `100.70.170.35` (location-independent); `felhom-pve-lan` = LAN `192.168.0.162` fallback | pveum/pct + live Proxmox validation | **Tier 0 — disposable** |
| Demo Proxmox host (HP t740) | `ssh demo-hp` — via Tailscale `100.76.96.79`; `demo-hp-lan` = LAN `192.168.0.87` (ProxyJump `felhom-pve`). **No baked SSH key** — G1 break-glass password vaulted in the hub | **The designated drill + build VM host** (operator ruling 2026-07-25) | **Tier 0 — disposable. Reach here first** |
**Host addresses, routes, node names, break-glass and what is provisioned on each:**
**`documentation/operations/nodes.md`** — the single home. Do not restate them here; re-check an
address rather than trusting one written down. Tailscale topology, the accept-dns rule, the
accept-routes spike result and rollback: `documentation/operations/tailscale.md`.
**Which box do I break?****`documentation/runbooks/target-selection.md`** — the tiers, and per
machine what is freely permitted / needs care / forbidden, each with its reason. Read it before picking
a machine for a drill, a destructive test or a throwaway VM.
a machine for a drill, a destructive test or a throwaway VM. **DooPlex is Tier 2 — precious**: it *is*
the recovery chain (hub, Gitea, registry, PBS, k3s+Longhorn), and never a drill target. Both demo
Proxmox hosts are Tier 0 — disposable; drill and build VMs belong on the t740.
The `felhom-pve` transport is Tailscale (the N100 is travel-portable) — topology, the accept-dns
rule, the accept-routes spike result, rollback, and the vacation-day checklist live in
`documentation/operations/tailscale.md`.
> **Legacy: Windows workstation.** Until 2026-07-19 CC ran on Windows 11 with repos in `E:\git\`,
> and every remote command needed `SSH=/c/Windows/System32/OpenSSH/ssh.exe` (Git Bash's ssh fails
> silently). Retained in case that environment is revived.
<!--
LEGACY: WINDOWS WORKSTATION — until 2026-07-19 CC ran on Windows 11 with repos in E:\git\, and every
remote command needed SSH=/c/Windows/System32/OpenSSH/ssh.exe (Git Bash's ssh fails silently). The
workspace-root CLAUDE.md carries the full version. Retained in case that environment is revived.
-->
## Build & deploy — Hub (GitOps via ArgoCD)
@@ -194,12 +201,16 @@ and runs every gate — `site_gates.py`, `hostinstall_gates.py`, `hub_confirm_ga
and exiting non-zero if any fails. `--fast` selects only the gates that touch no network and no
container runtime; today that is all of them. A missing gate script is a FAILURE, never a skip.
**Why a runner and not five invocations** (2026-08-02, R-29): a census of all thirteen gates across
the four repos found that every check a `CLAUDE.md` names was passing, and two of the four nobody
is told to run were failing — one since 14 July. The single-entry-point shape is the only one that
demonstrably gets run here; `app-catalog-felhom.eu/scripts/catalog_gates.py` is the canonical
version of it (R-161) and `repo_gates.py` copies it. `site_gates.py` is a *gate*, not a runner —
do not model new work on it.
`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).
<!--
WHY A RUNNER AND NOT FIVE INVOCATIONS (2026-08-02, R-29): a census of all thirteen gates across the
four repos found that every check a CLAUDE.md names was passing, and two of the four nobody is told
to run were failing — one since 14 July. The single-entry-point shape is the only one that
demonstrably gets run here.
-->
**The pre-push hook.** `.githooks/pre-push` runs `repo_gates.py --fast` and refuses the push if it
fails. It is **per-clone** and switched on once with `git config core.hooksPath .githooks` — a
+34
View File
@@ -17,6 +17,40 @@
## Standing rulings
**S-35 — INSTRUCTION FILES ARE A SHORT CORE PLUS PATH-SCOPED RULES (2026-08-06, R-229).**
Decided while rightsizing the four `CLAUDE.md` files. The mechanisms were verified before being
relied on, and two of the three the task assumed turned out to need correcting:
1. **Shape.** A `CLAUDE.md` is a short always-loaded core: what the repo is, a "doing X → read Y"
retrieval map, the gotchas that cost an incident, one command per surface, the fences, the
end-of-session checklist. Everything path-bound goes to `.claude/rules/<topic>.md` with a
`paths:` glob list, which Claude Code loads **only when a matching file is read** (confirmed
against the installed 2.1.222 build). Procedures go to the skill that already covers them.
2. **Earned rationale goes in block-level HTML comments.** They are stripped before injection and
never reach the model — **verified empirically**, not assumed: a control (two plain markers →
both reported) against a treatment (one marker inside `<!-- -->` → not reported, twice). So the
incident histories stay in the repo for human readers at zero cost. **This makes the raw line
count the wrong measure** — the gate counts *effective* lines, and so should any future budget.
3. **What may NOT move into a lazily-loaded file:** irreversible fences and agent directives. Rules
are not re-injected after `/compact`; the project-root `CLAUDE.md` is. That is why the
destructive-target fences, the secrets rule and the clean-tree gate stay in the root file.
4. **Amnesty criterion.** A prohibition with no recorded production violation and a recoverable worst
case may be deleted. Anything paid for in a real incident stays — and **a fence keeps its
permitted target and its reason**, never reduced to a bare prohibition. Exactly one item met the
bar (three generic code-quality bullets).
5. **No component version literal in any `CLAUDE.md`.** Versions change several times a day; ask the
hub or the box. **A historical citation is not fleet state** — "fixed in hub v0.97.0" cannot go
stale the way "this box runs agent 0.93.0" can, so citations moved into the HTML comment beside
the rule they justify rather than earning a carve-out in the gate.
6. **Subagent rule, narrowed (operator, 2026-08-06):** read-only research, inventory and verification
are permitted with a bounded digest; **no subagent may edit, commit, build, deploy or touch live
hardware.** None was used for R-229.
Enforced by `scripts/instructions_gate.py`, registered in `controller_gates.py` and
`agent_gates.py`. **`felhom.eu/CLAUDE.md` is knowingly still over the ceiling (227 effective lines)**
and is therefore not yet gated — closing it needs the restructure R-229 defers.
**S-34 — UNLOCKING AND RESTORING ARE SEPARATE. The recovery screen shipped (2026-08-05, controller
v0.200.0, R-193 CLOSED). Read with S-32 and S-33; together they close the whole customer journey up to
the listing.**
+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.
@@ -0,0 +1,264 @@
# LEDGER — instruction-file trim, 2026-08-06
Every block removed from a `CLAUDE.md` in this task, where it went, and why. Nothing was deleted
without a row here. Destinations: `rule-file`, `html-comment`, `already-in:<path>`, `deleted-stale`,
`deleted-derivable`, `deleted-amnesty`.
**Baselines (verified against live Gitea before starting):** felhom-controller `a62bb3874b25`,
felhom-agent `a2e914f683bd`, felhom.eu `d30c2a51ed2a`, app-catalog-felhom.eu `ee2c8102016a` — all
`HEAD == origin/main`, all clean.
**Correction to the spec's §1 table:** the workspace-root `CLAUDE.md` measured **16,642 bytes / 208
lines**, not the stated 15,431 / 207 — it was edited at 08:32 on 2026-08-06, after the spec was
written. The other five files matched the spec exactly.
---
## A. Mechanism verification (done before any edit — the task's design depends on these)
| Claim (spec §3) | Verdict | Evidence |
|---|---|---|
| `.claude/rules/*.md` + `paths:` loads only on a matching file read | **confirmed** | product-authored strings in the installed Claude Code 2.1.222 binary describe exactly this |
| Block-level HTML comments are stripped before injection | **confirmed empirically** | control (two plain markers → both reported) vs treatment (one marker inside `<!-- -->` → not reported, run twice), via `claude -p` against a scratch `CLAUDE.md` |
| `unconditional: true` frontmatter | **NOT a product feature** | absent from the build. It is this project's own marker; the gate documents it as such |
| Root `CLAUDE.md` survives `/compact` | **not verified** | taken from the spec; drove keeping fences in the core, so recorded as an assumption |
The HTML-comment test needed two attempts. The first asked an open question ("list the markers you
can see") and the red-proof did **not** go red — the model reported one marker in both arms, so the
instrument was dropping a result silently. Rewritten as two forced yes/no questions with a
both-plain control, it discriminated cleanly. Recorded because it is the project's own rule
("an instrument that can drop results silently is not a measurement") catching a measurement made
*for* this task.
---
## B. `felhom-controller/CLAUDE.md` — 215 → 110 lines (92 effective)
| Heading / first words | Class | Destination | Reason |
|---|---|---|---|
| `## Layout (verified against the tree)` (36 ln) | derivable | `deleted-derivable` | reconstructible by `ls controller/internal/`; the per-package seams and traps the annotations stood in for are `REUSE.md`'s job |
| `!!! IMPORTANT !!!` header (3 ln) | duplicated | `already-in:CLAUDE.md` end-of-session checklist items 3 and 5 | a rule stated twice in one file gets edited in one of them |
| `## Environment & access` host table (13 ln) | duplicated + stale | `already-in:documentation/operations/nodes.md` | see §D — three separate defects in one table |
| `> **Legacy: Windows workstation.**` (4 ln) | duplicated | `already-in:CLAUDE.md` (workspace root, as an HTML comment) | archival; the root carries the fuller version |
| `> **felhom-pve is back on the home LAN…**` (8 ln) | stale | `deleted-stale` | bookkeeping *about* a retired block; the record is `audits/AUDIT-vacation-remote-ops-2026-07-20.md` |
| gates paragraph, `**Run … controller_gates.py**` (16 ln) | path-bound | `rule-file:.claude/rules/gates.md` | only matters when Go/HTML/CSS/scripts are being edited |
| R-29 "why a runner and not seven invocations" (6 ln) | inert rationale | `html-comment` in `gates.md` | nobody acts on it; it exists so a future reader narrows the rule correctly |
| logging paragraph (3 ln) | path-bound | `rule-file:.claude/rules/gates.md` | applies when writing Go |
| Hungarian-UI + design-tokens line (2 ln) | path-bound | `rule-file:.claude/rules/ui-hungarian.md` | applies when editing templates/CSS |
| ASCII-grep trap + `!`-in-credentials trap (8 ln) | path-bound gotcha | `rule-file:.claude/rules/ui-hungarian.md` | both are UI-validation traps; the core keeps a pointer |
| coupled-features / `featureProbes` (4 ln) | path-bound | `rule-file:.claude/rules/agent-coupling.md` | applies only in `internal/agentapi/` |
| `> **In every repository…**` CHANGELOG/REPORT/secrets (5 ln) | duplicated | `already-in:CLAUDE.md` (workspace root) | §8.4 names the root as the single home; the root always loads and survives `/compact` |
| generic code-quality line (1 ln) | duplicated | `already-in:CLAUDE.md` (workspace root) | "double-check for bugs, add debug logging, ask rather than guess" |
| build/deploy 4-step table (10 ln) | duplicated | `already-in:` the `felhom-build-deploy` skill | the file already said to use the skill, then restated it; the bootstrap-managed/no-compose-file gotcha was **kept** |
**Kept deliberately:** the seven session-critical invariants (highest-value block in the file), the
F9 live-validation fence, the `CHANGELOG.md` read-discipline, the end-of-session checklist.
**New:** `.claude/rules/{gates,ui-hungarian,backup-paths,agent-coupling}.md` — 4 files, all
`paths:`-scoped, all under 60 lines. `backup-paths.md` additionally carries the R-181 consequence-vs-
mechanism lesson and "presence is not success", which apply exactly where backup code is written.
---
## C. Workspace-root `CLAUDE.md` (+ its versioned copy) — 208 → 182 lines (142 effective)
| Heading / first words | Class | Destination | Reason |
|---|---|---|---|
| `## Per-repo guidance` (7 ln) | derivable | `deleted-derivable` | those files load on their own; the section said so itself |
| `## Skills` roster, 4 names + purposes (5 ln) | duplicated | `already-in:` the resident skill listing | kept the `install_skills.py` line, which is not derivable |
| `(119 files)` in `## Memory` | stale + derivable | `deleted-stale` | the directory holds **157** memory files plus the index |
| `## Access` host table, 5 rows (7 ln) | duplicated | `already-in:documentation/operations/nodes.md` | replaced by a pointer; the Tier-2 DooPlex fence and the "an absent fence is not permission" line were **kept** |
| `## Legacy: Windows workstation` (14 ln) | archival | `html-comment` | operator wants it kept for revival; invisible to the model, still in the file for a human |
| R-96 four incident narratives (18 ln) | inert rationale | `html-comment` | the four **rules** stay as directives; only the stories moved |
| "Presence is not success" 2-row table (5 ln) | inert rationale | `html-comment` | rule + corollary stay in the core |
| nine-row invariant table (11 ln) | actionable doctrine | `already-in:` `felhom.eu/skills/felhom-testing/SKILL.md` | it triggers on writing/reviewing a test, hardening a guard, fixing a bug — exactly when the table matters. **All three directive bullets stayed in the core** |
**Kept deliberately:** every production-infrastructure prohibition, the artifact taxonomy, the four
R-96 rules, the clean-tree gate, the CHANGELOG/REPORT/secrets blockquote, trunk-based, the
live-validation fence, the no-versions-in-docs rule, the target-selection fence.
**Copy discipline:** `felhom.eu/documentation/runbooks/workspace-CLAUDE.md` was re-synced and is
byte-identical (`cmp` clean). The live file sits in a directory that is **not a git repo**, so the
copy is the only version-controlled record of it — the new gate now enforces the equality.
---
## D. `felhom-agent/CLAUDE.md` — surgical only, 216 → 205 lines (173 effective)
| Heading / first words | Class | Destination | Reason |
|---|---|---|---|
| `> **TEMPORARY — felhom-pve is at a remote site (until ~2026-08-02).**` (16 ln) | stale | `deleted-stale` | **expired four days before this task** and instructed its own deletion; the location-independence fact worth keeping moved to an HTML comment |
| `> **Legacy: Windows workstation.**` (4 ln) | duplicated | `html-comment` | root carries the full version |
| drill-VM claim, "that ruling is **realized** — it hosts drill VM `300`" (5 ln) | duplicated | `already-in:documentation/operations/nodes.md:96` | **this file was right** (see §F) but the fact already had a single home |
| host addresses / node names / break-glass (4 ln) | duplicated | `already-in:documentation/operations/nodes.md` | replaced by a pointer |
| `felhom-agent --version → 0.115.0` | stale | `deleted-stale` | version literal; went with the TEMPORARY block |
| `go.mod` directive `go 1.25.0` | derivable | `deleted-derivable` | `go.mod`'s business |
| module path + binary name line (1 ln) | derivable | `deleted-derivable` | `go.mod` + the tree |
| R-115 / R-188 / R-186 release narratives (26 ln) | inert rationale | `html-comment` + `already-in:` `felhom-build-deploy` skill | the **directives** stayed (never hand-roll; the order; reproducible build); the history and the verification recipe moved |
| health-check block-I/O rule (4 ln) | path-bound | `rule-file:.claude/rules/health-checks.md` | it was duplicated from `felhom.eu/CLAUDE.md` *with a note explaining why* — that reasoning predates path-scoped rules |
**New:** `felhom-agent/.claude/rules/health-checks.md`, scoped to the five packages where health
checks are written.
---
## E. `felhom.eu/CLAUDE.md` — surgical only, 241 → 235 lines (**227 effective — still over the 200 ceiling**)
| Heading / first words | Class | Destination | Reason |
|---|---|---|---|
| `## Tech stack (Hub)` list (5 ln) | derivable | `deleted-derivable` | `hub/go.mod` + `manifests/`; the two constraints that are *not* derivable ("no web frameworks", "never inline `stringData`") were kept |
| three generic code-quality bullets (3 ln) | amnesty | `deleted-amnesty` | "double-check generated code", "handle edge cases", "add debug capabilities" — no recorded production violation, recoverable worst case. **"ask first — don't guess" was kept**: it is a real behavioural directive |
| skills roster, 4 names (4 ln) | duplicated | `already-in:` the resident skill listing | kept the `install_skills.py` line |
| host table, 3 rows (6 ln) | duplicated | `already-in:documentation/operations/nodes.md` | replaced by a pointer; the Tier-2/Tier-0 verdicts kept inline |
| `> **Legacy: Windows workstation.**` (3 ln) | duplicated | `html-comment` | root carries the full version |
| seam-wiring / `-run` / health-check citations (12 ln) | inert rationale | `html-comment` | the **rules** stayed; the incident detail and the three version literals moved into the comment beside them |
| R-29 gate-census narrative (6 ln) | inert rationale | `html-comment` | as elsewhere |
| `git add -A` sweep incident citation (1 ln) | inert rationale | `html-comment` | the prohibition stayed |
---
## F. The contradictions — resolved, not annotated
| # | Contradiction | Resolution | Where the fact lives now |
|---|---|---|---|
| 1 | agent: demo-hp "hosts drill VM `300` (`drill-r50`)" vs controller: "no drill VM is provisioned there yet" | **measured live**`ssh demo-hp "qm list"` shows `300 drill-r50 stopped`. **felhom-agent was right; felhom-controller was wrong.** No new text needed: `documentation/operations/nodes.md:96` already stated it correctly, and `runbooks/target-selection.md:64` already fences it | `nodes.md` (unchanged) |
| 2 | agent: `TEMPORARY — felhom-pve is at a remote site (until ~2026-08-02)` vs controller: "back on the home LAN (2026-07-25)" | both blocks deleted; neither belongs in a `CLAUDE.md`. The gate now fails any TEMPORARY block whose date has passed | `audits/AUDIT-vacation-remote-ops-2026-07-20.md` |
| 3 | `agent 0.93.0` recorded in controller's host table, against the root file's own no-versions rule | deleted, with every other version literal in effective text | ask the hub `/hosts` + `/configs`, or the box |
| 4 | controller gave `demo-felhom` as plain `root@192.168.0.162` (the LAN *fallback*) while the other two documented the Tailscale route | host tables removed from all three `CLAUDE.md` files | `nodes.md` |
| 5 | root file: memory is `(119 files)`; the directory holds 157 + the index | parenthetical deleted rather than corrected — it is derivable and would go stale again | `ls .claude-memory/` |
**The sweep found no contradictions beyond the five.** After the trim: zero version literals in
effective text across all four files, zero TEMPORARY blocks, and exactly one file (`nodes.md`)
stating the drill-VM status.
---
## G. Amnesty list (ruling 1) — every prohibition deleted, and why it qualified
Deliberately short. Only one item met the bar.
| Rule deleted | Where | Why it qualified |
|---|---|---|
| "Always double-check generated code for bugs, logic issues, syntax errors" / "Handle edge cases without overcomplicating" / "Add debug capabilities (logging, verbose output)" | `felhom.eu/CLAUDE.md` | Generic best practice with **no recorded production violation** and a recoverable worst case. Also duplicated in the workspace-root file, which keeps the one clause that is a real directive: **ask rather than guess** |
**Nothing on the protected list was touched.** The eight-invariant table (in fact **nine** rows — the
spec's §8.1 undercounts it), "presence is not success", the four R-96 standing rules, the F9
live-validation fence, secrets-never-in-committed-files, trunk-based/no-branches, the DooPlex
protection rules, the destructive-target fences with their permitted targets, and the clean-tree
gate all survive — moved or compressed, never dropped, and every compressed fence kept its permitted
target and its reason.
---
## H. Before / after
| File | before (ln/B) | after raw (ln/B) | after **effective** (ln/B) | ceiling |
|---|---|---|---|---|
| workspace-root `CLAUDE.md` | 208 / 16,642 | 182 / 11,280 | **142 / 8,105** | pass |
| `felhom.eu/…/workspace-CLAUDE.md` | 208 / 16,642 | 182 / 11,280 | **142 / 8,105** | pass (byte-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, see below** |
| `app-catalog-felhom.eu/CLAUDE.md` | 79 / 6,294 | untouched | 79 / 6,294 | pass — **the reference shape** |
*Effective* = with block-level HTML comments stripped, i.e. what the model actually receives.
**Controller-session total** (workspace root + repo file): 31,417 → **17,621 bytes raw**, and
**12,986 bytes effective** — a 59% reduction in what loads. Stated as bytes, not tokens: the measured
`/context` ratio (§I) shows a bytes/4 token estimate understates the real cost by 1.61.9×, so the
byte figure is the honest one.
**`felhom.eu/CLAUDE.md` is knowingly left over the ceiling at 227 effective lines.** Spec §12
forbids restructuring it into core+rules ("a diff Viktor cannot read is a diff that gets approved
unread") and §6.2 registers the gate only in the controller and agent runners, so it is not gated
today. Bringing it under 200 would require the restructure §12 prohibits. Filed as deferred work
(§N.5 row below); when that lands, register `instructions_gate` in `felhom.eu/scripts/repo_gates.py`
too.
---
## I. Measured context baseline (operator-supplied `/context`, workspace root, Opus 5 / 1M window)
| Category | Measured |
|---|---|
| Window | 1,000,000 tokens · 131.1k used (13%) · **868.9k free (86.9%)** |
| Memory files | **2 files · 15.0k tokens (1.5%)** — root `CLAUDE.md` 6.6k + `MEMORY.md` 8.4k |
| Skills | 19 · 2.5k (0.3%) |
| MCP tools | deferred · 0 tokens |
**Which files load, settled:** the `/context` breakdown names exactly two memory files at the
workspace root — the root `CLAUDE.md` and the auto-memory `MEMORY.md`. The per-repo `CLAUDE.md`
files are **not** loaded at launch; they load on demand once a file in that directory is read. This
confirms the spec's §1 assumption and is why the irreversible fences were kept in the root file.
**Token ratio, measured:** root `CLAUDE.md` 16,642 B → 6.6k tokens (2.52 B/token); `MEMORY.md`
17,688 B → 8.4k tokens (2.11 B/token). A bytes/4 estimate understates by **1.59× and 1.90×**
respectively — the spec's 1.72× range is right, and every disk-based figure in this ledger is
labelled as bytes for that reason.
**Space was never the constraint.** 869k tokens were free. The justification for this task is the
contradiction count (§F) and the adherence guidance, and the gate's failure message says so
explicitly so that no future reader re-derives the wrong reason.
---
## J. `MEMORY.md` — measured only, unchanged (ruling 4 deferred it)
| Measure | Value | Limit | Verdict |
|---|---|---|---|
| Lines | 146 | 200 | within |
| Size | 17,688 B (17.3 KB) | 25 KB | within |
| Referenced past the limit | 0 | — | **nothing is truncated** |
The expected finding — a truncated index — is **not present**. A different one is: the index
references **113** distinct memory files while **157** exist on disk (plus the index itself), and
**zero** referenced files are missing. So 44 memory files exist that the index never points at.
Recorded, not acted on.
---
## K. Reconciliation
| Repo | `git diff --stat` | Ledger rows |
|---|---|---|
| felhom-controller | `CLAUDE.md` 92 ins / 194 del; `controller_gates.py` +3 | 14 rows (§B) |
| felhom-agent | `CLAUDE.md` 65 ins / 73 del; `agent_gates.py` +3 | 9 rows (§D) |
| felhom.eu | `CLAUDE.md` +113 ctx; `workspace-CLAUDE.md` 242 changed; `SKILL.md` +25 | 8 rows (§C) + 8 rows (§E) |
Rows reconcile against the diffstat: every deleted block above appears in a deletion, and the
insertions are the replacement pointers, the rule files, the HTML comments and the skill section.
Files created: 5 rule files, `instructions_gate.py`, `test_instructions_gate.py`, this ledger.
---
## L. Observations — noticed, not acted on
1. **`target-selection.md` carries a known error** (the t740's off-site tier). Out of scope per §12;
still present.
2. **The workspace-root `CLAUDE.md` lives outside any git repo.** `/mnt/5_hdd/felhom.eu/git` is not a
repository, so the live file is untracked and only the `felhom.eu` copy is version-controlled.
Making the live file a **symlink** to the versioned copy would remove the divergence class
entirely and looks safe here — both are on the same filesystem and Claude Code reads through
symlinks (the four skills are already symlinks into this tree). **Not done in this task**, per
§3.2. If adopted, the gate's copy-identity check becomes trivially true and should be replaced by
a check that the symlink still points where it should.
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 address**. The gate excludes dotted
quads; without that it fails on `192.168.0.180` in the agent's own file.
4. **Historical version citations are not fleet state.** "fixed in hub v0.97.0" cannot go stale the
way "this box runs agent 0.93.0" can. Rather than carve an exception into the gate, each citation
moved into the HTML comment beside its rule — the rule text stays clean and the gate stays
absolute. Recorded because it is a deviation from §8.2 item 3's literal wording ("delete every
one") in favour of its stated purpose.
5. **The spec's §8.1 calls the invariant table "the eight-invariant table"; it has nine rows** and
its own text says "Nine instances". Nothing was dropped — flagging the miscount so the protected
list is not later applied to eight of nine.
6. **`demo-hp` also hosts VM `321 c11-appliance`, running**, alongside the drill VM. Seen in the same
`qm list`; `nodes.md` does not mention it. Not investigated.
7. **Two unused Claude Code plugins 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. **These live in `~/.claude/settings.json`, are not in any
repo, and are deliberately out of scope here.** Recorded only.
8. **The `felhom-agent` core is at 173 effective lines** — passing, but with the least headroom. Its
release section is the next candidate for the `felhom-build-deploy` skill when §12's
corrections-only restriction is lifted.
+6
View File
@@ -109,6 +109,12 @@ the fault was real. Full observables: `tests/campaign11-evidence-2026-08-05/jour
| **R-228** | **After „I do not want the old data", the set-aside history becomes invisible — the box records where it is and shows it to nobody.** The move-aside itself is **correct and was verified byte-for-byte**: `/home/felhom-repo``/home/felhom-repo.orphaned-20260805` with its mtime, its `du -s` (**12 535 KB**) and snapshot **`f3d9cd67`** all unchanged, and a fresh empty repo initialised beside it. **Nothing was deleted.** But `settings.json` then carries `"orphaned_renamed_to": "/home/felhom-repo.orphaned-20260805"` and a census returns **zero** references to `OrphanedRenamedTo` in any template or web handler — the field is written and read by nobody. `/backups/remote` after the set-aside contains no occurrence of the path, „félretéve", „régi előzmény" or any equivalent (instrument controls: `felhom-repo` → 2, „letétbe helyezve" → 1). **12.5 MB of the customer's deliberately retained data sits at a path the box knows and never shows**; its only mention is a flash message on the redirect, gone on the next click. Meanwhile `GET /recovery` → 302 and `POST /recovery/unlock` → 302 with no message, so a customer who changes their mind gets **no explanation at all** (correctly, not a typing accusation — but not an explanation either). **The project's own "seam built but never wired" pattern**, landing on the one promise the set-aside screen makes | **CLOSED 2026-08-06 — controller v0.202.0.** `OrphanedRenamedTo` is surfaced as two facts and stops. **It does not promise the history can be reopened** — it cannot be, by anyone, today (R-199's inventory is unbuilt) — and the set-aside **confirmation copy was corrected** for the same reason: *"a helyreállítási kód nélkül többé nem lesznek megnyithatók"* implied that WITH the code they could be. The field's own comment said "recovery-code-recoverable", the same over-promise in the code. **PROVEN LIVE**: the notice renders on the venue |
| **R-227** | **A controller restart mid-unlock returns a raw English `Bad Gateway`.** F8 restarted the container at T+0.7 s, inside the unseal window (control: `StartedAt` moved). The customer got **HTTP 502 / „Bad Gateway"** from traefik — a raw upstream error, in English, naming no reason and saying nothing about whether the key was installed. **The state half is clean**: the four `/data/offbox` files stayed byte-identical with mtimes frozen, and the controller returned healthy in 40 s. Breaches **I3** | **CLOSED 2026-08-06 — controller v0.202.0, partially and stated as such.** **The layer that answers is traefik**, whose config this repo generates — but traefik v3 serves no static files, so a branded proxy page needs a **new always-up container** for every 502 on the box: **scoped, not built**. Shipped: the unlock posts via `fetch` and answers a gateway failure in Hungarian in-page. **Progressive enhancement — with no JS the plain POST still shows the proxy's error** |
## Instruction files — deferred half, 2026-08-06
| ID | What | State |
|---|---|---|
| **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. **Deferred, and why:** (a) **`felhom.eu/CLAUDE.md` is at 227 effective lines, over the 200 ceiling** — reducing it needs the core+rules restructure that the task spec explicitly forbade for reviewability, so it is not gated today; when it lands, also register `instructions_gate` in `scripts/repo_gates.py`. (b) **`felhom-agent/CLAUDE.md` at 173 effective lines** passes with the least headroom; its release section is the next candidate for the `felhom-build-deploy` skill. (c) **The auto-memory decision**`MEMORY.md` was measured only (146 lines / 17.3 KB, **within** both the 200-line and 25 KB limits, nothing truncated), but the index references 113 memory files while **157 exist on disk**, so 44 are unindexed. (d) **The spec-as-failing-test pilot**, approved in principle and not started. Full accounting: `audits/LEDGER-instruction-trim-2026-08-06.md` | **READY** — owner Viktor |
**Recorded against existing rows by Phase 2:**
- **R-216 — §4.1 is now MEASURED, not deduced.** The previous session could only offer two absences.
+110 -136
View File
@@ -2,15 +2,21 @@
## What this workspace is
`/mnt/5_hdd/felhom.eu/git` is a parent folder holding the felhom sibling repos. Most are one logical
product — **Felhom**, a managed home-server service for Hungarian households — spread across several
repos. (Any non-felhom repo is unrelated; ignore unless asked.)
A parent folder holding the felhom sibling repos. Most are one logical product — **Felhom**, a
managed home-server service for Hungarian households. (Any non-felhom repo is unrelated; ignore
unless asked.)
**Claude Code runs HERE, on DooPlex (192.168.0.180), as `kisfenyo`.** Builds are local commands; the
Proxmox host is one SSH hop (`ssh felhom-pve`). The Windows workstation is no longer the
orchestration point and its trees are stale — see "Legacy: Windows workstation" at the bottom.
**Claude Code runs HERE, on DooPlex (192.168.0.180), as `kisfenyo`.** Builds are local; the Proxmox
host is one SSH hop. Run CC inside tmux so sessions survive SSH drops: **`tmux new -A -s cc`**.
Run CC inside tmux so sessions survive SSH drops: **`tmux new -A -s cc`**.
- **Hub** — operator backend on k3s (`hub.felhom.eu`), in `felhom.eu/hub/`.
- **Host agent** — one per Proxmox host, operator-tier, owns all Proxmox interaction: `felhom-agent/`.
- **In-guest controller** — one per customer LXC, Docker-only: `felhom-controller/`.
- Also: `app-catalog-felhom.eu/` (app templates), `homelab-manifests/` (DooPlex k3s).
Each repo's own `CLAUDE.md` and `.claude/rules/` load when you touch files there. The four Felhom
skills are installed from `felhom.eu/skills/` with `python3 felhom.eu/scripts/install_skills.py`
(symlink — repo edits are live immediately).
## This host is production infrastructure
@@ -23,77 +29,58 @@ DooPlex runs Gitea, the container registry, k3s + Longhorn, PBS, and the hub. Tr
- Do not run Claude Code with permission prompts disabled on this host.
- Watch disk headroom before large builds: `df -h /mnt/5_hdd /` — abort if either is >90%.
## The Felhom system (three-component model, Proxmox-based)
## Artifact taxonomy (it prevents the "what do I do?" stall)
- **Hub** — operator backend on k3s (`hub.felhom.eu`). Lives in `felhom.eu/hub/`.
- **Host agent** — one per Proxmox host, operator-tier, owns all Proxmox interaction. Repo `felhom-agent/`.
- **In-guest controller** — one per customer LXC, Docker-only. Repo `felhom-controller/`.
The planning/architecture assistant (in claude.ai, "project Claude") produces files with distinct
roles. **A file being open in the editor is NOT an instruction. If no task is stated, ask.**
Other felhom repos: `app-catalog-felhom.eu/` (app templates), `homelab-manifests/` (DooPlex k3s).
- **`TASK.md` / `TASK-*.md`** — a spec for **you (Claude Code) to implement**. Implement it when it is
placed as `TASK.md` at a repo root, or when explicitly told "implement <file>". Then push, update
`CHANGELOG.md`, and write the repo's `REPORT.md`.
- **`RUNBOOK-*.md`** — an operational procedure. CC executes every step it has access and capability
for, live hosts included (CC has root@felhom-pve SSH + the felhom-agent token). Mark a step HUMAN
only when it genuinely needs physical presence, a real-world decision, or credentials CC lacks.
**Do not decline a whole procedure because it touches a live host or a privileged token.** Confirm
before irreversible ops on real customer data; demo scratch guests are fair game.
- **Validation/review** — checking a push against a spec's criteria is **project Claude's** job, not
yours, unless asked.
**Authoritative design docs (read these before designing anything):** `felhom.eu/documentation/architecture/01..05-*.md`, `felhom.eu/documentation/proxmox-platform.md`, `felhom.eu/documentation/tests/phase{0,1-2,3,4}-findings.md`.
## Standing rules — each earned by a real failure (R-96)
## Per-repo guidance
1. **Never combine a test run and a commit in one command.** A combined command has ONE exit code and
the interesting one gets swallowed. Run the suite, read `rc`, *then* commit.
2. **A "no access" claim must list what was tried.** "No access" is unfalsifiable unless it names its
attempts.
3. **An absent log line is not evidence of correct behaviour.** Verify with a POSITIVE observable —
something that MUST appear when the system is healthy. An empty log is equally consistent with
"working" and "stopped entirely".
4. **A recommendation that is not followed gets one line saying why.** Silence reads as agreement and
the disagreement is lost.
When you work in a repo, read its `CLAUDE.md` (it loads on-demand the moment you touch a file there):
- `felhom-agent/CLAUDE.md` — the Go host agent.
- `felhom.eu/CLAUDE.md` — hub + website + manifests + the architecture docs.
- `felhom-controller/CLAUDE.md` — the in-guest controller.
## Skills
Four Felhom skills exist (personal scope, `~/.claude/skills/`): **`felhom-build-deploy`** (all
build/deploy/publish runbooks), **`felhom-ui-design`** (design-system v2 tokens/rules/gates),
**`felhom-testing`** (non-hollow tests + red-proofs + seams), **`felhom-app-catalog`** (catalog
authoring workflow). Source of truth: `felhom.eu/skills/`; install/update with
`python3 felhom.eu/scripts/install_skills.py` (symlink — repo edits are live immediately).
## Memory
The accumulated project memory (119 files) migrated from the Windows workstation lives at
`/mnt/5_hdd/felhom.eu/git/.claude-memory/`, surfaced to Claude Code via
`~/.claude/projects/-mnt-5-hdd-felhom-eu-git/memory` (symlink). `MEMORY.md` there is the index.
Memories reflect what was true when written — verify a named file/flag still exists before acting
on it.
## Artifact taxonomy (READ THIS — it prevents the "what do I do?" stall)
The planning/architecture assistant (in claude.ai, "project Claude") produces files with distinct roles. **A file being open in the editor is NOT an instruction. If no task is stated, ask.**
- **`TASK.md` / `TASK-*.md`** — a spec for **you (Claude Code) to implement**. Implement it when it is placed as `TASK.md` at a repo root, or when explicitly told "implement <file>". Then push, update `CHANGELOG.md`, and write the repo's `REPORT.md`.
- **`RUNBOOK-*.md`** — an operational procedure. CC executes the steps it has access and capability for, including live validation on the demo nodes and the demo Proxmox host (CC has root@felhom-pve SSH + the felhom-agent token). A step is human-only only when it genuinely needs physical presence, a real-world decision, or credentials CC truly lacks — mark those steps HUMAN. Do not decline a whole procedure because it touches a live host or a privileged token. (Judgment still applies: confirm before irreversible ops on real customer data — but demo scratch guests are fair game.)
- **Validation/review** — checking a push against a spec's criteria is **project Claude's** job, not yours, unless asked.
<!--
R-96 incident record (committed 2026-07-27) — rationale, not directives.
1. Three recorded occurrences; the worst pushed a red suite because `packages ok: 28` was read while
rc=1 was not.
2. Two wrong verdicts on 2026-07-27 alone: ep0 (declared unreachable after trying exactly one route
— felhom-pve -> 10.77.0.1; DooPlex -> 167.233.158.164 worked and the project memory said so), and
the storage-box API (api.hetzner.cloud 404s for every storage-box endpoint; api.hetzner.com/v1 is
the real one, and the hub's own hetznerapi.go:3 records it).
3. Earned twice on 2026-07-27: the R-88 watcher (an empty quiesce log could not distinguish a healthy
loop from a dead one — retired in favour of the per-tier /backup/due polls in pveproxy/access.log),
and a hub DB copy whose write had silently failed, returning a confident "0 events in window" from
a file a day stale until its mtime was checked.
4. Twice in the R-88/R-97 arc a review point was absorbed rather than argued: R-84 was folded into
R-82 without a word, and R-97a's operator-only guard was dropped while the claim it was meant to
enforce got committed as a comment — which is how a false guarantee shipped and survived a release.
-->
## Shared conventions
### Standing rules — each one earned by a real failure (R-96, committed 2026-07-27)
These were agreed in conversation and lived nowhere, so they bound nobody. They do now.
1. **Never combine a test run and a commit in one command.** A combined command has ONE exit code and
the interesting one gets swallowed. Three recorded occurrences; the worst pushed a red suite
because `packages ok: 28` was read while `rc=1` was not. Run the suite, read `rc`, *then* commit.
2. **A "no access" claim must list what was tried.** "No access" is unfalsifiable unless it names its
attempts. Two wrong verdicts on 2026-07-27 alone: ep0 (declared unreachable after trying exactly
one route — `felhom-pve → 10.77.0.1`; `DooPlex → 167.233.158.164` worked and the project memory
said so), and the storage-box API (`api.hetzner.cloud` 404s for every storage-box endpoint;
`api.hetzner.com/v1` is the real one, and the hub's own `hetznerapi.go:3` records it).
3. **An absent log line is not evidence of correct behaviour.** Verify with a POSITIVE observable —
something that MUST appear when the system is healthy. An empty log is equally consistent with
"working" and "stopped entirely". Earned twice on 2026-07-27: the R-88 watcher (an empty quiesce
log could not distinguish a healthy loop from a dead one — retired in favour of the per-tier
`/backup/due` polls in `pveproxy/access.log`), and a hub DB copy whose write had silently failed,
returning a confident "0 events in window" from a file a day stale until its mtime was checked.
4. **A recommendation that is not followed gets one line saying why.** Silence reads as agreement and
the disagreement is lost. Twice in the R-88/R-97 arc a review point was absorbed rather than
argued: R-84 was folded into R-82 without a word, and R-97a's operator-only guard was dropped
while the claim it was meant to enforce got committed as a comment — which is how a false
guarantee shipped and survived a release. Disagreeing is fine; disagreeing silently is not.
- **Push to `main` directly** — no feature branches.
- **Versioning** via build-time ldflags (`-X main.version`/`-X main.Version`); bump on meaningful
changes + a CHANGELOG entry.
- Code quality: double-check for bugs/edge cases; add debug logging; **ask rather than guess** when
you'd otherwise need to invent input or output.
> **Clean-tree gate before any build:** `git status --porcelain` must be empty and
> `git rev-parse HEAD` must equal `git rev-parse origin/main` in the repo being built. An unpushed
@@ -103,12 +90,11 @@ These were agreed in conversation and lived nowhere, so they bound nobody. They
> **In every repository where you make a change, update both files in that repo:**
> - **`CHANGELOG.md`** — a cumulative log of **all** changes; newest entry on top.
> - **`REPORT.md`** — **overwrite** with a summary of the **most recent** implementation (or significant validation/operational run) only; not cumulative.
> - **`REPORT.md`** — **overwrite** with a summary of the **most recent** implementation (or
> significant validation/operational run) only; not cumulative.
>
> **Never write secrets** — tokens, passwords, private keys, API keys — into `CHANGELOG.md`, `REPORT.md`, or any committed file. Reference them as "stored out-of-band" instead.
- **Versioning** is via build-time ldflags (`-X main.version`/`-X main.Version`); bump on meaningful changes + add a CHANGELOG entry.
- Code quality: double-check for bugs/edge cases; add debug logging; **ask rather than guess** when you'd otherwise need to invent input or output.
> **Never write secrets** — tokens, passwords, private keys, API keys — into `CHANGELOG.md`,
> `REPORT.md`, or any committed file. Reference them as "stored out-of-band" instead.
## Live validation — no browser here
@@ -122,87 +108,75 @@ Local (this host): repos `/mnt/5_hdd/felhom.eu/git/<repo>`, build dirs
`/mnt/5_hdd/felhom.eu/build/felhom-{controller,hub,agent}`, `sudo kubectl`, Go toolchain, Docker
build+push to `gitea.dooplex.hu/admin/`.
| Host | Access | Use | Blast radius |
|---|---|---|---|
| **DooPlex (this host)** | local — Debian 13, `kisfenyo`, `/mnt/5_hdd/felhom.eu/` | build/push images, `sudo kubectl`, build+run the agent for tests | **Tier 2 — precious.** It *is* the recovery chain (hub, Gitea, registry, PBS, k3s+Longhorn). **Never a drill target** |
| Demo Proxmox host `demo-hp` (HP t740) | `ssh demo-hp` (tailnet `100.76.96.79`; **no baked key** — G1 break-glass password vaulted in the hub) | **the designated drill + build VM host** (operator ruling 2026-07-25) | **Tier 0 — disposable. Reach here first** |
| Demo Proxmox host `demo-felhom` (N100) | `ssh felhom-pve` (root, no sudo; tailnet `100.70.170.35`) | pveum/pct + live Proxmox validation | **Tier 0 — disposable** |
| Demo guest 9201 | `ssh felhom-pve "pct exec 9201 -- ..."` | the live demo controller | Tier 0 (rides its host) |
| felhotest (legacy) | `ssh -p 33022 kisfenyo@router.abonet.hu`**`Connection refused` 2026-07-30** | OLD /opt/docker compose mechanism | untiered — assume nothing |
**Host addresses, routes, break-glass and per-node facts:**
`felhom.eu/documentation/operations/nodes.md` — the single home. Do not restate them elsewhere.
**Which box do I break?** → **`felhom.eu/documentation/runbooks/target-selection.md`** — the tiers, and
**Which box do I break?** → `felhom.eu/documentation/runbooks/target-selection.md` — the tiers, and
per machine what is freely permitted / needs care / forbidden, each with its reason. Read it before
picking a machine for a drill, a destructive test or a throwaway VM. **A task that needs a victim names
one; an absent fence is not permission.**
picking a machine for a drill, a destructive test or a throwaway VM. **A task that needs a victim
names one; an absent fence is not permission.** DooPlex is **Tier 2 — precious**: it *is* the recovery
chain, and never a drill target.
**Component versions are not recorded in any inventory doc** — agent/controller/hub versions change
several times a day and the fleet is not uniform. Ask the hub's `/hosts` + `/configs`, or
`felhom-agent --version` / `pct exec <vmid> -- docker ps` on the box.
The demo Proxmox host key changes on reprovision (N100) → refresh with
`ssh-keygen -R 192.168.0.162` then connect with `-o StrictHostKeyChecking=accept-new`
(`ssh-keyscan` hangs — avoid it).
## Memory
## Legacy: Windows workstation
Project memory lives at `/mnt/5_hdd/felhom.eu/git/.claude-memory/`, surfaced via
`~/.claude/projects/-mnt-5-hdd-felhom-eu-git/memory` (symlink); `MEMORY.md` is the index. Memories
reflect what was true when written — **verify a named file/flag still exists before acting on it.**
Kept so the old environment can be revived; **not the current setup**.
- Repos were in `E:\git\` (`/e/git/` in Git Bash); this file lived at `E:\git\CLAUDE.md`.
- **SSH binary had to be** `SSH=/c/Windows/System32/OpenSSH/ssh.exe` — Git Bash's `/usr/bin/ssh`
lacks access to the Windows SSH Agent and fails silently. Every remote command was
`$SSH kisfenyo@192.168.0.180 "..."`; details in `felhom-controller/docs/vscode-ssh-fix.md`.
- `pct exec` over SSH needed `export MSYS_NO_PATHCONV=1` (MSYS mangled `/`-paths).
- Agent deploy was a two-hop copy: build on 180 → `scp` to the Windows box (local path needed
`cygpath -w`) → `scp` on to felhom-pve. Beware CRLF when scp-ing config files through Windows.
- Skills were installed as Windows junctions (`mklink /J`) rather than POSIX symlinks.
- `claude-in-chrome` browser automation WAS available there (attaching only to sessions started
after the bridge connected).
### Presence is not success
## Presence is not success
A timestamp recording an **attempt** must never be read as evidence of a **result**. Where a status
field travels alongside a timestamp, the verdict consults both — or the timestamp records only
successes.
successes. Ask of any timestamp: *what exactly must have happened for this to be set?* If the answer
is "we tried", it cannot answer "did it work".
| # | instance | what happened |
|---|---|---|
| 1 | **F-CRIT-2** | a phantom snapshot's ctime set tier freshness — an aborted 1-byte upload made the tier look backed up |
| 2 | **R-100** | `LastRun` is written on failure, so a nightly-failing offsite tier kept the staleness clock fresh forever |
**Corollary:** when a verdict changes which field it counts from, the alarm text has to change with
it. Leaving the message reading `last run 8h ago` while alarming on a six-day-old success turns a true
alarm into one the operator dismisses.
Both were found by asking of a timestamp: *what exactly must have happened for this to be set?* If the
answer is "we tried", it cannot answer "did it work".
<!--
Two instances. F-CRIT-2: a phantom snapshot's ctime set tier freshness — an aborted 1-byte upload
made the tier look backed up. R-100: LastRun is written on failure, so a nightly-failing offsite tier
kept the staleness clock fresh forever. Both found by asking of a timestamp what must have happened
for it to be set.
-->
Corollary, from R-100's fix: when a verdict changes which field it counts from, **the alarm text has to
change with it**. Leaving the message reading `last run 8h ago` while alarming on a six-day-old success
turns a true alarm into one the operator dismisses.
### A comment asserting an invariant needs a test pinning it, or it is a wish
## A comment asserting an invariant needs a test pinning it, or it is a wish
**Nine instances in this project have shipped guarantees the code did not provide** — each survived
review because the comment read as settled:
| # | Comment | What it claimed | What the code did |
|---|---|---|---|
| 1 | `EffectiveProtected` | a stack was protected | it was not — the samba false alarm |
| 2 | `newestArchiveOn` | *"errors degrade to unknown, never to no-backup"* | the `(time,bool)` signature made that impossible (R-88 Part 2) |
| 3 | R-97a operator-only | the event *"cannot be routed to a customer"* | only configuration stopped it; fixed by a real `operatorOnlyEvents` register |
| 4 | `classifyRunStates` I1 | *"StateStopped means deliberately stopped by the user"* | quiesce stops stacks the same way — a failed restart was silent (F-CRIT-1) |
| 5 | `inflight.go` | *"a caller that cannot acquire DEFERS"* | the backup caller recorded a failure and paged the operator (F-A1) |
| 6 | `quiesce.go` | the agent's 409 *prevents* "a spurious failure" | on the start path it produced one (F-A1) |
| 7 | `recovery_unit.go` B2 refusal (R-181) | *"the previous unit is untouched and NOTHING was deleted"* | *nothing deleted* held; **untouched was measured false** — the floor was checked ONLY in `captureAllRecoveryUnits`, while the two dump legs wrote the bulk into the same tree first and unguarded, so a 182,272 B tar became 2,147,666,432 B under a manifest that had not moved |
| 8 | `ResolveManagedFloor` (R-216) | *"never push a controller past the agent it depends on"* | it compared the box's agent against the **golden's** MinAgent while serving a **floor** that could point elsewhere. Raise a floor above the vouched golden — which the day-0 runbook recommends and a per-customer override makes trivial — and the guard checks a version it is not serving. Measured live 2026-08-05: golden 0.192.0/MinAgent 0.113.0, floor 0.200.0, agent 0.120.0 → served, and the box landed on a controller needing 0.125.0. Its customer was then told their correct recovery code was wrong. **The first entry in this table where the false invariant was a GUARD, not a comment alone.** Fixed hub v0.97.0: a floor above the vouched golden is HELD, with its own reason |
| 9 | `escrow/recover.go` header (R-224) | *"The errors below are DISTINCT on purpose"*, naming **three** situations | there were **four**. A failed FETCH was wrapped as an anonymous error and fell through the local-api handler's `default` into the wrong-code answer, so a hub that could not be reached was reported to the customer as a bad recovery code. Measured live 2026-08-05 (CAMPAIGN-11 F3/F4) with a **correct current** code: **0.0556 s** with the hub firewalled off and **0.0299 s** with the agent stopped, against ~1.0 s for a genuine unseal — the machine accused the customer of something it had not attempted. **AND A GREEN TEST NAMED IT AND DID NOT PREVENT IT:** `TestRecoverOffsiteRepoPassword_FetchErrorIsDistinct` has said since v0.125.0 that *"the operator must not be sent to re-read their recovery code because the hub was unreachable"* — it asserted this package's error **string**, one layer below where the merge happened, and a string is not something a caller can branch on. **Mechanism asserted, consequence unpinned.** Fixed agent v0.126.0 (`ErrBundleFetch` → HTTP 502) + controller v0.202.0 (classify by value; the typing message reachable from ONE class; unknown → neutral) |
Three of these (4, 5/6 and 7) were found **on live hardware**, not by review or unit tests — #4 had a
green, red-proofed test suite over a production path that was broken two independent ways, and #7
survived a full green suite plus three of its own red-proofs, because every one of them asserted the
mechanism inside `captureAllRecoveryUnits` and none asserted the **consequence** across the whole
backup run. The test that would have caught it is the one #7's fix ships: fingerprint the tree before
and after, and compare. So:
review because the comment read as settled, and three were caught only on live hardware. The case
table is in the **`felhom-testing`** skill, which loads when you write or review a test, harden a
guard, or fix a bug.
- If a comment states an invariant, **name the test that pins it**, or write one.
- If an invariant has a stated dependency (*"if either invariant changes, revisit this"*), that is
not a safeguard — nobody revisits. Pin it with a test that fails when the dependency moves.
- If an invariant has a stated dependency (*"if either invariant changes, revisit this"*), that is not
a safeguard — nobody revisits. Pin it with a test that fails when the dependency moves.
- Prefer a test that asserts the **consequence** (does the alarm fire?) over one that asserts the
**mechanism** (does suppression expire?). R-97b's Scenario F proved the mechanism and the
consequence was still broken.
<!--
LEGACY: WINDOWS WORKSTATION — kept so the old environment can be revived; not the current setup.
- Repos were in E:\git\ (/e/git/ in Git Bash); this file lived at E:\git\CLAUDE.md.
- SSH binary had to be SSH=/c/Windows/System32/OpenSSH/ssh.exe — Git Bash's /usr/bin/ssh lacks
access to the Windows SSH Agent and fails silently. Every remote command was
$SSH kisfenyo@192.168.0.180 "..."; details in felhom-controller/docs/vscode-ssh-fix.md.
- pct exec over SSH needed export MSYS_NO_PATHCONV=1 (MSYS mangled /-paths).
- Agent deploy was a two-hop copy: build on 180 -> scp to the Windows box (local path needed
cygpath -w) -> scp on to felhom-pve. Beware CRLF when scp-ing config files through Windows.
- Skills were installed as Windows junctions (mklink /J) rather than POSIX symlinks.
- claude-in-chrome browser automation WAS available there (attaching only to sessions started after
the bridge connected).
THIS FILE'S SHAPE (2026-08-06, instruction-trim task): core + path-scoped rules. Removed here and
rehomed, not lost — the per-repo guidance list (those files load on their own), the skills roster
(already resident in the skill listing), the host table (nodes.md is the single home), the "(119
files)" memory count (derivable and wrong — 158), and the nine-row invariant table (felhom-testing
skill). Full accounting: felhom.eu/documentation/audits/LEDGER-instruction-trim-2026-08-06.md
An HTML comment is invisible to Claude and costs no context — verified 2026-08-06 with a control
(both markers plain -> both seen) and a treatment (one marker commented -> not seen), twice.
-->
+31
View File
@@ -1,3 +1,34 @@
## instructions_gate.py 1.0.0 — instruction files cannot silently regrow (2026-08-06, R-229)
New shared gate, registered in `controller_gates.py` and `agent_gates.py`, never copied into a
sibling repo (the `reuse_refs_check.py` precedent). `--fast` safe.
**It is a consistency gate, not a budget gate, and the failure message says so.** A `/context`
reading on 2026-08-06 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 Anthropic's *adherence* guidance, and a file nobody can hold in their head is
where contradictions hide: four were found in this project on the same day, two of which decided
where a destructive drill runs.
Checks, all 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 found while building it, recorded so they are not reintroduced:**
- A bare `\d+\.\d+\.\d+` matches the first three octets of **every IPv4 address**. The gate
excludes dotted quads; without that it fails on `192.168.0.180` in the agent's own file.
- `unconditional: true` is **not** a Claude Code feature — it is this project's marker, asserting
that always-loading was deliberate. The docstring says so, so nobody looks for it in the product.
`test_instructions_gate.py`: 20 fixture assertions, all on the **effect** (exit code *and* that the
message names the file and the reason). Includes the load-bearing negatives — 400 commented lines
must not trip the ceiling, and a version literal inside a comment must be allowed — because the gate
must not punish the very move it exists to encourage. Companion red-proof recorded in the ledger:
ceiling temporarily set to 100 against the real trimmed files, `felhom-agent` (173) FAILED and was
named, `felhom-controller` (92) still passed; threshold restored, suite re-run green.
## 1.25.0 — the off-site tier stops asking to prune (2026-08-04, R-191)
**A backup that worked must not report failure.** The off-site tier was written with `keep_last: 2`,
+238
View File
@@ -0,0 +1,238 @@
# -*- coding: utf-8 -*-
"""Instruction-file consistency gate — keeps CLAUDE.md files from silently regrowing.
Usage: python3 scripts/instructions_gate.py <repo-root> [<repo-root> ...]
python3 scripts/instructions_gate.py --fast <repo-root> (identical: no network,
no container runtime)
THIS IS A CONSISTENCY GATE, NOT A BUDGET GATE. Measured 2026-08-06 on a 1M-token window: the
instruction files occupied 15k tokens against 869k free. Space is NOT the constraint here and no
failure message may claim it is. The line ceiling exists because longer instruction files reduce
ADHERENCE Anthropic's guidance is to keep a CLAUDE.md under 200 lines — and because a file nobody
can hold in their head is where contradictions hide. Four were found in this project on 2026-08-06,
two of which decided where a destructive drill runs.
WHAT IS COUNTED. Every check runs against EFFECTIVE text: block-level HTML comments are stripped
first, because they are stripped before injection and never reach the model. Verified empirically on
Claude Code 2.1.222 with a control (two plain markers -> both seen) and a treatment (one marker
inside <!-- -->, twice -> not seen). That is the whole point of the comment convention: earned
rationale stays in the repo for human readers at zero cost to the instructions.
CHECKS (each names the file and the reason; a missing input is a FAILURE, never a skip):
1. <root>/CLAUDE.md is at most MAX_LINES effective lines.
2. Every <root>/.claude/rules/*.md declares `paths:` in frontmatter, or `unconditional: true`.
NOTE: `paths:` is a Claude Code feature a rule carrying it loads only when a file matching
one of its globs is read. `unconditional: true` is NOT a product feature; it is OUR marker,
asserting that always-loading was deliberate. The product loads a rule with no `paths:` key
unconditionally either way, so this check catches the ACCIDENT, not the product behaviour.
3. No component version literal in effective text. Versions change several times a day and the
fleet is not uniform, so a version in an instruction file is stale within a day (ask the hub's
/hosts + /configs, or the box). Historical citations belong in an HTML comment beside the rule
they justify, where they inform a human and cannot go stale in the model's view.
IPv4 addresses are NOT versions a bare \\d+\\.\\d+\\.\\d+ matches the first three octets of
every one of them, which is how this check would otherwise fail on its own repo.
4. No TEMPORARY block carrying a date already past. The block this gate was written for said
"TEMPORARY - until ~2026-08-02 ... Delete this block on return" and was still being read as
current fact on 2026-08-06, while a sibling repo's CLAUDE.md asserted the opposite.
5. The workspace-root CLAUDE.md and its versioned copy at
felhom.eu/documentation/runbooks/workspace-CLAUDE.md are byte-identical. The live file sits in
a directory that is not a git repo, so the copy is the only version-controlled record of it;
nothing but this check enforces that they agree.
THE POSITIVE OBSERVABLE. Every root prints a per-check tally with the measured numbers, not just a
verdict. "0 failures" alone cannot tell a working gate from a blind one if the effective line
count suddenly reads 3, the stripper broke, and the count is where you see it.
The kill condition is pinned by scripts/test_instructions_gate.py: an over-length CLAUDE.md still
FAILS, and a rule file with neither marker still FAILS.
"""
import os
import re
import sys
MAX_LINES = 200
# Block-level HTML comments: stripped before injection, so they cost nothing and are not counted.
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
# A semver-ish literal that is NOT part of a dotted quad (IPv4) and not part of a longer run.
VERSION_RE = re.compile(r"(?<![\d.])\d+\.\d+\.\d+(?![\d.])")
# "TEMPORARY" anywhere on a line, plus an ISO date somewhere in that block.
TEMPORARY_RE = re.compile(r"TEMPORARY")
ISO_DATE_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
WORKSPACE_COPY = os.path.join(
"felhom.eu", "documentation", "runbooks", "workspace-CLAUDE.md"
)
def effective(text):
"""The text the model actually receives: HTML comments removed."""
return COMMENT_RE.sub("", text)
def today_tuple():
"""Local date as (y, m, d). Injectable via FELHOM_GATE_TODAY for the test suite."""
override = os.environ.get("FELHOM_GATE_TODAY")
if override:
m = ISO_DATE_RE.match(override.strip())
if m:
return tuple(int(g) for g in m.groups())
import datetime
d = datetime.date.today()
return (d.year, d.month, d.day)
def check_length(path, failures, tally):
with io_open(path) as fh:
eff = effective(fh.read())
n = eff.count("\n")
tally.append(" CLAUDE.md effective lines : %d (ceiling %d)" % (n, MAX_LINES))
if n > MAX_LINES:
failures.append(
"%s: %d effective lines, ceiling %d. This is an ADHERENCE limit, not a space "
"limit — long instruction files get followed less reliably and hide "
"contradictions. Move path-bound guidance into .claude/rules/*.md with a `paths:` "
"list, procedures into the skill that already covers them, and earned rationale "
"into an HTML comment (free: stripped before injection)."
% (path, n, MAX_LINES)
)
def check_rules(root, failures, tally):
rules_dir = os.path.join(root, ".claude", "rules")
if not os.path.isdir(rules_dir):
tally.append(" rule files : none (no .claude/rules/)")
return
names = sorted(n for n in os.listdir(rules_dir) if n.endswith(".md"))
scoped = 0
for name in names:
path = os.path.join(rules_dir, name)
with io_open(path) as fh:
head = fh.read(2048)
has_paths = re.search(r"^paths:", head, re.M) is not None
has_uncond = re.search(r"^unconditional:\s*true\s*$", head, re.M) is not None
if has_paths:
scoped += 1
elif not has_uncond:
failures.append(
"%s: rule file has neither a `paths:` frontmatter list nor an explicit "
"`unconditional: true`. Without `paths:` Claude Code loads it in EVERY session, "
"which is rarely what a rule file is for — add the globs it applies to, or "
"declare `unconditional: true` to say the always-loading is deliberate." % path
)
tally.append(
" rule files : %d (%d path-scoped)" % (len(names), scoped)
)
def check_versions(path, failures, tally):
with io_open(path) as fh:
eff = effective(fh.read())
hits = []
for i, line in enumerate(eff.split("\n"), 1):
for m in VERSION_RE.finditer(line):
hits.append((i, m.group(), line.strip()[:90]))
tally.append(" version literals : %d" % len(hits))
for lineno, ver, ctx in hits:
failures.append(
"%s:%d: component version literal %r — versions change several times a day and the "
"fleet is not uniform, so this is stale within a day. Ask the hub (/hosts, /configs) "
"or the box. A historical citation belongs in an HTML comment beside the rule it "
"justifies.\n %s" % (path, lineno, ver, ctx)
)
def check_temporary(path, failures, tally):
with io_open(path) as fh:
eff = effective(fh.read())
lines = eff.split("\n")
today = today_tuple()
found = 0
for i, line in enumerate(lines, 1):
if not TEMPORARY_RE.search(line):
continue
found += 1
window = "\n".join(lines[i - 1 : i + 6])
for m in ISO_DATE_RE.finditer(window):
when = tuple(int(g) for g in m.groups())
if when < today:
failures.append(
"%s:%d: TEMPORARY block carrying the past date %s. A temporary block that "
"outlives its own deadline is read as current fact — this gate exists "
"because one did, for four days, while a sibling repo asserted the "
"opposite. Delete it; the audit trail is the record."
% (path, i, m.group())
)
break
tally.append(" TEMPORARY blocks : %d" % found)
def check_workspace_copy(workspace_root, failures, tally):
live = os.path.join(workspace_root, "CLAUDE.md")
copy = os.path.join(workspace_root, WORKSPACE_COPY)
if not os.path.exists(live) or not os.path.exists(copy):
tally.append(" workspace copy : n/a (not this workspace)")
return
with io_open(live) as a, io_open(copy) as b:
same = a.read() == b.read()
tally.append(" workspace copy identical : %s" % ("yes" if same else "NO"))
if not same:
failures.append(
"%s and %s have diverged. The live workspace file sits in a directory that is not a "
"git repo, so the copy is its only version-controlled record — nothing but this "
"check enforces that they agree. Copy the live file over the versioned one."
% (live, copy)
)
def io_open(path):
return open(path, "r", encoding="utf-8")
def main(argv):
roots = [a for a in argv[1:] if a != "--fast"]
if not roots:
sys.stderr.write("usage: instructions_gate.py [--fast] <repo-root> ...\n")
return 2
failures = []
for root in roots:
root = os.path.abspath(root)
print("instructions_gate: %s" % root)
tally = []
claude_md = os.path.join(root, "CLAUDE.md")
if not os.path.exists(claude_md):
failures.append(
"%s: no CLAUDE.md. A missing input is a FAILURE, never a skip — a gate that "
"silently passes on an absent file is how the check stops running." % root
)
else:
check_length(claude_md, failures, tally)
check_versions(claude_md, failures, tally)
check_temporary(claude_md, failures, tally)
check_rules(root, failures, tally)
check_workspace_copy(os.path.dirname(root), failures, tally)
for line in tally:
print(line)
if failures:
print("")
print("instructions_gate: %d FAILURE(S)" % len(failures))
for f in failures:
print(" - %s" % f)
return 1
print("")
print("instructions_gate: OK")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+200
View File
@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
"""Fixture tests for instructions_gate.py.
Run: python3 scripts/test_instructions_gate.py
Every test asserts the EFFECT the gate's exit code AND that its message names the file and the
reason not merely that "it ran". A gate that exits non-zero for the wrong reason is not a gate.
The negatives matter as much as the positives here: the whole point of the HTML-comment convention
is that commented content costs nothing, so `test_comments_do_not_count_toward_ceiling` and
`test_version_literal_in_comment_is_allowed` are what stop the gate from punishing the very move it
is meant to encourage.
"""
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
GATE = os.path.join(HERE, "instructions_gate.py")
PASSED = []
FAILED = []
def run_gate(root, today="2026-08-06"):
env = dict(os.environ, FELHOM_GATE_TODAY=today)
p = subprocess.run(
[sys.executable, GATE, root],
capture_output=True,
text=True,
env=env,
)
return p.returncode, p.stdout + p.stderr
def make_repo(tmp, claude_md, rules=None):
root = os.path.join(tmp, "repo")
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, "CLAUDE.md"), "w", encoding="utf-8") as fh:
fh.write(claude_md)
if rules:
rd = os.path.join(root, ".claude", "rules")
os.makedirs(rd, exist_ok=True)
for name, body in rules.items():
with open(os.path.join(rd, name), "w", encoding="utf-8") as fh:
fh.write(body)
return root
def check(name, cond, detail=""):
(PASSED if cond else FAILED).append(name + (("" + detail) if detail else ""))
print((" PASS " if cond else " FAIL ") + name + ((" " + detail) if detail else ""))
def test_short_file_passes():
with tempfile.TemporaryDirectory() as tmp:
root = make_repo(tmp, "# Repo\n" + "a line\n" * 150)
rc, out = run_gate(root)
check("150-line CLAUDE.md exits 0", rc == 0, "rc=%d" % rc)
def test_long_file_fails_and_names_file_and_count():
with tempfile.TemporaryDirectory() as tmp:
root = make_repo(tmp, "# Repo\n" + "a line\n" * 204)
rc, out = run_gate(root)
check("205-line CLAUDE.md exits non-zero", rc != 0, "rc=%d" % rc)
check("message names the file", "CLAUDE.md" in out)
check("message names the effective line count", "205 effective lines" in out)
check(
"message says ADHERENCE, not space",
"ADHERENCE limit, not a space limit" in out,
)
def test_comments_do_not_count_toward_ceiling():
"""The convention's load-bearing negative: 400 commented lines must not trip the ceiling."""
with tempfile.TemporaryDirectory() as tmp:
body = "# Repo\n" + "a line\n" * 100 + "<!--\n" + "history\n" * 400 + "-->\n"
root = make_repo(tmp, body)
rc, out = run_gate(root)
check("400 commented lines do not trip the ceiling", rc == 0, "rc=%d" % rc)
def test_rule_with_paths_passes():
with tempfile.TemporaryDirectory() as tmp:
root = make_repo(
tmp,
"# Repo\n",
rules={"scoped.md": '---\npaths: ["**/*.go"]\n---\n\n# Scoped\n'},
)
rc, out = run_gate(root)
check("rule with paths: exits 0", rc == 0, "rc=%d" % rc)
def test_rule_without_paths_fails():
with tempfile.TemporaryDirectory() as tmp:
root = make_repo(
tmp, "# Repo\n", rules={"bare.md": "---\nname: bare\n---\n\n# Bare\n"}
)
rc, out = run_gate(root)
check("rule with neither marker exits non-zero", rc != 0, "rc=%d" % rc)
check("message names the rule file", "bare.md" in out)
def test_rule_with_unconditional_marker_passes():
with tempfile.TemporaryDirectory() as tmp:
root = make_repo(
tmp,
"# Repo\n",
rules={"always.md": "---\nunconditional: true\n---\n\n# Always\n"},
)
rc, out = run_gate(root)
check("explicit unconditional: true exits 0", rc == 0, "rc=%d" % rc)
def test_version_literal_fails():
with tempfile.TemporaryDirectory() as tmp:
root = make_repo(tmp, "# Repo\n\nThe box runs agent 0.93.0 today.\n")
rc, out = run_gate(root)
check("version literal exits non-zero", rc != 0, "rc=%d" % rc)
check("message quotes the version", "0.93.0" in out)
def test_ipv4_is_not_a_version():
"""A bare \\d+\\.\\d+\\.\\d+ matches the first three octets of every IPv4."""
with tempfile.TemporaryDirectory() as tmp:
root = make_repo(tmp, "# Repo\n\nDooPlex is 192.168.0.180 and the demo box is 10.0.0.1.\n")
rc, out = run_gate(root)
check("IPv4 addresses are not flagged as versions", rc == 0, "rc=%d" % rc)
def test_version_literal_in_comment_is_allowed():
with tempfile.TemporaryDirectory() as tmp:
root = make_repo(tmp, "# Repo\n\n<!-- fixed in hub v0.97.0 -->\n")
rc, out = run_gate(root)
check("version literal inside a comment is allowed", rc == 0, "rc=%d" % rc)
def test_expired_temporary_fails():
with tempfile.TemporaryDirectory() as tmp:
root = make_repo(
tmp,
"# Repo\n\n> **TEMPORARY — host is away (until ~2026-08-02).**\n> Delete on return.\n",
)
rc, out = run_gate(root)
check("expired TEMPORARY block exits non-zero", rc != 0, "rc=%d" % rc)
check("message quotes the past date", "2026-08-02" in out)
def test_future_temporary_passes():
with tempfile.TemporaryDirectory() as tmp:
root = make_repo(
tmp, "# Repo\n\n> **TEMPORARY — host is away (until ~2026-12-31).**\n"
)
rc, out = run_gate(root)
check("TEMPORARY with a future date exits 0", rc == 0, "rc=%d" % rc)
def test_missing_claude_md_fails():
with tempfile.TemporaryDirectory() as tmp:
root = os.path.join(tmp, "repo")
os.makedirs(root)
rc, out = run_gate(root)
check("absent CLAUDE.md is a FAILURE, not a skip", rc != 0, "rc=%d" % rc)
def test_diverged_workspace_copy_fails():
with tempfile.TemporaryDirectory() as tmp:
ws = os.path.join(tmp, "ws")
root = os.path.join(ws, "repo")
os.makedirs(root)
with open(os.path.join(root, "CLAUDE.md"), "w", encoding="utf-8") as fh:
fh.write("# Repo\n")
with open(os.path.join(ws, "CLAUDE.md"), "w", encoding="utf-8") as fh:
fh.write("# Workspace live\n")
cp = os.path.join(ws, "felhom.eu", "documentation", "runbooks")
os.makedirs(cp)
with open(os.path.join(cp, "workspace-CLAUDE.md"), "w", encoding="utf-8") as fh:
fh.write("# Workspace copy — DIVERGED\n")
rc, out = run_gate(root)
check("diverged workspace copy exits non-zero", rc != 0, "rc=%d" % rc)
check("message names both files", "workspace-CLAUDE.md" in out)
def main():
print("test_instructions_gate")
for fn in sorted(
(v for k, v in globals().items() if k.startswith("test_")),
key=lambda f: f.__name__,
):
fn()
print("")
print("passed: %d failed: %d" % (len(PASSED), len(FAILED)))
return 1 if FAILED else 0
if __name__ == "__main__":
sys.exit(main())
+25
View File
@@ -65,6 +65,31 @@ now, it is a regression. **The lesson generalises: "known flake, just re-run it"
it needs the same evidence as any other one.** A test that fails at a stable, explainable rate is
usually telling the truth about a rare input, not misbehaving.
## A comment asserting an invariant needs a test pinning it — the nine instances
The three directives live in the workspace-root `CLAUDE.md` and apply always. This is the evidence
behind them: **nine shipped guarantees the code did not provide**, each surviving review because the
comment read as settled. Read this table when you are about to trust a comment, or write one.
| # | Comment | What it claimed | What the code did |
|---|---|---|---|
| 1 | `EffectiveProtected` | a stack was protected | it was not — the samba false alarm |
| 2 | `newestArchiveOn` | *"errors degrade to unknown, never to no-backup"* | the `(time,bool)` signature made that impossible (R-88 Part 2) |
| 3 | R-97a operator-only | the event *"cannot be routed to a customer"* | only configuration stopped it; fixed by a real `operatorOnlyEvents` register |
| 4 | `classifyRunStates` I1 | *"StateStopped means deliberately stopped by the user"* | quiesce stops stacks the same way — a failed restart was silent (F-CRIT-1) |
| 5 | `inflight.go` | *"a caller that cannot acquire DEFERS"* | the backup caller recorded a failure and paged the operator (F-A1) |
| 6 | `quiesce.go` | the agent's 409 *prevents* "a spurious failure" | on the start path it produced one (F-A1) |
| 7 | `recovery_unit.go` B2 refusal (R-181) | *"the previous unit is untouched and NOTHING was deleted"* | *nothing deleted* held; **untouched was measured false** — the floor was checked ONLY in `captureAllRecoveryUnits`, while the two dump legs wrote the bulk into the same tree first and unguarded, so a 182,272 B tar became 2,147,666,432 B under a manifest that had not moved |
| 8 | `ResolveManagedFloor` (R-216) | *"never push a controller past the agent it depends on"* | it compared the box's agent against the **golden's** MinAgent while serving a **floor** that could point elsewhere. Measured live 2026-08-05: golden 0.192.0/MinAgent 0.113.0, floor 0.200.0, agent 0.120.0 → served, and the box landed on a controller needing 0.125.0. Its customer was then told their correct recovery code was wrong. **The first entry where the false invariant was a GUARD, not a comment alone.** Fixed hub v0.97.0 |
| 9 | `escrow/recover.go` header (R-224) | *"The errors below are DISTINCT on purpose"*, naming **three** situations | there were **four**. A failed FETCH fell through the local-api handler's `default` into the wrong-code answer, so an unreachable hub was reported to the customer as a bad recovery code. Measured live 2026-08-05: **0.0556 s** hub-firewalled and **0.0299 s** agent-stopped, against ~1.0 s for a genuine unseal. **AND A GREEN TEST NAMED IT AND DID NOT PREVENT IT**`TestRecoverOffsiteRepoPassword_FetchErrorIsDistinct` asserted the package's error **string**, one layer below where the merge happened, and a string is not something a caller can branch on. **Mechanism asserted, consequence unpinned.** Fixed agent v0.126.0 + controller v0.202.0 |
**Three of these (4, 5/6 and 7) were found on live hardware**, not by review or unit tests. #4 had a
green, red-proofed suite over a production path broken two independent ways. #7 survived a full green
suite plus three of its own red-proofs, because every one asserted the mechanism inside
`captureAllRecoveryUnits` and none asserted the **consequence** across the whole backup run. The test
that would have caught it is the one #7's fix ships: **fingerprint the tree before and after, and
compare.**
## Live validation doctrine (after unit-land)
Exercise the SERVER-SIDE PIPELINE a real user triggers, end-to-end — never hand-set state around it