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"
}
+25
View File
@@ -70,6 +70,19 @@ const FeatureOffsiteKeyRecovery Feature = "offsite_key_recovery"
// and it heals itself the moment the agent updates.**
const FeatureRecoveryFailureClass Feature = "recovery_failure_class"
// FeatureRetainedRecoveryClass is agent v0.129.0's FIFTH status on a failed unlock (R-311): 422, the
// code is correct and opens a RETAINED earlier package rather than the current one.
//
// ⚠ WHAT THIS GATE GUARDS is whether the screen may say WHICH of the two causes it is. Before
// v0.129.0 nothing ever tried the retained packages, so a correct-but-earlier code and a mistype were
// genuinely indistinguishable and the screen said so. That sentence was HONEST then and becomes a
// falsehood the moment the agent can tell them apart — so the gate decides which of two true
// sentences to print, never whether to attempt the unlock.
//
// Unknown → the older, hedged sentence. That is the safe direction: it claims less, it was correct
// for two months, and it heals itself when the agent updates.
const FeatureRetainedRecoveryClass Feature = "retained_recovery_class"
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
// refuses — the existing agent-error paths speak honestly when the agent is down).
type SupportState int
@@ -141,6 +154,11 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
FeatureRecoveryFailureClass: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe
},
// R-311, same route and same reason. The row must exist or SupportsWithSource returns
// "unregistered"/SupportUnknown on the table gap and the version row is never consulted.
FeatureRetainedRecoveryClass: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe
},
}
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
@@ -167,6 +185,13 @@ var featureMinAgent = map[Feature]string{
// R-224 — the four-way status split of a failed unlock.
FeatureRecoveryFailureClass: "0.126.0",
// R-311 — the FIFTH status: 422, "your code is correct, it opens an EARLIER package". Before
// v0.129.0 the agent never looked at retained packages, so this situation was indistinguishable
// from a mistype and arrived as 400. An older agent therefore cannot produce a 422 at all, and the
// screen must keep saying it cannot tell the two apart — which was true, and is what this gate
// preserves for boxes that have not updated yet.
FeatureRetainedRecoveryClass: "0.129.0",
}
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no