# LEDGER — instruction trim, part 2 (`felhom.eu`, the memory index, the versioned workspace)

Companion to `LEDGER-instruction-trim-2026-08-06.md`, which this closes legs (a) and (c) of. Same
per-block format; destinations are `rule-file`, `html-comment`, `already-in:<path>`,
`deleted-derivable`, `deleted-stale`, `kept-in-core`.

**Baselines (verified against live Gitea, all clean, all `HEAD == origin/main`):** `felhom.eu`
`c21bcf84f709`, `felhom-controller` `7db42c5fec3b`, `felhom-agent` `062a7027abff`.

**Concurrency:** a second Claude Code session (`f192bf03`, started 10:05) was live in these repos
throughout, doing `qm`/`ssh demo-hp` console work. Managed rather than waited out: clean-tree and
`origin/main` re-checked immediately before every commit, explicit-path staging only, `pull --rebase`
before every push, and the memory store snapshotted before edit and re-diffed after. Per this repo's
own rule for parallel sessions, this run wrote `REPORT-instruction-trim-part2-2026-08-06.md` and did
**not** touch the shared `REPORT.md`.

---

## A. Mechanism verification — done before relying on any of it

| Claim | Verdict | Evidence |
|---|---|---|
| `.claude/rules/*.md` + `paths:` loads **only** on a matching file read | **confirmed, both directions** | hook log, two fresh sessions: reading `website/index.html` → `website.md path_glob_match`, **no** `hub.md`; reading `hub/internal/api/handler.go` → `hub.md path_glob_match`, **no** `website.md` |
| Creating `.claude/rules/` **mid-session** arms it | **DISPROVEN** | after writing the four rule files, in-session reads of `website/index.html` produced **no hook line at all**. A directory whose instructions were already seeded is not re-scanned — same class as the settings-watcher caveat. This is why the proof above needed `claude -p` |
| On-demand rules are accounted under `Messages` in `/context` | **still inference, not measured** | two `/context all` readings: Memory-files stayed at exactly 11.5k across three rule loads while Messages grew 8 → 105.7k. That shows rules are **not** counted as memory files; it does not show where they *are*. Recorded as reasoned, per spec §3 |

The second row is the load-bearing one and it is a **negative result about the instrument**: a rule
file can be correct, pass every gate, and reach the model never, purely because of when it was
created. Nothing in the gate or the frontmatter would have shown that.

---

## B. `felhom.eu/CLAUDE.md` — 227 → 115 effective lines

