diff --git a/CONTEXT.md b/CONTEXT.md index 29f27f8..da6796b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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) **The off-site key recovery is a COUPLED feature and now declares it.** It needs agent **0.125.0** diff --git a/REPORT.md b/REPORT.md index 38527ac..50a1e28 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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 -byte-identical; the journey did not exist.** This pass closes six of the nine findings. +## 1. The venue's state at the end — WORKING -## 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 -`POST /escrow/recover-offsite-password` with 404, the unlock was attempted anyway, and a **correct** -recovery code came back — in **0.134 s**, far too fast for `age`'s scrypt — as: +**Two changes to the venue, both restorations rather than reconfigurations, and both stated because +they were not asked for.** The previous session shredded the venue's dashboard password along with +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, -> szóközökkel elválasztva írtad be…"* +## 2. Scenario A live, and its red-proof — the session's headline -**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) - R-216 RETURNED: an agent that cannot answer is still reported as a wrong recovery code ---- FAIL: TestRecoveryGate_A_OldAgentIsNamed_NeverTheCode/agent_cannot_be_asked_at_all - R-216 RETURNED: an agent that cannot answer is still reported as a wrong recovery code +hub reachable → HTTP 400 "the recovery code did not open the sealed bundle — nothing was written" +hub REJECTed → HTTP 502 "the sealed recovery bundle could not be fetched from the hub — + the recovery code was NOT used and nothing was written" +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 -succeeded regardless of the agent verdict, so the accusation could not appear. The fixture now models -the real agent: one that cannot answer the route also fails the call.)* +**Red-proof (unit):** deleting the `ErrBundleFetch` case reproduces it exactly — +`status: got 400, want 502 — {"error":"the recovery code did not open the sealed bundle…"}`. Deleting +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: - > „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, - > nyugodtan próbáld újra." -2. **The machine cannot ask** (R-216): - > „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." +| | 2026-08-05 (before) | now | +|---|---|---| +| hub unreachable, correct code | **0.0556 s** → accusation | **502**, classified, no unseal attempted | +| agent stopped, correct code | **0.0299 s** → accusation | **transport error** → agent-unreachable class | +| 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 | -**Live proof of message 4**, on the exact box and the exact code that produced the defect in Phase 3 -step 7: rendered verbatim with the real supersession timestamp `2026-08-05 15:03:14`, and -`blames typing = False`. +**A limit, stated plainly.** The customer-facing messages were **not** re-driven end-to-end on the +venue, because `/recovery` correctly **redirects**: the previous session's F7 set the old data aside, +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 -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. +## 4. Every changed Hungarian string, for review as copy -**Chosen: a floor ABOVE the vouched golden is HELD**, with its own reason. **No new operator input at -all** — the vouched MinAgent keeps its exact meaning and the guard simply stops applying it to a -version it does not describe. The rejected alternative was a second "floor MinAgent" field, which -would have the operator type the same CHANGELOG number twice for the same release. +**New — hub unreachable:** +> „Most nem sikerült elérni a Felhom központi rendszerét, 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, és próbáld újra +> 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." -``` -managed floor HELD for c11: held: floor 0.200.0 is ABOVE the vouched golden 0.192.0, so its agent -requirement is unknown — vouch a golden carrying the floor's controller (publish-train rule 1) -``` +**New — the hub holds no package:** +> „Ehhez a géphez nem őrzünk lezárt csomagot, ezért nincs mit megnyitni. Ez nem a kódoddal van +> ö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 -**held, not served**. It stops being lied to; the feature works for it only after R-223. +**New — the package predates the field:** +> „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 -`agent "0.125.0" < MinAgent 0.113.0` for a box held for the NEW reason — a comparison that is false. -That is `CLAUDE.md`'s corollary (a verdict that changes which field it counts from must change its -alarm text). Both surfaces now come from one `ManagedFloorDecision.HoldReason()`, pinned by a test. +**New — the neutral default:** +> „A művelet nem fejeződött be, és nem tudjuk biztosan, miért. Semmi nem változott, és a mentéseid +> érintetlenek. Próbáld újra néhány perc múlva — ha másodszorra sem sikerül, szólj a Felhom +> ü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 -short-circuit fails `TestOffsiteDeclare_StillDeclaresAfterARecoveredKeyIsPlaced` with *"the box went -SILENT after recovering its key while still having no off-site target"*. +**NEW — the set-aside notice (R-228):** +> „**A korábbi mentéseid félre vannak téve — nem töröltük őket.** Amikor új mentési kulcsot kapott a +> 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 -campaign venue, and §11 forbids that because Phase 2 needs it as it is. What IS verified live on the -venue is Scenario E's side — the configured tier is silent — plus the whole R-222 ACK chain end to -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`). +**CHANGED — the set-aside confirmation bullet (§7.6, it over-promised):** +> was: „a helyreállítási kód nélkül **többé nem lesznek megnyithatók**" +> now: „a félretett mentések **innen többé nem nyithatók meg** — sem kóddal, sem anélkül" -## 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 | -|---|---|---|---| -| A | `TestRecoveryGate_A_OldAgentIsNamed_NeverTheCode` | the capability gate deleted | **FAILED as required** | -| 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** | +**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 +szükséged lesz, úgyhogy tartsd kéznél." -Green gates: `go build ./... && go vet ./... && go test ./...` rc=0 in both repos, read separately -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. +## 5. How `rerr` was classified, and what it cost -## Deployed +**From the value. It could not be done in this repo alone, and that is the cost.** -| | | -|---|---| -| controller `0.201.0` | campaign venue (c11 guest 9201) **and** demo-felhom guest 9201 — both `healthy` | -| hub `0.97.1` | GitOps: manifest bump → ArgoCD hard-refresh + deliberate sync → `Synced Healthy`, rollout complete, image verified | -| agent | **untouched** — v0.125.0 is what R-223 asks the operator to vouch, not what this changed | +`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 +them** — Scenario A and Scenario C were mutually unsatisfiable. The task scoped `felhom-agent` as +untouched; its §5 (*"if the step is not recoverable from the value, make it so — and say what that +cost"*) and §4.3 (*the source wins*) authorise the expansion, and the source forced it. -CI: controller task **171** (`a3499d18`) success; felhom.eu tasks **172–175** success. No -`--no-verify`; every pre-push gate ran and passed. +**The cost:** a second repo, **agent v0.126.0**, published and installed on the venue +(`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 -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. +## 6. §7.5 — which layer answers the gateway error -## 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, - 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 - by registering a probe that returns a sentinel; the ordering itself is untouched (out of scope). -2. The recovery screen renders `SealedAt` as a raw RFC3339 UTC string (`2026-08-05T13:11:11Z`) to a - Hungarian household; `recoverySealedAt()` is documented as "the human date". -3. The escrow wizard's form says *„tíz szó"* while the issued code is hyphen-joined, and the - wrong-code message says *szóközökkel elválasztva* (space-separated). Both forms should be accepted - or the copy aligned; not investigated. +## 7. What is true about the set-aside store today (§7.6) + +**It cannot be opened by anyone** — not the customer, not the operator. Serving a superseded package +is an unbuilt link (R-199's inventory). The screen therefore states two facts and stops, and the +**confirmation copy was corrected** because it implied that *with* the code the history could be +reopened. The field's own comment called it "recovery-code-recoverable" — the same over-promise in the +code, also corrected. + +## 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.