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:
2026-08-07 11:47:42 +02:00
parent a491abef6c
commit a5d90ff801
7 changed files with 573 additions and 2 deletions
+28
View File
@@ -318,6 +318,34 @@ type OffboxTarget struct {
// 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.
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.