# CAMPAIGN 11 — step-by-step journal Every observable, in the order it was taken. Times are CEST (demo-hp's clock) unless marked UTC. --- ## Pre-flight — baselines re-read on arrival (2026-08-05) | What | Value | How read | |---|---|---| | `felhom-controller` `main` | **v0.200.0** @ `a315d623b857` | `git rev-parse HEAD` == `origin/main`, tree clean | | `felhom-agent` `main` | **v0.125.0** @ `0404f60e6a7b` | same | | `felhom.eu` `main` | hub **v0.96.0** @ `f45b1f6761ff` | same | | hub, LIVE | `gitea.dooplex.hu/admin/felhom-hub:0.96.0` | `kubectl -n felhom-system get deploy hub -o jsonpath=…` | | ISO, published | `felhom-installer-1.26.1-pve9.2-1.iso` · 1 705 322 496 B · sha `f3cc86d5f0ec…59a6` | `curl -I` on `iso.felhom.eu` + published `.sha256` | | ISO, local copy on demo-hp | **sha `f3cc86d5f0ec…59a6`, 1 705 322 496 B — identical** | `sha256sum` on demo-hp | | Highest register ID | **R-213** | fresh `grep -rhoE 'R-[0-9]{1,3}' --include=*.md` over all four repos | **All three cited commits match the brief exactly.** Two brief assumptions were stale and reading fresh caught both: 1. The brief says installer **1.25.0**; the published artifact is **1.26.1** (since 2026-07-31). Same class Campaign 10 caught, same file. 2. The brief's §11 says *"Five scratch customers have accumulated"*. **There are none.** The hub holds exactly five customer records and every one is legitimate: `david` (a real prospect), `demo-felhom`, `demo-hp`, `drill-r50` (the fixture, BLOCKED), `peti-felhom` (the external tester). `c10-soak`, `sess-c`, `sess-d`, `sess-f` are all gone. ### The finding that decided the campaign's shape, found before anything was built Day-0 artifact manifest, read from the hub's `/configuration`: | | Vouched | Newest that exists | `main` | |---|---|---|---| | golden (bakes the controller) | **0.192.0** | **0.192.0** (no newer golden exists) | controller **0.200.0** | | agent | **0.120.0** | **0.125.0** (published in Gitea) | **0.125.0** | | global controller floor | **0.156.0** | — | — | The controller's auto-update target is **always the floor, never latest** (`selfupdate/updater.go:64`). So a box installed from today's published ISO lands on controller **0.192.0 and stays there** — which is before R-203's fix (0.197.0), before the credential self-heal (0.199.0), and **before the recovery screen this campaign exists to test (0.200.0)**. Both demo boxes run agent **0.125.0** while the manifest vouches **0.120.0** — R-121's drift family, live, on the day-0 channel rather than on a box. **Operator ruling, 2026-08-05:** install stock, record what a customer gets today, then raise the **per-customer** floor (`configs.go:1229`) to 0.200.0 and let the box converge. Per-customer means the other four customers are untouched. ### Two further operator rulings taken before building - **ep0 + the Hetzner Storage Box are written to, additively.** Off-site hard-requires the DR tier (`hub/internal/web/configs.go:1298`), and the DR tier provisions a PBS namespace + token on **ep0** via tenantsync plus a WireGuard peer; enabling off-site provisions a Storage Box sub-account. All three are creates on the ordinary customer path — nothing existing is modified or deleted — and teardown removes the campaign's own. Campaign 10 avoided this only by running with Tier 3 OFF, which is not available to a campaign about off-site recovery. **The brief's I7 wording ("ep0 read-only") was therefore relaxed deliberately, by ruling, and is recorded here rather than quietly widened.** - **Recovery codes** travel file→file into a `0600` file, are moved by the operator into their own store, and are then shredded with a planted-copy positive control. --- ## Venue | | | |---|---| | Host | `demo-hp` (HP t740), Tier 0, the designated drill host. Reached **by SSH key, first try** — R-129 stands | | VM | **321 `c11-appliance`** — q35/OVMF (`pre-enrolled-keys=0`), 4 cores, 8 GB, `cpu=host` | | Disks | `scsi0` 200 G system · `scsi1` 50 G · `scsi2` 50 G, qcow2 | | Storage | **`c11-scratch`**, `dir` at **`/mnt/nvme-1tb` — the mount ROOT** (a subdirectory fails the agent's `exactMount` check; Campaign 10 §1) | | Box | `c11.felhom.eu` @ **192.168.0.105/24**, gw/DNS 192.168.0.1, on demo-hp's `vmbr0` | | Hub customer | **`c11`** "Campaign 11", domain `c11.felhom.eu`, DR tier ON, **off-site initially OFF** | | Host id | **`c11-36d660`** | | Untouched | `drill-r50` (VM 300, stopped), guest 9201 on both demo boxes, `local-lvm`, `felhom-backup`, every other hub customer | ### Harness constraint — the appliance is NOT reachable from DooPlex DooPlex is `192.168.0.180/24` on `eno1` and **demo-hp is a different site with an overlapping `192.168.0.0/24`** — `ssh demo-hp` resolves to Tailscale `100.76.96.79`, and DooPlex's ARP for `192.168.0.105` is `INCOMPLETE` while demo-hp pings it in 0.4 ms. Every HTTP call to the appliance therefore goes **DooPlex → (Tailscale) → demo-hp → vmbr0 → appliance**, through a SOCKS proxy (`ssh -N -D 11080 demo-hp`). No server logic is skipped — this is a transport, not a shortcut. **Harness, not product.** --- ## Phase 0 ### Step 1 — install from the published ISO, through the real day-0 path Driven blind through the **Terminal UI** (`qm monitor screendump` → PNG → read visually; `qm sendkey` for input). Campaign 10's three traps, all handled: - **GRUB's default is the graphical entry** and the graphical entry is undrivable (monitor mouse events do not move the guest cursor). The menu appears ~6 s in with a 15 s countdown; the first attempt sent `down` from a *separate* round trip and missed the window, booting graphical (a grey screen with a cursor). Reset and sent `down`+`ret` **inside the same remote command** — the console then read *"A Felhom telepítése indul szöveges módban…"*. - **The guest keymap defaulted to Hungarian while `sendkey` emits US scancodes.** Changed to **U.S. English** before typing anything. **Positive control:** the administrator email was typed through the identical code path and rendered **`c11@felhom.eu`** on screen — the `@` is `shift-2` on a US layout and `AltGr+V` on a Hungarian one, so a correct `@` proves the mapping for the 24-char root password that is masked and cannot be read back. *(Deviation from a real Hungarian install, recorded: the installed console keymap is US. The customer journey is the web dashboard, so this is cosmetic — but it is a deviation and it is not buried.)* - **`--boot` set in its own `qm set` after the disks existed**, and verified from `qm config` (`boot: order=scsi0`) before the first boot. `Automatically reboot` was **unchecked** so the reboot into the freshly installed system was deliberate rather than a race with the ISO. Only `scsi0` was attached during the install, so the installer offered exactly one target (`/dev/sda (QEMU HARDDISK) (200.00 GiB)`) and could not reach a data drive. The two 50 G drives were attached afterwards. Summary screen, verbatim: `ext4` · `/dev/sda` · `Europe/Budapest` · `U.S. English` · `c11@felhom.eu` · `nic0` · `c11.felhom.eu` · `192.168.0.105/24` · `192.168.0.1` · `192.168.0.1`. **Day-0 fired on first boot**, unaided. The console showed, in Hungarian: ``` Felhom — a doboz készen áll, és a párosításra vár. Párosító kód: RPM-YNF Nyisd meg az e-mailben kapott linket, és add meg ezt a kódot és a jelszavadat. Ez a képernyő magától frissül — nincs teendő a doboznál, és nyugodtan itt hagyhatod bekapcsolva. ``` and the hub's **Unclaimed appliances** table carried it within a minute: uuid `27f35841-0609-410d-af52-76d5c0dbf801` (matching the VM's smbios uuid), pairing code `RPM-YNF`, MAC `bc:24:11:c8:4e:ce`, hardware `Standard PC (Q35 + ICH9, 2009)` / `AMD Ryzen Embedded V1756B` / 7.7 GB, and **three SSH host keys**. Bound to `c11` through the real endpoint (`POST /appliances/20/bind`, HTTP 303) → the row reads `bound → Campaign 11`. Host **`c11-36d660`** enrolled ~3 minutes later: `ONLINE`, agent **0.120.0**, `PBS wrapper matches vouched`, desired generation 2. **Day-0 provision SUCCESS, 14:12:37 CEST** (`journalctl -u felhom-bootstrap`), 7 minutes after the bind: ``` [OK] controller: Up 19 seconds (healthy) (after ~0s) [INFO] controller image: gitea.dooplex.hu/admin/felhom-controller:0.192.0 [OK] Day-0 provision SUCCESS — vmid=9201 host_id=c11-36d660 customer=c11 golden=local:backup/vzdump-lxc-9100-2026_08_05-14_10_02.tar.zst [INFO] root@pam was rotated + vaulted at step 4b — retrieve at hub → host page felhom-bootstrap: host-install SUCCESS — writing done-flag, disabling unit, scrubbing secrets ``` `/etc/felhom/.bootstrap-done` written 14:12, unit `inactive (dead)` and disabled, 1 min 35 s CPU. **The installer's root password stopped working exactly as Campaign 10's A2 recorded** — the first SSH attempt with it was refused, and the hub's vaulted managed credential (`POST /hosts/c11-36d660/reveal-recovery-credential`, `set_at 2026-08-05T12:09:34Z`, `username root@pam`) authenticated first try. That refusal is the **positive observable** that the day-0 credential handover completed. The controller landed on **0.192.0** — the golden's baked version, exactly as predicted from the manifest. ### Step 1b — the per-customer floor, and the convergence it drives `POST /customers/c11/floor` with `min_controller_version=0.200.0` (HTTP 303). The customers table then reads `c11 … 0.192.0 … v0.200.0 (override)` while **every other customer still reads `v0.156.0`** — the blast radius is zero, measured rather than asserted. **The box converged in under two minutes**, unaided: ``` gitea.dooplex.hu/admin/felhom-controller:0.200.0 Up 9 seconds (healthy) ``` So the recovery screen IS reachable on a fresh box — but only behind an operator floor override, because no golden carrying it exists. ### FINDING (candidate R-214) — the physical console never stops asking to be paired Thirty minutes after `Day-0 provision SUCCESS`, with the host `ONLINE` in the hub and the customer guest running, the appliance's physical console still read: ``` Felhom — a doboz készen áll, és a párosításra vár. Párosító kód: RPM-YNF ... Ez a képernyő magától frissül — nincs teendő a doboznál, és nyugodtan itt hagyhatod bekapcsolva. ``` **Established from source, not inferred.** `print_pairing_banner` is called from exactly one place — the pairing wait loop (`scripts/iso/felhom-bootstrap.sh:456`), every 10 cycles (5 min). A census of `/dev/console` writers across the entire day-0 path returns **two**, both in `felhom-bootstrap.sh`: the banner (`:76`) and the R-59 network-refusal screen (`:252`). **`scripts/felhom-host-install.sh` writes to the console not at all.** So after the bind lands, nothing repaints, and the banner is the terminal state of the screen. Two things are wrong at once, and the second is the one that makes it a defect rather than a cosmetic gap: the screen shows a **stale pairing code** for a box that is already bound, and the banner **asserts of itself** that it refreshes. That is `CLAUDE.md`'s "a comment asserting an invariant needs a test pinning it" family, in customer-facing Hungarian copy on the one surface a customer looks at when they are already unsure. Mitigating, and stated so the severity is not overclaimed: the same banner says *"nincs teendő a doboznál"* — the customer's real route is the e-mailed link, not the box. ### Step 2 — the claim, and the constraint it exposed The dashboard is served by traefik on 443 in the guest (`192.168.0.207`), not by the controller directly; `Host: felhom.c11.felhom.eu` is mandatory. `GET /` → 302 → **`/claim`**, titled *"A szerver beállítása — Campaign 11"*, asking for the *beállító kód* and a new password (min. 12 characters). **The claim code is e-mail-only by design and the campaign has to live with it.** The hub stores a hash (`store.RotateClaimCode`, `store.go:1667`) and the resend path states it outright: *"the hub stores a hash — no plaintext code exists to display"* (`configs.go:806`). It went first to `c11@felhom.eu`, a mailbox that does not exist. **This is not a Phase-0 inconvenience — it decides whether Phase 1 can be claimed as unaided.** Phase 1 and Phase 3 each need a further claim, and the controller's `--print-reset-code` escape hatch is a command line inside the guest. **Operator ruling, 2026-08-05:** point c11's registered address at the operator's own mailbox and have them relay each code — which is precisely what a real customer does, and what R-119 already records as *"the single human step"*. `POST /configs/c11/edit` with `email=` (HTTP 303), then `POST /configs/c11/claim-resend` (HTTP 303) → *"Nyitott — kód kiküldve just now, generation 2"*. Claim driven through the real customer form (`POST /claim`, code + a 20-char password, HTTP 302 → `/`), landing on **`/launcher`** ("Indítópult"), controller **0.200.0**. The box is claimed and the household password is customer-owned. ### F9, taken here rather than in Phase 2 — and it FAILS F9's stated right answer for a box that never had off-site backups is **"no screen, no entry point, nothing"**. `c11` is exactly that box: installed today, off-site never configured. **Two of the three hold. The third does not.** | | Expected | Observed | |---|---|---| | full-page interruption | none | **none** — `GET /` → `/launcher`, not `/recovery` ✅ | | entry point in the backups area | none | **none** — `/backups/remote` reads *"Még nincs beállítva távoli mentési cél."* and contains **zero** `/recovery` links ✅ | | the page itself | should not exist for this box | **`GET /recovery` → HTTP 200, and it asserts the machine was reinstalled and that sealed backups exist** ❌ | Verbatim, from the rendered page on a box with no backups of any kind: > *„Ezt a gépet újratelepítették. A korábbi, **házon kívüli mentéseid megvannak** — a Felhom központi > rendszere őriz hozzájuk egy lezárt csomagot."* **Ground truth, measured, not inferred.** Hub SQLite snapshot taken WITH its `-wal` and `-shm` (`PRAGMA integrity_check` → `ok`; freshness by positive observable — newest `host_reports.received_at` `2026-08-05 12:55:05` against `datetime('now')` `12:55:39`, **34 s old**): ```sql SELECT host_id, length(identity_blob) FROM host_escrow; demo-felhom-8363b5|572 demo-hp-bb76ea|572 -- c11-36d660: NO ROW AT ALL ``` So `identity_blob_present` is false in every ACK c11 has ever received, `GetHubEscrowIdentityPresent()` is false, and **`OffsiteRecoveryOffer()` correctly returns false. The predicate is right. The page never asks it.** **The mechanism, from source.** - `recoveryPageHandler` (`internal/web/recovery_handlers.go:65-67`) is three lines and calls `renderRecovery` with **no gate**. - `renderRecovery` sets `data["Offer"] = s.recoveryOffer()` (`:76`) and **`recovery.html` never reads it** — `grep -c '\.Offer'` over the template returns **0**. The template's only top-level branch is `{{if .Unlocked}}`, so every non-unlocked render prints the headline above. - **Its POST sibling IS gated**: `recoveryUnlockHandler:141` opens with `if !s.recoveryOffer() { redirect to /backups/remote }`. The action is protected; the page is not. - **And the other template gets it right**, in the same release, for the same claim: `backups_remote.html:11` wraps the entry point in `{{if .RecoveryOffer}}` — which is why no entry point appeared. One gated, one not, same sentence. **Why the green suite missed it.** `TestRecovery_B_DoesNotAppearForAnyoneElse/never had off-site backups (no hub package)` (`recovery_test.go:200-208`) asserts **`f.s.recoveryOffer()`** and issues no `GET /recovery`. It pins the mechanism; the consequence is unpinned. That is `CLAUDE.md`'s own rule — *prefer a test that asserts the consequence over one that asserts the mechanism* — and the seam-wiring rule's inverse case: usually the gate is missing, here the gate exists and the render does not consult it. **Severity, stated without inflation.** There is no link to `/recovery` on such a box, so a customer reaches it only by typing the URL or following a stale bookmark. But what they are then told is false in two ways at once — *your machine was reinstalled* and *your off-site backups exist* — on the one screen whose entire purpose is to be believed about backups. It is I6: an absence reported as a fact. → **R-215** ### Ground state for teardown — ep0, captured BEFORE anything else The DR tier provisioned itself on ep0 the moment the customer was saved with `dr_tier=on` and the WG peer registered. Recorded so teardown removes exactly the campaign's own and nothing else: ``` namespaces: c11 demo-felhom demo-hp ← c11 is the campaign's tokens: felhom@pbs!c11 (+ the two demo) ← c11 is the campaign's acl: felhom@pbs → /datastore/felhom-offsite/c11 DatastoreBackup felhom@pbs!c11 → /datastore/felhom-offsite/c11 DatastoreBackup df /mnt/pbs-datastore: 98G total, 9.9G used, 11% ``` ### Step 2b — the drives, through the real endpoints Both 50 G drives appeared as raw candidates (`GET /api/disks/candidates` → `initialize: [/dev/sdb, /dev/sdc]`) and were enrolled through the customer flow (`POST /api/storage/init`, polled on `/api/storage/init/status` to `phase: done` — Campaign 10's F6 detached-job path): | Drive | Device | Stable path | Role | |---|---|---|---| | `adatok` | `/dev/sdb` | `/mnt/felhom-drives/adatok` | app data, `set_default` | | `mentes` | `/dev/sdc` | `/mnt/felhom-drives/mentes` | **backup target** | The backup-target offer behaved exactly as Campaign 10 recorded — offered, accepted through `POST /api/storage/backup-target/assign`, `restart_required: true`, and the state flipped only after the agent restart it asked for: ``` before: {"degraded":true,"target":"local","message":"A rendszermentés jelenleg ugyanazon a lemezen van…"} after: {"degraded":false,"known":true,"label":"Mentes","target":"felhom-backup"} ``` That healthy read is this campaign's **I5/I6 baseline**. ### Step 3 — the app and the three sentinels **`calibre-web`** — the drill's own fixture app, and the only sensible choice: it declares `userdata: media/books class: mandatory` (`app-catalog-felhom.eu/templates/calibre-web/.felhom.yml:21-23`), it is single-container, and its file leg is a bind rather than a named volume (the 2026-08-04 drill established that a named-volume-only app has **no restorable file leg** — the off-site tier tars it and the customer restore flow never unpacks it). Deployed through the real API (`POST /api/stacks/calibre-web/deploy`, HTTP 202) with `HDD_PATH=/mnt/felhom-drives/adatok` — a real enrolled drive, which is the normal customer shape and deliberately **not** R-203's `/mnt/sys_drive` case. Reached `running` / `Up About a minute (healthy)`. Three sentinels, written into the mandatory path, hashed after `sync`: | # | File | Bytes | sha256 | |---|---|---|---| | A | `C11-SENTINEL-A.txt` | 54 | `beb9175d52325bf583039beb6db1ecc80ea0781720e262c83230ed46a166df27` | | B | `C11-őrszem-ékezetes-árvíztűrő.txt` | 73 | `7c8cb0ad040856440cc96bb1ad8f336d04f458138d85e6904ee759e690743a87` | | C | `C11-SENTINEL-C-12MB.bin` | 12 582 912 | `e012e76f3502de7c608d5b35f67b89c2404edad49eea92a1fc35a1bf02cbb9c0` | *(Method, stated: the sentinels were written on the box, not through the dashboard. Phase 1's no-command-line rule governs Phase 1; fixture construction is not part of what it measures.)* ### Step 4 — the off-site tier, and what enabling it actually did `POST /configs/c11/edit` with `offsite_enabled / shared / 50 GB / bx11`. The hub provisioned a Storage Box sub-account and the box picked up the target: ``` [offsite] shared provisioned for c11 (subaccount 284166, user u629488-sub4) [offsite] host-key scan u629488-sub4.your-storagebox.de failed (attempt 1/6 … 5/6): lookup … no such host ← DNS for a just-created sub-account [INFO] Customer config updated: c11 ← succeeded on attempt 6 ``` **Worth recording rather than treating as noise:** first-provision blocks on DNS propagation for the new sub-account hostname and took **over 60 s of backoff** (2+4+8+16+30). The retry ladder is doing exactly its job — but the HTTP request outlived a 120 s client timeout while the hub completed the work anyway, so a client that gives up here would be looking at a *succeeded* provision it thinks failed. No R minted: the hub's own copy already warns *"offsite provisioning can take up to a minute"*. Target on the box: `u629488-sub4@u629488-sub4.your-storagebox.de:/home/felhom-repo`, quota 50 GB. `calibre-web` toggled on for off-site (`POST /backup/offbox/toggle`). ### Step 5 — the escrow ceremony, and R Preflight (`GET /api/escrow/preflight`) — **six of six green**, including the one that matters: ``` pbs_storage_id ok (felhom-pbs) · dr_tier ok (DR tier applied) · age_binary ok (/usr/bin/age) hub_upload ok · staged_secret ok (staged secret present) · sudo_grant ok ``` Ceremony driven through the customer wizard's own endpoints (`/api/escrow/start` with a password re-auth → `/api/escrow/status` → `/api/escrow/claim`): ``` phase: done · restic_pw_sealed: TRUE · uploaded: true · entropy_bits: 129.24 key_fingerprint: 3c:f7:76:82:e3:0c:85:d7:…:1d:04:43:b8 claimable: true → claimed: true, claim_expires_in_sec 585 → 0 ``` **`restic_pw_sealed: true` is the field this whole campaign rests on** — the off-site repository password is inside the sealed bundle. **R was captured file→file** into a `0600` file and never rendered. Its shape was verified without printing it: **ten hyphen-separated alphabetic words, 85 characters**, consistent with the ~129-bit EFF phrase the ceremony reports. Hub-side, immediately after (SQLite snapshot with `-wal`, freshness 9 s): ``` host_escrow(c11-36d660): blob 383 B · identity_blob 572 B · stale_at NULL restic_pw_sha256 626e424670248db3a25b0a48aa8a114da9061529823ce9cea7d95d9e0ee3c303 created_at 2026-08-05T13:11:11Z ``` The box then read *„A helyreállítási kód letétbe helyezve."* and `escrow_state: escrowed`. ### Step 5b — the off-site backup, and the sentinels listed BY NAME `POST /backup/offbox/run` → `Fut…` → **`✓ Rendben`**, 1 m 6 s. **The gate is not the green tick.** `restic snapshots` + `restic ls -l latest`, run against the repository with its own credentials: ``` snapshot f3d9cd67d539c00359f0454ea7a782f45beac8406c55275bee5a6886afa8d791 (short f3d9cd67) time 2026-08-05T13:13:32.326542714Z hostname c11 tags [felhom-offbox, calibre-web] paths /mnt/felhom-drives/adatok/backups/primary/calibre-web /mnt/felhom-drives/adatok/userdata/media/books -rw-r--r-- 1000 1000 54 …/userdata/media/books/C11-SENTINEL-A.txt -rw-r--r-- 1000 1000 12582912 …/userdata/media/books/C11-SENTINEL-C-12MB.bin -rw-r--r-- 1000 1000 73 …/userdata/media/books/C11-őrszem-ékezetes-árvíztűrő.txt -rw-r--r-- 1000 1000 413696 …/userdata/media/books/metadata.db + the recovery unit: compose/{.felhom.yml,app.yaml,docker-compose.yml}, manifest.json, volume-dumps/calibre-web_calibre_web_config.tar (389 632 B) ``` **All three sentinels are in the snapshot, by name, at the right sizes — and the Hungarian filename survived the whole chain into restic byte-intact.** The mojibake gate the brief warned about does not fire here. **Independent live confirmation of R-203's fix, on a shape the drill never tested.** The capture set's path is `/mnt/felhom-drives/adatok/userdata/media/books` and the app's live bind is the *same* directory (`docker inspect`: `bind /mnt/felhom-drives/adatok/userdata/media/books -> /calibre-library`). On a non-system drive `NamespaceRoot` correctly omits the `felhom-data` segment for both, so the two roots agree. *(The import leg still resolves under `/mnt/sys_drive/felhom-data/userdata/import/calibre` — a different drive AND the `felhom-data` root — which is correct for an `excluded`-class path but is the same asymmetry R-203 was filed about. It is harmless here because nothing mandatory lives there.)* ### Step 6 — the pre-destruction truth Box's own state (`settings.json`, secrets stripped): ``` offbox: enabled true · escrow_state "escrowed" last_run/last_success 2026-08-05T13:14:26Z · last_status "ok" · last_duration 1m6s snapshot_count 1 · repo_size_bytes 12 611 522 ("12.0 MB") · quota_gb 50 hub_escrow_identity_present: true ``` Hub's state: `identity_blob` **572 B**, `restic_pw_sha256` `626e4246…c303`, `stale_at` NULL, Key Escrow **present**, DR Recipe **present**. ### I5 at the Phase 0 → Phase 1 boundary — investigated, and DISPROVED At 15:20 CEST the hub's operator view read **`Snapshots 0 · Repo Size 0 MB · Integrity Unknown`** while the box held 1 snapshot and 12 611 522 B. That is exactly the shape I5 exists to catch, and it would have been easy to file. **It was cadence, not disagreement.** c11's legacy controller report last landed at 13:11:43 UTC — **three minutes before** the backup finished at 13:14:26. Rather than judge from a single stale read, a poll ran until either convergence or a full interval elapsed. It converged at **13:23:10 UTC**, 8 m 44 s after the backup: ``` {"enabled":true,"escrow_state":"escrowed","last_run":"2026-08-05T13:14:26Z","last_status":"ok", "last_success":"2026-08-05T13:14:26Z","snapshot_count":1,"repo_size_bytes":12611522,"quota_gb":50} ``` Byte-for-byte what the box's own `settings.json` holds. **I5 PASS.** No `R-n` minted. The general lesson, and the reason this is written down rather than dropped: **on this fleet the hub's view lags the box's by up to one report interval, so "the two views disagree" is only a finding after an interval has passed.** A verdict taken from one read at the wrong instant is Campaign 10 §5.3's mistake — reading the two halves at different instants of a transition. --- ## Phase 1 — the clean journey, unaided **Method note, stated before the result.** There is no browser on DooPlex, so every customer action is driven at the endpoint the UI itself invokes (`felhom-controller/CLAUDE.md`'s standard method), through the SOCKS transport described in the venue section. **The rule Phase 1 actually tests — no command line INSIDE THE GUEST — is enforced literally**: after the destruction, nothing reaches guest 9201 except HTTP requests a browser could have made. Actions on the appliance *host* (the Proxmox layer) are the customer's installer, not the guest. **No rollback archive was taken, deliberately.** `qm snapshot 321` refused — *"snapshot feature is not available"*, because the OVMF `efidisk0` is `raw` and a raw disk blocks qcow2 snapshots. Rather than convert a disk mid-campaign, the run proceeded without one: the venue is disposable and its loss is authorised, and **the only failure that matters — the off-site repository failing to open — is not protected by a local archive anyway.** ### Step 1 — the destruction, 2026-08-05T13:35:19Z Recorded immediately before, so the loss is measured rather than assumed: ``` rootfs local-lvm:vm-9201-disk-0 32G · mp0 local-lvm:vm-9201-disk-1 70G (/var/lib/felhom) containers: felhom-controller 0.200.0 · calibre-web · filebrowser · traefik /mnt/felhom-drives/adatok/userdata/media/books: all three sentinels + metadata.db (13M total) /mnt/felhom-drives/adatok/backups/primary/calibre-web: the Tier-1 recovery unit ``` Then: ``` pct stop 9201 ; pct destroy 9201 --purge Logical volume "vm-9201-disk-0" successfully removed. Logical volume "vm-9201-disk-1" successfully removed. purging CT 9201 from related configurations.. pct list → (empty) rm -rf /mnt/felhom-drives/{adatok,mentes}/* ← a hardware loss takes the drives too du -sh → 4.0K / 4.0K ``` **The guest, its 102 GB of volumes, both enrolled drives and the entire local backup tier are gone. The customer's three sentinels now exist in exactly one place on Earth: the off-site restic repository at `u629488-sub4.your-storagebox.de:/home/felhom-repo`.** This is a deliberately *harder* destruction than the 2026-08-04 night drill, which wiped only the controller's data volume. The brief asks for "the guest and its data volume, as a hardware loss would", and being attended rather than unattended makes the guest-destroy + reprovision chain safe to walk. **The host identity `c11-36d660` survives** — which is the point: the hub's sealed package is keyed to the HOST, so destroying the host as well would make recovery impossible by construction rather than by defect. ### Step 2 — reinstall through the same day-0 path, started 13:36:07Z The hub's own Setup Command, `felhom-host-install.sh` **v1.25.0** fetched live from `https://felhom.eu/scripts/` — the documented path that *"does what the ISO's package would have done"*. The retrieval passphrase went in via `--passphrase-file` (a `0600` file), never on argv. Completed **13:38:06Z** — `Day-0 provision SUCCESS — vmid=9201 host_id=c11-36d660 customer=c11`, guest back at **192.168.0.227**, controller **0.192.0** healthy. **`root@pam` rotated + re-vaulted again.** Then, unprompted, the per-customer floor did its job: ``` 13:35:19Z destroy 13:36:07Z reinstall start 13:38:06Z controller 0.192.0 healthy ← the GOLDEN's version 13:38:42Z controller 0.200.0 healthy ← floor-driven self-update, 36 s later ``` **The assertion that keeps recovery possible — PASSED.** Read from the hub with the `-wal`, after the destruction: ``` host_escrow(c11-36d660): identity_blob 572 B · restic_pw_sha256 626e424670248db3… (UNCHANGED) created/updated 2026-08-05 13:11:11 (UNCHANGED) · stale_at NULL host_escrow_superseded: rows 3 and 4 only — the two demo boxes. NOTHING re-escrowed itself. ``` **And the rebuilt box already knew.** Its fresh `settings.json`, before any human touched it: `hub_escrow_identity_present: true`, no `offbox` key, no `claimed` key — precisely shape (a) of `OffsiteRecoveryOffer()`. ### Steps 3–4 — claim, and the first thing the customer sees The rebuild left the box unclaimed, so the hub rotated a **reset code (generation 3)** to the registered address; the operator relayed it, as R-119 records is the one human step. ``` 13:42:41.101Z POST /claim (code + a new password) → 302 → / GET / → 200 https://192.168.0.227/recovery ← NOT /launcher ``` > **The recovery screen appears without being sought.** The customer's very first landing after > claiming their rebuilt machine is the screen that tells them their backups survived. ✅ ### Step 5 — the screen, read as a customer It says what is waiting, when it was sealed, and that nobody can replace a lost code: > *„Ezt a gépet újratelepítették. A korábbi, **házon kívüli mentéseid megvannak** — a Felhom központi > rendszere őriz hozzájuk egy lezárt csomagot, amelyet **2026-08-05T13:11:11Z** zártunk le."* > > *„**A helyreállítási kódot senki nem tudja pótolni** — sem a Felhom, sem az ügyfélszolgálat, sem az > üzemeltető. Ez szándékos… Ha a kód elveszett, a korábbi mentések nem nyithatók meg többé."* > > *„Ebben a lépésben **semmit nem állítunk vissza és semmi nem változik**."* All three of step 5's questions answered, in plain Hungarian. The set-aside option was correctly **not** offered, with its reason stated rather than the button merely hidden: *„ez a lehetőség akkor válik elérhetővé, ha a gép már újra kapcsolódott a házon kívüli tárhelyhez"* — the `CanSetAside` gate (`ResetOrphanedRepo` refuses unless orphaned) working, and explaining itself. **One copy defect on this screen:** the seal date renders as the raw RFC3339 UTC string **`2026-08-05T13:11:11Z`** to a Hungarian household. `recoverySealedAt()` is documented as *"the human date the hub says the sealed package was created"*; it is not humanised anywhere. Minor beside what follows, and recorded rather than dropped. ### Step 6 — the recovery code was entered, and REFUSED. **Phase 1 FAILS HERE.** ``` 13:43:30.800Z POST /recovery/unlock (the code exactly as issued) 13:43:30.953Z HTTP 200 in 0.134 s ``` > *„A megadott helyreállítási kódot nem fogadtuk el. Ellenőrizd, hogy mind a tíz szót pontosan, > szóközökkel elválasztva írtad be — a kis- és nagybetűk nem számítanak."* **0.134 s is the tell.** `age`'s scrypt is deliberately slow; a real unseal cannot finish that fast. The controller's own log names the truth: ``` [WARN] [web] recovery: unlock failed: agentapi: POST /escrow/recover-offsite-password: HTTP 404 ``` **The agent does not have the endpoint.** `POST /escrow/recover-offsite-password` ships in agent **v0.125.0** (*"the agent opens the sealed bundle and returns one field"*, 2026-08-04, R-199 links 7–8). This box runs the **vouched day-0 agent, 0.120.0**. **So the machine told the customer their recovery code was wrong, when the code was perfect and the machine simply could not ask.** That is the single worst outcome the brief names — an unrelated failure reported as a wrong code — and it happened in the CLEAN journey, not under an injected fault. → **R-216** ### The counterfactual — measured, so R-216 is a fact and not an inference Agent **0.125.0** installed on the campaign box **only** (published artifact, sha `f7d8339b53d92a6c45be7eaf189469a041b6b00b758a64511c0479beae7016b3`, verified on the box against the hub's own vouched value; **no global manifest change, so no other customer moved**). The *identical* code was then re-entered: | | agent 0.120.0 | agent 0.125.0 | |---|---|---| | verdict | *„A megadott helyreállítási kódot nem fogadtuk el"* | **„A mentéseid elérhetők — A kulcs visszakerült"** | | elapsed | **0.134 s** | **1.113 s** — the scrypt cost, visible | | outcome | — | `recovery: the offsite repository key was recovered and placed (outcome=installed)` | Nothing else changed. **The code was always correct; the machine could not ask, and blamed the customer.** ### R-217 — the same screen then asserted three things it could not know The successful unlock rendered the honest error *and* a contradiction beneath it: > *„A kulcs visszakerült. A gép még most kapcsolódik újra a házon kívüli tárhelyhez…"* **and** > *„A tároló megnyílt, és van benne tartalom, de nem tudtuk alkalmazásokhoz rendelni."* The store did **not** open, its content is **unknown**, and attribution is **not** the problem. `recovery_handlers.go:184` builds `backup.OffsiteInventory{}` on the failure path — `Empty` defaults to **false** — so the template's `InvUntagged` (`!Empty && len(Apps)==0`) fires. The type's own doc comment names precisely this hazard: > *"Empty is true when the repository opened cleanly and holds no snapshots… it is named rather than > inferred from `len(Apps)==0`, **which is also what a failed read looks like**."* The field built to prevent it was defaulted past. → **R-217** ### R-218 — succeeding at recovery is what stops the machine asking for what it still needs **The store never opened.** 47 minutes after the unlock, against a screen promising *"néhány perc"*. Not slow — **deadlocked**, and each half is independently sufficient. **Measured, from the hub's own report stream:** ``` 13:38:06 (offsite absent) 13:38:46 (offsite absent) 13:39:54 {"state":"needs_credential", …} ← the box asking. v0.199.0 working. 13:42:43 {"state":"needs_credential", …} ← second report = the debounce satisfied 13:47:03 hub: "offsiteheal: re-staged the stored one-time offsite secret for customer c11 (declared needs_credential across 2 reports) — the box re-consumes on its next cycle" 13:47:35 THE CUSTOMER'S RECOVERY UNLOCK PLACES THE REPOSITORY PASSWORD 13:53:42 (offsite absent) ← the box has stopped asking 14:08:42 (offsite absent) 14:23:42 (offsite absent) ``` **Half 1 — the box stops asking the moment recovery succeeds.** `needsOffsiteCredential` (`backup/offbox.go`) short-circuits on the repository password: ```go if _, ok := m.OffboxRepoPasswordHash(); ok { return false // we still hold our repository password: not a fresh data area } ``` Placing that password is the recovery screen's **entire job**. Doing it makes `OffboxReportStatus()` return nil, the `offsite` object vanishes from the report, and `offsiteheal`'s reconciler — which acts on that one declaration and nothing else (`reconciler.go:39-52`) — can never fire again. The box's state now: ``` /data/offbox/ repo_password 64 B (13:47) ← the recovered key, present no known_hosts, no ssh_key, no applied_marker settings.json offbox key ABSENT ← no coordinates, no transport credential hub_escrow_identity_present true ``` **A correct key, and nowhere to use it.** **Half 2 — the re-stage nudges nothing.** `Reconciler.heal` re-arms the secret, logs *"the box re-consumes on its next cycle"*, emits an event, and returns. A census of the whole `offsiteheal` package for `poke|bumpIntent|SetHostDesired|generation` returns **zero hits** — no poke, no generation bump, nothing that would make the box re-pull. And the controller's apply-bridge retries only *"on next config refresh/restart"* (its own 13:38:51 line). Last `Config downloaded for customer c11`: **13:38:00** — **nine minutes BEFORE the re-stage**, and none since. So the hub holds an **unconsumed** credential (`one_time_secrets`: one c11 row, created `2026-08-05 13:07:03`, `consumed_at` NULL after the re-arm) that the box has no reason to come and collect, while the box no longer says it needs one. > **The remedy fires correctly, 32 seconds before the customer succeeds — and the customer's success > is what closes the door on it.** Two shipped features, each correct alone, cancel each other on the > exact path they were both built for. → **R-218**. This is the direct cause of Phase 1 not reaching an open store. ### The intervention (deviation 3, operator-authorised) — and what it proved `systemctl restart felhom-controller-bootstrap.service` at **14:31:33Z** — the apply-bridge's own documented retry trigger, not a code change. It worked in **17 seconds**: ``` 14:31:46 [offsite-apply] settle-gate: GO — at/above floor 0.200.0 14:31:50 [offsite-apply] offsite configured for u629488-sub4@…:/home/felhom-repo (pending key escrow) ``` **Which confirms R-218 exactly**: nothing was wrong with the credential, the target or the key — the only thing missing was anything at all to trigger a retry. **And the recovered key survived the re-apply, byte-exact — three independent sources agreeing:** | source | sha256 | |---|---| | hub `restic_pw_sha256`, recorded BEFORE the destruction | `626e424670248db3a25b0a48aa8a114da9061529823ce9cea7d95d9e0ee3c303` | | the file on the rebuilt box | `626e424670248db3a25b0a48aa8a114da9061529823ce9cea7d95d9e0ee3c303` | | mtime of that file | **13:47** — the unlock. Untouched by the 14:31 re-apply | The re-apply wrote `known_hosts`, `ssh_key` and `applied_marker` at 14:31 and **left `repo_password` alone**, exactly as `WriteOffboxSecrets` documents. R-193's orphaning mechanism did **not** fire. ### R-219 — the listing the screen promises can never render on the shape it exists for The screen's headline promise is explicit: > *„Ha megadod a kódot, **feloldjuk a mentéseid zárolását és megmutatjuk, mi van bennük** — melyik > alkalmazás, mikorról, mekkora."* **On shape (a) — the pristine rebuilt box, the shape `OffsiteRecoveryOffer`'s own header calls the customer's actual state — that listing cannot render. Ever.** By construction: 1. `ApplyOffsiteTarget` → `WriteOffboxSecrets` **auto-generates the repository password whenever none is present** (`offbox.go:400-403`). So **a target cannot exist without a repository password.** 2. Shape (a) is defined by *no repository password*. Contrapositive of (1): **no password ⇒ no target.** 3. `OffsiteInventoryList` needs a target → returns `ErrNoOffsiteTarget` → the handler takes the failure branch every time. Measured live at 13:47:35: the unlock succeeded and the customer got *„a mentéseid listája néhány perc múlva jelenik meg"* — plus R-217's false *„A tároló megnyílt, és van benne tartalom"*. **Not once did the promised listing appear.** **And there is no second chance.** After the unlock places the password, `recoveryOffer()` evaluates `OffboxRepoPasswordHash()` → ok, then `OffboxOrphaned()` → `t != nil && …` → **false** with no target. So the offer retires itself, and `recoveryUnlockHandler`'s opening guard redirects. Measured, after the re-apply: ``` POST /recovery/unlock → 302 /backups/remote ← the listing is permanently unreachable GET / → 200 /launcher ← the screen has stepped aside ``` The customer's single opportunity to see what is in their backups is the unlock response itself — and on shape (a) that response is structurally guaranteed not to contain it. → **R-219** *(The screen stepping aside once the job is done is CORRECT and is recorded as a pass: `GET /` went back to `/launcher`. The defect is that the one thing it promised was never delivered before it went.)* ### Step 8 blocked — the restore page is empty exactly when it is needed `/backups/restore`, immediately after a successful recovery on a rebuilt box: ``` „Nincs távoli mentésre jelölt alkalmazás — a kijelölés a … oldalon történik." „Nincs ellenőrző másolat a meghajtón." „Nincs telepített alkalmazás." ``` The post-unlock screen's own button — *„Tovább a visszaállításhoz"* — lands here. **The restore flow is keyed on locally installed, off-site-toggled apps, and a rebuilt box by definition has none**; the snapshot in the repository (`calibre-web`, with its recovery unit) is invisible to it. This is R-213's territory ("putting files back is not built"), but sharper than *not built*: the entry point exists, is reachable, and is empty precisely on the path the recovery screen hands the customer to. ### R-220 — after a rebuild the customer's own drives cannot be re-enrolled, and the refusal names an impossible action Redeploying the app was the next step, so the app needs its data drive. The deploy refused, in Hungarian, through the real endpoint: > *„A megadott tárhely nem azonosítható regisztrált meghajtóként, ezért alkalmazás > adatkönyvtáraként nem használható. **Válasszon a listából csatlakoztatott meghajtót.**"* **The list is empty.** `GET /api/disks/candidates` → `initialize: []`, `attach: []`, while the customer's two drives sit mounted and visible in `/api/disks`. The wizard at `/storage/attach` renders *„Betöltés…"* over nothing. **The seam, named:** `felhom-agent/internal/storage/claim.go:84-85` ```go if n.mountpoint != "" && !underFelhomDrives(n.mountpoint) { return false, "device is mounted at " + n.mountpoint + " (" + n.name + ")" } ``` A device mounted anywhere outside `/mnt/felhom-drives` is *claimed* and never offered as a candidate. After the rebuild, `/dev/sdb` was mounted at **both** `/mnt/felhom-drives/adatok` (the bind the guest sees) **and `/mnt/adatok`** — the raw enrol mount that `disks.go:606` leaves behind **by design**, which `eject` also deliberately preserves. The controller's registry, however, lives in `settings.json` and went with the wipe. > **The drive is invisible to the re-enrolment wizard because of the mount that enrolling it created.** **RED-PROOF, both directions.** Unmounting only `/mnt/adatok` and `/mnt/mentes` — changing nothing else — flipped the scan immediately: ``` before: initialize: [] attach: [] after : initialize: [/dev/sdb, /dev/sdc] attach: [(/dev/sdb, ext4), (/dev/sdc, ext4)] ``` Both drives then re-enrolled through the real endpoint with their data intact (`POST /api/storage/attach` → `{"registered":true,"where":"/mnt/felhom-drives/adatok"}`), and the deploy that had returned **409** returned **202**. **Why this is a defect and not an edge case.** (a) It is on the rebuild path, which is the whole subject of the recovery feature. (b) It breaches **I3** — the refusal names an action ("choose a connected drive from the list") that the customer cannot perform, because the list it points at is empty for a reason unrelated to anything they did. (c) **Campaign 10 reached this exact state by hand and recorded it as its own harness error** (§4a: *"hand-driving format + assign first left the drives mounted but unregistered — invisible to both the raw-candidate scan and `/disks`"*). **Here the product's own rebuild path arrives there.** What was a harness mistake a week ago is now a product state. → **R-220** *(Deviation 4, recorded: the unmount was an operator action on the appliance host. It doubles as the red-proof, and everything after it went through the customer's real endpoints.)* ### Step 8 — the sentinels, restored and compared `calibre-web` redeployed (70.1 s) onto the re-enrolled drive, toggled for off-site, and restored through the customer's own two-step full-restore flow: ``` step 1 (size gate) POST /backup/offbox/restore mode=full → 302 …&full_size=12.8+MB step 2 (confirm) POST /backup/offbox/restore mode=full&confirm=1 14:43:27 → 14:43:43 [offbox] restored calibre-web (f3d9cd67, full=true) → /mnt/felhom-drives/adatok/backups/offsite-restore/calibre-web ``` **16 seconds**, out of snapshot `f3d9cd67` — the one taken **before** the wipe. Non-destructive: the live data was untouched, exactly as the message says (*„A meglévő adatok változatlanok."*). | # | file | expected sha256 | restored sha256 | | |---|---|---|---|---| | A | `C11-SENTINEL-A.txt` | `beb9175d…6df27` | `beb9175d52325bf583039beb6db1ecc80ea0781720e262c83230ed46a166df27` | **✅** | | B | `C11-őrszem-ékezetes-árvíztűrő.txt` | `7c8cb0ad…43a87` | `7c8cb0ad040856440cc96bb1ad8f336d04f458138d85e6904ee759e690743a87` | **✅** | | C | `C11-SENTINEL-C-12MB.bin` | `e012e76f…bb9c0` | `e012e76f3502de7c608d5b35f67b89c2404edad49eea92a1fc35a1bf02cbb9c0` | **✅** | **All three byte-identical**, sizes intact (54 / 73 / 12 582 912 B), and **the Hungarian filename survived the entire round trip** — disk → restic → SFTP → Storage Box → restic restore → disk — without a single mangled character. The mojibake gate never fired. --- ## Phase 1 — VERDICT: **FAIL** The pass condition is *"a sentinel's sha256 is byte-identical **AND** no step needed a command line inside the guest."* **The brief asks which of the two failed, and it matters enormously:** | half | result | |---|---| | **the data** | **PASS** — all three sentinels byte-identical, including a 12 MB binary and a non-ASCII Hungarian name | | **the journey** | **FAIL** — four operator interventions, none available to a customer | **The backup promise holds. The recovery journey does not.** ### The four interventions, each one a customer dead end | # | Where it stopped | What was needed | Finding | |---|---|---|---| | 1 | the correct recovery code was refused as wrong | install agent 0.125.0 | **R-216** | | 2 | the store never opened; the box stopped asking for its credential | restart the controller | **R-218** | | 3 | the promised listing never rendered, and became unreachable | *(none — structurally impossible)* | **R-219** | | 4 | the app could not be redeployed; its drives were unenrollable | unmount the raw mounts on the host | **R-220** | ### The RTO the business gets to quote **Undefined — the unaided journey does not complete.** The attended elapsed time, stated so it is not mistaken for the customer number: ``` 13:42:41 login (claim submitted) 13:43:30 recovery code entered → REFUSED (R-216) 13:47:35 recovery code accepted (after intervention 1) +4m 54s 14:31:50 off-site target applied (after intervention 2) +49m 09s 14:42:42 app redeployed (after intervention 4) +59m 61s 14:43:43 sentinels restored + verified +61m 02s ``` **61 minutes with an operator, three of whose four blockers required root on the appliance.** The only segment that reflects the product working is the last one: **16 seconds to pull 12.8 MB back out of the off-site repository once everything was in place.** For comparison, the 2026-08-04 night drill measured the same restore leg on a smaller fixture and also needed four undocumented manual steps (R-204). **Three of those four were closed on 2026-08-05. This run found four new ones.** --- ## Phase 3 — the retention proof (R-198) ### Step 1 — the pre-supersession record Hub SQLite with its `-wal`, freshness **2 s** (`MAX(received_at) 14:47:19` vs `now 14:47:21`): ``` host_escrow (c11-36d660): blob 383 B · identity_blob 572 B · stale_at NULL restic_pw_sha256 626e424670248db3a25b0a48aa8a114da9061529823ce9cea7d95d9e0ee3c303 created 2026-08-05T13:11:11Z host_escrow_superseded: id 3 demo-hp-bb76ea 383 identity_blob NULL 2026-08-04 07:15:36 id 4 demo-felhom-… 383 identity_blob NULL 2026-08-04 07:20:08 ``` **Two superseded rows, neither for c11, and both carrying `identity_blob` NULL** — the pre-v0.93.0 shape the RECON documented. This is the "before" the whole phase turns on. ### Step 2 — destroy and reinstall, WITHOUT recovering (14:47:43Z) Same destruction as Phase 1 (guest purged, both drives wiped to `4.0K`), same day-0 path, and **the recovery screen deliberately not used** so the box mints a fresh key. ### R-216, part 4 — the day-0 path REVERTS the fix on every rebuild The reinstall put the broken agent back, and said so: ``` [INFO] agent (existing): felhom-agent 0.125.0 [INFO] manifest: agent v0.120.0 (sha a7763d31b55b5ce7…), golden v0.192.0 [OK] installed /usr/local/bin/felhom-agent (felhom-agent 0.120.0) ``` **Not a skip — a deliberate downgrade to the vouched version.** The first reinstall had logged `[SKIP] agent v0.120.0 already installed`; this one found 0.125.0, compared it against the manifest, and installed 0.120.0 **over** it. > **So an operator who fixes a box by hand has it re-broken by the next rebuild — which is precisely > the event that makes the recovery feature necessary in the first place.** The vouched-artifact > channel does not merely default to a stale agent; it actively restores it. Until the manifest vouches > ≥ 0.125.0, no box can hold the fix across the only event that needs it. ### Steps 2b–2c — the box mints a fresh key, exactly as R-193 predicts The rebuilt box came up **claimed-less with no `offbox` directory at all** — no repository password — and `hub_escrow_identity_present: true`. It declared its need on schedule: ``` 14:50:43 {"state":"needs_credential", …} 14:55:42 {"state":"needs_credential", …} ← debounceReportsDefault = 2 satisfied 14:57:03 hub: offsiteheal re-staged the stored one-time offsite secret for c11 ``` The reconciler's own constants explain the timing: `debounceReportsDefault = 2`, `tickIntervalDefault = 5 * time.Minute` (`offsiteheal/reconciler.go:70,74`). **The hub side works.** **And R-218 reproduced exactly.** The box again did not consume the re-staged credential on its own; the same lever was applied (`systemctl restart felhom-controller-bootstrap.service`, 14:57:18Z) and the tier configured **17 seconds later** — the identical 17 s as in Phase 1: ``` 14:57:31 [offsite-apply] settle-gate: GO 14:57:35 [offsite-apply] offsite configured for u629488-sub4@…:/home/felhom-repo (pending key escrow) ``` **Second occurrence, same signature, same fix.** R-218 is not a one-off. **The fresh key, which is the whole premise of Phase 3:** | | sha256 of the repository password | |---|---| | Phase 0/1 (recovered, sealed under the FIRST code) | `626e424670248db3a25b0a48aa8a114da9061529823ce9cea7d95d9e0ee3c303` | | **after this rebuild (newly minted)** | **`e11a6c542b73477ad02635673286f22f56ba8e1d5d9706efb4b0a0ff94473d57`** | Different keys. The Phase 0 history (snapshot `f3d9cd67`, holding all three sentinels) is now encrypted under a password this box no longer has — **R-193's orphaning mechanism, live, on demand.** That is precisely the state whose recoverability the retention is supposed to preserve. ### A third instance of the "nothing triggers a refresh" family The escrow preflight refused immediately after the tier configured: ``` {"id":"pbs_storage_id","ok":false,"detail":"escrow.pbs_storage_id not configured"} ← ok:false overall (the other five green: dr_tier, age_binary, hub_upload, staged_secret, sudo_grant) ``` The ceremony cannot start until the controller's config carries `escrow.pbs_storage_id`, which arrives on a config refresh — the same channel R-218 showed nothing reliably triggers. Recorded as an observation on the same family rather than as a separate finding; it resolved on the next refresh. ### R-221 — a rebuilt box cannot run the escrow ceremony at all, and the cause is a lifetime mismatch With the tier configured, the ceremony's preflight refused: ``` {"id":"pbs_storage_id","ok":false,"detail":"escrow.pbs_storage_id not configured"} ← overall ok:false (the other five green) ``` `escrow.pbs_storage_id` is **agent-side** and is seeded by the pbsdr bridge — `finishConverged → seedEscrowStorageID` (`felhom-agent/internal/pbsdr/manager.go:427-438`), whose own comment says *"pbsdr convergence that just seeded escrow.pbs_storage_id flips this row green with no restart."* Measured on the box: **`grep -c escrow /etc/felhom-agent/agent.json` → 0.** **The two things have different lifetimes, and that is the whole defect.** | | lives | survives a guest rebuild? | |---|---|---| | the convergence marker | `/var/lib/felhom-agent/pbsdr/marker.json` — on the **HOST** | **YES** (`applied_at 2026-08-05T12:25:07Z`, from Phase 0) | | the seeded key | `/etc/felhom-agent/agent.json` — **rewritten by the installer** | **NO** | And the idempotence guard reads only the marker (`manager.go:285-288`): ```go if mk := m.loadMarker(); mk != nil && mk.Hash == h && (cf == nil || cf.Hash != h) { m.setStatus(...) return // idempotent: this exact descriptor already converged } ``` Unchanged descriptor → same hash → **early return → `finishConverged` never runs → the seed never happens** — into a config file that no longer has it. The directory even carries `agent.json.pre-seed` (14:25) proving the seeder ran exactly once, in the Phase 0 window. **RED-PROOF.** Moving *only* the marker aside and restarting the agent — nothing else — produced the seed instantly: ``` 17:02:26 pbsdr: seeded escrow.pbs_storage_id (the ceremony one-liner needs no flags) storage_id=felhom-pbs 17:02:26 pbsdr: converged state=adopted storage_id=felhom-pbs grep -c escrow agent.json: 0 → 1 preflight overall ok: false → TRUE ``` **Consequence: on any rebuilt box whose PBS-DR descriptor has not changed, the customer cannot run the escrow ceremony at all** — which is precisely when they must, because the rebuild just changed their repository key. → **R-221** *(deviation 5: the marker move-aside. It doubles as the red-proof.)* --- ## ★ Phase 3 step 4 — THE RESULT. R-198's retention WORKS IN PRODUCTION. Second ceremony run through the customer wizard at 15:03:12Z (`restic_pw_sealed: true`, `uploaded: true`), its code claimed one-shot into a **separate** `0600` file — **ten words, 90 characters, verified different from the Phase 0 code** without either being rendered. Hub SQLite with `-wal`, freshness 99 s: ``` =========== host_escrow_superseded =========== id host_id k_blob identity_blob_len restic_pw_sha256 superseded_at 3 demo-hp-bb76ea 383 -1 (NULL) 8e03eddf9ff7fde2 2026-08-04 07:15:36 4 demo-felhom-8363b5 383 -1 (NULL) 48741892f0ef4d59 2026-08-04 07:20:08 5 c11-36d660 383 572 626e424670248db3 2026-08-05 15:03:14 ★ =========== host_escrow (current) =========== c11-36d660 blob 383 identity_blob 572 e11a6c542b73477a 2026-08-05T15:03:14Z ``` | the question | the answer | |---|---| | does the superseded row carry the previous sealed package? | **YES** | | is its length equal to step 1's? | **YES — 572 B, exactly** | | is it the OLD key? | **YES — `626e424670248db3`, the Phase 0 key, sealed under the FIRST code** | | and rows 3–4, superseded before hub v0.93.0? | **`identity_blob` NULL — the old, lossy shape** | > **This is the first supersession that has ever occurred in production since the fix, and the > retention held.** What was unit-proven only is now proven live. The blob that R-198 exists to keep > was kept, byte-length exact, on the first real opportunity to lose it. **Step 5 — both signals fired, at the instant of supersession:** ``` 15:03:14 offsite_repo_key_changed warning "Offsite repository key CHANGED for host c11-36d660…" (R-197) 15:03:14 escrow_superseded info "A korábbi helyreállítási csomag megőrizve (új kulcs érkezett)." 14:57:03 offsite_selfheal_restaged info (R-204 item 4's event, also correct) ``` **Step 6 — the run REFUSED rather than starting a fresh history:** ``` 15:03:41 [escrow-confirm] hub-verified: the escrow covers the current repo password (hash e11a6c542b73…) — EscrowState auto-confirmed escrowed; offsite runs enabled 15:04:43 [offbox] offsite repo ORPHANED — remote holds backups written under a previous, no-longer-available key; runs will skip until reset 15:04:43 Event pushed: offbox_repo_orphaned (warning) ``` The Phase 0 history — snapshot `f3d9cd67`, holding all three sentinels — is **intact and untouched** on the remote. Nothing was overwritten. The escrow auto-confirm also worked with no manual `confirm-escrow`, which the 2026-08-04 drill had needed (R-204 item 3, closed, confirmed live here). ### R-222 — step 7: the retained package has no read path, and reaching for it is reported as a wrong code With agent 0.125.0 restored so the answer would not be confounded by R-216, the **first** recovery code — the one that genuinely opens the orphaned history — was entered: ``` 15:05:59 unlock HTTP 200 in 1.111 s ← a REAL unseal attempt, not a 404 [WARN] recovery: unlock failed: agentapi: POST /escrow/recover-offsite-password: HTTP 400: the recovery code did not open the sealed bundle, or the bundle could not be fetched — nothing was written ``` **The engine is correct**: the retrieval serves the CURRENT row, the first code cannot open a bundle sealed under the second, it fails closed, and nothing is written. Exactly as the RECON predicted (`ListSupersededEscrow`: zero production callers). **The customer-facing message is not:** > *„A megadott helyreállítási kódot nem fogadtuk el. Ellenőrizd, hogy mind a tíz szót pontosan…"* — and the same screen simultaneously states the hub holds a package sealed at **`2026-08-05T15:03:14Z`** (the NEW one), with no hint that an older package exists, is deliberately retained, or corresponds to the code just typed. > **So the customer holding exactly the right code for their orphaned history is told they mistyped > it.** And this is the same sentence R-216 produces for a missing agent capability, and the same > sentence a genuinely wrong code produces. **Three different situations — wrong code / the machine > cannot ask / the machine asked about the wrong package — one message.** This is also the live proof of **R-202**'s open concern: the orphan card promises the old history *„a hozzá tartozó helyreállítási kóddal később visszaállítható lehet"*. Here is a customer **with** that code, and the answer is "wrong code". → **R-222.** Per the brief: the gap is recorded, **nothing was built.** --- ## The fix pass (2026-08-05, evening) — and the golden rebake it turned into Six findings fixed in controller **v0.201.0** + hub **v0.97.0/0.97.1**, each red-proofed. Full account: `felhom-controller/REPORT.md`. ### R-223 — vouching agent 0.125.0 was NOT a dropdown, and the manifest had been frozen The first vouch attempt was **REFUSED**, correctly, by R-120's gate (`hub/internal/web/configs.go:1162`): ``` [WARN] artifact vouch REFUSED: golden 0.192.0 is older than the newest controller the fleet reports (0.201.0) — a fresh install would land on stale application code (R-120) ``` The manifest was **verified unchanged afterwards** rather than inferred from the HTTP 303 (which is a redirect carrying the refusal, not a success). **The artifacts form saves as a unit**, so the agent could not be vouched while the golden was stale — and the golden had been 0.192.0 since before controller 0.193.0 shipped. **The Day-0 manifest had therefore been effectively unvouchable for days, and nobody had cause to notice because nobody tried.** That is the finding; the "one dropdown" I first reported was wrong. ### The rebake — golden 0.201.0 Baked in the drill VM on DooPlex (the sanctioned exception for bakes, `target-selection.md`). Preconditions checked first: 6.6 T free, **no qemu held the qcow2 — proved by the revert succeeding**, `virgin` intact, Debian template `13.6-1` listed rather than assumed (the point release rots) and checksum-verified on download. | | | |---|---| | artifact | **658 165 766 B**, sha256 `e730d7cab343eb35bcef627829b40c160f83945e0a9bd57da546efc21f007654` | | markers | `Result=success` · `ExecMainStatus=0` · **no** `FATAL`/`excluding`/`ERROR` · `docker OK (overlay2; data-root /var/lib/docker)` · `including mount point rootfs ('/')` **and** `mp0 ('/var/lib/felhom')` · `upload OK (HTTP 201)` | | round trip | **the bytes downloaded from Gitea hash identically.** A local checksum proves only that the local file is what it is | | token hygiene | token read from a 0600 file **inside** the VM, never on a command line; literal-value grep on the saved log → **0 hits** | | teardown | guest 9100 `--purge`d · token/script/log shredded **after** the log was copied out for evidence · qemu exited (checked by `ps -eo comm`, not `pgrep -f`, which self-matches) · disk reverted to `virgin` | *Doc drift noticed, not acted on: the runbook's pass markers say `including mount point` for rootfs **and mp0 and mp1**. `build-golden.sh` v3.0.0 bakes ONE data volume, so two mount points is correct and the runbook describes an older layout.* ### The manifest, read back after the save ``` agent_version = 0.125.0 agent_sha256 = f7d8339b53d92a6c…7016b3 golden_version = 0.201.0 golden_sha256 = e730d7cab343eb35…f007654 min_agent = 0.125.0 ``` **A machine installed from now on lands on current agent AND current controller**, and can open a recovery package on day one. R-223 closed. ### The held floor — what is and is not established **Established, positively, that the hold WAS in force**: the hub logged it with the corrected reason (*"floor 0.200.0 is ABOVE the vouched golden 0.192.0…"*, 22:12:06), and the box observed it from its own side — `[offsite-apply] settle-gate: GO — floor still unknown after 1m30s`, which is what a withheld directive looks like to a controller. **NOT established by a positive observable: that the hold is now released.** After the vouch, c11's next report (22:27:06) produced **no** HELD line and the Hosts dashboard renders **no** held reason — but both of those are absences, and this project's own rule is that an absent log line is not evidence. The hub logs nothing on the served path by design, and `SetFloor` is DEBUG-only and fires only on a change, so the box-side positive would need a controller restart — **which is not taken, because Phase 2 needs the venue as it is.** What can be said with certainty is the arithmetic the code performs: floor `0.200.0` ≤ golden `0.201.0` (so the beyond-golden branch cannot fire) and agent `0.125.0` ≥ min_agent `0.125.0` (so the below-MinAgent branch cannot fire) — the exact combination `TestResolveManagedFloor_R216_FloorAboveGolden/floor_at_the_golden_→_unchanged_behaviour` pins as served. **Deduction plus a pinned test, not a live positive. Recorded as such.**