# AUDIT — R-108: network storage may not host an app's data namespace (2026-07-30) Shipped: **controller v0.187.0** (`felhom-controller` `2f27a36`), live on **both** demo boxes. **D5's precondition is met.** --- ## 1. Baselines, established separately | component | value | how | |---|---|---| | controller on `main` | **0.186.0** → shipped 0.187.0 | top of `CHANGELOG.md` | | controller on demo-felhom 9201 | **0.186.0** (pre-fix — used as the BEFORE) | `docker ps --filter name=felhom-controller` | | controller on demo-hp 9201 | **0.185.1** (was drifted one release behind) | same | | **golden-baked controller** | **0.186.0** | `hub_settings.artifact_golden_version` | | agent (both demo boxes) | **0.118.1** | `hosts.agent_version`, hub-side | | hub | **0.83.0** | `deploy/hub` image tag | | `felhom.eu` HEAD | `70f8494` | — | **R-120's gate holds:** golden `0.186.0` == newest fleet-reported controller `0.186.0` at session start, so `compareVersions(golden, newest) == 0`, not `< 0`. **It will now REFUSE** until the golden is re-baked to 0.187.0 — deliberate and correct: the fleet has moved past the golden, which is exactly the drift the gate exists to catch. **No golden re-bake was performed** (out of scope; not required for this fix). **R-121 (agent-on-box drift): CLOSED for the demo fleet.** Both boxes report 0.118.1 and the manifest vouches 0.118.1 — no drift. `sess-f` (0.116.0) and `drill-r50` (0.113.0) remain behind and were not touched. --- ## 2. §2 citation reconfirmation — all six hold, line numbers moved | register citation | now at | verdict | |---|---|---| | `handlers.go:2450-2460` local userdata-scoped | `:2464-2465` | **holds** | | `handlers.go:2432` network share-ROOT, `download: true` | bind `:2437`; `download: true` at `internal/infra/infra.go:328` | **holds** | | `settings.go:904-914` no `IsNetwork()` filter | `GetSchedulableStoragePaths`, `:926-936` | **holds** | | `handlers.go:674-679` migrate targets unfiltered | `:673-678` | **holds** | | `storage_handlers.go:410-424` migrate-app unguarded | `:416-425` | **holds** | | `storage_handlers.go:397` sibling refuses — the precedent | `handleStorageMigrate`, `:404` — refuses BOTH source and target | **holds, and is stronger than cited** | --- ## 3. The §3.1 ruling — REFUSE the placement, KEEP the browse bind **"Scope the bind" is not implementable.** Establishing that is what inverted the fix: 1. **The share-root `:rslave` bind is load-bearing, not an oversight.** Its own doc comment records a Phase-0 probe (2026-07-22) proving an in-container access through an `rslave` bind wakes the idle automount trigger. Narrow it and NAS access itself breaks. 2. **There is no `userdata/` layer to scope to.** `deployedAppsOnPath` (`internal/web/netstorage_handlers.go`) documents that *apps on a share store `/`* — the local `/userdata|appdata|backups` convention does not apply to shares at all. 3. **Creating one is forbidden:** *"Never a skeleton, never any write toward the NAS — Felhom conventions must not be written onto a customer's own NAS."* The browsing surface being **immovable** is precisely why the backup tree must never be placed under it. Tier 2 had already reached that conclusion for its own targets (`F-6C-1`, `internal/backup/tier2.go`); R-108 closes the PRIMARY namespace, the last remaining route. **This needed an operator decision** (it removes a capability going forward) and got one. Evidence supplied with the question: **zero apps on network storage across all six hub customers including Peti** — verified from every stored DR-recipe app half; only `hdd_1` and `nvme-1tb`, both local drives. demo-hp's `Felhom-Share` holds only the customer's own files (`.DS_Store`, `test file.txt`, `teszt/`) and **no `backups/`**. Nothing stranded, no migration owed. Two capabilities were separable and only one was removed: - **browse a NAS through FileBrowser** (R-67) — **KEPT, byte-identical.** - **host an app's data namespace on a NAS** — **REFUSED.** --- ## 4. The exposure chain, confirmed at source `namespaceRoot(drivePath)` returns any non-system drive path AS-IS (`internal/backup/backup.go:262-263`), so an app's namespace root **is** its `HDD_PATH`; the recovery unit is `/backups/primary//` (`internal/appbackup/paths.go`). On a NAS that lands inside the share, which FileBrowser binds whole with `download: true`. **This was documented, deliberate behaviour** — not an accident. `controller/README.md` carried *"NAS backup locality (v0.118.0, CAMPAIGN-3 Part 4 — decision A): a NAS-resident app's tier-1 artifacts live on the NAS itself"*. R-108 supersedes decision A; the README now says so, and the NAS-outage window that block warned about is gone with it. --- ## 5. §3.2 — the surface set is FIVE, not four | # | surface | state before | in the register? | |---|---|---|---| | 1 | **deploy POST** (`internal/api/router.go`) | accepted any caller-supplied `HDD_PATH`; only other validation is `os.Stat` existence (`internal/stacks/deploy.go`) | **understated** — the row says "the dropdown has no filter", but the dropdown is a UI list; this is the boundary | | 2 | per-app migrate target list (`internal/web/handlers.go`) | unfiltered | yes | | 3 | `handleStorageMigrateApp` | no guard | yes | | 4 | **`handleStorageDecommission` mode=`migrate`, the TARGET** | `refuseNetworkLifecycle` guarded only `req.Where` (the SOURCE) — a whole namespace could be decommissioned ONTO a NAS | **NO — found by enumerating** | | 5 | the FileBrowser bind | share ROOT + `download: true` | yes — deliberately unchanged, now pinned by a test | Also found and **filed, not fixed**: `storageDriveList()` (`internal/web/handler_export.go`) does not filter network paths, so a `.fab` bundle (plaintext secrets, optional password) can be **exported onto** a NAS → **R-126**. The register already noted this as R-108's "second effect, independent of D5"; it is now its own row so it did not vanish with R-108's closure. It is **not** a D5 precondition: an explicit customer-chosen export destination is not a browsing surface reaching a backup tree. **No gap was found in the §10.1 swept set.** Every other row in that table was re-read and still holds. --- ## 6. Fail-closed, and why it needed a function rather than an `IsNetwork()` call `/mnt/felhom-drives` holds **both** storage kinds in-guest — `.../nvme-1tb` is a local drive, `.../Felhom-Share` is a NAS — so a **path prefix cannot classify**. `Kind` is the only discriminator and it exists only on a **registered** path. Therefore an unregistered path under that root is un-classifiable, and un-classifiable **refuses**. Every share is registered under that root by construction, so the network set is completely covered without touching drives. Empty `HDD_PATH` (SSD-resident) stays allowed; a nil registry refuses. --- ## 7. Live evidence — same box, before and after, through the real endpoint **Method:** `POST /api/storage/migrate-app` — the exact endpoint the storage UI's migrate button invokes — authenticated with the session cookie and the `X-CSRF-Token` the page carries, against the controller's container IP with the mandatory `Host` header. No browser exists on DooPlex; this is the endpoint-level method, and no server logic is skipped. The app name is deliberately **non-existent**, so the call is safe on both code paths (pre-fix it fails on the app name; post-fix it is refused earlier). ### demo-felhom — the same box across the upgrade ``` BEFORE (controller 0.186.0) the target was NEVER EXAMINED target=/mnt/felhom-drives/Felhom-Share -> 409 {"error":"alkalmazás nem található: r108-probe-nonexistent"} target=/mnt/felhom-drives/not-registered -> 409 {"error":"alkalmazás nem található: r108-probe-nonexistent"} AFTER (controller 0.187.0) refused at the target target=/mnt/felhom-drives/Felhom-Share -> 400 "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." target=/mnt/felhom-drives/not-registered -> 400 (same — fail-closed) target=/mnt/felhom-drives/hdd_1 -> 409 {"error":"alkalmazás nem található"} <- guard PASSED THROUGH ``` Pre-fix, both paths sailed into `MigrateApp` and failed only on the app name — **with a real app name the migration would have started**. That is the defect, live. Post-fix both are refused, and the real local drive still reaches `MigrateApp` (**409, a different code and message**), which is the not-over-broad proof. ### demo-hp — the box with a REGISTERED network share ``` target=/mnt/felhom-drives/Felhom-Share -> 400 "Hálózati tárhelyen (NAS) nem futtatható alkalmazás adatkönyvtára — a NAS megosztás tallózásra és médiatárolásra használható. Válasszon csatlakoztatott meghajtót." target=/mnt/felhom-drives/not-registered -> 400 (undeterminable reason — fail-closed) target=/mnt/felhom-drives/nvme-1tb -> 409 app not found <- guard PASSED THROUGH ``` The **network-specific** reason fires here because the share is registered with `kind=network`; on demo-felhom the same path is unregistered and correctly gets the fail-closed reason instead. The two messages differing per box is the predicate demonstrably reading real state, not a constant. ### Non-effect — the state after the refused calls (demo-hp) ``` storage registry: path=/mnt/felhom-drives/nvme-1tb kind=drive migrated_to=- decommissioned=False path=/mnt/felhom-drives/Felhom-Share kind=network migrated_to=- decommissioned=False app HDD_PATH: /mnt/felhom-drives/nvme-1tb (unchanged) share backups/: ls: cannot access '.../Felhom-Share/backups': No such file or directory ``` No `migrated_to`, nothing decommissioned, no job id or `started` flag returned, `HDD_PATH` unchanged, and no `backups/` tree on the share. ### R-67 preserved — the FileBrowser compose, diffed across the deploy ``` - /mnt/felhom-drives/nvme-1tb/userdata:/srv/nvme-1tb <- drive: userdata-scoped - /mnt/felhom-drives/Felhom-Share:/srv/Felhom-Share:rslave <- network: ROOT (deliberate) - /mnt/sys_drive/felhom-data/userdata/import:/srv/beolvasas ``` `diff` of demo-hp's generated compose before vs after 0.187.0: **IDENTICAL**. The capability the ruling preserves is preserved byte-for-byte, not merely "probably unaffected". --- ## 8. Tests **9 new**, all asserting the **non-effect**. The refusal tests run against a Server with a deliberately **nil `stackMgr`**: a guard that fails to fire reaches the mutation and **panics** rather than passing quietly. Asserted: no job id, no `started` flag, no `MigratedTo`, and for decommission that the source was **not** soft-marked. Fixtures are demo-hp's real two-class storage set (both paths under the same mount root — the trap). **Seams named (R-125).** The FileBrowser tests inject at `fbPathDeps` (`isMount`/`classify`/ `ensureSkeleton`) and run the real `buildFileBrowserPaths`; the bind-string construction — what the assertion is about — is **not** injected. **Not covered by that span:** `RenderFileBrowserConfig` and the compose template downstream, closed by the live compose diff in §7. The handler tests inject **nothing** — real handler, real settings store, `httptest`. **Red-proofs: 4**, each mutation asserted to have landed *before* the run: | # | mutation | caught by | |---|---|---| | 1 | drop the migrate-app target guard | panic on nil `stackMgr` — execution reached the mutation | | 2 | drop the decommission migrate-target guard | panic, same shape | | 3 | let an unregistered path under the mount root fall open | 3 tests, incl. the live-shaped handler case | | 4 | userdata-scope the share (break R-67) | the regression guard, quoting the broken bind | Suite **rc=0, 27 packages, 0 FAIL** (run separately from every commit); `go vet` rc=0; `template_id_gate.py` and `emoji_gate.py` both OK. --- ## 9. §7 — the sentence this task exists to produce **D5's precondition IS met.** An app's data namespace can no longer be placed on network storage, so no `backups/` tree can exist inside FileBrowser's share-root bind; every other read surface was already clear in the §10.1 sweep. **Nothing in `07-backup-architecture.md` §10.1 blocks D5 any more.** What remains is scheduling, not blocking. `.fab`-export-onto-NAS (**R-126**) is open but is not a D5 precondition — it is an export destination the customer chooses, and D5 moves secrets into the local recovery unit, not into `.fab`. --- ## 10. Four-artifact update | artifact | what changed | |---|---| | `architecture/07-backup-architecture.md` | §10.1 retitled **CLOSED**, with the ruling, the reasoning and the D5 sentence; the network-share table row flipped YES→NO *with the reason it is closed at the placement rather than the bind*; the exposure chain annotated with the fifth surface and the deploy-POST correction; §7.3 retitled **UNBLOCKED**; §10.2 register row collapsed + R-126 added; open question **F answered** | | `architecture/00-capability-map.md` | new §D row, **PROVEN-LIVE**, citing this audit — with the un-exercised legs named explicitly (deploy POST + decommission refusals are unit-tested, not live-fired) | | `backlog/ROADMAP.md` | R-108 collapsed to a shipped one-liner; **R-126** added | | `backlog/OPEN-ITEMS.md` | R-108 dispositioned; **D5 given its own row** as READY/UNBLOCKED (it had existed only inside other rows' prose — the R-123 pattern); **R-126** registered | --- ## 11. What did NOT happen, and why - **D5 not implemented** — explicitly fenced; this unblocks it only. - **No customer data migrated** — none needed it (zero apps on network storage). - **The share-root bind not narrowed** — deliberate, and §3 is the argument. - **R-126 filed, not fixed** — it is a separate surface with its own decision (filter the destination, or force the bundle password), and folding it in would have widened a security fix mid-flight. - **No golden re-bake** — out of scope. **Consequence to be aware of:** the golden bakes 0.186.0 while the fleet now runs 0.187.0, so R-120's vouch gate will REFUSE the next artifact vouch until the golden is re-baked. That is the gate working as designed, not a regression. - **`sess-f` and `drill-r50` not upgraded** — neither was named as a venue; `drill-r50` is fenced. - **No live-fire of the deploy-POST or decommission refusals** — both are unit-tested with non-effect assertions; live-firing them needs a real app deploy and a real decommission on a box with a registered share, which is a destructive drill this task did not authorise. Named in the capability-map row. - **Nothing written to demo-hp's `local-lvm`**; `drill-r50`, guest 9201 on other hosts and Peti's cluster untouched; neither demo box re-targeted; no `AttachDrive` call.