v0.290.0: the clean-up guard skips same-day superseded young snapshots instead of refusing (R-824), refuses above the weekly cap; a due set-aside deletion is handed to the hub's 7-day wait (decision 74, R-823)
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -25,11 +25,9 @@ import (
|
||||
// - the plan would remove a snapshot younger than offsiteGuardMinAge — the honest policy
|
||||
// (--keep-daily 7) never removes the newest snapshot of any of the last 7 days, while a poisoning
|
||||
// shape does exactly that.
|
||||
// And bound the damage of anything the guard cannot see: at most MaxRemove (the hub's number) snapshots
|
||||
// per window, OLDEST first. DISAGREEMENT RECORDED (R-96 rule 4): the brief asked to ABORT when the plan
|
||||
// exceeds a week's removal; the first window after the interim legitimately exceeds it (weeks of
|
||||
// unpruned history), so an abort would never prune at all. Capping and taking the oldest gives the
|
||||
// same bound on loss per window and still converges.
|
||||
// And a plan larger than MaxRemove (the hub's number for one week) REFUSES (v0.290.0, per the 2026-10-04
|
||||
// brief, replacing v0.289's cap). The cost, recorded (R-96 rule 4): after a long gap without windows the
|
||||
// honest backlog exceeds a week and the guard refuses until the operator grants a window by hand — R-833.
|
||||
//
|
||||
// The NAS tier (Transport "") is unchanged: the household's own disk, pruned by the box as before.
|
||||
//
|
||||
@@ -72,13 +70,39 @@ func (m *Manager) SetOffsiteWindowClient(c OffsiteWindowClient) { m.offsiteWindo
|
||||
var retentionPolicy = []string{"--group-by", "host,tags", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "6"}
|
||||
|
||||
type guardSnap struct {
|
||||
ID string `json:"id"`
|
||||
ShortID string `json:"short_id"`
|
||||
Time time.Time `json:"time"`
|
||||
ID string `json:"id"`
|
||||
ShortID string `json:"short_id"`
|
||||
Time time.Time `json:"time"`
|
||||
Hostname string `json:"hostname"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// group is the `--group-by host,tags` key.
|
||||
func (g guardSnap) group() string {
|
||||
t := append([]string{}, g.Tags...)
|
||||
sort.Strings(t)
|
||||
return g.Hostname + "|" + strings.Join(t, ",")
|
||||
}
|
||||
|
||||
// supersededSameDay: a NEWER snapshot of the same group exists on the same UTC day (a manual run after
|
||||
// the night's) — the one benign reason the honest policy removes a young snapshot (R-824, measured on
|
||||
// demo-hp 2026-10-03).
|
||||
func supersededSameDay(s guardSnap, all []guardSnap) bool {
|
||||
day := s.Time.UTC().Format("2006-01-02")
|
||||
for _, o := range all {
|
||||
if o.ID != s.ID && o.group() == s.group() && o.Time.After(s.Time) && o.Time.UTC().Format("2006-01-02") == day {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// offsiteGuard is the PURE decision: from all snapshots and the policy's remove-plan, either the ids to
|
||||
// remove (oldest first, at most maxRemove) or a refusal reason.
|
||||
// remove (oldest first) or a refusal reason. v0.290.0 (R-824): a YOUNG snapshot that a newer same-day
|
||||
// snapshot of its group supersedes is EXCLUDED (kept for a later window, when it is old) instead of
|
||||
// refusing the run — v0.289.x refused every window after any manual run. A young removal WITHOUT that
|
||||
// explanation still refuses: it is the poisoning signature. Future-dated snapshots, snapshots newer than
|
||||
// the hub allows, and a plan larger than a week's removal (maxRemove) refuse.
|
||||
func offsiteGuard(all, plan []guardSnap, now, newestAllowed time.Time, maxRemove int) ([]string, string) {
|
||||
for _, s := range all {
|
||||
if s.Time.After(now.Add(offsiteGuardSkew)) {
|
||||
@@ -88,19 +112,23 @@ func offsiteGuard(all, plan []guardSnap, now, newestAllowed time.Time, maxRemove
|
||||
return nil, fmt.Sprintf("snapshot %s (%s) is newer than the hub allows (%s)", s.ShortID, s.Time.UTC().Format(time.RFC3339), newestAllowed.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
var keep []guardSnap
|
||||
for _, s := range plan {
|
||||
if now.Sub(s.Time) < offsiteGuardMinAge {
|
||||
return nil, fmt.Sprintf("the policy would remove snapshot %s from %s — younger than %d days, which honest retention never does",
|
||||
if supersededSameDay(s, all) {
|
||||
continue // excluded: removed in a later window, once older than offsiteGuardMinAge
|
||||
}
|
||||
return nil, fmt.Sprintf("the policy would remove snapshot %s from %s — younger than %d days and not superseded the same day, which honest retention never does",
|
||||
s.ShortID, s.Time.UTC().Format(time.RFC3339), int(offsiteGuardMinAge.Hours()/24))
|
||||
}
|
||||
keep = append(keep, s)
|
||||
}
|
||||
sorted := append([]guardSnap{}, plan...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Time.Before(sorted[j].Time) })
|
||||
if maxRemove >= 0 && len(sorted) > maxRemove {
|
||||
sorted = sorted[:maxRemove]
|
||||
if maxRemove >= 0 && len(keep) > maxRemove {
|
||||
return nil, fmt.Sprintf("the plan would remove %d snapshots, more than one week's retention may (%d)", len(keep), maxRemove)
|
||||
}
|
||||
ids := make([]string, 0, len(sorted))
|
||||
for _, s := range sorted {
|
||||
sort.Slice(keep, func(i, j int) bool { return keep[i].Time.Before(keep[j].Time) })
|
||||
ids := make([]string, 0, len(keep))
|
||||
for _, s := range keep {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
return ids, ""
|
||||
|
||||
Reference in New Issue
Block a user