# 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 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-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.
