Files
felhom-controller/REPORT.md
T
admin 3d3b4496f3
gates / gates (push) Successful in 22s
REPORT.md — R-241 fixed, deployed, live-validated on both demo boxes
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.
2026-08-07 12:23:55 +02:00

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

  1. OffboxAwaitingRecoveryKey omitted t.Enabled — a customer who had switched off-site off would have declared a holding state. Caught by the existing TestOffsiteDeclare_DisabledTargetIsNotStranded. Now pinned from the new predicate's side too.
  2. recoveryInterrupts returned 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 by TestR241_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-controller763de3a025a5 (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.euac4b2a4ba934 (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.