v0.214.0 — the recovery screen stops hedging about a code it can now check (R-311)
gates / gates (push) Successful in 13s

MinAgent: 0.129.0

What was already right: the screen did not bluntly accuse. R-222/R-226 hedged,
naming both causes and the kept package, and saying it could not tell them apart.
That was honest - and it could not tell them apart because nothing ever looked.
Agent v0.129.0 looks, so the hedge becomes an answer.

New class RecoveryCodeOpensRetained on HTTP 422, gated by
FeatureRetainedRecoveryClass (MinAgent 0.129.0). The gate is SEPARATE from the
R-224 one because the two name different agent versions and a box can sit between
them, where a 422 is a shape we did not design. ClassifyRecoveryFailure therefore
takes both flags; the compiler found every call site.

The message says the code is correct, names the supersession date, says the
earlier package is kept, and says the CURRENT backups are unaffected - the half a
customer will otherwise assume wrong. It promises NO restore: there is no
in-product route to a set-aside store (R-312) and the retained package may itself
predate the repository-password field. It routes to support, which can do it.

The claim guard grew a surface and immediately convicted something. It scanned
templates only, while every recovery message is a Go string in a handler - the
highest-stakes copy in the product, never scanned. It now scans recovery_handlers.go
too, and found a PRE-EXISTING unregistered claim on its first run.

Six handler tests asserting which SENTENCE the customer sees; red-proofs asserted
applied, including: 422 unconditional makes an agent that never looked read as
having looked, and routing 400 to the new class congratulates a mistype.
This commit is contained in:
2026-08-12 18:42:02 +02:00
parent 3168a78935
commit 3ed5e3e770
10 changed files with 365 additions and 11 deletions
+50 -1
View File
@@ -269,6 +269,31 @@ func (s *Server) SetRecoveryRefusalTrusted(fn func(context.Context) bool) {
s.recoveryRefusalTrustedFn = fn
}
// recoveryRetainedTrusted reports whether a 422 from the agent may be read as "the code is correct
// and opens a RETAINED earlier package" (R-311).
//
// Only agent >= v0.129.0 ever looks at retained packages, so only it can produce that verdict. On
// anything older the two causes really are indistinguishable and the screen must keep saying so.
//
// ⚠ Like its R-224 twin this gate BLOCKS NOTHING — the unlock is attempted either way. It decides
// only which of two TRUE sentences the customer reads, and "not sure" picks the one that claims less.
func (s *Server) recoveryRetainedTrusted(ctx context.Context) bool {
if s.recoveryRetainedTrustedFn != nil {
return s.recoveryRetainedTrustedFn(ctx)
}
agent, err := s.agentClient()
if err != nil {
return false
}
state, _ := s.netFeatures.SupportsWithSource(ctx, agent, agentapi.FeatureRetainedRecoveryClass)
return state == agentapi.SupportYes
}
// SetRecoveryRetainedTrusted overrides the R-311 version gate (tests). INIT-ONLY.
func (s *Server) SetRecoveryRetainedTrusted(fn func(context.Context) bool) {
s.recoveryRetainedTrustedFn = 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
@@ -373,7 +398,7 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
// 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()))
class := agentapi.ClassifyRecoveryFailure(rerr, s.recoveryRefusalTrusted(r.Context()), s.recoveryRetainedTrusted(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:
@@ -397,6 +422,30 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
// 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.RecoveryCodeOpensRetained:
// ── R-311 — THE CODE IS CORRECT, AND WE CHECKED. ──────────────────────────────────
//
// The agent tried the retained packages and one of them opened. This is no longer an
// inference from "the hub says an earlier package exists" (R-222) — it is a measurement,
// and it is the difference between hedging and knowing.
//
// What this message may NOT do: promise the older history can be reopened. The retained
// package may itself predate the repository-password field, and there is no route from
// this screen to a set-aside store in any case (the restore machinery resolves its
// repository from settings and its password from one file — see the session's spike). A
// conditional promise that turns out false HERE is worse than saying less; that is the
// R-202 lesson and it applies with full force to a screen about someone's backups.
//
// So it states what is known, denies what the customer will otherwise assume (that their
// current backups are affected), and names a route. A refusal that names no route is a
// defect on this surface.
when := ""
if _, at := s.recoverySuperseded(); at != "" {
when = " (" + at + ")"
}
s.logger.Printf("[INFO] [web] recovery: the code opened a RETAINED package — the customer is not at fault")
s.renderRecovery(w, r, "A kódod helyes, de egy korábbi csomagot nyit meg, nem azt, amit most őrzünk ehhez a géphez. A géped időközben új mentési kulcsot kapott. A korábbi csomagot"+when+" nem töröltük, megőrizzük — a mostani mentéseidet ez nem érinti, azokkal semmi nem történt. A régebbi előzményed visszanyitásához a Felhom ügyfélszolgálatának segítsége kell: írj nekik, és add meg, hogy a régi mentéseidhez szeretnél hozzáférni. A kódodat tedd el, szükség lesz rá.", "", 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.