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

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:
2026-10-04 07:27:38 +02:00
parent c4bf730637
commit c1a73b24b3
10 changed files with 309 additions and 68 deletions
+84 -12
View File
@@ -52,6 +52,16 @@ func (m *Manager) abandonNow() time.Time {
// SetOffboxClock injects the abandonment clock (tests only).
func (m *Manager) SetOffboxClock(fn func() time.Time) { m.offboxNow = fn }
// OffsiteAbandonClient is the hub's set-aside deletion (decision 74; offsiteapply.HubAbandonClient).
type OffsiteAbandonClient interface {
Request(ctx context.Context, path string) (dueAt time.Time, err error)
Status(ctx context.Context) (state string, err error) // none | pending | cancelled | deleted
Cancel(ctx context.Context) error
}
// SetOffsiteAbandonClient wires the hub's set-aside deletion.
func (m *Manager) SetOffsiteAbandonClient(c OffsiteAbandonClient) { m.offsiteAbandon = c }
// startAbandonCountdown records the decision and the date the terminal step will run. Called by
// resetOrphanedRepo AFTER the move-aside has succeeded — a countdown started before the store has
// actually moved would count down to deleting a path that does not exist.
@@ -90,6 +100,9 @@ type AbandonState struct {
DaysLeft int // ceiling, so "0 days left" only ever means "today"
RepoPath string // the set-aside store awaiting deletion
PurgeRequested bool // the store is gone; awaiting the hub to drop the sealed package
// HubPending / HubDueAt (v0.290.0, decision 74): the deletion is the HUB's, due at HubDueAt.
HubPending bool
HubDueAt time.Time
// RetrievalStillOffered (R-302) — may the banner still say the set-aside copies can be retrieved
// with the recovery code? TRUE only while the hub is holding the SAME sealed package it held when
// the customer decided. Derived here, once, so the banner and anything else asking cannot disagree.
@@ -108,14 +121,25 @@ func (m *Manager) AbandonStatus() AbandonState {
return AbandonState{}
}
st := AbandonState{RepoPath: t.AbandonRepoPath, PurgeRequested: t.AbandonPurgeRequested}
if t.AbandonAt == "" {
if t.AbandonHubDueAt != "" {
if d, err := time.Parse(time.RFC3339, t.AbandonHubDueAt); err == nil {
st.HubPending, st.HubDueAt = true, d
}
}
at := t.AbandonAt
if at == "" && st.HubPending {
// Decision 74: the countdown continues at the HUB — the household sees the hub's date, and the
// deletion it chose is still pending and still cancellable (the page and the banner stay true).
at = t.AbandonHubDueAt
}
if at == "" {
return st
}
due, err := time.Parse(time.RFC3339, t.AbandonAt)
due, err := time.Parse(time.RFC3339, at)
if err != nil {
// A malformed stamp must not silently mean "never due" — that would strand the store for ever
// with a countdown the customer can see and nothing behind it.
m.logger.Printf("[WARN] [offbox] abandonment due-date is unparseable (%q) — treating the countdown as NOT running: %v", t.AbandonAt, err)
m.logger.Printf("[WARN] [offbox] abandonment due-date is unparseable (%q) — treating the countdown as NOT running: %v", at, err)
return st
}
st.Active, st.DueAt = true, due
@@ -148,12 +172,23 @@ func (m *Manager) AbandonStatus() AbandonState {
// only thing that deletes, and it has not run.
func (m *Manager) CancelAbandon(reason string) {
t := m.settings.GetOffboxTarget()
if t == nil || (t.AbandonAt == "" && !t.AbandonPurgeRequested) {
if t == nil || (t.AbandonAt == "" && !t.AbandonPurgeRequested && t.AbandonHubDueAt == "") {
return // nothing running — silent, so a healthy recovery does not log about a countdown
}
if t.AbandonHubDueAt != "" && m.offsiteAbandon != nil {
// Decision 74: the hub holds the request — cancel it there, or the hub deletes on schedule.
cctx, cancel := context.WithTimeout(context.Background(), time.Minute)
if err := m.offsiteAbandon.Cancel(cctx); err != nil {
cancel()
m.logger.Printf("[ERROR] [offbox] could not cancel the hub's pending deletion of %s (%v) — the countdown is kept so the next sweep retries the cancel", t.AbandonRepoPath, err)
return
}
cancel()
}
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonStartedAt, o.AbandonAt = "", ""
o.AbandonPurgeRequested = false
o.AbandonHubDueAt = ""
}); err != nil {
m.logger.Printf("[WARN] [offbox] could not cancel the abandonment countdown: %v", err)
return
@@ -183,6 +218,33 @@ func (m *Manager) AbandonSweep(ctx context.Context) (bool, error) {
m.logger.Printf("[DEBUG] [offbox] abandonment: the set-aside store is deleted; awaiting the hub to drop the sealed package")
return false, nil
}
if st.HubPending && m.offsiteAbandon != nil {
state, err := m.offsiteAbandon.Status(ctx)
if err != nil {
m.logger.Printf("[DEBUG] [offbox] abandonment: hub status unreadable (retried): %v", err)
return false, nil
}
switch state {
case "deleted":
if uerr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonPurgeRequested = true
o.AbandonHubDueAt = ""
}); uerr != nil {
return true, uerr
}
m.logger.Printf("[INFO] [offbox] abandonment: the hub deleted the set-aside copy %s; requesting the sealed package's removal", st.RepoPath)
if m.offboxOrphanEvent != nil {
m.offboxOrphanEvent("offbox_abandon_completed", st.RepoPath)
}
return true, nil
case "cancelled", "none":
_ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonHubDueAt, o.AbandonStartedAt = "", ""
})
m.logger.Printf("[INFO] [offbox] abandonment: the hub's deletion of %s was cancelled — the set-aside copy is kept", st.RepoPath)
}
return false, nil
}
if !st.Active || st.DueAt.After(m.abandonNow()) {
return false, nil // not due — quiet by construction on every healthy box
}
@@ -193,16 +255,26 @@ func (m *Manager) AbandonSweep(ctx context.Context) (bool, error) {
return false, fmt.Errorf("abandonment due with no recorded path")
}
if t.Pinned() {
// Decision 69 (v0.289.0): the box's off-site key is append-only and cannot delete — by design,
// so that a broken-into box cannot erase history. The set-aside copy STAYS; the operator removes
// it (R-823). The schedule is closed so the sweep stops; nothing was deleted.
if uerr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.AbandonAt = "" }); uerr != nil {
m.logger.Printf("[WARN] [offbox] abandonment: could not close the schedule: %v", uerr)
// Decision 74 (v0.290.0): the box's key cannot delete (decision 69), so a due abandonment is a
// REQUEST to the hub, which deletes the set-aside copy after its own delay (7 days) unless the
// household (a recovery here cancels it) or the operator cancels. Two-phase as before: the
// store is gone only when the hub says "deleted"; then the sealed package is asked to go.
if m.offsiteAbandon == nil {
m.logger.Printf("[WARN] [offbox] abandonment DUE for %s but the hub's deletion service is not wired — nothing deleted; retried tomorrow", t.AbandonRepoPath)
return false, nil
}
m.logger.Printf("[WARN] [offbox] abandonment DUE for %s, but the off-site key is append-only (decision 69) — NOTHING deleted; the set-aside copy stays until the operator removes it", t.AbandonRepoPath)
if m.offboxOrphanEvent != nil {
m.offboxOrphanEvent("offbox_abandon_deferred", t.AbandonRepoPath)
due, err := m.offsiteAbandon.Request(ctx, t.AbandonRepoPath)
if err != nil {
m.logger.Printf("[WARN] [offbox] abandonment: handing the deletion of %s to the hub failed (retried tomorrow): %v", t.AbandonRepoPath, err)
return false, err
}
if uerr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonAt = ""
o.AbandonHubDueAt = due.UTC().Format(time.RFC3339)
}); uerr != nil {
return false, uerr
}
m.logger.Printf("[INFO] [offbox] abandonment: the hub deletes the set-aside copy %s at %s unless cancelled (decision 74)", t.AbandonRepoPath, due.UTC().Format(time.RFC3339))
return false, nil
}
port := t.Port