# REPORT — R-204 items 1 & 3 (controller v0.198.0), 2026-08-05 **Two of the four steps a customer would have hit alone.** The 2026-08-04 R-201 drill passed and only passed because a person was there. Items 1 and 3 are this repo's; item 2 is the hub's (felhom.eu v0.95.0); item 4 remains open (R-193). ## 1. Baselines, re-read on arrival | Repo | Expected | Found | |---|---|---| | `felhom-controller` | `f4796e0d00e9` / v0.197.0 | **exact match**, tree clean, `HEAD == origin/main` | §3.1's landmarks held: `claim.go:46` `effectiveClaimCode`, and the `--print-reset-code` block in `cmd/controller/main.go` loading its OWN `settings.Load` from `cfg.Paths.DataDir+"/settings.json"` — the separate-process seam the whole item turns on. §3.3's held: `mode` defaulted to `"unit"`, and the `full` path was two-step and size-gated. **One drift, in the sibling repo:** §3.2 described `offsite.go:222-231` carrying a known-consequence comment. That comment had already been rewritten (the R-196 comment-correction commit) and now gave a *non-false* ground for the mark — the guest-rebuild shape. That made Scenario D the crux rather than a formality; see the hub's REPORT. ## 2. Per-item outcome | Item | Outcome | |---|---| | **1 — the reset code needs a restart** | **CLOSED.** `effectiveClaimCode` reads through to the persisted claim state (`settings.ReloadClaimCode`) before applying the precedence rule, which is **unchanged**. Read-through only, on the claim path only; fails closed. | | **3 — the restore's default returns the wrong thing silently** | **CLOSED.** `restoreScratchOutcomeMsg` names what was restored, what was not, and the next step; the wizard card states its scope before the choice. Size gate untouched. | | **2 — a re-issue marks a healthy escrow stale** | The hub's half — shipped in felhom.eu v0.95.0. | | **4 — a rebuilt box cannot obtain an off-site credential unaided** | **REMAINS OPEN.** Dependency: the one-shot credential design decision. It needs an operator ruling and belongs to **R-193**. Not begun. | ## 3. The exact Hungarian strings added (for review as copy) **Unit-restore outcome** — with the scratch path resolved (the fallback drops `: `): > A(z) **<app>** beállításai és adatbázisa visszaállítva ellenőrző mappába: **<path>**. A saját > fájljaid (dokumentumok, képek, feltöltések) NEM kerültek vissza — ez az ellenőrző visszaállítás csak > az alkalmazás beállításait és adatbázisát hozza vissza. Ha a fájljaidra van szükséged, indítsd el a > „Teljes visszaállítás előkészítése” lépést ezen az oldalon. A meglévő adatok változatlanok. **Full-restore outcome:** > A(z) **<app>** teljes mentése visszaállítva ellenőrző mappába: **<path>** — a saját fájljaiddal > együtt. A meglévő adatok változatlanok. **Wizard intent card 1** (heading + body, stated BEFORE the choice): > **1. Ellenőrzés külön mappába (beállítások és adatbázis)** > Az alkalmazás beállításait és adatbázisát hozza vissza egy külön ellenőrző mappába. A saját > fájljaidat (dokumentumok, képek, feltöltések) **nem** hozza vissza — azokhoz a 3. pont teljes > visszaállítása kell. Az élő adataid nem változnak. **Claim fail-closed message** (new — shown on the page and on a refused submission): > A beállító állapot most nem olvasható — próbáld újra néhány perc múlva. ## 4. Was the restore default changed? NO — and why **The default stays `mode=unit`.** All three wizard forms set `mode` explicitly, so the `mode == ""` fallback in `offboxRestoreHandler` is reachable **only by a hand-crafted POST**. Changing it would alter nothing a customer sees while silently changing that POST's behaviour — and it would push a mode-less request into the size-gated two-step path. The defect was **silence**, not the choice of default, and silence is what was fixed: a mode-less POST now gets the same scoped wording, pinned by `TestOffboxRestore_DefaultModeGetsTheScopedOutcome`. **The size gate stays in every case.** ## 5. Files created / modified | File | Change | |---|---| | `controller/internal/settings/settings.go` | **new** `ReloadClaimCode()` — read-through of the persisted claim fields only | | `controller/internal/web/claim.go` | `effectiveClaimCode` reads through + returns an error; `claimGateActive`, `claimLegacyOpen`, `handleClaimPage`, `handleClaimSubmit` fail closed | | `controller/internal/web/claim_readthrough_test.go` | **new** — Scenarios A, B and the fail-closed case | | `controller/internal/web/offbox_handlers.go` | **new** `restoreScratchOutcomeMsg`; the handler calls it with `full` | | `controller/internal/web/offbox_restore_scope_test.go` | **new** — Scenarios E and F through the real handler | | `controller/internal/web/restore_wizard_test.go` | intent-card copy assertion updated to the new scope wording | | `controller/internal/web/templates/backups_restore_wizard.html` | intent card 1 states its scope | | `CHANGELOG.md`, `CONTEXT.md`, `controller/README.md` | v0.198.0 entry; session block; claim-hatch + restore-scope architecture notes | **Commits on `main`:** `73b6dbc` (item 1) · `2e936f4` (item 3) · `33fcc50` (CHANGELOG). **Deployed:** `gitea.dooplex.hu/admin/felhom-controller:0.198.0` on demo-felhom 9201 and demo-hp 9201. ## 6. Tests and red-proofs Green gate after each phase: `go build ./... && go vet ./... && go test ./...` — **full suite rc=0**; `python3 controller/scripts/controller_gates.py --fast` — **all eight gates OK**. | Test | Result | Red-proof — what was mutated | Outcome | |---|---|---|---| | `TestClaimCode_FreshlyMintedByHatch_AcceptedWithoutRestart` (A) | PASS | deleted the `ReloadClaimCode()` call in `effectiveClaimCode` | **FAILED** — *"freshly minted code refused… the running server did not see the persisted code"* | | `TestClaimCode_SupersededByASecondMint_RefusedImmediately` (B) | PASS | substituted a **per-instance 30 s TTL cache** for the read-through | **FAILED** — *"the SUPERSEDED code was accepted — a stale/TTL read left an old code alive"* | | `TestClaimCode_UnreadablePersistedState_FailsClosed` | PASS | both mutations above | **FAILED** both times (*"the gate OPENED on an unreadable claim state"*) | | `TestOffboxRestore_UnitOutcomeNamesWhatItDidNotRestore` (E) | PASS | reverted `restoreScratchOutcomeMsg` to the single pre-R-204 sentence | **FAILED** on all four assertions | | `TestOffboxRestore_DefaultModeGetsTheScopedOutcome` | PASS | same | **FAILED** | | `TestOffboxRestore_FullPathUnchanged` (F) | PASS | same | **FAILED** on the full-outcome assertion; the size-gate half stayed green, correctly | | `TestOffboxRestore_UnitRestrictsToTheUnitPath` | PASS | — (the mechanism half of E) | — | Each mutation removed every guard its test covers, except deliberately in the last row: reverting the message does **not** touch the size gate, and Scenario F's gate assertions correctly stayed green while its wording assertion failed. That split is the evidence the two halves are independent. **A red-proof that first failed for the WRONG reason, corrected and recorded** (task §10's exact trap). Scenario B's first TTL mutation used a *package-level* variable, so test A's earlier read had already opened the window and B failed with *"the gate is not active after the first mint"* — no code at all, rather than a superseded code surviving. Two fixes: the mutation was made **per-`Settings`** (what a real TTL fix would be), and the test was restructured so the server has **already seen the first code** — through `claimGateActive()`, the production path a page load takes — before the second is minted. Only then does the TTL demonstrably keep the old code alive. **Seams named.** The claim tests stub **nothing**: a real `settings.Load` on a real file, the real `PrintLocalResetCode` against a second `*Settings` loaded from the same path (the `docker exec` shape), and submission through `fullMux()` (RequireAuth + CsrfProtect + ServeHTTP). The restore tests stub **only the restic exec** (`SetOffboxRunner`) and drive `offboxRestoreHandler` itself, asserting the flash on `RestoreStatus().Last.Message` — the field the wizard renders — and waiting on a real completion marker (`!Running && Last.FinishedAt != 0`), never a fixed sleep. ## 7. Live validation (endpoint-level; `claude-in-chrome` is not available on DooPlex) | # | What | Observable | |---|---|---| | 1 | **A freshly minted code is accepted with nothing restarted** | demo-felhom 9201. Two codes minted through the real hatch (`docker exec … --print-reset-code`, generations 3 then 4). Container `StartedAt 05:21:28Z`, **`RestartCount=0`**, both mints after it. The **current** code reached *"A jelszónak legalább 12 karakter hosszúnak kell lennie"* — a **positive** observable reachable only **after** the hash, generation and expiry checks all pass. The same code was then submitted with the real password: **302 → `/`**, accepted end to end. | | 2 | **The previously valid code is refused** | The generation-3 code, superseded seconds earlier, returned **„Hibás vagy lejárt kód"**. No window. | | 3 | **A unit restore names what it did not restore** | demo-hp 9201, app `privatebin`, via `POST /backup/offbox/restore` (mode=unit), read back from `/api/backup/restore-status`: **„A(z) privatebin beállításai és adatbázisa visszaállítva ellenőrző mappába: /mnt/sys_drive/felhom-data/backups/offsite-restore/privatebin. A saját fájljaid (dokumentumok, képek, feltöltések) NEM kerültek vissza — ez az ellenőrző visszaállítás csak az alkalmazás beállításait és adatbázisát hozza vissza. Ha a fájljaidra van szükséged, indítsd el a „Teljes visszaállítás előkészítése” lépést ezen az oldalon. A meglévő adatok változatlanok."** | | 4 | **The full-restore size gate still computes and reveals** | Same box/app, `mode=full` **without** `confirm`: `302 → /backups/restore/app?name=privatebin&full_prep=privatebin&full_size=6.8+KB`, and **no restore ran**. | **Method:** authenticated `curl` against the container IP with the mandatory `Host` header, run inside the guest; Hungarian strings transported base64 so the `ssh → pct exec → bash` chain could not mangle an accented assertion. **demo-hp fences respected:** no wipe, no ceremony, no re-issue. `privatebin` was chosen precisely so the drill's `calibre-web` scratch stayed untouched — confirmed after teardown, it is the only directory left under `backups/offsite-restore/`. **Why 9201 on demo-felhom could not serve items 3–4:** its off-site repository is **orphaned with 0 snapshots** (`status: error`, the known R-193 guest-rebuild shape), so no restore can run there. ## 8. Teardown Nothing was provisioned. The outstanding generation-4 reset code on 9201 was **consumed** through the real claim flow using the box's existing password, so the box is left **claimed, healthy and with an unchanged password** and no live reset code; re-submitting it now returns „Hibás vagy lejárt kód". The `privatebin` restore scratch created on demo-hp was removed. All credential-bearing helper scripts were deleted from `/tmp` on both Proxmox hosts and both guests. No secret appears in any committed file — the minted codes are one-time, now consumed or superseded, and are referenced here only by generation number. **CI:** run **153** for `33fcc50` — success. `--no-verify` was **not** used; the pre-push gate ran and passed on every push. ## 9. Observations — noticed, NOT acted on - **`--print-reset-code` rewrites the WHOLE settings file** from its own `settings.Load`. If the running server persists a setting between that load and the hatch's save, the hatch's write wins and the server's change is lost. Not triggered here (the hatch is short-lived and operator-driven) and out of scope — but it is a genuine lost-update window on a shared file. - The hub's `allowedEventTypes` still lists **`escrow_stale`**, which now has **no producer** in either repo. Harmless, but inert; removing an allowlist entry is a behaviour change and was left alone. - `/` on DooPlex is at **86%** used. Under the 90% abort line, but worth watching before large builds.