From 72368654e42114864a49063a8d42811d30b626fc Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 7 Aug 2026 12:08:11 +0200 Subject: [PATCH] R-241 part 5: escalating reminders, and operator levers for a running countdown REMINDERS (SEC 2.3). The offer epoch now stamps when it began, and the undecided reminder escalates in EMPHASIS at 1, 3, 7 and 14 days. THE READING IS STATED BECAUSE THE SPEC IS AMBIGUOUS, and it is written into the code where it can be corrected. For an ABANDONING box, 5/3/1 are unambiguously days REMAINING before a deletion. An undecided box has no deadline - nothing counts down to anything, because SEC 7.5 deliberately does NOT auto-abandon - so 14/7/3/1 cannot be "remaining" and are taken as days ELAPSED, with the wording firming up rather than the bar appearing and disappearing. If the operator meant something else, one function changes. The stamp is re-set on every entry into the offered state, so a box that settles and is later rebuilt starts its ladder again instead of inheriting an old one. OPERATOR LEVERS (SEC 7.5). --abandon-status, --abandon-extend=N and --abandon-stop on the controller CLI, beside the existing operator subcommands. They exist because the path that ACTUALLY happens is the customer telephoning, and support needs something to press. They live on the CLI and not in the customer UI deliberately: extending a deletion the customer asked for is an operator judgement, and a customer who wants it stopped already has the self-service route - they recover with their code, which cancels it. BOTH REFUSE RATHER THAN NO-OP, in two situations: when no countdown is running, and when the store has already been deleted. A silent success is the thing an operator most easily mistakes for "handled" - they would tell the customer their data was safe when it is gone. Pinned by two tests. --abandon-extend counts from NOW, not from the old due date, and a test proves the old date passes without deleting anything. Green: go build, go vet, go test ./... all pass; controller gates OK. --- controller/cmd/controller/main.go | 52 +++++++++++ controller/internal/backup/offbox_abandon.go | 53 +++++++++++ .../backup/offbox_abandon_r241_test.go | 88 +++++++++++++++++++ controller/internal/settings/settings.go | 10 ++- controller/internal/web/recovery_handlers.go | 46 +++++++++- controller/internal/web/templates/layout.html | 6 ++ 6 files changed, 253 insertions(+), 2 deletions(-) diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 1b5cdc2..04a9df6 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -78,6 +78,12 @@ func main() { recoverOffsiteCheck := flag.Bool("recover-offsite-check", false, "R-200 diagnostic: read the customer recovery code from STDIN, recover the offsite repository password from the hub-held sealed escrow via the agent, and report whether it matches the one on disk — BY HASH. Compares, never installs; writes nothing. Exit 0 = match, 2 = clean mismatch, 1 = a step failed.") recoverOffsiteInstall := flag.Bool("recover-offsite-install", false, "R-200: read the customer recovery code from STDIN, recover the offsite repository password, print both hashes, and — with --confirm-install — PLACE it so the existing repository reopens. Without --confirm-install it is a dry run that writes nothing.") confirmInstall := flag.Bool("confirm-install", false, "Required alongside --recover-offsite-install to actually write the recovered repository password. Deliberately a second invocation so the hashes are seen before any write is possible.") + // §7.5 (R-241): the operator levers for a running abandonment countdown. The automatic 30-day + // ending is deliberately NOT built (R-245) — what is built is the path that actually happens, + // which is the customer getting in touch and support needing something to press. + abandonStatus := flag.Bool("abandon-status", false, "R-241: print the state of this box's off-site abandonment countdown (set-aside path, due date, days left) and exit. Read-only.") + abandonExtend := flag.Int("abandon-extend", 0, "R-241 (operator): extend a running abandonment countdown by N days from now, then exit. Refuses when no countdown is running.") + abandonStop := flag.Bool("abandon-stop", false, "R-241 (operator): stop a running abandonment countdown, then exit. The set-aside history is kept and nothing is deleted. Refuses when no countdown is running.") flag.Parse() if *showVersion { @@ -152,6 +158,52 @@ func main() { })) } + // §7.5 (R-241) — the operator's abandonment levers. Grouped in one block, before the server + // starts, exactly like the other CLI subcommands: each loads config + settings, acts, and exits. + if *abandonStatus || *abandonExtend > 0 || *abandonStop { + cfg, err := config.LoadPermissive(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "abandon: loading config: %v\n", err) + os.Exit(1) + } + lg := log.New(os.Stderr, "", 0) + sett, err := settings.Load(cfg.Paths.DataDir+"/settings.json", lg) + if err != nil { + fmt.Fprintf(os.Stderr, "abandon: loading settings: %v\n", err) + os.Exit(1) + } + mgr := backup.NewManager(cfg, sett, lg) + st := mgr.AbandonStatus() + switch { + case *abandonStop: + if serr := mgr.StopAbandon(); serr != nil { + fmt.Fprintf(os.Stderr, "abandon-stop: %v\n", serr) + os.Exit(2) + } + fmt.Printf("abandonment STOPPED — the set-aside history at %s is kept; nothing was deleted\n", st.RepoPath) + case *abandonExtend > 0: + due, eerr := mgr.ExtendAbandon(*abandonExtend) + if eerr != nil { + fmt.Fprintf(os.Stderr, "abandon-extend: %v\n", eerr) + os.Exit(2) + } + fmt.Printf("abandonment EXTENDED by %d day(s) — the set-aside history at %s is now deleted on %s\n", + *abandonExtend, st.RepoPath, due.Format("2006-01-02")) + default: + if !st.Active && !st.PurgeRequested { + fmt.Println("no abandonment countdown is running on this box") + os.Exit(0) + } + if st.PurgeRequested { + fmt.Printf("set-aside history DELETED at %s; awaiting the hub to drop the sealed package\n", st.RepoPath) + os.Exit(0) + } + fmt.Printf("abandonment countdown RUNNING\n set-aside history : %s\n chosen on : %s\n deleted on : %s\n days left : %d\n", + st.RepoPath, st.StartedAt.Format("2006-01-02"), st.DueAt.Format("2006-01-02"), st.DaysLeft) + } + os.Exit(0) + } + if *printResetCode { cfg, err := config.LoadPermissive(*configPath) if err != nil { diff --git a/controller/internal/backup/offbox_abandon.go b/controller/internal/backup/offbox_abandon.go index bd1ab2f..0a7c0d0 100644 --- a/controller/internal/backup/offbox_abandon.go +++ b/controller/internal/backup/offbox_abandon.go @@ -218,3 +218,56 @@ func (m *Manager) ClearAbandonPurgeIfConfirmed(supersededPresent bool) { } m.logger.Printf("[INFO] [offbox] abandonment COMPLETE — the set-aside history and the sealed package that protected it are both gone; nothing further to ask about") } + +// ── OPERATOR CONTROL (§7.5) ───────────────────────────────────────────────────────────────────── +// +// The automatic 30-day abandonment is deliberately NOT built (see R-245). What IS built is the path +// that actually happens: **the customer gets in touch.** Someone who cannot find their recovery code +// rings support, and support needs something to press — either "give them longer" or "stop it". +// +// Both live on the controller CLI rather than in the customer UI, deliberately: extending a deletion +// the customer asked for is an operator judgement, not a self-service button, and a customer who +// wants it stopped already has the self-service route — they recover with their code, which cancels +// it (Scenario G). + +// ExtendAbandon pushes the terminal step out by `days` from NOW. Returns the new due date. +// +// It refuses when no countdown is running: extending nothing would print a reassuring date for a +// deletion that was never scheduled, which is the kind of comfort this project keeps removing. +func (m *Manager) ExtendAbandon(days int) (time.Time, error) { + if days <= 0 { + return time.Time{}, fmt.Errorf("the extension must be a positive number of days") + } + st := m.AbandonStatus() + if !st.Active { + if st.PurgeRequested { + return time.Time{}, fmt.Errorf("too late: the set-aside history has already been deleted and only the sealed package is still being removed") + } + return time.Time{}, fmt.Errorf("no abandonment countdown is running on this box — nothing to extend") + } + due := m.abandonNow().UTC().AddDate(0, 0, days) + if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.AbandonAt = due.Format(time.RFC3339) + }); err != nil { + return time.Time{}, fmt.Errorf("record the extension: %w", err) + } + m.logger.Printf("[WARN] [offbox] abandonment EXTENDED by an operator: the set-aside history at %s is now deleted on %s (was %s)", + st.RepoPath, due.Format("2006-01-02"), st.DueAt.Format("2006-01-02")) + return due, nil +} + +// StopAbandon cancels the countdown outright — the operator's version of Scenario G, for the +// customer who telephoned instead of finding their code. The set-aside history is kept and nothing +// is deleted; it is `CancelAbandon` with an operator's reason and a refusal when nothing is running, +// so an operator never gets a silent no-op they might read as success. +func (m *Manager) StopAbandon() error { + st := m.AbandonStatus() + if !st.Active { + if st.PurgeRequested { + return fmt.Errorf("too late: the set-aside history has already been deleted") + } + return fmt.Errorf("no abandonment countdown is running on this box — nothing to stop") + } + m.CancelAbandon("stopped by an operator") + return nil +} diff --git a/controller/internal/backup/offbox_abandon_r241_test.go b/controller/internal/backup/offbox_abandon_r241_test.go index 759f2c1..d020907 100644 --- a/controller/internal/backup/offbox_abandon_r241_test.go +++ b/controller/internal/backup/offbox_abandon_r241_test.go @@ -245,3 +245,91 @@ func TestR241_UnclaimedAutoResetStartsNoCountdown(t *testing.T) { t.Fatal("the unclaimed auto-reset must not start a customer abandonment countdown") } } + +// ── §7.5 — THE OPERATOR LEVERS ────────────────────────────────────────────────────────────────── +// +// The automatic 30-day ending is deliberately NOT built (R-245). These are what IS built: the path +// that actually happens is the customer telephoning, and support needs something to press. +func TestR241_OperatorCanExtendARunningCountdown(t *testing.T) { + start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) + m, _, rec := abandonFixture(t, start) + if err := m.ResetOrphanedRepo(context.Background()); err != nil { + t.Fatal(err) + } + day10 := start.AddDate(0, 0, 10) + m.SetOffboxClock(func() time.Time { return day10 }) + + due, err := m.ExtendAbandon(30) + if err != nil { + t.Fatalf("extend: %v", err) + } + if want := day10.AddDate(0, 0, 30); !due.Equal(want) { + t.Errorf("new due = %v, want %v (from NOW, not from the old date)", due, want) + } + // The original date has passed and nothing is deleted, because the extension moved it. + m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) }) + rec.cmds = nil + if deleted, serr := m.AbandonSweep(context.Background()); deleted || serr != nil { + t.Fatalf("an extended countdown must not fire on the old date: deleted=%v err=%v", deleted, serr) + } + if len(rec.cmds) != 0 { + t.Fatalf("nothing may be deleted after an extension, got %v", rec.cmds) + } +} + +func TestR241_OperatorCanStopARunningCountdown(t *testing.T) { + start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) + m, _, rec := abandonFixture(t, start) + if err := m.ResetOrphanedRepo(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.StopAbandon(); err != nil { + t.Fatalf("stop: %v", err) + } + if m.AbandonStatus().Active { + t.Fatal("the countdown must be stopped") + } + m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 90) }) + rec.cmds = nil + if deleted, err := m.AbandonSweep(context.Background()); deleted || err != nil { + t.Fatalf("a stopped countdown must never delete: deleted=%v err=%v", deleted, err) + } + if len(rec.cmds) != 0 { + t.Fatalf("a stopped countdown must issue no remote commands, got %v", rec.cmds) + } +} + +// Both levers REFUSE when nothing is running. A silent no-op is the thing an operator most easily +// mistakes for success — they would tell the customer it was handled. +func TestR241_OperatorLeversRefuseWhenNothingIsRunning(t *testing.T) { + m, _, _ := abandonFixture(t, time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)) + if _, err := m.ExtendAbandon(30); err == nil { + t.Error("extending a countdown that is not running must be an error, never a quiet success") + } + if err := m.StopAbandon(); err == nil { + t.Error("stopping a countdown that is not running must be an error, never a quiet success") + } + if _, err := m.ExtendAbandon(0); err == nil { + t.Error("a non-positive extension must be refused") + } +} + +// Once the store is deleted there is nothing left to extend or stop, and saying otherwise would be +// the worst kind of reassurance: an operator telling a customer their data is safe when it is gone. +func TestR241_OperatorLeversRefuseAfterTheDeletion(t *testing.T) { + start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) + m, _, _ := abandonFixture(t, start) + if err := m.ResetOrphanedRepo(context.Background()); err != nil { + t.Fatal(err) + } + m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) }) + if _, err := m.AbandonSweep(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := m.ExtendAbandon(30); err == nil { + t.Error("extending after the deletion must be refused — there is nothing left to save") + } + if err := m.StopAbandon(); err == nil { + t.Error("stopping after the deletion must be refused — there is nothing left to save") + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 681fef0..398d185 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -137,6 +137,10 @@ type Settings struct { // nothing that can be forgotten to clear (§7.1 condition 2). RecoveryOfferEpoch int `json:"recovery_offer_epoch,omitempty"` RecoveryOfferActive bool `json:"recovery_offer_active,omitempty"` + // RecoveryOfferSince (RFC3339) stamps when the CURRENT epoch began — the anchor the undecided + // reminders escalate against (§2.3). Re-stamped on every entry, so a box that settles and is later + // rebuilt starts its reminder ladder again rather than inheriting an old one. + RecoveryOfferSince string `json:"recovery_offer_since,omitempty"` RecoveryNoticePostponedEpoch int `json:"recovery_notice_postponed_epoch,omitempty"` // RecoveryRemindOptOutEpoch — the customer ticked „ne emlékeztessen újra" in this epoch. // @@ -2185,6 +2189,7 @@ type RecoveryOfferView struct { Active bool PostponedEpoch int OptOutEpoch int + Since string // RFC3339 — when the current epoch began } // GetRecoveryOfferView returns the epoch state in one lock. @@ -2196,6 +2201,7 @@ func (s *Settings) GetRecoveryOfferView() RecoveryOfferView { Active: s.RecoveryOfferActive, PostponedEpoch: s.RecoveryNoticePostponedEpoch, OptOutEpoch: s.RecoveryRemindOptOutEpoch, + Since: s.RecoveryOfferSince, } } @@ -2207,13 +2213,14 @@ func (s *Settings) GetRecoveryOfferView() RecoveryOfferView { // are still in. Re-interrupting them on upgrade would be a regression dressed as a feature, so the // first epoch inherits that choice and the legacy flag is retired. A box that never dismissed keeps // PostponedEpoch 0 and is interrupted, which is correct. -func (s *Settings) SyncRecoveryOfferEpoch(offered bool) (RecoveryOfferView, error) { +func (s *Settings) SyncRecoveryOfferEpoch(offered bool, now time.Time) (RecoveryOfferView, error) { s.mu.Lock() defer s.mu.Unlock() changed := false if offered && !s.RecoveryOfferActive { s.RecoveryOfferActive = true s.RecoveryOfferEpoch++ + s.RecoveryOfferSince = now.UTC().Format(time.RFC3339) if s.RecoveryOfferEpoch == 1 && s.RecoveryNoticePostponed { s.RecoveryNoticePostponedEpoch = 1 // inherit the pre-epoch dismissal, once s.RecoveryNoticePostponed = false @@ -2226,6 +2233,7 @@ func (s *Settings) SyncRecoveryOfferEpoch(offered bool) (RecoveryOfferView, erro view := RecoveryOfferView{ Epoch: s.RecoveryOfferEpoch, Active: s.RecoveryOfferActive, PostponedEpoch: s.RecoveryNoticePostponedEpoch, OptOutEpoch: s.RecoveryRemindOptOutEpoch, + Since: s.RecoveryOfferSince, } if !changed { return view, nil diff --git a/controller/internal/web/recovery_handlers.go b/controller/internal/web/recovery_handlers.go index 6fd1b23..56766a0 100644 --- a/controller/internal/web/recovery_handlers.go +++ b/controller/internal/web/recovery_handlers.go @@ -62,7 +62,7 @@ func (s *Server) recoveryOfferEpoch() settings.RecoveryOfferView { if s.settings == nil { return settings.RecoveryOfferView{} } - v, err := s.settings.SyncRecoveryOfferEpoch(s.recoveryOffer()) + v, err := s.settings.SyncRecoveryOfferEpoch(s.recoveryOffer(), s.recoveryNow()) if err != nil { s.logger.Printf("[WARN] [web] recovery: could not persist the offer epoch: %v", err) } @@ -607,6 +607,17 @@ func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request) if data["CSRFField"] == nil { data["CSRFField"] = s.csrfField(r) } + // §2.3 — THE UNDECIDED LADDER. A customer who never decides is reminded with escalating + // EMPHASIS as the situation ages: marks at 1, 3, 7 and 14 days since this epoch began. + // + // ⚠ THE READING IS STATED BECAUSE THE SPEC IS AMBIGUOUS. For an ABANDONING box, 5/3/1 are + // unambiguously days REMAINING before a deletion. An undecided box has no deadline — nothing is + // counting down to anything, because §7.5 deliberately does NOT auto-abandon — so 14/7/3/1 cannot + // be "remaining" and are taken as days ELAPSED, with the wording escalating rather than the bar + // appearing and disappearing. If the operator meant something else, this is the line to change. + dw := s.recoveryDaysWaiting() + data["RecoveryDaysWaiting"] = dw + data["RecoveryReminderTier"] = RecoveryReminderTier(dw) // While a countdown runs the bar counts it down instead of asking the same question — and the // reminder opt-out is deliberately NOT offered there: a deletion date is not something to silence. if s.backupMgr != nil { @@ -626,3 +637,36 @@ func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request) } } } + +// recoveryDaysWaiting returns whole days since the current offered epoch began (0 when unknown). +// It drives the escalating emphasis of the undecided reminder — see addRecoveryBanner. +func (s *Server) recoveryDaysWaiting() int { + if s.settings == nil { + return 0 + } + v := s.settings.GetRecoveryOfferView() + if v.Since == "" { + return 0 + } + since, err := time.Parse(time.RFC3339, v.Since) + if err != nil { + return 0 // an unparseable stamp means "we do not know", never "it has been ages" + } + d := int(s.recoveryNow().Sub(since) / (24 * time.Hour)) + if d < 0 { + return 0 + } + return d +} + +// RecoveryReminderTier maps days-waiting to the escalation marks in §2.3. It returns the HIGHEST +// mark reached, so the copy can firm up without the bar flickering in and out. +func RecoveryReminderTier(daysWaiting int) int { + tier := 0 + for _, mark := range []int{1, 3, 7, 14} { + if daysWaiting >= mark { + tier = mark + } + } + return tier +} diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index 1543ad9..fd37de3 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -141,6 +141,12 @@ {{if .RecoveryAbandonDays}} A korábbi távoli mentéseidet {{.RecoveryAbandonDays}} nap múlva ({{.RecoveryAbandonDate}}) véglegesen töröljük, a kérésed szerint. Addig még visszaszerezheted őket a helyreállítási kóddal. + {{else if ge .RecoveryReminderTier 14}} + Két hete várnak rád a korábbi távoli mentéseid, és még nem adtad meg a helyreállítási kódodat. Amíg nem teszed, ezekhez a mentésekhez nem férsz hozzá. + {{else if ge .RecoveryReminderTier 7}} + Már egy hete megvannak a korábbi távoli mentéseid, de a helyreállítási kódod nélkül nem tudjuk megnyitni őket. + {{else if ge .RecoveryReminderTier 3}} + A korábbi távoli mentéseid megvannak — a megnyitásukhoz a helyreállítási kódod szükséges. {{else}} A korábbi távoli mentéseid megvannak, de ehhez a géphez a helyreállítási kódod szükséges. {{end}}