# REPORT — v0.154.0 (R-48 restore wizard) + Part 2 docs; **Part 3 STOPPED**, STOP-1 pending Session date: **2026-07-21**. Executed on DooPlex as `kisfenyo`. ## 0. Outcome at a glance | Leg | Status | |---|---| | **Part 1 — R-48 restore wizard (controller v0.154.0)** | **DONE.** Committed `3a9d744`, pushed, image `0.154.0` built + pushed. **Deliberately NOT deployed** (by design — the floor save is the deploy). | | **Part 2 — capability-map cell fix (felhom.eu)** | **DONE.** Committed `ce8c539`, pushed. | | **Part 3 — agent 0.90.1 publish + deploy** | **STOPPED before publishing — premise does not hold.** See §5. Nothing published, nothing deployed, felhom-pve untouched. | | **STOP-1 — floor save + single-fire assertion** | **PENDING — needs the operator.** Preconditions verified green (§2). | | **Phase D — the two post-evidence doc flips** | **NOT DONE**, correctly: both are gated on evidence that does not exist yet (§7). | ## 1. Baselines (re-confirmed live at session start) | Repo | `main` @ start | Clean + synced | End state | |---|---|---|---| | felhom-controller | `b30e2e5` | yes | `3a9d744` (v0.154.0) | | felhom.eu | `fb0b8c1` | yes | `ce8c539` | | felhom-agent | `8c55ac7` | yes | **unchanged — no commit, no publish, no deploy** | ## 2. Phase-0 probes ### P1 — self-update enabled and hub-wired on guest 9201: **PASS** From `/var/lib/docker/volumes/felhom-controller-data/_data/controller.yaml`: ```yaml self_update: auto_update: false check_interval: 6h enabled: true health_timeout_seconds: 60 image: gitea.dooplex.hu/admin/felhom-controller ``` `auto_update: false` is **not** a blocker, and this was verified by reading the code rather than assumed: `MaybeAutoUpdate` (`internal/selfupdate/updater.go`) never consults `cfg.AutoUpdate`. That flag is the customer's opt-in to chase *latest*; the FLOOR path is the managed one and is independent of it. Stronger evidence than the config — the path has already fired on this box. `data/update-state.json`: ```json { "status": "success", "previous_version": "0.143.0", "target_version": "0.145.0", "initiated_by": "auto-floor", "initiated_at": "2026-07-18T16:32:29Z", "completed_at": "2026-07-18T16:32:34Z" } ``` `initiated_by: "auto-floor"` proves the agent swapper is wired (a nil agent short-circuits with "no agent — auto-update unavailable") and that a floor-driven swap completes in ~5 s on this box. Anti-flap will **not** block the 0.154.0 swap: the persisted `target_version` is `0.145.0`, not `0.154.0`, and the in-process `lastAutoFloorAttempt` is unset for it (the current-≥-floor branch returns before setting it). Live guest state at probe time: image `0.153.0`, `Up 15 hours (healthy)`, container `StartedAt 2026-07-20 15:03:11 UTC`. Registry precondition for the floor validation also verified: `0.154.0` is the **highest** semver tag in `admin/felhom-controller` (23 tags; top four `0.151.0, 0.152.0, 0.153.0, 0.154.0`), so the `floor <= latest` gate passes rather than deferring. ### P2 — agent version: **PASS, with a finding that changed the plan** `cmd/felhom-agent/main.go:59` holds `var version = "0.89.0"` as a *fallback only*; the real version is ldflags-injected (`-X main.version`) by `scripts/publish-agent.sh`. Built at `main` and verified by self-report, not by CHANGELOG: ``` $ felhom-agent --version felhom-agent 0.90.1 sha256 ba1d029602c96b1b743a4dca3ddbd9b63415fcb47021b91a21f8c32dcc534870 13 734 067 bytes ``` The R-39 fix commit `9596d5a` is an ancestor of `main` and is not reverted. Agent green gate passes. **The finding is in §5** — this binary does not contain the R-39 fix, and cannot. ### P3 — before-state of `/backups/restore`: **CAPTURED** Method: **endpoint-level** (no browser on DooPlex). Authenticated session against the container IP with the `Host:` header, from inside guest 9201. `LOGIN_HTTP=302`, `PAGE_HTTP=200`, 58 115 bytes. Offsite section, buttons in render order — this **is** the R-48 defect: ``` immich / bookstack / calibre-web each: - Visszaállítás ellenőrzéshez (konfiguráció + adatbázis) - Teljes visszaállítás előkészítése immich additionally (scratch prepared): - 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 ``` Form actions on the page: 6× `/backup/offbox/restore`, 1× `place`, 1× `reconstitute`. The two decisive controls are adjacent siblings differing only by label. Snapshot retained at `scratchpad/restore-before.html`. ## 3. Part 1 — what shipped (commit `3a9d744`) Files touched: `internal/web/restore_wizard.go` (new), `templates/backups_restore_wizard.html` (new), `internal/web/restore_wizard_test.go` (new), `templates/backups_restore.html`, `offbox_handlers.go`, `server.go`, `templates/style.css`, plus `CHANGELOG.md` / `CONTEXT.md` / `REUSE.md` / `controller/README.md`. - **One entry per app.** The five inline forms per row collapse to a single „Visszaállítás…" link to `GET /backups/restore/app?name=`. - **Three intent CARDS** with consequence sentences, danger styling on card 3, the R-43 double-confirm and its pair-honesty facts carried over **verbatim**. - **`deriveWizardStep` is pure** over (op running, size-gate flash, scratch ready). Precedence is strict and load-bearing: a running op outranks a stale `?full_prep=`. - **No new mutation endpoint.** One GET route added; every card posts to the pre-existing `/backup/offbox/{restore,place,reconstitute}` with unchanged field names and gates. - Untouched as instructed: the shares block, the local restore panel, the .fab block, `internal/backup`, `internal/appbackup`, `internal/selfupdate`. **Bug found and fixed on the way (not in the spec).** `offboxRedirectTo` hardcoded `"?"` when appending its flash. Retargeting redirects at a URL that already carries `?name=` would have produced `...?name=immich?flash=...`, burying the flash inside the `name` value — the wizard would then have refused its own app with "nincs kijelölve" after every action. The separator is now chosen. ### Test results Full suite green: `go build ./... && go vet ./... && go test ./...` — **0 failures**. All six controller design gates pass (`template_id`, `emoji`, `mojibake`, `native_confirm`, `offbox_rename`, `app_row_dedup`). | Group | Test | Result | |---|---|---| | B | `TestDeriveWizardStep_Table` (7 rows) | PASS | | C | `TestResolveWizardApp_Refusals`, `TestRestoreWizardHandler_UnconfiguredRedirects` | PASS (302, no 500) | | A | `TestRestoreList_SingleEntryPerApp` | PASS | | C | `TestRestoreWizard_ThreeIntentCards`, `TestRestoreWizard_NoScratchLocksDataIntents` | PASS | | E | `TestRestoreWizard_OpRunningSuppressesAllMutations` | PASS | | D | `TestRestoreWizard_NoNewMutationEndpoints`, `TestRestoreWizard_FieldContract` | PASS | Two pre-existing tests were coupled to the old IA and were updated, not deleted: `TestAppRow_RestoreLists` and `TestBackupsSplit_SectionsOnExactlyOnePage` asserted `action="/backup/offbox/restore"` on the list page — now inverted to assert those forms are **absent** there and the single wizard entry is present. ### Group-B red-proof (run, then reverted) Replaced the body of `deriveWizardStep` with the trivial `return restoreWizardView{Step: wizStepIntent, VerifyEnabled: true}`. Result — **all 7 table rows FAILED**, plus the execution render test: ``` --- FAIL: TestDeriveWizardStep_Table/op_running_(this_app)_→_execution;_nothing_offered --- FAIL: TestDeriveWizardStep_Table/op_running_OUTRANKS_a_stale_full_prep_flash… --- FAIL: TestDeriveWizardStep_Table/scratch_ready_→_intent,_and_BOTH_data-touching_intents_unlock… --- FAIL: TestDeriveWizardStep_Table/full_prep_flash_for_THIS_app_→_prepare-confirm… (+3 more rows) --- FAIL: TestRestoreWizard_OpRunningSuppressesAllMutations restore_wizard_test.go:253: the execution step must render NO mutation form restore_wizard_test.go:256: the execution card must name what is actually running ``` Implementation restored; suite green; `git diff` clean before commit. One assertion in `TestRestoreWizard_ThreeIntentCards` was itself caught 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 the rendered banner markup and the `data-restore-*` attributes instead. ### Image `gitea.dooplex.hu/admin/felhom-controller:0.154.0` built + pushed, 145 MB. Self-report verified: ``` felhom-controller 0.154.0 (built 2026-07-21T06:31:08Z, commit 3a9d744) ``` commit matches `HEAD`. **Not deployed to 9201** — by design; the floor save is the deploy. ## 4. Part 2 — capability-map cell (`ce8c539`) Row 61's status cell read `**PARTIAL** — *scope corrected 2026-07-19*` while its own note body already ended with the 2026-07-20 destructive drill and the words "the row now earns PROVEN-LIVE". Cell corrected to `**PROVEN-LIVE** (2026-07-20)`; the full note history retained verbatim. ## 5. Part 3 — **STOPPED. The premise is wrong; publishing would encode a false claim.** Part 3 asked me to publish + deploy v0.90.1 as "R-39's cheap half — the PBS wrapper argv fix", while Rule 12 forbids touching the wrapper. Those two cannot both be satisfied, because **the fix IS the wrapper**. Evidence, in order: **(a) v0.90.1 contains zero non-test Go changes.** `git show 9596d5a --stat`: ``` CHANGELOG.md | 51 + REPORT.md | 184 +- configs/felhom-pbs-apply | 11 +- <- the wrapper: the actual fix internal/pbsdr/manager_test.go | 48 + <- the red-proof test ``` Its own commit message says so outright: *"Config-only (wrapper + red-proof); **the Go binary is unchanged**, so this ships with the next agent deploy as a config artifact."* The 0.90.1 binary is functionally identical to the 0.90.0 already running — only the ldflags string differs. **(b) felhom-pve already has the fix.** The wrapper was hotfixed in place on 2026-07-18: ``` /usr/local/sbin/felhom-pbs-apply:107: args=(--fingerprint "$fp") <- fixed (no --server) /usr/local/sbin/felhom-pbs-apply.bak-20260718-preR39 <- rollback retained ``` **(c) New hosts already get the fix too, independent of the binary version.** `felhom-host-install.sh:1914` fetches `configs/felhom-pbs-apply` via `fetch_raw`, which pulls from `raw/branch/main` — **unversioned**. `9596d5a` is an ancestor of `main`, so every install since 2026-07-18 has installed the fixed wrapper. **Conclusion.** R-39's cheap half is *already closed in the field*, through a channel that carries no version number. Publishing + deploying the 0.90.1 binary would: - deliver **no functional change** to felhom-pve; - restart the agent on a **production host at a remote site** for that nothing; - and, once the operator saved Day-0 manifest Agent → 0.90.1 in the same sitting (as STOP-1 contemplates), **advertise that the R-39 fix shipped as a versioned artifact — which it never did.** A fresh box gets the fixed wrapper from `main` whether it installs binary 0.90.0 or 0.90.1, so the version number would be a false signal about fleet state. Per §0 ("if anything requires touching agent source → STOP and report") and the Part-3 STOP rule, I stopped **before** the publish. **Nothing was published. Nothing was deployed. `felhom-agent` has no new commit and felhom-pve is byte-for-byte as found.** The built binary sits unpublished in the session scratchpad; the registry pre-check (the diagnosed state) was confirmed and left as-is: ``` GET .../generic/felhom-agent/0.90.1/felhom-agent -> 404 (authed and anonymous) GET .../generic/felhom-agent/0.90.0/felhom-agent -> 200 (control) ``` **This needs an operator ruling** — see §8. ## 6. STOP-1 — not yet run (needs the operator) Preconditions are green (§2 P1) and the target image is published and verified. The assertion is ready to run the moment Viktor saves the floor; I have not observed a swap, so **no R-23(a) evidence exists yet and the map row is untouched.** ## 7. Doc flips deliberately NOT made - **R-23(a) → banked**: requires STOP-1 single-fire evidence. Does not exist. Not flipped. - **"config/state change round-trips in seconds" PARTIAL → PROVEN-LIVE**: same gate. Not flipped. - **R-48 → SHIPPED** and the **R-39 artifact note** in `ROADMAP.md`: not written this session. R-48's is merely deferred until the live click-through; the R-39 line is deferred **because the sentence the task specified would have been false** (§5). ## 8. Remaining — human 1. **Ruling on Part 3 (blocking).** Options as I see them: (i) publish 0.90.1 anyway as a version-hygiene artifact, with the CHANGELOG stating plainly that the binary is unchanged and the R-39 fix rode the wrapper — cheap, but the Day-0 manifest still implies more than it delivers; (ii) leave 0.90.0 as the published binary and close R-39's cheap half in the docs, recording that the wrapper is delivered unversioned from `main`; (iii) treat "a privileged host artifact tracks `main` with no version" as the real defect and spec that as TASK-B alongside R-39's primary half. **Recommendation: (ii) now, (iii) as the actual fix** — (iii) is what stops a root-owned wrapper from silently tracking `main`, which is the same class of problem R-39 already demonstrated once. 2. **STOP-1**: floor save 0.153.0 → **v0.154.0**, password-gated. I observe and assert single-fire. 3. **Wizard click-through + one non-destructive Ellenőrzés** through the new surface. 4. **TASK-B** ruling still pending (R-39 primary half: the descriptor-hash re-apply gap). 5. **C6 customer-restore pass** for the alpha. ## 9. Observations - **`docker_run_volume_path_gate.py` fails at `HEAD`, unrelated to this work.** `internal/appexport/estimate.go:179` has an unreviewed `docker run -v`. Verified pre-existing by stashing my changes and re-running (still rc=1). Left alone — out of scope — but it means that gate is currently not a usable signal. - **The `?`-vs-`&` redirect bug (§3) is the kind the wizard makes more likely**, since app-scoped pages now carry query strings as a matter of course. `offboxRedirectTo`'s REUSE.md row now carries the trap. - **`felhom-pve` is up and healthy** despite the vacation note in `CLAUDE.md` saying the agent is down: `systemctl is-active` → `active`, `felhom-agent 0.90.0`. The F1 static-`.162` mitigation is holding; the `CLAUDE.md` TEMPORARY block is stale on that point. - **P3's accented-grep trap fired as documented.** ASCII-safe substrings were required; an accented pattern through `ssh → pct exec → bash -c` returned a false 0. Used a local Python pass over the fetched bytes instead of grepping remotely.