16 KiB
REPORT — R-75: canonical import root, catalog-derived skeleton, import surfaces (2026-07-26)
Overwritten per the standing rule. Controller v0.172.0, live on demo-felhom 9201 and demo-hp 9201. MinAgent unchanged.
1. Baselines used
| Repo | start | end |
|---|---|---|
| felhom-controller | 3b672ba7 (v0.171.0) |
8fadbd9 (v0.172.0) |
| app-catalog-felhom.eu | 3067a946 |
4252121 |
| felhom.eu | 2d2050c3 |
see §10 |
| felhom-agent | dfd5d731 (v0.96.0) |
untouched |
2. Part 1.0 probe result — and the STOP it triggered
The brief anticipated an ambiguity between cfg.Paths.SystemDataPath and the registered path. The
probe found something bigger, and I stopped before Part 1.1 as instructed.
| demo-felhom 9201 | demo-hp 9201 | |
|---|---|---|
paths.system_data_path |
/mnt/sys_drive |
/mnt/sys_drive |
| Registered storage paths | /mnt/felhom-drives/hdd_1 ("USB HDD 1TB", default) |
/mnt/felhom-drives/nvme-1tb ("NVME 1TB", default); /mnt/felhom-drives/Felhom-Share (network) |
| System drive registered? | No | No |
/mnt/sys_drive is a real mountpoint (50 G, 4 % used) holding only
felhom-data/backups/{primary,secondary} and lost+found. Neither /mnt/sys_drive/userdata nor
/mnt/sys_drive/felhom-data/userdata existed on either box — the userdata convention had never been
exercised on the system drive.
Which string is the namespace root: /mnt/sys_drive/felhom-data, per
appbackup.NamespaceRoot(drivePath, inGuestDrive=false), confirmed by the on-disk
felhom-data/backups/primary. But withUserdataPath disagrees — it computes USERDATA_PATH as
<hdd>/userdata, not NamespaceRoot(hdd)/userdata, so an app on the system drive would get
/mnt/sys_drive/userdata while its backups go to /mnt/sys_drive/felhom-data/backups/. Latent, and
left untouched; recorded in CONTEXT.md.
Why it blocked Part 6 — verified against the real guard with a passing control, not read from source:
sysroot NamespaceRoot shape -> REFUSED (Ez a mappa nem osztható meg.)
sysroot withUserdataPath shape -> REFUSED (Ez a mappa nem osztható meg.)
user drive (control) -> ACCEPTED (…/felhom-drives/hdd_1/userdata/import)
And the obvious fix had a trap: if the system drive were registered, SharingDeniedRoots denies
<root>/felhom-data, so the namespace-consistent shape is refused anyway.
SharingDeniedRoots("…/sys_drive") = [appdata backups felhom-data felhom-data/appdata felhom-data/backups]
NamespaceRoot shape (felhom-data) -> REFUSED
withUserdataPath shape -> ACCEPTED
Operator ruling: leave the system drive unregistered; the controller writes the beolvasas share
directly. sharingResolvePath validates CUSTOMER-supplied picker paths — a controller-generated
constant is a different trust class — so no guard was weakened, and none was bypassed for user
input.
3. Exhaustive-switch audit
| Site | Verdict | Action |
|---|---|---|
stacks/classify_binds.go:20 composeVarRoots |
needs third entry | {"${IMPORT_PATH}", RootImport} added |
appbackup/classify.go ValidateBackupSpec / ClassifyBinds |
needs third root | Import []BindSpec + both loops extended |
appbackup/captureset.go resolveAbs |
BROKEN for import — the sharp one | fell through to path.Join(hddPath, relPath) → a directory on the WRONG DRIVE. Now takes importRoot; RootImport is its own case |
appbackup/captureset.go structuralGuard |
needs a case | an unresolvable import root is refused into Skipped (reasonNoImportRoot) rather than joined onto "". The RootHDD bare-root/backups rules deliberately do NOT transfer — <sysNS>/userdata/import nests no backups tree |
ComputeCaptureSet / ComputeFabBuckets |
needed the root threaded | importRoot param added; compile-forced at all 4 call sites (2 backup, 2 appexport) |
appexport/fabplan.go:53 |
needs a case | import binds fell into selectedHDD and would tar from the wrong root. excluded makes them default-out, but OptInExcluded can pull them in |
appexport/fabplan.go:78 classifiedHDD |
correctly indifferent | import binds are not RootHDD, so they are not listed |
backup/tier2_capture.go tier2DestRel |
indifferent, but only by class | TierSecondary is mandatory+optional and import is excluded. Left as-is and recorded rather than relied on silently |
stacks/samba_classify.go:44 |
produces binds, does not switch | shareRelPath falls back to path-minus-leading-slash for a share under no registered root. Known gap, not fixed here — see §9.2 |
appexport/export.go:629 |
indifferent | compares resolved paths, not the enum |
backup/appbackup_bridge.go |
indifferent | pure re-export shim |
GetImportRoot() added to both provider interfaces (appbackup.StackDataProvider,
appexport.ExportStackProvider) and both adapters in cmd/controller/main.go.
4. Commits
| Repo | Commit | What |
|---|---|---|
| felhom-controller | 2958946 |
v0.172.0 main implementation |
| felhom-controller | 4773809 |
fixup 1 — EnsureImportRoot parent convention |
| felhom-controller | 8fadbd9 |
fixup 2 — drop import/* from the carry-list |
| app-catalog-felhom.eu | 4252121 |
ingest binds → ${IMPORT_PATH}, backup: moved, data_paths: added |
Push ordering was deliberate and load-bearing: the controller was built and deployed to BOTH boxes
before the catalog was pushed. A catalog carrying ${IMPORT_PATH} reaches a running v0.171.0
controller within 15 minutes and would resolve the variable to "", binding a bogus root-owned dir at
the container root on the next deploy or restart.
Files: 10 new (stacks/{datapaths,skeleton_derive}.go, web/{datapath_card,filebrowser_link}.go,
6 test files), 43 modified.
5. Tests + the three mandatory red-proofs
915 → 951 test functions; 27 packages; go build ./... && go vet ./... && go test ./... all green.
Red-proof B — classification regression
Pre-fix shape: revert the third-root plumbing in ValidateBackupSpec/ClassifyBinds.
--- FAIL: TestScenarioB_ImportMoveKeepsClassification (0.00s)
import_root_classify_test.go:107: import/paperless: class = "mandatory", want "excluded" — the moved ingest bind must classify under the import root
import_root_classify_test.go:110: import/paperless: origin = "default_writable", want "explicit" — the moved ingest bind must classify under the import root
The failure is worse than "degrades to legacy": the drop-zone silently becomes mandatory, i.e. a
transient consume inbox would be shipped OFFSITE.
Red-proof C — determinism (the P6 gate)
Pre-fix shape: remove sort.Strings from BuildUserdataSkeleton.
--- FAIL: TestScenarioC_SkeletonDeterminism (0.00s)
skeleton_determinism_test.go:28: generation 2/20 differs — a non-deterministic skeleton force-recreates FileBrowser on every sync pass
first: [a/b media media/audiobooks media/books media/photos import import/paperless roms a/b/c …]
got: [a/b media media/music media/comics media/photos downloads import/calibre media/tv …]
Failed on generation 2 of 20.
Red-proof E — server-side share refusal
Pre-fix shape: remove BOTH server-side refusals (handler + store), leaving only the template gate.
--- FAIL: TestScenarioE_SystemShareDeleteRefusedServerSide (0.07s)
import_share_test.go:63: the system share was DELETED by a direct POST — the refusal is not server-side
--- FAIL: TestScenarioE_StoreLayerRefusesSystemShare (0.06s)
import_share_test.go:84: RemoveSMBShare must refuse a System share
Design gates: template_id_gate, emoji_gate, mojibake_gate, native_confirm_gate,
app_row_dedup_gate, offbox_rename_gate — all rc=0.
docker_run_volume_path_gate rc=1, PRE-EXISTING (internal/appexport/estimate.go:179, a file
this change does not touch — git status --porcelain on it is empty). That is R-29(a), red since
v0.129.0; not fixed here.
6. Live validation (Part 8)
Deployed gitea.dooplex.hu/admin/felhom-controller:0.172.0 — both boxes Up … (healthy).
Method: endpoint-level — the exact endpoints the UI invokes, driven with an authed session against
the controller container IP with the Host: header. No browser exists on DooPlex.
Leg 1 — canonical bind. Catalog synced through the real button endpoint (POST /api/sync →
updated: [calibre-web, paperless-ngx, romm]), then POST /api/stacks/paperless-ngx/update:
/mnt/sys_drive/felhom-data/userdata/import/paperless -> /usr/src/paperless/consume
drwxrwsr-x 2 1000 1000 /mnt/sys_drive/felhom-data/userdata/import/paperless
PASS — 2775, group 1000, on the system drive.
Leg 2 — ingest round-trip. File written into /srv/beolvasas/paperless through FileBrowser's own
mount as uid 1000; paperless saw it as paperless:paperless, then:
INGESTED+DELETED after ~30s
[paperless.consumer] Consuming r75-teszt.txt
[paperless.consumer] Document 2026-07-26 r75-teszt consumption finished
[paperless.tasks] ConsumeTaskPlugin completed with: Success. New document id 16 created
final drop-zone contents: (empty)
PASS — the consume-and-delete contract holds across the new bind.
Leg 3 — app-page deep link, rendered live on demo-hp:
<a href="https://files.enkisfelhom.hu/files/Beolvas%C3%A1s/paperless" … >Beolvasandó dokumentumok ↗</a>
<span class="datapath-space">18.5 GB szabad</span>
<p class="datapath-note">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.</p>
PASS. Honest scope: the link was verified as rendered and correctly encoded, not followed in a browser — see §8.
Leg 4 — the share. POST /sharing/enable → the beolvasas share auto-appeared at
/mnt/sys_drive/felhom-data/userdata/import, row marked rendszer with no delete button. A direct
POST /sharing/shares/delete name=beolvasas returned 303 and the share survived:
[('beolvasas', True, '/mnt/sys_drive/felhom-data/userdata/import')]
PASS. Note felhom-samba is not running — sharing is on but no household password is set, so
the stack correctly stays undeployed. Enabling the toggle therefore did not put SMB on the LAN.
Leg 5 — zero removals, both boxes.
| Box | paths removed | paths added |
|---|---|---|
| demo-felhom | none | none |
| demo-hp | none | media/podcasts |
documents intact on both. media/podcasts is the one genuinely derived addition (audiobookshelf's
second bind, in no hardcoded list) — and its appearance on demo-hp but not demo-felhom, where it
already existed, is the clean control that the derivation works. System-drive trees on both boxes:
userdata and userdata/import at 2775 root:1000, plus import/paperless at 2775 1000:1000 on
demo-hp where paperless runs.
7. Two defects found DURING the live legs, fixed in the same version
EnsureImportRootleft the parent at 755.EnsureUserdataDirMkdirAll's intermediates at plain0755and chmods only the leaf, so<sysNS>/userdatacame out755 root:root— the one userdata root on the box outside the 2775/gid-1000 convention. Both now carry it (TestEnsureImportRoot_ParentCarriesTheConvention).- The carry-list re-created the dead drop-zone forever. With
import,import/paperlessandimport/calibrecarried, the derived skeleton would re-create a per-drive drop-zone on every drive in perpetuity — the exact lookalike this arc removes, and unbacked (class: excluded). Dropped from the carry-list. Not a zero-removals violation: nothing deletes what a box already has, and both boxes' old drop-zones were verified to hold zero files first.TestSkeletonNeverCreatesAPerDriveDropZonepins it;TestUserdataSkeleton_Listwas updated to assert their absence (a deliberate behaviour change, not a weakened assertion).
One latent 500 caught before it shipped: the sharing template's row struct was function-local, so
adding {{if .System}} would have failed at render for every share row. ShareRow is now
package-level and the render test constructs the handler's own type.
8. NOT yet live-validated
- No browse through the FileBrowser UI. The source, the bind and the deep-link URL are verified; nothing drove FileBrowser's HTTP UI or API. The capability-map row File access via browser is therefore deliberately left at IMPLEMENTED, with a note. Needs a human click-through.
- The deep link was not followed. Rendered and encoding-verified only; the cold-link → login → redirect path is proven from the shipped router source (spike P2), not exercised live.
- calibre-web's ingest bind was not redeployed — calibre-web runs on demo-felhom, and leg 1 exercised paperless-ngx on demo-hp. The catalog change is identical in shape.
- SMB access to the
beolvasasshare — the samba stack is not deployed on demo-hp (no household password), so no Windows/Explorer leg ran. Config-level only. data_pathsroleslibraryandexportwere not seen live: paperless declares onlyimport. calibre-web (library) and romm (library) are unit-tested but not rendered on a box.- A multi-drive box. Both demo boxes have one non-network data drive, so the canonical root's central benefit — no dead drop-zone on drive #2 — is proven by construction and unit test, not in the field.
- The
.fabexport path with an opted-in import bind —resolveAbs's third case is unit-tested, never exercised through a real export. - A migration off the system drive (the
appDataSkipSetexclusion) — unit-tested only.
9. Observations — noticed, not acted on
withUserdataPathputs userdata outside the namespace on the system drive (<hdd>/userdatavsNamespaceRoot(hdd)/userdata). Latent — no app with a userdata bind has been deployed there — but it is a real inconsistency and would surface the day one is.samba_classify.shareRelPathhas no case for a share under no registered root. It falls back to path-minus-leading-slash, which for the system share yields a bogusRootHDDrelpath. Only reached for backup classification of shares, and the share isOffsite: false, so it is inert today. Left alone deliberately (out of scope); worth its own item.- A customer with pending files in an OLD per-drive drop-zone would find them never ingested after this upgrade — the bind moved, and nothing migrates the files. Both demo boxes were verified empty so no action was needed here, but a real box would need an operator-run move. No destructive or move operation was added anywhere in this arc.
docker_run_volume_path_gateis still red (R-29(a), since v0.129.0), in a file this change does not touch.- R-76 untouched (FileBrowser's
0755/no-setgid, andimport/calibreat 755 on demo-felhom — still755 1000:1000after this deploy, since nothing here chmods it). Nothing built in this arc assumes animport/*directory stays 2775. - Sharing is now ENABLED on demo-hp (leg 4, explicitly authorised by the brief). No household
password is set, so
felhom-sambais not deployed and SMB is not on the LAN. Reverting would be a further unrequested state change, so it was left as-is — flagging for the operator's decision. - I re-hit the accented-Hungarian grep trap myself while checking leg 3: an accented pattern
through
ssh → pct exec → bash -creturned nothing and briefly read as "the consequence line is missing". It was rendering correctly all along. ASCII-only gating patterns, every time.
10. felhom.eu docs
documentation/controller/import-and-data-paths.md— NEW: the canonical root, the three roles, thedata_pathscontract, the class-driven copy rule, and the seven invariants a future change must not break.documentation/architecture/00-capability-map.md— File access via browser note added, status unchanged (§8).documentation/backlog/ROADMAP.md— R-75 collapsed to its shipped one-liner; R-76 left open.