# 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} = <system namespace root>/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 `<HDD_PATH>/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 `<root>:/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`.
