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.
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:
- The share-root
:rslavebind is load-bearing, not an oversight. Its own doc comment records a Phase-0 probe (2026-07-22) proving an in-container access through anrslavebind wakes the idle automount trigger. Narrow it and NAS access itself breaks. - 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|backupsconvention does not apply to shares at all. - 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 <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-fanddrill-r50not upgraded — neither was named as a venue;drill-r50is 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; noAttachDrivecall.