Found by the checklist's own last item (confirm your push's CI run by run ID). Corrected here and
in the three sibling repos. Ledger J2 records the two lessons: a trim is a VOLUME operation and
carries stale claims forward unless each is re-checked, and the gate cannot catch this class --
"this register item is closed" is not mechanically checkable from the instruction file.
227 -> 115 effective lines, split into .claude/rules/{hub,website,manifests,docs}.md, and
repo_gates.py gains gate 6. Trim first, register second: a registered-but-failing gate refuses
every push through the pre-push hook, which is why this repo -- the one that OWNS the gate --
was the only one not running it.
Register discipline and the R-110 installer fence deliberately stayed in the core; both have
triggers no fixed glob covers, and scoping them would have rebuilt the failure class they exist
to prevent.
Scoping proven from the InstructionsLoaded hook log in two fresh sessions, not from frontmatter.
New shared scripts/instructions_gate.py, registered in controller_gates.py and
agent_gates.py, never copied into a sibling repo (the reuse_refs_check.py
precedent). 20 fixture tests, all asserting the effect: exit code AND that the
message names the file and the reason.
It is a consistency gate, not a budget gate, and the failure message says so. A
/context reading measured the instruction files at 15k tokens against 869k free in
a 1M window -- space is not the constraint, and a future reader must not re-derive
the wrong reason. The 200-line ceiling is adherence guidance; a file nobody can
hold in their head is where contradictions hide, and five were found here.
Checks run against effective text (HTML comments stripped, because they are
stripped before injection): the line ceiling; every .claude/rules/*.md declares
paths: or an explicit unconditional: true; no component version literal; no
TEMPORARY block carrying a past date; and the workspace-root CLAUDE.md is
byte-identical to its versioned copy -- the live file sits outside any git repo,
so that copy is its only version-controlled record.
Two traps recorded so they are not reintroduced: a bare \d+\.\d+\.\d+ matches the
first three octets of every IPv4 (the gate excludes dotted quads, or it fails on
192.168.0.180 in the agent's own file); and unconditional: true is NOT a Claude
Code feature but this project's own marker.
Workspace-root CLAUDE.md 208 -> 182 lines (142 effective), copy kept identical.
The nine-instance invariant table moved into the felhom-testing skill, which
triggers when writing or reviewing a test; all three directive bullets stayed in
the core. felhom.eu/CLAUDE.md got surgical corrections only and is knowingly still
over the ceiling at 227 effective lines -- closing it needs the restructure R-229
defers, said plainly rather than quietly absorbed.
CONTEXT.md gains standing ruling S-35. OPEN-ITEMS.md gains R-229.
Docs only -- no Go, no version bump, nothing built or deployed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJc8sAGRWmavP3rMtdpkr2
R-182's direction REVERSED by Part 0's measurement. Filed yesterday as "the
reserve re-alerts on every status refresh" — too many alerts, seen at the
sending end. Measured at the receiving end: 9 events received today, 2 operator
emails sent. When two apps are refused in the same second the operator is told
about ONE; the other is dropped before LogNotification, so it leaves no row on
any channel and cannot be audited. The operator cooldown key is
customerID:eventType(+tier) and the capture-failed event carries `app` but no
`tier`, so the key has no app identifier. Same failure mode as R-97a, in a
second event type that never opted into the narrow fix. Nothing changed —
Part 0 was investigation only.
Correction owed: yesterday's report said "one recovery_unit_capture_failed per
app, HTTP 200". True of what the CONTROLLER pushed; a reader would take it as
"the operator was told about each app", which is false.
R-110 CLOSED (installer v1.23.0). Both channels moved. The spec's mechanism for
channel 2 rested on a factual error — the run-time fetches are sixteen, not
nine, and come from felhom-agent, not this repo — so no tag here could cover
them; pinned to the agent version being installed instead, on the operator's
ruling. Channel 3 needed no change: the URL never carried a ref, so no hub
change and no hub bump.
R-115 CLOSED. release-agent.sh builds, tags, publishes and verifies by an
independent download; check-published-versions.py refuses a tag with no package;
CI now runs the full gate set so it actually runs.
R-183 NEW+CLOSED: a fresh install fetched the vouched agent binary and its
sixteen config files from two different refs, and nothing compared them.
R-184 NEW: nothing stops the hub vouching a version that was never released.
The R-115 gate cannot see it — measured, the hub manifest and Gitea's package
listing are both 401 anonymously.
capability map: new PROVEN-LIVE row for the published installer channel.
STATUS.md 138 -> 127 lines.
Controller v0.189.0 implements operator decision D-b. Docs only here; no hub
change and no hub version bump.
- architecture/02-controller-module-map.md §0a — NEW, and it is the S-1 contract:
desired (app.yaml) / in-flight (own marker file) / observed (not persisted),
with the rule that ties them — never derive one from another. Absent desired
state means UNKNOWN, never "running". One file, one writer. D-b's binding
safety rule quoted verbatim.
- 00-capability-map.md — the boot-recovery row now rests on a recorded signal,
with the three live flows from 9201. The interrupted-operation half is marked
IMPLEMENTED, not PROVEN-LIVE: nobody killed the controller mid-backup on metal.
- OPEN-ITEMS/ROADMAP — R-166 SHIPPED with both blocking facts and their answers;
R-157 mechanism B CLOSED and A restated as the whole item; R-170 NEW (the
drive-backed boot gate still infers a Stop from a container count).
- STATUS.md — the "an app can stay switched off and nothing says so" line
rewritten to what is actually left: timing.
- CLAUDE.md — end-of-session checklist gains: confirm your own last push's CI run
went green, BY RUN ID. The failure email is a push signal; this is the pull check.
- CONTEXT.md S-12 — the rulings, and the two lessons worth carrying: a test that
constructs the thing it should prove the caller constructs is hollow (its
red-proof will say so), and a field-by-field struct rebuild in a save path is a
defect on sight.
SPIKE-ci-runner-2026-08-02.md: all six probes with method, measurement and ruling; none
STOPped. P2 (stock image has git but no python3) and P6 (a runner that loses its state
re-registers and orphans the old record) changed the design; P5 (a failed run signals
NOTHING) is why the alarm exists at all.
R-168 SHIPPED with its evidence. R-29 CLOSED — on the demonstrated alarm and not on a green
run, as required: the class it opened is answered at both ends, the hook refusing locally and
CI catching a --no-verify bypass and emailing. R-161 noted: its automatic half now exists for
the STATIC gate, while its original scope, the runtime gate, is deliberately still not
automatic and should stay that way.
NEW R-169 (grep established R-168 was the highest in use): CI can only report, because there
is no gate in the road. Making it blocking needs branch protection plus a PR workflow, both
of which change how the operator works — so it is theirs to decide, and the row states the
cost honestly rather than recommending it.
CONTEXT gains S-8 (CI detects, does not block, and why that is structural), S-9 (a detector
that tells no one is not finished, plus the curl and Cloudflare-1010 traps), S-10 (the runner
is unprivileged because DooPlex is Tier 2), S-11 (CI reproduces the sibling layout).
CLAUDE.md gains the rule earned by red-proofing: a go test -run pattern that matches no test
prints ok and exits 0, and an instrument that can silently drop results is not a measurement.
A census of all thirteen gate scripts across the four felhom repos on 2026-08-02 found one
clean correlation: every check a CLAUDE.md tells a person to run was passing, and two of the
four nobody is told to run were failing — one since 14 July. Neither failure was harmful in
effect (checked line by line); nothing would have said so if they had been. The fix is not
more gates, it is one place to run them from.
repo_gates.py runs site + hostinstall + hub-confirm + manifest-bearer + reuse-refs, streams
each gate's own output, and exits worst-wins non-zero. A missing gate script is a FAILURE and
prints the path tried — fail-closed, because a runner that quietly skips a gate is the
inert-seam failure this project has shipped four times. It copies catalog_gates.py (R-161),
NOT site_gates.py, which is a gate and not a runner.
.githooks/pre-push runs it with --fast and refuses the push. Honest limits are written into
the hook itself: per-clone (core.hooksPath is local config), and --no-verify bypasses it on
purpose. Any manual run WARNS when the clone is unarmed. Measured on git 2.47.3: a relative
core.hooksPath resolves correctly and the hook's cwd is the repo root from any subdirectory.
test_repo_gates.py is a SEAM test — it asserts each member gate's own distinctive stdout, not
the runner's summary line, which an inert runner prints while calling nothing. Red-proofed:
replacing run_gate's body with 'return 0' still prints 'all felhom.eu gates OK' and exits 0,
and turns the seam test red.
Documentation only — no code, no box, no build.
STATUS.md (repo root, 652 words / 67 lines): what works · what's broken ·
what we're working on · waiting on you · changed since. A VIEW of
OPEN-ITEMS.md, holding nothing of its own; not CONTEXT.md, and both files
now say why they stay separate. No R-n is the subject of a sentence —
identifiers are bracketed pointers only.
CONTEXT.md S-5 records the four operator decisions taken 2026-08-02
(D-a … D-d), none of them implemented:
D-a merge mp1 into mp0 rather than resize it — before any external
install, and D-c ships in the same step → R-165
D-b desired/observed app state in its own store, with the state-store
safety rule verbatim → R-166 (BLOCKED)
D-c customer fill warning + operator backup-failure alert → R-167
D-d only DooPlex and Peti's box are protected → target-selection.md
R-163 RE-FRAMED, not closed: the sizing question is withdrawn rather than
answered; the row survives as the record of the constraint until R-165
lands. R-156's papra referral RESOLVED — deployed nowhere, so the template
fix strands nothing; the docker ps evidence is recorded with its
provenance and its scope limit.
target-selection.md: two protected machines, everything else disposable.
ep0 is no longer Tier 2 but is not scratch (it holds the only off-premises
copy of real customer data) — flagged for explicit operator confirmation.
The demo-box backup-target fence drops from prohibition to stated cost,
because D-d spends that reference anyway.
CLAUDE.md gains an End-of-session checklist carrying the STATUS.md
maintenance rule and "a finding goes in OPEN-ITEMS.md first".
Two record items, banked before any Go file is opened.
1. CLAUDE.md gains a standing rule beside the seam-wiring rule: 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. Liveness is
decided from /proc and kernel state.
Measured in the R-117 spike §6.3: D state 3m50s after kill -9; a buffered
write with no fsync blocked too (O_CREAT needs journal access); statfs and
getdents returned HEALTHY on a namespace that EIOs every byte.
Repeated as a one-line pointer in felhom-agent/CLAUDE.md, because health
checks are written in that repo and felhom.eu/CLAUDE.md does not load in an
agent-only session — a standing rule that does not load where it binds is the
inert-seam shape applied to a rule.
2. The R-116 row gains the clause the spike recommended but did not apply. Its
verdict stands and every input to the pairing fix is configuration-derived.
But the over-correction window's degraded:false was read off a drive whose
bind was dead, so it evidences "the gate did not over-fire", not "the drive
was healthy". The two RETURNED lines remain a genuine positive observable, so
rule 3 is still satisfied. Nothing else about the row changed.
Nothing in the repo said which machines are safe to break. The host table gave
access and role and stopped there, so a session needing a victim had to guess --
and the guessing inverted: the two boxes that exist to be broken were treated as
sacred, and DooPlex (the recovery chain) got used because it was the only box no
spec had fenced.
New documentation/runbooks/target-selection.md -- one page, three tiers, and per
machine what is freely permitted / needs care / forbidden, each carrying its
REASON so a rule can be correctly narrowed later instead of ossifying. States the
selection rule positively (start at Tier 0; a Tier 2 box only when a task says so
explicitly; an absent fence is not permission) and that fences name ACTS, not
machines -- demo-hp's over-subscribed local-lvm is one dangerous storage, not a
dangerous box.
CLAUDE.md: host table gains a Blast radius column, gains the missing demo-hp row
(it was where the drill VMs ran and it was not in the table at all), and a pointer
line to the new runbook.
CORRECTION to the spec's problem statement: the designation was not missing. The
2026-07-25 operator ruling naming the t740 as drill+build VM host -- explicitly
"moved off DooPlex" -- already existed in operations/nodes.md. It sat where no
session reads at start, while the prohibitions were repeated in every task spec.
The defect is reachability of the ruling, not its absence, and the R-116 drill on
DooPlex contradicted a written ruling rather than filling a vacuum.
CORRECTION to the R-116 record, same commit: the baseline claimed controller
0.186.0 on both demo boxes. Only felhom-pve was sampled and generalised; demo-hp
re-checked directly runs 0.185.1, so the fleet is split and R-114's TargetAbsent
branch is absent from demo-hp. Fixed in the audit table and REPORT-r116-diag.
Docs only -- no code, no build, no deploy, no host reconfigured, no host renamed.
felhom-pve joins the tailnet as a host package (100.70.170.35, expiry
disabled); DooPlex already advertised 192.168.0.0/24 via its GitOps k3s
tailscale pod (100.107.87.53). ssh felhom-pve now targets the tailnet IP
(direct-over-LAN at home, tunnel when away); felhom-pve-lan = LAN fallback.
--accept-dns=false on the host. Host converted static->DHCP (reservation
keeps .162 at home). Measured: --accept-routes hairpins the local subnet
at home -> travel-only opt-in. PBS is offsite (own WireGuard tunnel), needs
none of this. Recorded not fixed: controller<->agent plane hard-pinned to
192.168.0.162 (agent listen_addr + guest bootstrap.json) -> not yet portable
off-LAN. No code changed. Full doc: documentation/operations/tailscale.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMForrE4c1wZxd9LukxYVt
Adds the CAMPAIGN 7 audit doc (53/53 apps deployed and validated on the demo box
through the real controller endpoints; 45 pass, 13 template fixes, ranked
findings) and the uninstall-semantics map row it proves out: remove requires stop
first, named docker volumes are ALWAYS destroyed including the DB, HDD data and
backups survive unless explicitly ticked, and the per-app offsite toggle outlives
the app while tier-2 config is cleared.
ROADMAP +3:
R-40 the update path cannot express a multi-hop major upgrade (nextcloud
forbids skipping majors; the template moved 31 -> 34)
R-41 no standing 'does every template still deploy?' check -- the sweep was
the first thing to try, and found 5 apps that had NEVER been deployable
R-42 ruling needed on DB/cache sidecar majors (deliberately not bumped)
CLAUDE.md: REPORT.md is overwritten, so a second concurrent session in this repo
writes REPORT-<topic>.md instead -- applied to this run's own report.
Campaign 7 catalog sweep.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
- Auto-refresh: the 60s reload fires only while a live tab is active
(nav data-live-tabs="overview,applications,events,host") AND no form is
dirty (delegated input/change listener; never reset — a reload clears it).
Skipped ticks reschedule; a muted (paused) hint shows next to the toggle on
non-live tabs / dirty forms. Toggle, localStorage key, 60s cadence, and
default-on behavior unchanged. The refresh script resolves the legacy
settings→edit hash alias like the tabs script.
- Rider 4a: every template's stylesheet link is /style.css?v={{hubVersion}}
(the v0.47.0 gotcha: max-age=3600 served stale styling for up to an hour
after a deploy). Red-proof run: a reverted bare link fails the test.
- Rider 4b: CLAUDE.md standing rule — never git add -A in this repo
(the 146d165 sweep incident); explicit paths + pull-rebase + one writing
session per clone.
- Tests: Group C structural pins (attribute read, dirty listeners, alias x2,
hint element, cadence/key survivors) + Group D cache-bust over six pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZc5w5jDhFLv6qDC32KN5v
The argocd CLI on 180 has no server session and --core breaks under sudo (env stripped);
the reliable scripted sync is annotate refresh + patch .operation on the Application CR.
Verified by deploying hub v0.7.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
No separate hub app; manifests/ synced by app 'felhom' (auto-sync off). Deploy =
build+push pinned image -> bump manifests/hub.yaml tag + commit -> manual sync.
Never :latest (manifest is ArgoCD's truth). Replaces the stale kubectl apply/set image steps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unify the REPORT/CHANGELOG convention with the sibling repos (REPORT.md was
append/cumulative -> now overwrite-latest; CHANGELOG stays cumulative). Reflow
removes hard mid-paragraph line wraps; rendered output unchanged. CHANGELOG entry
in hub/CHANGELOG.md. No hub code change -> no version bump.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
deploy-felhom-compose -> felhom-controller, proxmox-controller -> felhom-agent in
README.md and CLAUDE.md. Hub source (templatefetcher.go) intentionally left untouched
per scope; its raw-template URL is flagged separately for the operator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add CLAUDE.md with build workflow, project overview, and key patterns
- Add .gitignore to prevent committing binaries and IDE files
- Remove hub.exe from tracking (was accidentally committed)
- Fix statusIcon: use Unicode ● character instead of HTML entities
that get double-escaped by Go html/template
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>