diff --git a/CHANGELOG.md b/CHANGELOG.md index e2026d3..199bd46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +## v0.214.0 — the recovery screen stops hedging about a code it can now check (2026-08-12, R-311) +**MinAgent: 0.129.0** + +**What was already right, and is worth saying first.** The screen did NOT bluntly accuse a customer +holding an older code: R-222/R-226 already hedged, naming both possible causes and the kept package. +That sentence was honest — *"innen nem tudjuk megkülönböztetni őket"*, we cannot tell them apart from +here. **It could not tell them apart because nothing ever looked.** Agent v0.129.0 looks, so the hedge +can become an answer. + +**New failure class `RecoveryCodeOpensRetained`** on HTTP 422, gated by `FeatureRetainedRecoveryClass` +(MinAgent 0.129.0). The gate is the R-224 twin and is SEPARATE from `trustRefusal` on purpose: the two +name different agent versions (0.126.0 and 0.129.0) and a box can sit between them, where a 422 is a +shape we did not design and must not be read as a verdict. `ClassifyRecoveryFailure` therefore takes +both flags; the compiler found every call site. + +**The message, and what it deliberately does not say.** It states the code is correct, names the +supersession date, says the earlier package is kept, and — the half a customer will otherwise assume +wrong — says the CURRENT backups are unaffected. It does **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 on this +screen is worse than saying less — the R-202 lesson, on the highest-stakes copy in the product. It +routes to support, which CAN do it: the 2026-08-12 drill did exactly that by hand. + +**An older agent keeps the hedged sentence.** Unknown → claims less → heals itself on update. + +**The claim guard grew a surface (and immediately convicted something).** `retrieval_promise_gate.py` +scanned `internal/web/templates` only — while every recovery message is a Go string in a handler, i.e. +the highest-stakes copy in the product had never been scanned. It now scans `recovery_handlers.go` +too, with Go comments stripped for the same reason template comments are. On its first run it found a +PRE-EXISTING unregistered claim (`RecoverRefused`'s "reopening would overwrite it") — now registered +as an explanation rather than a promise. `visszanyit` joins the stems: the new message uses a fourth +verb for the same claim, and the gate's own history is what happens when it chases words not claims. + +Six handler tests asserting which SENTENCE the customer sees, with red-proofs asserted applied — +including: make 422 unconditional and an agent that never looked is read as having looked; route 400 +to the new class and a mistype is congratulated. + +--- + ## v0.213.0 — the banner promises only what the box can still see is true (2026-08-12, R-302) — MinAgent 0.127.0 **The abandon countdown told every customer who had given up their off-site history: *„Addig még diff --git a/controller/README.md b/controller/README.md index 4fd34ef..3723739 100644 --- a/controller/README.md +++ b/controller/README.md @@ -200,6 +200,14 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated per-visit, the durable opt-out silences the bar only, and **none of the three removes the entry point on Távoli mentés**. While a recovery is outstanding, „Helyreállítási kód létrehozása" is UNAVAILABLE — creating a new code would put the earlier history beyond every shipped path. + **A code for an EARLIER package is now recognised as correct (v0.214.0, R-311; MinAgent 0.129.0).** + When the supplied code does not open the package the hub currently holds, the agent tries the + RETAINED ones and answers 422 if one opens; the screen then says the code is *correct*, names the + supersession date, says the earlier package is kept and that the CURRENT backups are unaffected, and + routes to support. It deliberately 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. + On an agent older than 0.129.0 the screen keeps the earlier hedged wording, which was honest: until + something looked, a correct-but-earlier code and a mistype really were indistinguishable. (traefik/cloudflared/filebrowser) get curated Hungarian display identity from the `inframeta.go` map (name + description + generic `/static/infra-logo.svg` fallback icon); filebrowser is the only infra stack with a customer link (`files.`). diff --git a/controller/internal/agentapi/escrow.go b/controller/internal/agentapi/escrow.go index 5979e4b..090883b 100644 --- a/controller/internal/agentapi/escrow.go +++ b/controller/internal/agentapi/escrow.go @@ -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.126–128 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" } diff --git a/controller/internal/agentapi/features.go b/controller/internal/agentapi/features.go index b6f6858..cb46e9e 100644 --- a/controller/internal/agentapi/features.go +++ b/controller/internal/agentapi/features.go @@ -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 diff --git a/controller/internal/web/recovery_class_test.go b/controller/internal/web/recovery_class_test.go index 4891553..d2c488b 100644 --- a/controller/internal/web/recovery_class_test.go +++ b/controller/internal/web/recovery_class_test.go @@ -231,14 +231,14 @@ func TestClassifyRecoveryFailure_MapsFromTheValueNotTheText(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := agentapi.ClassifyRecoveryFailure(tc.err, tc.trusted); got != tc.want { + if got := agentapi.ClassifyRecoveryFailure(tc.err, tc.trusted, tc.trusted); got != tc.want { t.Fatalf("got %v, want %v", got, tc.want) } }) } // The text must be irrelevant: the SAME sentence under two statuses classifies two ways. same := "the recovery code did not open the sealed bundle" - if agentapi.ClassifyRecoveryFailure(refusal(400, same), true) == agentapi.ClassifyRecoveryFailure(refusal(502, same), true) { + if agentapi.ClassifyRecoveryFailure(refusal(400, same), true, true) == agentapi.ClassifyRecoveryFailure(refusal(502, same), true, true) { t.Fatal("classification followed the TEXT — it must follow the status") } } diff --git a/controller/internal/web/recovery_handlers.go b/controller/internal/web/recovery_handlers.go index 122c640..812c41a 100644 --- a/controller/internal/web/recovery_handlers.go +++ b/controller/internal/web/recovery_handlers.go @@ -269,6 +269,31 @@ func (s *Server) SetRecoveryRefusalTrusted(fn func(context.Context) bool) { s.recoveryRefusalTrustedFn = fn } +// recoveryRetainedTrusted reports whether a 422 from the agent may be read as "the code is correct +// and opens a RETAINED earlier package" (R-311). +// +// Only agent >= v0.129.0 ever looks at retained packages, so only it can produce that verdict. On +// anything older the two causes really are indistinguishable and the screen must keep saying so. +// +// ⚠ Like its R-224 twin this gate BLOCKS NOTHING — the unlock is attempted either way. It decides +// only which of two TRUE sentences the customer reads, and "not sure" picks the one that claims less. +func (s *Server) recoveryRetainedTrusted(ctx context.Context) bool { + if s.recoveryRetainedTrustedFn != nil { + return s.recoveryRetainedTrustedFn(ctx) + } + agent, err := s.agentClient() + if err != nil { + return false + } + state, _ := s.netFeatures.SupportsWithSource(ctx, agent, agentapi.FeatureRetainedRecoveryClass) + return state == agentapi.SupportYes +} + +// SetRecoveryRetainedTrusted overrides the R-311 version gate (tests). INIT-ONLY. +func (s *Server) SetRecoveryRetainedTrusted(fn func(context.Context) bool) { + s.recoveryRetainedTrustedFn = fn +} + // recoveryNow is the clock the unlock path measures itself against. Real time in production; tests // inject so §7.2's guard — the typing message may only follow a REAL unseal — can be asserted // without sleeping. It is an observability seam and a test seam: **it must never become a @@ -373,7 +398,7 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) { // The duration is logged because it is what DIAGNOSED this and it is the cheapest possible // tell for the operator — but it is NEVER the classifier. Time is a symptom; the status is // the fact. - class := agentapi.ClassifyRecoveryFailure(rerr, s.recoveryRefusalTrusted(r.Context())) + class := agentapi.ClassifyRecoveryFailure(rerr, s.recoveryRefusalTrusted(r.Context()), s.recoveryRetainedTrusted(r.Context())) s.logger.Printf("[WARN] [web] recovery: unlock failed after %s (class=%s): %v", unsealTook.Round(time.Millisecond), class, rerr) switch class { case agentapi.RecoveryHubUnreachable: @@ -397,6 +422,30 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) { // The code WORKED — the bundle opened. It simply predates the field we need. s.renderRecovery(w, r, "A kódod megnyitotta a csomagot, de az még nem tartalmazza a házon kívüli tárhely kulcsát — régebben készült, mint amikor ezt elkezdtük belerakni, és utólag nem pótolható. A kódoddal semmi baj. Keresd a Felhom ügyfélszolgálatát.", "", nil) return + case agentapi.RecoveryCodeOpensRetained: + // ── R-311 — THE CODE IS CORRECT, AND WE CHECKED. ────────────────────────────────── + // + // The agent tried the retained packages and one of them opened. This is no longer an + // inference from "the hub says an earlier package exists" (R-222) — it is a measurement, + // and it is the difference between hedging and knowing. + // + // What this message may NOT do: promise the older history can be reopened. The retained + // package may itself predate the repository-password field, and there is no route from + // this screen to a set-aside store in any case (the restore machinery resolves its + // repository from settings and its password from one file — see the session's spike). A + // conditional promise that turns out false HERE is worse than saying less; that is the + // R-202 lesson and it applies with full force to a screen about someone's backups. + // + // So it states what is known, denies what the customer will otherwise assume (that their + // current backups are affected), and names a route. A refusal that names no route is a + // defect on this surface. + when := "" + if _, at := s.recoverySuperseded(); at != "" { + when = " (" + at + ")" + } + s.logger.Printf("[INFO] [web] recovery: the code opened a RETAINED package — the customer is not at fault") + s.renderRecovery(w, r, "A kódod helyes, de egy korábbi csomagot nyit meg, nem azt, amit most őrzünk ehhez a géphez. A géped időközben új mentési kulcsot kapott. A korábbi csomagot"+when+" nem töröltük, megőrizzük — a mostani mentéseidet ez nem érinti, azokkal semmi nem történt. A régebbi előzményed visszanyitásához a Felhom ügyfélszolgálatának segítsége kell: írj nekik, és add meg, hogy a régi mentéseidhez szeretnél hozzáférni. A kódodat tedd el, szükség lesz rá.", "", nil) + return case agentapi.RecoveryAskedAndRefused: // The bundle was fetched and the code did not open it. THIS is the only class from which // the customer may be told to check their typing — see the two messages below. diff --git a/controller/internal/web/recovery_retained_r311_test.go b/controller/internal/web/recovery_retained_r311_test.go new file mode 100644 index 0000000..f0200c6 --- /dev/null +++ b/controller/internal/web/recovery_retained_r311_test.go @@ -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) + } +} diff --git a/controller/internal/web/recovery_test.go b/controller/internal/web/recovery_test.go index 1900150..b0dee75 100644 --- a/controller/internal/web/recovery_test.go +++ b/controller/internal/web/recovery_test.go @@ -147,6 +147,9 @@ func newRecoveryFixture(t *testing.T) *recoveryFixture { // 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 }) + // R-311: likewise a current agent, so a 422 may be read as "the code opens a RETAINED package". + // The old-agent behaviour has its own test, which overrides this. + s.SetRecoveryRetainedTrusted(func(context.Context) bool { return true }) return &recoveryFixture{s: s, mgr: mgr, sett: sett, rec: rec, runner: rr, dataDir: cfg.Paths.DataDir} } diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index f8d0b15..d616a77 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -116,6 +116,9 @@ type Server struct { // recoveryRefusalTrustedFn overrides the R-224 gate deciding whether a 400 may be read as a // genuine refusal (tests). nil → the agent-version path. recoveryRefusalTrustedFn func(context.Context) bool + // recoveryRetainedTrustedFn overrides the R-311 gate deciding whether a 422 may be read as "the + // code is correct and opens a RETAINED earlier package" (tests). INIT-ONLY. + recoveryRetainedTrustedFn func(context.Context) bool // recoveryNowFn is the unlock path's clock (tests inject; nil → time.Now). Observability and // tests only — never a classifier. recoveryNowFn func() time.Time diff --git a/controller/scripts/retrieval_promise_gate.py b/controller/scripts/retrieval_promise_gate.py index d2e4d3f..d8ec79a 100644 --- a/controller/scripts/retrieval_promise_gate.py +++ b/controller/scripts/retrieval_promise_gate.py @@ -27,10 +27,28 @@ import os import re import sys -TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "internal", "web", "templates") +_HERE = os.path.dirname(os.path.abspath(__file__)) +TEMPLATES = os.path.join(_HERE, "..", "internal", "web", "templates") + +# R-311 — THE GATE HAD A BLIND SPOT THE SIZE OF THE RECOVERY SCREEN. +# +# It scanned `internal/web/templates` only. But every one of the recovery screen's messages is a Go +# STRING in a handler, not template text — including the four R-224 messages and the R-222/R-226 one, +# i.e. the highest-stakes customer copy in the product, on the one screen whose whole purpose is to be +# believed about someone's backups. None of it had ever been scanned. +# +# Go `//` and `/* */` comments are stripped for the same reason template comments are: they never +# reach a customer. (A `//` inside a Hungarian string literal would be stripped too — there are none, +# and a false NEGATIVE there is the safe direction for a guard that convicts on presence.) +GO_SOURCES = [ + os.path.join(_HERE, "..", "internal", "web", "recovery_handlers.py".replace(".py", ".go")), +] # The verbs that carry the claim "your old backups can be got back". -STEMS = ["visszaállíthat", "visszaszerezhet", "visszahozhat"] +# R-311 adds `visszanyit`: the honest new message says a customer needs support's help „a régebbi +# előzményed visszanyitásához". That is the SAME claim in a fourth verb, and the docstring above +# records what happens when the guard chases words instead of claims — it misses the next one. +STEMS = ["visszaállíthat", "visszaszerezhet", "visszahozhat", "visszanyit"] # (template, substring that identifies the occurrence) -> why it is allowed. # The substring must be specific enough that a DIFFERENT claim in the same file does not match it. @@ -53,20 +71,38 @@ ALLOWLIST = { "the banner's promise — R-302 made it conditional on RecoveryAbandonRetrievalOffered; TRUE branch.", ("layout.html", "Hogy ezek még visszaszerezhetők-e"): "R-302's cautious branch on the banner. As above.", + ("recovery_handlers.go", "A régi előzmény visszanyitása felülírná azt"): + "PRE-EXISTING and never scanned until R-311 extended this gate to Go handlers — which is the " + "point of extending it. It is NOT a promise: it is the reason for a REFUSAL (RecoverRefused, " + "a different repository password is already present), i.e. the sentence says the reopening " + "would overwrite and was therefore not done. Registered as an explanation, not a claim.", + ("recovery_handlers.go", "A régebbi előzményed visszanyitásához a Felhom ügyfélszolgálatának segítsége kell"): + "R-311's truthful message for a code that opens a RETAINED package. It is a claim, and it is " + "TRUE: the drill of 2026-08-12 recovered exactly this by hand (unsealed the retained package, " + "opened the set-aside store, restored planted files byte-identical). It routes to SUPPORT " + "rather than to a button precisely because there is no in-product route yet — the restore " + "machinery resolves its repository from settings and its password from one file. If that route " + "is ever built, this entry changes; if support ever cannot do it, this sentence must go.", ("recovery.html", "a mentéseid visszaszerezhetők, és a törlés elmarad;"): "the abandon CONFIRMATION screen, shown at the moment of the decision. True by construction " "there: the package the hub holds right now is the one about to be pinned. Left alone.", } TEMPLATE_COMMENT = re.compile(r"\{\{/\*.*?\*/\}\}", re.S) +GO_COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S) def scan(): convictions, seen_keys = [], set() files = sorted(f for f in os.listdir(TEMPLATES) if f.endswith(".html")) - for name in files: - path = os.path.join(TEMPLATES, name) - text = TEMPLATE_COMMENT.sub("", open(path, encoding="utf-8").read()) + sources = [(f, os.path.join(TEMPLATES, f), TEMPLATE_COMMENT) for f in files] + for gp in GO_SOURCES: + if not os.path.exists(gp): + raise SystemExit(f"retrieval-promise gate: declared Go source is missing: {gp}") + sources.append((os.path.basename(gp), gp, GO_COMMENT)) + files = files + [os.path.basename(gp)] + for name, path, stripper in sources: + text = stripper.sub("", open(path, encoding="utf-8").read()) for stem in STEMS: for m in re.finditer(re.escape(stem) + r"[a-záéíóöőúüű]*", text): line = text[: m.start()].count("\n") + 1 @@ -105,8 +141,8 @@ def main(): print("claim, make it CONDITIONAL on what the box can see; if it is a question about") print("knowability, or an unrelated local-backup sentence, add it to ALLOWLIST with the reason.") return 1 - print(f"retrieval-promise gate OK — {len(files)} templates, {len(ALLOWLIST)} registered claim(s), " - f"none unregistered") + print(f"retrieval-promise gate OK — {len(files)} surface(s) incl. {len(GO_SOURCES)} Go handler file(s), " + f"{len(ALLOWLIST)} registered claim(s), none unregistered") print(" (BLIND SPOT: it registers WHERE the claim is made, not whether each conditional is wired") print(" to a true predicate — that is what the R-302 render tests are for.)") return 0