| Heading / first words | Class | Destination | Reason |
|---|---|---|---|
| `## The Felhom system` three-component model (7 ln) | duplicated | `already-in:CLAUDE.md` (workspace root) | the root always loads and survives `/compact`; restating it here is a second place to edit |
| `## Hub — architecture` ingest paragraph (11 ln) | path-bound | `rule-file:hub.md` | only matters when `hub/` is being edited; the frozen-contract warning went with it |
| `## Hub stack — the two constraints` (7 ln) | path-bound | `rule-file:hub.md` | same trigger |
| `## Build & deploy — Hub (GitOps)` (25 ln) | path-bound | `rule-file:hub.md` | the clean-tree gate inside it was `already-in:CLAUDE.md` (workspace root) and dropped here |
| `## Key patterns` (8 ln) | path-bound | `rule-file:hub.md` | status logic, `parseSQLiteTime`, auto-refresh — all hub internals |
| seam-wiring / template-gates rule (4 ln) | path-bound | `rule-file:hub.md` | it is about `html/template` gates, which exist only in `hub/` |
| health-check block-I/O rule (5 ln) | path-bound | `rule-file:hub.md` | deliberately **duplicated** into three repos' rule files — a pointer to a sibling repo's rule does not load |
| logging levels/phrasing (3 ln) | path-bound | `rule-file:hub.md` | applies when writing Go |
| website deploy + BOM + emoji + cache-busting (10 ln) | path-bound | `rule-file:website.md` | |
| ArgoCD / deliberate sync / `secretKeyRef` (12 ln) | path-bound | `rule-file:manifests.md` | |
| doc routing, one-home-per-fact, N.5 coupling (part of the checklist) | path-bound | `rule-file:docs.md` | |
| `## Project overview` per-directory list (14 ln) | derivable | compressed to 6 lines + retrieval map | the tree says what the directories are; what it cannot say is which one is *authoritative for the whole system* — that was kept |
| artifact taxonomy, `TASK.md`/`RUNBOOK-*.md` (10 ln) | duplicated | `already-in:CLAUDE.md` (workspace root) | the root defines the taxonomy for all four repos |
| browser-automation / `claude-in-chrome` block (6 ln) | duplicated | `already-in:CLAUDE.md` (workspace root) | root's "Live validation — no browser here" |
| `## Environment & access` DooPlex + host pointers (22 ln) | duplicated | `already-in:CLAUDE.md` (workspace root) + `nodes.md` | replaced by two rows in the retrieval map |
| Legacy-Windows HTML comment (5 ln) | archival | `deleted-derivable` | the root carries the fuller version in its own comment |
| `## Build & deploy — Website/Manifests` R-110 installer block (17 ln) | **fence** | **`kept-in-core`, compressed to 6** | see §C |
| `## Gates — ONE entry point` (25 ln) | entry point | `kept-in-core`, compressed | §6 names it as core; the R-29 narrative stayed an HTML comment (free) |
| end-of-session checklist (18 ln) | **register discipline** | **`kept-in-core`** | see §C |

**Kept deliberately:** the gate entry point, the end-of-session checklist, register discipline, the
R-110 fence, the `git add -A` prohibition, the parallel-session `REPORT.md` rule, the `go test -run`
false-green rule, and "ask first — don't guess".

---

## C. Two placements that deviate from the spec's §6 sketch — and why

The spec fixed the rule-file names and globs and left line-by-line placement to the implementer.
Two blocks were **kept in the core** that §6's sketch would have relocated. Both would otherwise have
rebuilt the exact failure class they exist to prevent.

| Block | §6 would put it in | Kept in core because |
|---|---|---|
| **Register discipline** — `OPEN-ITEMS.md` first, `STATUS.md` is a view, nothing exists only in a report | `docs.md` (`paths: documentation/**`) | it applies at the end of **every** session that ships, breaks or decides something. Behind that glob it is invisible in precisely the code-shipping sessions where findings get lost — which is how R-153/154/155 and R-156/157 were minted and lost. A rule that loads only when you are already writing documentation cannot remind you to write documentation |
| **The R-110 installer fence** — pushing `felhom-host-install.sh` publishes nothing; publishing is a tag move | `website.md` (`paths: website/**`) | its trigger is editing `scripts/felhom-host-install.sh`, which **none of the four fixed globs matches**. Behind `website/**` it would never load for the one artifact that runs **as root on a virgin box**. Compressed to its actionable clauses rather than relocated |

`docs.md` states the first of these explicitly, so a reader who expects the rule there is told where
it went and why.

---

## D. The memory store — reconciled, nothing deleted

**Backup first (ruling 2):** `/mnt/5_hdd/felhom.eu/backups/claude-memory-20260806-103418`, 158 files,
taken before any edit.

| Measure | Before | After |
|---|---|---|
| `.md` files total | 158 | **158 — equal, zero deletions** |
| top-level topic files | 157 | 117 |
| `archive/` | — | 40 |
| indexed | 113 | **117 (all of them)** |
| orphaned | **44** | **0** |
| dangling references | 0 | 0 |
| `MEMORY.md` | 145 ln / 17,688 B | **150 ln / 17,977 B** |

