# REPORT — Capture-set computation (INERT; Task 3-core) — controller v0.133.0 ## Summary Task 3-core of the backup-classification-redesign arc (`felhom.eu/documentation/architecture/07-backup-architecture.md` §3; tier×class matrix §2; spike verdicts in `SPIKE-restic-snapshot-shape-2026-07-14.md`). Ships **one pure function**, `appbackup.ComputeCaptureSet`, that turns an app's classified binds + a tier + the app's live `hddPath` into the tier-filtered, structurally-guarded, containment-deduped absolute capture set that the 3a (offsite) and 3b (tier-2) engines will consume — plus a `Skipped` list for structurally unsafe would-be captures and a pure `CrossAppOverlaps` advisory. **Deliberately INERT** like Task 2: no backup tier changes behavior; nothing consumes any of it yet. ## Baselines (live-verified at session start) | Repo | `main` @ start | Version | This task | |---|---|---|---| | felhom-controller | `95f3180` | v0.132.0 → **v0.133.0** | `appbackup` engine + `stacks` wiring test | | felhom.eu | `b279312` | — | §3 docs alignment only (commit `8d85da7`) | ## Files created / modified - **new** `controller/internal/appbackup/captureset.go` — `ComputeCaptureSet`, `CaptureTier`, `CapturePath`, `SkippedPath`, `CaptureSet`, `CrossAppOverlaps`, `Overlap`; the §8 pipeline + structural guards. - **new** `controller/internal/appbackup/captureset_test.go` — Groups A–F (7 tests). - **new** `controller/internal/stacks/captureset_wiring_test.go` — Group G, F-S3 no-seam end-to-end. - **mod** `controller/CHANGELOG.md` (v0.133.0, newest-on-top), `controller/REPORT.md` (this), `controller/CONTEXT.md`, `controller/README.md` (appbackup surface, one block). - **mod** `felhom.eu/documentation/architecture/07-backup-architecture.md` §3 (as-built API sketch; separate commit `8d85da7`). No engine edits (`RunTier2`/`RunOffboxBackup`/`RestoreOffbox` untouched), no web/scheduler wiring, no `ClassifyBinds`/`ValidateBackupSpec`/`ParseComposeClassifiableBinds` change, no `AppDataDirNames` call. ## Design (as-built) Fixed pipeline (§8): **legacy short-circuit → tier filter (§2) → structural guards → equal-Abs collapse (mandatory > optional) → containment dedup (keep ancestor) → sort by Abs.** Pure: no `os`/`exec`/`filepath`/logging; slash algebra throughout (`RelPath` is forward-slash, resolved Abs is an in-container Linux path — `filepath` on the Windows test host would flip separators and break the containment prefix checks). Resolution: `RootHDD → path.Join(hddPath, rel)`, `RootUserdata → path.Join(hddPath, "userdata", rel)`. Structural guards are load-bearing security: the compose parser path.Cleans but does not reject `..`, so an unlisted writable `${HDD_PATH}/../x` bind reaches the function classed mandatory; the guard moves it to `Skipped` (with the bare-drive-root and reserved-`backups/` guards) instead of into a captured path. ## Tests — results `go build ./... && go vet ./... && go test ./...` — **all green, both repos** (felhom.eu has no Go). New tests (8 total): `internal/appbackup` +7 (`PerTierSplit`, `LegacyInert`, `ExcludedInvisible`, `StructuralGuards`, `LegitDotDotName`, `ContainmentAndCollision`, `CrossAppOverlaps`); `internal/stacks` +1 (`CaptureSet_Wiring`). Full-suite package count unchanged, all `ok`. ### §10 red-proofs (mutation → FAIL → revert), every one verified | ID | Mutation | Test that must fail | Observed failure | |---|---|---|---| | **B** (SQ5) | legacy short-circuit resolves binds as mandatory | `LegacyInert` | legacy app resolved `[appdata/sonarr, media/tv]` into Paths (+HasClassification=true) | | **A** (tier) | `TierOffsite` includes optional | `PerTierSplit` | offsite Paths gained `/userdata/media/photos` | | **D** (guard) | traversal guard deleted | `StructuralGuards` | `/mnt/evil` (escaped root) present in Paths; 2 Skipped not 3 | | **E1** (contain) | containment dedup disabled | `ContainmentAndCollision` | descendant `appdata/paperless/media` not dropped | | **E2** (mand-wins) | mandatory strength = optional | `ContainmentAndCollision` | collapsed `/userdata/media` class degraded to optional | | **G** (wiring) | tier constants swapped in the filter | `CaptureSet_Wiring` (end-to-end) | real-Manager secondary lost photos / offsite gained it — no fake absorbed the typo | All mutations reverted; post-revert full suite green; no `RED-PROOF` residue in the new files. ## Deploy / verify Built + pushed `felhom-controller:0.133.0` on 180 (digest `sha256:0832106…c772d8`); deployed to demo guest 9201 (bootstrap-managed). Code commit `2668ac4` (controller `main`); docs `8d85da7` (felhom.eu `main`). - `docker ps` (guest 9201): `gitea.dooplex.hu/admin/felhom-controller:0.133.0 Up (healthy)`. - Startup log: `[INFO] Event pushed: controller_started (info) — Controller elindult (0.133.0)`; recovery-unit capture + hub report + health probes all normal (the seerr/radarr/calibre-web `no such host` warns are pre-existing demo-DNS noise, unrelated to this change). - **INERT-silence check PASS:** `grep -iE 'capturese|computecapture|crossapp'` over the container logs returns nothing — the package has zero call sites, so no feature log line fires at runtime. ## Live-validation scope Live validation beyond deploy-health is **inherently N/A** for an inert pure package: it has no runtime surface, no UI, no behavior change. The real live legs belong to 3a (offsite) and 3b (tier-2) acceptance, which consume this function. ## Observations (documented, NOT acted on) - **Parser-side traversal:** `ParseComposeClassifiableBinds` / `classifyRoot` path.Clean the compose host token but do not reject a `..` that survives cleaning (`${HDD_PATH}/../x` → RelPath `../x`). This is by design per the task (the parser stays a faithful extractor; the guard lives in 3-core), and the structural guard here is what makes it safe. If a future task ever wants defence-in-depth, the parser is the second place it could live — noted, not changed. - `ClassifyBinds` emits legacy binds with an **empty** `Class`; `ComputeCaptureSet`'s legacy short-circuit means those are never resolved, but `tierKeeps` also treats an empty class as not-captured (defensive) — so even a future caller that skipped the short-circuit could not resolve a classless bind. Belt-and-suspenders, intentional. - The equal-Abs collapse can arise from two *spellings* of one path (`hdd:userdata/media` vs `userdata:media`); today no catalog app does this, but the collapse + mandatory-wins rule makes it safe if one ever does.