# REPORT — R-108: network storage may not host an app's data namespace (v0.187.0, 2026-07-30) **Overwritten** per the standing rule. Controller **v0.186.0 → v0.187.0**, MinAgent 0.113.0 (unchanged). **The §7 sentence, up front: D5's precondition IS now met.** No app namespace can be placed on network storage, therefore no `backups/` tree can exist inside FileBrowser's share-root bind, therefore moving app secrets into the local recovery unit no longer exposes them to a browsing surface. Nothing else was blocking D5 — the §10.1 sweep had already cleared every other read surface. ## 1. The §3.1 ruling: REFUSE the placement, KEEP the browse bind The task offered "scope the bind" or "refuse". **Scoping is not implementable**, and establishing that inverted the fix: - The share-root `:rslave` bind is **load-bearing, not an oversight**. A Phase-0 probe (2026-07-22, recorded in the `buildFileBrowserPaths` doc comment) proved an in-container access through it wakes the idle automount trigger. Narrow it and NAS access itself breaks. - There is **no `userdata/` layer to scope to**. Apps on a share store at `/` (`deployedAppsOnPath` doc comment, `internal/web/netstorage_handlers.go`), not under `/userdata/`. - 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."* So the browsing surface cannot be narrowed, and the backup tree must never be placed under it. Tier 2 already refuses network targets for this same class of reason (`F-6C-1`); R-108 closes the PRIMARY namespace, the last route by which a `backups/` tree could land inside a share-root bind. **Operator decision required and taken** (the ruling removes a capability going forward). Evidence supplied with the question: **zero apps on network storage across all six hub customers including Peti** — nothing is stranded, and no migration is owed. R-67's browse capability is byte-identical after. ## 2. The exposure chain, confirmed at source `namespaceRoot(drivePath)` returns any non-system drive path AS-IS (`internal/backup/backup.go:262`), so an app's namespace root **is** its `HDD_PATH`; its recovery unit is `/backups/primary//` (`internal/appbackup/paths.go`). On a NAS that sits 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 block now says so. ## 3. §2 citation reconfirmation — all four 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 | bind `:2437`, `download: true` at `infra/infra.go:328` | **holds** | | `settings.go:904-914` no `IsNetwork()` filter | `GetSchedulableStoragePaths`, `:926` | **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 | `handleStorageMigrate`, `:404` (both source AND target) | **holds — the precedent** | ## 4. §3.2 — the set is FIVE, not four Surface 4 below is **not in the register**; it was found by enumerating rather than trusting the named set. 1. **deploy POST** (`internal/api/router.go`) — **the actual boundary.** The register's "the dropdown has no filter" understates it: the dropdown is a UI list, this endpoint takes any caller-supplied `HDD_PATH`, and `DeployStack` validates only `os.Stat` existence. A list filter alone would have left the surface open. 2. **per-app migrate target list** (`internal/web/handlers.go`) — dropped from the offer. 3. **`handleStorageMigrateApp`** — refused before `MigrateApp`. 4. **`handleStorageDecommission` mode=migrate TARGET** — `refuseNetworkLifecycle` guarded only `req.Where` (the SOURCE); a whole namespace could be decommissioned ONTO a NAS. **NEW.** 5. **the FileBrowser bind** — deliberately unchanged, now pinned by a regression test. Also noted, not fixed (register already records it as a separate effect): `storageDriveList()` (`handler_export.go`) does not filter network paths, so a `.fab` bundle — plaintext secrets, optional password — can still be **exported onto** a NAS. That is a bundle destination, not a namespace placement, and it is outside R-108's scope. Filed as **R-126**. ## 5. Fail-closed, and why it needed a function `/mnt/felhom-drives` holds **both** kinds in-guest (`.../nvme-1tb` is a drive, `.../Felhom-Share` is a NAS), so a path prefix cannot classify. `Kind` is the only discriminator and 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 fully covered without touching drives. Empty `HDD_PATH` (SSD-resident) stays allowed; a nil registry refuses. ## 6. Tests — 9 new, all non-effect; seams named (R-125) 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` written, and for decommission that the source was **not** soft-marked. Fixtures are demo-hp's real two-class storage set. **Seams injected:** 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 reading the live generated compose (§7). The handler tests inject nothing — real handler, real settings store, `httptest`. **Red-proofs: 4**, each mutation asserted to have landed before the run: drop the migrate-app guard → panic; drop the decommission-target guard → panic; break fail-closed → 3 tests; userdata-scope the share → the R-67 regression guard fires quoting the broken bind. Suite rc=0, 27 packages, 0 FAIL (run separately from the commit); `go vet` rc=0; `template_id_gate.py` + `emoji_gate.py` OK. ## 7. Live evidence See `felhom.eu/documentation/audits/R108-network-app-namespace-2026-07-30.md`. ## 8. Not done - **D5 not implemented** — this unblocks it only. - **No data migrated** — nothing needed migrating (zero apps on network storage). - **The share-root bind unchanged** — deliberate; R-67's capability is preserved intact. - **R-126 filed, not fixed** (`.fab` export onto a NAS).