From c21bcf84f709ce461b6a502a2bd20e2795d45568 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 6 Aug 2026 09:38:52 +0200 Subject: [PATCH] docs+gate: instruction files cannot silently regrow (R-229) 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) Claude-Session: https://claude.ai/code/session_01JJc8sAGRWmavP3rMtdpkr2 --- CLAUDE.md | 113 ++++--- CONTEXT.md | 34 ++ REPORT.md | 301 +++++++++--------- .../LEDGER-instruction-trim-2026-08-06.md | 264 +++++++++++++++ documentation/backlog/OPEN-ITEMS.md | 6 + documentation/runbooks/workspace-CLAUDE.md | 246 +++++++------- scripts/CHANGELOG.md | 31 ++ scripts/instructions_gate.py | 238 ++++++++++++++ scripts/test_instructions_gate.py | 200 ++++++++++++ skills/felhom-testing/SKILL.md | 25 ++ 10 files changed, 1125 insertions(+), 333 deletions(-) create mode 100644 documentation/audits/LEDGER-instruction-trim-2026-08-06.md create mode 100644 scripts/instructions_gate.py create mode 100644 scripts/test_instructions_gate.py diff --git a/CLAUDE.md b/CLAUDE.md index dd4816f..5a84aa8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. + + + - **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). + + + ## 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//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 stringData — REUSE.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. + ## 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). + + + **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 diff --git a/CONTEXT.md b/CONTEXT.md index 5bac63a..afc8fa8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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/.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.** diff --git a/REPORT.md b/REPORT.md index ad9502c..df76c89 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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 `/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 1–3 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 2–5 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.59–1.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. diff --git a/documentation/audits/LEDGER-instruction-trim-2026-08-06.md b/documentation/audits/LEDGER-instruction-trim-2026-08-06.md new file mode 100644 index 0000000..70cd9f0 --- /dev/null +++ b/documentation/audits/LEDGER-instruction-trim-2026-08-06.md @@ -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:`, `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.6–1.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.7–2× 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. diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index 8b8b3ad..b655d53 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -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. diff --git a/documentation/runbooks/workspace-CLAUDE.md b/documentation/runbooks/workspace-CLAUDE.md index 15d9be6..d75230f 100644 --- a/documentation/runbooks/workspace-CLAUDE.md +++ b/documentation/runbooks/workspace-CLAUDE.md @@ -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 ". 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 ". 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. + ## 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/`, 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 -- 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". + -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. + + diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index dae8554..781ba7a 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -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`, diff --git a/scripts/instructions_gate.py b/scripts/instructions_gate.py new file mode 100644 index 0000000..f0f70a6 --- /dev/null +++ b/scripts/instructions_gate.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +"""Instruction-file consistency gate — keeps CLAUDE.md files from silently regrowing. + +Usage: python3 scripts/instructions_gate.py [ ...] + python3 scripts/instructions_gate.py --fast (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. /CLAUDE.md is at most MAX_LINES effective lines. + 2. Every /.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"(? 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] ...\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)) diff --git a/scripts/test_instructions_gate.py b/scripts/test_instructions_gate.py new file mode 100644 index 0000000..e888649 --- /dev/null +++ b/scripts/test_instructions_gate.py @@ -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" + 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\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()) diff --git a/skills/felhom-testing/SKILL.md b/skills/felhom-testing/SKILL.md index 1e77f38..066fe49 100644 --- a/skills/felhom-testing/SKILL.md +++ b/skills/felhom-testing/SKILL.md @@ -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