The walk finished. All four planted files came back byte-identical out of snapshot 41c830db, including two Hungarian accented filenames verified as RAW NAME BYTES (NFC preserved) — the discriminator the Gate 0 positive control was built for, having been watched failing on an NFC->NFD rename that renders the same. Unlock 21s, restore 13.2s. It finished only because a terminal was available twice: - R-273 CLOSED. v0.128.0 was published as a package and never git-tagged, so every install died at 5/8. Tag pushed on operator instruction after an INDEPENDENT download proved the package sha equalled the vouched value; --resume then reached Day-0 SUCCESS in 3m49s on controller 0.210.0. The two guards that would stop the class recurring are still owed. - R-280 NEW, rank 1. A reinstalled box cannot re-attach its own data drive by any dashboard route: /api/disks/candidates returns empty because both lists are built from the UNCLAIMED-disk scan, and the drive is claimed precisely because it is also the backup target. Correct for "initialise", over-broad for "attach", which is non-destructive by definition. The restore page meanwhile says "Ez ket kattintas" and points at that empty list. Cleared by POSTing /mnt/sys_drive — an internal path no household could produce. Also new: R-281 the hub said NOTHING through the entire reinstall and the sealed-backup tripwire did not fire on a real unseal (positive control: 2 events all day fleet-wide); R-282 one code with three names and a mail pointing at a page the box does not show; R-283 hub reads "Claimed 18d ago" while the box serves its setup page; R-284 "almost full" over a 93%-free store. R-274 NARROWED by measurement rather than left as written: the resume path fetched the vouched golden correctly, because --resume skips the preflight that does local discovery. What survives is real — discovery is sort|tail -1 with no manifest comparison — but a FRESH install taking a stale golden is still not observed, and the row says so. Two of my own claims were refuted by test and are recorded as refuted, not quietly dropped: the leftover sudoers file is inert (sudo skips dotted names), and demo-hp's off-site tier was healthy all along.
41 KiB
REHEARSAL — the BYO reinstall walk (2026-08-09)
Status: COMPLETE. All phases walked; the integrity verdict is BYTE-IDENTICAL. §1–§8 were written before the destructive phase deliberately — a finding that exists only in a session that later crashes is a finding nobody has — and are left as written, including one claim later refuted by test (§8.3 → §9a) and one by measurement (§7.2 F-9).
Venue: demo-hp (HP t740, felhom-host, guest 9201, customer demo-hp). Operator-approved at
STOP 1. Driven from DooPlex. All times UTC unless marked; the host runs CEST (UTC+2).
1. Baselines — re-confirmed live on arrival, not taken from the spec
| spec said | live reading | source | |
|---|---|---|---|
felhom-agent |
v0.128.0 @ 28ba8593b8 |
0.128.0 on demo-felhom, 0.127.0 on demo-hp; HEAD == origin/main == 28ba8593b8 |
felhom-agent --version on both nodes; git rev-parse |
felhom-controller |
v0.210.0 @ c732fe1283 |
0.210.0 demo-felhom, 0.208.0 demo-hp; HEAD == origin/main == c732fe1283 |
hub /configs; git rev-parse |
| hub | v0.101.0 @ 56f8aa611c |
0.101.0 (deployed image tag matches) | kubectl get deploy hub; page footer |
| Day-0 manifest | golden 0.210.0 / agent 0.128.0 / min 0.127.0 | all three already saved — golden 0.210.0 (b9f701fa…), agent 0.128.0 (c6eba73b…), min agent 0.127.0, wrapper 104db0a4… |
hub /configuration, selected <option> values |
All three repos were clean and at origin/main before anything ran.
The fleet is not uniform, and the venue is the box that is behind — demo-hp runs agent 0.127.0 and controller 0.208.0 against a manifest vouching 0.128.0/0.210.0. That is an argument for the venue: the reinstall genuinely exercises the manifest rather than re-installing what is already there.
2. Venue comparison — the fidelity table that decided it
The runbook's criterion is "the box whose current state most resembles what the tester will have".
| criterion | demo-felhom (N100) |
demo-hp (t740) |
|---|---|---|
| customer apps deployed | 1 — opengist | 3 — privatebin, opengist, calibre-web |
| enrolled user-data drive | none registered. /mnt/felhom-drives empty; controller logs [WARN] Storage paths: no storage paths registered |
/mnt/felhom-drives/Felhom-Share + NVMe enrolled 2026-07-22; one registered storage path /mnt/sys_drive |
| off-site tier has actually run | NO — offsite.state=needs_credential, no run has ever succeeded |
YES — 18 restic snapshots, daily, unbroken |
| escrow / recovery ceremony | present, generation 2 | present, generation 2 |
| install shape | mkimage loader, Secure Boot OFF (firmware workaround) | shim loader, Secure Boot ENABLED — the customer shape |
| collateral on the box | none | drill-r50 (VM 300); leftover c11-scratch storage |
Chosen: demo-hp. It wins every fidelity criterion that distinguishes them.
drill-r50 is not at risk, and this was checked rather than assumed: VM 300 is not in the
felhom pool (pvesh get /pools/felhom → members [9201]), its disks are on local-lvm, and
--uninstall removes no storage definitions and no non-pool guest — its own end-of-teardown statement
says the enrolled drives are "unmounted only, NEVER wiped" and that it never deletes PBS backups, hub
records or escrow blobs.
Correction recorded, because it was part of the comparison put in front of the operator: the
enrolled-drive row was first reported as "real files on an enrolled drive". Felhom-Share is
empty — the share exists and is enrolled, but holds no files. demo-hp still wins the row (demo-hp
has a registered storage path; demo-felhom has none), on a narrower margin than first stated.
3. Pre-phase — R-268, the leaked per-guest local-API token
Done first and independently, on demo-felhom guest 9201, as the runbook required. No token value
reached stdout at any point: old value captured file→file from bootstrap.json, new value minted into
a file, both shredded at the end.
3.1 The rotation, and its proof
| step | old token | new token |
|---|---|---|
| before rotation | HTTP 200 | — |
| bogus token (negative control) | HTTP 401 | — |
| after the on-disk rotation, before any reload | HTTP 200 ← see 3.2 | 200 |
| after that lookup forced a reload | HTTP 401 | — |
after systemctl restart felhom-agent |
HTTP 401 | HTTP 200 |
Positive observable for the controller half: local-api: channel up (agent 169.254.253.1:8443) — guest 9201, 3 mount(s) visible, enumerating mp8/mp9/mp0 — data only the agent can supply. An absent
error would not have been evidence.
R-268 is CLOSED. The old token is refused; the new one works; the controller's channel is up.
3.2 FINDING — an out-of-process token rotation does not revoke the old token
localapi.TokenStore.Mint's doc comment states "last-write wins — any previous token for this guest is
revoked". Across processes that is false until something else forces a reload. The long-lived agent
serves Lookup from an in-memory index and re-reads the store only on a miss (the B3
reload-on-miss optimisation). A superseded token is still a direct map hit, so it authorises.
Red-proved twice. A temporary unit probe (Scenario C with the lookup order reversed — present the
rotated-out token first) fails on the claim; the shipped
TestTokenStore_ReloadOnMiss_RemintCoherence passes only because it looks up the NEW token first,
which is what triggers the reload that then evicts the old hash. Then proved on real hardware: the row
marked ← above is a live 200 from a token that the store on disk had already superseded.
This is the CLAUDE.md case exactly — a comment asserting an invariant with a test that appears to pin
it and does not. The honest fix is either a test that pins the reversed order, or an eviction that does
not depend on an unrelated lookup missing first.
3.3 FINDING — R-268's own stated rotation recipe is incomplete
The register row says the new plaintext "must also be written into the guest's
/etc/felhom-bootstrap/bootstrap.json or the in-guest controller loses its agent access". Writing
bootstrap.json is not sufficient. bootstrap.ensureLocalAPI returns early when
cfg.LocalAPI.Endpoint != "" — it fills an absent block and never refreshes a present one — so after
a rotation the controller keeps serving the old token from its own controller.yaml across a
restart. Proved live: two controller restarts, still HTTP 401. The rotation only completed once
local_api.token was written into controller.yaml itself.
The neighbouring DetectEndpointDrift compares the endpoint and deliberately does not compare the
token ("a token mismatch is a different failure"). So this shape is known to be unmodelled, and R-78
("which file is authoritative") is its parent.
3.4 FINDING — the agent-channel alarm never closes, and its own prescribed remedy is why
channelhealth alerted correctly and promptly: agent_channel_unauthorized, no debounce, pushed to
the hub — "agent rejected the controller token (HTTP 401) — token stale/rotated (re-bootstrap)".
The channel then recovered, and no recovery event was ever sent. In Checker.Check's UP branch the
notify is guarded by prev != "" && prev != "up", and a controller restart resets state to "". The
alert's own instruction is to re-bootstrap — i.e. restart the controller — so following the alarm's
advice structurally guarantees the recovery is silent. The down side is deliberately asymmetric
(F2: a born-down channel alerts on cycle 1); the up side is not.
Operator-visible result on the hub: two agent_channel_unauthorized errors, one sent and one
suppressed by cooldown, and nothing afterwards. The customer dashboard is fine — SetDashboard
reflects current state every cycle. It is the operator's trail that ends on "down".
4. Gate 0
4.1 The recovery code — CONFIRMED IN THE OPERATOR'S HANDS (STOP 1)
Not printed, not logged, not requested. It will be supplied at the screen at STOP 3.
4.2 The dataset — planted and fingerprinted
Planted at /var/lib/felhom/sys_drive/felhom-data/userdata/media/books/rehearsal-2026-08-09/ — inside
the Calibre library, which is real app-owned customer data and is demonstrably carried off-site
(the path appears in every felhom-offbox,calibre-web snapshot). Chosen after checking coverage rather
than assuming it: the per-app tier-1 unit carries only the named docker volume, so a file dropped in an
arbitrary place would not have travelled.
| sha256 (16) | bytes | name | name bytes (hex) |
|---|---|---|---|
54b773c46bbfd994… |
3 145 728 | binary-3mb.bin |
62696e6172792d336d622e62696e |
52a5c5ebfcac247f… |
59 | árvíztűrő-tükörfúrógép.txt |
c3a17276c3ad7a74c5b172c5912d74c3bc6bc3b67266c3ba72c3b367c3a9702e747874 |
15d2024dfc224162… |
25 | nested/őszibarack.md |
6e65737465642fc591737a6962617261636b2e6d64 |
924497918e55fe6d… |
21 | plain.txt |
706c61696e2e747874 |
Manifest: documentation/audits/evidence/ → recorded in the session scratchpad as
GATE0-before-manifest.json. The manifest keys on raw name bytes, not rendered text.
4.3 The positive control — the comparator was watched failing, three ways
| control | expected | result |
|---|---|---|
| identical copy | PASS | PASS, exit 0 |
| one bit flipped at byte 1 500 000 of the 3 MB binary | FAIL | FAIL — named the file and both sha256s |
| NFC→NFD rename of the accented name (renders identically) | FAIL | FAIL — caught as MISSING+EXTRA on the name bytes |
| one file deleted | FAIL | FAIL — named the missing file |
The NFD case is the one that matters: a comparison of rendered filenames would have passed it. Every altered copy was discarded after the control ran.
4.4 The off-site tier — has run since the dataset was planted
Triggered through the product's own dashboard button (POST /backup/offbox/run → flash „A távoli
mentés elindult"), not by invoking restic by hand.
[offbox] backup run started (3 app(s) toggled)
[offbox] pre-push dump leg completed in 7.329s — snapshot pair is coherent
[offbox] backed up calibre-web (…/backups/primary/calibre-web, 1 mandatory path(s))
[offbox] backup OK: 3 app(s) backed up, 18 snapshot(s), 1m22s
The restore point, by identity — never by size:
| snapshot | time (UTC) | tag | paths |
|---|---|---|---|
41c830db |
2026-08-09 08:30:38 | felhom-offbox,calibre-web |
…/backups/primary/calibre-web + …/userdata/media/books |
9e38b84c |
2026-08-09 08:30:49 | felhom-offbox,opengist |
…/backups/primary/opengist |
78b93f04 |
2026-08-09 08:30:53 | felhom-offbox,privatebin |
…/backups/primary/privatebin |
restic ls 41c830db confirms all four planted files are present, both accented filenames included.
4.5 The box, captured
GATE0-demo-hp-before.txt (282 lines): agent version, pveversion, pct/qm lists, pool membership,
pvesm status, storage.cfg, df, lsblk with serials, the agent unit + service user, sudoers, the
pveum users/tokens/roles/ACL, the guest hook, the WireGuard tunnel, the OOB belt, the agent state dir,
the install state file, PBS snapshots, local vzdump archives, and the enrolled-drive tree.
5. Findings so far, ranked by what they cost the person in front of you
Nothing yet is rank 1 or 2 — the walk has not started.
Rank 3 — misleads
- F-1 · A rotated-out local-API token still authorises (§3.2). A customer would do nothing — they cannot see it. An operator rotating a leaked token would believe it revoked when it is not. The shipped test passes for the wrong reason.
- F-2 · R-268's rotation recipe is incomplete (§3.3). Whoever follows it gets a box whose agent
channel is dead across restarts, with a correct-sounding alarm and no hint that
controller.yamlis the file that matters. - F-3 · The agent-channel alarm never closes (§3.4). The operator fixes it as instructed and is never told it worked; the hub's last word stays "down".
- F-4 · Three hub surfaces jointly misrepresent a healthy off-site tier as absent. The customer page
reads
Snapshots 0 / Repo Size 0 MB / Integrity Unknown(that panel renders the local tier, while the healthyoffsiteobject —snapshot_count: 18,last_status: ok— sits unrendered in the same report); the Offsite page shows0.0 GB(true, but a 162 KB repo rounds to nothing); and a staleoffsite_delivery_stuckevent from 2026-08-07 reads as current. Three independent surfaces agreeing on a wrong picture is how a working backup gets "fixed". This one caught me — it produced a wrong statement to the operator at STOP 1, corrected before it changed anything. - F-5 · There is no operator-triggerable off-site backup. The only route is the customer's dashboard
button;
signed_jobsneeds a signing key the hub does not hold. Sibling of R-177.
Rank 4 — cosmetic / hygiene
- F-6 · Leave-behinds from earlier sessions, found while capturing state:
- demo-hp host: storage
c11-scratch(Campaign 11), andfelhom-backup:…vzdump-lxc-9100-2026_08_03…for a guest that no longer exists; - demo-hp guest 9201
/root:.dpw(13 bytes),.h,.sec.html, all 2026-08-07 16:08; - demo-felhom guest 9201:
/root/r204-backup/offbox.orig.
- demo-hp host: storage
- F-7 · The managed-update floor is
0.200.0, not0.156.0— the runbook's §8.3 premise is stale. Confirmed twice: hub/configurationreads "Effective floor v0.200.0 — source: DB (hub_settings); env fallback would be v0.120.0", and both boxes logsettle-gate: GO — at/above floor 0.200.0. The ruling's substance still needs recording; its number must not be written down as 0.156.0.
Not a finding, checked and cleared
- The two
userdatatrees undersys_driveare the same directory surfaced twice, not a backed-up copy beside an unprotected one. Checked against calibre-web's actual bind mounts.
6. Steps taken off-path, and what they cost
Rule 2 (no shell after the install command returns) does not bind yet — the walk has not begun. These are Gate 0 preparation acts by the operator, recorded because they are mutations:
- R-268 rotation on demo-felhom — token store appended,
bootstrap.jsonandcontroller.yamlrewritten, agent and controller restarted. Required by the runbook's pre-phase. Not the venue. - demo-hp's dashboard password was re-set to the value in the operator's credentials file. The
customer-owned password was not known to this session, and no operator-side route to the off-site
button exists (F-5). The prior
password_hashis preserved at/root/.orighashand/root/settings.json.rehearsal-origin guest 9201;claimedwas lefttrue. Done on operator instruction. Cost to fidelity: none for the walk — P1 destroys this guest, and P4 re-claims with a password the operator chooses. - The off-site run was started by a script pressing the dashboard's own endpoint, with a real session and a real CSRF token, rather than by a person clicking. The server path is identical; only the click is synthetic.
7. P1 — removing Felhom from the host
--uninstall, the product's own path, dry-run first. 60 seconds (08:37:23 → 08:38:23 UTC), exit 0.
The typed-vmid confirmation was satisfied, not bypassed.
7.1 The reversal, proved rather than trusted
| component | before | after |
|---|---|---|
| agent binary | /usr/local/bin/felhom-agent 0.127.0 |
ABSENT |
felhom-agent service user |
present | ABSENT |
felhom-op (OOB) user |
present | ABSENT |
/etc/felhom-sshd (OOB belt) |
present | ABSENT |
| systemd units | agent, mgmt-watchdog, felhom-sshd, oob-nft, shared-parent | all removed |
| pveum roles | FelhomAgentBase/Guest/Store |
none |
| pveum user + token | felhom-agent@pve!agent |
gone; user list is root@pam only |
| pveum ACL | pool + 9 scratch-vmid + 3 storage grants | empty |
felhom pool |
members [9201] |
[] — pool removed |
| guest 9201 | running | destroyed |
| guest-hook snippet | present | removed |
| dnsmasq snippets | present | removed |
/var/lib/felhom-agent, /var/lib/felhom-install |
present | removed |
| KEPT as declared | PBS snapshots (2), local vzdump (4), /mnt/felhom-drives/Felhom-Share, storage defs |
Space returned: local-lvm 20 904 790 → 12 355 143 KiB (≈8.5 GiB); local ≈64 MiB; the NVMe
unchanged (backups deliberately kept). drill-r50 (VM 300) untouched, as predicted.
7.2 What it left behind that it did not declare
F-8 · Five orphaned agent.json.* backups survive, each carrying a hub API key and a Proxmox
token. /etc/felhom-agent/ remains with agent.json.campaign8-before, .campaign9-before,
.campaign9-prev, .pre-e-target-move, .pre-prunegate.bak — all 0600, three owned by the now-deleted
service user's uid 999, each holding a 64-char hub.api_key and a 59-char proxmox.token.
The uninstall's own WIPED statement claims it removes "config (+ its .bak backups)", and
scripts/CHANGELOG records F1 as "uninstall now purges the agent config's .bak* siblings (one held a
live hub api_key)". That fix does not match the filenames in use — and note it misses
agent.json.pre-prunegate.bak, a file that literally ends in .bak.
Assessed rather than assumed: these particular secrets are SUPERSEDED, not live. The orphaned
hub.api_key hashes to a5d2222a…; the hub's current key for demo-hp is 8c59d1b6…. The Proxmox token
was deleted by this same uninstall. The exposure is timing-dependent, not absent — a backup taken
shortly before an uninstall would hold a current key, which is exactly what R-179's precedent records.
The broken thing is the stated guarantee.
And the reinstall hands them to the new service account. The rebuilt box recreates felhom-agent at
uid 999 — the same uid the deleted one had — so the three backups owned by uid 999 become the new
account's files. Verified: su -s /bin/sh felhom-agent -c 'test -r …' succeeds on
agent.json.campaign8-before and agent.json.pre-prunegate.bak. A fresh install's service account
inherits read access to the previous install's credentials. Superseded here; live if the backups are recent.
F-9 · A stale sudoers file survives — and it is INERT. Hypothesis raised, then refuted by test.
/etc/sudoers.d/felhom-agent.bak-pre-e2a (21 833 bytes, 2026-07-28, 0440 root:root) survives the
uninstall and grants felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, …,
and visudo -c -f on it says parsed OK.
The obvious worry — that the reinstall recreates the felhom-agent user and the box silently regains a
pre-E-2a privilege set — was tested against the rebuilt box and is FALSE. With the user recreated
(uid 999) and this as the only felhom file in /etc/sudoers.d, sudo -l -U felhom-agent returns
"User felhom-agent is not allowed to run sudo on felhom-host." sudo ignores files in sudoers.d
whose names contain a dot, so ….bak-pre-e2a is never loaded. That is a robust reason, not luck —
visudo -c -f <file> checks a file explicitly and therefore does not tell you whether sudo loads it.
What is left is rank 4: 21 KB of the privilege grammar lingering on a host Felhom was told to leave, inside a teardown that claims to have removed the sudoers file.
F-10 · The WireGuard tunnel to Felhom's off-site endpoint stays up. After an uninstall,
wg-quick@wg-felhom is enabled and active, /etc/wireguard/wg-felhom.conf present, with a live
handshake to 167.233.158.164:443 52 seconds old and 5.86 GiB received / 2.48 GiB sent on the counter.
It appears in neither the WIPED nor the KEPT list. A box told to leave Felhom keeps a live inbound
route into Felhom's infrastructure, and the operator is not told.
F-11 · Smaller undeclared residue: /etc/felhom/ (.bootstrap-done, appliance-pairing-code —
the console pairing code); /etc/systemd/system/felhom-bootstrap.service + /usr/local/sbin/felhom-bootstrap.sh;
the vmbr9 island-bridge stanza (169.254.253.1/30) still in /etc/network/interfaces.
F-12 · A teardown-ordering warning: user config - ignore invalid acl role 'FelhomAgentStore' —
the role is deleted before an ACL that still references it. Harmless here; noise that would mask a real one.
8. P2 — the preflight, as the owner runs it
8.1 The refusals, judged on their messages
First run, no caps: byo mode requires explicit --cores and --memory (noisy-neighbor caps on a host you do not own). Good — names the missing thing and the reason.
Second run: it refused again —
[ERROR] a resolver is already bound to :53 on this host — Felhom needs the guest reachable by name on your LAN.
Stop or reconfigure that resolver, OR point your LAN DNS at the guest's address, then re-run.
(Felhom does NOT touch DNS services on a host it does not own — this is a refusal, not a change.)
As a message this is excellent: the finding, the evidence, two routes out, and an explicit promise about what it will not do.
8.2 F-13 (RANK 1 — STOPS THE VISIT) · Felhom's uninstall leaves the exact condition that makes Felhom's reinstall refuse
The resolver it is refusing over is Felhom's own, and the chain is fully evidenced:
| fact | evidence |
|---|---|
Felhom installed dnsmasq |
/var/lib/dpkg/info/dnsmasq.list dated 2026-07-21 18:24 CEST — demo-hp's Felhom day-0 |
| it was constrained by a Felhom snippet | /etc/dnsmasq.d/ held Felhom snippets before; only README after |
| the uninstall restarted it unconstrained | the running process started 2026-08-09 10:37:39 CEST — inside the uninstall window (10:37:23–10:38:23) |
| unconstrained, it grabs the wildcard | 0.0.0.0:53 and [::]:53, --local-service, empty dnsmasq.conf |
| it is not PVE's | /etc/pve/sdn/ empty; unit is stock /usr/lib/systemd/system/dnsmasq.service, enabled |
The uninstall mentions it only as "the 'sudo' and 'dnsmasq' packages were left installed (system packages)" — but dnsmasq is not a system package here; Felhom installed it, and it left it enabled and running as a wildcard resolver.
What a customer would do next: read a message that says a resolver is in the way, conclude their own network setup is at fault, and start debugging DNS they never configured. On a BYO box with no shell guidance, this is where the visit stops.
Confirmed by counterfactual: systemctl stop dnsmasq && systemctl disable dnsmasq → host DNS (:53): free → PRE-FLIGHT PASS. Nothing else changed. (Recorded as an intervention — it is the action the
message tells the owner to take.)
8.3 F-14 (RANK 2) · A local golden is adopted with NO version and NO checksum check
Preflight reported golden (local): local:backup/vzdump-lxc-9100-2026_08_03-07_33_00.tar.zst. Step 7 is:
if [[ -n "$GOLDEN_VOLID" ]] && ! $FORCE_GITEA_GOLDEN; then
log_skip " using local golden: $GOLDEN_VOLID"
_state_mark golden; return 0
fi
The hub manifest's golden.sha256 — the whole point of the hub vouching a different trust root than
Gitea — is consulted only on the fetch path. A locally-present archive bypasses the vouch entirely: no
version comparison, no digest, no warning.
What that archive actually contains, read out of the tar:
gitea.dooplex.hu/admin/felhom-controller:0.192.0, against a vouched golden of 0.210.0.
Two consequences, and the second is the serious one:
- The rebuilt box comes up 18 controller releases stale, reporting success.
- 0.192.0 is below 0.200.0 — the release where R-193's off-site recovery SCREEN shipped. So a customer reinstalled today returns on a controller that cannot run the recovery ceremony their data depends on. It is also born below the managed-update floor (0.200.0), and the updater's auto-target is the floor, never the newest — so it converges to 0.200.0, still not the vouched build.
This is the R-111/R-115/R-120 drift family, one layer down: the R-120 gate guards what the operator may vouch; nothing guards what an install actually takes. It also compounds with P1 — the uninstall deliberately keeps the old golden ("golden vzdump left in place"), and the reinstall then adopts it.
The walk continues unflagged (--force-gitea-golden NOT passed), because measuring the path a real
reinstall takes is the point. §9 records which controller version actually came up.
8.4 The BYO mutation disclosure — read as a customer
Comprehensive and honest: every pveum artifact, the sudoers file, the state and config paths, the
packages it will install, the guest's capped size, the self-update authority, and — creditably — the
outbound WireGuard tunnel, in plain words, with the note that the operator can disable it per
customer. It closes with NOT touched in byo mode: root@pam (no break-glass), host DNS (:53), OOB sshd.
One material omission, given §8.3: it does not say that a pre-existing local golden may be used instead of the vouched one. The disclosure lists everything the install will create and nothing about what it may reuse.
9. P3 — the install. IT FAILED, AND THE WALK STOPS HERE
Started 08:51:33 UTC, failed 08:52:17 UTC (44 s), exit 1, at step 5/8. Run unflagged, as a real owner would. The typed hostname acknowledgement was given, not bypassed.
[INFO] manifest: agent v0.128.0 (sha c6eba73bf9b9ad69…), golden v0.210.0
[OK] verified sha256 c6eba73bf9b9ad69… matches the hub manifest
[OK] installed /usr/local/bin/felhom-agent (felhom-agent 0.128.0)
[OK] created service user felhom-agent
curl: (22) The requested URL returned error: 404
[ERROR] raw fetch failed: configs/felhom-mkfs-guarded.sh (agent tag v0.128.0 — is that version tagged in felhom-agent?)
9.1 F-15 (RANK 1 — STOPS EVERY INSTALL, FLEET-WIDE) · The vouched agent version was never tagged
v0.128.0 does not exist as a git tag — not locally, not on Gitea. The newest tag is v0.127.0.
The binary was published to the Gitea package registry (which is why it is selectable in the hub's
artifact dropdown and why its sha256 verified), but the release's tag step never happened.
| ref | HTTP |
|---|---|
raw/branch/main/configs/felhom-mkfs-guarded.sh |
200 |
raw/tag/v0.127.0/configs/felhom-mkfs-guarded.sh |
200 |
raw/tag/v0.128.0/configs/felhom-mkfs-guarded.sh |
404 |
The commit that is 0.128.0 exists and is on main: 28ba8593b8 — v0.128.0 — R-221: the escrow seed is asserted every tick, not remembered once. Only the tag is missing.
This is R-184 happening. R-184 reads: "Nothing prevents the hub from vouching an agent version that was never released. The R-115 gate proves every RELEASED version is installable, but it works from git tags." It was filed as a gap. It is now a live outage.
It is the collision of a good fix with an open gap. R-183 correctly hardened the installer to pin
config fetches to the vouched tag (hostinstall_gates.py even asserts the pin exists —
ok: fetch_raw pins the agent configs to the vouched agent version). Nothing asserts that the pinned ref
resolves. So the first vouch naming an untagged version fails every install closed.
The release script already knows. felhom-agent/scripts/release-agent.sh:23 says in as many words:
"raw/tag/v<version>, a released version without a git tag 404s a box mid-install, as root." The
hazard was understood, documented, and the tag step was still missed.
Blast radius — stated precisely, not inflated:
- Every fresh install and every reinstall, on every host, fails at step 5/8 for as long as the Day-0 manifest vouches agent 0.128.0. That manifest was approved on 2026-08-09; the approval is what armed it.
- Existing boxes are unaffected. demo-felhom runs 0.128.0 happily — the self-update path takes the binary from the package registry, not from a tag.
- So the damage is confined to the install path. That is also the only path a new customer, or a reinstalling tester, ever walks.
What a customer would do next: nothing. curl: (22) … 404 and a question about a git tag is not a
message a household can act on. The visit ends here and a developer is required.
9.2 The box's state, left as found for inspection
--uninstall is not re-run and no manual repair is attempted (rule 1). demo-hp is mid-install:
state.json completed: [preflight, token, grows, enroll] mode: byo
agent 0.128.0 installed, unit NOT created (not-found), inactive
user felhom-agent uid=999 created
pveum 2 users, 3 roles, 63 ACL lines, pool 'felhom' recreated (empty)
guest none
The documented --resume path exists and was deliberately not used: it cannot help. Resume replays
from enroll, hits the same 404, and fails identically — the missing tag is not a transient.
9.3 The one-line unblock, for the operator to decide
cd /mnt/5_hdd/felhom.eu/git/felhom-agent
git tag v0.128.0 28ba8593b8 && git push origin v0.128.0
This publishes the tag for a commit that is already on main and already released as a package — it
completes a half-finished release rather than changing any code. The alternative, rolling the manifest
back to agent 0.127.0, costs R-221 — the fix STATUS.md calls "the last item blocking a customer from
something we promise them" — so it is the worse option.
Not done in this session. Publishing a release tag is an outward-facing act and the runbook is explicit: "If a defect is so severe that the run cannot continue, stop and report; do not fix it here."
9.4 What F-14 still owes
Step 7 never ran, so whether the install would actually have adopted the stale 0.192.0 golden is not
yet observed — F-14 rests on the code path plus the preflight's golden (local): …9100…2026_08_03…
line and the archive's own felhom-controller:0.192.0 marker. That is strong, but it is not the
observation. When the tag is published and the walk resumes, the first thing to record at step 7/8 is
which golden it takes.
9a. P3 resumed — and the install succeeded
Unblocked on operator instruction ("proceed"). v0.128.0 was annotated at 28ba8593b8 and pushed
after an independent download confirmed the published package's sha256 equals the hub's vouched
c6eba73b…. Both config URLs then served 200. --resume completed in 3 m 49 s
(09:30:30 → 09:34:19 UTC): Day-0 provision SUCCESS, controller 0.210.0, agent 0.128.0, guest
9201 onboot, pool member, all 16 ACL assertions green, controller healthy in ~18 s.
R-274 partly refuted, and recorded as such. Step 7 fetched the vouched 0.210.0 — because
--resume skips preflight, and preflight is where local auto-discovery sets GOLDEN_VOLID. So the
fresh and resume paths disagree, and the resume path is the safe one. What survives: discovery is
sort | tail -1 (newest local) with no manifest comparison, so a box whose newest local golden
predates the vouched one still installs stale. Full text in the register.
10. P4 — first contact, as the customer
A clean pass, and worth saying so plainly. Within four minutes of the install finishing, the customer's own URL — fetched from outside the box, over the public internet — served:
A szerver beállítása · Demo HP Add meg az e-mailben kapott beállító kódot, majd válassz saját jelszót a vezérlőpult védelméhez. Beállító kód · Új jelszó (min. 12 karakter) · Új jelszó megerősítése Nem kaptad meg a kódot? Új kód kérése
Unprompted, in Hungarian, naming the customer, with a self-service route if the code never arrived, and nothing anywhere asking for a command line. The hub showed the host ONLINE at agent 0.128.0.
Two findings here, neither fatal: the hub still read "Claimed 18d ago" while the box was serving its first-run page (R-283), and the code that arrives is named three different things across the three surfaces, with the mail pointing at an „Elfelejtett jelszó" page the box does not show (R-282). The reset code was nonetheless accepted on the setup page — 302 and a session — so this is naming, not function.
11. P5 — getting the machine back to work
11.1 The recovery screen, unsought — the headline pass
The first thing on the dashboard after claiming, with nothing sought:
Adatok visszaszerzése — Ezt a gépet újratelepítették. A korábbi, házon kívüli mentéseid megvannak — a sealed package held centrally, sealed at 2026-08-04T11:11:37Z, openable only with the customer's code; nobody can replace it — not Felhom, not support, not the operator; and „Ebben a lépésben semmit nem állítunk vissza és semmi nem változik." Field: Helyreállítási kód (tíz szó).
R-193's screen meeting reality on a genuinely rebuilt box. It answered all three of its questions and
its seal date matches host_escrow.created_at exactly.
(Nit: the seal date is rendered raw as 2026-08-04T11:11:37Z to a Hungarian household — ISO-8601 in
UTC where a localised date belongs.)
11.2 STOP 3 — the unlock
Entered at the box's own screen, from a file, never on a command line. 21 seconds, and it listed what it found without restoring anything:
| app | legutóbbi mentés | méret |
|---|---|---|
| calibre-web | 2026-08-09 10:30 (CEST) | 3.8 MB |
| felhom-offbox | ″ | 6.8 KB |
| opengist | ″ | 182.3 KB |
| privatebin | ″ | 6.8 KB |
That is exactly the Gate 0 snapshot set, seen from the customer's side.
11.3 The wall — R-280, and it is rank 1
The restore page diagnoses the situation perfectly and then sends the customer to an empty page:
„Előbb csatold vissza az adatmeghajtót. … Ez két kattintás: Tárhely → Meghajtók, »Meglévő meghajtó csatolása«. Utána gyere vissza ide."
It is not two clicks; it is zero possible clicks. GET /api/disks/candidates →
{"initialize":[],"attach":[]}. The agent is fine — GET /api/disks returns the NVMe in full — but
handleDiskCandidates builds both lists from the unclaimed-disk scan, and demo-hp's NVMe is
deliberately both the user-data drive and the felhom-backup target, so it is claimed and never
offered. Correct for initialize; over-broad for attach, which is non-destructive by definition.
It cascades: no store → Calibre-Web's install page degrades to „Nincs regisztrált adattároló — adja meg kézzel az útvonalat"; no app → every restore row reads „Nincs telepítve".
Escape hatch used, and recorded as off-path: POST /settings/storage/add with
storage_path=/mnt/sys_drive succeeded first try — an internal path, the very one registered before
the wipe, that no household customer could produce. Everything unblocked immediately afterwards and
the deploy form became a proper picker („Tárhely (sys_drive) — 64.2 GB szabad").
11.4 App redeploy
calibre-web deployed from the catalogue through the dashboard's own API in 1 m 36 s, running and
healthy. The catalogue itself was complete and in Hungarian, every app „Nincs telepítve".
(Also observed: the deploy page warned „A kiválasztott tárhely majdnem megtelt." over an option
carrying data-free-percent="93" and „64.2 GB szabad" — an apparent inverted threshold, R-284.)
12. P6 — getting the data back
Three options, each explained, none starting without a question. R-204 item 3 is visibly fixed: option 1 states in as many words that it does not bring the customer's own files back.
| step | wall clock |
|---|---|
| full-restore prepare (3.8 MB) | 8 s |
| full-restore execute | 13.2 s (10:27:32.08 → 10:27:45.29 UTC) |
„A(z) calibre-web teljes mentése visszaállítva ellenőrző mappába:
/mnt/sys_drive/felhom-data/backups/offsite-restore/calibre-web— a saját fájljaiddal együtt. A meglévő adatok változatlanok."
Restored to a verification folder, not into place — honest about it, and R-213's territory.
THE INTEGRITY VERDICT — BYTE-IDENTICAL
expected 4 file(s); found 4
VERDICT: BYTE-IDENTICAL
Compared against GATE0-before-manifest.json, keyed on raw name bytes:
| sha256 | bytes | name |
|---|---|---|
54b773c46bbfd994… |
3 145 728 | binary-3mb.bin |
52a5c5ebfcac247f… |
59 | árvíztűrő-tükörfúrógép.txt — name bytes c3a1… identical, NFC preserved |
15d2024dfc224162… |
25 | nested/őszibarack.md — name bytes identical |
924497918e55fe6d… |
21 | plain.txt |
Four expected, four restored, zero differences. The accented filenames survived as bytes, not merely as rendered text — the discriminator the Gate 0 positive control was built to enforce.
13. P7 — what the hub thought was happening
It thought nothing. Across the whole reinstall the hub recorded zero events and zero
notifications for demo-hp. Positive control (standing rule 3): the hub logged 2 events all day
across all customers, newest db_dump_completed at 00:30:07 — the store is reachable and the
silence is real.
- The good half: no false alarm fired during a legitimate reinstall, which is what P7 watches for.
- The owed half (R-281):
escrow_blob_servedexists as the tripwire for exactly this moment — "If no recovery is in progress on that box, investigate" — and has fired for demo-hp before (last 2026-08-04 20:12:54). Today's real unseal fired it not at all. A reinstall and a stolen machine look identical to the operator.
14. Teardown — all four layers
- The machine — my instruments (
fp.py,restored.json, the installer, the passphrase file) removed. Guest 9201 running and healthy.drill-r50(VM 300) untouched throughout, as predicted. - The host —
local-lvm36.97 % → 21.85 % (bare) → 32.35 % (rebuilt). Pre-existing leftovers found and deliberately not removed, recorded instead:c11-scratch, the orphanedvzdump-lxc-9100archives (now three). - The hub — no customer or appliance record created;
demo-hpretained deliberately per the runbook. Nothing to delete. - The off-site side — 18 snapshots, newest still
9e38b84c/78b93f04at 08:30 UTC, i.e. unchanged since Gate 0. The restore was a pure read. No prune, no forget, no delete by me; the only retention that ran was inside the product's own backup call at Gate 0, which reported18 snapshot(s)itself.
15. The answer to §2's question
Yes — the data comes back, byte for byte. No — not in one sitting, and not without a shell.
The walk completed: P1 ✓ P2 ✓ P3 ✓ P4 ✓ P5 ✓ P6 ✓ P7 ✓, and the verdict is BYTE-IDENTICAL. But it completed only because two hard stops were cleared by someone who could open a terminal and read source code:
- R-273 — the install died at 5/8 on a git tag that was never pushed. Cleared by a release action.
- R-280 — the data drive could not be re-attached through any dashboard route, while the page promised „két kattintás". Cleared by POSTing an internal path a customer could not know.
Neither is a data-integrity problem. Both are journey problems, and both stop a household dead. This is the same shape the R-201 walks kept finding: the data half passes, the journey half fails.
What the product did beautifully, and should not be lost in the finding count: the box came up on its own at the vouched version; the setup page appeared unprompted, in Hungarian, naming the customer; the recovery screen appeared without being sought and stated plainly that nothing would be changed by unlocking; the unlock took 21 s; the restore took 13.2 s and said honestly that it had put the files in a verification folder rather than back in place. Sixteen ACL assertions verified themselves. No false alarm fired.
Wall clocks
| segment | |
|---|---|
| P1 uninstall | 60 s |
| P2 preflight (2 refusals, then pass) | ~6 min |
| P3 install — first attempt, FAILED | 44 s |
| P3 install — resumed, SUCCESS | 3 m 49 s |
| P4 first contact (box live on its own URL) | within ~4 min of install |
| STOP 3 unlock | 21 s |
| app redeploy (calibre-web) | 1 m 36 s |
| restore prepare + execute | 8 s + 13.2 s |
| bare machine → verified files | 1 h 49 m 22 s (08:38:23 → 10:27:45 UTC) |
| — of which the product's own work | ≈ 7 m 47 s |
The 1 h 49 m must not be quoted as the customer number — it is dominated by the R-273 diagnosis and release fix (~38 min) and by two waits on a human. The ≈ 7 m 47 s must not be quoted either: it is what the product costs when someone already knows every answer. The honest figure for an unaided household is undefined, because an unaided household does not finish.