# REPORT — R-193: the recovery screen (controller v0.200.0), 2026-08-05 **A customer whose machine was rebuilt can now find out, and unlock their backups, without a command line.** R-193's remaining half. No hub change; `felhom.eu` carries registers and docs only. ## 1. Baselines, and §3's five pieces `felhom-controller` at `be3c5fa7f6a1` / v0.199.0 — **exact match**, tree clean, `HEAD == origin/main`. `felhom.eu` at hub v0.96.0, unchanged. **All five pieces of §3's table were where it said**, at the cited landmarks: `RunRecoveryCheck` (`:55`), `RecoverAndInstall` (`:140`), `offboxResetHandler` (`:245`) with its `confirm=1` gate and orphaned precondition, `OffboxOrphaned` (`:244`), and the per-app snapshot lookup (`offbox_restore.go:84`). **No mechanism was invented.** **Register:** the highest ID was **R-212** as stated, and still is — re-checked at the end for the second writer. This session mints **R-213**. ## 2. The one thing the task under-specified, and what I did about it §8.1 says the page appears on the same two facts the box uses to declare `needs_credential`: **a fresh data area** (no repository password) and **a hub-held package**. Taken literally that is a window that **closes by itself**: `WriteOffboxSecrets` AUTO-GENERATES a repository password on re-apply — which is R-193's own orphaning mechanism — and since hub v0.96.0's credential self-heal the re-apply now happens unaided within ~15–30 minutes. The customer who logs in the next morning would never have seen the page. So the predicate is **the hub holds a package AND (no repository password OR the tier is orphaned)**. Shape (b) is the state a real rebuilt box is in, and it is also the state the shipped move-aside requires — which is what lets "I do not want the old data" reach the existing handler instead of needing a new one. **Scenario B is unaffected:** a healthy box has its own key and is not orphaned; a box that never had off-site backups fails the package condition. **demo-felhom is in shape (b) right now**, which is why validation 1 needed no arrangement at all. ## 3. Every Hungarian string, quoted for review as copy **The two MANDATORY sentences of §8.2** — both verified in the live rendered page: > **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: így a mentéseidet rajtad kívül senki nem tudja megnyitni. Ha a kód > elveszett, a korábbi mentések nem nyithatók meg többé. > 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. **Ebben a lépésben semmit nem állítunk vissza és semmi nem változik.** > A visszaállítást utána, alkalmazásonként külön választhatod. **The situation, before any 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 **<dátum>** zártunk le. A csomagot csak a > **te helyreállítási kódoddal** lehet kinyitni. **The form:** `Helyreállítási kód (tíz szó)` · placeholder `tíz szó, szóközökkel elválasztva` · buttons `Mentések feloldása` / `Most nem`. **What "most nem" means:** > A „Most nem" csak azt jelenti, hogy nem zavarunk vele többet a kezdőlapon. A mentéseid ettől > megmaradnak, és ez az oldal a **Biztonsági mentés → Távoli mentés** oldalról bármikor újra elérhető. **A wrong code** (quoted from the LIVE response): > 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. Semmi nem változott, nyugodtan > próbáld újra. **Refused (a different key is already present):** > Ezen a gépen már van egy másik mentési kulcs. A régi előzmény visszanyitása felülírná azt, ezért nem > hajtottuk végre. Vedd fel a kapcsolatot a Felhom ügyfélszolgálatával. **After the unlock:** > Ezek a **te** mentéseid, a lent jelzett időpontokból. Nézd át, hogy tényleg azt találod-e itt, amire > számítasz — **semmit nem állítottunk vissza és semmi nem változott.** > A mentéseid zárolása feloldva. Az alábbiakat találtuk a tárolóban — semmit nem állítottunk vissza. Table headings: `Alkalmazás` · `Legutóbbi mentés` · `Méret`. Next step: > A visszaállítás alkalmazásonként történik, a **Biztonsági mentés → Visszaállítás** oldalon. Ott > választhatod ki, melyik alkalmazás mit hozzon vissza. **An EMPTY store, stated plainly (§8.6):** > A tároló megnyílt, de **nincs benne egyetlen mentés sem**. Ez azt jelenti, hogy a kulcs jó volt, de > ehhez a géphez nem tartozik korábbi mentés. Ha korábban biztosan készültek mentések, keresd a Felhom > ügyfélszolgálatát, mielőtt bármit tennél. **Unlocked but no coordinates yet** (the pristine rebuilt shape, added after a test found the crash): > A kulcs visszakerült. A gép még most kapcsolódik újra a házon kívüli tárhelyhez — a mentéseid listája > néhány perc múlva jelenik meg a Biztonsági mentés oldalon. **The exceptional path — first mention, then the SECOND confirmation:** > Ha a helyreállítási kódod véglegesen elveszett, és tudomásul veszed, hogy a korábbi mentések így nem > nyithatók meg többé: *nem kérem vissza a korábbi adatokat*. > **Biztosan nem kéred vissza a korábbi mentéseket?** Ha megerősíted: > • a korábbi mentéseket **félretesszük — nem töröljük**; > • a helyreállítási kód nélkül **többé nem lesznek megnyithatók**; > • a gép **új, üres mentési tárolót kezd**, és mostantól oda ment; > • ez az oldal **többé nem jelenik meg**. > Ha csak most nincs kéznél a kódod, válaszd inkább a „Most nem" lehetőséget. Button: `Igen, félretehetitek a korábbi mentéseket` / `Mégsem`. **The permanent entry point in the backups area:** > **A korábbi, házon kívüli mentéseid visszaszerezhetők.** Ezt a gépet újratelepítették, és a Felhom > központi rendszere őriz hozzá egy lezárt csomagot. A helyreállítási kódoddal feloldhatod a korábbi > mentéseidet, és megnézheted, mi van bennük. → `Adatok visszaszerzése` ## 4. §8.4 — repeated failures: LOG LOCALLY, DO NOT ALERT. The reasoning. **No lockout**, as instructed. On repeated failures I chose to **log locally and raise no operator signal**, and the reasoning is: - The realistic generator of repeated failures is **the owner mistyping a ten-word phrase**, which is the single most likely event on this page. An alert there is noise on every real recovery. - The threat it would guard against — someone guessing R — is not credible: a 10-word EFF phrase is ~129 bits, and an attacker who could reach this page already holds the household password. - **Silence is not total:** every failure writes a `[WARN] [web] recovery: unlock failed: …` line carrying the agent's step and no code, so it reaches the debug ring and any operator log pull. A box being probed is visible; it just does not page anyone. The deliberate residual: a slow, patient prober produces only ring entries nobody reads unprompted. That is accepted, and stated here rather than left implied. ## 5. Files created / modified | File | Change | |---|---| | `internal/backup/offbox_recovery_cli.go` | **new** `RecoverInstallCore` + its three outcomes; `RecoverAndInstall` reduced to a wrapper (exit codes and printed lines unchanged) | | `internal/backup/offbox_inventory.go` | **new** — the read-only listing, the explicit empty case, `ErrNoOffsiteTarget`, `HumanizeBytes` | | `internal/backup/offbox.go` | **new** `OffsiteRecoveryOffer` (the two-shape predicate) | | `internal/settings/settings.go` | **new** `RecoveryNoticePostponed` + accessors | | `internal/report/escrow_confirm.go` | **new** `SealedAt()` — the ACK's escrow `created_at` | | `internal/web/recovery_handlers.go` | **new** — the page, unlock, postpone, the seams | | `internal/web/templates/recovery.html` | **new** — the full page | | `internal/web/{server,handlers,funcmap}.go`, `templates/backups_remote.html` | routes, the landing-page interception, the permanent entry point, `humanBytes` | | `cmd/controller/main.go` | wires `SetEscrowSealedAt` | | `internal/web/recovery_test.go`, `recovery_wiring_test.go` | **new** — Scenarios A–H + three AST seam tests | **Commits on `main`:** `636c51e` (implementation) · `62b85ec` (CHANGELOG). **Deployed:** `gitea.dooplex.hu/admin/felhom-controller:0.200.0` on demo-felhom 9201 and demo-hp 9201. ## 6. Tests and red-proofs Green gate: `go build ./... && go vet ./... && go test ./...` — **full suite rc=0**; `controller_gates.py --fast` — **all eight OK**. | Scenario | Test | Result | Red-proof — what was mutated | Outcome | |---|---|---|---|---| | A | `TestRecovery_A_PageAppearsForARebuiltBox` | PASS | — | — | | B | `TestRecovery_B_DoesNotAppearForAnyoneElse` (3 sub-cases) | PASS | dropped the hub-package condition from `OffsiteRecoveryOffer` | **FAILED** — *"a box that never had off-site backups was offered a recovery screen for data it never had"* | | C | `TestRecovery_C_UnlockOpensAndLists` + `…EmptyStoreSaysSo` | PASS | — | — | | D | `TestRecovery_D_WrongCodeFailsClosedAndIsKind` | PASS | — | — | | E | `TestRecovery_E_PostponeKeepsTheEntryPoint` | PASS | bound the backups-page entry point to `recoveryInterrupts` instead of `recoveryOffer` | **FAILED** — *"the backups page no longer offers the route… a customer who clicked past it once has lost the way to their own data"* | | F | `TestRecovery_F_SetAsideNeedsTwoConfirmations` | PASS | removed the `ConfirmSetAside` gate in the template | **FAILED** — *"the set-aside form is on the FIRST view — one click would set the customer\'s history aside"* | | G | `TestRecovery_G_PageAndCLIShareOneCore` + `TestRecoveryHandlerDrivesTheSharedCore` | PASS | gave the handler its own inline fetch→compare→install | **FAILED** — *"recoveryUnlockHandler does NOT call backup.RecoverInstallCore"* | | H | `TestRecovery_H_CodeLeavesNoTrace` | PASS | (carries its own positive control) | — | | I | `TestCLIWrapperDrivesTheSharedCore`, `TestRecoveryRoutesAreWired` | PASS | — | — | **Scenario E's assertion was STRENGTHENED mid-session** because the first version asserted only `recoveryOffer()`, which the mutation did not touch — it would have passed against the very defect it names. It now asserts the **rendered** backups page contains `href="/recovery"`, and only then did the mutation fail. That is the red-proof-that-passes trap, caught and closed rather than reported as green. **Two real defects the tests caught before shipping:** 1. **An UNCLAIMED (legacy-open) box would have been shown the page.** `RequireAuth` passes a password-less box through, so `ServeHTTP`'s interception fired for an unauthenticated visitor. Fixed by an explicit `authEnabled()` check in `recoveryOffer`. 2. **The inventory nil-dereferenced when no off-site target was configured** — exactly the pristine rebuilt shape. Now a named error with its own honest customer message. ## 7. Live validation (endpoint-level; `claude-in-chrome` is not available on DooPlex) | # | What | Observable | |---|---|---| | 1 | **The page rendered on a genuinely stranded box — no arrangement needed** | demo-felhom 9201 is really in shape (b) (`orphaned=True, status=error, snapshots=0`). `GET /launcher` → **`302 Location: /recovery`**; `GET /recovery` → 200 with the code form; both MANDATORY sentences present in the rendered bytes (transported base64 so the ssh→pct chain could not mangle an accented assertion). | | 2 | **A healthy box shows no page** | demo-hp 9201 (`orphaned=False, status=ok, 6 snapshots`, image `0.200.0`): `GET /launcher` → **no redirect**, and the backups page carries **0** occurrences of `href="/recovery"`. | | 3 | **A wrong code is refused, nothing written, no lockout** | Three consecutive wrong codes → `200` each time, the form still present each time, the Hungarian above rendered, **0** occurrences of `age:` / `passphrase` / `unseal` in the page, and the `offbox/` directory listing **byte-identical before and after**. The controller log shows the failure came from the AGENT (`POST /escrow/recover-offsite-password: HTTP 400: the recovery code did not open the sealed bundle`), i.e. the whole chain — handler → agent → hub fetch → age KDF — ran live. | | 4 | **The correct code unlocking + the listing** | **NOT RUN LIVE, and it cannot be here.** No recovery code was kept for demo-felhom's orphaned history (`RECON-offsite-dr-chain-2026-08-04.md` §12.3: *"no recovery code was kept"*), and demo-hp's drill code is operator-held out of band. It rests on `TestRecovery_C_UnlockOpensAndLists` (install + listing + "restores nothing"), `…EmptyStoreSaysSo`, and `TestRecovery_G_PageAndCLIShareOneCore` (installed → unchanged on re-run). | | 5 | **The code persists nowhere — with the positive control** | 0 hits in every file under the data volume, 0 in the container log, 0 in the debug ring. **The positive control earned its keep:** the first sweep ran against the CONTAINER path, which does not exist in the guest, so its "0 hits" proved nothing — the planted copy was NOT found, which exposed the mis-aimed sweep. Re-run against the real path: plant found (**1**), removed (**0**), and the real sweeps stayed at 0. | **Teardown:** nothing was arranged, so nothing needed reversing — demo-felhom is exactly as found (`recovery_notice_postponed` unset, offbox enabled, `repo_state=orphaned`, repository password present). Helper scripts removed from both guests. ## 8. Registers - **R-193 → CLOSED** (credential half v0.199.0/hub v0.96.0; the screen v0.200.0), with the two operator rulings recorded and the shape-(b) reasoning stated. - **R-213 → NEW**: putting files back in place, with the **live-versus-backup comparison** named as its requirement. Deliberately not started. - **R-202 stays open**, untouched. - The register's highest ID was R-212 on arrival **and still is** — re-checked for the second writer. ## 9. The capability-map row as it now reads All four drill interventions closed **and** the customer is now offered the recovery. The old *"needs someone who knows to look"* qualifier is **gone**. What remains, stated narrowly: (1) the final unlock has never been driven with a **correct** code through the page; (2) **putting files back is deliberately not part of this** (→ R-213); (3) **the journey has not been re-walked end to end** since these fixes — one more drill is what is owed. ## 10. CI and hooks CI green by `head_sha` for both pushes. **`--no-verify` was NOT used**; every push ran the pre-push gate, including the workspace-root assertion added yesterday. ## 11. Observations — noticed, NOT acted on - **The postpone flag is never cleared.** If a box leaves and re-enters the stranded state, a customer who once chose „most nem" will not be interrupted again. The entry point still shows, so the route is intact; clearing it on a transition is a judgement call I did not make unasked. - **`/backup/offbox/reset` redirects to `/backups/remote`, not back to the recovery page.** Correct — the situation has ended — but the customer lands on a page whose recovery banner has just disappeared, with no explicit "done" message. Worth a copy pass with the put-back work. - **`RunRecoveryCheck` was left alone**, as instructed, so it still contains its own fetch+compare. It is compare-only and writes nothing, so it cannot diverge dangerously — but it is now the ONE recovery path not routed through `RecoverInstallCore`.