v0.214.0 — the recovery screen stops hedging about a code it can now check (R-311)
gates / gates (push) Successful in 13s
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:
@@ -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
|
## 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
|
**The abandon countdown told every customer who had given up their off-site history: *„Addig még
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
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
|
(traefik/cloudflared/filebrowser) get curated Hungarian display identity from the
|
||||||
`inframeta.go` map (name + description + generic `/static/infra-logo.svg` fallback icon);
|
`inframeta.go` map (name + description + generic `/static/infra-logo.svg` fallback icon);
|
||||||
filebrowser is the only infra stack with a customer link (`files.<domain>`).
|
filebrowser is the only infra stack with a customer link (`files.<domain>`).
|
||||||
|
|||||||
@@ -201,6 +201,15 @@ const (
|
|||||||
// agent verdict at all. **The code was not used.** Distinct from RecoveryHubUnreachable because
|
// 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.
|
// it is a different fault, with different words and a different remedy.
|
||||||
RecoveryAgentUnreachable
|
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.
|
// 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
|
// "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
|
// 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.
|
// 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 {
|
if err == nil {
|
||||||
return RecoveryUnknown
|
return RecoveryUnknown
|
||||||
}
|
}
|
||||||
@@ -230,6 +243,14 @@ func ClassifyRecoveryFailure(err error, trustRefusal bool) RecoveryFailure {
|
|||||||
return RecoveryNoBundle
|
return RecoveryNoBundle
|
||||||
case http.StatusConflict:
|
case http.StatusConflict:
|
||||||
return RecoveryBundleTooOld
|
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:
|
case http.StatusBadRequest:
|
||||||
if trustRefusal {
|
if trustRefusal {
|
||||||
return RecoveryAskedAndRefused
|
return RecoveryAskedAndRefused
|
||||||
@@ -253,6 +274,8 @@ func (f RecoveryFailure) String() string {
|
|||||||
return "no-bundle"
|
return "no-bundle"
|
||||||
case RecoveryBundleTooOld:
|
case RecoveryBundleTooOld:
|
||||||
return "bundle-too-old"
|
return "bundle-too-old"
|
||||||
|
case RecoveryCodeOpensRetained:
|
||||||
|
return "code-opens-retained"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,19 @@ const FeatureOffsiteKeyRecovery Feature = "offsite_key_recovery"
|
|||||||
// and it heals itself the moment the agent updates.**
|
// and it heals itself the moment the agent updates.**
|
||||||
const FeatureRecoveryFailureClass Feature = "recovery_failure_class"
|
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
|
// 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).
|
// refuses — the existing agent-error paths speak honestly when the agent is down).
|
||||||
type SupportState int
|
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 {
|
FeatureRecoveryFailureClass: func(ctx context.Context, p SupportProber) error {
|
||||||
return errNoRecoveryProbe
|
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
|
// 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.
|
// R-224 — the four-way status split of a failed unlock.
|
||||||
FeatureRecoveryFailureClass: "0.126.0",
|
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
|
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
|
||||||
|
|||||||
@@ -231,14 +231,14 @@ func TestClassifyRecoveryFailure_MapsFromTheValueNotTheText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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)
|
t.Fatalf("got %v, want %v", got, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// The text must be irrelevant: the SAME sentence under two statuses classifies two ways.
|
// The text must be irrelevant: the SAME sentence under two statuses classifies two ways.
|
||||||
same := "the recovery code did not open the sealed bundle"
|
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")
|
t.Fatal("classification followed the TEXT — it must follow the status")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,6 +269,31 @@ func (s *Server) SetRecoveryRefusalTrusted(fn func(context.Context) bool) {
|
|||||||
s.recoveryRefusalTrustedFn = fn
|
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
|
// 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
|
// 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
|
// 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
|
// 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
|
// tell for the operator — but it is NEVER the classifier. Time is a symptom; the status is
|
||||||
// the fact.
|
// 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)
|
s.logger.Printf("[WARN] [web] recovery: unlock failed after %s (class=%s): %v", unsealTook.Round(time.Millisecond), class, rerr)
|
||||||
switch class {
|
switch class {
|
||||||
case agentapi.RecoveryHubUnreachable:
|
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.
|
// 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)
|
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
|
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:
|
case agentapi.RecoveryAskedAndRefused:
|
||||||
// The bundle was fetched and the code did not open it. THIS is the only class from which
|
// 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.
|
// the customer may be told to check their typing — see the two messages below.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 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.
|
// that need the OLD-agent behaviour override this explicitly.
|
||||||
s.SetRecoveryRefusalTrusted(func(context.Context) bool { return true })
|
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}
|
return &recoveryFixture{s: s, mgr: mgr, sett: sett, rec: rec, runner: rr, dataDir: cfg.Paths.DataDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ type Server struct {
|
|||||||
// recoveryRefusalTrustedFn overrides the R-224 gate deciding whether a 400 may be read as a
|
// recoveryRefusalTrustedFn overrides the R-224 gate deciding whether a 400 may be read as a
|
||||||
// genuine refusal (tests). nil → the agent-version path.
|
// genuine refusal (tests). nil → the agent-version path.
|
||||||
recoveryRefusalTrustedFn func(context.Context) bool
|
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
|
// recoveryNowFn is the unlock path's clock (tests inject; nil → time.Now). Observability and
|
||||||
// tests only — never a classifier.
|
// tests only — never a classifier.
|
||||||
recoveryNowFn func() time.Time
|
recoveryNowFn func() time.Time
|
||||||
|
|||||||
@@ -27,10 +27,28 @@ import os
|
|||||||
import re
|
import re
|
||||||
import sys
|
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".
|
# 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.
|
# (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.
|
# 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.",
|
"the banner's promise — R-302 made it conditional on RecoveryAbandonRetrievalOffered; TRUE branch.",
|
||||||
("layout.html", "Hogy ezek még visszaszerezhetők-e"):
|
("layout.html", "Hogy ezek még visszaszerezhetők-e"):
|
||||||
"R-302's cautious branch on the banner. As above.",
|
"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;"):
|
("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 "
|
"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.",
|
"there: the package the hub holds right now is the one about to be pinned. Left alone.",
|
||||||
}
|
}
|
||||||
|
|
||||||
TEMPLATE_COMMENT = re.compile(r"\{\{/\*.*?\*/\}\}", re.S)
|
TEMPLATE_COMMENT = re.compile(r"\{\{/\*.*?\*/\}\}", re.S)
|
||||||
|
GO_COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S)
|
||||||
|
|
||||||
|
|
||||||
def scan():
|
def scan():
|
||||||
convictions, seen_keys = [], set()
|
convictions, seen_keys = [], set()
|
||||||
files = sorted(f for f in os.listdir(TEMPLATES) if f.endswith(".html"))
|
files = sorted(f for f in os.listdir(TEMPLATES) if f.endswith(".html"))
|
||||||
for name in files:
|
sources = [(f, os.path.join(TEMPLATES, f), TEMPLATE_COMMENT) for f in files]
|
||||||
path = os.path.join(TEMPLATES, name)
|
for gp in GO_SOURCES:
|
||||||
text = TEMPLATE_COMMENT.sub("", open(path, encoding="utf-8").read())
|
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 stem in STEMS:
|
||||||
for m in re.finditer(re.escape(stem) + r"[a-záéíóöőúüű]*", text):
|
for m in re.finditer(re.escape(stem) + r"[a-záéíóöőúüű]*", text):
|
||||||
line = text[: m.start()].count("\n") + 1
|
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("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.")
|
print("knowability, or an unrelated local-backup sentence, add it to ALLOWLIST with the reason.")
|
||||||
return 1
|
return 1
|
||||||
print(f"retrieval-promise gate OK — {len(files)} templates, {len(ALLOWLIST)} registered claim(s), "
|
print(f"retrieval-promise gate OK — {len(files)} surface(s) incl. {len(GO_SOURCES)} Go handler file(s), "
|
||||||
f"none unregistered")
|
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(" (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.)")
|
print(" to a true predicate — that is what the R-302 render tests are for.)")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
Reference in New Issue
Block a user