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.
This commit is contained in:
2026-07-26 08:30:45 +02:00
parent 2d2050c362
commit 89d64f64b3
3 changed files with 119 additions and 2 deletions
@@ -93,7 +93,7 @@
| Documents/OCR | paperless-ngx (classified) | **PROVEN-LIVE** | `CAMPAIGN-6C` 4P-1 (deploy paperless-ngx, ingest 3 docs via consume flow, OCR + PDF/A ~90s) + 4P-2/3/5 | Consume-folder ingestion awkward without SMB → R-7 |
| Files from Windows Explorer / Mac Finder (SMB **server**) | controller v0.145.0 + `felhom-samba:1.0.0` | **PROVEN-LIVE** | `felhom-controller` REPORT.md (v0.144.0) + `controller/sharing.md`; transport verdict `audits/SPIKE-lan-discovery-2026-07-18.md` | „Megosztás" page: enable + one household password + shares (new folder or picked existing, per-share read-only). Fourth protected infra stack (host-net, smbd+**nmbd**+wsdd). Live on demo: 445 reachable, NetBIOS `FELHOM` resolves, write/read byte-compare PASS, write to a read-only share REFUSED, SMB writes land as uid 1000. **Explorer leg PASSED 2026-07-18 (Viktor):** Network → FELHOM → both shares open; a real Explorer *save* into `dokumentumok` landed owned uid 1000, and a write into the read-only `filmek` was refused by Windows with the folder left untouched. Share data RIDES BOTH BACKUP TIERS (R-7b, controller v0.145.0, Model B sibling shares source): tier-2 cross-drive legs + an offsite `_shares` restic snapshot carrying the share definitions and the credential copy, with a „Megosztások" restore. **All four legs PROVEN-LIVE on demo 2026-07-18** — tier-2 tree md5-verified; offsite snapshots `e0b9d723` (Viktor 12:18:16Z) and `4e2b15ec` both carrying manifest + `passdb.tar`; restore round-trip returned a deleted probe file byte-identical and a deleted share DEFINITION with its original flags without overwriting live files; samba liveness → hub-accepted `health_critical`. Remaining human leg: SMB positive auth with the real household password |
| Media to TV via DLNA | — | **MISSING** | — | Jellyfin app exists; DLNA/SSDP unvalidated → R-6, R-8 |
| File access via browser | FileBrowser (infra app, auto-mount sync) | **IMPLEMENTED** | FileBrowser runs healthy + userdata-bound (`storage-lifecycle-acceptance-2026-06-15`, `CAMPAIGN-3`) | Actual browse/download **through FileBrowser** is exercised in no doc. (Cited `CAMPAIGN-2` T-PAGE-ALL renders only the controller dashboard pages, not FileBrowser.) Demoted |
| File access via browser | FileBrowser (infra app, auto-mount sync) | **IMPLEMENTED** | FileBrowser runs healthy + userdata-bound (`storage-lifecycle-acceptance-2026-06-15`, `CAMPAIGN-3`) | Actual browse/download **through FileBrowser** is exercised in no doc. (Cited `CAMPAIGN-2` T-PAGE-ALL renders only the controller dashboard pages, not FileBrowser.) Demoted. **2026-07-26, controller v0.172.0 (R-75) — status DELIBERATELY UNCHANGED.** The canonical drop-zone now has its own FileBrowser source („Beolvasás" → `/srv/beolvasas`, a separate bind of `<system namespace>/userdata/import`) and the app page carries a per-app deep link into it. Verified live on demo-hp: the source and bind are in the generated config, the app page renders `https://files.enkisfelhom.hu/files/Beolvas%C3%A1s/paperless`, and a file written through FileBrowser's OWN mount was consumed and deleted by paperless in ~30 s. **That is still not a browse.** Nothing in this arc drove the FileBrowser HTTP UI — no browser exists on DooPlex — so the row's standing caveat survives intact and the upgrade to PROVEN-LIVE remains unearned. What it would take: a human click-through, or an authenticated `/api/resources` round-trip against the live instance. See `controller/import-and-data-paths.md` |
| **Indítópult (app launcher) — one-tap grid of the household's openable apps** | controller v0.163.0 | **IMPLEMENTED** | New FIRST sidebar page `/launcher`: colored tiles (deterministic slug color or `.felhom.yml` `brand_color`) + white glyph/monogram, one per openable app (tile ⟺ „Megnyitás" — subdomain presence is the single criterion; controller excluded). Operational → `<a target=_blank>` to the public URL; stopped → greyed + state badge, no link. `/` stays the Vezérlőpult. Endpoint-level + render-test verified; `felhom-controller/REPORT.md` (2026-07-24) | Live operator click-through of a real tile → app pending (browser automation not available on DooPlex). Follow-up: curate `brand_color` for top catalog apps (R-72). Sharing the launcher outside the household is now the capability-URL guest link — see the row below |
| **Indítópult megosztás (vendég link)** — capability URL `/s/<token>` serves a standalone read-only guest launcher (no account, no admin session); optional per-share password; QR | controller v0.165.0 | **IMPLEMENTED** | 160-bit `crypto/rand` token, constant-time match (empty stored = disabled = byte-identical to the mux default 404); guest headers `noindex`/`no-referrer`/`no-store`; optional SEPARATE bcrypt share password + its own per-IP attempt map; signed cookie `HMAC(token`\|`passwordHash)` keyed with `session_secret` (rotate-token OR change-password invalidates all cookies); token redacted in logs (`/s/<redacted>`). Groups AG (14 tests) + 3 red-proofs; **§13 endpoint-level live validation on 9201 all-pass** (`felhom-controller/REPORT.md` 2026-07-24). **Design ruling: member accounts SUPERSEDED by this capability-URL model; per-member tile visibility parked under the SSO arc (R-15).** | Full operator browser click-through + a validation doc pending → then PROVEN-LIVE. Accepted residuals: link-preview crawlers fetch once (noindex prevents indexing); reverse-proxy/CF access logs hold the path (ops-tier); the modal link carries the request Host (LAN-IP admin ⇒ LAN-IP link) |
| Forgot dashboard password → instant reset code | controller v0.123, hub | **PROVEN-LIVE** | `DRILL-day0-take2-2026-07-12` F-15 (live re-run of the exact failure path: hash applied 1s after request, code accepted first try) | |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,117 @@
# 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`.