R-543 closed: the household is asked for the recovery code (controller v0.245.0)
gates / gates (push) Successful in 21s

The tier-3 pause is the zero-knowledge escrow design and is untouched. What was
missing was the ASK, while the backup page promised the copy that had never run.

- VOLUNTEER-first-hour.md: a new step 6, right after the dashboard password and
  before the first app - what the code is, where, write it on PAPER, and that
  Felhom cannot get it back for them. Sections 6..12 renumbered to 7..13.
- day0-install.md A.2b: the operator step for a REBUILT box, which was missing.
  Acknowledged delete -> the hub re-issues by itself; otherwise ONE press of
  "Re-issue PBS credentials" (F-14 ruling 2026-07-13, hub/internal/web/pbsdr.go).
  This is the correction to last night's "zero presses" note.
- 07-backup-architecture.md: 6.1 records tier-3's paused state as a DESIGN, and
  2 records that the household is asked from first login.
- capability map: the first-hour row's last gap closed, with what it still does
  not claim (no volunteer has walked the ask from the written guide).
- register: R-543 CLOSED with the live measurements; R-545 filed (nothing
  un-configures an off-site target). R-511 was already closed yesterday.
- STATUS: the answered publish question removed (1.28.0 is live), readiness yes.
- evidence: red-proofs, the two-box live validation, teardown on three layers,
  and both of my own mistakes in this session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-16 21:20:51 +02:00
