Files
admin 89d64f64b3 R-75 SHIPPED (controller v0.172.0): docs, capability-map note, ROADMAP collapse
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.
2026-07-26 08:30:45 +02:00

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

  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.