R-241 part 3: abandoning starts a 14-day countdown that ends the question
Until now "set aside" renamed the remote store and touched neither the escrow
nor the key, so the hub went on holding a sealed package for a key the box no
longer used. Shape (c) compares those two, finds them different, and offers
recovery - correctly, and for ever. A customer who had already said "I do not
want the old data" would be asked again at every login.
The operator's ruling is that the answer is NOT a "they decided" flag: fix the
state, do not remember that it is wrong. So the decision starts a countdown,
at the end of which the set-aside store and the sealed package that protects
it are removed TOGETHER. Afterwards shape (c) has nothing to compare and the
offer falls silent on its own - because the state is right, not because
something remembers it once was not.
THE GRACE IS REAL. The recovery offer stays reachable for the whole 14 days;
that is the change-of-mind path, and a grace in which recovery is impossible
would be decorative.
BOTH HALVES OR NEITHER. Removing only the store leaves a package that opens
nothing; removing only the package leaves ciphertext nobody can ever decrypt.
The two cannot be atomic across two machines, so it is a two-phase commit:
delete the store, record a durable marker, and keep DECLARING
offsite.abandon_purge_requested until the hub's ACK stops reporting a
superseded package. A crash between the halves re-declares on the next sweep;
it never leaves the pair half-removed and silent.
HUB HALF - SEC 8.2 ANSWERED: yes, the hub was needed, and only for this.
store.PurgeSupersededEscrowForCustomer is the one place R-198's retention is
ever undone, and it never touches host_escrow (the package covering the key
the box uses now). The handler acts on the DECLARATION, never an inference,
and is placed immediately BEFORE the ACK is built - so
GetEscrowStatusForCustomer reads the effect and the SAME response closes the
box's two-phase commit. No second round-trip and no window where the box
thinks it is still owed. felhom-agent was NOT touched.
The countdown starts in ResetOrphanedRepo, NOT in the shared helper: the
helper is also the unclaimed auto-reset path, where nobody decided anything,
and an as-delivered box tidying a stranger's leftover store must not get a
customer's deletion clock. Pinned by a test.
Cancellation is wired into the recovery unlock, BEFORE the tier-up and the
listing - those can fail, and a countdown surviving a successful unlock
because a later step errored would delete the history the customer just
proved they can open.
The sweep is a Daily job at 05:10, not on the backup leg: it must run on a box
whose tier is not configured for runs. Quiet by construction on every box with
no countdown, and that silence is asserted.
Tests (all clock-injected; SEC 7.4 forbids shortening a live timer):
Scenario E (aside + package kept + countdown + offer still reachable, and
NOTHING deleted), Scenario F (both halves, the declaration repeating, the
close-out), Scenario G (cancel, path still nameable, no later deletion),
plus: not closed out while the package remains, a transport failure leaves the
countdown due and retrying, the no-op sweep issues zero remote commands, and
the unclaimed auto-reset starts no countdown.
RED-PROOFS, each with the mutation confirmed present in the file first:
F1) store deletion skipped -> Scenario F FAILS (no rm issued)
F2) declaration dropped from the report -> Scenario F FAILS (the hub is
never asked; the package would outlive the store for ever)
G) CancelAbandon made a no-op -> Scenario G FAILS (uncancellable countdown)
Green: controller and hub both build, vet and test clean; controller gates OK.
NOTHING WAS DELETED ANYWHERE - the terminal step has only ever run against
in-test fakes.
This commit is contained in:
@@ -677,7 +677,17 @@ func main() {
|
|||||||
RecordPresence: sett.SetHubEscrowIdentityPresent,
|
RecordPresence: sett.SetHubEscrowIdentityPresent,
|
||||||
// v0.201.0 (R-222): whether the hub is ALSO keeping an EARLIER sealed package, so the
|
// v0.201.0 (R-222): whether the hub is ALSO keeping an EARLIER sealed package, so the
|
||||||
// recovery screen can name that situation instead of blaming the customer's typing.
|
// recovery screen can name that situation instead of blaming the customer's typing.
|
||||||
RecordSuperseded: sett.SetHubEscrowSuperseded,
|
// v0.206.0 (R-241): the superseded fact ALSO closes out an abandonment. When the hub stops
|
||||||
|
// reporting a superseded package, the sealed package that protected the deleted set-aside
|
||||||
|
// history is gone too — both halves are away and the question is finished (Scenario F).
|
||||||
|
// Chained onto the existing recorder rather than added as a second ACK consumer, for the
|
||||||
|
// reason RecordPresence's own comment gives.
|
||||||
|
RecordSuperseded: func(present bool, at string) error {
|
||||||
|
if backupMgr != nil {
|
||||||
|
backupMgr.ClearAbandonPurgeIfConfirmed(present)
|
||||||
|
}
|
||||||
|
return sett.SetHubEscrowSuperseded(present, at)
|
||||||
|
},
|
||||||
// v0.206.0 (R-241): the hash the hub's package COVERS. This is the fact the recovery
|
// v0.206.0 (R-241): the hash the hub's package COVERS. This is the fact the recovery
|
||||||
// screen's shape (c) reads, and the comparison against it was already being computed here
|
// screen's shape (c) reads, and the comparison against it was already being computed here
|
||||||
// on every ACK and discarded. Wired at the same point as the two above, deliberately —
|
// on every ACK and discarded. Wired at the same point as the two above, deliberately —
|
||||||
@@ -937,6 +947,12 @@ func main() {
|
|||||||
case "offbox_repo_reset":
|
case "offbox_repo_reset":
|
||||||
notifier.PushEvent("offbox_repo_reset", "info",
|
notifier.PushEvent("offbox_repo_reset", "info",
|
||||||
"A távoli mentési tároló visszaállítva: a régi előzmény félretéve (nem törölve), és egy üres, új tároló jött létre a mostani kulccsal.", map[string]string{"renamed_to": renamedTo})
|
"A távoli mentési tároló visszaállítva: a régi előzmény félretéve (nem törölve), és egy üres, új tároló jött létre a mostani kulccsal.", map[string]string{"renamed_to": renamedTo})
|
||||||
|
case "offbox_abandon_completed":
|
||||||
|
// R-241: the ONLY event in the product that reports a customer's off-site history
|
||||||
|
// being deleted. It is fired after the deletion, not before — the operator wants to
|
||||||
|
// know it happened, and a pre-announcement that then fails would be worse than silence.
|
||||||
|
notifier.PushEvent("offbox_abandon_completed", "info",
|
||||||
|
"A korábbi távoli mentések a türelmi idő lejártával törlésre kerültek, az ügyfél döntése alapján. A hozzájuk tartozó lezárt helyreállítási csomag eltávolítását is kértük.", map[string]string{"deleted_path": renamedTo})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
sched.Daily("offbox-backup", offboxLeg, func(ctx context.Context) error {
|
sched.Daily("offbox-backup", offboxLeg, func(ctx context.Context) error {
|
||||||
@@ -946,6 +962,26 @@ func main() {
|
|||||||
}
|
}
|
||||||
return backupMgr.RunOffboxBackup(ctx)
|
return backupMgr.RunOffboxBackup(ctx)
|
||||||
})
|
})
|
||||||
|
// R-241 — the abandonment terminal step. DAILY and not on the backup leg, deliberately: it must
|
||||||
|
// run on a box whose off-site tier is NOT configured for runs (an abandoning box may be sitting
|
||||||
|
// with escrow pending), and tying it to the backup leg would make the deletion depend on a
|
||||||
|
// condition that has nothing to do with it.
|
||||||
|
//
|
||||||
|
// It is quiet by construction: on every box with no countdown it returns immediately and logs
|
||||||
|
// nothing, which is asserted (TestR241_Sweep_QuietWhenNothingDue).
|
||||||
|
sched.Daily("offsite-abandon-sweep", "05:10", func(ctx context.Context) error {
|
||||||
|
deleted, err := backupMgr.AbandonSweep(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// NOT a job failure: the countdown stays due and tomorrow's sweep retries. A transport
|
||||||
|
// blip must never silently abandon the abandonment.
|
||||||
|
logger.Printf("[WARN] [offbox] abandonment sweep: %v (the countdown stays due and retries)", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if deleted {
|
||||||
|
logger.Printf("[INFO] [offbox] abandonment sweep: the set-aside history was deleted this cycle")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics prune — daily at 04:00
|
// Metrics prune — daily at 04:00
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ type Manager struct {
|
|||||||
// offboxSizer (3a) — the mandatory-set byte estimator for the pre-push enlargement gate, overridable
|
// offboxSizer (3a) — the mandatory-set byte estimator for the pre-push enlargement gate, overridable
|
||||||
// in tests so the gate is unit-testable without a real du. Nil → the real dirSizeBytes (du -sb).
|
// in tests so the gate is unit-testable without a real du. Nil → the real dirSizeBytes (du -sb).
|
||||||
offboxSizer func(path string) int64
|
offboxSizer func(path string) int64
|
||||||
|
// offboxNow (v0.206.0, R-241) is the abandonment countdown's clock. Nil → time.Now.
|
||||||
|
//
|
||||||
|
// IT EXISTS SO THE TERMINAL STEP IS TESTABLE WITHOUT SHORTENING A LIVE TIMER (§7.4). The sweep is
|
||||||
|
// the only thing in the product that deletes a customer's off-site history; driving it with a
|
||||||
|
// clock keeps that step exercised on every run of the suite instead of once, on real data, by an
|
||||||
|
// operator who then has to hope.
|
||||||
|
offboxNow func() time.Time
|
||||||
// offboxEnlargeBlockedNotify (3a), if set, is called ONCE per app that NEWLY enters the
|
// offboxEnlargeBlockedNotify (3a), if set, is called ONCE per app that NEWLY enters the
|
||||||
// quota-blocked (enlargement-refused) state — edge-triggered against the persisted EnlargedBlocked
|
// quota-blocked (enlargement-refused) state — edge-triggered against the persisted EnlargedBlocked
|
||||||
// set so a nightly schedule can't re-notify a persistently-blocked app (the hub owns cooldown; the
|
// set so a nightly schedule can't re-notify a persistently-blocked app (the hub owns cooldown; the
|
||||||
|
|||||||
@@ -344,7 +344,21 @@ func (m *Manager) ResetOrphanedRepo(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
t := m.settings.GetOffboxTarget()
|
t := m.settings.GetOffboxTarget()
|
||||||
base, env := m.offboxBaseArgs(t)
|
base, env := m.offboxBaseArgs(t)
|
||||||
return m.resetOrphanedRepo(ctx, base, env, "operator-confirmed (claimed)")
|
if err := m.resetOrphanedRepo(ctx, base, env, "operator-confirmed (claimed)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// R-241: THE COUNTDOWN STARTS HERE AND NOT IN THE SHARED HELPER, deliberately. The helper is also
|
||||||
|
// the UNCLAIMED auto-reset path (Scenario B in ensureOffboxRepo), where nobody decided anything —
|
||||||
|
// an as-delivered box tidying a stranger's leftover store must not put a customer's 14-day
|
||||||
|
// deletion clock on it. Only the confirmed, claimed choice is a decision.
|
||||||
|
//
|
||||||
|
// The path is read back from OrphanedRenamedTo, which the helper has just written.
|
||||||
|
if cur := m.settings.GetOffboxTarget(); cur != nil && cur.OrphanedRenamedTo != "" {
|
||||||
|
m.startAbandonCountdown(cur.OrphanedRenamedTo)
|
||||||
|
} else {
|
||||||
|
m.logger.Printf("[WARN] [offbox] the reset succeeded but no set-aside path was recorded — no countdown started; the old history stays indefinitely")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// shellQuote single-quotes a path for the remote shell (our repo paths have no single quotes).
|
// shellQuote single-quotes a path for the remote shell (our repo paths have no single quotes).
|
||||||
@@ -1406,6 +1420,15 @@ type OffboxReportStatus struct {
|
|||||||
// rebuilt-and-stranded — and the hub cannot tell them apart. The BOX can, from two local facts it
|
// rebuilt-and-stranded — and the hub cannot tell them apart. The BOX can, from two local facts it
|
||||||
// holds with certainty. So it says so.
|
// holds with certainty. So it says so.
|
||||||
State string `json:"state,omitempty"`
|
State string `json:"state,omitempty"`
|
||||||
|
// AbandonPurgeRequested (v0.206.0, R-241) — the customer's abandonment countdown has run out, the
|
||||||
|
// set-aside off-site history HAS been deleted, and the hub is asked to drop the sealed package
|
||||||
|
// that protected it so the two halves go together (Scenario F).
|
||||||
|
//
|
||||||
|
// It is a DECLARATION, on the same principle as State: the box knows it has deleted the store; the
|
||||||
|
// hub cannot see that and must not infer it. It keeps being sent until the ACK stops reporting a
|
||||||
|
// superseded package, so a lost request retries by itself rather than leaving the pair half-gone.
|
||||||
|
// Absent/false on every other box, so a healthy report is byte-identical to v0.205.0's.
|
||||||
|
AbandonPurgeRequested bool `json:"abandon_purge_requested,omitempty"`
|
||||||
LastRun string `json:"last_run,omitempty"` // RFC3339
|
LastRun string `json:"last_run,omitempty"` // RFC3339
|
||||||
LastStatus string `json:"last_status,omitempty"` // "ok" | "incomplete" (R-203) | "error" | "running"
|
LastStatus string `json:"last_status,omitempty"` // "ok" | "incomplete" (R-203) | "error" | "running"
|
||||||
// LastSuccess (R-100) is the last run that SUCCEEDED — the hub's staleness anchor. Absent on a
|
// LastSuccess (R-100) is the last run that SUCCEEDED — the hub's staleness anchor. Absent on a
|
||||||
@@ -1605,6 +1628,7 @@ func (m *Manager) OffboxReportStatus() *OffboxReportStatus {
|
|||||||
Enabled: true, EscrowState: t.EscrowState, LastRun: t.LastRun, LastStatus: t.LastStatus,
|
Enabled: true, EscrowState: t.EscrowState, LastRun: t.LastRun, LastStatus: t.LastStatus,
|
||||||
LastSuccess: t.LastSuccess,
|
LastSuccess: t.LastSuccess,
|
||||||
SnapshotCount: t.SnapshotCount, RepoSizeBytes: t.RepoSizeBytes, QuotaGB: t.QuotaGB,
|
SnapshotCount: t.SnapshotCount, RepoSizeBytes: t.RepoSizeBytes, QuotaGB: t.QuotaGB,
|
||||||
|
AbandonPurgeRequested: t.AbandonPurgeRequested, // R-241: declared until the hub drops the package
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ABANDONMENT — deciding to give up the old off-site history is a finishable thing (R-241, v0.206.0).
|
||||||
|
//
|
||||||
|
// THE PROBLEM THIS SOLVES. `resetOrphanedRepo` renamed the remote store aside and touched neither the
|
||||||
|
// escrow nor the key, so the hub went on holding a sealed package for a key the box no longer used.
|
||||||
|
// Shape (c) compares those two, finds them different, and offers recovery — correctly, and for ever.
|
||||||
|
// A customer who has already said "I do not want the old data" would be asked again at every login.
|
||||||
|
//
|
||||||
|
// THE OPERATOR'S RULING (2026-08-07) is that the answer is NOT a "they decided" flag. A flag would
|
||||||
|
// leave the box in a state that is genuinely wrong (the hub holding a package for a key nobody uses)
|
||||||
|
// and paper over it. Instead the decision starts a **14-day countdown**, at the end of which the
|
||||||
|
// set-aside store and the sealed package that protects it are removed TOGETHER — after which there is
|
||||||
|
// nothing left to compare and nothing left to ask about. **Fix the state, do not remember that it is
|
||||||
|
// wrong.**
|
||||||
|
//
|
||||||
|
// THE GRACE IS REAL, NOT DECORATIVE. The recovery offer stays reachable for the whole window; that is
|
||||||
|
// the change-of-mind path (Scenario G). A grace period during which recovery is impossible would be
|
||||||
|
// theatre.
|
||||||
|
|
||||||
|
// abandonGraceDays is the countdown the operator set. Reminders fire at 5, 3 and 1 days (see
|
||||||
|
// AbandonRemindAtDays) — visible, reversible, and running out in public.
|
||||||
|
const abandonGraceDays = 14
|
||||||
|
|
||||||
|
// AbandonRemindAtDays are the remaining-day marks at which the abandoning box reminds the customer.
|
||||||
|
// Descending, so the surface can pick the first one that has been reached.
|
||||||
|
var AbandonRemindAtDays = []int{5, 3, 1}
|
||||||
|
|
||||||
|
// abandonNow is the countdown's clock seam. Tests inject; nil → time.Now. It exists so the terminal
|
||||||
|
// step can be driven deterministically — §7.4 forbids shortening a live timer to watch it fire,
|
||||||
|
// because that is how an irreversible step gets tested once and regretted once.
|
||||||
|
func (m *Manager) abandonNow() time.Time {
|
||||||
|
if m.offboxNow != nil {
|
||||||
|
return m.offboxNow()
|
||||||
|
}
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOffboxClock injects the abandonment clock (tests only).
|
||||||
|
func (m *Manager) SetOffboxClock(fn func() time.Time) { m.offboxNow = fn }
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (m *Manager) startAbandonCountdown(setAsidePath string) {
|
||||||
|
now := m.abandonNow().UTC()
|
||||||
|
due := now.AddDate(0, 0, abandonGraceDays)
|
||||||
|
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||||
|
o.AbandonStartedAt = now.Format(time.RFC3339)
|
||||||
|
o.AbandonAt = due.Format(time.RFC3339)
|
||||||
|
o.AbandonRepoPath = setAsidePath
|
||||||
|
o.AbandonPurgeRequested = false
|
||||||
|
}); err != nil {
|
||||||
|
m.logger.Printf("[WARN] [offbox] could not record the abandonment countdown: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.logger.Printf("[INFO] [offbox] abandonment countdown started: the set-aside history at %s and the hub's sealed package "+
|
||||||
|
"are removed together on %s (%d days). The recovery screen stays reachable until then.",
|
||||||
|
setAsidePath, due.Format("2006-01-02"), abandonGraceDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbandonState is the surface's read model. Zero value = nothing in progress.
|
||||||
|
type AbandonState struct {
|
||||||
|
Active bool // a countdown is running
|
||||||
|
StartedAt time.Time //
|
||||||
|
DueAt time.Time // when the terminal step runs
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbandonStatus reports the countdown for the UI and the report. It never mutates.
|
||||||
|
func (m *Manager) AbandonStatus() AbandonState {
|
||||||
|
t := m.settings.GetOffboxTarget()
|
||||||
|
if t == nil {
|
||||||
|
return AbandonState{}
|
||||||
|
}
|
||||||
|
st := AbandonState{RepoPath: t.AbandonRepoPath, PurgeRequested: t.AbandonPurgeRequested}
|
||||||
|
if t.AbandonAt == "" {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
due, err := time.Parse(time.RFC3339, t.AbandonAt)
|
||||||
|
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)
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
st.Active, st.DueAt = true, due
|
||||||
|
if s, serr := time.Parse(time.RFC3339, t.AbandonStartedAt); serr == nil {
|
||||||
|
st.StartedAt = s
|
||||||
|
}
|
||||||
|
// Ceiling: a countdown with 30 minutes left says "1 day", never "0". Zero is reserved for due.
|
||||||
|
remaining := due.Sub(m.abandonNow())
|
||||||
|
if remaining <= 0 {
|
||||||
|
st.DaysLeft = 0
|
||||||
|
} else {
|
||||||
|
st.DaysLeft = int((remaining + 24*time.Hour - time.Nanosecond) / (24 * time.Hour))
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelAbandon stops a running countdown — the change-of-mind path (Scenario G). Called when a
|
||||||
|
// recovery succeeds: the customer has their code after all, and the history they were about to give
|
||||||
|
// up is exactly what the code opens.
|
||||||
|
//
|
||||||
|
// It clears the schedule but KEEPS AbandonRepoPath, so the set-aside store remains nameable on the
|
||||||
|
// backups page. Nothing has been deleted at this point by construction — the terminal step is the
|
||||||
|
// 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) {
|
||||||
|
return // nothing running — silent, so a healthy recovery does not log about a countdown
|
||||||
|
}
|
||||||
|
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||||
|
o.AbandonStartedAt, o.AbandonAt = "", ""
|
||||||
|
o.AbandonPurgeRequested = false
|
||||||
|
}); err != nil {
|
||||||
|
m.logger.Printf("[WARN] [offbox] could not cancel the abandonment countdown: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.logger.Printf("[INFO] [offbox] abandonment countdown CANCELLED (%s) — the set-aside history at %s is kept and nothing was deleted", reason, t.AbandonRepoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbandonSweep is the daily terminal step. It is the ONLY thing in the product that deletes a
|
||||||
|
// customer's off-site history, and it does so on a date the customer was shown.
|
||||||
|
//
|
||||||
|
// ⚠ IT REMOVES BOTH HALVES OR NEITHER — Scenario F. The set-aside store and the sealed package that
|
||||||
|
// protects it are the two halves of one thing; removing only the store leaves the hub holding a
|
||||||
|
// package for a key that opens nothing, and removing only the package leaves ciphertext nobody can
|
||||||
|
// ever decrypt. Either is a state that asks a question nobody can answer.
|
||||||
|
//
|
||||||
|
// The two halves cannot be made atomic across two machines, so this is a two-phase commit with the
|
||||||
|
// STORE FIRST and a durable marker: delete the remote store, record AbandonPurgeRequested, and keep
|
||||||
|
// declaring it in the report until the hub's ACK stops reporting a superseded package. A crash
|
||||||
|
// between the two leaves the marker set and the next sweep re-declares — it never leaves the pair
|
||||||
|
// half-removed and silent.
|
||||||
|
//
|
||||||
|
// Returns (deleted, err). deleted=false with err=nil is the normal "nothing due" case.
|
||||||
|
func (m *Manager) AbandonSweep(ctx context.Context) (bool, error) {
|
||||||
|
st := m.AbandonStatus()
|
||||||
|
// Phase 2 outstanding: the store is gone, the hub has not confirmed. Re-declare and wait.
|
||||||
|
if st.PurgeRequested {
|
||||||
|
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.Active || st.DueAt.After(m.abandonNow()) {
|
||||||
|
return false, nil // not due — quiet by construction on every healthy box
|
||||||
|
}
|
||||||
|
t := m.settings.GetOffboxTarget()
|
||||||
|
if t == nil || t.AbandonRepoPath == "" {
|
||||||
|
m.logger.Printf("[WARN] [offbox] abandonment is due but no set-aside path is recorded — nothing deleted; clearing the countdown so it does not retry for ever")
|
||||||
|
m.CancelAbandon("no set-aside path recorded")
|
||||||
|
return false, fmt.Errorf("abandonment due with no recorded path")
|
||||||
|
}
|
||||||
|
port := t.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
m.logger.Printf("[WARN] [offbox] abandonment DUE — deleting the set-aside off-site history at %s (chosen by the customer on %s; this is irreversible)",
|
||||||
|
t.AbandonRepoPath, st.StartedAt.Format("2006-01-02"))
|
||||||
|
out, err := m.sshRunner()(ctx, t.Host, t.User, port, m.offboxKeyPath(), m.offboxKnownHosts(),
|
||||||
|
"rm -rf "+shellQuote(t.AbandonRepoPath))
|
||||||
|
if err != nil {
|
||||||
|
// NOT cleared: a transport failure must retry tomorrow, not silently abandon the abandonment.
|
||||||
|
m.logger.Printf("[ERROR] [offbox] abandonment: deleting the set-aside history failed — the countdown stays due and retries: %v: %s", err, truncate(out))
|
||||||
|
return false, fmt.Errorf("delete set-aside history: %w", err)
|
||||||
|
}
|
||||||
|
// Phase 1 done. Record it durably BEFORE anything else, so a crash here re-declares rather than
|
||||||
|
// forgetting that the store is already gone.
|
||||||
|
if uerr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||||
|
o.AbandonPurgeRequested = true
|
||||||
|
o.AbandonAt = "" // the schedule has fired; the marker now drives the rest
|
||||||
|
}); uerr != nil {
|
||||||
|
m.logger.Printf("[ERROR] [offbox] abandonment: the store was deleted but the marker could not be saved — the hub's package may outlive it: %v", uerr)
|
||||||
|
return true, uerr
|
||||||
|
}
|
||||||
|
m.logger.Printf("[INFO] [offbox] abandonment: set-aside history deleted; requesting the hub to drop the sealed package that protected it")
|
||||||
|
if m.offboxOrphanEvent != nil {
|
||||||
|
m.offboxOrphanEvent("offbox_abandon_completed", t.AbandonRepoPath)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAbandonPurgeIfConfirmed closes the two-phase commit: once the hub's ACK stops reporting a
|
||||||
|
// superseded package, both halves are gone and the abandonment is finished. Called from the ACK path.
|
||||||
|
//
|
||||||
|
// This is what makes §2.1 work without a "they decided" flag: afterwards the hub holds a package for
|
||||||
|
// the key the box is actually using (or none at all), shape (c) has nothing to compare, and the
|
||||||
|
// recovery offer falls silent on its own — because the state is right, not because something is
|
||||||
|
// remembering that it once was not.
|
||||||
|
func (m *Manager) ClearAbandonPurgeIfConfirmed(supersededPresent bool) {
|
||||||
|
t := m.settings.GetOffboxTarget()
|
||||||
|
if t == nil || !t.AbandonPurgeRequested || supersededPresent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||||
|
o.AbandonPurgeRequested = false
|
||||||
|
o.AbandonRepoPath = ""
|
||||||
|
o.AbandonStartedAt = ""
|
||||||
|
o.OrphanedRenamedTo = ""
|
||||||
|
}); err != nil {
|
||||||
|
m.logger.Printf("[WARN] [offbox] could not close out the abandonment: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-241 — abandoning starts a countdown that ENDS THE QUESTION (Scenarios E, F, G).
|
||||||
|
//
|
||||||
|
// The countdown is driven by an injected clock throughout. §7.4 forbids shortening a live timer to
|
||||||
|
// watch the terminal step fire: it is the only thing in the product that deletes a customer's
|
||||||
|
// off-site history, and a step tested once on real data is a step regretted once.
|
||||||
|
|
||||||
|
// abandonFixture: an orphaned, configured box holding a key, with the hub holding a package for a
|
||||||
|
// DIFFERENT key — i.e. shape (c) is live and the customer is being offered recovery.
|
||||||
|
// Returns the manager and a recorder of every remote shell command issued.
|
||||||
|
type sshRecorder struct{ cmds []string }
|
||||||
|
|
||||||
|
func (r *sshRecorder) run(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) {
|
||||||
|
r.cmds = append(r.cmds, remoteCmd)
|
||||||
|
return []byte(""), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func abandonFixture(t *testing.T, now time.Time) (*Manager, *settings.Settings, *sshRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
m, sett, _ := offerFixture(t, true)
|
||||||
|
if err := sett.SetHubEscrowKeySHA256(otherKeyHash, now.Format(time.RFC3339)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||||
|
o.EscrowState = "escrowed"
|
||||||
|
o.RepoState = "orphaned"
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := &sshRecorder{}
|
||||||
|
m.SetOffboxSSH(rec.run)
|
||||||
|
m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) { return []byte(""), nil })
|
||||||
|
m.SetOffboxClock(func() time.Time { return now })
|
||||||
|
return m, sett, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO E — abandoning sets aside, keeps the package, starts a countdown, stays reversible ──
|
||||||
|
func TestR241_ScenarioE_AbandonStartsAReversibleCountdown(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.Fatalf("abandon: %v", err)
|
||||||
|
}
|
||||||
|
// The store was MOVED, not deleted — no rm anywhere in this phase.
|
||||||
|
joined := strings.Join(rec.cmds, " | ")
|
||||||
|
if !strings.Contains(joined, "mv ") {
|
||||||
|
t.Errorf("the old store must be moved aside; commands were: %s", joined)
|
||||||
|
}
|
||||||
|
if strings.Contains(joined, "rm -rf") {
|
||||||
|
t.Fatalf("NOTHING may be deleted when the customer abandons — only at the end of the grace. Commands: %s", joined)
|
||||||
|
}
|
||||||
|
st := m.AbandonStatus()
|
||||||
|
if !st.Active {
|
||||||
|
t.Fatal("a countdown must be running after an abandonment")
|
||||||
|
}
|
||||||
|
if got := st.DueAt.Sub(start); got != abandonGraceDays*24*time.Hour {
|
||||||
|
t.Errorf("countdown length = %v, want %d days", got, abandonGraceDays)
|
||||||
|
}
|
||||||
|
if st.DaysLeft != abandonGraceDays {
|
||||||
|
t.Errorf("DaysLeft = %d, want %d", st.DaysLeft, abandonGraceDays)
|
||||||
|
}
|
||||||
|
if st.RepoPath == "" {
|
||||||
|
t.Error("the set-aside path must be recorded, or the terminal step has nothing to delete")
|
||||||
|
}
|
||||||
|
// THE GRACE IS REAL: the recovery offer stays reachable for the whole window. A grace in which
|
||||||
|
// recovery is impossible would be decorative.
|
||||||
|
if !m.OffsiteRecoveryOffer() {
|
||||||
|
t.Fatal("the recovery offer MUST stay reachable during the grace — that is the change-of-mind path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO G — changing your mind inside the window ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// RED-PROOF: make the countdown uncancellable (delete the body of CancelAbandon). The countdown then
|
||||||
|
// survives a successful recovery and this test fails — a customer who proved they hold their code
|
||||||
|
// would still have the history deleted under them.
|
||||||
|
func TestR241_ScenarioG_RecoveryInsideTheWindowCancelsTheCountdown(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)
|
||||||
|
}
|
||||||
|
day6 := start.AddDate(0, 0, 6)
|
||||||
|
m.SetOffboxClock(func() time.Time { return day6 })
|
||||||
|
if st := m.AbandonStatus(); !st.Active || st.DaysLeft != 8 {
|
||||||
|
t.Fatalf("precondition: day 6 of 14 should leave 8 days, got %+v", st)
|
||||||
|
}
|
||||||
|
pathBefore := m.AbandonStatus().RepoPath
|
||||||
|
|
||||||
|
m.CancelAbandon("the customer recovered with their code")
|
||||||
|
|
||||||
|
st := m.AbandonStatus()
|
||||||
|
if st.Active {
|
||||||
|
t.Fatal("a countdown must be cancellable — the customer found their code")
|
||||||
|
}
|
||||||
|
if st.RepoPath != pathBefore {
|
||||||
|
t.Errorf("the set-aside store must stay NAMEABLE after a cancel: got %q want %q", st.RepoPath, pathBefore)
|
||||||
|
}
|
||||||
|
// And a sweep now deletes nothing, on any later date.
|
||||||
|
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 90) })
|
||||||
|
deleted, err := m.AbandonSweep(context.Background())
|
||||||
|
if err != nil || deleted {
|
||||||
|
t.Fatalf("a cancelled countdown must never delete: deleted=%v err=%v", deleted, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO F — the countdown ends the question, and removes BOTH halves ────────────────────────
|
||||||
|
//
|
||||||
|
// RED-PROOF (store half): make AbandonSweep skip the rm. The first assertion fails.
|
||||||
|
// RED-PROOF (package half): drop AbandonPurgeRequested from OffboxReportStatus. The declaration
|
||||||
|
// assertion fails — the hub is never asked and the package outlives the store for ever.
|
||||||
|
func TestR241_ScenarioF_TerminalStepRemovesBothHalvesTogether(t *testing.T) {
|
||||||
|
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||||
|
m, sett, rec := abandonFixture(t, start)
|
||||||
|
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
setAside := m.AbandonStatus().RepoPath
|
||||||
|
|
||||||
|
// Not due yet — nothing happens, quietly.
|
||||||
|
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 13) })
|
||||||
|
if deleted, err := m.AbandonSweep(context.Background()); deleted || err != nil {
|
||||||
|
t.Fatalf("day 13 must not delete: deleted=%v err=%v", deleted, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Due.
|
||||||
|
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 14).Add(time.Minute) })
|
||||||
|
rec.cmds = nil
|
||||||
|
deleted, err := m.AbandonSweep(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("terminal step: %v", err)
|
||||||
|
}
|
||||||
|
if !deleted {
|
||||||
|
t.Fatal("the terminal step must delete when due")
|
||||||
|
}
|
||||||
|
// HALF 1: the store is gone.
|
||||||
|
joined := strings.Join(rec.cmds, " | ")
|
||||||
|
if !strings.Contains(joined, "rm -rf") || !strings.Contains(joined, setAside) {
|
||||||
|
t.Fatalf("the set-aside store at %s must be deleted; commands: %s", setAside, joined)
|
||||||
|
}
|
||||||
|
// HALF 2: the hub is ASKED for the package, and keeps being asked until it confirms.
|
||||||
|
st := m.OffboxReportStatus()
|
||||||
|
if st == nil || !st.AbandonPurgeRequested {
|
||||||
|
t.Fatalf("the report must declare abandon_purge_requested until the hub drops the package, got %+v", st)
|
||||||
|
}
|
||||||
|
// It repeats — a lost request must retry rather than leave the pair half-removed.
|
||||||
|
if d2, err2 := m.AbandonSweep(context.Background()); d2 || err2 != nil {
|
||||||
|
t.Fatalf("a second sweep must be a quiet no-op while awaiting the hub: deleted=%v err=%v", d2, err2)
|
||||||
|
}
|
||||||
|
if st2 := m.OffboxReportStatus(); st2 == nil || !st2.AbandonPurgeRequested {
|
||||||
|
t.Fatal("the declaration must persist across sweeps until confirmed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hub confirms by no longer reporting a superseded package → the question is over.
|
||||||
|
m.ClearAbandonPurgeIfConfirmed(false)
|
||||||
|
if got := sett.GetOffboxTarget(); got.AbandonPurgeRequested || got.AbandonRepoPath != "" || got.AbandonAt != "" {
|
||||||
|
t.Errorf("the abandonment must be fully closed out, got %+v", got)
|
||||||
|
}
|
||||||
|
if st3 := m.OffboxReportStatus(); st3 != nil && st3.AbandonPurgeRequested {
|
||||||
|
t.Error("the declaration must stop once the hub has confirmed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// While the hub STILL reports a superseded package, the close-out must not fire — otherwise the box
|
||||||
|
// stops asking and the package outlives the store silently, which is exactly half of Scenario F.
|
||||||
|
func TestR241_PurgeIsNotClosedOutWhileThePackageRemains(t *testing.T) {
|
||||||
|
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||||
|
m, sett, _ := 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)
|
||||||
|
}
|
||||||
|
m.ClearAbandonPurgeIfConfirmed(true) // the hub STILL holds a retained package
|
||||||
|
if !sett.GetOffboxTarget().AbandonPurgeRequested {
|
||||||
|
t.Fatal("the request must stand while the hub still reports a superseded package")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transport failure during the terminal step must NOT clear the countdown — it retries tomorrow.
|
||||||
|
// Silently abandoning the abandonment would leave the store for ever with nothing counting down.
|
||||||
|
func TestR241_TerminalStepFailureKeepsTheCountdownDue(t *testing.T) {
|
||||||
|
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||||
|
m, sett, _ := abandonFixture(t, start)
|
||||||
|
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.SetOffboxSSH(func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) {
|
||||||
|
return []byte("ssh: connect to host nas.local port 22: No route to host"), context.DeadlineExceeded
|
||||||
|
})
|
||||||
|
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) })
|
||||||
|
deleted, err := m.AbandonSweep(context.Background())
|
||||||
|
if deleted || err == nil {
|
||||||
|
t.Fatalf("a failed deletion must be reported, not swallowed: deleted=%v err=%v", deleted, err)
|
||||||
|
}
|
||||||
|
got := sett.GetOffboxTarget()
|
||||||
|
if got.AbandonAt == "" || got.AbandonPurgeRequested {
|
||||||
|
t.Fatalf("a failed terminal step must leave the countdown DUE and unrequested, got %+v", got)
|
||||||
|
}
|
||||||
|
if !m.AbandonStatus().Active {
|
||||||
|
t.Error("the countdown must still be active so tomorrow's sweep retries")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiet by construction: a box with no countdown does no work and says nothing (§ the daily job's
|
||||||
|
// own contract). Asserted, because "it probably does nothing" is how a sweep with a bug hides.
|
||||||
|
func TestR241_Sweep_QuietWhenNothingDue(t *testing.T) {
|
||||||
|
m, _, rec := abandonFixture(t, time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC))
|
||||||
|
deleted, err := m.AbandonSweep(context.Background())
|
||||||
|
if deleted || err != nil {
|
||||||
|
t.Fatalf("a box with no countdown must be a pure no-op: deleted=%v err=%v", deleted, err)
|
||||||
|
}
|
||||||
|
if len(rec.cmds) != 0 {
|
||||||
|
t.Fatalf("a no-op sweep must issue no remote commands, got %v", rec.cmds)
|
||||||
|
}
|
||||||
|
if m.AbandonStatus().Active {
|
||||||
|
t.Error("no countdown should be reported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The UNCLAIMED auto-reset must NOT start a customer countdown — nobody decided anything there.
|
||||||
|
// An as-delivered box tidying a stranger's leftover store must not put a 14-day deletion clock on it.
|
||||||
|
func TestR241_UnclaimedAutoResetStartsNoCountdown(t *testing.T) {
|
||||||
|
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||||
|
m, _, _ := abandonFixture(t, start)
|
||||||
|
t2 := m.settings.GetOffboxTarget()
|
||||||
|
base, env := m.offboxBaseArgs(t2)
|
||||||
|
if err := m.resetOrphanedRepo(context.Background(), base, env, "auto (unclaimed)"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.AbandonStatus().Active {
|
||||||
|
t.Fatal("the unclaimed auto-reset must not start a customer abandonment countdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -318,6 +318,34 @@ type OffboxTarget struct {
|
|||||||
// NOT promise the history can be reopened — that is the R-202 lesson, and a conditional promise
|
// NOT promise the history can be reopened — that is the R-202 lesson, and a conditional promise
|
||||||
// that turns out false is worse on this surface than saying less.
|
// that turns out false is worse on this surface than saying less.
|
||||||
OrphanedRenamedTo string `json:"orphaned_renamed_to,omitempty"`
|
OrphanedRenamedTo string `json:"orphaned_renamed_to,omitempty"`
|
||||||
|
|
||||||
|
// ── ABANDONMENT (v0.206.0, R-241) — deciding to give up the old history is a FINISHABLE thing ──
|
||||||
|
//
|
||||||
|
// Until now "set aside" renamed the remote store and touched nothing else, so the hub went on
|
||||||
|
// holding a package for a key the box no longer used — and the recovery question came back at
|
||||||
|
// every login, for ever. The operator's ruling (2026-08-07) is that the answer is NOT to remember
|
||||||
|
// that the customer decided, but to reach a state where the question stops arising: **fix the
|
||||||
|
// state, do not remember that it is wrong.** These fields are that countdown.
|
||||||
|
//
|
||||||
|
// ⚠ THEY ARE NOT A "THEY DECIDED" FLAG, and the distinction is the ruling. Nothing here suppresses
|
||||||
|
// the recovery offer — the offer stays reachable for the whole 14 days, because a grace period in
|
||||||
|
// which nothing can be done is decorative (Scenario E). What ends the offer is the TERMINAL STEP
|
||||||
|
// removing the store and the sealed package together, after which shape (c) has nothing to
|
||||||
|
// compare and falls silent on its own.
|
||||||
|
//
|
||||||
|
// AbandonStartedAt / AbandonAt are RFC3339; AbandonRepoPath is the move-aside path the terminal
|
||||||
|
// step deletes. AbandonRepoPath is separate from OrphanedRenamedTo deliberately: that field is
|
||||||
|
// overwritten by the NEXT reset, and a countdown that loses the path it is counting down to would
|
||||||
|
// delete nothing and report success.
|
||||||
|
AbandonStartedAt string `json:"abandon_started_at,omitempty"`
|
||||||
|
AbandonAt string `json:"abandon_at,omitempty"`
|
||||||
|
AbandonRepoPath string `json:"abandon_repo_path,omitempty"`
|
||||||
|
// AbandonPurgeRequested — the remote store IS deleted and the hub has not yet confirmed the sealed
|
||||||
|
// package is gone. It is a two-phase-commit marker for an operation in flight, NOT a memory of a
|
||||||
|
// decision: it is set by the terminal step, declared in the report, and cleared the moment the
|
||||||
|
// hub's ACK stops reporting a superseded package. If the two halves could not be removed together
|
||||||
|
// this marker is what makes the box keep asking until they are (Scenario F).
|
||||||
|
AbandonPurgeRequested bool `json:"abandon_purge_requested,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrossDriveBackup configures per-app backup to a secondary drive.
|
// CrossDriveBackup configures per-app backup to a secondary drive.
|
||||||
|
|||||||
@@ -405,6 +405,20 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.logger.Printf("[INFO] [web] recovery: the offsite repository key was recovered and placed (outcome=%s)", res.Outcome)
|
s.logger.Printf("[INFO] [web] recovery: the offsite repository key was recovered and placed (outcome=%s)", res.Outcome)
|
||||||
|
|
||||||
|
// ── SCENARIO G — CHANGING YOUR MIND INSIDE THE WINDOW (R-241, v0.206.0) ─────────────────────
|
||||||
|
//
|
||||||
|
// The customer may have chosen to abandon the old history and then found their code after all.
|
||||||
|
// The countdown is cancelled HERE, at the moment the code proves they still have it — the same
|
||||||
|
// act that makes the abandonment wrong is the act that stops it.
|
||||||
|
//
|
||||||
|
// It is placed before the tier-up and the listing deliberately: those can fail, and a countdown
|
||||||
|
// that survives a successful unlock because a later step errored would delete the very history
|
||||||
|
// the customer just proved they can open. Nothing has been deleted at this point by construction —
|
||||||
|
// AbandonSweep is the only deleter, and a running countdown means it has not fired.
|
||||||
|
if s.backupMgr != nil {
|
||||||
|
s.backupMgr.CancelAbandon("the customer recovered with their code")
|
||||||
|
}
|
||||||
|
|
||||||
// ── FINISH THE JOB (R-219, v0.201.0) ───────────────────────────────────────────────────────
|
// ── FINISH THE JOB (R-219, v0.201.0) ───────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// The screen promises: *"feloldjuk a mentéseid zárolását és megmutatjuk, mi van bennük"*. On the
|
// The screen promises: *"feloldjuk a mentéseid zárolását és megmutatjuk, mi van bennük"*. On the
|
||||||
|
|||||||
Reference in New Issue
Block a user