**The discriminator was the store's own schema, not taste.** All 4 `reference`-type orphans are
durable pointers and were **indexed**; all 39 `project`-type orphans plus 1 untyped are dated episode
records — "SHIPPED", "spike GO", "FIXED in vX.Y.Z" — whose conclusions already live in CHANGELOGs and
`documentation/audits/`, and were **archived**. Each of the four also justifies indexing on content,
not merely type; `hetzner-storagebox-api.md` carries an **open security constraint** (the
project-scoped Hetzner token can delete ep0 — move customer boxes to a dedicated project before the
hub holds a write token).

**Detail moved, not dropped.** Seven index lines were shortened; for each, the removed detail was
grep-verified present in the topic file first. One check came back **negative**
(`controller-internals.md` did not contain the module path) — the line was trimmed anyway, caught on
review, and the detail written into `controller-internals.md`. Recorded because the verification step
is what caught it.

**Headroom:** 50 lines and 7,622 bytes (29%) against the 200-line / 25 KB limits.

---

## E. Staleness diagnosis (Part 2.4) — **diagnosed, deliberately not fixed**

A ruling is owed before anyone edits Claude's own notes at scale. Filed as **R-230(a)**.

| Class | Count | Sharpest instance |
|---|---|---|
| component version literals | **21 lines** | `ctrl 0.162.0`, `hub 0.64/0.65`, `installer 1.15.0` — the exact literal class the gate now bans in every hand-written instruction file |
| bare host addresses | **5 lines** | `100.70.170.35`, `167.233.158.164`, `10.77.0.1` — `nodes.md` is supposed to be the single home |
| expired temporal statements | 3 | **`"demo boxes REMOTE till ~08-02"`** |
| undated open items | 2 | `RENOVATE_GITHUB_COM_TOKEN ROTATION DUE` |

**The finding that matters is structural, not the counts.** `felhom-agent/CLAUDE.md` had its expired
`TEMPORARY — felhom-pve is at a remote site (until ~2026-08-02)` block deleted in part 1, and the gate
now fails any such block. The *same claim* is still live in `MEMORY.md` — which loads in every
session, is larger than the file the rule was written for, and is the one file whose content the gate
does not police. The contradiction was moved, not resolved: the hand-written half is clean and the
auto-written half still asserts the retired fact.

---

## F. The gate — check 6, and why its three outcomes differ

| Condition | Outcome | Reason |
|---|---|---|
| index over 200 lines or 25 KB | **FAIL** | auto-memory drops content past the limit with **no error** — silent truncation, no observable |
| top-level topic file unreferenced | **WARN** | the store is outside git and changes between sessions; a failure blocks pushes for something no commit can fix |
| store absent | **PASS, printing the reason** | machine-local by design; a clone elsewhere legitimately has none |

The third is a deliberate exception to check 1's *"a missing input is a FAILURE, never a skip"*. The
printed reason is the only thing that made it safe to grant, and the test asserts on **that text**,
not on `rc == 0` — a pass with no reason is indistinguishable from a gate that stopped running.

Two load-bearing negatives: a file reachable only via `[[wikilink]]` is **indexed, not orphaned**
(flagging it would push someone to add a duplicate row), and `archive/` contents are never orphans.

**Suite 20 → 39 assertions, 0 failures.** Red-proof against the **real** store, not a fixture:
ceiling 200 → 100, gate went red naming the real file and count
(`.claude-memory/MEMORY.md: 150 lines, ceiling 100`), restored, green again.

---

## G. The versioned workspace

**Canonical path decision (§4.3):** `documentation/runbooks/workspace-CLAUDE.md` **stays** the
source. `workspace/` carries only the hook fragment. A `workspace/CLAUDE.md` would be a *third* copy
of a file whose whole problem is that copies drift, and check 5 already enforces byte-identity.

**Symlink recommendation (§4.3): recommended, not done.** Making the live root file a symlink to the
versioned copy removes the divergence class outright, and Claude Code follows symlinks (the four
skills already are). Not done here because it changes what check 5 asserts — identity of two files
becomes correctness of one link — which deserves its own reviewable change. **R-230(b)**.

