Measured 2026-10-18 on controller jobs 1944 and 1959: same job id, run_attempt 2, conclusion and log are the rerun's; actions/tasks keeps one task per attempt (1962 failure + 1964 success; 1978 failure + 1980 success).
13 KiB
CLAUDE.md — felhom.eu
Stable orientation only — current state lives in
CONTEXT.mdand the tops ofhub/CHANGELOG.md/scripts/CHANGELOG.md/website/CHANGELOG.md, never here. Cross-repo conventions (the three-component model, artifact taxonomy, access, clean-tree gate, secrets): workspace-root/mnt/5_hdd/felhom.eu/git/CLAUDE.md, whose versioned copy isdocumentation/runbooks/workspace-CLAUDE.md. Path-scoped detail:.claude/rules/.
What this repo is
Four surfaces in one repo, plus the design home for the whole system:
hub/— felhom-hub, the operator backend (Go, k3s,hub.felhom.eu).website/— static HTML at felhom.eu, served by k3s nginx + git-sync.manifests/— k3s manifests for felhom-system, GitOps via one ArgoCD app.scripts/— the public installer (felhom-host-install.sh) and this repo's gates.documentation/— the authoritative design home for all of Felhom, not just this repo.skills/— versioned source of the Claude Code skills; install withpython3 scripts/install_skills.py(symlink — repo edits are live immediately).
Doing X → read Y
| Doing | Read |
|---|---|
| writing any new code | REUSE.md — helpers, seams, extension points, traps |
| needing current state / roadmap | CONTEXT.md |
| hub work (architecture, deploy, patterns) | loads itself: .claude/rules/hub.md |
| website or installer work | loads itself: .claude/rules/website.md |
| manifests / ArgoCD / secrets | loads itself: .claude/rules/manifests.md |
| writing or routing a document | loads itself: .claude/rules/docs.md |
| build, deploy, publish, verify a version | the felhom-build-deploy skill |
| writing or reviewing a test, fixing a bug | the felhom-testing skill |
| UI, tokens, badges, Hungarian copy | the felhom-ui-design skill |
| host addresses, break-glass, node facts | documentation/operations/nodes.md — never restate them |
| which box may I break | documentation/runbooks/target-selection.md |
a problem seen on the N100 (the lived-in box, 09 §3 decision 254) |
goes into documentation/lived-in/n100.md AND the register |
| what version is live anywhere | ask the hub (/hosts, /configs) or the box — never a doc |
| the authoritative design | documentation/architecture/01..11-*.md (00 = the capability map) |
Code quality
- If you need more input or troubleshooting output, ask first — don't guess.
- A
go test -runpattern that matches no test printsokand exits 0. A red-proof using-runmust first prove the filter matched something (-v, look for=== RUN). Generally: an instrument that can drop results silently is not a measurement.
The installer publishes by TAG, not by push (R-110)
This fence is in the core deliberately: its trigger is editing scripts/felhom-host-install.sh, and
no path-scoped rule covers that file. It governs the one artifact that runs as root on a virgin
box.
- Pushing
scripts/felhom-host-install.shtomainpublishes NOTHING.manifests/webpage.yamlruns two git-syncs: the website frommain, and/scripts/from the taginstaller-v<SCRIPT_VERSION>. - To publish: cut
installer-v<new SCRIPT_VERSION>, bump the--refinwebpage.yaml(both the sidecar and the init container), commit, sync. - To roll back: move the tag back and wait ~30 s. No ArgoCD sync, no deploy — that is the emergency lever; fix forward afterwards.
- Do NOT pin the website to the tag, and the URL never carries a ref —
felhom-bootstrap.shand the hub's day-0 command follow the tag with no edit. hostinstall_gates.pygate 6 fails if the manifest stops naming aninstaller-v…tag or if the website stops trackingmain.
Workflow — what is specific to this repo
- Never
git add -Ahere — parallel sessions share the clone and it sweeps foreign WIP. Stage explicit paths only,git pull --rebasebefore every push. REPORT.mdis overwritten, so two sessions in this repo clobber each other. The second session writesREPORT-<topic>.mdand never touches the sharedREPORT.md.CHANGELOG.mdhere is per-area:hub/,scripts/,website/,marketing/.
Gates — ONE entry point
Run python3 scripts/repo_gates.py after ANY change in this repo. It runs every gate in its
GATES table — that table is the list; this file does not restate it (a copy here read ten
gates while the table held nineteen) — streaming each gate's own output and exiting non-zero if any
fails. --fast selects the gates that touch no network and no container runtime, and skips the
full-run-only gates, naming them (today: iso-bootstrap, R-502 — it needs docker and an image).
A missing gate script is a FAILURE, never a skip.
due_checks_gate.py refuses the push when a dated check in OPEN-ITEMS.md's DUE-CHECKS block has
come due (R-341). It is not a scheduler — it fires on the next push, not on the date.
A gate ships with a decoy test that has been seen to fail (R-421). A decoy is the LABEL without
the FACT — a directory with the right name and no bake log, a note whose prose mentions the marker it
lacks. scripts/decoy_coverage_gate.py refuses a new gate that has neither a decoy nor a named
exemption carrying its row. The four shapes, the 2026-09-01 sweep that fooled 16 of 29 gates, and the
decoys withdrawn as illegitimate: documentation/audits/AUDIT-gate-decoys-2026-09-01.md and
felhom-controller/.claude/rules/gates.md. Scope is a fact too — prefer os.walk over
os.listdir, and a glob over a hand-maintained list.
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 it with --fast and refuses a failing push. It is
per-clone — switch it on once with git config core.hooksPath .githooks, and a manual run WARNS
when this clone is unarmed. git push --no-verify bypasses it deliberately; say so in the session
report when you use it — CI re-runs the same entry point on every push and emails the operator
on failure, so a bypass is noticed even though it is not blocked (R-168, CLOSED 2026-08-02; CI
reports rather than refuses because there is no PR to gate — R-169).
End-of-session checklist
Registers first — a finding goes in documentation/backlog/OPEN-ITEMS.md first, never only in a
report, an audit or STATUS.md. Four items in this project were minted in a spike doc and lost
(R-153/154/155, R-156/157). This applies to every session that ships, breaks or decides
something, not only sessions that touch documentation/ — which is why it is here and not in
docs.md.
-
CHANGELOG.md+REPORT.mdin every repo touched (see the workspace root for the rule, and the parallel-session caveat above). -
REUSE.md, if a shared helper or pattern moved (same commit). -
OPEN-ITEMS.md— every finding, with a number. A row you close moves toCLOSED-ITEMS.mdin the same commit —closed_register_gate.pyRULE 3 refuses a finished row left in the open register. A new row goes into its category's section with one Category and one Sev (P1–P4); the scale and the eleven names headOPEN-ITEMS.md, andregister_shape_gate.pyrefuses anything else. -
Root
STATUS.md— at the end of every session in which something shipped, broke or was decided. It is a view ofOPEN-ITEMS.md; nothing may exist only there. One screen, written for the operator in plain language, and deliberately notCONTEXT.md. -
The golden, weekly (operator ruling 2026-10-15 11:26,
09§3 decision 250; decision 7 stands): a controller release needs no golden — the floor carries it, MinAgent declared, after the three test boxes (09§3 decision 250 item 2). The golden is baked every Sunday night when the floor is above it (runbooks/nightly-rotation.md) and before ANY drill or fresh install, perdocumentation/runbooks/RUNBOOK-manual-build.md§4.1; the operator vouches it. Between bakes the dated waiver (§4.2,documentation/tests/golden-waiver.yml, ≤ 14 days) keepsgolden_currency_gate.pyadvisory; when it expires the gate is red and stays red until someone bakes or renews — that is the mechanism, so do not--no-verifypast it. A nightly or drill session that starts on a fresh install checks the golden FIRST. -
The capability map (
documentation/architecture/00-capability-map.md), if a capability's status changed — with its new evidence citation. -
python3 scripts/unproven.py --summary— one line per status, and the not-walked total. Run it at the end of any session that shipped, broke or proved something, and say in the report if a number moved. It exists because "which claims are unproven?" was answerable only by a person reading a page: a session asked for "the nine grey claims" could not determine which nine and rightly refused to guess (R-326). Nine was real and answered a different question — it is the count of claims the 2026-08-09 pass DOWNGRADED. Not-walked is 35 of 55 as of 2026-09-01 -- the figure read 32 here for weeks while the tool said 35, so re-read the tool rather than this line. A status that moves without anyone noticing is how the picture stops being true. -
Confirm your own last push's CI run went green, by run ID. CI emails on failure, which is a PUSH signal; this is the PULL check that catches a lost, filtered or unread mail. Quote the run id and its conclusion. Use the
jobsendpoint and match onhead_sha, never on an id (R-417, measured 2026-09-01):actions/tasksreturns"conclusion": nullfor every run, so a session following the old recipe here quotes a conclusion it never read; itsidis also offset from thejobsid for the same run (479 vs 478), andactions/runs/<n>takes a JOB id, soruns/294cheerfully returns an unrelated job from three weeks earlier. The list is oldest-first — page to the end.T=$(curl -s -u "$U:$P" ".../actions/jobs?limit=1" | python3 -c 'import json,sys;print(json.load(sys.stdin)["total_count"])') for pg in $(seq 1 $(( (T+49)/50 ))); do curl -s -u "$U:$P" ".../actions/jobs?limit=50&page=$pg"; done # then match your own head_sha across ALL of themTwo things this recipe got wrong until 2026-09-20, both measured the hard way:
- The response key is
jobs, notworkflow_runs. A parser readingworkflow_runsgets an empty list and prints nothing — which reads exactly like "no CI run for this commit" and is not. That is R-417's own shape (a field the API never populates) committed while checking R-417. - Job ids are NOT ordered within a page, so
page = total/50 + 1does not hold the newest rows — a run can sit several pages earlier. Scan every page and match onhead_sha; with a few hundred jobs that is a handful of requests. Guessing the page produced three consecutive false "no CI job for this commit" readings in one session. - A rerun REPLACES the attempt the
jobsendpoint shows (R-1137, measured 2026-10-18): the job keeps its id,run_attemptbecomes 2,started_at,conclusionand the log are the rerun's — the red first attempt is gone fromjobsand its log (controller jobs 1944, 1959: failure mails, now green). A matched job withrun_attempt> 1 is not a plain green: listactions/tasks(same paging, matchhead_sha) — one task per attempt, the verdict instatus, so it reads „failure, then rerun success". The first attempt's log is not reachable by the API.
An unchecked green is an assumption, not an observation — and so is a green read off a field the API never populates.
- The response key is