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
@@ -0,0 +1,167 @@
package web
import (
"context"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// ── R-311 — A CORRECT CODE FOR AN EARLIER PACKAGE STOPS BEING CALLED WRONG ───────────────────────
//
// What was measured on 2026-08-12: a recovery code that provably opens a RETAINED package — unsealed
// by hand, and it restored planted files byte-identical from a store the box itself could no longer
// open — was answered by the product as a code that opened nothing.
//
// The screen already hedged (R-222/R-226): "it may be a typo, or it may be an older code, and we
// cannot tell them apart from here." That sentence was TRUE and it was honest. It was also a
// statement about our own incuriosity — nothing ever tried the retained packages — read by the
// customer as a statement about their code. Now something tries, so the hedge can become an answer.
//
// Every test asserts the EFFECT at the HANDLER: which sentence the customer is shown.
// saysCodeIsCorrect is the load-bearing half of the new message. Anything that leaves this out has
// failed at the one job it has.
func saysCodeIsCorrect(body string) bool { return strings.Contains(body, "A kódod helyes") }
// saysCurrentBackupsUnaffected — a customer told "your code opens something else" will otherwise
// assume their CURRENT backups are in doubt. They are not, and the message must say so.
func saysCurrentBackupsUnaffected(body string) bool {
return strings.Contains(body, "mostani mentéseidet ez nem érinti")
}
// namesARoute — a refusal that names no next step is a defect on this surface.
func namesARoute(body string) bool { return strings.Contains(body, "ügyfélszolgálat") }
// ── THE ONE THAT MATTERS ────────────────────────────────────────────────────────────────────────
//
// RED-PROOF: delete the `agentapi.RecoveryCodeOpensRetained` case from recoveryUnlockHandler so a 422
// falls through → the customer gets the hedged "we cannot tell them apart" text (or, without R-222's
// gate, the bare typing accusation) → this FAILS on saysCodeIsCorrect. That is yesterday's behaviour
// returning, and it is the whole reason this case exists.
func TestR311_CodeOpensRetained_IsNotReportedAsWrong(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = refusal(422, "the recovery code is correct, but it belongs to an EARLIER sealed package (superseded 2026-08-12 15:18:55), not the one currently held")
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if namesTyping(body) || isBareAccusation(body) {
t.Fatalf("a CORRECT code was reported as a possible mistype — the R-311 defect is back; got %q", firstAlert(body))
}
if !saysCodeIsCorrect(body) {
t.Fatalf("the message must say the code is CORRECT; got %q", firstAlert(body))
}
if !saysCurrentBackupsUnaffected(body) {
t.Errorf("the message must deny the assumption it creates — that the CURRENT backups are affected; got %q", firstAlert(body))
}
if !namesARoute(body) {
t.Errorf("a refusal on this screen must name a route; got %q", firstAlert(body))
}
}
// The message must NOT promise the older history can be reopened from this screen. There is no
// in-product route to a set-aside store (the restore machinery resolves its repository from settings
// and its password from one file), and the retained package may itself predate the repository-password
// field. A conditional promise that turns out false HERE is worse than saying less — the R-202 lesson.
//
// RED-PROOF: add "és vissza is állítjuk" (or any unconditional retrieval promise) to the message →
// this FAILS.
func TestR311_TheMessageDoesNotPromiseARestore(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = refusal(422, "belongs to an EARLIER sealed package")
body := firstAlert(postUnlockWith(t, f.s, testRecoveryCode).Body.String())
// A promise would take the form "we will restore it" / "you can get them back from here".
for _, promise := range []string{"vissza is állítjuk", "most visszaállítjuk", "innen visszaszerezheted", "azonnal visszaállítható"} {
if strings.Contains(body, promise) {
t.Fatalf("the message promises a restore this screen cannot perform (%q): %q", promise, body)
}
}
// It must still say the package is KEPT — that is the true, useful half.
if !strings.Contains(body, "nem töröltük") {
t.Errorf("the message must say the earlier package was kept; got %q", body)
}
}
// AN OLDER AGENT CANNOT PRODUCE THIS VERDICT, so the screen must keep the hedged sentence. The gate
// decides which of two TRUE sentences to print, never whether to attempt the unlock.
//
// RED-PROOF: make ClassifyRecoveryFailure return RecoveryCodeOpensRetained for 422 regardless of
// trustRetained → an agent that never looked at a retained package is treated as having looked →
// this FAILS.
func TestR311_OlderAgent_KeepsTheHedgedSentence(t *testing.T) {
f := newRecoveryFixture(t)
f.s.SetRecoveryRetainedTrusted(func(context.Context) bool { return false })
f.rec.failWith = refusal(422, "some shape this agent should not be producing")
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if saysCodeIsCorrect(body) {
t.Fatalf("an agent too old to look at retained packages was read as having looked; got %q", firstAlert(body))
}
// RecoveryUnknown → the neutral message. It must not accuse either.
if isBareAccusation(body) {
t.Errorf("an unclassifiable 422 produced an accusation; got %q", firstAlert(body))
}
}
// SCENARIO C — a genuinely wrong code must still reach the typing message. The new branch must not
// become an escape hatch that stops the product ever saying "check your typing".
//
// RED-PROOF: route 400 to RecoveryCodeOpensRetained → a mistype is congratulated → this FAILS.
func TestR311_WrongCodeStillReachesTheTypingMessage(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = refusal(400, "the recovery code did not open the sealed bundle — nothing was written")
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if saysCodeIsCorrect(body) {
t.Fatalf("a WRONG code was told it is correct; got %q", firstAlert(body))
}
if !namesTyping(body) {
t.Errorf("a genuine refusal must still be able to mention typing; got %q", firstAlert(body))
}
}
// SCENARIO A — the ordinary successful recovery is untouched. This is the path that WORKS, and it is
// the one a change like this is most likely to break by accident.
//
// RED-PROOF: return the 422 refusal from the fixture's recoverer on the success path → this FAILS.
func TestR311_OrdinaryRecoveryUnaffected(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = nil // the current package opens
rr := postUnlockWith(t, f.s, testRecoveryCode)
body := rr.Body.String()
if saysCodeIsCorrect(body) {
t.Fatal("the retained message rendered on a SUCCESSFUL recovery")
}
if namesTyping(body) || isBareAccusation(body) {
t.Fatalf("a successful recovery produced a refusal; got %q", firstAlert(body))
}
}
// The classifier maps 422 by STATUS and gate, never by sentence — the R-224 rule, applied to the new
// status so it cannot regress the same way.
//
// RED-PROOF: classify on strings.Contains(reason, "EARLIER") instead of the status → the two
// same-text/different-status calls below stop differing → this FAILS.
func TestR311_ClassificationFollowsStatusNotText(t *testing.T) {
same := "the recovery code is correct, but it belongs to an EARLIER sealed package"
if agentapi.ClassifyRecoveryFailure(refusal(422, same), true, true) == agentapi.ClassifyRecoveryFailure(refusal(400, same), true, true) {
t.Fatal("classification followed the TEXT — it must follow the status")
}
if got := agentapi.ClassifyRecoveryFailure(refusal(422, same), true, true); got != agentapi.RecoveryCodeOpensRetained {
t.Fatalf("422 with the gate open = %v, want RecoveryCodeOpensRetained", got)
}
if got := agentapi.ClassifyRecoveryFailure(refusal(422, same), true, false); got != agentapi.RecoveryUnknown {
t.Fatalf("422 with the gate CLOSED = %v, want RecoveryUnknown (neutral)", got)
}
// The two gates are independent: an agent between v0.126.0 and v0.129.0 trusts 400 but not 422.
if got := agentapi.ClassifyRecoveryFailure(refusal(400, same), true, false); got != agentapi.RecoveryAskedAndRefused {
t.Fatalf("400 with only the R-224 gate open = %v, want RecoveryAskedAndRefused", got)
}
}