Backup classification: schema + parser + pure classifier (INERT, v0.132.0)

Task 2 of the backup-classification-redesign arc. Ships the referential-
coupling classification as DATA + PARSER + PURE CLASSIFIER, deliberately
inert — no backup tier changes behavior. Task 3 (tier policy engine) and
Task 4 (manual .fab UI) consume it.

- appbackup/classify.go: BackupSpec/BindSpec/ComposeBind/ClassifiedBind;
  ClassifyBinds (SQ5 two-level default — explicit beats :ro; unlisted
  writable→mandatory, unlisted :ro→excluded; nil spec→legacy/false);
  ValidateBackupSpec (whole-block-reject on any defect, first defect named).
- stacks/classify_binds.go: ParseComposeClassifiableBinds — ${VAR}-relative
  binds + :ro flag (NOT ParseComposeHDDMounts/ExportDataMounts, the traps).
- Metadata.Backup + LoadMetadata as the single validation choke point (bad
  catalog block → nil + one ERROR → legacy, within one sync cycle).
- Manager.ClassifiedBinds + StackDataProvider.GetStackClassifiedBinds seam
  (delegated by stackAdapter, nil-stubbed in every fake) — wired + tested
  now so Task 3 consumes a tested seam.

INERT: full pre-existing suite green with zero test-logic edits. +14 tests;
red-proofs RP-1..RP-4 confirmed. The 13 catalog backup: blocks ship in the
same app-catalog change (this controller deploys first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
This commit is contained in:
2026-07-14 18:46:32 +02:00
parent af98c53c82
commit 0649f9a3e6
18 changed files with 942 additions and 2 deletions
+44
View File
@@ -1,5 +1,49 @@
## Changelog
### v0.132.0 — Backup classification: schema + parser + pure classifier (INERT; Task 2) (2026-07-14)
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. Task 3 (tier policy engine) and Task 4 (manual `.fab` UI) are the
consumers; today only a validation log pass touches it.
- **Schema (`internal/appbackup/classify.go`, new):** `BackupSpec`/`BindSpec` model the `.felhom.yml`
`backup:` block (`userdata:`/`hdd:` lists of `{path, class}`); `ComposeBind` is a `${VAR}`-relative
host bind carrying the `:ro` flag. Classes: **mandatory** (COUPLED — restore-without is
broken-not-empty, SQ3), **optional** (DECOUPLED-precious), **excluded** (DECOUPLED-bulk/transient).
- **Pure classifier `ClassifyBinds`:** the SQ5 **two-level default** — an explicit block entry ALWAYS
wins (an explicit `optional` on immich's `:ro` external library beats the reader default); an
unlisted **writable** bind defaults **mandatory** (the C6B-F1 capture direction, never silent-drop);
an unlisted **`:ro`** bind defaults **excluded** (reader rule). Returns `hasClassification` — **a
nil spec (no block) → every bind is `legacy` with NO class semantics**, so an unmigrated app's
behavior is byte-identical.
- **Validation `ValidateBackupSpec` (whole-block-reject):** ANY defect — unknown/empty class (a typoed
`clas:` key leaves `""`), empty/absolute/`..`/backslash/non-clean path, duplicate `(root, path)`, or
an entry matching NO compose bind (a stale/typoed path must not silently shift the real bind onto
the mandatory default) — rejects the ENTIRE block with the first defect named. Never partial.
- **Parser `ParseComposeClassifiableBinds` (`internal/stacks`):** copies the
`ParseComposeUserdataMounts` scanner shape but stays in `${VAR}`-relative space and preserves `:ro`
(why it does NOT reuse `ParseComposeHDDMounts`, which resolves absolutes and drops the mode). Deduped
on `(root, relpath)`, first-occurrence `:ro` wins; short-syntax only.
- **Integration:** `Metadata` gains `Backup *appbackup.BackupSpec`; `LoadMetadata` is the SINGLE
validation choke point (catalog listing, deployed-stack scan, and git-sync all flow through it, so a
bad catalog push logs `[ERROR] ... backup block rejected in <dir>: <reason>` within one sync cycle
and the app degrades to legacy). `stacks.Manager.ClassifiedBinds` + a new
`StackDataProvider.GetStackClassifiedBinds` seam (delegated by `stackAdapter`, nil-stubbed in every
fake) exist so Task 3 consumes a **wired, end-to-end-tested** seam — the F-S3 lesson that wiring is
where typos hide.
INERT by design: offsite/tier-2/`.fab`/deploy/sync are byte-identical — proven by the entire
pre-existing test suite staying green with **zero test-logic edits** (only mandated nil-stub methods
added to fakes). Recovery units already carry `.felhom.yml` and git-sync already whitelists it, so the
block propagates to deployed stacks + units with zero plumbing changes; no recovery-unit SchemaVersion
bump. The 13 catalog `backup:` blocks ship in the same `app-catalog-felhom.eu` change (this controller
must be live first so the parser validates them on first sync). +14 test functions (Groups AE,
incl. a 9-case validation table); red-proofs RP-1..RP-4 all
confirmed (validation, explicit-beats-ro precedence, capture-default direction, LoadMetadata→validate
wiring). Controller-only; no MinAgent/hub coupling.
### v0.131.0 — F-S2 + F-S3: compose-derived appdata dir resolution (paperless-ngx → appdata/paperless) (2026-07-14)
The controller assumed an app's HDD appdata dir is always `appdata/<stackName>`. paperless-ngx binds