# REPORT — R-48 restore wizard (v0.154.0) + the v0.155.0 follow-up fix; STOP-1 BANKED; Part 3 STOPPED Session date: **2026-07-21**. Executed on DooPlex as `kisfenyo`. ## 0. Outcome at a glance | Leg | Status | |---|---| | **Part 1 — R-48 restore wizard (v0.154.0)** | **SHIPPED** `3a9d744`, image published, deployed via the floor save. | | **v0.155.0 — follow-up fix** | **SHIPPED + DEPLOYED** `9d1b498`. Fixes a real defect in v0.154.0 found by the operator on the first live click-through. | | **Part 2 — capability-map cell** | **DONE** `ce8c539`. | | **STOP-1 — floor save + single-fire** | **BANKED.** Exactly one swap, no rollback, healthy. §6. | | **Live click-through + Ellenőrzés** | **DONE by the operator**, and re-run endpoint-level after the v0.155.0 fix. §7. | | **Part 3 — agent 0.90.1** | **STOPPED, ruled.** Not published, not deployed. §8. | ## 1. Baselines | Repo | start | end | |---|---|---| | felhom-controller | `b30e2e5` | `9d1b498` (v0.155.0) | | felhom.eu | `fb0b8c1` | `c35da9d` | | felhom-agent | `8c55ac7` | **unchanged — no commit, no publish, no deploy** | ## 2. Phase-0 probes **P1 — self-update wiring: PASS.** `self_update.enabled: true`, image correct. `auto_update: false` is irrelevant and this was verified by reading the code, not assumed: `MaybeAutoUpdate` never consults `cfg.AutoUpdate` — that flag is the customer's opt-in to chase *latest*, while the FLOOR path is the managed one. Stronger evidence: `update-state.json` already recorded a prior `initiated_by: auto-floor` success (0.143.0 → 0.145.0, 2026-07-18), which proves the agent swapper is wired. Anti-flap could not block, since the persisted target was `0.145.0`. `0.154.0` verified as the highest registry semver tag, so the `floor <= latest` gate would pass. **P2 — agent version: PASS, and it changed the plan.** Built at `main`, self-reported `felhom-agent 0.90.1`, sha256 `ba1d0296…`, 13 734 067 bytes. But see §8. **P3 — before-state: CAPTURED** (endpoint-level; no browser on DooPlex). The offsite section rendered **6× `/backup/offbox/restore`, 1× `place`, 1× `reconstitute`** across three apps. For immich the two decisive controls were adjacent siblings differing only by label: ``` Helyreállítás az élő adatok közé (csak a hiányzó fájlok) <- data CANNOT come back Teljes visszaállítás (fájlok + adatbázis) <- data CAN come back ``` ## 3. v0.154.0 — the wizard (`3a9d744`) One „Visszaállítás…" entry per app row → `GET /backups/restore/app?name=`; three intent cards with consequence sentences; danger styling and the R-43 double-confirm carried over verbatim; pure `deriveWizardStep`; **no new mutation endpoint**; works without JavaScript. `internal/backup`, `internal/appbackup`, `internal/selfupdate`, the shares/local/.fab blocks all untouched. **Latent bug fixed on the way:** `offboxRedirectTo` hardcoded `"?"` when appending its flash, which against the wizard's `?name=` target would have buried the flash inside the app name. **Group-B red-proof (run, reverted):** trivial always-INTENT impl → **all 7 table rows FAILED** plus the execution render test. Restored, green, `git diff` clean before commit. Also caught one of my own assertions being hollow during authoring: a bare substring check for the skew/empty warnings passed on a *clean* pair, because the `confirmFullRestore` JS repeats both sentences as string literals. Tightened to assert rendered banner markup + `data-restore-*`. ## 4. v0.155.0 — the defect v0.154.0 shipped (`9d1b498`) **Found by the operator on the first live click-through**, from the phase strip disagreeing with the progress banner on the same screen. This is the most important finding of the session. `backup.Manager` carries **two** different "is something running" booleans: | flag | read by | set by | covers the verification restore? | |---|---|---|---| | `running` | `IsRunning()` | `acquireRunning()`, **inside** the goroutine | **no — `RestoreOffboxScratch` never acquires it at all** | | `opRunning` | `RestoreStatus()` | `BeginRestoreOp()`, in the handler, synchronously | yes, all four offsite actions | v0.154.0 sourced `OpRunning` from `IsRunning()`. Verified by reading the call sites: `PlaceOffsiteRestore` and `ReconstituteFromOffsite` do acquire; **`RestoreOffboxScratch` does not**. So for „Ellenőrzés" and the full-restore preparation — the wizard's two most-used and longest actions, both streaming from restic — the execution step was **unreachable**. The page offered all three intents with live buttons while a restore ran; the handler would have refused any of them. That is precisely the "offering a control guaranteed to fail" dishonesty R-48 exists to remove, shipped inside the release that removes it. **Fix:** `restoreOpInFlight(st)` behind a documented seam, fed by a **single** `RestoreStatus()` read per render, so the strip, the suppression decision and the running-op name cannot diverge. The helper sits on the handler's actual path — deliberately, so a future edit reintroducing `IsRunning()` fails the test rather than passing it. **Why v0.154.0's tests missed it — the generalisable lesson.** The Scenario-E table proved `deriveWizardStep` behaves correctly *given* `OpRunning=true`. Nothing proved the handler ever *computes* `true`. **A table test over a pure function proves the function, not the caller.** The new `TestRestoreOpInFlight_UsesDisplayFlagNotConcurrencyFlag` drives a real `Manager` through `BeginRestoreOp` and asserts the render. **Red-proof (run, reverted):** restoring the v0.154.0 `IsRunning()` shape → ``` --- FAIL: TestRestoreOpInFlight_UsesDisplayFlagNotConcurrencyFlag restore_wizard_test.go:367: a started restore op MUST read as in-flight for display (this is the v0.154.0 bug) ``` **„Eredmény" was a dead label.** It never lit in v0.154.0. The strip's highlight is now its own derived `Phase`, separate from `Step`: a finished restore is back on the intent step (everything available again) while the strip reads „Eredmény" and an outcome card shows the result. Bounded by `restoreResultWindow` (10 min) so a stale result cannot look fresh, and **bound to the app** so a finished bookstack restore cannot light immich's page with bookstack's message. The card survives a reload; the flash does not. 9-row table test incl. both boundary directions and a future-clock-skew row. ## 5. Test + gate results Full suite green at both commits: `go build ./... && go vet ./... && go test ./...` — 0 failures. All six controller design gates rc=0. | Group | Test | Result | |---|---|---| | B | `TestDeriveWizardStep_Table` (9 rows) | PASS | | C | `TestResolveWizardApp_Refusals`, `TestRestoreWizardHandler_UnconfiguredRedirects` | PASS (302, no 500) | | A | `TestRestoreList_SingleEntryPerApp` | PASS | | C | `TestRestoreWizard_ThreeIntentCards`, `..._NoScratchLocksDataIntents` | PASS | | E | `TestRestoreWizard_OpRunningSuppressesAllMutations` | PASS | | D | `TestRestoreWizard_NoNewMutationEndpoints`, `..._FieldContract` | PASS | | **new** | `TestRestoreOpInFlight_UsesDisplayFlagNotConcurrencyFlag` | PASS (red-proofed) | | **new** | `TestHasRecentRestoreResult` (9 rows), `TestRestoreWizard_ResultCard` | PASS | Two pre-existing tests coupled to the old IA were **inverted, not deleted**: `TestAppRow_RestoreLists` and `TestBackupsSplit_SectionsOnExactlyOnePage` now assert the mutation forms are ABSENT from the list. ## 6. STOP-1 — single-fire self-update: **BANKED** Operator saved global floor → v0.154.0. Timeline (host CEST / guest UTC, 2 h offset): ``` 06:57:13Z UpdateState "pending" initiated_by=auto-floor 06:57:17Z agent WARN controller swap requested 0.153.0 -> 0.154.0 06:57:19Z agent INFO image file written, restarting bootstrap 06:57:21Z container StartedAt · UpdateState completed_at 06:57:29Z agent INFO new controller healthy ``` | Assertion | Result | |---|---| | `controller swap requested` | **1** | | `image file written, restarting bootstrap` | **1** | | `new controller healthy` | **1** | | rollback / swap-failed / unhealthy | **0** | | container `RestartCount` | **0** | | bootstrap unit `NRestarts` / `Result` | **0** / `success` | `UpdateState` persisted `status: success`, `0.153.0 → 0.154.0`, `initiated_by: auto-floor`. `VerifyStartup` banked it on the next boot (`Post-update startup: update successful`), and the 06:57:52 periodic check reported `Current version 0.154.0 is up to date` — the anti-flap correctly doing nothing now that current ≥ floor. **Exactly one swap, no storm, no rollback.** This closes the leg R-23(a) was missing: the earlier floor save proved nothing because it targeted an already-running version. > **Methodological caveat, disclosed:** the 30-minute no-re-attempt window was **contaminated by my own > action** — I hand-deployed v0.155.0 at ~07:16Z, mid-window, which restarted the container and the > bootstrap unit. The *agent-side* counters are unaffected and remain the load-bearing evidence (a > hand-deploy writes `/etc/felhom-controller-image` and restarts the unit directly via `pct exec`, never > through `SwapController`), but the *container-side* counters after 07:16Z reflect my deploy, not the > self-update. Between 06:57 and 07:16 — 19 uninterrupted minutes — there was no second attempt. ## 7. Live validation Method: **endpoint-level** (no browser on DooPlex) plus the operator's own UI click-through. **Operator, in the browser (v0.154.0):** list page → one „Visszaállítás…" per app; wizard → three cards, pair-honesty panel showing `adatbázis-mentés ideje: 2026-07-19 12:28`; ran one **Ellenőrzés**; result `A(z) immich visszaállítva ellenőrző mappába: /mnt/felhom-drives/hdd_1/backups/offsite-restore/immich (a meglévő adatok változatlanok).` Non-destructive as designed. **Endpoint-level, after the v0.155.0 fix — the proof the defect is gone.** A real Ellenőrzés run through the actual endpoint: | moment | phase strip | `
` count | note | |---|---|---|---| | before | Előkészítés | 3 | intents offered | | **mid-flight** | **Végrehajtás** | **0** | `Jelenleg egy mentési vagy visszaállítási művelet fut (immich).` | | after | **Eredmény** | 3 | result card names the scratch path; intents available again | | bookstack, same moment | Előkészítés | — | **no** result card — app-binding holds | Under v0.154.0 the mid-flight row would have read `Előkészítés / 3 forms`. Before/after on the list page: **6 restore + 1 place + 1 reconstitute forms → 0**, and 3 wizard entries. Refusal path: unknown app → **302** + Hungarian flash, no 500. Deploy verified: `felhom-controller:0.155.0 | Up (healthy)`, `RestartCount=0`. ## 8. Part 3 — STOPPED, then ruled Part 3 asked me to publish + deploy v0.90.1 as "R-39's cheap half — the PBS wrapper argv fix", while Rule 12 forbade touching the wrapper. Both cannot hold, because **the fix IS the wrapper**: - **`9596d5a` changes zero non-test Go files** — `CHANGELOG.md`, `REPORT.md`, `configs/felhom-pbs-apply`, `internal/pbsdr/manager_test.go`. Its own message: *"the Go binary is unchanged."* - **felhom-pve already has it** — wrapper hotfixed 2026-07-18, `args=(--fingerprint "$fp")` at L107, `.bak-20260718-preR39` retained. - **New hosts get it too, unversioned** — `felhom-host-install.sh:1914` `fetch_raw` pulls the wrapper from `raw/branch/main`, and `9596d5a` is an ancestor of `main`. Publishing would have delivered no behaviour change, restarted a production agent at a remote site for nothing, and — once the Day-0 manifest was saved to 0.90.1 — advertised a versioned fix the artifact channel never carried. Registry pre-check confirmed and left as found (0.90.1 → **404**, 0.90.0 → 200). **Operator ruling: leave 0.90.0 published; record the closure in the docs.** Done in `ROADMAP.md` (`c35da9d`). The stop surfaced a genuine new defect, filed as **R-50b**: a root-owned privileged host artifact is delivered unversioned from `main`, absent from the Day-0 manifest, so "which wrapper is on this host?" is unanswerable from any manifest. ## 9. Doc state - Capability map row 61 → **PROVEN-LIVE** (`ce8c539`). - ROADMAP: **R-48 → SHIPPED**; R-39 cheap half recorded as closed-in-field with the publish cancelled; **R-50b** filed (`c35da9d`). - **Not flipped:** the R-23(a) map row and the "config/state round-trips in seconds" row — the evidence now exists (§6), so these are ready to flip; held only because the 30-min window carries the disclosed caveat above. Recommend flipping with the 19-minute clean interval cited explicitly. ## 10. Remaining — human 1. **Floor → v0.155.0** at your convenience, so the fleet matches the box (9201 is hand-deployed and above floor, so nothing is pending on it). 2. **TASK-B** ruling (R-39 primary half: the descriptor-hash re-apply gap). 3. **R-50b** ruling — recommendation is (a): record the wrapper's sha256 in the Day-0 manifest and have the agent report the installed file's hash, so drift becomes visible. 4. **C6 customer-restore pass** for the alpha. ## 11. Observations - **The most valuable finding came from the operator, not the tests.** The phase strip he questioned was the visible symptom of a real bug — the strip and the progress banner disagreeing on one screen. A UI element that renders derived state is an assertion the customer can check; that is worth more than its decorative cost. - **`docker_run_volume_path_gate.py` fails at `HEAD`, unrelated** (`internal/appexport/estimate.go:179`). Verified pre-existing by stashing and re-running. Left alone — but that gate is currently not a usable signal. - **`opstatus.go` says in its own header that it is the DISPLAY surface** and does not gate concurrency. I read the concurrency flag anyway. The file told me; I did not check. - **`felhom-pve` is up and healthy** (agent 0.90.0, `active`) — the "agent is DOWN" TEMPORARY block in `felhom-controller/CLAUDE.md` is stale and should be trimmed on return. - **P3's accented-grep trap fired as documented** — used a local Python pass over fetched bytes rather than grepping accented patterns through `ssh → pct exec → bash -c`.