9.9 KiB
REPORT — R-203: the app and its backup look in the same place, and "ok" means it (2026-08-04)
Controller v0.196.0 → v0.197.0, deployed to demo-hp. No hub change. Nothing deleted, wiped or
moved on any box; the drill was not resumed. demo-felhom untouched.
1. Confirmed baselines, and §3's landmarks
felhom-controller 532f5712a891 (v0.196.0) → v0.197.0; felhom.eu a0c4b607a6cf, no bump.
Both trees clean on arrival. Every §3 landmark held, including the one that decided the fix:
paths.go:26 names the system-drive arrangement "the SSD-only system-data fallback" — it is
supported, so the resolution was what was wrong and no API refusal was added.
2. The call sites found — FIVE, not four
| # | Site | Named by the spec? |
|---|---|---|
| 1 | stacks/deploy.go withPathVars → ${USERDATA_PATH} |
yes — the live defect |
| 2 | appexport/fabplan.go |
yes |
| 3 | appexport/export.go |
yes |
| 4 | stacks/delete.go ExportDataMounts |
yes |
| 5 | web/handlers.go FileBrowser mount builder |
NO |
The fifth is the customer's own file browser: on a non-enrolled path it would have mounted the wrong
directory. Latent, not live — the system drive is deliberately never a registered StoragePath, so
the resolver is the identity there today. Wired anyway, with that reason in the code.
Two further sites of the same class were found and fixed: ComputeFabBuckets was receiving the drive
path where ComputeCaptureSet has always received the namespace root, so the export's classified
paths and the backup's capture set could describe different directories for the same declared bind.
The rule had TWO existing copies and they differed. backup.Manager.namespaceRoot compared
without filepath.Clean; stacks.Manager.inGuest compared with it. A trailing slash from config
would have flipped the mode in one package and not the other. Both now delegate to
appbackup.NamespaceRootFor.
3. Scenario A — the two paths, before and after (live, demo-hp)
before: /mnt/sys_drive/userdata/media/books ← app bind; capture set looked elsewhere
after: /mnt/sys_drive/felhom-data/userdata/media/books ← app bind == capture root
Capture log: 0 mandatory path(s) → 1 mandatory path(s).
4. Scenario F — the sentinel in the snapshot's file listing
$ restic ls -l latest --tag calibre-web
-rw-r--r-- 1000 1000 181 2026-08-04 12:53:06
/mnt/sys_drive/felhom-data/userdata/media/books/DRILL-SENTINEL.txt
And the snapshot's own paths:
["…/backups/primary/calibre-web", "/mnt/sys_drive/felhom-data/userdata/media/books"].
Not a green status — the file, by name and size. sha256
643166269103a25cf41d34a26b75fd6ebba0a837bbcd7e0d2b5aba7106bcbe7c, byte-identical to the drill's
step 3 after the fix's migration moved it into the corrected directory.
5. Scenario E — the delete path
The §8.3 risk does not exist, and this is the correction owed. ExportDataMounts lives in
delete.go but is export-only: its single production caller is the .fab export adapter and
nothing deletes on its result. The delete path's own guard, ProtectedHDDPaths, is layout-agnostic
by construction — it protects both <hdd>/… and <hdd>/felhom-data/… — so deletion was never
affected by the namespace-root defect. That note is now in the function's doc comment, because the
file placement misled this change's own specification.
Does the corrected path point anywhere the old code did not? Yes, for the export only, and only on
the system-data fallback: <hdd>/felhom-data/userdata instead of <hdd>/userdata. That is the
directory the app actually binds after this release, and the tests assert the negative — no
emitted path lies outside the app's own data roots, on either drive kind. It shipped as its own commit.
6. The verdict value
incomplete — minted, because ok | error | running contained nothing meaning "it ran,
and this app is not fully protected". Not error: the rest of the run worked and what was
captured is real, so SnapshotCount and the LastSuccess anchor still record it.
It reaches the operator through the existing per-run digest, backup_run_failures — already
operator-only, already allowlisted. A new event type would have been a two-repo change and the hub
drops anything outside allowedEventTypes; the prompt ruled out a hub change. The Hungarian customer
warning is unchanged, and the backups page renders ! Hiányos with the warn styling.
7. §8.4's narrowing — no customer-visible warning disappears
TierOffsite's tierKeeps() already admits ClassMandatory only, so an optional path cannot reach
the stat-filter. The added class check is a no-op today, written for parity with Tier 2 — and
demonstrated to be load-bearing anyway: widening the tier filter alone keeps the tests green
because of the check; widening it and removing the check makes an optional gap start reporting.
8. §8.6's live effect — anticipated, and then NOT reproducible
Anticipated in the CHANGELOG before it could fire: calibre-web on demo-hp had exactly this gap, so its
status would become incomplete. In the event it did not, because the same session fixed the
underlying path — after the corrected bind and the data migration the app has no gap, and the run is
legitimately ok.
Attempting to observe the verdict live by hiding the directory did not work, and that is recorded
rather than dressed up: the running container's bind mount recreated it, so os.Stat succeeded
and no gap existed. Worth knowing in itself — a bind-mounted directory cannot easily be "missing"
while its app runs, so the mandatory-gap condition arises in practice when the path resolves somewhere
the app never binds (the R-203 case), not when a live app's own directory vanishes. The verdict is
proven by a run-level test that drives the real RunOffboxBackup. The fixture was restored and the
sentinel re-verified at the same hash.
9. Tests and every red-proof
| Scenario | Result | Red-proof — mutation → outcome |
|---|---|---|
| A both paths agree | PASS + LIVE | restore the bare-path call → FAIL: /mnt/sys_drive/userdata vs /mnt/sys_drive/felhom-data/userdata |
| B enrolled drive unchanged | PASS | invert the drive-kind comparison → FAIL on every enrolled row |
| C mandatory gap → not ok | PASS | unreachable gap recording → FAIL; unconditional ok → FAIL |
| D optional gap → ok | PASS | class check removed with the tier filter widened → FAIL; tier widened alone → PASS, i.e. the check holds the line |
| E export mounts, both kinds + the negative | PASS | leave the site bare → FAIL, emits the short path |
| F sentinel in the listing | LIVE | — |
A RED-PROOF PASSED AND THE TEST WAS WRONG, NOT THE CODE (§9.11 — three of the last six sessions).
My first Scenario-C test exercised offboxCaptureSet alone, while the mutation lives in
runOffboxInternal. A mutation the test cannot observe is not a red-proof. Replaced with a run-level
test that drives RunOffboxBackup and asserts incomplete, the retained LastSuccess, and the
operator signal; it fails under both mutations. The original Scenario-D "red-proof" also could not
fail by construction — recorded above with the two-part mutation that does.
Green gate after each phase (go build && go vet && go test ./..., rc=0) plus
controller_gates.py --fast. No test run was combined with a commit.
10. Commits
| Commit | Contents |
|---|---|
73efb09 |
Part 1 — one resolver, the four non-export sites, ComputeFabBuckets, the two delegating copies |
a96c3d9 |
Part 1.3 alone — the delete.go-resident export-mount site, with its scope correction |
58c703b |
Part 2 — the verdict, the structural gaps, the operator digest, the status rendering |
73fb595 |
docs (felhom.eu) |
11. Registers
- R-203 → SHIPPED + PROVEN-LIVE.
- R-201 → READY TO RESUME, blocker gone, fixture staged and verified.
- R-202 stays open; the orphaned-ciphertext deletion is still owed.
- Capability map: new row for mandatory-path capture on both layouts, its predecessor corrected as optimistic, and the R-199 back-pointer omitted two sessions ago is now added.
- The v0.93.0
identity_blobretention remains unit-proven only — nothing here superseded a key.
12. CI
Run numbers and task ids in the session summary. --no-verify was not used.
13. Teardown
Nothing provisioned. calibre-web and the sentinel stay — R-201 needs them. The temporarily
hidden directory was restored and the sentinel re-verified byte-identical.
14. Observations — noticed, NOT acted on
resolveAbsuses ONE root for two bind classes.RootHDDresolves against the same parameter asRootUserdata. On an enrolled drive they coincide; on the system-data fallback a${HDD_PATH}bind resolves under the namespace root while compose binds it bare. Both callers now pass the namespace root, so the export and the backup agree with each other — but whether${HDD_PATH}itself should mean the namespace root on the system drive is a separate question that touches every already-deployed app's binds. Not fixed here; it needs a decision, not a patch.- The blast radius was measured before changing anything: exactly one app in the fleet has
HDD_PATH == system_data_path—calibre-webon demo-hp, deployed for the drill. Every other deployed app has noHDD_PATH. Nothing else needed migrating. /api/stacks/<name>/deployis first-deployment-only (409 afterwards), so the corrected${USERDATA_PATH}reaches an existing app through a start/redeploy, not a re-deploy.- The controller's CSRF form field is
_csrf, notcsrf_token— the login page uses one name and the protected forms another. Second session in a row this cost time; it belongs in the headless-access memory.