New documentation/controller/import-and-data-paths.md: the canonical import root (and why it is NOT a registered StoragePath), the three data_paths roles, the Fork-3 validation asymmetry, the class-driven copy rule, and the seven invariants a future change must not break. Capability map "File access via browser" — status DELIBERATELY UNCHANGED. The drop-zone now has its own FileBrowser source and the app page carries a deep link, both verified live, but nothing drove the FileBrowser HTTP UI (no browser on DooPlex), so the row's standing "browse is exercised in no doc" caveat still holds and PROVEN-LIVE remains unearned. R-75 collapsed to its shipped one-liner. R-76 left open — this task does not fix it, and nothing built here assumes an import/* directory stays 2775.
6.1 KiB
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.
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
- Zero removals.
UserdataSkeletonCarry()is the v0.171.0 hardcoded list, retained forever, so derivation can only ADD.documentsis implied by no catalog app and exists on both demo boxes. - The skeleton set is SORTED.
fbNeedsRecreateforce-recreates FileBrowser on any byte diff across ~14SyncFileBrowserMountscall sites; the naive map-order derivation measured 20 distinct outputs from 20 identical runs. - The catalog sync is NOT wired to
SyncFileBrowserMounts. Connecting them turns a 15-minute catalog cycle into a 15-minute container-restart cycle. url.PathEscapeper path segment for deep links — neverurl.QueryEscape(it encodes a space as+, which in a path segment is a literal plus). Lethtml/templatedo the attribute escaping.- Sharing stays opt-in. The
beolvasasshare is created when sharing is ENABLED, never as a side effect of deploying an app. ${IMPORT_PATH}binds do not resolve againsthddPath. Any new consumer ofBindRootneeds an explicitRootImportcase with the import root passed in.- Do not assume an
import/*directory stays2775— see R-76: FileBrowser creates0755without propagating setgid, and a liveimport/calibrewas found at755.