R-302: the abandon banner promises only what the box can still see is true
gates / gates (push) Successful in 10s

The retrieval clause rendered unconditionally on every page and is false on a
reachable state - the same screen where the orphan card says we cannot tell.

The condition is a fingerprint PINNED at the decision, not a comparison against
the current key. The obvious proxy asks about the wrong key: the set-aside
copies were written under an older key the box no longer has, so on a
twice-rebuilt box the proxy promises about copies nothing can open. Demonstrated
- under the proxy, the replaced-package and legacy cases both flip back to
promising.

The pin is a recorded assumption and says so: nothing on the box records which
key wrote those copies. Empty is not a match. A countdown started before this
carries no pin and takes the cautious branch, not a backfill.

A sweep of all 36 templates found a fourth instance (backups page, same
condition applied) and a fifth (the confirmation screen, correctly left alone -
true at the moment of the decision).

New retrieval_promise_gate registers each claim with a reason rather than
banning a verb: a string ban failed twice, and the honest replacement copy
contains the stem.
This commit is contained in:
2026-08-12 15:27:29 +02:00
parent 1b66010298
commit 89712563a0
11 changed files with 554 additions and 3 deletions
@@ -58,11 +58,21 @@ func (m *Manager) SetOffboxClock(fn func() time.Time) { m.offboxNow = fn }
func (m *Manager) startAbandonCountdown(setAsidePath string) {
now := m.abandonNow().UTC()
due := now.AddDate(0, 0, abandonGraceDays)
// R-302: pin the hub's escrow key fingerprint HERE, at the decision — the one moment it is a fact
// rather than something inferred later from an adjacent value. From now on the banner asks exactly
// one question, "is the hub still holding that same package?", instead of guessing which key is
// which. Written once and never refreshed: a field re-read at render answers a different question
// and would silently restore the defect this replaces.
pinned := ""
if m.settings != nil {
pinned, _ = m.settings.GetHubEscrowKeySHA256()
}
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
o.AbandonPinnedEscrowKeySHA256 = pinned
}); err != nil {
m.logger.Printf("[WARN] [offbox] could not record the abandonment countdown: %v", err)
return
@@ -80,6 +90,15 @@ type AbandonState struct {
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
// RetrievalStillOffered (R-302) — may the banner still say the set-aside copies can be retrieved
// with the recovery code? TRUE only while the hub is holding the SAME sealed package it held when
// the customer decided. Derived here, once, so the banner and anything else asking cannot disagree.
//
// FALSE covers: the package was replaced after the decision (a fresh escrow ceremony — the act that
// cost both demo boxes their history); the hub reports an empty hash (a legacy package sealing no
// repository password); and a countdown started before R-302, which carries no pin. All three are
// "we cannot see that this is still true", and all three must read as such rather than as a promise.
RetrievalStillOffered bool
}
// AbandonStatus reports the countdown for the UI and the report. It never mutates.
@@ -103,6 +122,13 @@ func (m *Manager) AbandonStatus() AbandonState {
if s, serr := time.Parse(time.RFC3339, t.AbandonStartedAt); serr == nil {
st.StartedAt = s
}
// R-302: the pinned fingerprint vs what the hub reports NOW. Both must be non-empty and equal.
// Empty on either side is "we could not see", never "they match" — the settings comment on
// HubEscrowKeySHA256 establishes that the hub sends "" for a package sealing no repo password.
if cur, _ := m.settings.GetHubEscrowKeySHA256(); cur != "" &&
t.AbandonPinnedEscrowKeySHA256 != "" && cur == t.AbandonPinnedEscrowKeySHA256 {
st.RetrievalStillOffered = true
}
// 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 {