REPORT + CONTEXT: v0.202.0's rule, its live proof, and what was NOT verified live
gates / gates (push) Successful in 9s

CONTEXT gains the rule so it outlives the bug: on the unlock path the customer
is blamed only after a real attempt REFUSED their code; every other outcome,
including an unclassifiable one, says something else. Plus the two things that
must not be 'fixed' into it — elapsed time is never a classifier, and the error
TEXT is never read (when the distinction was not a value, the agent was changed
to provide one).

REPORT states the split honestly: the AGENT half is proven live on the venue
(400 -> 502 -> 400, same wrong code, only the hub's reachability changed), while
the controller's message selection rests on handler tests and red-proofs,
because /recovery correctly redirects since F7 set the old data aside and
restoring that state is the reconfiguration §11 forbids. Also records that the
correct codes were shredded by the previous session, so the live re-run used a
WRONG code — which makes the test harder, not weaker.

Two venue changes stated because they were not asked for, both restorations: a
fresh dashboard password (the previous session shredded it, leaving the box
impossible to log into) set through the supported --print-reset-code escape
hatch, and one normal off-site run to populate stats_known.
This commit is contained in:
2026-08-06 08:34:19 +02:00
parent 7534ea203d
commit a62bb3874b
2 changed files with 180 additions and 110 deletions
+34
View File
@@ -1776,6 +1776,40 @@ Last updated: 2026-06-13 (v0.60.0 backlog-Medium cleanup)
--- ---
## THE UNLOCK PATH'S RULE (v0.202.0, 2026-08-06) — state it before changing anything there
> **On the recovery unlock path the customer is blamed only after a real attempt REFUSED their code.
> Every other outcome — including one that cannot be classified — says something else.**
This is the rule, and it outlives the bug that produced it. It was learned twice, because fixing it
once was not enough:
- **v0.201.0** stopped an agent that is too OLD from being reported as a wrong code (R-216).
- **v0.202.0** found the same defect through a different door: an agent that is **stopped**, and a hub
that cannot be **reached**, still fell through to a message about the code. Measured with a
**correct** code at 0.0299 s and 0.0556 s, against ~1.0 s for a real unseal — the machine accused the
customer of something it had not tried (R-224).
- And the inverse: the one message that says *"check your ten words"* was unreachable on any box that
had re-escrowed, which is exactly the box a customer has just recovered (R-226).
**How it is enforced.** `agentapi.ClassifyRecoveryFailure` maps the failure to one of five classes
**from the value, never the text**; the typing message is reachable from **one** of them
(`RecoveryAskedAndRefused`, i.e. HTTP 400, i.e. the bundle was fetched and `age` refused it); and the
zero value is `RecoveryUnknown`, which renders **neutral**. **The safe default is the load-bearing
part** — an unrecognised status must not fall into an accusation.
**Two things that are deliberately NOT how it works, and must not be "fixed" into it:**
1. **Elapsed time is never a classifier.** It is what diagnosed this, it is logged for the operator,
and that is all. A duration guard would be a second thing that can be wrong.
2. **The error's TEXT is never read.** A string match is a defect waiting for a rewording. When the
distinction was not available as a value, the **agent was changed to provide one**
(`escrow.ErrBundleFetch` → HTTP 502, agent v0.126.0, `MinAgent 0.126.0`) rather than parsed for.
**The coupling degrades safely and silently:** an agent below 0.126.0 answers 400 for both causes, so
`FeatureRecoveryFailureClass` withholds the refusal reading and the 400 becomes neutral. The gate
blocks nothing; it only decides whether the customer may be told to check their typing.
## CAMPAIGN 11 — what changed in v0.201.0 (2026-08-05) ## CAMPAIGN 11 — what changed in v0.201.0 (2026-08-05)
**The off-site key recovery is a COUPLED feature and now declares it.** It needs agent **0.125.0** **The off-site key recovery is a COUPLED feature and now declares it.** It needs agent **0.125.0**
+146 -110
View File
@@ -1,144 +1,180 @@
# REPORT — CAMPAIGN 11 fix pass: a correct recovery code is never called wrong again # REPORT — felhom-controller v0.202.0 (+ felhom-agent v0.126.0)
**2026-08-05 · controller `v0.201.0` · commit `a3499d1`** (paired with hub `v0.97.1`) **The customer is blamed only after a real attempt refused their code.** R-224, R-226, R-225, R-227,
R-228 — all five closed. No campaign venue was rebuilt, re-ceremonied or reconfigured.
Campaign 11 walked the customer's recovery journey end to end for the first time. **The data came back ## 1. The venue's state at the end — WORKING
byte-identical; the journey did not exist.** This pass closes six of the nine findings.
## Scenario A and its red-proof — the session's headline `c11-36d660` ONLINE, controller **0.202.0** healthy, agent **0.126.0**, off-site `last_status: ok`,
**2 snapshots**, `stats_known: true`, the set-aside history intact at
`/home/felhom-repo.orphaned-20260805`. Every injected fault reverted; the hub route restored and
confirmed (`http=302`, `OUTPUT` chain empty).
Measured live on the campaign venue 2026-08-05: agent 0.120.0 answers **Two changes to the venue, both restorations rather than reconfigurations, and both stated because
`POST /escrow/recover-offsite-password` with 404, the unlock was attempted anyway, and a **correct** they were not asked for.** The previous session shredded the venue's dashboard password along with
recovery code came back — in **0.134 s**, far too fast for `age`'s scrypt — as: the recovery codes, leaving the box **impossible to log into** — which a future re-walk needs. A fresh
password was set through the supported escape hatch (`--print-reset-code` → the real `/claim` form,
R-204 item 1) and stored `0600` in `~/.config/campaign11/`. And a normal off-site run was triggered to
populate the new `stats_known` field. Neither touches the recovery/escrow state the re-walk depends on.
> *„A megadott helyreállítási kódot nem fogadtuk el. Ellenőrizd, hogy mind a tíz szót pontosan, ## 2. Scenario A live, and its red-proof — the session's headline
> szóközökkel elválasztva írtad be…"*
**RED-PROOF.** Deleting the `if support := s.recoverySupport(...)` block from `recoveryUnlockHandler`: **Live, on the venue, with the SAME wrong code and only the hub's reachability changed:**
``` ```
--- FAIL: TestRecoveryGate_A_OldAgentIsNamed_NeverTheCode/agent_predates_the_route_(404) hub reachable → HTTP 400 "the recovery code did not open the sealed bundle — nothing was written"
R-216 RETURNED: an agent that cannot answer is still reported as a wrong recovery code hub REJECTed → HTTP 502 "the sealed recovery bundle could not be fetched from the hub —
--- FAIL: TestRecoveryGate_A_OldAgentIsNamed_NeverTheCode/agent_cannot_be_asked_at_all the recovery code was NOT used and nothing was written"
R-216 RETURNED: an agent that cannot answer is still reported as a wrong recovery code hub restored → HTTP 400 (back to the first)
``` ```
The accusation returns verbatim, on the assertion that names the finding. Restored; green. Controls: the hub read `http=302` before, `http=000 exit=7` while blocked, `302` after. **Before this
change both answered 400 with one merged sentence** — that is CAMPAIGN-11 F3, which told a customer
holding a *correct* code that it did not open their package.
*(The first attempt at this red-proof failed the test for weaker reasons — the fake recoverer **Red-proof (unit):** deleting the `ErrBundleFetch` case reproduces it exactly —
succeeded regardless of the agent verdict, so the accusation could not appear. The fixture now models `status: got 400, want 502 — {"error":"the recovery code did not open the sealed bundle…"}`. Deleting
the real agent: one that cannot answer the route also fails the call.)* the controller's `RecoveryHubUnreachable` case fails Scenario A with the accusation restored.
## The four messages, quoted for review as copy ## 3. Elapsed times, beside CAMPAIGN-11's originals
1. **The code did not open it** — the only one that mentions typing: | | 2026-08-05 (before) | now |
> „A megadott helyreállítási kódot nem fogadtuk el. Ellenőrizd, hogy mind a tíz szót pontosan, |---|---|---|
> szóközökkel elválasztva írtad be — a kis- és nagybetűk nem számítanak. Semmi nem változott, | hub unreachable, correct code | **0.0556 s** → accusation | **502**, classified, no unseal attempted |
> nyugodtan próbáld újra." | agent stopped, correct code | **0.0299 s** → accusation | **transport error** → agent-unreachable class |
2. **The machine cannot ask** (R-216): | genuinely wrong code ×3 | 1.194 / 1.004 / 1.014 s → accusation *without* the typing hint | **400** → the merged message **with** the ten-words prompt |
> „Ez a gép még nem tudja megnyitni a mentéseidet — a hozzá tartozó házon belüli szolgáltatás
> régebbi, mint amit ehhez a lépéshez használunk. A kódoddal semmi baj, és nem is használtuk fel:
> tedd el biztonságos helyen. A gép magától frissül; próbáld újra később, vagy szólj a Felhom
> ügyfélszolgálatának, ha egy nap múlva sem működik."
3. **The store could not be read** (R-217) — two shapes:
> „A kulcs visszakerült, de a mentések listáját most nem sikerült beolvasni. A mentéseid nincsenek
> veszélyben — nézd meg a Biztonsági mentés oldalt néhány perc múlva."
> „A kulcs visszakerült, és biztonságban van. A gép még várja a házon kívüli tárhely kapcsolódási
> adatait — amint megvannak, a mentéseid listája megjelenik a Biztonsági mentés oldalon. Nincs
> teendőd."
4. **A retained earlier package** (R-222) — states two facts and promises nothing:
> „Ez a kód nem nyitja meg azt a csomagot, amit most őrzünk ehhez a géphez. Ha egy korábbi kódot
> adtál meg: a géped azóta új mentési kulcsot kapott, és a régebbi csomagot (…) nem töröltük —
> megőrizzük. Megnyitni viszont innen egyelőre nem lehet, ezért ha a régebbi mentéseidre van
> szükséged, keresd a Felhom ügyfélszolgálatát. A kódoddal semmi nem történt, és semmi nem
> változott."
**Live proof of message 4**, on the exact box and the exact code that produced the defect in Phase 3 **A limit, stated plainly.** The customer-facing messages were **not** re-driven end-to-end on the
step 7: rendered verbatim with the real supersession timestamp `2026-08-05 15:03:14`, and venue, because `/recovery` correctly **redirects**: the previous session's F7 set the old data aside,
`blames typing = False`. which retires the offer by design. Restoring that state would be exactly the "reconfigure the venue"
§11 forbids. **The agent half is proven live (above); the controller's message selection is proven by
handler tests and their red-proofs.** That split is the honest description of what was verified how.
## Part 1b — the shape chosen, and why nobody types a number twice Similarly, the correct recovery codes were shredded at the end of the previous session per its own
§7, so the live re-run used a **wrong** code. That makes the test *harder*, not weaker: with the hub
down and a genuinely wrong code, the system must still resist the easy accusation — and it does.
`ResolveManagedFloor` compared the box's agent against `ArtifactManifest.MinAgent`, which by its own ## 4. Every changed Hungarian string, for review as copy
doc comment describes **the golden's** controller, while `publish-train-rules.md` rule 3 states the
rule about **the floor's**. They are the same number only while the floor sits at or below the golden.
**Chosen: a floor ABOVE the vouched golden is HELD**, with its own reason. **No new operator input at **New — hub unreachable:**
all** — the vouched MinAgent keeps its exact meaning and the guard simply stops applying it to a > „Most nem sikerült elérni a Felhom központi rendszerét, ezért a mentéseidet nem tudtuk megnyitni. A
version it does not describe. The rejected alternative was a second "floor MinAgent" field, which > kódodat NEM használtuk fel, és semmi nem változott — tedd el biztonságos helyen, és próbáld újra
would have the operator type the same CHANGELOG number twice for the same release. > néhány perc múlva. Ha egy óra múlva sem megy, szólj a Felhom ügyfélszolgálatának."
Live, immediately after deploy: **New — the machine's own service unreachable:**
> „A gép házon belüli szolgáltatása most nem válaszol, ezért a mentéseidet nem tudtuk megnyitni. A
> kódodat NEM használtuk fel, és semmi nem változott — tedd el biztonságos helyen. A gép magától
> rendbe jöhet; próbáld újra néhány perc múlva, és ha egy óra múlva sem megy, szólj a Felhom
> ügyfélszolgálatának."
``` **New — the hub holds no package:**
managed floor HELD for c11: held: floor 0.200.0 is ABOVE the vouched golden 0.192.0, so its agent > „Ehhez a géphez nem őrzünk lezárt csomagot, ezért nincs mit megnyitni. Ez nem a kódoddal van
requirement is unknown — vouch a golden carrying the floor's controller (publish-train rule 1) > összefüggésben. Ha korábban készültek házon kívüli mentéseid, szólj a Felhom ügyfélszolgálatának."
```
**⚠ What it does NOT do:** with the guard corrected and the Day-0 agent unchanged, a new box is **New — the package predates the field:**
**held, not served**. It stops being lied to; the feature works for it only after R-223. > „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."
**A slip caught by the deploy itself, and fixed in v0.97.1:** the first deployed build logged **New — the neutral default:**
`agent "0.125.0" < MinAgent 0.113.0` for a box held for the NEW reason — a comparison that is false. > „A művelet nem fejeződött be, és nem tudjuk biztosan, miért. Semmi nem változott, és a mentéseid
That is `CLAUDE.md`'s corollary (a verdict that changes which field it counts from must change its > érintetlenek. Próbáld újra néhány perc múlva — ha másodszorra sem sikerül, szólj a Felhom
alarm text). Both surfaces now come from one `ManagedFloorDecision.HoldReason()`, pinned by a test. > ügyfélszolgálatának."
## R-218's live proof — stated with its limit **CHANGED — the retained-earlier-package message (R-226 adds the mistype clause):**
> „Ez a kód **nem nyitotta meg** azt a csomagot, amit most őrzünk ehhez a géphez. **Két oka lehet, és
> innen nem tudjuk megkülönböztetni őket. Lehet elgépelés: ellenőrizd, hogy mind a tíz szót pontosan,
> szóközökkel elválasztva írtad-e be — a kis- és nagybetűk nem számítanak. Vagy** egy korábbi kódot
> adtál meg: a géped azóta új mentési kulcsot kapott, és a régebbi csomagot (…) nem töröltük —
> megőrizzük, megnyitni viszont innen egyelőre nem lehet. **Ha újrapróbálod és úgy sem megy,** és a
> régebbi mentéseidre van szükséged, keresd a Felhom ügyfélszolgálatát. A kódoddal semmi nem történt,
> és semmi nem változott."
**Proven by test + red-proof**, not yet by a live rebuild-shaped run. Restoring the deleted **NEW — the set-aside notice (R-228):**
short-circuit fails `TestOffsiteDeclare_StillDeclaresAfterARecoveredKeyIsPlaced` with *"the box went > „**A korábbi mentéseid félre vannak téve — nem töröltük őket.** Amikor új mentési kulcsot kapott a
SILENT after recovering its key while still having no off-site target"*. > géped, a régebbi előzményt átmozgattuk a távoli tárhelyen, és ott is maradt. **Megnyitni innen
> egyelőre nem lehet**, és ez nem a kódodon múlik. Ha szükséged van rá, keresd a Felhom
> ügyfélszolgálatát."
**The live half is deliberately not taken**: reproducing the stranded shape means reconfiguring the **CHANGED — the set-aside confirmation bullet (§7.6, it over-promised):**
campaign venue, and §11 forbids that because Phase 2 needs it as it is. What IS verified live on the > was: „a helyreállítási kód nélkül **többé nem lesznek megnyithatók**"
venue is Scenario E's side — the configured tier is silent — plus the whole R-222 ACK chain end to > now: „a félretett mentések **innen többé nem nyithatók meg** — sem kóddal, sem anélkül"
end (hub 0.97.1 → ACK → controller 0.201.0 → `settings.json`: `hub_escrow_superseded_present: True`,
`hub_escrow_superseded_at: 2026-08-05 15:03:14`).
## Tests and red-proofs **NEW — R-225's unknown states:** „a pillanatképek száma még ismeretlen" ·
„még nem tudjuk, mennyi van a tárolóban — legfeljebb N GB"
| Scenario | Test | Red-proof — what was mutated | Result | **NEW — R-227's gateway message:** „A gép éppen újraindul, ezért most nem tudtuk befejezni a
|---|---|---|---| műveletet. Semmi nem változott. Várj néhány másodpercet, és próbáld újra — a kódodra továbbra is
| A | `TestRecoveryGate_A_OldAgentIsNamed_NeverTheCode` | the capability gate deleted | **FAILED as required** | szükséged lesz, úgyhogy tartsd kéznél."
| A | `TestRecoveryGate_A_SupportedAgentProceeds`, `_CouplingIsDeclared` | — | pass |
| B/C | `TestResolveManagedFloor_R216_FloorAboveGolden` (5 sub-cases) | the floor-above-golden branch deleted | **FAILED as required** |
| — | `TestResolveManagedFloor_HoldReasonMatchesTheCause` | — | pass |
| D | `TestOffsiteDeclare_StillDeclaresAfterARecoveredKeyIsPlaced` | the short-circuit restored | **FAILED as required** |
| E | `TestOffsiteDeclare_ConfiguredTierIsSilent`, `_DisabledTargetIsNotStranded` | — | pass |
| F | `TestRecoveryGate_F_UnlockBringsTheTierUpBeforeListing`, `_TierNotUpYetSaysPendingNotFailed` | — | pass |
| G | `TestRecoveryGate_G_UnreadableStoreNeverClaimsToHaveOpened`, `_ThreeDistinctStates` | the zero-value inventory restored | **FAILED as required** |
| H | `TestRecoveryGate_H_SupersededPackageIsNamed`, `_WithoutASuperseded…` | — | pass |
| I | `TestRecoveryGate_I_DirectGetRefusedOnABoxThatNeverHadBackups`, `_StillWorksWhenOffered` | the direct-GET gate deleted | **FAILED as required** |
Green gates: `go build ./... && go vet ./... && go test ./...` rc=0 in both repos, read separately ## 5. How `rerr` was classified, and what it cost
from every commit. `controller_gates.py --fast` and `repo_gates.py --fast` both all-OK. The `-run`
filter was proven to match (13 `=== RUN` lines) rather than trusted.
## Deployed **From the value. It could not be done in this repo alone, and that is the cost.**
| | | `RecoverInstallCore` returns the agent's error verbatim, and the agent answered **HTTP 400 with one
|---|---| sentence** for both a failed fetch and a wrong code. **No value available to the controller separated
| controller `0.201.0` | campaign venue (c11 guest 9201) **and** demo-felhom guest 9201 — both `healthy` | them** — Scenario A and Scenario C were mutually unsatisfiable. The task scoped `felhom-agent` as
| hub `0.97.1` | GitOps: manifest bump → ArgoCD hard-refresh + deliberate sync → `Synced Healthy`, rollout complete, image verified | untouched; its §5 (*"if the step is not recoverable from the value, make it so — and say what that
| agent | **untouched** — v0.125.0 is what R-223 asks the operator to vouch, not what this changed | cost"*) and §4.3 (*the source wins*) authorise the expansion, and the source forced it.
CI: controller task **171** (`a3499d18`) success; felhom.eu tasks **172175** success. No **The cost:** a second repo, **agent v0.126.0**, published and installed on the venue
`--no-verify`; every pre-push gate ran and passed. (`7ecf8e9cdba237bc…`, verified by independent download); a new `escrow.ErrBundleFetch` sentinel; a
`MinAgent 0.126.0` coupling; and **an agent version that is deliberately NOT vouched** — so new
installs are unaffected until the operator vouches, and boxes on 0.125.0 simply degrade to the neutral
message. Verified live: demo-hp runs controller 0.202.0 on agent **0.125.0**, healthy.
## Still open, deliberately Within the controller, `agentapi.RecoveryRefusal` carries the status; `ClassifyRecoveryFailure` is the
single mapping point; **no branch reads the error text.** A test asserts the same sentence under two
statuses classifies two ways.
**R-214** (console pairing banner), **R-220** (drives unenrollable after a rebuild — the deploy refuses ## 6. §7.5 — which layer answers the gateway error
and the wizard's list is empty), **R-221** (a rebuilt box cannot run the escrow ceremony at all).
R-221 in particular is a real blocker for a different flow. **R-213** and **R-202** untouched.
## Observations, not acted on **traefik**, and **this repo generates its config** (`internal/infra/templates/traefik*.tmpl`). So the
fix *could* live here — but traefik v3 serves no static files, so a branded page would need a **new
always-up container** holding an error page for every 502 on the box. That is out of proportion to
this finding and is **scoped, not built**. Shipped instead: the sanctioned client-side alternative —
the unlock posts via `fetch` and answers a gateway failure in Hungarian in-page. **Progressive
enhancement: with no JS the plain POST is unchanged and still shows the proxy's error.**
1. **`SupportsWithSource` looks up `featureProbes` FIRST and returns `SupportUnknown` on a table gap, ## 7. What is true about the set-aside store today (§7.6)
before the version path runs.** So `FeatureBackupAgeState`, which has a `featureMinAgent` row and no
`featureProbes` row, can never be decided by version — it is always Unknown. This fix works around it **It cannot be opened by anyone** — not the customer, not the operator. Serving a superseded package
by registering a probe that returns a sentinel; the ordering itself is untouched (out of scope). is an unbuilt link (R-199's inventory). The screen therefore states two facts and stops, and the
2. The recovery screen renders `SealedAt` as a raw RFC3339 UTC string (`2026-08-05T13:11:11Z`) to a **confirmation copy was corrected** because it implied that *with* the code the history could be
Hungarian household; `recoverySealedAt()` is documented as "the human date". reopened. The field's own comment called it "recovery-code-recoverable" — the same over-promise in the
3. The escrow wizard's form says *„tíz szó"* while the issued code is hyphen-joined, and the code, also corrected.
wrong-code message says *szóközökkel elválasztva* (space-separated). Both forms should be accepted
or the copy aligned; not investigated. ## 8. Tests and red-proofs
`go build` · `go vet` clean · **28 controller packages ok** · **29 agent packages ok** · all controller
gates OK · all agent gates OK.
| Scenario | Red-proof mutation | Result |
|---|---|---|
| A | delete the `RecoveryHubUnreachable` case | **FAIL** — accusation returns |
| A (agent) | remove the `%w` join / delete the handler case | **FAIL** — 400 with the wrong-code sentence |
| C | remove the mistype clause | **FAIL** — ten-words prompt unreachable again |
| D | default to the accusing message | **FAIL** — unclassifiable blames the customer |
| E | route an instant transport failure to the typing message | **FAIL** on that row |
| F | remove the `StatsKnown` guards | **FAIL** — "an unread store reports a snapshot COUNT of zero" |
| H | delete the set-aside block | **FAIL** — "the set-aside history is not mentioned at all" |
**Two existing tests encoded the defect and were corrected, not deleted** — the web fake returned a
*bare* error for "wrong code" (the shape of an unclassifiable failure), and R-222's test forbade **any**
mention of typing on a superseded box, half of which R-226 deliberately reverses.
**A third test named the defect and did not prevent it.** The agent's
`TestRecoveryOffsiteRepoPassword_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"* — and passed
throughout, because it checked an error **string** one layer below where the merge happened. Mechanism
asserted, consequence unpinned.
## 9. Observations, not acted on
- **The Day-0 manifest still vouches agent 0.125.0.** A new box therefore lands on the old agent and
gets the neutral message rather than the typing hint. Vouching is deliberately the operator's act.
- **R-202's orphan card still promises recoverability** („a hozzá tartozó helyreállítási kóddal később
visszaállítható lehet"), which is the same over-promise this session corrected two doors away. It is
explicitly left open and is now the last place on that surface still saying it.
- The recovery screen's seal date still renders a raw RFC3339 string to a Hungarian household, in two
different formats on the same screen. Cosmetic; recorded in CAMPAIGN-11 and untouched.