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.
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user