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:
@@ -3,6 +3,7 @@ package agentapi
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
@@ -132,8 +133,11 @@ func (c *Client) RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode st
|
||||
if perr != nil {
|
||||
return "", "", perr
|
||||
}
|
||||
if rerr := refusalError("/escrow/recover-offsite-password", status, env); rerr != nil {
|
||||
return "", "", rerr
|
||||
// R-224: this route's refusal keeps its STATUS as a value. `refusalError` flattens status into a
|
||||
// sentence, and a sentence is not something a caller can branch on — which is exactly how a failed
|
||||
// fetch and a wrong recovery code came to produce one customer-facing message.
|
||||
if status < 200 || status > 299 || !env.OK {
|
||||
return "", "", &RecoveryRefusal{Status: status, Reason: truncateErr(env.Error, 300)}
|
||||
}
|
||||
var out struct {
|
||||
ResticRepoPassword string `json:"restic_repo_password"`
|
||||
@@ -147,3 +151,109 @@ func (c *Client) RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode st
|
||||
}
|
||||
return out.ResticRepoPassword, out.ResticPwSHA256, nil
|
||||
}
|
||||
|
||||
// ── R-224 — CLASSIFYING A FAILED UNLOCK ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// CAMPAIGN-11 measured what happens without this. On 2026-08-05, with a CORRECT current recovery
|
||||
// code: the hub firewalled off returned the customer "this code does not open your package" in
|
||||
// 0.0556 s, and this agent stopped returned the same in 0.0299 s — against ~1.0 s for a genuine
|
||||
// unseal. Neither attempted one. The failure path had exactly two branches, both of them statements
|
||||
// about the customer's code, and `rerr` was never inspected.
|
||||
//
|
||||
// The rule this type exists to enforce: **the customer is blamed only after a real attempt refused
|
||||
// their code.** Everything else — including anything we cannot classify — says something else.
|
||||
|
||||
// RecoveryRefusal is the agent's refusal of an unlock, carrying the STATUS as a value so callers
|
||||
// classify on it rather than on the sentence. The message keeps `refusalError`'s shape so operator
|
||||
// logs read as they did.
|
||||
type RecoveryRefusal struct {
|
||||
Status int
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *RecoveryRefusal) Error() string {
|
||||
reason := e.Reason
|
||||
if reason == "" {
|
||||
reason = "(no reason in agent response)"
|
||||
}
|
||||
return fmt.Sprintf("agentapi: POST /escrow/recover-offsite-password: HTTP %d: %s", e.Status, reason)
|
||||
}
|
||||
|
||||
// RecoveryFailure is what went wrong, as far as it can be known.
|
||||
type RecoveryFailure int
|
||||
|
||||
const (
|
||||
// RecoveryUnknown — the cause could not be determined. **The safe default**, and deliberately the
|
||||
// zero value: a new status, a transport shape nobody anticipated, or an agent too old to
|
||||
// distinguish fetch from refusal all land here, and none of them may blame the customer.
|
||||
RecoveryUnknown RecoveryFailure = iota
|
||||
// RecoveryHubUnreachable — the agent answered, and it could not FETCH the sealed package: the hub
|
||||
// refused, was unreachable, or recovery is not configured on this agent. **The code was not used.**
|
||||
RecoveryHubUnreachable
|
||||
// RecoveryAskedAndRefused — the bundle was fetched and the code did not open it. The ONLY class
|
||||
// from which the customer may be told to check their typing.
|
||||
RecoveryAskedAndRefused
|
||||
// RecoveryNoBundle — the hub holds no sealed package for this host at all.
|
||||
RecoveryNoBundle
|
||||
// RecoveryBundleTooOld — the bundle opened but predates the repository-password field.
|
||||
RecoveryBundleTooOld
|
||||
// RecoveryAgentUnreachable — the machine's own in-house service never answered, so there is no
|
||||
// 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
|
||||
)
|
||||
|
||||
// ClassifyRecoveryFailure maps an unlock error to its class, from the VALUE and never the text.
|
||||
//
|
||||
// ⚠ `trustRefusal` is the agent-version gate and it is not optional. An agent older than v0.126.0
|
||||
// answers **400 for BOTH** a fetch failure and a wrong code, so a 400 from one cannot be read as
|
||||
// "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 {
|
||||
if err == nil {
|
||||
return RecoveryUnknown
|
||||
}
|
||||
var ref *RecoveryRefusal
|
||||
if !errors.As(err, &ref) {
|
||||
// Not a refusal at all — the request never produced an agent verdict (dial failure, TLS,
|
||||
// timeout, or the channel could not be built). The machine could not even ASK its own service,
|
||||
// which is a different sentence from "the hub was unreachable" and a different thing to fix.
|
||||
return RecoveryAgentUnreachable
|
||||
}
|
||||
switch ref.Status {
|
||||
case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
||||
// 502 is agent >= v0.126.0's "the sealed bundle could not be fetched". 503 is its
|
||||
// "recovery is not configured on this agent (no hub client)". Neither used the code.
|
||||
return RecoveryHubUnreachable
|
||||
case http.StatusNotFound:
|
||||
return RecoveryNoBundle
|
||||
case http.StatusConflict:
|
||||
return RecoveryBundleTooOld
|
||||
case http.StatusBadRequest:
|
||||
if trustRefusal {
|
||||
return RecoveryAskedAndRefused
|
||||
}
|
||||
return RecoveryUnknown
|
||||
default:
|
||||
return RecoveryUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// String names the class for the operator log. The customer never sees these words.
|
||||
func (f RecoveryFailure) String() string {
|
||||
switch f {
|
||||
case RecoveryHubUnreachable:
|
||||
return "hub-unreachable"
|
||||
case RecoveryAgentUnreachable:
|
||||
return "agent-unreachable"
|
||||
case RecoveryAskedAndRefused:
|
||||
return "asked-and-refused"
|
||||
case RecoveryNoBundle:
|
||||
return "no-bundle"
|
||||
case RecoveryBundleTooOld:
|
||||
return "bundle-too-old"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user