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
+24 -1
View File
@@ -201,6 +201,15 @@ const (
// agent verdict at all. **The code was not used.** Distinct from RecoveryHubUnreachable because
// it is a different fault, with different words and a different remedy.
RecoveryAgentUnreachable
// RecoveryCodeOpensRetained — the code was used, it WORKED, and it opened a RETAINED earlier
// package rather than the one currently held (R-311, agent >= v0.129.0).
//
// **The customer is not at fault here and must not be told they might be.** This class exists
// because until 2026-08-12 this situation and a mistype were indistinguishable: both fail closed
// against the current package, and nothing ever tried the retained ones. The screen said as much
// out loud — a true sentence about our own incuriosity that a customer reads as a statement about
// their code.
RecoveryCodeOpensRetained
)
// ClassifyRecoveryFailure maps an unlock error to its class, from the VALUE and never the text.
@@ -210,7 +219,11 @@ const (
// "the code was refused" — it means "one of two things, and we cannot tell which". Pass false there
// and the 400 degrades to RecoveryUnknown, which is neutral. That degradation is the point: it is
// safe, it is silent, and it heals itself when the agent updates.
func ClassifyRecoveryFailure(err error, trustRefusal bool) RecoveryFailure {
// ⚠ `trustRetained` is the R-311 twin of `trustRefusal` and is separate on purpose: the two gates
// name different agent versions (v0.126.0 and v0.129.0) and a box can sit between them. Passing
// `trustRefusal` for both would let a v0.126128 agent's unexpected 422 be read as a verdict it
// cannot produce.
func ClassifyRecoveryFailure(err error, trustRefusal, trustRetained bool) RecoveryFailure {
if err == nil {
return RecoveryUnknown
}
@@ -230,6 +243,14 @@ func ClassifyRecoveryFailure(err error, trustRefusal bool) RecoveryFailure {
return RecoveryNoBundle
case http.StatusConflict:
return RecoveryBundleTooOld
case http.StatusUnprocessableEntity:
// R-311. Gated on the SAME trust flag as 400, and for the mirror-image reason: an agent that
// predates the retained lookup cannot emit 422 at all, so a 422 from anywhere else is a shape
// we did not design and must not be read as a statement about the customer's code.
if trustRetained {
return RecoveryCodeOpensRetained
}
return RecoveryUnknown
case http.StatusBadRequest:
if trustRefusal {
return RecoveryAskedAndRefused
@@ -253,6 +274,8 @@ func (f RecoveryFailure) String() string {
return "no-bundle"
case RecoveryBundleTooOld:
return "bundle-too-old"
case RecoveryCodeOpensRetained:
return "code-opens-retained"
default:
return "unknown"
}