**Proofs.** Merge preserved all 7 top-level `settings.json` keys byte-identically (`sha256` of the
file minus `.hooks` unchanged across the write). Run 2 wrote nothing — *idempotent* means "changed
nothing the second time", not "ran twice without erroring", and only the second is evidence.

`install_workspace.py` **refuses** an unparseable `settings.json` rather than overwriting it: a
malformed settings file silently disables every setting in it, and overwriting would destroy whatever
was mid-fix.

---

## H. Backup coverage (§4.4) — and what it does *not* buy

Added to `dooplex-backup.service`'s User Data component via `CLAUDE_MEMORY_DIR`. **Positive
observable**, from the journal of the real unit:

```
Including Claude auto-memory store: /mnt/5_hdd/felhom.eu/git/.claude-memory
start backup on [/mnt/4_hdd/data /mnt/5_hdd/felhom.eu/git/.claude-memory]
```

**Completed and verified in the repository, not just in the log.** Snapshot `b587f775`
(2026-08-06 10:51:17), 58,158 files / 405.865 GiB in 27:56:

```
Files:       58158 new,     0 changed,     0 unmodified
Added to the repository: 994.132 MiB (77.440 MiB stored)
snapshot b587f775 saved
```

| Check | Result |
|---|---|
| snapshot lists the store path | yes — alongside `/mnt/4_hdd/data` |
| `.claude-memory` entries in the snapshot | **161** |
| top-level `.md` (incl. the index) | **118 / 118** |
| `archive/` `.md` | **40 / 40** |
| previous snapshot `511b7f9d` (03:07) | `/mnt/4_hdd/data` only — a clean before/after in one listing |

77 MiB stored for a 405 GiB re-read confirms the dedup prediction: the one-time full read cost I/O,
not storage.

**A false alarm I raised against my own instrument, recorded because the class is the point.** The
first verification used `restic ls <snapshot> <path>` and reported **0 of 40** archive files —
which read exactly like a real defect (40 memories silently unprotected). It was wrong: in restic
0.18.0 that path filter lists entries **at** the filtered path and does not recurse into its
subdirectories. The unfiltered `ls` shows all 40. This is the project's own rule — *an instrument
that can drop results silently is not a measurement* — caught on a measurement made **to verify a
safety property**, which is the worst place for a silent drop. Same class as the `| tail -5` census
and the `go test -run` false green already in the register.

**Three caveats, each of which makes this weaker than "backed up" sounds:**

1. The destination `/mnt/5_hdd/backup` is on the **same physical disk** (`sda1`) as the store. This
   protects against deletion, **not** against disk loss.
2. The DooPlex backup set has **no off-site leg**. `sync-hetzner-backups.sh` is a *jarrs.eu* job that
   pulls **from** Hetzner **to** DooPlex — the opposite direction.
3. `/opt/backup/scripts/` is itself **unversioned host state** (**R-231**) — the change exists only on
   the box, transcribed into `workspace/README.md` so it is at least recorded.

One-time cost, so it is not later read as a fault: adding a path invalidates restic's parent-snapshot
match, so the first run logs `no parent snapshot found` and re-reads all 405 GiB. Dedup keeps storage
flat; later runs are incremental again.

---

## I. Secrets scan of the memory store (§9.3)

Pattern set: `token|password|passwd|secret|api[_-]?key|private[_-]?key|BEGIN .*PRIVATE|bearer|credential`,
case-insensitive, over all 158 files.

| Measure | Count |
|---|---|
| lines matching the keyword set | 535 across 104 files |
| matching a `key: value` shape with a ≥16-char value | **1** — and it is prose (`scoped token: provision-into-pool, …`) |
| private-key blocks | **0** |

