Scenario A's live result first: on demo-hp in the rebuilt shape, no key was minted on the real start-up offsite-apply path, and the hub received the state it reports instead - offsite.state=awaiting_recovery_key with enabled:false. Key restored byte-identical afterwards. Includes Q4's seven rows mapped to the three states, the SEC 7.2 choice and why, SEC 7.3's answer on the new-code button, every changed Hungarian string quoted, all nine red-proofs with what was mutated, the R-245 reasoning, and three observations noticed but not acted on.
16 KiB
REPORT — R-241 fixed: the box does not mint over a sealed package (2026-08-07)
controller v0.206.0 · hub v0.98.0 · deployed to both demo boxes · felhom-agent untouched
Implements the ruling in SPIKE-r241-recovery-offer-2026-08-07.md. No STOP was required and none was
taken: nothing here deletes customer data.
1. SCENARIO A — the live result, and its red-proof
THE SESSION, in one measurement. On demo-hp, controller 0.206.0, with the hub holding a sealed
package: the repository key was moved aside to create the rebuilt shape, and the controller restarted
through its real start-up off-site-apply path.
=== 1. was a key minted on the startup offsite-apply path? ===
NO repo_password — the guard held
applied_marker known_hosts repo_password.r241-live-check ssh_key
And the state it reports instead, read from the hub's stored report, not from the box:
{"enabled": false, "escrow_state": "escrowed", "state": "awaiting_recovery_key",
"snapshot_count": 0, "repo_size_bytes": 0, "quota_gb": 0}
enabled:false is what keeps every existing hub reader inert; the string is what names the difference.
The key was then restored and re-hashed: 8a9e33aa4da6769c5aea1831f87759e10930e2ec1dea0062576484e0598d080a
— byte-identical to before. The box is healthy on 0.206.0.
RED-PROOF A — the fresh key returns under the mutation. The guard block was deleted from
WriteOffboxSecrets, the mutation was confirmed present in the file, and both Scenario A tests failed:
offbox_mintguard_r241_test.go:82: R-241 REGRESSION: apply minted a repository password over the sealed package
--- FAIL: TestR241_ScenarioA_NoMintWhenHubHoldsSealedPackage
--- FAIL: TestR241_ScenarioA_ApplyOffsiteTargetHoldsInsteadOfMinting
--- PASS: TestR241_ScenarioB_FirstTimeBoxStillMints ← the mutation is SPECIFIC
RED-PROOF B — the over-broad fix. Dropping the GetHubEscrowIdentityPresent() conjunct made
Scenario B fail ("a first-time box must mint exactly as before") while Scenario A still passed. The
guard is a conjunction because both failure directions are real.
2. Q4's seven rows, mapped to the three states
| # | Q4 state | resolves to | note |
|---|---|---|---|
| 1 | never had off-site backups | settled | fact 1 fails; nothing offered |
| 2 | pristine rebuild, credential not yet arrived | offered (shape a) | no longer a closing window — the mint guard means it does not end by itself |
| 3 | self-healed with a fresh key, hub holds the older package ← the venue | offered (shape c) | the row R-241 was, and it now cannot be entered at all — the guard prevents the key |
| 4 | healthy, key matches | settled | shape (c) compares and matches |
| 5 | re-escrowed, old package retained | settled | unchanged; the retained package is still unreadable (R-199) |
| 6 | orphaned (a run proved it) | offered (shape b, corroborated by c) | (b) retained as a corroborator |
| 7 | customer set the old data aside | abandoning, then settled | the spike's trap: (c) alone would re-offer for ever. The countdown resolves it by removing both halves |
Every row fits. Row 3 is the interesting one: it is now unreachable rather than merely handled — the fix removes the state instead of describing it.
3. §7.2 — what a stale comparison resolves to
A KNOWN DIFFERENCE OFFERS, however old the reading. An ABSENT HASH falls back to (a)/(b).
Age is deliberately not gated on. Both sides of the comparison are local; only the hub's half can
be stale, and what the hub holds does not change without a ceremony this box runs — which refreshes
the hash on the next ACK. Gating on age would add a second failure mode (a box offline from the hub
silently stops offering) to fix a window that closes itself. CheckedAt is persisted for diagnosis.
An empty hash is not an unknown: it is the hub positively saying its package seals no repository password (a legacy hash-less escrow). Offering on it would put a permanent screen in front of every legacy box.
This path was exercised on real hardware, unplanned. demo-hp's escrow row carries
stale_at = 2026-08-04 20:15:49 from the R-201 drill, so the hub withholds the hash — and the live box
recorded hub_escrow_key_sha256 = "" with checked_at set. It correctly did not offer. On
demo-felhom, where the hub does serve it, the recorded hash is byte-identical to the local key:
local key hash = c60c8bc737a6b7c6647c7849283f52087f650a885babedb4ef5fdf9a5c9543cb
hub_escrow_key_sha256 = c60c8bc737a6b7c6647c7849283f52087f650a885babedb4ef5fdf9a5c9543cb
The fact that was computed on every ACK and kept nowhere is now on disk, on a live box.
4. §7.3 — the „Helyreállítási kód létrehozása" button
Made UNAVAILABLE while a recovery is outstanding, not merely captioned. Creating a new code seals the current key, demotes the package that opens the earlier history to retained custody no shipped path can read (R-199), and re-enables the recovery screen through the orphan route while invalidating the code that screen accepts — a trap that looks like progress.
A warning beside a button is a warning people click past. The card now explains and points at
/recovery, where both real choices live.
5. Every changed Hungarian string
The abandon confirmation (recovery.html) — §2.4. It used to promise „félretesszük — nem
töröljük", which after this change would be false:
„a korábbi mentéseket most félretesszük, és {N} nap múlva véglegesen töröljük — a lezárt helyreállítási csomaggal együtt;" „a {N} nap alatt meggondolhatod magad: ha előkerül a helyreállítási kódod, a mentéseid visszaszerezhetők, és a törlés elmarad;" „a pontos dátumot a Távoli mentés oldalon végig látni fogod, és emlékeztetni is fogunk;" „a gép új, üres mentési tárolót kezd, és mostantól oda ment;" „a törlés után ez a kérdés nem jön vissza többé — mert nem marad mit visszaszerezni." „Ha csak most nincs kéznél a kódod, válaszd inkább a „Most nem" lehetőséget — az semmit nem indít el."
(N is rendered from backup.AbandonGraceDays, never a literal in prose.)
The blocked new-code card (backups_remote.html):
„Ehhez a géphez egy korábbi helyreállítási kód tartozik, és a korábbi mentéseid még megvannak. Új kód létrehozása a régi mentéseidet elérhetetlenné tenné, ezért most nem indítható. Előbb add meg a meglévő kódodat — vagy ott jelezheted, ha nem kéred vissza a korábbi adatokat."
The countdown card (backups_remote.html):
„A korábbi mentések törlése folyamatban" „A kérésed szerint a korábbi távoli mentéseidet {dátum} napján véglegesen töröljük (még {N} nap). Addig meggondolhatod magad: ha megvan a helyreállítási kódod, a mentéseid visszaszerezhetők, és a törlés elmarad." „Mégis visszaszerzem a kóddal"
Post-deletion (backups_remote.html):
„A korábbi távoli mentéseid törlése megtörtént. A hozzájuk tartozó lezárt helyreállítási csomag eltávolítása még folyamatban van."
The reminder bar (layout.html) — abandoning, then the undecided ladder:
„A korábbi távoli mentéseidet {N} nap múlva ({dátum}) véglegesen töröljük, a kérésed szerint. Addig még visszaszerezheted őket a helyreállítási kóddal." „Két hete várnak rád a korábbi távoli mentéseid, és még nem adtad meg a helyreállítási kódodat. Amíg nem teszed, ezekhez a mentésekhez nem férsz hozzá." (14 days) „Már egy hete megvannak a korábbi távoli mentéseid, de a helyreállítási kódod nélkül nem tudjuk megnyitni őket." (7 days) „A korábbi távoli mentéseid megvannak — a megnyitásukhoz a helyreállítási kódod szükséges." (3 days) „A korábbi távoli mentéseid megvannak, de ehhez a géphez a helyreállítási kódod szükséges." (base) Buttons: „Megnézem" · „Most nem" · „Ne emlékeztessen újra"
Hub operator event (offsite_abandon_purged):
„Az ügyfél korábbi távoli mentései és a hozzájuk tartozó megőrzött helyreállítási csomag is törölve ({n} csomag). Az ügyfél döntése alapján, a 14 napos türelmi idő lejárta után."
Controller operator event (offbox_abandon_completed):
„A korábbi távoli mentések a türelmi idő lejártával törlésre kerültek, az ügyfél döntése alapján. A hozzájuk tartozó lezárt helyreállítási csomag eltávolítását is kértük."
6. Tests and red-proofs
33 R-241 tests across three packages; full suite green in both repos.
| Group | Scenario | Result |
|---|---|---|
| A | no key minted over a sealed package; apply holds and stages nothing | PASS |
| B | a first-time box still mints | PASS |
| C | a differing key offers recovery (both proxies asserted false first) | PASS |
| D | a matching key offers nothing | PASS |
| E | abandon: aside, package kept, countdown, offer still reachable, nothing deleted | PASS |
| F | the terminal step removes both halves; the declaration repeats; the close-out | PASS |
| G | recovery inside the window cancels the countdown | PASS |
| H | per-visit banner; opt-out silences the banner only | PASS |
| I | the operator can extend or stop a countdown | PASS |
| — | §7.2 both halves; shape (a) intact; fact 1 intact; Scenario-E carve-out; nil-settings fail-safe; idempotency; no-op sweep silent; unclaimed auto-reset starts no countdown; failure leaves the countdown due; levers refuse after deletion; same-site redirect | PASS |
Nine red-proofs. Every mutation was confirmed present in the file before its result was trusted.
| # | Mutation | Outcome |
|---|---|---|
| 1 | mint guard block deleted | Scenario A FAILS (R-241 REGRESSION: apply minted…); B still passes |
| 2 | guard over-widened (hub-package conjunct dropped) | Scenario B FAILS (first-time box cannot start); A still passes |
| 3 | hubHash != localHash conjunct dropped |
Scenario D FAILS (healthy box offered for ever); C still passes |
| 4 | RecordEscrowKeyHash unwired in main.go |
wiring test FAILS — the ships-inert shape: everything compiles, every package test passes, the auto-confirm still works, and shape (c) reads an empty hash for ever |
| 5 | store deletion skipped in the sweep | Scenario F FAILS (no rm issued) |
| 6 | AbandonPurgeRequested dropped from the report |
Scenario F FAILS (the hub is never asked; the package would outlive the store) |
| 7 | CancelAbandon made a no-op |
Scenario G FAILS (uncancellable countdown) |
| 8 | (covered by 5/6 — the two halves are independently proved) | — |
| 9 | (covered by 2 — the guard's own failure direction) | — |
The countdown is driven by an injected clock throughout (§7.4). No live timer was shortened, and the terminal step has only ever run against fakes.
Two real bugs, caught by tests rather than by review
OffboxAwaitingRecoveryKeyomittedt.Enabled— a customer who had switched off-site off would have declared a holding state. Caught by the existingTestOffsiteDeclare_DisabledTargetIsNotStranded. Now pinned from the new predicate's side too.recoveryInterruptsreturned early when the offer was false, so the falling edge was never recorded and the next entry counted as a continuation — the exact defect the epoch exists to fix, reintroduced inside the fix. Caught byTestR241_FullPageAppearsOncePerEntryNotOnceEver.
7. §7.5 — the automatic ending: recorded, NOT built → R-245
The operator's proposal: a box offered recovery for 30 days without a decision is auto-abandoned into the 14-day grace.
The reasoning against it, recorded with it so it can be revisited properly: (1) nobody is absent — a box does not reinstall itself, so whoever rebuilt it met the recovery question; a reinstall implies a person. (2) A customer who cannot find their code gets in touch, so the automation would fire at people we are already talking to — which is why the levers were the thing worth building. (3) The cost is theirs: the old history sits in their own storage allowance. (4) The real harm is QUOTA, and that is a condition, not a calendar — an automatic ending should trigger on the harm with a dated warning, never on a date alone.
Built instead: escalating reminders, and --abandon-status / --abandon-extend=N /
--abandon-stop, both of which refuse rather than no-op when nothing is running or the store is
already gone. A silent success is what an operator most easily mistakes for "handled".
8. Files and commits
felhom-controller — 763de3a025a5 (mint guard) · a491abef6c20 (discriminator) ·
a5d90ff80120 (countdown) · de39e47f53be (surface) · 72368654e421 (reminders + levers) ·
0a9158d53eb8 (docs). Deployed: 0.206.0.
New: backup/offbox_abandon.go, backup/offbox_mintguard_r241_test.go,
backup/offbox_offer_shapec_r241_test.go, backup/offbox_abandon_r241_test.go,
web/recovery_surface_r241_test.go.
Modified: backup/offbox.go, backup/backup.go, settings/settings.go, report/escrow_confirm.go,
report/escrow_presence_wiring_test.go, cmd/controller/main.go, web/{recovery_handlers,handlers,server,auth}.go,
web/recovery_test.go, templates/{layout,recovery,backups_remote}.html,
CHANGELOG.md, CONTEXT.md, REUSE.md, controller/README.md.
felhom.eu — ac4b2a4ba934 (hub purge) · 9657334fb72a (registers, map, STATUS, hub CHANGELOG) ·
b12f8ec2f32f (manifest). Deployed: felhom-hub:0.98.0, Synced/Healthy.
9. Registers
R-241 FIXED. R-243 UPDATED, not closed — the state can no longer be entered and what replaces it is visible rather than silent, but the alarm gap is untouched: a box whose customer never acts still stops backing up with no operator signal. R-245 NEW (WAITING-ON-OPERATOR). R-242 stays recorded-not-built. R-244 untouched. Still open and named: R-240, R-213, R-202, R-214.
Highest register ID moves R-244 → R-245.
10. The capability-map row
Still FAIL. These are fixes, not a walk — nothing here walked a customer end to end, and the row goes green only when one completes with no operator intervention AND a byte-identical sentinel.
11. CI
felhom-controller run 237 (0a9158d53eb8) success · felhom.eu runs 238 (9657334fb72a)
and 239 (b12f8ec2f32f) success — matched by head_sha, pulled rather than assumed.
--no-verify was NOT used; the pre-push hook ran the gates on every push.
12. Observations — noticed, NOT acted on
- demo-hp's escrow blob is flagged stale (
stale_at = 2026-08-04 20:15:49, from the R-201 drill), so the hub withholds its hash and shape (c) can never fire there. Correct per §7.2, and it gave the fallback path a free live exercise — but that box's escrow has been stale for three days and nothing has surfaced it. Possibly worth a row; not filed, because it is R-198/R-196 territory and I did not measure whether the staleness is real or an artefact of the drill. - The offer epoch advances on a landing-page visit, not on the report cycle. Correct for the interruption and the banner (both only matter when someone visits), but it means the undecided reminder ladder starts from the first visit rather than from the first report. Deliberate; stated here because it is a design choice a reader could mistake for an oversight.
--abandon-*runs inside the guest, i.e. a guest command line. Acceptable for operators (it sits beside the existing operator subcommands) but it is not a hub surface, so it is unavailable to an operator who cannot reach the box.