Files
felhom.eu/documentation/audits/R108-network-app-namespace-2026-07-30.md
T
admin d42d90fed7 R-108 CLOSED — D5's precondition is met (controller v0.187.0)
Four-artifact update per the coupling rule, plus the audit.

07-backup-architecture.md: §10.1 retitled CLOSED with the ruling and the D5
sentence; the FileBrowser network-share row flipped YES->NO, closed at the
PLACEMENT rather than at the bind; the exposure chain annotated with the fifth
surface (decommission-with-migrate guarded only its source) and the correction
that the boundary is the deploy POST, not the dropdown; §7.3 retitled UNBLOCKED;
register row collapsed; open question F answered.

00-capability-map.md: new §D row PROVEN-LIVE, with the un-exercised legs named —
the deploy-POST and decommission refusals are unit-tested, not live-fired.

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 thread-loss pattern);
R-126 registered.

ROADMAP.md: R-108 collapsed to a shipped one-liner; R-126 added.

R-126 filed not fixed: a .fab bundle (plaintext secrets, optional password) can
be exported ONTO a NAS. Split out of R-108 rather than folded in — it is an
explicit customer-chosen export destination, not a browsing surface reaching a
backup tree, so it was never part of D5's precondition.

Live evidence: same-box before/after on demo-felhom through the real authenticated
endpoint, the network-specific refusal on demo-hp, non-effect verified in the
registry, and R-67's share-root bind diffed byte-identical across the deploy.
2026-07-30 14:21:44 +02:00

15 KiB

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 <share-root>/<app> — the local <drive>/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 NASREFUSED.

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 <HDD_PATH>/backups/primary/<stack>/ (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.