parent 1acd693854
commit d124c77e17
9 changed files with 309 additions and 33 deletions
@@ -0,0 +1,130 @@
## R-543 live validation — controller v0.245.0, 2026-09-16, demo-hp
## Method: endpoint-level. Every request below is the exact request the browser makes (same handler,
## same session cookie, same CSRF); only rendering is skipped. claude-in-chrome is not available here.
## Reachability note: neither guest answers on its LXC address from DooPlex, and the controller does
## not listen on the guest's 127.0.0.1 — it answers on the container address 172.17.0.2:8080 with the
## mandatory Host header. All requests therefore run INSIDE the guest via `pct exec`.
## Secrets: the dashboard password was read with scripts/read_credential.py (value never printed,
## file->file, 0600) and passed to curl as --data-urlencode password@<file>.
### THE SEAM, STATED
The two halves of this proof are on two boxes, not one box before and after a ceremony:
* PAUSED half — guest 9202 (scratch), off-site configured and escrow NOT complete.
* ESCROWED half — guest 9201 (demo), off-site configured, escrowed, running.
Running a real escrow ceremony on a fresh target would provision off-site storage, and this task's
fences put ep0 out of bounds. So the escrowed side is OBSERVED on a box that is already escrowed
rather than produced here. What is NOT weakened by the seam: both boxes run the same binary
(0.245.0), and the paused box's state was produced through the product's own configuration endpoint.
## ── 1. ESCROWED + ACTIVE — guest 9201 (0.245.0) ────────────────────────────────────────────────
login OK
GET /dashboard -> 200 bytes=60816 escrow-bar-hits=0
GET /launcher -> 200 bytes=44462 escrow-bar-hits=0
GET /backups/apps -> 200 bytes=106304 escrow-bar-hits=0
tier-1 file sentence, as rendered (2 class-A apps):
"Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi — ez a helyi mentés a
beállításokat és az adatbázist tartalmazza."
word counts on /backups/apps: Kulcslet=0 szünetel=0 védi=2 védené=0
=> a box whose recovery code exists is NOT nagged, and the promise it prints is a true one.
## ── 2. PAUSED — guest 9202 (0.245.0) ───────────────────────────────────────────────────────────
The state was produced through the product's own endpoint, POST /backup/offbox/config, with a
throwaway ed25519 key and a pinned known_hosts line. 9202 has no agent local API, so the escrow
STAGE could not run — the handler said so and saved the target anyway:
POST /backup/offbox/config -> 302
flash: "A távoli mentési cél elmentve. — a kulcs letéti előkészítése nem sikerült
(az ügynök nem elérhető); próbáld újra."
Persisted state afterwards (read from the guest's own settings.json):
enabled=True host=192.168.0.162 repo_path=/mnt/nvme-1tb/r543-paused-proof
escrow_state=pending last_run=None last_status=None snapshot_count=None
data/offbox/: known_hosts 95 B (0644) | repo_password 64 B (0600) | ssh_key 411 B (0600)
=> OffboxConfigured() is genuinely true (valid target + both secret files), and the escrow is NOT
complete. This is the state a fresh box lands in on day one.
### 2a. The bar is on EVERY page, not on the three someone remembered
GET /dashboard -> 200 escrow-bar-hits=1
GET /launcher -> 200 escrow-bar-hits=1
GET /backups/apps -> 200 escrow-bar-hits=1
GET /settings -> 200 escrow-bar-hits=1
GET /apps -> 404 escrow-bar-hits=0 (no such route; a 404 carries no bar)
Quoted from /dashboard:
"A távoli mentés szünetel, amíg nem hozod létre a helyreállítási kódot."
<a href="/backup/escrow"> (the route out, on the bar itself)
### 2b. A manual off-site run, while paused — refused, FOR THE RIGHT REASON
POST /backup/offbox/run -> 302
flash: "A távoli mentés a kulcs letétbe helyezésére vár."
after the attempt: last_run=None last_status=None snapshot_count=None
=> No snapshot, and the refusal came from the fork-4 escrow gate (offbox_handlers.go:224
OffboxRunnable), NOT from an unreachable target — the target was never contacted. This matters:
a run that failed to connect would have proven nothing about the pause.
### 2c. „Most nem" is for the visit only
POST /backup/escrow/banner/dismiss -> 302
Set-Cookie: felhom_escrow_banner=1; Path=/; HttpOnly; SameSite=Lax
^ no Max-Age and no Expires => a browser SESSION cookie, exactly as R-241 does it
same visit, cookie sent -> escrow-bar-hits=0
next visit, no cookie -> escrow-bar-hits=1
=> the off-site tier is still paused tomorrow, so the question is still asked tomorrow.
### 2d. The tier-1 FILE sentence, live, with the tier paused
The first pass of this phase could not show it: 9202 had no class-A app, so the sentence had nothing
to render („védi"=0 AND „védené"=0 — the „szünetel"=1 on that page was the BAR in the layout, not the
sentence). Recorded because it was briefly written down as a limit and it was not one. A throwaway
class-A app was then deployed on the box's own drive and the off-site copy turned on for it:
POST /api/stacks/calibre-web/deploy -> 202 {"ok":true,"message":"Telepítés elindítva ..."}
(HDD_PATH=/mnt/felhom-drives/scratch_hdd — the guest's own registered data drive, 938 G)
POST /backup/offbox/toggle app=calibre-web enabled=true -> 302
Rendered on /backups/apps, with the tier configured and PAUSED:
"Az alkalmazás fájljait a távoli másolat védené — a távoli mentés a helyreállítási kód
létrehozásáig szünetel."
...followed by the route: "Helyreállítási kód létrehozása →"
word counts on /backups/apps: Kulcslet=1 szünetel=3 védi=0 védené=1
Kulcslet=1 is the tier-3 row's own state („Kulcsletétre vár").
szünetel=3 is the bar + the sentence + the tier row.
védi=0 is the point: the page no longer claims a protection that has never run.
=> Both wordings are now observed LIVE on real boxes running 0.245.0: „védi" on the escrowed box
(9201, §1) and „védené … szünetel" on the paused box (9202, here).
## ── 3. TEARDOWN, three layers ──────────────────────────────────────────────────────────────────
Recorded after the evidence above was already written to DooPlex (R-320: evidence leaves the machine
at the END OF THE PHASE, before any revert — not at the end of the session).
MACHINE (guest 9202, scratch):
* the throwaway class-A app removed WITH its data and its backups:
POST /api/stacks/calibre-web/stop -> 200
POST /api/stacks/calibre-web/remove -> 200 {"removed":"calibre-web",
"volumes_removed":["calibre-web_calibre_web_config"],"hdd_paths_removed":[],
"hdd_note":"Az alkalmazás nem tárolt saját adatot…"}
verified after: containers named calibre = 0, drive folders = 0
* the off-site target this proof created is GONE:
before: offbox present=True, data/offbox/ held ssh_key, repo_password, known_hosts
after : offbox present=False, data/offbox/ ABSENT, all three files `shred -u`'d
MY OWN MISTAKE, recorded: the first teardown pass used the CONTAINER's view of the data path
(/opt/docker/felhom-controller/data) from a shell running in the GUEST, where that path does
not exist. It printed „offbox dir now: ABSENT" — which was TRUE of a path that never existed
and FALSE of the thing being claimed. The target was still fully configured. The same wrong
path had already produced three FileNotFoundError tracebacks earlier in this phase; I read
those as noise instead of as the instrument telling me it was pointed at nothing. The re-run
stops the controller first, edits the real file, shreds the secrets, restarts, and RE-READS
the state to confirm — a teardown asserted is not a teardown observed.
* controller restarted and healthy: felhom-controller:0.245.0 Up (healthy)
* temp files: none left (`ls /tmp/.r543*` empty in the guest)
* NOT touched: filebrowser, traefik, the box's registered data drive, its password, its claim state.
HOST (demo-hp): /tmp/.r543pw and /tmp/.r543key shredded; .r543kh, the scripts and one stray
/tmp/.r543out removed. Verified: 0 files matching /tmp/.r543* remain.
HUB: provisioned nothing. Guest 9202 has hub reporting OFF and no tunnel, so no customer, no host
record, no escrow row and no event was created by any of this. Nothing to tear down.
NOT torn down, deliberately: guest 9201 now runs controller 0.245.0. That is the release being
shipped, not a drill artifact, and its own off-site tier was never touched (still escrowed, active).
@@ -0,0 +1,48 @@
## R-543 red-proofs — controller v0.245.0, 2026-09-16, DooPlex
## Each fix was BROKEN first and the test was watched convicting it. A test never seen failing has
## not been shown to test anything.
### RED-PROOF 1 — the reminder bar
Break: delete `s.addEscrowBanner(data, r)` from executeTemplate (internal/web/server.go).
That is the whole wiring: the bar hangs off the single render choke point, so removing one line
returns the product to the measured 2026-09-16 state (a paused off-site tier, and silence).
--- FAIL: TestR543_A_PausedBoxAsksOnEveryPage (0.20s)
r543_escrow_banner_test.go:72: R-543: /dashboard does not tell the household the off-site copy is PAUSED. The tier is on, nothing is running, and the page is silent about it
r543_escrow_banner_test.go:76: R-543: /dashboard states the pause but names no route to end it — a reminder without its door is the shape that left a fresh box waiting indefinitely
r543_escrow_banner_test.go:72: R-543: /launcher does not tell the household the off-site copy is PAUSED. The tier is on, nothing is running, and the page is silent about it
r543_escrow_banner_test.go:76: R-543: /launcher states the pause but names no route to end it — a reminder without its door is the shape that left a fresh box waiting indefinitely
FAIL gitea.dooplex.hu/admin/felhom-controller/internal/web 0.208s
Note BOTH pages fail. That is the point of the hook placement: a per-handler helper would have
covered the three pages someone remembered, which is the seam-built-but-never-wired shape.
### RED-PROOF 2 — the tier-1 sentence
Break: return the v0.244.0 wording from driveFilesNoteFor before the state switch, i.e. compute the
sentence from the app's shape alone, exactly as v0.244.0 shipped it.
--- FAIL: TestR543_Tier1Sentence_PausedStateDoesNotPromise (0.00s)
r543_tier1_sentence_test.go:38: R-543: the sentence claims the files ARE protected while the copy is paused for the recovery code. This is the exact promise a fresh box read for its whole first day: "Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi — ez a helyi mentés a beállításokat és az adatbázist tartalmazza."
r543_tier1_sentence_test.go:42: R-543: the paused sentence must say the copy WOULD protect them and that it is waiting; got "Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi — ez a helyi mentés a beállításokat és az adatbázist tartalmazza."
r543_tier1_sentence_test.go:46: R-543: the paused sentence names no route out (link="" text="")
FAIL gitea.dooplex.hu/admin/felhom-controller/internal/web 0.007s
### RESTORED
go test ./internal/web/ -run 'R543'
ok gitea.dooplex.hu/admin/felhom-controller/internal/web 0.895s
### The filter was proven to match (the `-run` trap: a pattern matching nothing prints `ok`, exit 0)
=== RUN TestR543_A_PausedBoxAsksOnEveryPage --- PASS (0.29s)
=== RUN TestR543_B_EscrowedBoxIsNotNagged --- PASS (0.19s)
=== RUN TestR543_C_UnconfiguredBoxIsNotNagged --- PASS (0.20s)
=== RUN TestR543_D_DismissIsForThisVisitOnly --- PASS (0.28s)
=== RUN TestR543_Tier1Sentence_ActiveStateKeepsThePromise --- PASS
=== RUN TestR543_Tier1Sentence_PausedStateDoesNotPromise --- PASS
=== RUN TestR543_Tier1Sentence_NoCopyAtAllSaysSo --- PASS
=== RUN TestR543_Tier1Sentence_SecondDriveCounts --- PASS
=== RUN TestR543_Tier1Sentence_NoFileLegsNoSentence --- PASS
### Fixture validity (an instrument that can lose its precondition measures nothing)
escrowServer asserts backupMgr.OffboxConfigured() itself before any assertion runs: the target is
enabled and valid AND the ssh_key + repo_password files exist on disk. A fixture that silently fell
back to "not configured" would make every one of these tests pass for the wrong reason.