# REPORT — Backup classification: schema + parser + classifier + catalog migration (controller v0.132.0) ## Summary Task 2 of the backup-classification-redesign arc (`felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md`). Ships the referential-coupling classification as **DATA + PARSER + PURE CLASSIFIER — deliberately INERT**: no backup tier changes behavior. The `backup:` block now lands in `.felhom.yml` for all 13 bind-bearing catalog apps; the controller parses + validates it loudly and a pure classifier resolves per-bind classes with the SQ5 two-level default. Task 3 (tier policy engine) and Task 4 (manual `.fab` UI) consume this — nothing does yet except a validation log pass. ## Confirmed baselines | Repo | `main` @ start | → | Commit(s) on `main` | |------|----------------|---|---------------------| | felhom-controller | `af98c53` (v0.131.0) | **v0.132.0** | `0649f9a` (code+tests+docs) | | app-catalog-felhom.eu | `365a017` | (no version scheme) | `21e8df1` (13 blocks + CHANGELOG) | Trunk-based, direct to `main`. Controller committed + deployed FIRST, then the catalog blocks (so the parser validates them on first sync). ## Files created / modified **Controller (`0649f9a`)** - `internal/appbackup/classify.go` (NEW) — schema (`BindClass`, `BindRoot`, `BackupSpec`, `BindSpec`, `ComposeBind`, `ClassOrigin`, `ClassifiedBind`) + `ValidateBackupSpec` (whole-block-reject) + `ClassifyBinds` (two-level default; explicit beats `:ro`; nil spec → legacy/false). - `internal/stacks/classify_binds.go` (NEW) — `ParseComposeClassifiableBinds`: `${VAR}`-relative binds + `:ro` flag, `ParseComposeUserdataMounts` scanner shape, dedupe (first-`:ro`-wins). - `internal/stacks/metadata.go` — `Metadata.Backup` field + the LoadMetadata validation choke point (bad block → nil + one `[ERROR]` → legacy) + `Manager.ClassifiedBinds`. - `internal/appbackup/appdata.go` — `StackDataProvider.GetStackClassifiedBinds`. - `internal/backup/appbackup_bridge.go` — `ClassifiedBind` alias. - `cmd/controller/main.go` — `stackAdapter.GetStackClassifiedBinds` delegation. - 5 test fakes (`recovery_unit_test.go`, `tier2_restore_test.go`, `volume_dumps_test.go`, `api/backup_snapshots_test.go`, `web/async_restore_test.go`) — nil-stub method ONLY (no logic edits). - Tests: `appbackup/classify_test.go`, `stacks/classify_binds_test.go`, `stacks/classify_wiring_test.go`. - Docs: `CHANGELOG.md`, `CONTEXT.md`, `REUSE.md`, `controller/README.md`. **Catalog (`21e8df1`)** — `backup:` block added after `resources:` in 13 `.felhom.yml` files (immich, paperless-ngx, nextcloud, calibre-web, audiobookshelf, komga, navidrome, radarr, sonarr, emby, jellyfin, plex, romm) + `CHANGELOG.md`. ## Tests `go build ./... && go vet ./... && go test ./...` — **all green**, both repos' content parses clean. **+14 test functions** (before → after in the touched packages; a 9-case validation subtable inside): - **Group A (classifier):** `TestClassify_ImmichShape` (explicit beats `:ro`), `_TwoLevelDefault` (unlisted writable→mandatory, `:ro`→excluded), `_NilSpecLegacy`, `_BareRootFallsToDefault`, `_SameRelPathBothRoots`. - **Group B (validation):** `TestValidateBackupSpec_Defects` (9 cases: unknown/empty class, empty/ absolute/`..`/backslash/non-clean path, duplicate, no-matching-bind — each names the entry) + `_ValidAndNil`. - **Group C (parser):** `TestParseClassifiableBinds_Variants` (ro/rw/ro,z/z, dedupe, bare root, both roots, non-volume sections), `_CleanAndSubpaths` (false-prefix rejected), `_Missing`. - **Group D (end-to-end, NO seams):** `TestClassifiedBinds_ValidFixture`, `_RejectedFixture` (RP-4), `_NoBlockLegacy` — real files through the REAL LoadMetadata path. - **Group E (catalog fidelity):** `TestCatalogBlocks_Fidelity` (immich, paperless-ngx, sonarr exact class sets). **Inertness proof (Scenario C):** the entire pre-existing suite is green with **zero test-logic edits** — only the mandated nil-stub method was added to each fake (verified: `+14 −1` across the 5 fake files, the `−1` being a gofmt re-alignment). ### Red-proofs (run → fail → revert → green) — all confirmed | RP | Mutation | Failing assertion | Reverted | |----|----------|-------------------|----------| | RP-1 | disable the class-value check | `TestValidateBackupSpec_Defects/unknown_class` + `/empty_class` accept silently | green | | RP-2 | `:ro` default overrides explicit | `TestClassify_ImmichShape`: media/photos forced excluded | green | | RP-3 | flip unlisted-writable default to excluded | `TestClassify_TwoLevelDefault`: unlisted writable ≠ mandatory | green | | RP-4 | LoadMetadata accepts `Backup` without validating | `TestClassifiedBinds_RejectedFixture`: bad block not degraded to legacy | green | ## Deploy + verify - Built `v0.132.0` on 180 (`build.sh 0.132.0 --push`, image 145M, digest `sha256:00c6f83e…`). - Deployed via the bootstrap mechanism to **BOTH guests** — `felhom-controller:0.132.0 … Up (healthy)` on demo 9201 (felhom-pve) and the drill guest 9201 (nested PVE `drill-day0`, 192.168.0.152). ## Live sync check (demo guest — the real server-side pipeline) Catalog `21e8df1` pushed AFTER the controller was live, then the controller's `doSync` pipeline (same code path as the "Sablonok frissítése" button) was triggered by a bootstrap restart: - `[sync] Updated /.felhom.yml` for **exactly the 13 apps**, then `[sync] Catalog sync complete`. - **`backup block rejected` count: 0.** Zero `[ERROR] [stacks]` lines anywhere in the log. Controller healthy. → **live proof that all 13 committed blocks parse, validate, and match their real composes.** ## 13-app cross-check (shipped parser against the real catalog clone — every bind resolved `explicit`) | App | Resolved class set (root/relpath = class) | |-----|-------------------------------------------| | immich | hdd/appdata/immich=mandatory · userdata/media/photos=optional | | paperless-ngx | hdd/appdata/paperless/media=mandatory · hdd/appdata/paperless/export=excluded · userdata/import/paperless=excluded | | nextcloud | hdd/appdata/nextcloud=mandatory | | calibre-web | userdata/media/books=mandatory · userdata/import/calibre=excluded | | audiobookshelf | userdata/media/audiobooks=optional · userdata/media/podcasts=excluded | | komga | userdata/media/comics=optional | | navidrome | userdata/media/music=excluded | | radarr | userdata/media/movies=excluded · userdata/downloads=excluded | | sonarr | userdata/media/tv=excluded · userdata/downloads=excluded | | emby | userdata/media=excluded | | jellyfin | userdata/media=excluded | | plex | userdata/media=excluded | | romm | userdata/roms=optional · hdd/appdata/romm/resources=excluded | Every bind resolves with origin `explicit` (full coverage — no writable bind left to the mandatory default). Verified via a throwaway `go run` harness over the shipped `LoadMetadata → ParseComposeClassifiableBinds → ClassifyBinds`, then removed (not committed). ## PENDING-VETO resolution **audiobookshelf `media/audiobooks` = `optional`** (spike SQ2 proposed `excluded`). Committed optional for consistency with komga/romm (curated, often personally-ripped → precious-decoupled). If Viktor rules `excluded`, flip that ONE line in `templates/audiobookshelf/.felhom.yml`. ## Observations (noticed, NOT acted on) - The classification is INERT by contract — offsite/tier-2/`.fab` still capture per today's legacy behavior. **Task 3** (tier policy engine) is the next step: consume `Manager.ClassifiedBinds` / `GetStackClassifiedBinds` to scope offsite/tier-2/`.fab` capture by class (mandatory→offsite, excluded→never, optional→customer-selectable). The seam is wired + tested for it. - Recovery units already carry `.felhom.yml` and git-sync already whitelists it, so the block reaches deployed stacks + units with zero plumbing changes; no recovery-unit SchemaVersion bump (Task 3 owns any content-semantics bump). - All 13 catalog apps list every classifiable bind explicitly — the two-level *default* branch (unlisted writable→mandatory) is exercised only by tests today, never by a real catalog app.