docs: R-203 contract + report (controller v0.197.0, proven live)
gates / gates (push) Successful in 10s

This commit is contained in:
2026-08-04 18:53:47 +02:00
parent 58c703bd44
commit f4796e0d00
2 changed files with 73 additions and 30 deletions
+44 -30
View File
@@ -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/<name>/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.