diff --git a/CHANGELOG.md b/CHANGELOG.md index 543bec1..cdc2eac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,40 @@ +## v0.126.0 — a fetch failure is not a wrong recovery code (2026-08-06, R-224) + +**A hub the agent could not reach was reported to the customer as a bad recovery code.** Measured live +on 2026-08-05 (CAMPAIGN-11 F3): with the hub REJECTed at the appliance's firewall and a **correct, +current** recovery code, the customer was told their code did not open their package — **in 0.0556 s**, +against ~1.0 s for a genuine unseal. No unseal was attempted. F4 produced the same message in 0.0299 s +with this agent stopped. + +**The discriminator existed here the whole time and this boundary threw it away.** `recover.go` fails +at four distinguishable points; the local-api handler had cases for two of them and a `default` that +answered *"the recovery code did not open the sealed bundle, or the bundle could not be fetched"* — +one sentence for two situations, only one of which is the customer's doing. + +**The fix is a value, not a log line.** `escrow.ErrBundleFetch` joins the fetch leg's error, and the +handler routes it to **502** with its own words: *"the sealed recovery bundle could not be fetched from +the hub — the recovery code was NOT used and nothing was written."* 502 rather than 4xx because the +request was not bad; an upstream dependency failed. The `default` now carries **only** the fail-closed +wrong-code case and says so without the "or". + +Four situations, four statuses — **502** fetch failed · **400** the bundle was fetched and refused the +code · **404** the hub holds no bundle · **409** the bundle predates the repository-password field. +The controller classifies on the STATUS and must never parse these sentences. + +⚠ **A GREEN TEST NAMED THIS DEFECT AND DID NOT PREVENT IT.** +`TestRecoverOffsiteRepoPassword_FetchErrorIsDistinct` has said since v0.125.0 that *"the operator must +not be sent to re-read their recovery code because the hub was unreachable"* — and it passed +throughout, because it asserted this package's error **string** one layer below where the merge +happened, and a string is something no caller can branch on. It now asserts the sentinel, and its +consequence-level twin asserts the STATUS at the boundary the customer's message is derived from. +**Prefer the test that asserts the consequence over the one that asserts the mechanism.** + +Tests: `recover_test.go` (fetch classifies as `ErrBundleFetch`; a wrong code does **not**; an absent +blob keeps its own identity) and `localapi/escrow_recover_class_test.go` (each situation's status, and +a standalone assertion that fetch-failure and wrong-code never share one). **Red-proofs:** removing the +`%w` join fails the sentinel test; deleting the handler case makes both answer `400` with the +wrong-code sentence — the exact pre-fix code, and the exact defect CAMPAIGN-11 measured. + ## v0.125.0 — the agent opens the sealed bundle and returns one field (2026-08-04, R-199 links 7–8) **Link 7 had one production caller and it was a `--selftest`.** `UnwrapIdentityBundle` has existed diff --git a/REPORT.md b/REPORT.md index e8d6584..5f1c266 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,57 +1,53 @@ -# REPORT — agent v0.125.0: open the sealed bundle, return one field (2026-08-04, R-199 links 7–8) +# REPORT — felhom-agent v0.126.0: a fetch failure is not a wrong recovery code (R-224) -**Released and deployed.** Published sha256 `f7d8339b53d92a6c45be7eaf189469a041b6b00b758a64511c0479beae7016b3`, -**verified by independent download** before install; running on demo-felhom (`felhom-agent --version` -→ `0.125.0`, `systemctl is-active` → `active`). **NOT vouched** — vouching stays the operator's act. +**Scope: this repo's half of R-224.** The controller half ships as felhom-controller v0.202.0. -## What shipped +## Why the agent changed at all -- `internal/escrow/recover.go` — `OffsiteKeyRecoverer`: fetch → unseal → extract, with **distinct** - errors for "no blob", "the bundle predates the field", and "the code did not open it". -- `internal/hub/client.go` — `FetchIdentityEscrow` (link 6's first client, ever). -- `internal/localapi/escrow_recover.go` + route registration — link 7's first production caller. -- `cmd/felhom-agent/main.go` — the production wiring, and the §8.6 selftest message fix. +The task that commissioned this work scoped `felhom-agent` as **untouched**. It could not be. Its +Scenario A (a hub outage must not blame the customer's code) and Scenario C (a genuine mistype must be +told to re-check the ten words) are **mutually unsatisfiable** while this agent answers both with one +HTTP 400 and one sentence. No value available to the controller separates them. The task's own §5 +anticipates this — *"if the step is not recoverable from the value, make it so, and say what that +cost"* — and §4.3 says the source outranks the register's recorded shape. **The cost is this version, +a publish, and a `MinAgent` coupling on the controller side.** -## The result +## What changed -**Proven live on demo-felhom, 2026-08-04:** the recovered offsite repository password's sha256 equals -the one on the box's disk, and equals the hash the hub had independently recorded. A deliberately wrong -code five minutes earlier produced -`escrow: the recovery code did not unwrap the identity escrow … exit status 1` and wrote nothing — -which also proves the fetch and the unseal ran independently of the success. +| File | Change | +|---|---| +| `internal/escrow/recover.go` | new `ErrBundleFetch` sentinel; the fetch leg joins it with `%w: %w` so the cause survives for the operator log | +| `internal/localapi/escrow_recover.go` | new `case errors.Is(err, escrow.ErrBundleFetch)` → **502** with its own words; the `default` now carries only the wrong-code case and drops the "or" | +| `internal/escrow/recover_test.go` | three new tests; the pre-existing `FetchErrorIsDistinct` re-pointed from a string to the sentinel, with the reason it failed to protect | +| `internal/localapi/escrow_recover_class_test.go` | new — the consequence-level test: four situations, four statuses | -**R persisted nowhere**, searched rather than claimed: 0 agent-journal lines, 0 controller-log lines, -0 files under `/tmp`, `/var/tmp`, `/var/lib/felhom-agent`, `/root`, 0 leftover `felhom-idesc-*` staging -dirs — with a **positive control** (a planted copy found, then removed and not found) so the sweep is a -measurement. +**Four statuses:** `502` fetch failed (the code was **not used**) · `400` fetched and refused · +`404` no bundle · `409` bundle predates the field. -## Tests +## The finding this turned up -`go build ./... && go vet ./... && go test ./...` — rc=0; `agent_gates.py --fast` — OK. -New: `internal/escrow/recover_test.go` (real `age` crypto) and -`cmd/felhom-agent/escrow_recover_wiring_test.go` (AST). +**A green test named the defect and did not prevent it.** `TestRecoverOffsiteRepoPassword_FetchErrorIsDistinct` +has asserted since v0.125.0 that *"the operator must not be sent to re-read their recovery code because +the hub was unreachable"*. It passed throughout, because it checked this package's error **string** one +layer below the local-api `default` that did the merging — and a string is not something a caller can +branch on. **Mechanism asserted, consequence unpinned**; the project's own rule names this exact case. +It is also a comment-vs-code entry: `recover.go`'s header said the errors were *"DISTINCT on purpose"* +and named **three** situations while a fourth was silently folded into one of them. -**Red-proofs, both observed failing:** -- Returning `bundle.PBSToken` instead of `ResticRepoPassword` → *"the recovered key is not the sealed - repository password (len 24 vs 64)"*. That is the mutation that mattered — every field of that bundle - is a non-empty string that looks like a secret. -- `_ = escrowRecoverer` with the `Options.EscrowRecovery` field dropped → the AST test fails with - *"localapi.Options … carries no EscrowRecovery field"*. +## Green gate -**One red-proof did NOT work on the first attempt and is recorded, not quietly redone:** the R-leak -mutation wrote R into the walked directory and the test still passed, because a later failing call -overwrote the leak file with a different code while the test scanned only for the first. **The test was -wrong.** It now asserts the TMPDIR tree is EMPTY (nothing in the test creates anything there), with the -content scan kept as defence in depth. Re-run: FAIL, *"the unseal left 1 file(s) behind under TMPDIR"*. -A second mutation (commenting the wiring line) failed to compile, which is not a red-proof either; the -compiling version is above. +`go build ./...` clean · `go vet ./...` clean · `go test ./...` → **29 packages ok** · +`python3 scripts/agent_gates.py --fast` → all gates OK. -## Scope +**Red-proofs, each demonstrated failing then restored:** -Links 6–8 only. **Nothing installs a recovered password, reopens a repository, or restores a file** — -R-200's remaining half and R-201. +| Mutation | Result | +|---|---| +| remove the `%w: %w` join (pre-R-224 wrap) | `FetchFailureIsClassifiedAsFetch` **FAILS** | +| delete the `ErrBundleFetch` handler case | fetch answers `400 "the recovery code did not open the sealed bundle"` — **the exact defect**, and both status tests **FAIL** | -## Observation +## Not changed -`ageBinary` is the hardcoded `/usr/bin/age` with no config override. Present on demo-felhom (1.2.1); a -host that installs `age` elsewhere would fail the unseal at the last step with no configuration escape. +No Proxmox surface, no privileged path, no report/hub contract, no config schema. The route's +success path, its scoping and its R-handling discipline (`R = ""` on both paths, never logged, never +persisted) are untouched. diff --git a/internal/escrow/recover.go b/internal/escrow/recover.go index 9bd8856..c254773 100644 --- a/internal/escrow/recover.go +++ b/internal/escrow/recover.go @@ -19,10 +19,27 @@ import ( // keeps that property: it takes R as an argument, passes it straight through, and holds no copy. // Callers must clear their own reference (the `R = ""` discipline in cmd/felhom-agent). // -// The errors below are DISTINCT on purpose. "no blob", "wrong code" and "the blob predates the field" -// are three different situations for the operator and only one of them is a fault. +// The errors below are DISTINCT on purpose. "could not fetch", "no blob", "wrong code" and "the blob +// predates the field" are FOUR different situations for the operator and only one of them is a fault. +// +// ⚠ THERE WERE THREE, AND THE FOURTH WAS THE DEFECT (R-224, 2026-08-06). This comment said "three" +// and named "no blob", "wrong code" and "predates the field" — while a FAILED FETCH was wrapped as an +// anonymous error and fell through the caller's `default` branch into the wrong-code message. So a +// hub that could not be reached was reported to the customer as a bad recovery code. +// +// Measured live on 2026-08-05 (CAMPAIGN-11 F3): with the hub REJECTed at the appliance's firewall and +// a CORRECT current recovery code, the customer was told the code did not open their package — in +// 0.0556 s, when a real unseal costs ~1 s of scrypt. The agent's own log carried the truth the whole +// time (`escrow: fetching the sealed bundle: hub: transport error: … no route to host`) and the HTTP +// boundary threw it away. +// +// The discriminator therefore has to be a VALUE, not a log line — that is what ErrBundleFetch is. var ( + // ErrBundleFetch — the sealed bundle could not be FETCHED (the hub refused, was unreachable, or + // the transport failed). **The recovery code was never used**, so nothing about it is known and + // nothing may be said about it. Wraps the underlying cause for the operator log; carries no secret. + ErrBundleFetch = errors.New("escrow: the sealed bundle could not be fetched") // ErrNoEscrowBlob — the hub holds no sealed bundle for this host. Not a fault: no ceremony has run. ErrNoEscrowBlob = errors.New("escrow: the hub holds no sealed identity bundle for this host (no ceremony has run)") // ErrNoResticPassword — the bundle opened, but carries no repository password. Real and expected @@ -58,7 +75,11 @@ func (r OffsiteKeyRecoverer) RecoverOffsiteRepoPassword(ctx context.Context, rec } blob, present, err := r.Fetch(ctx) if err != nil { - return "", fmt.Errorf("escrow: fetching the sealed bundle: %w", err) // carries no secret + // R-224: joined with ErrBundleFetch so the caller can classify by VALUE. The cause stays + // wrapped for the operator log; neither carries a secret. Before this, the fetch failure was + // an anonymous error and the local-api handler's `default` branch reported it to the customer + // as a wrong recovery code. + return "", fmt.Errorf("%w: %w", ErrBundleFetch, err) } if !present || len(blob) == 0 { return "", ErrNoEscrowBlob diff --git a/internal/escrow/recover_test.go b/internal/escrow/recover_test.go index 31aeb49..0bd4ef7 100644 --- a/internal/escrow/recover_test.go +++ b/internal/escrow/recover_test.go @@ -175,15 +175,93 @@ func TestRecoverOffsiteRepoPassword_RLeavesNoTrace(t *testing.T) { // A fetch failure surfaces as a fetch failure, not as a wrong-code error — the operator must not be // sent to re-read their recovery code because the hub was unreachable. +// +// ⚠ THIS TEST WAS GREEN THROUGHOUT THE DEFECT IT DESCRIBES (R-224, 2026-08-06). Its sentence is +// exactly right and it did not prevent anything, for two reasons worth keeping: +// +// 1. **It asserted the MECHANISM, one layer below the consequence.** It checked this package's error +// STRING. The merge happened one layer up, in the local-api handler's `default` branch, which +// answered a fetch failure with "the recovery code did not open the sealed bundle". The customer +// never sees this string; they see that one. The project's own rule — prefer the test that asserts +// the CONSEQUENCE (does the customer get blamed?) over the one that asserts the MECHANISM (is the +// error distinct here?) — names this case precisely. +// 2. **It asserted on TEXT.** `strings.Contains(err.Error(), …)` cannot be consumed by a caller, so +// it pinned something no production code could branch on. The distinction it checked was real and +// unusable. +// +// It now asserts the SENTINEL, which is what the handler branches on, and its consequence-level twin +// lives in `internal/localapi/escrow_recover_class_test.go` where the status is asserted. func TestRecoverOffsiteRepoPassword_FetchErrorIsDistinct(t *testing.T) { rec := OffsiteKeyRecoverer{Fetch: func(context.Context) ([]byte, bool, error) { return nil, false, errors.New("hub: connection refused") }} _, err := rec.RecoverOffsiteRepoPassword(context.Background(), testR) - if err == nil || !strings.Contains(err.Error(), "fetching the sealed bundle") { - t.Fatalf("a fetch failure must say so, got %v", err) + if err == nil || !errors.Is(err, ErrBundleFetch) { + t.Fatalf("a fetch failure must classify as ErrBundleFetch, got %v", err) } if errors.Is(err, ErrNoEscrowBlob) || errors.Is(err, ErrNoResticPassword) { t.Fatal("a transport failure must not masquerade as a content verdict") } } + +// ── R-224 — A FAILED FETCH IS NOT A WRONG CODE ────────────────────────────────────────────────── +// +// CAMPAIGN-11 F3 measured the consequence of these two being indistinguishable: with the hub +// firewalled off and a CORRECT current recovery code, the customer was told the code did not open +// their package, in 0.0556 s — no unseal was attempted at all. +// +// The pair below is the whole point. Asserting only the first would pass with a `return ErrBundleFetch` +// stuck on every error path, which is the same defect pointing the other way. +func TestRecoverOffsiteRepoPassword_FetchFailureIsClassifiedAsFetch(t *testing.T) { + boom := errors.New("hub: transport error: dial tcp 37.191.56.193:443: connect: no route to host") + r := OffsiteKeyRecoverer{Fetch: func(context.Context) ([]byte, bool, error) { return nil, false, boom }} + + _, err := r.RecoverOffsiteRepoPassword(context.Background(), testR) + if err == nil { + t.Fatal("a failing fetch must return an error") + } + // RED-PROOF: drop the `%w: %w` join in RecoverOffsiteRepoPassword (return the bare wrapped cause, + // as it was before R-224) → this FAILS, and the local-api handler falls back to the wrong-code + // message exactly as it did on 2026-08-05. + if !errors.Is(err, ErrBundleFetch) { + t.Fatalf("a failed fetch must classify as ErrBundleFetch, got %v", err) + } + // The underlying cause survives for the operator log. + if !errors.Is(err, boom) { + t.Fatalf("the fetch cause must stay wrapped for the operator, got %v", err) + } + // And it must NOT be mistaken for either of the bundle-content situations. + if errors.Is(err, ErrNoEscrowBlob) || errors.Is(err, ErrNoResticPassword) { + t.Fatalf("a transport failure is neither of the bundle-content errors: %v", err) + } +} + +// The other half: a genuinely wrong code must NOT classify as a fetch failure, or the fix trades one +// misattribution for its mirror image and the customer is told the hub is down when they mistyped. +func TestRecoverOffsiteRepoPassword_WrongCodeIsNotAFetchFailure(t *testing.T) { + ensureAge(t) + blob := sealBundle(t, IdentityBundle{ResticRepoPassword: "0123456789abcdef"}, testR) + r := OffsiteKeyRecoverer{Fetch: fetcherFor(blob)} + + _, err := r.RecoverOffsiteRepoPassword(context.Background(), + "wrong horse battery staple sedative anaconda wobbly kingdom placard yodel") + if err == nil { + t.Fatal("a wrong recovery code must fail closed") + } + if errors.Is(err, ErrBundleFetch) { + t.Fatalf("a wrong code must NOT classify as a fetch failure, got %v", err) + } +} + +// A clean "the hub holds nothing" keeps its own identity too — it is not a fetch failure, and the +// customer must not be told the hub was unreachable when it answered perfectly well. +func TestRecoverOffsiteRepoPassword_AbsentBlobIsNotAFetchFailure(t *testing.T) { + r := OffsiteKeyRecoverer{Fetch: func(context.Context) ([]byte, bool, error) { return nil, false, nil }} + _, err := r.RecoverOffsiteRepoPassword(context.Background(), testR) + if !errors.Is(err, ErrNoEscrowBlob) { + t.Fatalf("an absent blob must stay ErrNoEscrowBlob, got %v", err) + } + if errors.Is(err, ErrBundleFetch) { + t.Fatalf("an absent blob is not a fetch FAILURE, got %v", err) + } +} diff --git a/internal/localapi/escrow_recover.go b/internal/localapi/escrow_recover.go index 31b0993..0109ff1 100644 --- a/internal/localapi/escrow_recover.go +++ b/internal/localapi/escrow_recover.go @@ -75,6 +75,26 @@ func (s *Server) handleRecoverOffsitePassword(w http.ResponseWriter, r *http.Req if err != nil { // Each situation gets its own status and its own words. None of them names a secret. switch { + // ── R-224 (2026-08-06) — THE FETCH FAILURE IS NOT A WRONG CODE. ──────────────────────── + // + // This case did not exist, and its absence is the defect. A failed fetch fell through to the + // `default` below and was answered with "the recovery code did not open the sealed bundle" — + // so a hub that could not be reached was reported to the customer as a bad recovery code, on + // the one screen whose whole purpose is to be believed about their backups. + // + // Measured live 2026-08-05 (CAMPAIGN-11 F3 and F4): a CORRECT current code returned that + // message in 0.0556 s with the hub firewalled off, and in 0.0299 s with this agent stopped — + // against ~1.0 s for a genuine unseal. No unseal was attempted in either case. + // + // 502 rather than 400: 4xx says "your request was bad", and the request was not bad — an + // upstream dependency failed. The status is the machine-readable half; the controller + // classifies on it and must never parse this sentence. + // + // ⚠ THE CODE WAS NOT USED. Nothing may be said about it — not that it was wrong, and not + // that it was right. + case errors.Is(err, escrow.ErrBundleFetch): + s.logger.Warn("local-api: offsite key recovery: the sealed bundle could not be FETCHED — the recovery code was never used", "vmid", vmid, "err", err) + writeErr(w, http.StatusBadGateway, "the sealed recovery bundle could not be fetched from the hub — the recovery code was NOT used and nothing was written") case errors.Is(err, escrow.ErrNoEscrowBlob): s.logger.Warn("local-api: offsite key recovery: the hub holds no sealed bundle for this host", "vmid", vmid) writeErr(w, http.StatusNotFound, "the hub holds no sealed recovery bundle for this host — no escrow ceremony has run") @@ -82,9 +102,11 @@ func (s *Server) handleRecoverOffsitePassword(w http.ResponseWriter, r *http.Req s.logger.Warn("local-api: offsite key recovery: the bundle opened but predates the repository-password field", "vmid", vmid) writeErr(w, http.StatusConflict, "the recovery code opened the bundle, but it carries NO offsite repository password (sealed before that field existed; it cannot be retro-fitted)") default: - // Includes the fail-closed wrong-code case. The agent log records the STEP, never the code. - s.logger.Warn("local-api: offsite key recovery FAILED (wrong recovery code, or the blob could not be fetched)", "vmid", vmid, "err", err) - writeErr(w, http.StatusBadRequest, "the recovery code did not open the sealed bundle, or the bundle could not be fetched — nothing was written") + // The fail-closed WRONG-CODE case, and only it: the bundle was fetched and `age -d` + // refused it. Every other situation above has its own status. The agent log records the + // STEP, never the code. + s.logger.Warn("local-api: offsite key recovery: the fetched bundle did not open with the supplied recovery code", "vmid", vmid, "err", err) + writeErr(w, http.StatusBadRequest, "the recovery code did not open the sealed bundle — nothing was written") } return } diff --git a/internal/localapi/escrow_recover_class_test.go b/internal/localapi/escrow_recover_class_test.go new file mode 100644 index 0000000..861e479 --- /dev/null +++ b/internal/localapi/escrow_recover_class_test.go @@ -0,0 +1,99 @@ +package localapi + +import ( + "context" + "errors" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-agent/internal/escrow" +) + +// R-224 — THE STATUS IS THE DISCRIMINATOR, and this test asserts the CONSEQUENCE (what the HTTP +// boundary answers) rather than the mechanism (that the sentinel exists). +// +// The controller one trust tier down classifies on the STATUS and must never parse the sentence. So +// the contract this pins is: four distinguishable situations, four distinct statuses, and the +// wrong-code message reachable ONLY from a real refusal. +// +// Before R-224 the first and last rows both answered 400 with the same sentence — which is how +// CAMPAIGN-11 F3 told a customer holding a CORRECT code that it did not open their package. + +type fakeRecoverer struct{ err error } + +func (f fakeRecoverer) RecoverOffsiteRepoPassword(context.Context, string) (string, error) { + if f.err != nil { + return "", f.err + } + return "0123456789abcdef0123456789abcdef", nil +} + +func TestRecoverOffsitePassword_EachSituationGetsItsOwnStatus(t *testing.T) { + cases := []struct { + name string + err error + wantStatus int + // mustNotSay guards the specific misattribution each status exists to prevent. + mustNotSay []string + }{ + { + name: "fetch failed — the code was NEVER used", + err: errors.Join(escrow.ErrBundleFetch, errors.New("hub: transport error: no route to host")), + wantStatus: 502, + mustNotSay: []string{"did not open"}, + }, + { + name: "wrong code — the bundle WAS fetched and refused it", + err: errors.New("escrow: the recovery code did not unwrap the identity escrow"), + wantStatus: 400, + mustNotSay: []string{"could not be fetched"}, + }, + { + name: "the hub holds no bundle", + err: escrow.ErrNoEscrowBlob, + wantStatus: 404, + mustNotSay: []string{"did not open"}, + }, + { + name: "the bundle predates the repository-password field", + err: escrow.ErrNoResticPassword, + wantStatus: 409, + mustNotSay: []string{"could not be fetched"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := newTestServerS(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil) + srv.escrowRecovery = fakeRecoverer{err: tc.err} + w := do(t, srv.Handler(), "POST", "/escrow/recover-offsite-password", "A", + `{"vmid":8200,"recovery_code":"correct horse battery staple sedative anaconda wobbly kingdom placard yodel"}`) + if w.Code != tc.wantStatus { + t.Fatalf("status: got %d, want %d — body=%s", w.Code, tc.wantStatus, w.Body.String()) + } + for _, phrase := range tc.mustNotSay { + if strings.Contains(w.Body.String(), phrase) { + t.Fatalf("the %d answer must not say %q — body=%s", tc.wantStatus, phrase, w.Body.String()) + } + } + }) + } +} + +// The pair that matters most, stated as its own assertion so a regression cannot hide inside a table: +// a fetch failure and a wrong code must never answer with the SAME status. Collapsing them is the +// whole of R-224. +func TestRecoverOffsitePassword_FetchFailureAndWrongCodeDiffer(t *testing.T) { + status := func(err error) int { + srv := newTestServerS(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil) + srv.escrowRecovery = fakeRecoverer{err: err} + return do(t, srv.Handler(), "POST", "/escrow/recover-offsite-password", "A", + `{"vmid":8200,"recovery_code":"correct horse battery staple sedative anaconda wobbly kingdom placard yodel"}`).Code + } + fetch := status(errors.Join(escrow.ErrBundleFetch, errors.New("no route to host"))) + wrong := status(errors.New("escrow: the recovery code did not unwrap the identity escrow")) + // RED-PROOF: delete the ErrBundleFetch case from handleRecoverOffsitePassword → both become 400 + // → this FAILS. That is the exact pre-R-224 code, and the exact defect CAMPAIGN-11 measured. + if fetch == wrong { + t.Fatalf("a failed fetch and a wrong code must not share a status (both %d)", fetch) + } +}