diff --git a/CONTEXT.md b/CONTEXT.md index b325812..55173a0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1672,6 +1672,35 @@ Last updated: 2026-06-13 (v0.60.0 backlog-Medium cleanup) --- +## R-203 (v0.197.0) — the namespace-root contract, and what `ok` now means + +**The contract, in one line:** `appbackup`'s path helpers (`UserdataDir`, `PrimaryBackupPath`, +`RecoveryUnitPath`, `AppDataDir`) take a **NAMESPACE ROOT**. Anything that came out of `HDD_PATH` or a +`StoragePath` is a **DRIVE path** — put it through `appbackup.NamespaceRootFor(drive, systemDataPath)` +first. `UserdataDir(bareDrivePath)` still compiles and is still wrong; five callers proved it. + +**The rule now has ONE expression.** `NamespaceRootFor` / `IsEnrolledDrive` in `appbackup`; +`backup.Manager.namespaceRoot` and `stacks.Manager.inGuest` delegate. There were two copies before and +**they differed** — one compared without `filepath.Clean`, the other with it. + +**Why it was invisible:** on an enrolled drive the namespace root IS the drive path. The two diverge +only on the system-data fallback, which `paths.go:26` names as a supported arrangement. + +**`last_status` gains `incomplete`.** A run that could not capture a directory an app declares +MANDATORY is not a successful run. **Not `error`** — the rest of the run worked, so `SnapshotCount` +and `LastSuccess` still record what WAS captured. It reaches the operator via the existing +`backup_run_failures` digest (a new event type is a two-repo change; the hub drops unlisted types). +The Hungarian customer warning is unchanged; the page renders `! Hiányos`. + +**Still open, and NOT fixed here:** `resolveAbs` resolves `RootHDD` and `RootUserdata` against the same +root. Both callers now pass the namespace root so the export and the backup agree with each other, but +whether `${HDD_PATH}` should mean the namespace root on the system drive touches every deployed app's +binds and needs a decision, not a patch. + +**Blast radius, measured before changing anything:** exactly one app in the fleet had +`HDD_PATH == system_data_path` (`calibre-web` on demo-hp, the R-201 drill fixture). Its data was +migrated and its sentinel re-verified byte-identical. + ## R-203 (2026-08-04) — a MANDATORY userdata directory can be absent from the off-site snapshot while the run says `ok` Found live on demo-hp while staging the R-201 drill, and it **halted that drill**. diff --git a/REPORT.md b/REPORT.md index 9b45cce..5099bae 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,42 +1,56 @@ -# REPORT — controller v0.196.0 (R-200 Part 0) + the R-203 finding (2026-08-04) +# REPORT — controller v0.197.0: R-203, both halves (2026-08-04) -**Deployed:** `felhom-controller:0.196.0` on demo-hp guest 9201 and demo-felhom guest 9201. Requires -agent >= v0.125.0. +Deployed to demo-hp guest 9201. No hub change. Nothing deleted, wiped or moved; the R-201 drill was +not resumed. -## Part 0 — `--recover-offsite-install` +## What shipped -The sibling of `--recover-offsite-check`: same fetch → unseal → extract path through the agent, same -STDIN discipline for R, but it **places** the recovered repository password via `InjectOffboxPassword` -so a rebuilt box reopens the off-site history it inherited. +**Part 1 — one resolver, five callers.** `appbackup.NamespaceRootFor` / `IsEnrolledDrive` are now the +single expression of the drive-kind rule; `backup.Manager.namespaceRoot` and `stacks.Manager.inGuest` +delegate (they were two copies that already differed over `filepath.Clean`). Routed through it: +`stacks/deploy.go` `withPathVars` (the live defect), `appexport/fabplan.go` + `export.go` via a new +`GetStackNamespaceRoot` provider method, `web/handlers.go`'s FileBrowser mounts (the fifth site, which +the spec did not name — latent today), and `stacks/delete.go`'s `ExportDataMounts` (its own commit). +`ComputeFabBuckets` now receives the namespace root, matching `ComputeCaptureSet`. -- **The confirmation is a second invocation.** Without `--confirm-install` both hashes print and - nothing is written; the operator sees the comparison before a write is possible. -- **Three outcomes, named distinctly:** *installed* (no local password — the rebuilt-box shape), - *unchanged* (identical key present, nothing written), *refused* (a DIFFERENT key present — exit 2, - no force offered, because which history to keep is not this command's decision). -- It re-reads the file after writing rather than trusting the call's return. +**Part 2 — the verdict.** `last_status` gains `incomplete`; the capture gaps are recorded structurally +so the verdict has something to act on; the operator hears it through the existing per-run digest. -`go build && go vet && go test ./...` rc=0; `controller_gates.py --fast` OK. -**Red-proof observed:** removing the confirmation gate makes the dry run write the password. -The R-persistence test carries a **positive control** (a planted copy found, then removed and not -found) because an absence check is worth only what its sensitivity is. +## Proven live on demo-hp -**NOT exercised against a live recovery** — the R-201 drill halted before step 9. Unit-proven only. +``` +bind before: /mnt/sys_drive/userdata/media/books +bind after: /mnt/sys_drive/felhom-data/userdata/media/books +capture log: 0 mandatory path(s) -> 1 mandatory path(s) -## The R-203 finding — see CONTEXT.md +$ restic ls -l latest --tag calibre-web +-rw-r--r-- 1000 1000 181 2026-08-04 12:53:06 .../media/books/DRILL-SENTINEL.txt +``` -A customer-declared **mandatory** data directory was silently absent from the off-site snapshot while -the run reported `ok`, because the deploy-time `${USERDATA_PATH}` root and the backup-time -`NamespaceRoot` disagree for an app on the system drive. It halted the drill: the sentinel file was not -in the snapshot, so a wipe would have destroyed it and proven nothing. +The file, by name and size, inside the snapshot — not a green status. -Full record: `felhom.eu/documentation/audits/DRILL-r201-offsite-recovery-2026-08-04.md`. +## Tests + +`go build && go vet && go test ./...` rc=0; `controller_gates.py --fast` OK. Table-driven over BOTH +drive kinds, because this defect survived by being invisible on the kind that already worked. + +**One red-proof PASSED and the test was wrong, not the code:** the first Scenario-C test reached only +`offboxCaptureSet` while the mutation lives in `runOffboxInternal`. Replaced with a run-level test +driving `RunOffboxBackup`; it now fails under both an unreachable gap-recording and an unconditional +`ok`. Scenario D's proof also could not fail by construction (the tier filter already excludes +optional paths) — the two-part mutation that does is recorded. + +## Corrections to the spec + +- **Five call sites, not four.** The fifth is the FileBrowser mount builder. +- **`ExportDataMounts` is export-only despite living in `delete.go`.** Its only caller is the `.fab` + adapter, and the delete path's own `ProtectedHDDPaths` guard is layout-agnostic by construction, so + the destructive-path risk the spec flagged does not exist. ## Observations -- **The CSRF form field is `_csrf`, not `csrf_token`** — the login page uses one name and the protected - forms another. Cost real time; worth a line in the headless-access memory. -- **`/api/stacks//deploy` accepted `HDD_PATH=/mnt/sys_drive`** one call after refusing a NAS path - with a clear error. If the system drive is not a valid app namespace, that refusal is missing. -- **demo-hp has no registered drive usable as an app namespace** (its only drive is a NAS), which is - why the drill's file-leg app had to go on the system drive at all. +`resolveAbs` resolves `RootHDD` and `RootUserdata` against one root — fine on enrolled drives, and now +consistent between export and backup, but whether `${HDD_PATH}` should mean the namespace root on the +system drive is a separate decision affecting every deployed app. The deploy API is +first-deployment-only (409 after), so an existing app picks up the corrected path via start/redeploy. +The CSRF form field is `_csrf`, not `csrf_token` — second session running that this cost time.