diff --git a/documentation/architecture/07-backup-architecture.md b/documentation/architecture/07-backup-architecture.md index 7a847f5..ee2c898 100644 --- a/documentation/architecture/07-backup-architecture.md +++ b/documentation/architecture/07-backup-architecture.md @@ -103,25 +103,45 @@ Row-level decisions folded in: ## 3. Capture-set computation (Task 3-core) -One pure function, leaf package `appbackup`, consumed by all three engines: +One pure function, leaf package `appbackup`, consumed by all three engines (as-built, v0.133.0): ``` -ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Tier, - hddPath string) CaptureSet +ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, + tier CaptureTier, hddPath string) CaptureSet + → CaptureSet{HasClassification bool; Paths []CapturePath; Skipped []SkippedPath} ``` - Resolves each bind's `(Root, RelPath)` against `hddPath` (HDD) / `hddPath+"/userdata"` - (USERDATA) into absolute paths; filters by the tier column of §2; returns - `{UnitOnly bool, Paths []CapturePath}` where each `CapturePath` carries - `{Abs, Root, RelPath, Class}` (the relpath is what the tier-2 layout and restore need). -- `hasClassification == false` → `UnitOnly` for offsite, resolver-legacy marker for tier-2 — - engines branch on it explicitly, so the legacy path stays byte-identical and testable. -- Pure: no FS, no docker (the Tasks 1/2 leaf-package discipline). The stat-filter (decision 2.5) - lives in the engines, not here. -- Companion red-proofs: (a) sonarr with no block must produce UnitOnly for offsite (the SQ5 - cost-regression guard — this is the test that fails if the two-level default is miswired); - (b) immich's explicit-optional `:ro` external library must appear in tier-2's set and NOT in - offsite's; (c) paperless's `export` must appear in no automatic tier. + (USERDATA) into absolute paths (slash algebra — `path.Join`, never `filepath`); filters by the + tier column of §2 (`TierOffsite` = mandatory only; `TierSecondary` = mandatory + optional; + excluded is silently dropped). Each `CapturePath` carries `{Abs, Root, RelPath, Class}` (the + relpath is what the tier-2 layout and restore need). +- The former `UnitOnly` field is replaced by **`HasClassification`** (it mirrors the classifier's + existing bool); engines derive unit-only as `!HasClassification || len(Paths)==0`. When + `hasClassification == false` the function short-circuits to `{HasClassification:false}` — nothing + resolved, `Paths`/`Skipped` nil — so the legacy path stays byte-identical and testable (the SQ5 + cost-regression guard: an unmigrated app never resolves a bind into an automatic tier). +- **`Skipped`** carries would-be captures the tier filter selected but that are structurally unsafe, + each with an English `Reason`, so the pure function stays log-free (engines log a skipped + **mandatory** as a loud capture GAP). Three structural guards, evaluated after the tier filter: + traversal (a `..` path segment or an absolute RelPath — **load-bearing because the compose parser + path.Cleans but does NOT reject `..`, and `ValidateBackupSpec` only vets *spec* entries, so an + unlisted writable `${HDD_PATH}/../x` bind arrives classed mandatory**), bare HDD drive-root + (RelPath `""` — would nest `/backups` into the capture), and the reserved `backups/` + zone; a bare **userdata** root (`/userdata`) is allowed (it does not nest `backups/`). +- After filtering+guards: an equal-`Abs` collision from two binds collapses to one entry with + **mandatory winning** over optional (mandatory semantics never degrade); then **containment + dedup** drops any path whose ancestor is already in the set (keep the ancestor); output is sorted + by `Abs` (deterministic despite map/slice input). +- `CrossAppOverlaps(map[app]CaptureSet) []Overlap` is shipped here as a **pure** function (exact-Abs + match across ≥2 apps' `Paths`; cross-app *containment* is legitimate per §4 and is NOT an overlap). + Its WARN **wiring** lands in 3a/3b, not in 3-core — no log call sites exist yet. +- Pure: no FS, no docker, no logging (the Tasks 1/2 leaf-package discipline). The stat-filter + (decision 2.5) lives in the engines, not here. +- Companion red-proofs: (a) sonarr with no block must produce unit-only (`HasClassification:false`, + empty `Paths`) for offsite (the SQ5 cost-regression guard — the test that fails if the two-level + default is miswired); (b) immich's explicit-optional `:ro` external library must appear in + tier-2's set and NOT in offsite's; (c) paperless's `export` must appear in no automatic tier. The engines consume the existing wired seam `GetStackClassifiedBinds` (main.go ~L1106) — no new seam. Per the F-S3 lesson, 3-core still ships an end-to-end wiring test through a real Manager.