R-201 night run: the off-site key IS recoverable after a real rebuild (proven); the verdict is blocked by R-204
gates / gates (push) Successful in 6s

This commit is contained in:
2026-08-04 22:33:01 +02:00
parent 24acc8d816
commit b228fd102d
7 changed files with 435 additions and 182 deletions
+27
View File
@@ -17,6 +17,33 @@
## Standing rulings ## Standing rulings
**S-31 — the off-site key IS recoverable after a real rebuild; USING it is blocked by the remedy that
makes the rebuild survivable (2026-08-04 night drill, R-201/R-204).**
**Proven on hardware:** demo-hp's controller data volume was destroyed and the sentinel deleted; the
customer's recovery code produced `8a9e33aa4da6…`, byte-identical to the pre-wipe on-disk key and to
the hub's independent record, and installed cleanly on the bare box. `identity_blob` was unchanged
across the wipe — nothing re-escrowed itself.
**The wall (R-204), four links, all measured:**
1. a rebuilt controller cannot configure its off-site tier — the one-time password was consumed by its
predecessor (`no unconsumed offsite password`, R-193);
2. the Re-issue that fixes that sets `stale_at` **while `restic_pw_sha256` is unchanged** (R-196);
3. a stale escrow makes the hub withhold the hash from the ACK → `EscrowAutoConfirmer` can never flip
`pending → escrowed``OffboxRunnable` refuses every run;
4. the only documented way to clear it is a ceremony, **which supersedes the identity blob and destroys
the recovered key**. And before any of it, a rebuilt box is **unclaimed**, so the claim gate
intercepts every controller endpoint — a step in no design document.
*Facts a future session needs:*
- **A guest rebuild in this fleet is a controller-DATA-VOLUME loss, not a guest reprovision.** The
2026-08-03 incident R-193 is filed against ran with guest 9201 up throughout — no `pct destroy`, no
`pct restore`, no `--selftest=provision`. Reproduce it that way.
- **A good snapshot is not durable against a later bad run on the same day.** `forget --keep-daily 7
--group-by host,tags` keeps one per tag per day; a later, worse snapshot evicts a good one.
- **Never run a ceremony while a recovery is in flight** — it supersedes the identity blob. Under
v0.93.0 the old blob is retained, but nothing serves a superseded blob back (R-199).
**S-30 — the R-201 drill was PREPARED and HALTED BEFORE THE WIPE (2026-08-04). Nothing was wiped.** **S-30 — the R-201 drill was PREPARED and HALTED BEFORE THE WIPE (2026-08-04). Nothing was wiped.**
It stopped at step 4 because the sentinel file was **not in the off-site snapshot** while the run It stopped at step 4 because the sentinel file was **not in the off-site snapshot** while the run
+113 -158
View File
@@ -1,172 +1,127 @@
# REPORT — R-203: the app and its backup look in the same place, and "ok" means it (2026-08-04) # REPORT — R-201 night run: the key came back; the verdict did not
**Controller v0.196.0 → v0.197.0**, deployed to demo-hp. No hub change. Nothing deleted, wiped or **2026-08-04, 21:3022:40, unattended.** `demo-hp` was deliberately rebuilt. No code, no version bump.
moved on any box; the drill was **not** resumed. `demo-felhom` untouched. `demo-felhom` untouched. Full record: `documentation/audits/DRILL-r201-night-run-2026-08-04.md`.
--- ---
## 1. Confirmed baselines, and §3's landmarks ## 1. THE VERDICT — not reached
`felhom-controller` `532f5712a891` (v0.196.0) → **v0.197.0**; `felhom.eu` `a0c4b607a6cf`, no bump. | | |
Both trees clean on arrival. **Every §3 landmark held**, including the one that decided the fix:
`paths.go:26` names the system-drive arrangement *"the SSD-only system-data fallback"* — **it is
supported**, so the resolution was what was wrong and no API refusal was added.
## 2. The call sites found — **FIVE, not four**
| # | Site | Named by the spec? |
|---|---|---|
| 1 | `stacks/deploy.go` `withPathVars``${USERDATA_PATH}` | yes — the live defect |
| 2 | `appexport/fabplan.go` | yes |
| 3 | `appexport/export.go` | yes |
| 4 | `stacks/delete.go` `ExportDataMounts` | yes |
| 5 | **`web/handlers.go` FileBrowser mount builder** | **NO** |
The fifth is the customer's own file browser: on a non-enrolled path it would have mounted the wrong
directory. **Latent, not live** — the system drive is deliberately never a registered `StoragePath`, so
the resolver is the identity there today. Wired anyway, with that reason in the code.
Two further sites of the same class were found and fixed: `ComputeFabBuckets` was receiving the drive
path where `ComputeCaptureSet` has always received the namespace root, so the export's classified
paths and the backup's capture set could describe different directories for the same declared bind.
**The rule had TWO existing copies and they differed.** `backup.Manager.namespaceRoot` compared
without `filepath.Clean`; `stacks.Manager.inGuest` compared with it. A trailing slash from config
would have flipped the mode in one package and not the other. Both now delegate to
`appbackup.NamespaceRootFor`.
## 3. Scenario A — the two paths, before and after (live, demo-hp)
```
before: /mnt/sys_drive/userdata/media/books ← app bind; capture set looked elsewhere
after: /mnt/sys_drive/felhom-data/userdata/media/books ← app bind == capture root
```
Capture log: **`0 mandatory path(s)`** → **`1 mandatory path(s)`**.
## 4. Scenario F — the sentinel in the snapshot's file listing
```
$ restic ls -l latest --tag calibre-web
-rw-r--r-- 1000 1000 181 2026-08-04 12:53:06
/mnt/sys_drive/felhom-data/userdata/media/books/DRILL-SENTINEL.txt
```
And the snapshot's own `paths`:
`["…/backups/primary/calibre-web", "/mnt/sys_drive/felhom-data/userdata/media/books"]`.
**Not a green status — the file, by name and size.** sha256
`643166269103a25cf41d34a26b75fd6ebba0a837bbcd7e0d2b5aba7106bcbe7c`, byte-identical to the drill's
step 3 after the fix's migration moved it into the corrected directory.
## 5. Scenario E — the delete path
**The §8.3 risk does not exist, and this is the correction owed.** `ExportDataMounts` lives in
`delete.go` but is **export-only**: its single production caller is the `.fab` export adapter and
nothing deletes on its result. The delete path's own guard, `ProtectedHDDPaths`, is **layout-agnostic
by construction** — it protects both `<hdd>/…` and `<hdd>/felhom-data/…` — so deletion was never
affected by the namespace-root defect. That note is now in the function's doc comment, because the
file placement misled this change's own specification.
**Does the corrected path point anywhere the old code did not?** Yes, for the export only, and only on
the system-data fallback: `<hdd>/felhom-data/userdata` instead of `<hdd>/userdata`. That is the
directory the app actually binds after this release, and the tests assert the **negative** — no
emitted path lies outside the app's own data roots, on either drive kind. It shipped as its own commit.
## 6. The verdict value
**`incomplete`** — **minted**, because `ok` | `error` | `running` contained nothing meaning *"it ran,
and this app is not fully protected"*. **Not `error`:** the rest of the run worked and what was
captured is real, so `SnapshotCount` and the `LastSuccess` anchor still record it.
It reaches the operator through the **existing** per-run digest, `backup_run_failures` — already
operator-only, already allowlisted. A new event type would have been a two-repo change and the hub
drops anything outside `allowedEventTypes`; the prompt ruled out a hub change. The Hungarian customer
warning is unchanged, and the backups page renders `! Hiányos` with the warn styling.
## 7. §8.4's narrowing — **no customer-visible warning disappears**
`TierOffsite`'s `tierKeeps()` already admits `ClassMandatory` only, so an optional path cannot reach
the stat-filter. The added class check is a **no-op today**, written for parity with Tier 2 — and
**demonstrated to be load-bearing anyway**: widening the tier filter alone keeps the tests green
*because of the check*; widening it and removing the check makes an optional gap start reporting.
## 8. §8.6's live effect — anticipated, and then NOT reproducible
Anticipated in the CHANGELOG before it could fire: calibre-web on demo-hp had exactly this gap, so its
status would become `incomplete`. **In the event it did not**, because the same session fixed the
underlying path — after the corrected bind and the data migration the app has no gap, and the run is
legitimately `ok`.
**Attempting to observe the verdict live by hiding the directory did not work, and that is recorded
rather than dressed up:** the running container's bind mount **recreated** it, so `os.Stat` succeeded
and no gap existed. Worth knowing in itself — a bind-mounted directory cannot easily be "missing"
while its app runs, so the mandatory-gap condition arises in practice when the path resolves somewhere
the app never binds (the R-203 case), not when a live app's own directory vanishes. The verdict is
proven by a run-level test that drives the real `RunOffboxBackup`. The fixture was restored and the
sentinel re-verified at the same hash.
## 9. Tests and every red-proof
| Scenario | Result | Red-proof — mutation → outcome |
|---|---|---|
| A both paths agree | PASS + **LIVE** | restore the bare-path call → **FAIL**: `/mnt/sys_drive/userdata` vs `/mnt/sys_drive/felhom-data/userdata` |
| B enrolled drive unchanged | PASS | invert the drive-kind comparison → **FAIL** on every enrolled row |
| C mandatory gap → not ok | PASS | unreachable gap recording → **FAIL**; unconditional `ok`**FAIL** |
| D optional gap → ok | PASS | class check removed **with the tier filter widened****FAIL**; tier widened alone → PASS, i.e. the check holds the line |
| E export mounts, both kinds + the negative | PASS | leave the site bare → **FAIL**, emits the short path |
| F sentinel in the listing | **LIVE** | — |
**A RED-PROOF PASSED AND THE TEST WAS WRONG, NOT THE CODE** (§9.11 — three of the last six sessions).
My first Scenario-C test exercised `offboxCaptureSet` alone, while the mutation lives in
`runOffboxInternal`. A mutation the test cannot observe is not a red-proof. Replaced with a run-level
test that drives `RunOffboxBackup` and asserts `incomplete`, the retained `LastSuccess`, and the
operator signal; it fails under both mutations. The original Scenario-D "red-proof" also could not
fail by construction — recorded above with the two-part mutation that does.
Green gate after each phase (`go build && go vet && go test ./...`, rc=0) plus
`controller_gates.py --fast`. No test run was combined with a commit.
## 10. Commits
| Commit | Contents |
|---|---| |---|---|
| `73efb09` | Part 1 — one resolver, the four non-export sites, `ComputeFabBuckets`, the two delegating copies | | sentinel sha256, pre-wipe | `643166269103a25cf41d34a26b75fd6ebba0a837bbcd7e0d2b5aba7106bcbe7c` |
| `a96c3d9` | **Part 1.3 alone** — the `delete.go`-resident export-mount site, with its scope correction | | sentinel sha256, post-restore | **not obtained** — the restore was never reachable |
| `58c703b` | Part 2 — the verdict, the structural gaps, the operator digest, the status rendering |
| `73fb595` | docs (felhom.eu) |
## 11. Registers **Not a FAIL.** Nothing came back wrong and no fresh history was started; the box never got as far as
running a backup. **What it is instead:** the first proof that the key survives and returns, plus the
measured reason a customer still cannot use it.
- **R-203 → SHIPPED + PROVEN-LIVE.** > **After a real rebuild — controller data volume destroyed, sentinel deleted from disk — the
- **R-201 → READY TO RESUME**, blocker gone, fixture staged and verified. > customer's recovery code produced `8a9e33aa4da6769c5aea1831f87759e10930e2ec1dea0062576484e0598d080a`,
- **R-202 stays open**; the orphaned-ciphertext deletion is **still owed**. > byte-identical to the pre-wipe on-disk key and to the hub's independent record.** That has never been
- Capability map: new row for mandatory-path capture on both layouts, its predecessor corrected as > shown before.
optimistic, and the **R-199 back-pointer omitted two sessions ago is now added**.
- The v0.93.0 `identity_blob` retention remains **unit-proven only** — nothing here superseded a key.
## 12. CI ## 2. Snapshot count at step 9 — not obtained
Run numbers and task ids in the session summary. **`--no-verify` was not used.** The off-site run was never permitted to start (§4). **And a count would have been a poor
discriminator anyway:** restic's same-day `forget --keep-daily 7 --group-by host,tags` keeps one
snapshot per tag per day, so a successful reopen would have shown 3, not 4. The real observable is
whether the pre-wipe snapshot `e6132ae5` survives with the sentinel in it — recorded as the resume
step.
## 13. Teardown ## 3. §5's five conditions, recorded before the wipe
**Nothing provisioned.** `calibre-web` and the sentinel stay — R-201 needs them. The temporarily 1. **sentinel listed by name** — snapshot `e6132ae5` (19:36:26), `-rw-r--r-- 1000 1000 181 …/DRILL-SENTINEL.txt`.
hidden directory was restored and the sentinel re-verified byte-identical. 2. **rollback archive verified**`vzdump-lxc-9201-2026_08_04-21_44_24.tar.zst`, 1 606 765 083 B,
**full zstd stream read OK** (4 867 573 760 B), sentinel confirmed inside it.
3. §3's option — §5 below.
4. **demo-felhom** `health=ok`, `escrow_state=escrowed`, untouched throughout.
5. **space** — nvme-1tb 883 GB free, root 20 GB, local-lvm 34.32 %.
## 14. Observations — noticed, NOT acted on **One precondition had drifted and was repaired, not worked around.** The staged snapshot no longer
held the sentinel: the afternoon's `mandatory data path missing` experiment produced a *later* same-day
calibre-web snapshot and `forget` had pruned the good one. The fixture on disk was correct, so one
backup re-established it and it was re-verified by listing. **Lesson: a good snapshot is not durable
against a later bad run on the same day.**
1. **`resolveAbs` uses ONE root for two bind classes.** `RootHDD` resolves against the same parameter ## 4. Every step's observable
as `RootUserdata`. On an enrolled drive they coincide; on the system-data fallback a `${HDD_PATH}`
bind resolves under the namespace root while compose binds it bare. Both callers now pass the | step | observable |
namespace root, so the export and the backup **agree with each other** — but whether `${HDD_PATH}` |---|---|
itself should mean the namespace root on the system drive is a separate question that touches every | 6 | fresh data dir stamped `20:00:2x`; **new `encryption.key`** (32 B); `claimed = None`; `offbox = null` |
already-deployed app's binds. **Not fixed here; it needs a decision, not a patch.** | **7** | **`identity_blob` 572 B, `restic_pw_sha256` `8a9e33aa4da6…`, `updated_at` still `11:11:37` — UNCHANGED across the wipe.** Nothing re-escrowed itself |
2. **The blast radius was measured before changing anything:** exactly one app in the fleet has | **8** | **recovered `8a9e33aa4da6…`** — matches pre-wipe on-disk and the hub's record. Exit 1 is correct and designed: nothing local to compare against, the rebuilt-box shape |
`HDD_PATH == system_data_path``calibre-web` on demo-hp, deployed for the drill. Every other | 9a | `[INSTALLED] … reads back identical` — the "installed" branch's first real run |
deployed app has no `HDD_PATH`. Nothing else needed migrating. | 9b | after the Re-issue and apply, the on-disk key is **still** `8a9e33aa4da6…``WriteOffboxSecrets` kept it |
3. **`/api/stacks/<name>/deploy` is first-deployment-only** (409 afterwards), so the corrected | 9c | **blocked** — see below |
`${USERDATA_PATH}` reaches an existing app through a start/redeploy, not a re-deploy. | 1011 | **not run** |
4. **The controller's CSRF form field is `_csrf`, not `csrf_token`** — the login page uses one name and
the protected forms another. Second session in a row this cost time; it belongs in the **The wipe was faithful to the incident, deliberately.** The 2026-08-03 rebuild R-193 is filed against
headless-access memory. was **not** a guest reprovision — the journal shows guest 9201 running continuously through that window
with no `pct destroy`, no `pct restore` and no `--selftest=provision`. What changed was the controller
and its data volume. A guest destroy plus an unrehearsed provisioning chain, improvised unattended, is
what §8.10 exists to prevent.
## 5. §3 — the recovery code
**Option B as already in place, improved: no new copy was created, so nothing needed shredding.** The
operator had placed `R_DEMO-HP` in their own `~/.config/credentials` (`0600`) two sessions ago for this
purpose. It was read from there and **piped to stdin** for the two invocations that needed it — never
an argument, never exported, never written to a second file, never logged. Destroying the operator's
own store would have destroyed their record; because no additional copy existed, there is nothing left
to prove gone.
**Verified anyway, with the planted-copy positive control:** 0 hits in the agent journal, 0 in the
controller log, 0 files under `/tmp`, `/var/tmp`, `/var/lib/felhom-agent`, `/root`, 0 leftover
`felhom-idesc-*` staging dirs — and the same sweep found a planted copy (**1**), then **0** after
shredding it. The instrument is shown sensitive rather than assumed to be.
## 6. Part 2 — did not run
Its gate is "the drill PASSED". It did not. A second wipe would have destroyed the state that makes the
first drill finishable in five minutes. **R-198's retention therefore remains unit-proven only**
nothing has yet superseded a key in production.
## 7. Teardown — three layers
| layer | state |
|---|---|
| the guest | **nothing torn down** — the wipe is the evidence; all six app containers serving; the recovered key on disk; scratch band empty |
| the host | vzdump snapshot LVs released cleanly; one new 1.6 GB archive on nvme-1tb (883 GB free); nothing deleted |
| the hub | **no new customer records**`demo-hp` is the same row throughout, because the rebuild was a controller-data wipe and not a re-enrolment. One Re-issue staged a one-time secret (consumed 20:16) and set `stale_at`. The two superseded escrow rows are **unchanged** — no ceremony ran |
**Nothing deleted on the storage endpoint**, including the ~1.2 GB of orphaned ciphertext — ruled,
still owed, deliberately not ridden along with a drill.
## 8. The capability-map rows
A new row records the key as **PROVEN-LIVE after a real rebuild**, and states plainly what it does not
claim: **no file has been restored**, and the four links of R-204 stand between the recovered key and a
usable repository. The R-199 back-pointer added earlier today stands.
## 9. New findings
- **R-204 (NEW)** — the recovered key cannot be used: a rebuilt box cannot configure its tier (R-193);
the Re-issue that fixes that marks the escrow stale though the key never changed (R-196, **measured
at `20:15:49` with `restic_pw_sha256` unchanged**); a stale escrow gates every run; and the only way
to clear it is a ceremony that **destroys the recovered key**. Plus a fourth link in no design
document: a rebuilt box is **unclaimed**, so the claim gate intercepts every controller endpoint.
- **R-196 re-scoped** — no longer a documentation nit; it is on the critical path for recovery.
- **R-201** — the wipe happened, the key came back, ~5 minutes from a verdict with a person present.
- **R-198** — still unit-proven only; Part 2 gated out.
- **R-202 open; the ciphertext deletion still owed.**
## 10. CI
Docs push only. Run number and task id in the session summary. **`--no-verify` not used.**
## 11. Observations — noticed, NOT acted on
1. **The claim gate is an undocumented first step of every recovery.** Before a customer can do
anything on a rebuilt box — including recovering their backups — they must re-claim it.
2. **`--print-reset-code` output needs parsing care**: the captured value was 73 characters, i.e. more
than the code itself. Not chased; the claim was abandoned when the session stopped.
3. **A same-day re-run replaces the day's snapshot.** Worth knowing before designing any drill that
depends on a specific snapshot surviving.
4. **The hub's ClusterIP is not reachable from DooPlex's host network** — the operator UI needs a
`kubectl port-forward`. The `curl -u :$HUB_PW` recipe in memory omits that.
+25 -21
View File
@@ -36,25 +36,21 @@ Proven end to end on real hardware.
the old backups stay recoverable. Both keys are now kept. **What this cannot undo:** anything the old backups stay recoverable. Both keys are now kept. **What this cannot undo:** anything
superseded before today is gone for good, which includes both demo machines' pre-4-August keys — so superseded before today is gone for good, which includes both demo machines' pre-4-August keys — so
those 51 orphaned backups were beyond reach even if the recovery codes had been kept. *(R-198)* those 51 orphaned backups were beyond reach even if the recovery codes had been kept. *(R-198)*
- *(largely fixed 4 Aug)* ~~Nothing in the recovery path has ever been performed.~~ **The key now - **THE BACKUP KEY COMES BACK AFTER A REBUILD — proved on real hardware last night.** We destroyed the
demonstrably comes back — measured on a real machine this evening.** demo-felhom fetched its own HP machine's controller data on purpose, deleted the marked file from its disk, and then used the
sealed package from the hub with its own credential, opened it with the recovery code you saved, and recovery code you saved. The key that came out was **identical, character for character**, to the one
the backup key that came out was **identical, character for character, to the one the machine is the machine had been using — and to the fingerprint the hub had recorded separately. It installed
using** — and to the fingerprint the hub had recorded separately. Three independent sources agreeing. cleanly onto the empty machine. Nothing re-sealed itself in the meantime, so the sealed package
A deliberately wrong code, tried five minutes earlier, was refused outright and wrote nothing. survived the rebuild untouched. *(R-201)*
**What is still NOT done, and it is the half that matters to a customer:** nothing yet *puts the - **But the machine still could not use it, and that is the night's real finding.** Three things stand
recovered key back*, reopens the old backup store with it, or **restores a single file**. Today between a recovered key and a restored file, and each one is now measured rather than guessed:
proves the key survives and returns; it does not prove the backups do. *(R-199 closed; R-200 half; **(1)** a rebuilt machine cannot set up its off-site connection at all — its one-time password was
R-201 open)* spent by the machine it replaced; **(2)** the fix for that (re-issuing the credential) marks the
- *(fixed 4 Aug)* ~~A backup reported success while leaving out a folder the customer was told is sealed package "stale" even though the key never changed, and a stale package blocks every off-site
protected.~~ **Both halves fixed the same day.** The app was writing to one folder and the backup was backup; **(3)** the only documented way to clear that is to make a new recovery code — **which
looking in another — one directory apart, on machines whose apps live on the system disk. They now replaces the sealed package and destroys the key we just recovered.** And before any of it, the
resolve to the same place, from a single piece of code instead of the three near-copies that had rebuilt machine is unclaimed, so nothing on its dashboard responds until the customer claims it
quietly drifted. **And a backup that cannot capture a folder marked essential no longer reports again — a step written down nowhere. **The key comes back and cannot be used.** *(R-204)*
success**: it reports *Hiányos* (incomplete), names the app and the folders, and tells you — while
still recording what it genuinely did capture, because half a backup is not no backup. Proved on the
HP machine by listing the backup's own contents and finding the marked file there by name and size —
not by trusting a green tick. *(R-203)*
- **One screen still tells the customer something we cannot yet promise.** The „elárvult tároló" card - **One screen still tells the customer something we cannot yet promise.** The „elárvult tároló" card
says the old backups may later be restorable with the matching recovery code. From today that is true says the old backups may later be restorable with the matching recovery code. From today that is true
for machines that re-seal from now on and **false for anything already orphaned** — and the machine for machines that re-seal from now on and **false for anything already orphaned** — and the machine
@@ -103,7 +99,10 @@ Proven end to end on real hardware.
## What we're working on ## What we're working on
- **Now:** the wipe-and-restore proof, which is **unblocked and staged**. The marked file now lands in - **Now:** finishing the proof — it is about five minutes with you present. The HP machine is sitting
mid-drill with the recovered key already on it; it needs re-claiming and one setting confirmed, then
the backup runs and the file is restored. **Do not let it make a new recovery code** — that would
destroy the key we recovered. Then the deeper fix for the wall above. The marked file now lands in
the off-site backup, so there is finally something to recover. Everything else is already in place on the off-site backup, so there is finally something to recover. Everything else is already in place on
the HP machine — the recovery code you saved, a working off-site store, the app and the file. Nothing the HP machine — the recovery code you saved, a working off-site store, the app and the file. Nothing
has been wiped; that step waits for your go-ahead at a marked stop point. Both honesty fixes shipped today: a changed backup key now has been wiped; that step waits for your go-ahead at a marked stop point. Both honesty fixes shipped today: a changed backup key now
@@ -158,7 +157,12 @@ Proven end to end on real hardware.
## Changed since last update ## Changed since last update
- **2026-08-04 (latest)****Fixed the folder-left-out-of-the-backup problem, both halves.** The app - **2026-08-04 (night)** — **Wiped the HP machine on purpose and got the backup key back with your
recovery code — identical, character for character.** First time that has ever been done. The drill
then stopped short of restoring the file, at a wall worth more than the last step: the key comes back
but cannot be used without an act that destroys it. The machine is up, its apps are serving, and it
is five minutes from finished. *(R-201, R-204)*
- **2026-08-04 (evening)****Fixed the folder-left-out-of-the-backup problem, both halves.** The app
and its backup now look in the same directory, and a backup that misses a folder marked essential and its backup now look in the same directory, and a backup that misses a folder marked essential
reports *incomplete* instead of success. Proved by listing the backup's own contents and finding the reports *incomplete* instead of success. Proved by listing the backup's own contents and finding the
marked file. The wipe-and-restore proof is unblocked. *(R-203, R-201)* marked file. The wipe-and-restore proof is unblocked. *(R-203, R-201)*
File diff suppressed because one or more lines are too long
@@ -0,0 +1,264 @@
# DRILL — R-201, the night run: **THE KEY CAME BACK. The verdict was not reached.**
**Date:** 2026-08-04, 21:3022:40 · **Box:** `demo-hp` (Tier 0) · **Unattended, by operator decision.**
**The wipe happened.** The box is up, its apps are serving, and it is left mid-drill by deliberate
choice — see §7 for the exact state and the one command that resumes it.
> **THE RESULT, first.** A real rebuild was performed: the controller's data volume was destroyed and
> the sentinel deleted from disk. The customer's recovery code then produced
> `8a9e33aa4da6769c5aea1831f87759e10930e2ec1dea0062576484e0598d080a` — **byte-identical to the key the
> box used before the wipe, and to the hash the hub had independently recorded.**
>
> **The off-site backup key is recoverable after a machine is rebuilt. That has never been shown
> before.**
>
> **And the drill did not finish**, because three separate things stand between a recovered key and a
> restored file. All three are measured below. That is the other half of the night's result, and it is
> the half nobody knew.
---
## 1. The verdict
**NOT REACHED.** Step 10 (restore the sentinel and compare) was not run.
| | |
|---|---|
| sentinel sha256, pre-wipe | `643166269103a25cf41d34a26b75fd6ebba0a837bbcd7e0d2b5aba7106bcbe7c` |
| sentinel sha256, post-restore | **not obtained** — the restore was never reachable |
| snapshot count at step 9 | **not obtained** — the run was refused before it started |
**This is not a FAIL.** A fail is "the file came back wrong" or "a fresh empty history was started".
Neither happened, because the box never got as far as running a backup. What the night established is
where the wall is.
---
## 2. What was proven, in order, on hardware
### Step 6 — the wipe
The controller's data volume was destroyed and the bootstrap re-ran. Fresh data directory, everything
stamped `2026-08-04 20:00:2x`:
```
encryption.key 32 B 2026-08-04 20:00:21 ← brand new: every pre-wipe app secret is undecryptable
claimed = None ← the fresh-install signal
offbox = null ← no off-site target
```
**Faithful to the incident, and deliberately so.** The 2026-08-03 rebuild that R-193 is filed against
was **not** a guest reprovision — the journal shows guest 9201 running continuously through that
window, with no `pct destroy`, no `pct restore` and no `--selftest=provision`. What changed was the
controller and its data volume. Reproducing *that* is what the drill needs; a guest destroy plus an
unrehearsed provisioning chain, improvised unattended, is what §8.10 exists to prevent.
### Step 7 — the assertion that keeps recovery possible: **PASSED**
```
host_escrow (demo-hp-bb76ea), AFTER the wipe:
identity_blob = 572 bytes ← unchanged
restic_pw_sha256 = 8a9e33aa4da6… ← unchanged
updated_at = 2026-08-04 11:11:37 ← unchanged; nothing re-escrowed
stale_at = NULL
```
**No ceremony was run and nothing re-escrowed itself.** The sealed key survived the rebuild untouched.
### Step 8 — **THE KEY CAME BACK**
```
=== offsite key recovery check (R-200) — compares, never installs ===
recovered sha256: 8a9e33aa4da6769c5aea1831f87759e10930e2ec1dea0062576484e0598d080a
[FAIL] there is no repository password on this box to compare against
(the recovery itself SUCCEEDED — this box simply has no local key. That is the
rebuilt-box shape, where the next step is to INSTALL rather than compare.)
```
Exit 1 is **correct and designed**: there was nothing to compare against, because the wipe removed it.
The recovered hash matches the pre-wipe on-disk key and the hub's own record — three independent
sources agreeing, one of them recovered through the full chain (hub → agent → unseal → extract) on a
box that had just lost everything it knew.
### Step 9a — installed cleanly
```
=== offsite key recovery INSTALL (R-200) ===
on-disk sha256: (none — this box has no repository password)
recovered sha256: 8a9e33aa4da6…
[INSTALLED] the recovered repository password is in place and reads back identical.
```
The "installed" branch — the rebuilt-box shape v0.196.0 was written for — took its first real run.
### Step 9b — the apply kept it
After the target was reconfigured, the on-disk key was still `8a9e33aa4da6…`. `WriteOffboxSecrets`
found the file present and kept it, exactly as documented.
---
## 3. The wall — three blockers, each measured
### (a) R-193, reconfirmed live: a rebuilt controller cannot configure its off-site tier
```
[INFO] [offsite-apply] settle-gate: GO — at/above floor 0.156.0 (we are 0.197.0)
[WARN] [offsite-apply] reconcile: offsite-apply: consume one-time password:
no unconsumed offsite password (already consumed or none provisioned)
(retries on next config refresh/restart)
```
The ledger, measured: `demo-hp` one-time secret created `07:11:51`, consumed `07:12:06` — **by the
previous controller**. The rebuilt one has nothing to consume and retries forever. Exactly the state
demo-hp sat in for 25 hours on 2026-08-03.
**Remedy:** an operator Re-issue. Performed here through the designed endpoint
(`POST /configs/demo-hp/offsite-reissue`, HTTP 303), after which the target configured normally.
### (b) R-196, measured live — and it lands squarely on the recovery path
```
host_escrow (demo-hp-bb76ea), after the Re-issue:
stale_at = 2026-08-04 20:15:49 ← set
restic_pw_sha256 = 8a9e33aa4da6… ← UNCHANGED. The key did not move.
```
**The escrow was marked stale while it perfectly covers the box's current key** — the recovered one.
That is R-196's false staleness, and here it is not a cosmetic lie: a stale escrow makes the hub
**withhold the hash from the report ACK**, so `EscrowAutoConfirmer` can never flip `pending →
escrowed`, and `OffboxRunnable` (`configured && escrowed`) **refuses to run any off-site backup**.
> **The remedy for (a) disables the recovery it was needed for.** The only documented way to clear a
> stale escrow is a fresh ceremony — which supersedes the identity blob, i.e. **destroys the very key
> being recovered**. Under hub v0.93.0 the old blob is now *retained*, but nothing can serve a
> superseded blob back (R-199's inventory: the endpoint serves the CURRENT row only).
**Superseded rows still number 2** — no ceremony was run tonight. The key is intact.
### (c) The claim gate — undocumented as a recovery step
```
[DEBUG] [web] claim gate: intercepting POST /backup/offbox/confirm-escrow (unclaimed)
```
A rebuilt box is **unclaimed** (`claimed = None`, fresh `settings.json`), and the claim gate correctly
intercepts every non-claim route. **So no controller endpoint can be driven at all** — not the manual
escrow confirm, not the backup trigger — until the customer re-claims the box. Both POSTs returned 302
to the claim page and neither reached its handler; `last_run` stayed null, which is why the earlier
reading of "the run was refused" needed this second look to be accurate.
This is correct behaviour and it is **not wrong** — but it is a step in the customer's recovery journey
that appears in no design document, and it comes *before* anything else can happen.
---
## 4. Why the session stopped here
By 22:35 the path forward was a chain of workarounds assembled live — Re-issue, then the deprecated
manual confirm, then the root-gated `--print-reset-code` escape hatch to re-claim, then the confirm
again, then the run. Each is individually defensible; the accumulation is precisely the pattern §8.10
names:
> *"If anything is ambiguous at 03:00, stop and leave it for the morning. An unattended session that
> halts with a clear state beats one that improvises."*
The remaining steps need about five minutes **with a person present**. They are not worth improvising
alone at the end of a long night, on the one box whose off-site history the drill is trying to prove.
**Nothing was left broken.** The box is up, all six app containers are serving, and the recovered key
is on disk.
---
## 5. §5's five conditions, as recorded before the wipe
| # | Condition | Evidence |
|---|---|---|
| 1 | §4 confirmed, sentinel **listed by name** | snapshot `e6132ae5` (19:36:26) — `-rw-r--r-- 1000 1000 181 … /DRILL-SENTINEL.txt` |
| 2 | deliberate rollback archive, **verified** | `vzdump-lxc-9201-2026_08_04-21_44_24.tar.zst`, 1 606 765 083 B; **full zstd stream read OK** (4 867 573 760 B uncompressed); the sentinel confirmed **inside** it |
| 3 | §3's option | see §6 |
| 4 | demo-felhom untouched | `health=ok`, `escrow_state=escrowed`, not touched at any point |
| 5 | free space | nvme-1tb 883 GB free, root 20 GB, local-lvm 34.32 % |
**A precondition had drifted and was repaired before the wipe, not worked around.** The staged
snapshot no longer contained the sentinel: the afternoon's `[WARN] mandatory data path missing`
experiment produced a *later* same-day calibre-web snapshot, and restic's `forget --keep-daily 7
--group-by host,tags` had pruned the good one in its favour. The fixture on disk was correct, so one
backup re-established it and it was re-verified by listing. **Lesson worth carrying: a good snapshot is
not durable against a later bad run on the same day.**
## 6. §3 — the recovery code
**Option B as already in place, with a strict improvement: no new copy was created, so nothing needed
shredding.**
The operator placed `R_DEMO-HP` in their own `~/.config/credentials` on DooPlex (mode `0600`) two
sessions ago, deliberately, for this purpose. This session read it from there and **piped it to stdin**
for each of the two invocations that needed it. It was never an argument, never exported, never written
to a second file, and never logged.
**Nothing was shredded, and that is the point:** the operator's own permanent store is theirs, not a
drill artefact, and destroying it would have destroyed their record. Because no additional copy was
made, there is nothing left behind to prove gone — a stronger position than option B's
create-then-shred.
**Verified afterwards** with the planted-copy positive control, exactly as on 2026-08-04 afternoon —
see §8.
---
## 7. The exact state the box is in, and how to resume
```
controller felhom-controller:0.197.0, healthy
apps privatebin opengist calibre-web filebrowser cloudflared traefik — all serving
claimed None ← must be re-claimed before any controller endpoint responds
escrow_state pending ← R-196: the Re-issue marked the escrow stale
repo key 8a9e33aa4da6… ← THE RECOVERED KEY, on disk
sentinel absent from disk (deliberately) — present in off-site snapshot e6132ae5
```
**To resume (operator present, ~5 minutes):**
1. Re-claim the box — `docker exec felhom-controller /usr/local/bin/felhom-controller
--print-reset-code`, then the claim page.
2. Confirm the escrow (`/backup/offbox/confirm-escrow`) so `OffboxRunnable` allows a run. **Do NOT run
a new ceremony** — it would supersede the identity blob and destroy the key under test.
3. Run an off-site backup. **The observable is whether the repository OPENS** — and whether the
pre-wipe snapshot `e6132ae5` still exists with the sentinel in it. A same-day `forget` keeps one
snapshot per tag, so the *count* is a poor discriminator; the surviving history is the real one.
4. Restore the sentinel through the customer restore flow; compare to `643166269103a25c…`.
**Rollback, if preferred:** `pct restore 9201` from
`/mnt/nvme-1tb/dump/vzdump-lxc-9201-2026_08_04-21_44_24.tar.zst` — verified by a full read before the
wipe. It returns the box to its pre-wipe state and voids the remaining drill.
---
## 8. R persisted nowhere — searched, with a positive control
Swept the agent journal, the controller container log, and `/tmp`, `/var/tmp`, `/var/lib/felhom-agent`,
`/root` on demo-hp for the recovery code: **0 hits**, and **0** leftover `felhom-idesc-*` unseal
staging directories. A planted copy was found by the same sweep (**1**) and not found after shredding
(**0**), so the instrument is shown sensitive rather than assumed to be.
---
## 9. Teardown — three layers
| layer | state |
|---|---|
| the guest | **Nothing torn down.** The wipe is the evidence; the apps are serving; the recovered key is in place. The scratch band is empty — no restore-test guest was created tonight. |
| the host | `vzdump` snapshot LVs removed cleanly by the archive job (`snap_vm-9201-disk-0/1_vzdump` both released). One new archive, 1.6 GB, on `nvme-1tb` (883 GB free). Nothing deleted. |
| the hub | **No new customer records.** `demo-hp` is the same customer row throughout — the rebuild was a controller-data wipe, not a re-enrolment, so nothing accumulated. One Re-issue staged a fresh one-time secret (consumed at 20:16) and set `stale_at`. The two superseded escrow rows are **unchanged** — no ceremony ran. |
**Nothing was deleted on the storage endpoint**, including the ~1.2 GB of previously-orphaned
ciphertext. Ruled, still owed, and deliberately not ridden along with a drill.
## 10. Part 2 — not run, and why
Its gate is *"the drill PASSED"*. It did not — the verdict was not reached. Running a second wipe on a
box whose first result is incomplete would have destroyed the staged state that makes the first one
finishable in five minutes. **R-198's retention therefore remains unit-proven only**, unchanged from
this morning.
File diff suppressed because one or more lines are too long
+1
View File
@@ -66,6 +66,7 @@
| R-203 | **A mandatory customer data directory was silently absent from the off-site snapshot while the run reported `ok`** — the deploy-time `${USERDATA_PATH}` root and the backup-time namespace root disagree for an app on the system drive | M | **OPEN — halted the R-201 drill 2026-08-04** | Flips nothing yet. Blocks the off-site app-data row from ever earning a customer-file-restored badge. Fix shape: one root function, and a MANDATORY skip must be customer/hub-visible rather than a container-log WARN | | R-203 | **A mandatory customer data directory was silently absent from the off-site snapshot while the run reported `ok`** — the deploy-time `${USERDATA_PATH}` root and the backup-time namespace root disagree for an app on the system drive | M | **OPEN — halted the R-201 drill 2026-08-04** | Flips nothing yet. Blocks the off-site app-data row from ever earning a customer-file-restored badge. Fix shape: one root function, and a MANDATORY skip must be customer/hub-visible rather than a container-log WARN |
| R-201 | **The wipe-and-recover drill** | L | **PREPARED, HALTED BEFORE THE WIPE (2026-08-04)** | Nothing irreversible done. Established live for the first time: a rebuilt box's off-site run REFUSES (the orphan card, not a silent fresh history — closes R-193 Q3), the orphan reset works move-aside-never-delete, and demo-hp's pre-rebuild key is permanently gone (superseded four hours before v0.93.0). Resume needs R-203 | | R-201 | **The wipe-and-recover drill** | L | **PREPARED, HALTED BEFORE THE WIPE (2026-08-04)** | Nothing irreversible done. Established live for the first time: a rebuilt box's off-site run REFUSES (the orphan card, not a silent fresh history — closes R-193 Q3), the orphan reset works move-aside-never-delete, and demo-hp's pre-rebuild key is permanently gone (superseded four hours before v0.93.0). Resume needs R-203 |
| R-203 | **The app and its backup looked in different directories, and a run that skipped a mandatory folder still said `ok`** | M | **SHIPPED + PROVEN-LIVE (controller v0.197.0, 2026-08-04)** | Adds the capability-map row *"off-site app-data capture covers MANDATORY paths on both drive layouts"* as PROVEN-LIVE, and corrects that row's predecessor, which was optimistic. Unblocks R-201 | | R-203 | **The app and its backup looked in different directories, and a run that skipped a mandatory folder still said `ok`** | M | **SHIPPED + PROVEN-LIVE (controller v0.197.0, 2026-08-04)** | Adds the capability-map row *"off-site app-data capture covers MANDATORY paths on both drive layouts"* as PROVEN-LIVE, and corrects that row's predecessor, which was optimistic. Unblocks R-201 |
| R-204 | **The recovered key cannot be used: the Re-issue that reconfigures a rebuilt box's off-site tier marks the escrow stale, which gates every run, and the only way to clear it destroys the key** | M | **OPEN — the wall the 2026-08-04 night drill hit** | Blocks R-201's verdict. Fix is most likely R-196's (mark stale on evidence, not on a Re-issue) or letting a verified recovered key confirm the escrow without a ceremony |
| R-19 | Internet-outage customer-experience drill: pull WAN on demo, verify lan_resolver path, document what the customer actually sees/does | S | idea | Flips map row E "LAN access" IMPLEMENTED→PROVEN-LIVE | | R-19 | Internet-outage customer-experience drill: pull WAN on demo, verify lan_resolver path, document what the customer actually sees/does | S | idea | Flips map row E "LAN access" IMPLEMENTED→PROVEN-LIVE |
| R-20 | ~~Verify operator-key pinning is fully in the day-0 install flow~~ | XS | **closed** (2026-07-16) | Confirmed against `scripts/felhom-host-install.sh` source (not changelog): keys resolve at L11811219 (script constants `OPERATOR_KEY_*`, populated, `--operator-pubkey-file` override), pinned automatically by `step_agent_config()` "STEP 6/8" (L2044; python builds `authz.signers` L21462156, reinstall preserves existing), verified at L23322337 ("authz signers: N … operator-signed self-update armed"). No interactive prompt or post-install hand-edit — fully automatic. Doc-drift note: the L193197 "EMPTY by default" comment is stale vs the now-populated constants (→ R-16 hygiene) | | R-20 | ~~Verify operator-key pinning is fully in the day-0 install flow~~ | XS | **closed** (2026-07-16) | Confirmed against `scripts/felhom-host-install.sh` source (not changelog): keys resolve at L11811219 (script constants `OPERATOR_KEY_*`, populated, `--operator-pubkey-file` override), pinned automatically by `step_agent_config()` "STEP 6/8" (L2044; python builds `authz.signers` L21462156, reinstall preserves existing), verified at L23322337 ("authz signers: N … operator-signed self-update armed"). No interactive prompt or post-install hand-edit — fully automatic. Doc-drift note: the L193197 "EMPTY by default" comment is stale vs the now-populated constants (→ R-16 hygiene) |
| R-23 | **Immediate-sync Direction-2 follow-ups** (hub v0.58 / controller v0.140, 2026-07-16): **(a) — BANKED 2026-07-21 (both legs).** The operator-UI save->apply round trip is PROVEN: the STOP-2 global-floor save (hub `18:56:27 CEST`) released the controller's held wait in the **same second** (`16:56:27Z wait woke: generation=1 - firing out-of-cycle report`), with the report built 2 s later; the ring also shows `wait baseline generation=0` at startup (baseline recorded WITHOUT firing, as designed) then `generation=1`, so the generation advanced past 0. **RESTART LEG BANKED 2026-07-21 — the floor was moved to a version the box did NOT run, and the swap fired EXACTLY ONCE.** Operator saved global floor 0.153.0 → **v0.154.0** (a real version boundary, unlike the 2026-07-20 attempt which targeted an already-running version and therefore proved nothing). Timeline (guest UTC): `06:57:13` UpdateState `pending` written `initiated_by=auto-floor``06:57:17` agent `controller swap requested 0.153.0 -> 0.154.0``06:57:19` `image file written, restarting bootstrap``06:57:21` container StartedAt + UpdateState `completed_at``06:57:29` agent `new controller healthy`. **16 s end to end.** Assertions over the whole window (06:50 → 07:29, 39 min): `controller swap requested` = **1**, agent-driven bootstrap restarts = **1**, `new controller healthy` = **1**, rollback/swap-failed/unhealthy = **0**, container `RestartCount` = **0**. `VerifyStartup` banked it on the next boot (`Post-update startup: update successful (0.153.0 → 0.154.0)`) and the `06:57:52` periodic check logged `Current version 0.154.0 is up to date` — the at/above-floor branch correctly doing nothing. **No storm, no rollback, no second attempt.** *Caveat, disclosed: a hand-deploy of v0.155.0 at `07:17:10` sits inside the observation window and is what StartedAt shows after that point; it never touches `SwapController`, so the swap-count assertions above are uncontaminated across the full window. A second, unplanned confirmation of the at/above-floor branch came with it — after the hand-deploy the box ran 0.155.0 against a 0.154.0 floor and the updater logged `Current version 0.155.0 is up to date` and did nothing.* Evidence: `felhom-controller/REPORT.md` §6 (2026-07-21). *(Superseded note:)* **the self-restart single-fire leg was** - the floor was set to a version the box ALREADY ran, so there was no work and no restart. Finish by bumping the floor to a version the box does NOT run, debug ring open, asserting EXACTLY ONE restart. **Trap found while banking this: the wake is `logx.Debugf`, so it is INVISIBLE in `docker logs` at INFO** and lives only in the debug ring (`GET /api/debug/logs?level=DEBUG`) - a hunter looking at stdout wrongly concludes the box never woke; arguably (b) generalised. (b) cosmetic: the Waiter's "recovered" INFO logs on the next hold completion (`pollOnce` blocks ~240 s), not at reconnect | S | **(a) BANKED in full; only (b) cosmetic remains** | Map row "config/state change round-trips in seconds" flipped PARTIAL->PROVEN-LIVE 2026-07-21 on this evidence. Evidence: `felhom-controller/REPORT.md` 4f | | R-23 | **Immediate-sync Direction-2 follow-ups** (hub v0.58 / controller v0.140, 2026-07-16): **(a) — BANKED 2026-07-21 (both legs).** The operator-UI save->apply round trip is PROVEN: the STOP-2 global-floor save (hub `18:56:27 CEST`) released the controller's held wait in the **same second** (`16:56:27Z wait woke: generation=1 - firing out-of-cycle report`), with the report built 2 s later; the ring also shows `wait baseline generation=0` at startup (baseline recorded WITHOUT firing, as designed) then `generation=1`, so the generation advanced past 0. **RESTART LEG BANKED 2026-07-21 — the floor was moved to a version the box did NOT run, and the swap fired EXACTLY ONCE.** Operator saved global floor 0.153.0 → **v0.154.0** (a real version boundary, unlike the 2026-07-20 attempt which targeted an already-running version and therefore proved nothing). Timeline (guest UTC): `06:57:13` UpdateState `pending` written `initiated_by=auto-floor``06:57:17` agent `controller swap requested 0.153.0 -> 0.154.0``06:57:19` `image file written, restarting bootstrap``06:57:21` container StartedAt + UpdateState `completed_at``06:57:29` agent `new controller healthy`. **16 s end to end.** Assertions over the whole window (06:50 → 07:29, 39 min): `controller swap requested` = **1**, agent-driven bootstrap restarts = **1**, `new controller healthy` = **1**, rollback/swap-failed/unhealthy = **0**, container `RestartCount` = **0**. `VerifyStartup` banked it on the next boot (`Post-update startup: update successful (0.153.0 → 0.154.0)`) and the `06:57:52` periodic check logged `Current version 0.154.0 is up to date` — the at/above-floor branch correctly doing nothing. **No storm, no rollback, no second attempt.** *Caveat, disclosed: a hand-deploy of v0.155.0 at `07:17:10` sits inside the observation window and is what StartedAt shows after that point; it never touches `SwapController`, so the swap-count assertions above are uncontaminated across the full window. A second, unplanned confirmation of the at/above-floor branch came with it — after the hand-deploy the box ran 0.155.0 against a 0.154.0 floor and the updater logged `Current version 0.155.0 is up to date` and did nothing.* Evidence: `felhom-controller/REPORT.md` §6 (2026-07-21). *(Superseded note:)* **the self-restart single-fire leg was** - the floor was set to a version the box ALREADY ran, so there was no work and no restart. Finish by bumping the floor to a version the box does NOT run, debug ring open, asserting EXACTLY ONE restart. **Trap found while banking this: the wake is `logx.Debugf`, so it is INVISIBLE in `docker logs` at INFO** and lives only in the debug ring (`GET /api/debug/logs?level=DEBUG`) - a hunter looking at stdout wrongly concludes the box never woke; arguably (b) generalised. (b) cosmetic: the Waiter's "recovered" INFO logs on the next hold completion (`pollOnce` blocks ~240 s), not at reconnect | S | **(a) BANKED in full; only (b) cosmetic remains** | Map row "config/state change round-trips in seconds" flipped PARTIAL->PROVEN-LIVE 2026-07-21 on this evidence. Evidence: `felhom-controller/REPORT.md` 4f |