From 75b3b39254f79cc857fb74a4d49c98e00268e96c Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 8 Oct 2026 07:53:09 +0200 Subject: [PATCH] R-304: the recovery screen says 'we do not know' when earlier packages were not checked (hu + en) Unreleased; ships with tomorrow's release. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 10 +++- controller/internal/agentapi/escrow.go | 12 ++++ controller/internal/i18n/locales/en.json | 1 + controller/internal/i18n/locales/hu.json | 1 + controller/internal/web/recovery_handlers.go | 11 ++++ controller/internal/web/recovery_r304_test.go | 59 +++++++++++++++++++ controller/scripts/i18n_go_keys.json | 1 + 7 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 controller/internal/web/recovery_r304_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6c438..f601a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased (2026-10-08) — a daytime press never cancels the night's whole-guest backup (R-899; operator ruling 2026-10-08, option A) — ships with tomorrow's release +## Unreleased (2026-10-08) — a daytime press never cancels the night's whole-guest backup (R-899; operator ruling 2026-10-08, option A); an honest answer when older recovery packages were not checked (R-304) — ships with tomorrow's release **MinAgent: 0.131.0** (unchanged — the new `trigger=manual` query is ignored by an older agent, which keeps running the OS leg after a press exactly as before; the night itself is fixed by the controller alone). @@ -12,6 +12,14 @@ OS leg after a press exactly as before; the night itself is fixed by the control „owed" tier never fires the window gate's safety valve (it waits for the window). A scheduled success inside tonight's window ends it; a failed one does not. `internal/quiesce/nightowed.go`; tests `TestR899_*` (6; red-proved: with the rule off, the 2026-10-07 replay started nothing on night 2 — `started=[local local]`, and two more failed). +- **R-304 — the recovery screen no longer calls a code wrong when earlier packages were not checked.** The agent (same + day) answers **424** when the current sealed package refused the code, no earlier package opened it, and at least one + earlier package the hub holds was never tried (no key material, over the hub's cap, malformed, past the attempt cap, or + the list could not be read). `agentapi.RecoveryOlderUnchecked`; the page says we do not know whether the code is wrong, + names the mistype as one possibility and sends the household to support — new bundle key `recovery.older_unchecked`, + Hungarian and English. Not version-gated on purpose (it accuses nobody; a stray 424 lands on a neutral sentence). + Tests `TestR304_OlderUnchecked_IsNotAWrongCode` (hu + en; red-proved: without the case the page fell to the „nem tudjuk + biztosan, miért" default) and `TestR304_Classify424`. - The press now reaches the agent as `POST /backup?…trigger=manual` (`agentapi.StartBackupForTrigger`); an agent that knows it runs no OS leg after a press (agent, same day). Test `TestR899_PressCarriesTriggerManual` asserts the query the agent receives for each of the four shapes. diff --git a/controller/internal/agentapi/escrow.go b/controller/internal/agentapi/escrow.go index 090883b..9b3b242 100644 --- a/controller/internal/agentapi/escrow.go +++ b/controller/internal/agentapi/escrow.go @@ -210,6 +210,10 @@ const ( // out loud — a true sentence about our own incuriosity that a customer reads as a statement about // their code. RecoveryCodeOpensRetained + // RecoveryOlderUnchecked — the code did not open the current package, no earlier package opened it, and + // NOT every earlier package the hub holds was tried (R-304, agent 424). So whether the code is wrong is + // NOT known. The customer must not be told to check their typing as if it were. + RecoveryOlderUnchecked ) // ClassifyRecoveryFailure maps an unlock error to its class, from the VALUE and never the text. @@ -251,6 +255,12 @@ func ClassifyRecoveryFailure(err error, trustRefusal, trustRetained bool) Recove return RecoveryCodeOpensRetained } return RecoveryUnknown + case http.StatusFailedDependency: + // R-304. NOT version-gated, deliberately — unlike 400 and 422 above. Those two make a claim about the + // customer's code (wrong / correct), so a status we did not design must not be read as one. This class + // claims only „not everything was checked; contact support", which is the same direction as + // RecoveryUnknown: misreading a stray 424 here can never accuse anyone. + return RecoveryOlderUnchecked case http.StatusBadRequest: if trustRefusal { return RecoveryAskedAndRefused @@ -276,6 +286,8 @@ func (f RecoveryFailure) String() string { return "bundle-too-old" case RecoveryCodeOpensRetained: return "code-opens-retained" + case RecoveryOlderUnchecked: + return "older-unchecked" default: return "unknown" } diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index a2637bc..c12795d 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -1895,6 +1895,7 @@ "recovery.meret": "Size", "recovery.most_nem": "Not now", "recovery.nem_kerem_vissza_a_korabbi": "I do not want the earlier data back.", + "recovery.older_unchecked": "This code did not open the package we keep for this machine now. We also keep earlier packages for this machine, but we could not try all of them with your code — so we do not know whether your code is wrong. It can be a typing mistake: check that you typed all ten words exactly, separated by spaces, and try again. Or your code can belong to an earlier package. If it still does not work, keep your code and contact Felhom support: tell them that your recovery code did not open your backups. Nothing has changed.", "recovery.semmi_nem_valtozott_varj_nehany": "Nothing changed. Wait a few seconds and try again — you will still need your code,", "recovery.tiz_szo_szokozokkel_elvalasztva": "ten words, separated by spaces", "recovery.tovabb_a_visszaallitashoz": "Continue to restore", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 9f9c1b7..35b0fd5 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -1883,6 +1883,7 @@ "recovery.meret": "Méret", "recovery.most_nem": "Most nem", "recovery.nem_kerem_vissza_a_korabbi": "nem kérem vissza a korábbi adatokat.", + "recovery.older_unchecked": "Ez a kód nem nyitotta meg azt a csomagot, amit most őrzünk ehhez a géphez. Ehhez a géphez korábbi csomagokat is őrzünk, de ezeket nem tudtuk mind kipróbálni a kódoddal — ezért nem tudjuk, hogy a kódod hibás-e. Lehet elgépelés: ellenőrizd, hogy mind a tíz szót pontosan, szóközökkel elválasztva írtad-e be, és próbáld újra. Lehet az is, hogy a kódod egy korábbi csomaghoz tartozik. Ha újrapróbálod és úgy sem megy, a kódodat tedd el, és keresd a Felhom ügyfélszolgálatát: írd meg, hogy a helyreállítási kódod nem nyitotta meg a mentéseidet. Semmi nem változott.", "recovery.semmi_nem_valtozott_varj_nehany": "Semmi nem változott. Várj néhány másodpercet, és próbáld újra — a kódodra továbbra is szükséged lesz,", "recovery.tiz_szo_szokozokkel_elvalasztva": "tíz szó, szóközökkel elválasztva", "recovery.tovabb_a_visszaallitashoz": "Tovább a visszaállításhoz", diff --git a/controller/internal/web/recovery_handlers.go b/controller/internal/web/recovery_handlers.go index 688a353..aaf2771 100644 --- a/controller/internal/web/recovery_handlers.go +++ b/controller/internal/web/recovery_handlers.go @@ -446,6 +446,17 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) { 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.RecoveryOlderUnchecked: + // ── R-304 (2026-10-08) — NOT EVERY EARLIER PACKAGE WAS TRIED. ──────────────────────────── + // + // The current package refused the code and no earlier package opened it, but the hub holds + // earlier packages the agent could not try (no key material, over a cap, unreadable list). So we + // do NOT know the code is wrong, and the message says exactly that, names the mistype as one + // possibility (R-226: a new-code holder may be typing), and names the route. Pinned by + // TestR304_OlderUnchecked_IsNotAWrongCode. + s.logger.Printf("[WARN] [web] recovery: earlier sealed packages were not all checked — not reported as a wrong code (R-304)") + s.renderRecovery(w, r, s.msg(r, "recovery.older_unchecked"), "", 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_r304_test.go b/controller/internal/web/recovery_r304_test.go new file mode 100644 index 0000000..ff8eaae --- /dev/null +++ b/controller/internal/web/recovery_r304_test.go @@ -0,0 +1,59 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" +) + +// ── R-304 (2026-10-08) — NOT EVERY EARLIER PACKAGE WAS CHECKED, SO THE CODE IS NOT CALLED WRONG ─── +// +// The agent answers 424 when the current package refused the code, no earlier package opened it, and some +// earlier package the hub holds was never tried. The page must not accuse („nem fogadtuk el"), must say that +// we do not know whether the code is wrong, and must name the route — in both languages. +// +// RED-PROOF: delete the `agentapi.RecoveryOlderUnchecked` case from recoveryUnlockHandler → the 424 falls into +// the safe default („nem tudjuk biztosan, miért") → this FAILS on the „do not know whether wrong" sentence. +func TestR304_OlderUnchecked_IsNotAWrongCode(t *testing.T) { + for _, tc := range []struct { + lang, mustSay, route, mustNotSay string + }{ + {"hu", "nem tudjuk, hogy a kódod hibás-e", "ügyfélszolgálat", "nem fogadtuk el"}, + {"en", "we do not know whether your code is wrong", "contact Felhom support", "nem tudjuk"}, + } { + t.Run(tc.lang, func(t *testing.T) { + f := newRecoveryFixture(t) + f.rec.failWith = refusal(http.StatusFailedDependency, "the recovery code did not open the current sealed package, and earlier packages the hub holds were not all checked") + form := url.Values{"recovery_code": {testRecoveryCode}} + req := httptest.NewRequest(http.MethodPost, "/recovery/unlock", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: langCookieName, Value: tc.lang}) + rr := httptest.NewRecorder() + f.s.recoveryUnlockHandler(rr, req) + body := rr.Body.String() + if !strings.Contains(body, tc.mustSay) { + t.Fatalf("[%s] the page must say we do not know whether the code is wrong; got %q", tc.lang, firstAlert(body)) + } + if !strings.Contains(body, tc.route) { + t.Errorf("[%s] the page must name the route (support); got %q", tc.lang, firstAlert(body)) + } + if strings.Contains(body, tc.mustNotSay) { + t.Errorf("[%s] the page must not say %q; got %q", tc.lang, tc.mustNotSay, firstAlert(body)) + } + }) + } +} + +// The classifier maps 424 to its own class, never to the accusing one, with or without the version gates. +func TestR304_Classify424(t *testing.T) { + err := refusal(http.StatusFailedDependency, "x") + for _, trust := range []bool{false, true} { + if got := agentapi.ClassifyRecoveryFailure(err, trust, trust); got != agentapi.RecoveryOlderUnchecked { + t.Fatalf("trust=%v: 424 classified %s, want older-unchecked", trust, got) + } + } +} diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 1f74c62..a1cb0c2 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -1,6 +1,7 @@ { "_comment": "Localisation slice 2 (R-557). key -> the base-commit Go literal it replaced, or the ORDERED list of literals a concatenation joined. Checked by scripts/i18n_go_parity.py against scripts/i18n_go_base.json, which is frozen at 736f54b49610 (the base commit of release v0.252.0). A key whose Hungarian text is not byte-identical to what the Go code said fails the gate.", "_preexisting": { + "recovery.older_unchecked": "BORN AS A KEY (R-304, 2026-10-08) -- a NEW sentence of the recovery screen for the agent's 424 (earlier sealed packages not all checked), never a Go literal; pinned by TestR304_OlderUnchecked_IsNotAWrongCode.", "banner.missed_backup.never": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 110) -- a NEW sentence of the missed-backup banner, never a Go literal; pinned in Hungarian by TestR871_BannerShownThenClosedUntilTheNextMiss and the parity fixture launcher_missed_backup.", "banner.missed_backup.last": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 110) -- a NEW sentence of the missed-backup banner, never a Go literal; pinned in Hungarian by TestR871_BannerShownThenClosedUntilTheNextMiss and the parity fixture launcher_missed_backup.", "banner.missed_backup.off_at": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 110) -- a NEW sentence of the missed-backup banner, never a Go literal; pinned in Hungarian by TestR871_BannerShownThenClosedUntilTheNextMiss and the parity fixture launcher_missed_backup.",