# Import root & `data_paths` (R-75) Shipped in controller **v0.172.0** (2026-07-26). Evidence: `audits/SPIKE-catalog-data-paths-2026-07-26.md`. --- ## 1. The canonical import root ``` ${IMPORT_PATH} = /userdata/import = /mnt/sys_drive/felhom-data/userdata/import (both demo boxes) ``` **One drop-zone per box, on the system drive — never per data drive.** It is resolved from `cfg.Paths.SystemDataPath` via `appbackup.NamespaceRoot(sys, false)` (the system drive holds a `felhom-data` SUBDIR, so it is the `inGuestDrive=false` case) and exposed as `stacks.Manager.GetImportRoot()`. **Why canonical, not per-drive** — this overrides the spike's Fork-1 recommendation, on a stronger argument the spike did not weigh. Each drop-zone app has exactly **one** ingest bind. On a two-drive box a per-drive `import/` would therefore put a folder that *looks* like a drop-zone on every drive while only one of them does anything; and because `import/*` is `class: excluded`, files stranded in a dead one are never backed up either. A canonical root is the only shape with no dead drop-zone. **No fallback, deliberately.** If the system root is unresolvable, `IMPORT_PATH` is left **unset** and compose fails loudly on `${IMPORT_PATH}`. It must never fall back to `/userdata/import` — that silently recreates the shape this design removes. **The system drive is NOT a registered `StoragePath`** (verified on both demo boxes, 2026-07-26). Registering it would make a 50 GB volume holding the recovery units a customer-visible drive, a deploy target and a wipe candidate. Everything that must reach the import root therefore does so explicitly: | Surface | How it reaches the import root | |---|---| | Deploy belt | `ensureUserdataMounts` — an import loop that is **not** drive-absent-gated | | Skeleton | `Manager.EnsureImportRoot()`, not the per-storage-path skeleton loop | | FileBrowser | an explicit extra bind `:/srv/beolvasas`, outside `buildFileBrowserPaths` | | SMB share | written directly to the store by `ensureImportShare` | That last one matters: `sharingResolvePath` **refuses** the import root, correctly — it validates paths a *customer* supplied through the picker and requires containment in a registered live root. A controller-generated constant is a different trust class. (`TestImportRoot_NotReachableViaTheCustomerPicker` pins both halves.) --- ## 2. The three roles `data_paths:` is a **pure annotation** over paths that already exist as compose binds. It can never declare a path — `ValidateDataPaths` rejects any entry matching no bind. That is the property which keeps the design cheap: **no new filesystem-write primitive is introduced from catalog data.** ```yaml data_paths: - path: paperless # relative to the bind's root root: import # import | userdata | hdd role: import # import | library | export label: "Beolvasandó dokumentumok" ``` | role | meaning | rendered? | |---|---|---| | `import` | drop-zone; the app consumes and usually DELETES from it | yes, + free space | | `library` | where the customer's files live | yes | | `export` | the app writes results here for the customer | yes | `root: hdd` entries are **not** rendered: `appdata/` is app-internal, FileBrowser does not mount it, and a link would 404. ### Validation is asymmetric, and that is a decision | defect | outcome | precedent | |---|---|---| | malformed **path** (absolute, `..`, backslash, non-`Clean`'d, matches no bind, duplicate, unknown root) | **whole-block reject**, one ERROR, no folder card | `backup:` — paths govern DATA HANDLING | | unknown **role** | **fails OPEN** — that entry is not surfaced, one WARN, the rest renders | `Lifecycle` — roles govern PRESENTATION; a typo must never brick a template | The path rules reuse `appbackup.ValidateRelPath`, the single refusal set shared with `backup:`. **Do not write a second path validator.** --- ## 3. The class-driven copy rule The consequence sentence on the app page is driven by the **derived backup class**, never by the role and never by a per-app string, so the promise the UI makes cannot drift from what the backup engines actually do: | class | Hungarian copy | |---|---| | `excluded` + `role: import` | „Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — ez a mappa átmeneti, és nem készül róla biztonsági mentés." | | `excluded` (other) | „Ez a mappa átmeneti, és nem készül róla biztonsági mentés." | | `mandatory` / `optional` | „Itt tárolódnak a fájljaid. Biztonsági mentés készül róla." | | unclassified | **nothing** — an unverified backup promise is worse than no sentence | Both catalog import paths are `class: excluded`, so both must carry the temporary-and-unbacked warning. `TestConsequenceIsClassDriven` pins it. --- ## 4. Invariants a future change must not break 1. **Zero removals.** `UserdataSkeletonCarry()` is the v0.171.0 hardcoded list, retained forever, so derivation can only ADD. `documents` is implied by no catalog app and exists on both demo boxes. 2. **The skeleton set is SORTED.** `fbNeedsRecreate` force-recreates FileBrowser on any byte diff across ~14 `SyncFileBrowserMounts` call sites; the naive map-order derivation measured **20 distinct outputs from 20 identical runs**. 3. **The catalog sync is NOT wired to `SyncFileBrowserMounts`.** Connecting them turns a 15-minute catalog cycle into a 15-minute container-restart cycle. 4. **`url.PathEscape` per path segment for deep links — never `url.QueryEscape`** (it encodes a space as `+`, which in a path segment is a literal plus). Let `html/template` do the attribute escaping. 5. **Sharing stays opt-in.** The `beolvasas` share is created when sharing is ENABLED, never as a side effect of deploying an app. 6. **`${IMPORT_PATH}` binds do not resolve against `hddPath`.** Any new consumer of `BindRoot` needs an explicit `RootImport` case with the import root passed in. 7. **Do not assume an `import/*` directory stays `2775`** — see R-76: FileBrowser creates `0755` without propagating setgid, and a live `import/calibre` was found at `755`.