architecture §3: align capture-set API sketch to Task 3-core as-built

This commit is contained in:
2026-07-14 21:42:10 +02:00
parent b27931216c
commit 8d85da73af
@@ -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 `<hddPath>/backups` into the capture), and the reserved `backups/`
zone; a bare **userdata** root (`<hddPath>/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.