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:
2026-08-07 12:08:11 +02:00
parent de39e47f53
commit 72368654e4
6 changed files with 253 additions and 2 deletions
+9 -1
View File
@@ -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