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:
2026-08-06 08:06:57 +02:00
parent 05cf352a2f
commit 1e759a16ec
7 changed files with 529 additions and 10 deletions
+16 -4
View File
@@ -3,7 +3,6 @@ package web
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
@@ -38,16 +37,26 @@ type fakeRecoverer struct {
pw string
sha string
fail bool
codes []string
// failWith overrides `fail` with an EXACT error value, so a test can drive one specific
// R-224 class (a 502 fetch failure, a transport error, an unrecognised status).
failWith error
codes []string
}
func (f *fakeRecoverer) RecoverOffsiteRepoPassword(_ context.Context, code string) (string, string, error) {
f.mu.Lock()
f.codes = append(f.codes, code)
f.mu.Unlock()
if f.failWith != nil {
return "", "", f.failWith
}
if f.fail {
// The shape the agent returns: names the STEP, never the code.
return "", "", fmt.Errorf("unseal failed: age: incorrect passphrase")
// R-224: the shape agent >= v0.126.0 returns for a code that was TRIED AND REFUSED — HTTP 400,
// meaning the bundle was fetched and `age -d` rejected it. It used to be a bare error here,
// which is the shape of a failure we could NOT classify; under the R-224 rule that now renders
// the neutral message, and rightly so. Saying "wrong code" in a test requires saying it the way
// the agent says it.
return "", "", &agentapi.RecoveryRefusal{Status: 400, Reason: "the recovery code did not open the sealed bundle — nothing was written"}
}
return f.pw, f.sha, nil
}
@@ -133,6 +142,9 @@ func newRecoveryFixture(t *testing.T) *recoveryFixture {
// the protection working, and exactly not what those tests are about. The refusal has its own
// tests in recovery_gate_test.go, each overriding this.
s.SetRecoverySupport(func(context.Context) agentapi.SupportState { return agentapi.SupportYes })
// R-224: the fixture's agent is a current one, so a 400 may be read as a genuine refusal. Tests
// that need the OLD-agent behaviour override this explicitly.
s.SetRecoveryRefusalTrusted(func(context.Context) bool { return true })
return &recoveryFixture{s: s, mgr: mgr, sett: sett, rec: rec, runner: rr, dataDir: cfg.Paths.DataDir}
}