R-224/R-226 Part 1: why the unlock failed decides what we say
The failure branch was a two-way choice — superseded? M4 : M1 — and BOTH are statements about the customer's code. rerr was never inspected, so a hub that refused, an agent that was stopped and a genuinely mistyped code all produced the same accusation. Measured live 2026-08-05 with a CORRECT current code: hub firewalled off 0.0556s, agent stopped 0.0299s, against ~1.0s for a real unseal. Five classes, from the VALUE and never the text: hub-unreachable 502/503 from the agent — the code was NOT used agent-unreachable no agent verdict at all (transport) — NOT used no-bundle 404 bundle-too-old 409 asked-and-refused 400 — the ONLY class that may mention typing unknown everything else -> NEUTRAL, the safe default agentapi.RecoveryRefusal carries the status as a value (refusalError flattened it into a sentence, and a sentence is not something a caller can branch on). THE OLD-AGENT CASE IS WHY THIS NEEDS A COUPLING. Agent < 0.126.0 answers 400 for both a fetch failure and a wrong code, so a 400 from one cannot be read as a refusal. FeatureRecoveryFailureClass (MinAgent 0.126.0) withholds that reading and the 400 degrades to neutral. The gate BLOCKS NOTHING — it only decides whether the customer may be told to check their typing. R-226: the superseded message now names BOTH possibilities and restores the ten-words prompt. The two are indistinguishable at the engine; the honest message says so. It still does not promise the earlier package can be opened. Elapsed time is logged (it is what diagnosed this) and is NEVER a classifier. Tests: scenarios A-E at the HANDLER + the classifier table asserting the same sentence under two statuses classifies two ways. Red-proofs, each demonstrated failing then restored: delete the 502 case (A), remove the mistype clause (C), default to the accusation (D), route an instant transport failure to the typing message (E). Two existing tests encoded the defect and were corrected, not deleted: the web fake returned a BARE error for 'wrong code' (which is the shape of a failure we cannot classify), and R-222's test forbade any mention of typing on a superseded box — half of which R-226 deliberately reverses. 28 packages ok, vet clean, all controller gates OK.
This commit is contained in:
@@ -185,6 +185,47 @@ func (s *Server) SetRecoverySupport(fn func(context.Context) agentapi.SupportSta
|
||||
s.recoverySupportFn = fn
|
||||
}
|
||||
|
||||
// recoveryRefusalTrusted reports whether a 400 from the agent may be read as "the bundle was fetched
|
||||
// and the code was REFUSED" (R-224).
|
||||
//
|
||||
// Only agent >= v0.126.0 splits a failed fetch out to its own status. Before it, 400 covered both,
|
||||
// and reading one as a refusal is precisely how a hub outage became an accusation. Anything other
|
||||
// than a definite yes therefore withholds that reading, and the caller falls to the neutral message.
|
||||
//
|
||||
// ⚠ This gate BLOCKS NOTHING. The unlock is attempted either way — FeatureOffsiteKeyRecovery already
|
||||
// decides that, fail-closed. This only decides whether the customer may be told to check their
|
||||
// typing, and "not sure" means they may not.
|
||||
func (s *Server) recoveryRefusalTrusted(ctx context.Context) bool {
|
||||
if s.recoveryRefusalTrustedFn != nil {
|
||||
return s.recoveryRefusalTrustedFn(ctx)
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
state, _ := s.netFeatures.SupportsWithSource(ctx, agent, agentapi.FeatureRecoveryFailureClass)
|
||||
return state == agentapi.SupportYes
|
||||
}
|
||||
|
||||
// SetRecoveryRefusalTrusted overrides the R-224 version gate (tests). INIT-ONLY.
|
||||
func (s *Server) SetRecoveryRefusalTrusted(fn func(context.Context) bool) {
|
||||
s.recoveryRefusalTrustedFn = fn
|
||||
}
|
||||
|
||||
// recoveryNow is the clock the unlock path measures itself against. Real time in production; tests
|
||||
// inject so §7.2's guard — the typing message may only follow a REAL unseal — can be asserted
|
||||
// without sleeping. It is an observability seam and a test seam: **it must never become a
|
||||
// classifier.** Time is the symptom that diagnosed R-224; the agent's status is the fact.
|
||||
func (s *Server) recoveryNow() time.Time {
|
||||
if s.recoveryNowFn != nil {
|
||||
return s.recoveryNowFn()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// SetRecoveryClock overrides the unlock clock (tests). INIT-ONLY.
|
||||
func (s *Server) SetRecoveryClock(fn func() time.Time) { s.recoveryNowFn = fn }
|
||||
|
||||
// recoverySuperseded reports the hub's statement that an EARLIER sealed package is kept, and when
|
||||
// (R-222). Both zero on a pre-0.97.0 hub, which keeps the old message — an older hub simply cannot
|
||||
// make the screen claim anything new.
|
||||
@@ -254,12 +295,67 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
// THE SHARED CORE — the same function the command line drives. There is no second recovery
|
||||
// implementation in this codebase (R-193 §8.5).
|
||||
unsealStart := s.recoveryNow()
|
||||
res, rerr := backup.RecoverInstallCore(ctx, s.backupMgr, rec, code, true)
|
||||
unsealTook := s.recoveryNow().Sub(unsealStart)
|
||||
code = "" // cleared here, before any branch below, on success and failure alike
|
||||
if rerr != nil {
|
||||
// The agent's error names the STEP (fetch / unseal / place) and carries no secret. It is not
|
||||
// shown raw: a customer needs to know what to check, not what age's KDF returned.
|
||||
s.logger.Printf("[WARN] [web] recovery: unlock failed: %v", rerr)
|
||||
//
|
||||
// ── R-224 — WHY IT FAILED DECIDES WHAT WE SAY. ─────────────────────────────────────────
|
||||
//
|
||||
// This branch used to be a two-way choice — superseded? M4 : M1 — and BOTH are statements
|
||||
// about the customer's code. `rerr` was never inspected, so a hub that refused, an agent that
|
||||
// was stopped, and a genuinely mistyped code all produced the same accusation.
|
||||
//
|
||||
// Measured live on 2026-08-05 with a CORRECT current code: hub firewalled off → 0.0556 s;
|
||||
// agent stopped → 0.0299 s. A genuine unseal costs ~1.0 s of scrypt, so neither had attempted
|
||||
// one. **The machine accused the customer of something it had not tried.**
|
||||
//
|
||||
// The duration is logged because it is what DIAGNOSED this and it is the cheapest possible
|
||||
// tell for the operator — but it is NEVER the classifier. Time is a symptom; the status is
|
||||
// the fact.
|
||||
class := agentapi.ClassifyRecoveryFailure(rerr, s.recoveryRefusalTrusted(r.Context()))
|
||||
s.logger.Printf("[WARN] [web] recovery: unlock failed after %s (class=%s): %v", unsealTook.Round(time.Millisecond), class, rerr)
|
||||
switch class {
|
||||
case agentapi.RecoveryHubUnreachable:
|
||||
// The agent answered and could not FETCH the package. The code was NEVER USED. Say that,
|
||||
// name the connection, and say nothing whatever about whether the code is right — we do
|
||||
// not know, and guessing here is the whole defect.
|
||||
s.renderRecovery(w, r, "Most nem sikerült elérni a Felhom központi rendszerét, ezért a mentéseidet nem tudtuk megnyitni. A kódodat NEM használtuk fel, és semmi nem változott — tedd el biztonságos helyen, és próbáld újra néhány perc múlva. Ha egy óra múlva sem megy, szólj a Felhom ügyfélszolgálatának.", "", nil)
|
||||
return
|
||||
case agentapi.RecoveryAgentUnreachable:
|
||||
// The machine's own in-house service never answered, so there is no verdict at all. A
|
||||
// different fault from the one above, with a different remedy — and, again, the code was
|
||||
// not used.
|
||||
s.renderRecovery(w, r, "A gép házon belüli szolgáltatása most nem válaszol, ezért a mentéseidet nem tudtuk megnyitni. A kódodat NEM használtuk fel, és semmi nem változott — tedd el biztonságos helyen. A gép magától rendbe jöhet; próbáld újra néhány perc múlva, és ha egy óra múlva sem megy, szólj a Felhom ügyfélszolgálatának.", "", nil)
|
||||
return
|
||||
case agentapi.RecoveryNoBundle:
|
||||
// The hub answered, and it holds nothing for this machine. Not the customer's doing, and
|
||||
// not something a different code would fix.
|
||||
s.renderRecovery(w, r, "Ehhez a géphez nem őrzünk lezárt csomagot, ezért nincs mit megnyitni. Ez nem a kódoddal van összefüggésben. Ha korábban készültek házon kívüli mentéseid, szólj a Felhom ügyfélszolgálatának.", "", nil)
|
||||
return
|
||||
case agentapi.RecoveryBundleTooOld:
|
||||
// The code WORKED — the bundle opened. It simply predates the field we need.
|
||||
s.renderRecovery(w, r, "A kódod megnyitotta a csomagot, de az még nem tartalmazza a házon kívüli tárhely kulcsát — régebben készült, mint amikor ezt elkezdtük belerakni, és utólag nem pótolható. A kódoddal semmi baj. Keresd a Felhom ügyfélszolgálatát.", "", nil)
|
||||
return
|
||||
case agentapi.RecoveryAskedAndRefused:
|
||||
// The bundle was fetched and the code did not open it. THIS is the only class from which
|
||||
// the customer may be told to check their typing — see the two messages below.
|
||||
default:
|
||||
// ── THE SAFE DEFAULT (R-224 §7.1). ────────────────────────────────────────────────
|
||||
//
|
||||
// The cause could not be determined: an unrecognised status, an unexpected transport
|
||||
// shape, or an agent older than v0.126.0 whose 400 means "wrong code OR failed fetch"
|
||||
// and cannot be told apart. **We do not know, so we do not guess — and we certainly do
|
||||
// not guess the customer.**
|
||||
//
|
||||
// It claims neither that the code was wrong nor that it went unused; both would be
|
||||
// inventions. This is the branch whose ABSENCE let the defect survive being fixed once.
|
||||
s.renderRecovery(w, r, "A művelet nem fejeződött be, és nem tudjuk biztosan, miért. Semmi nem változott, és a mentéseid érintetlenek. Próbáld újra néhány perc múlva — ha másodszorra sem sikerül, szólj a Felhom ügyfélszolgálatának.", "", nil)
|
||||
return
|
||||
}
|
||||
// ── MESSAGE 4 of 4 — AN EARLIER PACKAGE IS KEPT (R-222) ────────────────────────────────
|
||||
//
|
||||
// The unseal failed against the package the hub CURRENTLY holds. That is the right outcome for
|
||||
@@ -276,12 +372,25 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// opened, because it cannot be: serving a superseded blob is an unbuilt link (R-199's
|
||||
// inventory), and a conditional promise that turns out false on this screen is worse than
|
||||
// saying less (the R-202 lesson).
|
||||
//
|
||||
// ── R-226 — AND IT MUST NAME THE MISTYPE TOO. ─────────────────────────────────────────
|
||||
//
|
||||
// As shipped, this message spoke only of an earlier package and sent the customer to support.
|
||||
// But it is tested BEFORE the typing message, so on every box the hub keeps an earlier package
|
||||
// for — precisely the boxes whose customer has just been handed a NEW recovery code and is
|
||||
// most likely to be typing one — a genuine mistype produced this text and the ten-words prompt
|
||||
// became unreachable. Measured on 2026-08-05 (CAMPAIGN-11 F1): three deliberately wrong codes,
|
||||
// three real ~1 s unseals, three copies of this message.
|
||||
//
|
||||
// The two are INDISTINGUISHABLE at the engine — both fail closed against the current package —
|
||||
// so the honest message names both and does not pretend to know which. Do not try to tell them
|
||||
// apart; there is nothing to tell them apart with.
|
||||
if present, at := s.recoverySuperseded(); present {
|
||||
when := ""
|
||||
if at != "" {
|
||||
when = " (" + at + ")"
|
||||
}
|
||||
s.renderRecovery(w, r, "Ez a kód nem nyitja meg azt a csomagot, amit most őrzünk ehhez a géphez. Ha egy korábbi kódot adtál meg: a géped azóta új mentési kulcsot kapott, és a régebbi csomagot"+when+" nem töröltük — megőrizzük. Megnyitni viszont innen egyelőre nem lehet, ezért ha a régebbi mentéseidre van szükséged, keresd a Felhom ügyfélszolgálatát. A kódoddal semmi nem történt, és semmi nem változott.", "", nil)
|
||||
s.renderRecovery(w, r, "Ez a kód nem nyitotta meg azt a csomagot, amit most őrzünk ehhez a géphez. Két oka lehet, és innen nem tudjuk megkülönböztetni őket. Lehet elgépelés: ellenőrizd, hogy mind a tíz szót pontosan, szóközökkel elválasztva írtad-e be — a kis- és nagybetűk nem számítanak. Vagy egy korábbi kódot adtál meg: a géped azóta új mentési kulcsot kapott, és a régebbi csomagot"+when+" nem töröltük — megőrizzük, megnyitni viszont innen egyelőre nem lehet. Ha újrapróbálod és úgy sem megy, és a régebbi mentéseidre van szükséged, keresd a Felhom ügyfélszolgálatát. A kódoddal semmi nem történt, és semmi nem változott.", "", nil)
|
||||
return
|
||||
}
|
||||
// ── MESSAGE 1 of 4 — THE CODE DID NOT OPEN IT. The ONLY one that mentions typing. ──────────
|
||||
|
||||
Reference in New Issue
Block a user