.fab exclusion scoping: classes in the manual export (Task 4, v0.136.0)

SQ6 over-capture FIXED for classified apps: the userdata root tar is exclude-scoped (keeps only
ancestors/descendants of a SELECTED bind relpath — R1-C, the tier2Reconcile keep-rule); no selected
userdata bind → no root tar (radarr state-only). New appbackup.ComputeFabBuckets (shared
resolveGuardCollapse pipeline; class buckets; guards over ALL classes; no cross-bucket containment).
appexport/fabplan.go: computeFabPlan + tarDirectoryExcluding + fabEstimateSplit. ExportRequest gains
DeselectOptional/OptInExcluded (both start handlers — two-call-site); mandatory is a server-side
floor. Manifest v1 + import UNTOUCHED; legacy apps byte-identical to v0.130.0. Estimate additive
class split; export UI: locked-mandatory/optional-checkboxes/excluded-opt-in + two-number warning.
All 6 §10 red-proofs verified. 6D Accept #1 now runs against this shape.
This commit is contained in:
2026-07-15 11:28:40 +02:00
parent 5f216138e5
commit cf9ce01917
19 changed files with 1392 additions and 177 deletions
+84 -31
View File
@@ -79,37 +79,9 @@ func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Capt
}
cs := CaptureSet{HasClassification: true}
// Stage 1+2: tier filter, then structural guards. Survivors are resolved to CapturePath.
var resolved []CapturePath
for _, b := range binds {
if !tierKeeps(tier, b.Class) {
continue // excluded (any tier) or optional@offsite — silently filtered, not skipped
}
if reason, bad := structuralGuard(b.Root, b.RelPath); bad {
cs.Skipped = append(cs.Skipped, SkippedPath{
Root: b.Root, RelPath: b.RelPath, Class: b.Class, Reason: reason,
})
continue
}
resolved = append(resolved, CapturePath{
Abs: resolveAbs(hddPath, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
})
}
// Stage 3: equal-Abs collapse — one entry per Abs, mandatory beats optional (mandatory semantics
// must never degrade). Deterministic on ties: keep the lexicographically-smaller (Root, RelPath).
byAbs := make(map[string]CapturePath, len(resolved))
for _, cp := range resolved {
if cur, ok := byAbs[cp.Abs]; ok {
byAbs[cp.Abs] = strongerCapture(cur, cp)
continue
}
byAbs[cp.Abs] = cp
}
uniq := make([]CapturePath, 0, len(byAbs))
for _, cp := range byAbs {
uniq = append(uniq, cp)
}
// Stages 13: tier filter structural guards → equal-Abs collapse (shared with ComputeFabBuckets).
uniq, skipped := resolveGuardCollapse(binds, hddPath, func(c BindClass) bool { return tierKeeps(tier, c) })
cs.Skipped = skipped
// Stage 4: containment dedup — drop any path whose ancestor is already present (keep the ancestor).
for _, cp := range uniq {
@@ -130,6 +102,87 @@ func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Capt
return cs
}
// resolveGuardCollapse is the pipeline shared by ComputeCaptureSet and ComputeFabBuckets: keep-filter
// (the caller's predicate over class) → structural guards (Skipped) → resolve to Abs → equal-Abs
// collapse (mandatory > optional > excluded; ties by smaller Root/RelPath). It does NOT apply
// containment dedup — the caller decides (ComputeCaptureSet does; ComputeFabBuckets must not, so a
// mandatory child inside an excluded parent stays independently addressable).
func resolveGuardCollapse(binds []ClassifiedBind, hddPath string, keep func(BindClass) bool) (uniq []CapturePath, skipped []SkippedPath) {
var resolved []CapturePath
for _, b := range binds {
if !keep(b.Class) {
continue
}
if reason, bad := structuralGuard(b.Root, b.RelPath); bad {
skipped = append(skipped, SkippedPath{Root: b.Root, RelPath: b.RelPath, Class: b.Class, Reason: reason})
continue
}
resolved = append(resolved, CapturePath{
Abs: resolveAbs(hddPath, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
})
}
byAbs := make(map[string]CapturePath, len(resolved))
for _, cp := range resolved {
if cur, ok := byAbs[cp.Abs]; ok {
byAbs[cp.Abs] = strongerCapture(cur, cp)
continue
}
byAbs[cp.Abs] = cp
}
uniq = make([]CapturePath, 0, len(byAbs))
for _, cp := range byAbs {
uniq = append(uniq, cp)
}
return uniq, skipped
}
// FabBuckets is the class-bucketed capture set for the manual `.fab` export (Task 4). Unlike
// ComputeCaptureSet it keeps ALL classes (structural guards run over every class — a traversal path is
// never plannable, opt-in or not) and does NOT collapse across containment (mandatory `media/books`
// inside excluded `media` both survive, in their own buckets). HasClassification=false ⇒ empty (the
// legacy full-root capture, unchanged).
type FabBuckets struct {
HasClassification bool
Mandatory []CapturePath
Optional []CapturePath
Excluded []CapturePath
Skipped []SkippedPath
}
// ComputeFabBuckets resolves an app's classified binds into per-class buckets for the `.fab` export
// selection UI + plan. Same resolution + structural guards + equal-Abs collapse as ComputeCaptureSet
// (via resolveGuardCollapse), bucketed by class, no cross-bucket containment dedup. Each bucket is
// Abs-sorted (deterministic).
func ComputeFabBuckets(binds []ClassifiedBind, hasClassification bool, hddPath string) FabBuckets {
if !hasClassification {
return FabBuckets{HasClassification: false}
}
fb := FabBuckets{HasClassification: true}
uniq, skipped := resolveGuardCollapse(binds, hddPath, func(BindClass) bool { return true })
fb.Skipped = skipped
for _, cp := range uniq {
switch cp.Class {
case ClassMandatory:
fb.Mandatory = append(fb.Mandatory, cp)
case ClassOptional:
fb.Optional = append(fb.Optional, cp)
default:
fb.Excluded = append(fb.Excluded, cp)
}
}
for _, b := range []*[]CapturePath{&fb.Mandatory, &fb.Optional, &fb.Excluded} {
bk := *b
sort.Slice(bk, func(i, j int) bool { return bk[i].Abs < bk[j].Abs })
}
sort.Slice(fb.Skipped, func(i, j int) bool {
if fb.Skipped[i].Root != fb.Skipped[j].Root {
return fb.Skipped[i].Root < fb.Skipped[j].Root
}
return fb.Skipped[i].RelPath < fb.Skipped[j].RelPath
})
return fb
}
// tierKeeps applies the §2 tier column: mandatory everywhere, optional only for secondary, excluded
// never.
func tierKeeps(tier CaptureTier, class BindClass) bool {