**No credential values.** Nothing from the store was committed regardless (ruling 2); the scan is the
evidence the later decision needs, not a licence.

---

## J. context7 (Part 5) — nothing written, by the rule

The operator's `/mcp` and `/plugin` output: `plugin:context7:context7` is **`✘ failed`** (ENOENT on
`npx -y @upstash/context7-mcp`), and `/mcp` lists only the six Google auth stubs as available. Per
§5, **write nothing** — instructions telling Claude to use a tool that does not exist are worse than
none. Not written: the `felhom-app-catalog` SKILL.md line and the infrastructure rule-file line.

**Why the Go code is not the use case either, recorded as §5 asks:** one direct dependency in
`felhom-agent` (`golang.org/x/crypto`), and the hub and controller are stdlib-only by standing rule.
There is no third-party API surface here that a docs-retrieval tool would help with; the catalog's
upstream images are the only plausible consumer, and there `docker inspect` still decides.

---

## J2. A stale claim found by the checklist's own last item — corrected in all four repos

The end-of-session checklist says **"confirm your own last push's CI run went green, by run ID."**
Doing that surfaced a contradiction in the file that had just been trimmed.

Four instruction files asserted that continuous integration **was still owed** (`OPEN-ITEMS.md`
R-168). **R-168 was CLOSED on 2026-08-02**: `.gitea/workflows/gates.yml` re-runs each repo's gate
entry point on every push and emails the operator on failure via Resend. This session's own commits
produced runs 117 (`success`) and 118 against the real SHAs.

| File | Status |
|---|---|
| `felhom.eu/CLAUDE.md` | **carried forward into the trim by this session** — corrected |
| `felhom-controller/.claude/rules/gates.md` | from part 1 — corrected |
| `felhom-agent/CLAUDE.md` | from part 1 — corrected |
| `app-catalog-felhom.eu/CLAUDE.md` | older wording, same claim — corrected |

**`felhom-agent/CLAUDE.md` contradicted itself.** Its release section already said *"R-168 sends that
failure by mail"* while its gates section said CI was still owed — a contradiction **inside a single
instruction file**, which is exactly the class the whole R-229 effort exists to find, sitting in a
file part 1 had already gone through.

Two lessons, both about this task rather than about CI:

1. **A trim carries stale claims forward unless each is re-checked.** Compressing 227 lines to 115 is
   a *volume* operation; it does not validate content. This sentence survived because it read as
   settled — the same reason the nine invariant comments in the project's own case table survived.
2. **The gate cannot catch this class.** It checks length, version literals, expired TEMPORARY blocks,
   rule scoping and copy identity. "This register item is closed" is not mechanically checkable from
   the instruction file alone. The checklist item that *did* catch it is a human PULL check, and it
   only worked because it demands a **run ID**, not a memory.

No new register row: found, fixed, and pushed in the same session. `REPORT.md` was deliberately **not**
overwritten in the three sibling repos — a one-line docs correction must not destroy the record of
their last real implementation.

---

## K. Observations — noticed, not acted on

1. **`target-selection.md` t740 error** — still present, carried from part 1 §L.1.
2. **`felhom-agent/CLAUDE.md` at 173 effective lines** — passing with the least headroom; R-229(b).
3. **The `/context` Messages-accounting inference** — see §A row 3. Not built on.
4. **`rules_report.py` first run: 6 of 9 rule files had never fired.** Not a defect list — the log
   only covers since the hook was armed, and `felhom-controller/gates.md` is proof of the
   distortion: it fired at 08:11 the same day, before installation, and reads as silent.
5. **The other session's read of `OPEN-ITEMS.md` fired `docs.md`** — independent third-party
   confirmation the new rules are live, from a session that knew nothing about them.
6. **`MEMORY.md`'s header still says "felhom-controller Project Memory"** though it indexes all four
   repos plus the homelab. Cosmetic; left for the R-230(a) ruling rather than edited.
