# REPORT — Tier-2 engine rework (Task 3b) — controller v0.135.0 ## Summary Task 3b: the tier-2 column of the matrix (architecture §2/§8). The tier-2 appdata leg becomes **class-driven** for classified apps (per-bind mandatory + optional; paperless's copy shrinks as `export` drops out), legacy apps keep a byte-identical resolver set — both mapped into a new **v2 relpath-mirroring layout** that lifts the N>1 refusal structurally and gains a reconcile step; the SSD fallback is an enforced **state-only** tier; and `selectTier2Target` gains the F-6C-1 **network-target exclusion** (pinned + auto). Restore reads v2 behind a marker gate. Plus **Part 0** (persisted prefs seed — fixes the 3a-fix un-disableable checkbox) and **Part 0.5** (offsite scratch prefers local). Every destructive write lands only under `backups/secondary//` (code-asserted). ## Baselines (live-verified at session start) | Repo | `main` @ start | Version | → | |---|---|---|---| | felhom-controller | `1245a6c` | v0.134.1 | **v0.135.0** | ## Files - **new** `internal/backup/tier2_capture.go` — `tier2CaptureSet` (TierSecondary legs + gaps), `tier2Leg`, `tier2DestRel`. - **mod** `internal/backup/tier2.go` — `selectTier2Target` (network exclusion, two sizes, `StateOnly`), `RunTier2` v2 body (migration/mirror/reconcile/marker-last), `tier2SafeRemove` + `tier2Reconcile` + `classifyTier2Rel`, `Tier2Info` rewired, header + `RunTier2` comments truthful; deleted `errTier2MultiDir`/`tier2AppDataName`. - **mod** `internal/backup/tier2_restore.go` — v2 marker gate + two-subtree missing-only merge; deleted `errTier2MultiDirRestore`. - **mod** `internal/backup/offbox_restore.go` — Part 0.5 scratch local-preference. - **mod** `internal/backup/backup.go` — `tier2SSDFits` seam. - **mod** `internal/backup/appbackup_bridge.go` — `ClassMandatory/Optional/Excluded` aliases. - **mod** `internal/settings/settings.go` — Part 0 seed (`OffboxEnlargeNoticeSeeded` + `seedOffboxEnlargeNotice` at Load; getter append removed); `CrossDriveBackup.LastWarning`. - **mod** `internal/web/handlers.go` + `templates/backups_apps.html` — surface `Tier2LastWarning`. - **tests** new `tier2_v2_test.go`, `offbox_scratch_test.go`; rewrote `notif_migration_test.go` (Part 0), `tier2_restore_test.go` fixture (v2), `async_restore_test.go` fixture (v2), `tier2_test.go` signatures; **deleted** obsolete `tier2_appdata_test.go`. - CHANGELOG / REPORT / CONTEXT / README. Untouched: offbox engine (except `offboxRestoreScratchDir`), appexport, hub, agent; `rsyncMirror`/`rsyncRestoreMissing` unchanged. ## Tests — results `go build ./... && go vet ./... && go test ./...` — **green.** New: v2 suite (§7 A–H + reconcile keep/remove + safe-remove boundary proof + `classifyTier2Rel` table), Part 0 (idempotent seed + opt-out-sticks + fresh-customer), Part 0.5 (local-preference + network-only WARN). ### §10 red-proofs (mutate → FAIL → revert), all verified | ID | Mutation | Test | |---|---|---| | A | classified app routed to the legacy resolver | `ClassifiedPaperless` (`export` appears in dest) | | B | legacy routed through ComputeCaptureSet | `LegacyMultiDir` (empty legs) | | C | marker written before the legs | `MigrationAndMarkerLast` (marker present after a leg failure) | | D-reconcile | disable stale detection | `Reconcile` (stale dir survives) | | D-boundary | neuter the safe-remove prefix assert | `Reconcile` (removal outside destBase not refused) | | E | SSD copies optional (no state-only filter) | `SSDStateOnly` (optional on the SSD) | | F1 | drop the pinned IsNetwork check | `NetworkExclusion/pinned` (NAS pin honored) | | F2 | drop the auto IsNetwork check (NFS iterated first) | `NetworkExclusion/auto` (NAS selected) | | G2 | neuter the restore marker gate | `RestoreRefusesOldLayout` (merge runs on old layout) | | Part 0 | don't persist the seed marker | `SeedOffboxEnlargeNotice_OptOutSticks` (type re-enabled) | All reverted; post-revert full suite green; no residue. ## Deploy / verify Built + pushed `felhom-controller:0.135.0` on 180; deployed to guest 9201 → `Up (healthy)`. Commit `3603d1f`. ## §13 live validation - **Leg 0 (pre-deploy diagnose) — the prompt's expected cause was WRONG.** The nightly `crossdrive_failed` was **calibre-web** with `LastError = "mkdir …/teszt_enroll/backups/secondary/ calibre-web/recovery-unit…"`. teszt_enroll is a **local** ext4 drive, now mounted + writable — the failure was a **transient disconnect** at run time, **not F-6C-1**. Confirmed read-only that **no app auto-targets the `nas-media` NFS path** (all dests are felhom-usb / teszt_enroll). So F-6C-1 is not the demo's failure cause. - **Leg 1 — classified v2 tree (calibre-web):** `Tier 2 copied calibre-web → teszt_enroll (258.4 MB, 1 leg, 33s)`; dest tree = `.felhom-tier2-layout`=**2** + `recovery-unit/` + the class-driven **`userdata/media/books/`** leg (its mandatory bind — not the whole userdata, no flat appdata). Also the leg-0 before/after: calibre-web was `error` → now `ok`. Migration clean (no old flat `appdata/`). - **Leg 2 — v2 layout (radarr):** marker=2 + `recovery-unit/` only, **0 legs** (radarr is classified all-excluded → correctly unit-only). Migration clean. - **Leg 3 — NAS exclusion:** read-only confirmed no app targets the nas-media NFS path (leg 0); pinned + auto exclusion is unit-tested (F1/F2 red-proofs) and live in the deployed code. - **Leg 4 — restore round-trip (calibre-web):** planted a sentinel in live userdata → tier-2 copied it to the v2 secondary (`userdata/media/books/.felhom-t2probe`) → deleted it live → `RestoreTier2Files` → **restored** (the v2 `userdata/` subtree missing-only merge, end-to-end). Sentinel cleaned up. - **Leg 5 — Part 0 opt-out sticks (LIVE):** unchecked `offbox_enlarge_blocked` + saved + **restarted the controller** → the checkbox is **still unchecked** and the stored `enabled_events` count is 0 — the seed did NOT re-enable it (vs the 3a-fix getter-append bug). Pref restored afterward. - **Leg 6 — customer-email diagnosis:** the controller's `notifications.email` is **EMPTY** (synced as `email=` to the hub), so the dispatcher has no customer address — the synthetic event's operator-only delivery is **expected, not a bug** (the customer whitelist has the type, the hub accepts it). Fix = configure a customer email (operator/customer action); **no hub code change** (flagged per §13-6). - **Leg 7 — NAS hygiene:** removed the empty F-6C-1-era `nas-media/backups/secondary/` dir (rmdir; nas-media/backups/ now holds only `primary/` + `offsite-restore/`); the NAS is never a tier-2 target again. ## 6D-pending (shrunk) - `PlaceOffsiteRestore` against live data; organic enlarge-block firing; the SQ3 immich offsite-only full circle (all from Task 3a — unchanged by 3b). - A pure **legacy (no-block)** app's live tier-2 `hdd/appdata/` leg — not found on the demo (all toggled apps carry classification blocks); covered by unit tests (`LegacyMultiDir`). ## Observations - The `Tier2LastWarning` surfacing is minimal (one line on the app's tier-2 row); the SSD state-only hint was already rendered via `EffectiveIsSSD`/`EffectiveDesc` (no new template needed there). - `tier2SSDFits` seam added because `system.GetDiskUsage` is Linux-only (nil on the Windows test host) — production uses the real predicate. - **Leg-0 finding contradicts the prompt:** the demo's nightly `crossdrive_failed` is a transient teszt_enroll mkdir failure, not F-6C-1 (no app auto-targets the NFS path). Recorded, not acted on. - `nas-media/backups/offsite-restore/` (Jul 15) remains — a pre-Part-0.5 offsite scratch on the network drive (3a-fix era). Out of leg-7 scope (that leg is tier-2 `backups/secondary/` residue); Part 0.5 prevents NEW offsite scratch landing there. Noted for a future offsite-scratch hygiene pass. - Leg 6: the demo customer has no notification email configured — the customer-email delivery leg is blocked on that config gap (not code); setting a customer email + re-firing one synthetic event would complete it.