Files
felhom-controller/REPORT.md
T
admin 2f27a363d5 R-108: network storage may not host an app's data namespace (v0.187.0)
This is D5's precondition and it is now met.

An app's namespace root IS its backup root: namespaceRoot returns a non-system
drive path as-is, so the recovery unit lands at <HDD_PATH>/backups/primary/<stack>/.
On a NAS that sits inside the share, which FileBrowser binds WHOLE — share root,
:rslave, download:true.

The bind was NOT narrowed, and establishing why inverted the fix. The share-root
:rslave bind is load-bearing (a 2026-07-22 probe proved an in-container access
through it wakes the idle automount trigger), and scoping is undefinable anyway:
apps on a share store at <share>/<app>, there is no userdata/ layer, and creating
one would write Felhom convention onto a customer's own NAS, which R-67 forbids.
So the browsing surface cannot be narrowed and the backup tree must never be
placed under it. Operator ruling: refuse the placement, keep the browse bind.
Tier 2 already refuses network targets for this reason (F-6C-1).

Nothing stranded: zero apps on network storage across all six hub customers
including Peti. R-67's browse capability is byte-identical.

FIVE surfaces, not the four the register named — settings.RefuseAsAppNamespace is
the single predicate. The deploy POST is the real boundary (it accepts any
caller-supplied HDD_PATH; DeployStack validates only os.Stat). Surface 4,
handleStorageDecommission mode=migrate, guarded only its SOURCE, so a whole
namespace could be decommissioned ONTO a NAS — that one is not in the register.

Fails closed: /mnt/felhom-drives holds both kinds, Kind exists only on a
registered path, so an unregistered path under that root refuses.

Supersedes README's "NAS backup locality — decision A" (v0.118.0).

9 tests, all non-effect (nil stackMgr, so a guard that misses panics rather than
passing). 4 red-proofs, each mutation asserted to have landed.
Suite rc=0, 27 packages, 0 FAIL. vet rc=0. Template + emoji gates OK.
2026-07-30 14:10:20 +02:00

6.8 KiB

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 <share>/<app> (deployedAppsOnPath doc comment, internal/web/netstorage_handlers.go), not under <share>/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 <HDD_PATH>/backups/primary/<stack>/ (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 TARGETrefuseNetworkLifecycle 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).