# REPORT — R-356: the off-site restore refused every app that has no data drive **Controller v0.219.0**, shipped and proven live on `demo-hp` 2026-08-22. ## Baselines used (re-verified at session start) | repo | `main` @ | version | → | |---|---|---|---| | felhom-controller | `2da259af38cc` | v0.218.0 | **v0.219.0** | | felhom-agent | `40d857b52711` | v0.130.0 | unchanged | | felhom.eu (hub) | `f2edf7e54595` | v0.106.0 | unchanged | | app-catalog | `459766cb1639` | — | unchanged | **MinAgent stays 0.129.0.** All four hashes matched the prompt. **The catalogue count, measured in this session and not taken from the prompt:** 53 templates, **13 declare `needs_hdd: true`**, **40 declare `false`**, and exactly 13 carry a `backup:` block. Agrees with §1. ## The defect `ReconstituteFromOffsite` and `PlaceOffsiteRestore` both resolved the restore destination with the **raw** `stackProvider.GetStackHDDPath(stack)` and refused when it was empty, with a message saying the app is not installed. For the 40 driveless apps that value is never set, so both refused permanently for a running, healthy app — and the remedy they offered ("reinstall it in the same place") cannot be carried out, because those apps offer no place to pick. The capture side never had this: `CaptureRecoveryUnit` resolves via `GetAppDrivePath`, which falls back to `systemDataPath`. That is why the 2026-08-21 refusal could print `/mnt/sys_drive` — a destination the backup had recorded and the restore refused to use. ## The change - `internal/backup/backup.go` — new `Manager.isStackDeployed`, built on `knownStackNames()` → `ListDeployedStacks()`. **Nil provider ⇒ false**, documented with its reason: the caller's next act is a write, so "cannot tell" must fail into the recoverable refusal, not into a copy. - `internal/backup/offbox_reconstitute.go` — the refusal now asks *installed?* first (R-351 sentence and the recorded-drive half intact), then resolves the destination with `GetAppDrivePath`, then has a **third, distinct** refusal for installed-but-no-resolvable-data-root. R-253 and R-351 comments kept and extended with what the refusal stopped covering and why. - `internal/backup/offbox_restore.go` — the same four steps in `PlaceOffsiteRestore`. The headroom gate below it then measures the resolved namespace, which is the correct disk in both cases. **Not changed, deliberately:** `offboxCaptureSet`'s raw `GetStackHDDPath` (`offbox_capture.go:43`) and `offboxRestoreScratchDir`. **`ListDeployedStacks()` semantics were verified, not assumed.** Source: `stackAdapter` skips `!s.Deployed` (`cmd/controller/main.go:2138`). Test: positive and negative controls in `TestR356_IsStackDeployedIsExactAndFailsClosed`. Seam: an AST walk in `cmd/controller/r356_deployed_seam_test.go` pins both the `SetStackProvider` wiring in `main()` and the `!s.Deployed` guard itself. ## Tests New: `internal/backup/r356_hot_only_restore_test.go` (10 tests, Scenarios A–E across both entry points) and `cmd/controller/r356_deployed_seam_test.go` (2 AST tests). **Test count in `internal/backup` 315 → 325, `cmd/controller` 42 → 44.** **Every red-proof was SEEN failing.** Mutation → observed failure: | # | mutation applied | observed | |---|---|---| | A | destination back to `stackProvider.GetStackHDDPath` | `ScenarioA` failed: *"a(z) immich telepítve van, de a vezérlő nem tudja megállapítani, hová tartoznak az adatai…"* | | B | `GetAppDrivePath` always returns `systemDataPath` | 3 tests failed, incl. *"placement …/sys/felhom-data/… left the app's own drive"* — this is also the **positive control** for the "13-class unchanged" absence claim | | C | `isStackDeployed` always true | 3 tests failed; the not-installed refusal was replaced by a placement-mismatch prompt | | D | widen `nincs telepítve` to cover the no-data-root case | both ScenarioD tests failed: *"a running app must not be told it is not installed"* | | E | revert `PlaceOffsiteRestore` only (fix one entry point) | `ScenarioE_PlaceDriveless` failed | **No red-proof passed.** Green gate: `go build ./... && go vet ./... && go test ./...` — all green. **Fixture correction, stated rather than hidden:** several existing fixtures marked an app "installed" by giving it an HDD path — the exact conflation this change removes. 17 tests failed on that alone after the change; they now state deployment as its own fact (`prov.deployed`). No assertion was weakened, and `TestPlace_UndeployedRefused` was passing for the wrong reason before. ## Live walk on `demo-hp` (controller v0.219.0, healthy) Endpoint-level — `claude-in-chrome` is not available on DooPlex. Full record and every artefact: `felhom.eu/documentation/audits/DRILL-r356-hot-only-restore-2026-08-22/`. **Driveless (`privatebin`):** planted through the app's own JSON API + a direct file plant with a Hungarian accented name; findability proved by plant→find→remove→fail-to-find; off-sited; **11 files deleted outright**; scratch prepared (snapshot `306accff`, **unit-only** — the shape never driven to completion before); `POST /backup/offbox/reconstitute` — **no refusal**; **15/15 files back byte for byte**. Message (160 bytes): *„A(z) privatebin: 0 fájl és 1 adatkötet visszaállítva (mentés: 2026-08-22 13:14) — az alkalmazás újraindult. Ennek az alkalmazásnak nincs adatbázisa."* It **names the volume**, so it is not the R-354 shape. **Drive app (`calibre-web`):** same walk, **3/3 byte-identical**, placed on `/mnt/felhom-drives/hdd_1`; `/mnt/sys_drive/felhom-data/userdata/media` does not exist, so nothing was misplaced onto the SSD. **Every downstream leg held on first run.** No finding raised against them. ## Also in this session CI run **387** (job 386) failed on `instructions_gate` — **a real gate bug, not mine**: `ef6ac6f` in `felhom.eu` compressed closed rows into `CLOSED-ITEMS.md`, which the gate does not read, so every citation of a compressed item became "a reference to nothing". Fixed in `felhom.eu/scripts/instructions_gate.py` (commit `e18668f`); both controls still convict. ## Operator follow-up 1. Bake and **vouch** a golden carrying controller 0.219.0. The `golden-currency` gate is red until then, correctly: a machine installed now still receives 0.218.0. 2. **Then** raise the update floor to 0.219.0 — last, in a separate save.