RECON: trace the offsite DR chain link by link — it does not join up (R-198..R-201)
gates / gates (push) Successful in 7s

Read-only recon of the escrow -> recovery chain, from a dead node to an open
repository. No production code, no build, no version bump.

Headline: the hub's superseded-escrow retention does NOT retain the offsite
repository password. host_escrow_superseded has no identity_blob column and
demoteCurrentEscrowTx copies only the K-escrow blob, so what survives a
supersession is the PBS datastore key, not the restic repo password. The next
escrow ceremony -- which the system tells a rebuilt box's customer to run --
destroys the last copy. Both demo boxes crossed that line on 2026-08-04.

Also established:
- the hub's blob-serving endpoints (re-enroll / restore-directive) have zero
  callers anywhere: agent, hub UI, scripts, runbooks (R-199)
- POST /backup/offbox/inject-password is routed and handled but no template
  contains the form (R-200)
- nothing in the recovery path has ever been exercised; the one live
  round-trip proof (2026-06-10) predates the ResticRepoPassword field (R-201)
- a fail-closed mint refusal IS implementable: the report ACK already carries
  escrow{identity_blob_present, restic_pw_sha256} and the controller discards
  it whenever no offbox target exists

Corrections: yesterday's spike annotated (candidate (b) overturned in part --
unattended recovery is impossible, customer-present is not); capability-map
retention claim struck through and replaced with what the code does.

Deliverable: documentation/audits/RECON-offsite-dr-chain-2026-08-04.md
Register: new R-198..R-201; R-193 and R-192 updated; STATUS.md refreshed.
This commit is contained in:
2026-08-04 12:16:04 +02:00
parent d26f49ad68
commit 3f2b7bc023
6 changed files with 726 additions and 310 deletions
+99 -307
View File
@@ -1,329 +1,121 @@
# REPORT — R-193/R-192 spike (no code) + R-195 phantom-customer alarm (hub v0.92.0)
# REPORT — RECON: can a customer actually get their backups back? (2026-08-04)
**Date:** 2026-08-04 · **Class:** spike (Parts 03) + one fenced implementation (Part 4)
**Boundary held:** the spike shipped **no production code for R-193 or R-192**, and Part 4 never
touched the spike's subject matter.
**Class:** recon / spike. **No production code. No build, no deploy, no version bump.**
**Baselines re-read on arrival:** `felhom-agent` `856a127cd60b` (v0.124.1) · `felhom-controller`
`0887fd676d60` (v0.194.0) · `felhom.eu` `d26f49ad680e` (hub v0.92.0). All three trees clean.
> Supersedes the earlier 2026-08-04 report (R-191 + R-190), per the overwrite convention. Nothing
> durable from it lived only here — it is recorded in `hub/CHANGELOG.md`, `CONTEXT.md` S-24/S-25 and
> `OPEN-ITEMS.md` R-190/R-191.
> Supersedes the earlier 2026-08-04 report (R-193/R-192 spike + R-195), per the overwrite convention.
> Nothing durable from it lived only here — it is recorded in `hub/CHANGELOG.md`, `OPEN-ITEMS.md`
> R-192/R-193/R-195/R-196/R-197 and `audits/SPIKE-offsite-credential-recovery-2026-08-04.md`.
**Deliverable:** `documentation/audits/RECON-offsite-dr-chain-2026-08-04.md` — Q1Q8 with method,
measurement and ruling per question, the eleven-link chain table, the drill design, every query used,
and the operator's open decisions.
---
## 1. Confirmed baselines, re-read at the cited commit
## 1. What was established
| Repo | `main` @ commit on arrival | Version | End state |
|---|---|---|---|
| `felhom.eu` | `f456835bbcc4` ✔ matches the spec | hub `v0.91.1` (deployed `felhom-hub:0.91.1`) | **`v0.92.0`** built, pushed, manifest bumped, synced, **live** |
| `felhom-controller` | `0887fd676d60` | — | **untouched** (read-only) |
| `felhom-agent` | `856a127` | — | **untouched** (read-only) |
**The chain from a dead node to an open repository is not assembled, and the failure is deeper than
"some links are manual".**
Clean-tree gate passed before the build: `git status --porcelain` empty, `HEAD == origin/main`.
Highest register ID on arrival was **R-194**; grepped before minting.
1. **Links 14 are PROVEN-LIVE** — mint → stage to the agent → seal under R → store on the hub.
2. **Link 5 is lossy, and this is the finding of the session.** `host_escrow_superseded` has **no
`identity_blob` column**, and `demoteCurrentEscrowTx` (`hub/internal/store/store.go:2547-2556`)
copies only the K-escrow `blob`. The **restic repository password lives in `identity_blob`** — so
the retention feature built for the reinstall-orphan incident does not retain the secret that
incident is about. The **next escrow ceremony destroys the last copy**, and that ceremony is exactly
what the system tells a rebuilt box's customer to run. Both demo boxes crossed that line on
2026-08-04 at 07:15:36 and 07:20:08. → **R-198**
3. **Link 6 has no client anywhere.** `handleReEnroll` / `handleGetRestoreDirective` serve the blobs;
zero callers in the agent, the hub UI, any script or any runbook. Retrieval is `sqlite3` by hand on
the hub pod. → **R-199**
4. **Links 78 are manual-only.** `UnwrapIdentityBundle`'s only production caller is
`--selftest=identity-consume`, reading R from `FELHOM_RECOVERY_CODE`; nothing extracts
`restic_repo_password` from the recovered bundle.
5. **Link 9's form does not exist.** `POST /backup/offbox/inject-password` is routed, handled and
unit-tested; **no template contains it.****R-200**
6. **Link 11 has never happened.** No blob served, no fork-4 bundle unsealed with a real R outside a
unit test, no recovered password injected, no repo reopened, no restore. The one live round-trip
proof (2026-06-10) **predates the `ResticRepoPassword` field by a month**. → **R-201**
---
**And the most useful thing settled: a fail-closed refusal IS implementable.** The hub already sends
every box `escrow{identity_blob_present, restic_pw_sha256, created_at}` on every report ACK
(`api/handler.go:504-510`), and the controller **discards it** whenever no offbox target exists
(`report/escrow_confirm.go:75-84`). Persisting it needs no new hub API and no new secret; the
`ClaimSync` set-only pattern is the precedent.
## 2. Q1Q8 — each answered or explicitly marked unmeasured
**The operator's ruled recovery-screen shape is buildable** and is priced row by row in §9 of the
findings doc: fresh-install signal exists (the mint branch's own `os.Stat`); hub-has-a-blob exists on
the wire; the preview is cheap and genuinely read-only (`restic snapshots --json` +
`stats --mode raw-data --json` are already the box's own counters); the one real cost is crossing
agent→controller for the unseal, because the controller image ships no `age` — one new agent local-API
endpoint mirroring `/escrow/ceremony/claim`, plus a narrow hub `GET /hosts/<id>/escrow`.
Full method, quotes and query output: **`documentation/audits/SPIKE-offsite-credential-recovery-2026-08-04.md`**.
## 2. Corrections owed and made
### Q2 and Q3 first — they decide whether the rest matters
- **Yesterday's spike annotated, not rewritten** (`audits/SPIKE-offsite-credential-recovery-2026-08-04.md`):
a dated block at the top plus an inline note on Q8(b). Its candidate (b) verdict *"not implementable
against a zero-knowledge escrow"* is **too strong** — unattended recovery is impossible; a
customer-present recovery is not. Everything else in that spike stands, and its Q3 remains unmeasured.
- **Capability map corrected** (`architecture/00-capability-map.md`, escrow-ceremony row): the hub
v0.60.0 retention claim *"a previous passphrase stays recoverable with its recovery code"* is struck
through and replaced with what the code does — K-escrow only, no read path, `ListSupersededEscrow`
has zero production callers.
- **The same false promise is made to the customer in Hungarian** on the orphan card
(`controller/internal/web/templates/backups_remote.html:66,69`). Recorded in R-198; **not edited**
this session ships no controller change.
- **The prompt's Q4 premise is a conflation, and it is corrected in the doc:** `escrow_confirm.go`
**does** fire on a mint (pending branch, hash mismatch, one WARN). The "wired to the wrong path"
finding was about the hub's `escrow_stale` event (R-196), a different mechanism. What is missing is
not the detection but the consequence — the warning reaches a log file and prescribes the act that
closes the recovery window.
**Q2 — does a rebuilt controller reuse the escrowed repository password, or mint a new one?**
**RULING: it MINTS A NEW ONE.**
*Method — source:* `ConfigureOffbox``ApplyOffsiteTarget``WriteOffboxSecrets`, which generates a
fresh 256-bit password whenever `<DataDir>/offbox/repo_password` is absent
(`felhom-controller/controller/internal/backup/offbox.go:392-401`). A rebuilt guest has a fresh data
dir. The only recovery path, `InjectOffboxPassword`, has **exactly one caller in the entire repo**
(census: `grep -rn "InjectOffboxPassword" --include=*.go .` → 3 hits, 1 definition, 1 caller, 1
comment) — `web/offbox_handlers.go:189`, a web form a human pastes into. **The apply-bridge never
calls it.**
*Method — live, without touching a box:* `host_escrow.restic_pw_sha256` vs
`host_escrow_superseded.restic_pw_sha256`. **demo-hp `8e03eddf…``8a9e33aa…`; demo-felhom
`48741892…``c60c8bc7…`. Both CHANGED.**
**This is bigger than R-193 as filed:** demo-felhom, which R-193 records as having survived by luck,
had **36 snapshots / 1,136,685,919 B** before its rebuild and has reported `snapshot_count: 0` in all
109 reports since. Its luck restored **delivery**, not the **repository**.
## 3. Register
**Q3 — what did the 04:15 run report?** **UNMEASURED, and the binary in the question is wrong.**
The run is daily at ~02:15 UTC (measured `last_run` values on both boxes). Neither box could run on
2026-08-04: demo-hp had no target at all until 07:15:47 UTC; demo-felhom's target was
`escrow_state: pending` from 08-03 07:19:10 to 08-04 07:20:13, which `OffboxRunnable` blocks
(configured **AND** escrowed). **The decisive run is 2026-08-05 ~02:15 UTC.** Source predicts a
**third** outcome, neither 15 nor 1: same account + same repo path (`u629488-sub3:/home/felhom-repo`;
`repoPath` is a compile-time constant) + new password + `claimed: 1``ensureOffboxRepo` classifies
**orphaned**`ErrOffboxOrphaned`**the run refuses and shows the orphan card.** Recorded as a
prediction, not a measurement.
- **New: R-198, R-199, R-200, R-201** (highest previous ID was R-197; grepped across `OPEN-ITEMS.md`,
`ROADMAP.md`, `STATUS.md`, `CONTEXT.md` before minting).
- **Updated: R-193** (seven-part addendum: (b) overturned in part, the chain census, the retention
finding, fail-closed implementable, the operator ruling priced, the drill, Q3 still unmeasured) and
**R-192** (two inputs, no verdict change).
- **`STATUS.md`** updated in operator language: two new "what's broken" entries, three new
"waiting on you" items, a "changed since" line.
### The rest
## 4. Live measurements taken (read-only)
- **Q1 — what is one-shot.** Full table in the spike. Headline: the hub's one-time provider password
is one-shot **but freely re-issuable**; the box's SFTP key is **regenerated on every apply by
design**; the **restic repository password** is the one nothing can restage — the agent's own source
calls it *"irreplaceable (unlike the SFTP access key…)"*
(`felhom-agent/internal/escrow/identity.go:35-39`). **R-193 was filed against the recoverable
secret; the damage is in the irreplaceable one.**
- **Q4 — see §3 below.**
- **Q5 — why one box recovered and the other not. CONFIRMED FROM THE LEDGER.** demo-felhom's
`one_time_secrets` row was created `2026-07-21 08:29:29` (traceable to a Re-issue at that exact
timestamp) and sat **UNCONSUMED for 13 days** until the rebuild consumed it at
`2026-08-03 07:17:58`. Without one: exactly demo-hp — `consume`**404** and a retry loop, 25 hours
unprotected. **Correction to the contrast:** the recovery was of the plumbing only (Q2).
- **Q6 — why delivery is one-shot.** The design's own reasons quoted from three sites. **The security
half, stated:** the consume endpoint is gated by `checkAuthCustomer` — **the customer's API key, the
same credential as the config pull** — so an automatic restage grants **no new access to an
unauthenticated party**. What it genuinely widens is the **exposure window**: a live provider
password becomes fetchable whenever the box reports no offsite target, rather than only in the
seconds after an operator acts. Blast radius stays the customer's own label-scoped sub-account. The
R-39(a) unconsumed-secret guard must survive any change.
- **Q7 — R-192's guard. BOTH HALVES CONFIRMED WITH NUMBERS.** See §4.
- **Q8 — what each candidate costs.** See §5.
Hub SQLite snapshot with the `-wal` (`kubectl exec … cat`), `PRAGMA integrity_check``ok`, freshness
proven positively (`MAX(host_reports.received_at)` = `2026-08-04 10:00:40` vs `datetime('now')` =
`10:03:48`). Escrow tables, `PRAGMA table_info` on both, latest-report `$.offsite.*` and `$.claimed`
per customer, the `claimed` 1→0→1 transition across both rebuilds, and the events table since
2026-08-03. Plus one `ls -la --time-style=full-iso` inside guest 9201 proving the data dir, the
`encryption.key` and `offbox/repo_password` are all newborn as of `2026-08-03 07:17:25``07:18:02`.
Every query with its field names and values is in §11 of the findings doc.
---
**Nothing was written, run, rotated, re-issued, deleted or reconfigured anywhere.** No ceremony, no
`selftest=identity-consume`, no touch on the storage endpoint.
## 3. Which of the two contradictory claims about Re-issue is true
## 5. What could not be established
**The claim that Re-issue "only resets the transport credential" is TRUE about what
`ReissueCredentials` does. R-39's "rotates the restic password" is FALSE.**
The 2026-08-05 ~02:15 UTC run's outcome (still the decisive measurement; neither box has run —
`last_run` absent on both); whether demo-hp's data dir was fresh (measured only on demo-felhom;
demo-hp needs the break-glass credential this read-only session did not use); whether the orphaned
ciphertext still exists at the provider; whether `age` is present on either demo host. Named in §12
of the findings doc.
From source (`hub/internal/offsite/offsite.go:150-228`), the function does four things: `genPassword`,
`ResetSubaccountPassword`/`ResetBoxPassword`, `SaveOneTimeSecret`, and bookkeeping. **There is no
reference to a restic password anywhere in it, and none is possible** — that password is generated on
the box and never leaves it except into the R-wrapped escrow, which the hub cannot open.
## 6. Observations recorded, not acted on
**But the bookkeeping is justified by the false premise**, `offsite.go:198-201`: *"the restic repo
password just changed, so any existing key-escrow blob … is now STALE"*. The same wrong claim appears
at `api/handler.go:1067-1069` and in R-193's own row. **Eighth entry in `CLAUDE.md`'s table of
comments asserting a guarantee the code does not provide** — the first where the comment factually
describes a *different function*. → **R-196**.
A fresh `encryption.key` on every rebuild makes every pre-rebuild `app.yaml` `ENC:` secret
undecryptable — circular dependency **C-1** of `_recovery-inventory-2026-07-28.md`, which fired live on
both demo boxes this week and is a strictly larger blast radius than the offsite repo. `Claimed`
returning to 1 with no event means "the customer claimed this box" has no audit trail.
`offboxRecordStats` persists `RepoSizeBytes` as a side effect of counting. `handleReEnroll` returns a
freshly minted API key in its response body on an endpoint nothing currently calls.
**What corroborated it, and the corroboration is the strongest part.** The task asked: if the repo
password was untouched, what explains demo-hp's escrow going `pending` after the Re-issue? **Fully
explained, and not by the Re-issue:** `ApplyOffsiteTarget` carries `EscrowState` over from the
*existing* target, the rebuilt guest had none, so the `pending` fallthrough fired — and the box had
just minted a new repo password, so `pending` was *correct for a different reason*.
**The proof they are independent is demo-felhom:** it received **no Re-issue at all** (its only
`escrow_stale`/`offsite_reissued` pair is dated `2026-07-21 08:29:29`), its repo password changed
anyway, and **nothing marked its escrow stale for 13 hours.** The mechanism is exactly inverted:
`escrow_stale` is wired to the one path that does **not** change the repo password and absent from the
path that does.
## 7. Conventions
---
## 4. Every query used, with its fields and values
The DB snapshot was taken from the live pod and its **freshness proven with a positive observable**,
not with "the query returned no error":
| # | Query / read | Result |
|---|---|---|
| 1 | `kubectl exec deploy/hub -- cat /data/{hub.db,hub.db-wal,hub.db-shm}` | **The `-wal` is load-bearing**: `hub.db` alone was 2 h 15 m stale (mtime 08:34 vs wal 10:45 CEST). Copying only the main file is the R-3 shape. |
| 2 | `PRAGMA integrity_check` | `ok` |
| 3 | `MAX(host_reports.received_at)` vs `datetime('now')` | `2026-08-04 08:45:38` vs `08:49:46`**newest row 4 min old** |
| 4 | `SELECT customer_id, status, created_at FROM customer_configs` | `demo-felhom` active · `peti-felhom` active · `demo-hp` active · `drill-r50` **blocked** · `david` active (created `2026-08-01 16:51:49`). **`sess-f` is gone — confirmed, not assumed.** `drill-r50` untouched. |
| 5 | `hosts` / `host_deletions` / `host_reports` / `reports` counts for `david` | **0 / 0 / 0 / 0** — never bound |
| 6 | `events WHERE event_type='expected_dbdump_missed' AND created_at > '2026-07-25'` | **only `david`**, 03:00 UTC on 08-02, 08-03, 08-04 |
| 7 | `SELECT customer_id, MAX(received_at), COUNT(*) FROM reports GROUP BY customer_id` | `demo-hp` 1329 · `demo-felhom` 6690 · `drill-r50` 183 (last 07-29) · `peti-felhom` 482 (last **2026-07-15**) — the reason peti-felhom is `down` and does not alarm |
| 8 | distinct `$.offsite` objects per box with first/last seen | demo-hp: `15 snapshots / 40 927 044 B` until `08-03 05:59:21`, **ABSENT** `06:12:19``08-04 07:12:01` (111 reports), then `pending` 07:15:47, `escrowed` 07:16:02, `snapshot_count 0`. demo-felhom: `36 snapshots / 1 136 685 919 B` until `08-03 07:01:28`, ABSENT ×1, then `pending` `07:19:10``08-04 07:20:13`, `escrowed` 07:20:28, `snapshot_count 0` |
| 9 | `host_escrow` + `host_escrow_superseded` `restic_pw_sha256` | **the decisive measurement** — both changed (§2) |
| 10 | `one_time_secrets` (no values read) | demo-felhom created `2026-07-21 08:29:29`, consumed `2026-08-03 07:17:58`; demo-hp created+consumed `2026-08-04 07:11:51`/`07:12:06` |
| 11 | `$.claimed` on the latest report of each box | **`1` on both** — which selects `ensureOffboxRepo`'s refuse branch |
| 12 | `CountReportsOffsiteSince` reproduced verbatim (`ORDER BY id LIMIT 500`) for demo-hp @ `2026-07-23 09:53:41` | **`total=500, withOffsite=500`**, spanning `2026-07-23 09:53:47`**`2026-07-28 11:17:40`** |
| 13 | same window, **no LIMIT** | `total=1174, withOffsite=1063` (⇒ 111 without — matches #8) |
| 14 | full text of the live `offsite_delivery_stuck` message | *"…and **500 report(s) since carry no offbox target**…"* while `OffsiteReportsSinceConsume` was **500** |
| 15 | `COUNT(*)` of `offsite_credential_restaged` | **0, for every customer, ever** |
| 16 | the shipped predicate run verbatim against live data (post-deploy) | `david`**0**; `demo-felhom`/`demo-hp`/`drill-r50`/`peti-felhom`**1** |
**Instrument limits recorded rather than discovered later:** `one_time_secrets` has `customer_id` as
PRIMARY KEY and is last-write-wins **by design**, so it holds only the newest credential per customer —
demo-hp's `2026-07-23` consume, on which R-192's whole diagnosis rests, was **overwritten** by
yesterday's Re-issue and is no longer readable from that table. The `2026-07-23 09:53:41` anchor in
query #12 comes from R-192's record, and that is stated in the spike rather than presented as a live
read.
---
## 5. Ranked options, with a recommendation
| rank | option | verdict |
|---|---|---|
| **1** | **(d) the honesty pass — now** | Cheap, weakens nothing, and **the only option that helps the incident already in flight**. Two parts: correct R-192's message + log its refusal (**R-196**), and **detect a changed `restic_pw_sha256`** — both hashes are already in the hub's DB and **nothing reads them** (**R-197**). |
| **2** | **(c) agent-retained repo password — NOT PREVIOUSLY NAMED** | The only candidate aimed at the irreplaceable secret. The agent lives on the Proxmox host, **survives a guest rebuild**, already receives the password over the pinned local API and already writes it to a fixed 0600 path — it merely **wipes** it after the ceremony. Every seam exists. Price: a copy of the data key at rest on the host. **The operator's trade to make.** |
| **3** | **(a) hub restages automatically** | **Already exists** as `reissueOnReenroll`'s F3 leg, wired to the wrong event (behind `handleHostEnroll`'s mint-once-reuse short-circuit; a guest rebuild leaves the `hosts` row intact). Worth doing **after** (c), never instead: on its own it restores the transport to a box whose data key is already gone, and **would have made both boxes look healthy on 2026-08-04 while their snapshots were orphaned** — strictly worse than the current loud failure. |
| **4** | **(b) recover from escrow at re-bootstrap** | **Not implementable as stated.** The escrow is R-wrapped and zero-knowledge; the hub has no recovery code. "Recoverable at re-bootstrap" describes a customer-present ceremony — i.e. the manual form that already exists. |
**Recommendation: ship (d) now; then decide (c). Do not ship (a) first.**
### The operator's question — stated, and NOT answered
> The irreplaceable secret is the restic repository password. It is generated on the box, destroyed by
> a guest rebuild, and the only surviving copy is sealed under the customer's recovery code. Every
> machine will be reinstalled.
> **Do you want that key to survive a rebuild unattended — by keeping a copy on the Proxmox host,
> where you already have root — or do you want it to stay sealed and accept that every rebuild starts
> a new repository, provided the system says so loudly on the day it happens?**
**STOPPED here. Neither candidate was begun.**
---
## 6. Part 4 — the phantom-customer alarm (SHIPPED)
**Files changed**
- `hub/internal/store/store.go`**new** `(*Store).HasEverBoundHost(customerID) (bool, error)`:
a live `hosts` row **OR** a `host_deletions` tombstone.
- `hub/internal/monitor/deadline.go` — one guard at the top of `CheckBackupDeadlines`' customer loop,
plus an `unbound` counter in the summary line.
- `hub/internal/monitor/deadline_unbound_test.go`**new**, 5 tests.
**Commit:** `7fff45d688fa` (code + tests + spike + docs) · `f581ac134964` (manifest bump).
**The mechanism, established at source and corroborated live.** The existing down-skip could never
have covered this: it reads `StalenessChecker.GetState()`, whose map is seeded from
`store.GetCustomers()`**a query over the `reports` table** (`store.go:1055-1067`). A customer with
zero reports is in no row, gets no state, and `GetState()` returns `""` rather than `"down"`. **The
skip misses exactly the customer it would most obviously cover.** Corroborated by query #7 and by the
live post-deploy startup log — `Staleness checker initialized: 2 ok, 0 stale, 1 down` — where the
`1 down` is `peti-felhom` (host deleted 2026-07-15, 482 old reports) and `david` is **absent from the
census entirely**.
**The discriminator is "was a host EVER bound", not "has a report arrived"**, which is the point:
a box that was installed, bound and went silent has a `hosts` row and keeps alarming. **Fail-open on a
read error** — an unreadable binding must never suppress a real alarm. The deferral is **logged with
its own counter** (the v0.73.0 Part-7 precedent). The anchored-verdict structure is untouched, and
`david`'s record was not modified.
**Sibling verdicts checked rather than assumed.** The census of every customer-iterating checker:
`deadline.go` backup half (already guarded by `reportJSON == ""`), `restoretest.go` (guarded by
`latest == ""`), `staleness.go` + `offsite.go` (iterate `GetCustomers()`, report-derived — a hostless
customer never appears), `offsite_delivery.go` (a hostless customer derives `DeliveryNoSecret` and is
skipped). **The DB-dump half was the only verdict that could fire**; the guard covers both verdicts in
that function uniformly.
**Test results — the suite run and the exit code read BEFORE any commit, never in one command.**
```
go build ./... → BUILD OK
go vet ./... → VET OK
go test ./... → rc=0, 17 packages ok, no FAIL lines
```
The 13 `TestCheckBackupDeadlines*`/`TestHasEverBoundHost` tests were run with `-v` and **the `=== RUN`
lines were checked**, per `CLAUDE.md`'s rule that a `-run` pattern matching nothing prints `ok` and
exits 0.
**THE RED-PROOF THAT MATTERS.** `TestCheckBackupDeadlines_BoundButNeverReported_StillAlarms` is the
case the change could break — a real box that was installed and never phoned home, indistinguishable
from `david` on every signal *except* the one the guard reads. With the guard removed it **still
passes**, which is the correct result and proves the test is aimed at the guard's *risk*, not its
presence. The companion red-proof of the fix itself, **observed**:
```
=== RUN TestCheckBackupDeadlines_NeverBoundHost_Silent
deadline_unbound_test.go:58: a customer with NO host ever bound must raise NOTHING;
got [expected_dbdump_missed]
--- FAIL: TestCheckBackupDeadlines_NeverBoundHost_Silent
```
— verbatim the event `david` sent three mornings running. `deadline.go` restored from a backup taken
before the mutation; full suite re-run green afterwards.
**Deployed version.** `felhom-hub:0.92.0` built + pushed (`sha256:ca71140cf54a…`),
`manifests/hub.yaml` bumped `0.91.1 → 0.92.0` in git, ArgoCD hard-refresh + **deliberate sync** (never
`kubectl set image`). Verified: app **Synced / Healthy**, `rollout status``deployment "hub"
successfully rolled out` (rc=0), running image `gitea.dooplex.hu/admin/felhom-hub:0.92.0`, pod
`hub-6775889d95-gc8sk` 1/1 Running, clean startup log.
**Is the 05:00 alarm confirmed silent? NO — CONFIRMATION IS PENDING, and it is stated rather than
assumed.** The hub's own log says `deadline-check: next run at 2026-08-05 05:00 CEST (in 17h53m57s)`;
there is no manual trigger (`CheckBackupDeadlines` has exactly one caller, the scheduler at
`cmd/hub/main.go:598`). An absent alarm tomorrow would in any case be weak evidence, so a **positive**
observable was taken instead: the shipped predicate was run verbatim against a fresh copy of the live
DB (query #16) — `david`**0** (will be skipped), every other customer → **1** (behaviour
unchanged). **The 05:00 firing itself is still owed.**
---
## 7. `OPEN-ITEMS.md` rows, and the capability map
- **R-192** — updated with Q7's confirmed numbers; **NOT closed**, and now carries a *new* reason not
to fix it in isolation (a successful auto-restage would have protected the plumbing while the data
was already gone).
- **R-193** — updated with the whole spike; **NOT closed**, state moved to **WAITING-ON-OPERATOR**,
owner `CC + operator`. The old "15+ vs 1 decides it" line is struck through and replaced with the
measured answer plus the third predicted outcome.
- **R-195** *(new)* — the phantom-customer alarm. **SHIPPED**, hub v0.92.0.
- **R-196** *(new)*`escrow_stale` wired to the path that does not change the repo password, absent
from the one that does. **OPEN.**
- **R-197** *(new)* — the hub holds both `restic_pw_sha256` values and reads neither. **OPEN.**
**The capability map DID need touching, and the §N.5 escalation did NOT fire — the row was not
overclaiming.** `00-capability-map.md`'s offsite row already recorded reinstall-continuity: *"a
recreated data volume that orphaned the repo … is now CLASSIFIED … + a move-aside (never-delete)
reset"*, shipped controller v0.142.0 and live-fired 2026-07-18. **That independently corroborates the
Q3 prediction from a different direction.** What it did not record — and now does — is that this fires
on an **ordinary, planned, unattended guest rebuild** on every box, with the measured 2026-08-03/04
instance, the hash evidence, and the inverted `escrow_stale` wiring. **Status unchanged
(PROVEN-LIVE):** the guard is correct and is not the gap; the cadence is.
`ROADMAP.md`'s newest row is R-171 and none of R-190…R-197 has one, so **there was nothing to
collapse.** One correction *was* warranted under the coupling rule: R-1's row claimed the v0.57.0
reinstall arc makes offsite re-issue *"auto-fire on re-enrollment"* — scope-corrected in place, since
that means **host** re-enrollment after a host **delete**, and a guest rebuild never reaches it.
`CONTEXT.md` gained **S-26** (the spike's Q1/Q2 headlines + the three corrections that must not be
re-inherited) and **S-27** (the R-195 ruling and its mechanism). `REUSE.md` gained the
`HasEverBoundHost` row with its "never a substitute for a liveness check" trap. `STATUS.md` rewritten
for the operator, one screen.
---
## 8. CI, task ids, and `--no-verify`
| commit | what | CI task id | run # | conclusion |
|---|---|---|---|---|
| `7fff45d688fa` | R-195 code + tests + the spike + all docs | **126** | 72 | **success** |
| `f581ac134964` | `manifests/hub.yaml` 0.91.1 → 0.92.0 | **127** | 73 | **success** |
| `b7c5cfc06188` | this report + `STATUS.md` + the ROADMAP scope correction | **128** | 74 | **success** |
*(A report cannot contain the run id of the commit that adds it; the docs-only follow-up that appended
run 74 to this table is reported in the session summary.)*
Matched by `head_sha` via
`curl -s "https://gitea.dooplex.hu/api/v1/repos/admin/felhom.eu/actions/tasks?limit=5"` — a PULL
check, not a reliance on the failure mail.
**`--no-verify` was NOT used.** The pre-push hook ran and reported `gates OK - push proceeding`;
`python3 scripts/repo_gates.py` was additionally run in full (all five gates OK) before committing.
**Teardown:** the spike provisioned nothing — no guest, no VM, no storage, no credential. The only
artefact is a hub-DB snapshot in the session scratchpad, outside the repo.
**Read-only discipline held:** no `ReissueCredentials`, no ceremony, no credential rotation, no
change to any box's offsite configuration, no snapshot deleted, no customer record modified.
`drill-r50` untouched; `sess-f`'s removal **confirmed** rather than assumed.
---
## 9. Observations — noticed and NOT acted on
1. **The orphaned ciphertext is retained and keeps consuming quota, with nothing to prune it.**
`resetOrphanedRepo` is move-aside-never-delete by design (correct), but 40.9 MB + 1.14 GB of
unopenable data will sit against a 50 GB soft quota indefinitely. S-24's ep0 prune jobs cover the
**PBS** namespaces, a different tier. Not filed — it belongs to whichever option is chosen for
R-193.
2. **`host_escrow_superseded` holds only two rows in the entire database**, both from 2026-08-04. That
is why the spike could not establish whether demo-felhom's 2026-07-21 Re-issue re-sealed an
*unchanged* password — the predicted false-staleness shape of R-196. Retention policy for that
table was not investigated.
3. **Both demo guests were rebuilt on consecutive mornings** (demo-hp 08-03 06:12, demo-felhom 08-03
07:17, both to controller 0.192.0 with new config hashes). *Why* is outside this spike's scope and
was not chased.
4. **`api/handler.go:1067-1069`'s comment is the third copy of R-196's false premise**, and it sits in
a different package from the other two. Whoever fixes R-196 should grep rather than fix the one
they were shown.
5. **`peti-felhom` is `active` with its host deleted since 2026-07-15**, judged only by the `down`
skip. It is correct today and would start alarming the moment it reported once. Not a defect, but
the shape is worth knowing.
6. **The `-shm`/`-wal` trap generalises.** Any future session reading the hub DB from the pod must
copy all three files. Reading `hub.db` alone today would have produced confident answers from a
file 2 h 15 m stale — the R-3 shape with a different filename.
- **No `CHANGELOG.md` entry.** `felhom.eu` has no root changelog — they are per-area (`hub/`,
`scripts/`, `website/`) and log code changes. This session changed none of those areas. Stated
rather than silently skipped.
- **`REUSE.md`** untouched — no shared helper or pattern moved.
- **Gates + CI run id** are recorded in the session summary.
</content>
+50
View File
@@ -35,6 +35,25 @@ Proven end to end on real hardware.
**The next scheduled off-site run is tomorrow at 04:15, and it will refuse rather than quietly start
a new history** — the machine already knows how to recognise this and asks before resetting.
**There is a decision here for you — see "Waiting on you".** *(R-193, R-192, and two new items)*
- **And the safety net we believed was under all of this is not there.** We traced the whole path today —
from a dead machine to reopened backups — and it does not join up. The important part is not that
several steps are manual; it is this: **the central hub keeps the old sealed key when a machine
re-seals, and we found it keeps the wrong one.** It keeps the key for the whole-machine backups and
**not** the key for the off-site file backups — the one this entire problem is about. So the moment
a rebuilt machine is asked to make a new recovery code, which is exactly what it asks the customer to
do, **the last copy of the old key is gone.** Both demo machines crossed that line yesterday morning.
**This means the 51 orphaned backups were beyond reach even if you had kept the recovery codes**
which is a relief in one narrow sense and much worse in every other. It also means the customer is
currently told, in Hungarian on their own screen, that their old backups "may later be restorable
with the matching recovery code". That sentence is not true today. It is a small fix — one missing
column — and it must land before anything else here. *(R-198)*
- **Nothing in the recovery path has ever been performed.** Making a recovery code is proven, live,
with a real customer. **Using one is not.** No sealed package has ever been handed back to a machine
(the hub has the buttons for it; nothing anywhere presses them), no recovered key has ever been put
back, no old backup store has ever been reopened, and no file has ever been restored from a recovered
key. There is a form missing, a step missing, and a connection missing between two parts of the
system. We designed the exercise that would prove it end to end — see "Waiting on you".
*(R-199, R-200, R-201)*
- *(fixed 4 Aug)* ~~The weekly off-site backup reports FAILED although it worked.~~ It uploaded fine
and then tripped on a tidy-up step it is deliberately not allowed to perform. The machine no longer
asks — tidying up is the endpoint's job, and **that was checked first**: the endpoint has been doing
@@ -106,6 +125,32 @@ Proven end to end on real hardware.
nothing else, and it is the piece whose absence let a gigabyte of history go quiet for thirteen
hours. What we are asking you to choose is (a). *(R-193; the full reasoning is in the findings
document)*
**Correction to (b), from today:** "old backups are kept" is true, but "openable later with the
recovery code" is **not** — see R-198 above. Option (b) is only honest once that is fixed.
- **Your ruling about the recovery screen has been priced, and it can be built.** You said: a freshly
installed machine that finds a sealed package waiting should say so loudly, offer the customer a box
to type their recovery code into, and show what would come back before doing anything. **All of it is
buildable, and one part is already free** — the hub is *already* telling every machine, on every
check-in, that a sealed package exists and which key it covers, and the machine currently throws that
message away. Showing a preview is also cheap: listing what is in an off-site store reads it without
writing to it, so the customer can see how many backups, from when, and for which apps before
committing. The real work is one new connection: the unsealing has to happen in the part that runs on
the Proxmox host, because the customer-facing part cannot do it — the same crossing your recovery-code
ceremony already makes in the other direction. **One thing for you to weigh, which we deliberately did
not decide:** a screen that takes a recovery code and then shows what is in a backup store is reachable
by anyone with the household's dashboard password, and the preview reveals backup dates and app names.
*(R-193)*
- **Should we run the proof?** Nothing about recovery has ever actually been done, so we designed the
exercise: on the spare demo machine, make a recovery code and **keep it**, put a marked file into an
app, back it up off-site, wipe the machine, reinstall, recover with the saved code, and check the
marked file comes back byte for byte. About half a day, of which under an hour needs you. The failure
we are watching for is precise: **if the recovered machine reports one backup instead of the ones we
put there, it started a fresh history and the proof failed** — "the store opened" is not good enough.
It costs that machine's contents if it goes wrong, which is acceptable there. **Do R-198 first**, or
the exercise walks a path that is missing a piece. *(R-201)*
- **The orphaned backups still on the storage box.** About 1.2 GB across the two demo machines, sitting
in set-aside stores nobody can open and nothing prunes, using the 50 GB allowance. We did not touch
them. **Delete, or leave?** *(R-193)*
- **A job, not a decision: the hub password needs changing.** A diagnostic command printed it into a
session log; nothing suggests anyone else saw it. *(R-132)*
- **One small question, not urgent.** The automatic check cannot see which version you have told
@@ -115,6 +160,11 @@ Proven end to end on real hardware.
## Changed since last update
- **2026-08-04 (latest)** — Traced the whole recovery path end to end and it does not join up. The hub
keeps the wrong sealed key when a machine re-seals, so a rebuilt machine's old off-site key is lost the
moment the customer is asked for a new recovery code. Nothing in the recovery path has ever been
performed. Your ruling about the recovery screen is priced and buildable. The proof exercise is
designed and awaiting your go-ahead. *(R-198, R-199, R-200, R-201, R-193)*
- **2026-08-04 (later)** — Established what a machine rebuild actually destroys: not the storage
password we had been re-issuing, but the key that encrypts the backups. Both demo machines lost
access to their off-site history, one of them silently. A decision is now waiting on you. The daily
File diff suppressed because one or more lines are too long
@@ -0,0 +1,531 @@
# RECON — can a customer actually get their backups back?
**Date:** 2026-08-04 · **Items:** R-193, R-192 (+ new: R-198…R-201) · **Class:** recon — **no production code**
**Session scope:** read-only everywhere. Nothing built, deployed, wiped, rotated, re-issued or deleted.
> **Headline, before the detail.** The chain is **not** assembled, and the reason is worse than "two
> links are manual". **The hub's superseded-escrow retention — the feature built for exactly this
> incident — does not retain the offsite repository password.** `host_escrow_superseded` has no
> `identity_blob` column, and `demoteCurrentEscrowTx` copies only the K-escrow blob. So the last copy
> of a repo password is destroyed by the **next escrow ceremony** — the very act the system tells the
> customer to perform when their escrow goes `pending` after a rebuild. The recovery window closes
> because the remedy closes it.
---
## 1. Confirmed baselines (re-read on arrival)
| Repo | `main` @ commit | tree | Version |
|------|-----------------|------|---------|
| `felhom-agent` | `856a127cd60b0be797867c162f0ae49c7b64993e` | clean | v0.124.1 |
| `felhom-controller` | `0887fd676d608e7c103fa13b3e87536ad2481811` | clean | v0.194.0 |
| `felhom.eu` | `d26f49ad680e2be2ce2888fd58dff7dee886e4ad` | clean | hub v0.92.0 |
All three match the prompt's cited commits exactly. `git status --porcelain` empty in each.
**Highest register ID: R-197** (`OPEN-ITEMS.md` R-196/R-197 were minted by yesterday's spike;
R-194/R-195 by the R-190 arc). Grepped across `OPEN-ITEMS.md`, `ROADMAP.md`, `STATUS.md`,
`CONTEXT.md`. New items in this session therefore start at **R-198**.
---
## 2. Instruments — what each can and cannot answer
| # | Instrument | Exact read | Known limit |
|---|---|---|---|
| I1 | source | the three repos at the commits above | the authority; every claim below cites a file:line |
| I2 | hub SQLite snapshot | `kubectl -n felhom-system exec pod/hub-6775889d95-gc8sk -- cat /data/{hub.db,hub.db-wal,hub.db-shm}` → local `sqlite3` | the `-wal` is load-bearing (copied; `PRAGMA integrity_check``ok`) |
| I2-fresh | freshness proof | `MAX(host_reports.received_at)` = `2026-08-04 10:00:40`, `datetime('now')` = `2026-08-04 10:03:48` | **positive observable**: newest row 3 m 08 s old, not "the query did not error" |
| I3 | live filesystem, demo-felhom guest 9201 | `ssh felhom-pve "pct exec 9201 -- ls -la --time-style=full-iso …"` | read-only `ls`; mtimes only, no file contents read |
| I4 | caller census | `grep -rn <symbol> --include=*.go` across all four repos | proves absence of a caller in Go; templates checked separately |
**Not used, deliberately:** no ceremony, no re-issue, no rotation, no `selftest=identity-consume`, no
write of any kind, nothing touched on the storage endpoint.
---
## 3. Q1 — the chain, link by link
From a dead node to an open repository. **Status vocabulary as specified.**
| # | Link | Component / function | Who invokes it | Status |
|---|---|---|---|---|
| 1 | repo password is minted | controller `Manager.WriteOffboxSecrets` → mint at `internal/backup/offbox.go:392-401`; generator `:405` | `ApplyOffsiteTarget` (`offbox.go:474`) ← `ConfigureOffbox` ← the apply-bridge (`offsiteapply.go:221,256`) | **PROVEN-LIVE** — fires on every fresh data dir; measured twice this week (I2 hash change, I3 mtime) |
| 2 | password staged to the agent | controller `PushOffboxPasswordForEscrow` (`offbox.go:530`) → agent `POST /escrow/stage-secret` (`localapi/server.go:518`) → `escrow.StagedResticPasswordPath()` = `/var/lib/felhom-agent/escrow-stage/restic_repo_password`, 0600 (`identity.go:44-47`) | the controller enable/ceremony path | **PROVEN-LIVE** |
| 3 | sealed under R at the ceremony | `AttachResticPassword` (`identity.go:73`) → `WrapIdentityBundle``WrapIdentity` (`identity.go:114`, `age -p -a`, scrypt + ChaCha20-Poly1305) | agent `--selftest=escrow-create`, driven by the controller wizard (`/escrow/ceremony`, `localapi/server.go:525-527`) | **PROVEN-LIVE** — customer-facing wizard first live firing 2026-07-18 (`00-capability-map.md`, escrow row) |
| 4 | stored by the hub | `PUT /api/v1/hosts/<id>/escrow` (`api/handler.go:239`, `handleHostEscrowPut :1104`) → `host_escrow.identity_blob` | agent `main.go:2937` | **PROVEN-LIVE** — measured: `identity_blob` present, 572 B, for **both** hosts (I2) |
| 5 | **old blob retained on supersession** | `demoteCurrentEscrowTx` (`store/store.go:2547-2556`) → `host_escrow_superseded` | `SaveHostEscrow`, `DeleteHost` | **BROKEN FOR THIS PURPOSE.** The INSERT copies `host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256`**not `identity_blob`**, and the table has **no such column** (`PRAGMA table_info`, I2). The K-escrow survives; **the restic repo password does not** |
| 6 | served back to a re-enrolling box | hub `handleReEnroll` (`api/dr.go:101`) and `handleGetRestoreDirective` (`:155`), returning `identity_escrow_b64`; recovery-mode gated, armed with the **global operator key** (`:34`) | **NOBODY.** I4: zero callers in `felhom-agent` (no `ReEnroll` symbol, no `/re-enroll` or `/restore-directive` string anywhere in the agent's hub client, `internal/hub/client.go` reaches only `desired-state`, `wg`, `pbs/consume-token`, `jobs`); zero in the hub UI (no template mentions them); zero in `scripts/` or any runbook | **implemented-never-exercised, and CLIENTLESS.** The only documented retrieval is by hand: `sqlite3 "SELECT writefile('/root/idblob', identity_blob) FROM host_escrow …"` on a `kubectl cp`-ed `hub.db` (project memory, 2026-07-04 S5 prep) |
| 7 | unsealed with R | `UnwrapIdentityBundle` (`identity.go:172`) → `UnwrapIdentity` (`:139`) | **`runSelftestIdentityConsume` only** (`cmd/felhom-agent/main.go:2845-2900`), reading R from `FELHOM_RECOVERY_CODE`. I4: no other production caller | **manual-only — the only caller is a `--selftest` mode** |
| 8 | the repo password extracted | none. The selftest writes the **whole bundle JSON** to `-keydest` 0600 (`main.go:2872`) and prints *"identity recovered (tunnel_token + pbs_token)"* — it does not mention, extract or route `restic_repo_password` at all | a human reads the JSON | **missing** |
| 9 | placed on the rebuilt controller | `InjectOffboxPassword` (`offbox.go:541`) | one caller: `offboxInjectPasswordHandler` (`web/offbox_handlers.go:189`) at `POST /backup/offbox/inject-password` (`web/server.go:510`). **I4: no template in the repo contains that path or any form posting to it** | **manual-only, and UI-unreachable** — the handler exists, the form does not. A person must POST it by hand with a valid session cookie + CSRF token |
| 10 | the existing repo opens | `ensureOffboxRepo` (`offbox.go:653`): `restic cat config` → success clears `RepoState` | the scheduled/manual offbox run | **implemented-never-exercised** on a *recovered* password (exercised constantly on a live one) |
| 11 | a backup or restore proves it | — | — | **never** |
### The chain in one sentence
Links 14 are real and proven. **Link 5 silently drops the payload.** Links 69 are each individually
manual, and link 6 has no client at all. Link 11 has never happened.
---
## 4. Q2 — where the chain stops today
**The first link that is not automatic is #5, and it is not merely manual — it is lossy.**
Two distinct stopping points, and the order matters:
**(a) If the box has NOT re-escrowed since the rebuild** — the current `host_escrow.identity_blob`
still seals the *old* password. Recovery is possible in principle, and a person must:
1. Arm recovery mode on the hub (`PUT /api/v1/admin/hosts/<id>/recovery-mode`, global operator key) —
or skip it entirely, because nothing consumes the endpoint it gates;
2. `kubectl cp` / `exec cat` the hub DB and `sqlite3 … writefile('/root/idblob', identity_blob)`;
3. copy the blob to a machine with the agent binary and `age`;
4. `FELHOM_RECOVERY_CODE=… felhom-agent --selftest=identity-consume -blob /root/idblob -keydest /root/bundle.json`;
5. open `bundle.json` by hand and read `restic_repo_password` out of it;
6. log in to the customer's dashboard and `POST /backup/offbox/inject-password` **by hand** (curl with
session + CSRF) — there is no form;
7. re-run the apply so `WriteOffboxSecrets` finds the file present and keeps it;
8. trigger a run and hope `restic cat config` succeeds.
Eight manual steps, five of them requiring operator-tier access to the hub, one requiring the customer
to be present with R, and one (step 6) with no user interface at all.
**(b) If the box HAS re-escrowed** — which is what the system pushes the customer to do, because a
rebuilt box lands in `EscrowState: pending`, offsite runs are blocked (`OffboxRunnable`,
`offbox.go:570`), and the card says *„Helyreállítási kód szükséges"* — then the old `identity_blob`
has been **overwritten** by `SaveHostEscrow` and **not carried** into the superseded row. At that point
**no procedure exists**, with or without R, at any level of effort. The password is gone.
Both demo boxes are in state (b) as of 2026-08-04 07:15:36 (demo-hp) and 07:20:08 (demo-felhom) — I2,
`host_escrow.updated_at`. This is an **independent, and much stronger, reason** the 51 orphaned
snapshots are unrecoverable than "nobody kept the recovery codes". Keeping R would not have helped.
---
## 5. Q3 — can a rebuilt controller know it should recover?
### RULING: **YES — the hub already tells it, on every single report, and the controller throws the answer away.**
**What the box knows at the mint decision.** `WriteOffboxSecrets` is called from
`ApplyOffsiteTarget`, driven by the apply-bridge with the hub's offsite **descriptor**, whose fields
are `host, user, port, repo_path, host_fingerprint, quota_gb` (`offsiteapply.go:198-204`). **Nothing
about escrow is in scope at that point.** Locally, the only signal is the one the mint branch already
reads: `os.Stat(m.offboxPwPath())` returning `IsNotExist` — which *is* the fresh-data-dir signal, at
exactly the right line, but cannot distinguish "never configured" from "rebuilt".
**What the box is nonetheless already told.** Every report ACK carries an `escrow` object
(`api/handler.go:504-510`), consumed as `report.EscrowStatus`
(`internal/report/escrow_confirm.go:19-23`):
```go
type EscrowStatus struct {
IdentityBlobPresent bool `json:"identity_blob_present"`
ResticPwSHA256 string `json:"restic_pw_sha256"`
CreatedAt string `json:"created_at"`
}
```
That is precisely *"the hub has a blob, and here is the hash of the password it covers"* — arriving
every report cycle, on a channel that already exists, with no new API and no new secret.
**And it is discarded.** `EscrowAutoConfirmer.Reconcile` (`escrow_confirm.go:75-84`) returns
immediately unless `Pending()` or `Escrowed()` — both of which require `OffboxConfigured()` **and** a
non-nil target (`cmd/controller/main.go:580-590`). On a freshly rebuilt box, before the offsite apply,
there is no target, so the status is dropped on the floor. It is never persisted anywhere: I4 finds
exactly **one** reference to the ACK field in the whole controller
(`cmd/controller/main.go:641: escrowConfirmer.Reconcile(resp.Escrow)`).
**Therefore a fail-closed refusal IS implementable**, with no new hub endpoint:
- persist the last-seen ACK escrow status alongside settings (the precedent exists — `ClaimSync`
caches the hub's claim-code state into `settings.json` idempotently by generation,
`internal/report/claim_sync.go:39-53`, and it does so *set-only, never cleared on hub silence*,
which is the correct shape here too);
- at the mint branch, refuse when the password file is absent **and** the cached status says
`identity_blob_present && restic_pw_sha256 != ""`.
**One ordering caveat, stated rather than assumed.** On a truly fresh box the apply can race the first
ACK. Measured on demo-hp: first post-rebuild report `2026-08-03 06:12:19`, offsite apply
`2026-08-04 07:12` — the ACK preceded the apply by 25 hours, but that gap was an incident, not a
design. A fail-closed rule handles the race correctly by construction: refuse to mint until the escrow
status is known, and retry next cycle (the apply-bridge is already built to retry and to persist
nothing on a partial failure, `offsiteapply.go:1-6`).
**Corroborating measurement that the data dir really is fresh on a rebuild** (I3, demo-felhom guest
9201, `/var/lib/docker/volumes/felhom-controller-data/_data/data/`):
| file | mtime |
|---|---|
| the data directory's parent | `2026-08-03 07:17:25` |
| `encryption.key` (32 B) | `2026-08-03 07:17:26` |
| `offbox/` | `2026-08-03 07:18:02` |
| `offbox/repo_password` (64 B) | `2026-08-03 07:18:02` |
The volume, the app-secret encryption key and the repo password are all newborn, 36 seconds apart.
Corroborated hub-side (I2): `json_extract(report_json,'$.claimed')` reads **1 → 0 → 1** across the
rebuild on both boxes — demo-hp `0` in 5 reports `2026-08-03 06:12:19``06:21:03`; demo-felhom `0` in 4
reports `07:17:54``07:19:46`. `Claimed` is set only by `SetClaimed()`, whose sole caller is the claim
submit handler (`web/claim.go:349`), so the return to `1` was a **human re-claim**, not automation.
> **Note, not acted on:** a fresh `encryption.key` means every pre-rebuild `app.yaml` `ENC:` secret is
> undecryptable — the C-1 circular dependency in `_recovery-inventory-2026-07-28.md` fired on both
> demo boxes this week. That is outside R-193's scope and is recorded as an observation (§13).
---
## 6. Q4 — what the stale-escrow detector actually covers
### RULING: **the prompt's premise conflates two different mechanisms. `escrow_confirm.go` DOES fire on a mint. But it fires into a log file, and the remedy it prescribes destroys the last copy of the old key.**
**The conflation, stated because it matters.** Yesterday's spike's sentence *"wired to the one path
that doesn't change the key and absent from the one that does"* is about the **hub's `escrow_stale`
event**, emitted by `ReissueCredentials` (`hub/internal/offsite/offsite.go:198-201`) — that is R-196,
and it stands. It is **not** about `controller/internal/report/escrow_confirm.go`. Two different
detectors; only one of them was ever claimed to be misplaced.
**What `escrow_confirm.go` does on a mint, from source.** After a rebuild the apply creates a target
with `EscrowState: "pending"` (the `cur == nil` fallthrough, `offbox.go:482-494`). So `Pending()` is
true, the pending branch runs (`escrow_confirm.go:86-107`), the hub's hash (sealing the OLD password)
is compared against `LocalHash()` (the NEW minted one), they differ, and it logs:
```
[WARN] [escrow-confirm] the hub's escrow blob does not cover the CURRENT repo password
(hub hash %.12s… != local %.12s…) — run the escrow ceremony … staying pending
```
**So: confirmed, it fires.** The detector is not absent from the mint path.
**Three limits, each of which matters more than the fact that it fires.**
1. **It is a controller-local log line.** No event, no e-mail, no hub-side signal. `StaleBlob()` — the
flag the web card renders — is set **only** by the escrowed branch (`reconcileEscrowed`,
`:130-165`); the pending branch sets no display flag. The operator learns nothing.
2. **Its prescription is the destructive act.** *"run the escrow ceremony"* → a new ceremony →
`SaveHostEscrow` overwrites `host_escrow.identity_blob``demoteCurrentEscrowTx` does not carry it
**the old repo password ceases to exist**. The detector correctly identifies the situation and
then tells the customer to close the door.
3. **It cannot distinguish the two situations that need different answers** — "you re-keyed
deliberately" and "your machine was rebuilt and your history is about to be orphaned" produce the
identical warning.
So: **less needs building than the prompt feared for the *detection*, and more needs building for the
*consequence*.** The comparison exists; what does not exist is a path from that comparison to either a
signal or a recovery.
---
## 7. Q5 — is the superseded-escrow retention real, and what does it retain?
### RULING: **the retention is real, it retains the wrong blob, and nothing can read it back.**
**What it retains — source and live, agreeing.** `demoteCurrentEscrowTx` (`store/store.go:2547-2556`):
```sql
INSERT INTO host_escrow_superseded (host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, superseded_at)
SELECT host_id, blob, key_fingerprint, posture, created_at, COALESCE(restic_pw_sha256, ''), datetime('now')
FROM host_escrow WHERE host_id = ?
```
`identity_blob` is not in the column list. Live schema (I2, `PRAGMA table_info`):
| table | columns |
|---|---|
| `host_escrow` | `host_id, blob, key_fingerprint, posture, created_at, updated_at, **identity_blob**, directive_json, restic_pw_sha256, stale_at` |
| `host_escrow_superseded` | `id, host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, superseded_at` |
**`blob` is the K-escrow — the PBS datastore encryption key**, wrapped by the PBS-native scrypt path.
**`identity_blob` is the age-wrapped `IdentityBundle`** — the one carrying `ResticRepoPassword`
(`felhom-agent/internal/escrow/identity.go:34-39`), plus `TunnelToken`, `PBSToken` and
`WGPrivateKey`. Live sizes confirm they are different objects: both hosts' current rows hold
`blob` = 383 B **and** `identity_blob` = 572 B; both superseded rows hold `blob` = 383 B and nothing
else.
**So, exactly what a customer with the matching OLD recovery code could recover from a retained blob:**
- ✅ the PBS datastore encryption key **K** — i.e. the Plane-2 whole-guest offsite ciphertext;
- ❌ **not** the restic repository password — the Tier-3 offsite app-data history;
- ❌ not the tunnel token, not the PBS access token, not the WireGuard private key.
**And there is no read path.** I4: `ListSupersededEscrow` (`store/store.go:2617`) has **zero
production callers** — only two tests. The only production surface is `CountSupersededEscrow`, used
for a number on the host page (`web/hosts.go:573`) and a log line (`api/handler.go:1151`). The schema
comment says so itself: *"The ACK and restore-serving read `host_escrow` (the CURRENT row) — never
this table."* Retrieval is `sqlite3` on the hub pod, by hand, by the operator.
**The claim this contradicts.** `00-capability-map.md` (escrow-ceremony row) records hub v0.60.0 as:
> *"a re-escrow with a DIFFERENT sealed passphrase no longer destroys the old blob — the hub RETAINS
> it (`host_escrow_superseded`), so a previous passphrase stays recoverable with its recovery code
> (turns the reinstall-orphan incident from 'history destroyed' into 'history recoverable')."*
The same claim appears in the schema comment (`store.go:370-375`) and, in Hungarian, on the customer's
own orphan card (`controller/internal/web/templates/backups_remote.html:66,69`):
> *„A meglévő mentések nem sérültek — a hozzájuk tartozó helyreállítási kóddal később visszaállíthatók
> lehetnek."* · *„A régi előzmény félretéve marad (nem törlődik), és a hozzá tartozó helyreállítási
> kóddal később visszaállítható lehet."*
**For the offsite restic repository — the incident it names — that is false.** The retained blob does
not contain the key that opens it. This is the **ninth** entry in `CLAUDE.md`'s table of comments
asserting an invariant the code does not provide, and the first that is also **customer-facing copy**.
**R-198**, and the capability-map correction in §9.
---
## 8. Q6 — has any part of this ever been exercised?
**Method:** read every audit, validation doc, findings file and REPORT that mentions escrow consume,
identity-consume, re-enroll or inject-password; then check what the cited evidence actually covers.
### Proven
| what | evidence | scope of the proof |
|---|---|---|
| ceremony (create + seal + upload) | `00-capability-map.md` escrow row; `tests/VALIDATION-n100-rehearsal-2026-07-18.md` S6 — customer drove the wizard, R claimed one-shot 16:56:39, auto-confirmed 16:56:41 | the **create** side only |
| identity-bundle round-trip with a real R on a secret-less box | `tests/slice10d-identity-restore-spike-findings.md` §1, 2026-06-10 — wrap→unwrap byte-identical (`sha256` match); wrong-R **fails closed**, no plaintext emitted | **PRE-FORK-4.** The bundle then was `{tunnel_token, pbs_token}`. `ResticRepoPassword` was added in agent **v0.77.0 on 2026-07-09** (`felhom-agent/CHANGELOG.md:2344`) — a month later. **The proof does not cover the field this whole question is about** |
| `ResticRepoPassword` survives the wrap/unwrap | agent v0.77.0 CHANGELOG: *"`IdentityBundle` round-trip carries `ResticRepoPassword` byte-exact + not-in-blob + wrong-R fails closed"* | **unit test only** |
| `InjectOffboxPassword` places and refuses correctly | `controller/internal/backup/offbox_test.go:952-990`, incl. the companion showing a fresh mint without inject | **unit test only** |
### Never exercised — named in those words
- **A blob has never been served to a box.** Link 6 has no client (§3).
- **A fork-4 bundle has never been unsealed with a real R outside a unit test.** The only production
caller is a `--selftest` mode; the one live consume ever prepared (S5 Part 4-B, 2026-07-04) was
explicitly **deferred and never run** — its own resume note still reads *"DEFERRED 2026-07-04, not
run"* (project memory; `06-offsite-connectivity.md:327` marks the destructive drill operator-gated).
- **A recovered repo password has never been injected into a controller.**
- **An existing offsite repository has never been reopened with a recovered password.**
- **No restore of any kind has ever been performed from a recovered secret.**
`_recovery-inventory-2026-07-28.md` already said most of this and said it accurately — A.2.7 records
*"the **consume** side is proven at spike level … the destructive in-place drill is explicitly
operator-gated and unrun"*, and C.1 row 1 says *"escrow **creation** proven live; **consume never
exercised destructively**"*. **That document is right and this session confirms it.** What it did not
catch — because it was written from `host_escrow`, not from the supersession path — is Q5.
**Capability-map correction owed:** not for a "recovery proven" claim (no row makes one), but for the
**retention** claim quoted in §7. See §9.
---
## 9. Q7 — the operator's ruled shape, assessed row by row
> **Operator ruling, 2026-08-04, verbatim:** *If a node is a fresh install AND the hub has a recovery
> blob, then the controller should yell that recovery is available, and provide a form for the customer
> to enter the recovery key. After unlocking the blob, the controller should show what will be
> recovered before proceeding.*
| The ruling requires | Exists? | What it needs, and what it costs |
|---|---|---|
| the controller knows it is a **fresh install** | **YES, at the right line** | `os.Stat(m.offboxPwPath())``IsNotExist` is already the mint branch (`offbox.go:392`). It is trustworthy as *"this controller has no repo password"*, which is the only fact the decision needs; it does **not** by itself distinguish first-ever install from rebuild — that is what the next row supplies. Corroborators exist but are weaker: `Claimed` is set-only and was measured re-set by a human within minutes (§5); a fresh `encryption.key` is a stronger signal but is not currently read by anything. **Cost: zero — the branch already exists.** |
| the controller knows **the hub has a blob** | **YES on the wire, NO in the code** | `EscrowStatus{IdentityBlobPresent, ResticPwSHA256, CreatedAt}` arrives on every report ACK and is discarded when no offbox target exists (§5). **Needs:** persist it (the `ClaimSync` set-only-by-generation pattern is the precedent, `claim_sync.go:39-53`). **Cost: small — controller only, one settings field, one reconciler, no hub change.** |
| it can **yell** rather than silently mint | **NO** | The refusal belongs **inside `WriteOffboxSecrets`, at the mint branch** — the one place that decides. Returning a new sentinel (`ErrOffboxRecoveryAvailable`, sibling of `ErrOffboxOrphaned`) propagates through `ApplyOffsiteTarget``ConfigureOffbox` → the apply-bridge, which already treats a failed configure as "persist nothing, retry next cycle" (`offsiteapply.go:5-6, 256`) — so a refusal is **fail-safe by construction**, not a new failure mode. Meanwhile the customer sees a card on `/backups/remote` (the orphan card at `backups_remote.html:60-70` is the exact precedent) and offsite runs stay blocked, which they already are (`OffboxRunnable`). **Cost: small-medium — controller only.** **Design hazard to name:** a first-ever install of a *re-used customer record* would also trip this; the refusal must be dismissible ("start fresh anyway"), and dismissal must be the act that mints. |
| a **form takes R** | **NO — and R is not a password** | The controller UI **emits** R (`backups_escrow.html:235-236`, `/backup/escrow/…/claim` returns it once) and has **never accepted** one. The only thing resembling an entry point is `POST /backup/offbox/inject-password`, which takes a **64-hex repo password** (`offboxRepoPwPattern`, `offbox.go:543`) — a different secret at a different layer — and **has no form in any template** (§3 link 9). **Cost: small for the form; the hard part is the next row.** |
| the blob is **unsealed** with R | **NO — and there is a real boundary** | Unsealing is `age -d`. `ageBinary = "/usr/bin/age"` (`identity.go:24`) and the code live in the **agent**. The **controller image does not ship `age`** (`controller/Dockerfile:56-70` — restic, openssh-client, sshpass, sqlite3, docker-cli; no age) and must not: the agent is the privileged tier by design. **The crossing already has a transport and a precedent:** the pinned local API carries `POST /escrow/ceremony` + `GET /escrow/ceremony/status` + `POST /escrow/ceremony/claim` (`localapi/server.go:524-527`) — i.e. the agent **already** handles R server-side for the create direction and hands the result back exactly once. **Needs: one new agent local-API endpoint** (`POST /escrow/unseal`: blob + R in, the single `restic_repo_password` field out, R never logged, R never persisted) — the exact mirror of the claim endpoint. **Plus** a way for the blob to reach the agent: the agent's hub client has no escrow **GET** (only the `PUT` at `main.go:2937`), and `dr.go`'s re-enroll is the wrong tool (it rotates the host API key and needs operator-armed recovery mode). **Needs: a second small hub endpoint**`GET /hosts/<id>/escrow`, per-host-key authed, serving opaque bytes to the box that owns them. Zero-knowledge is untouched: the hub still never decrypts, and the bytes are useless without R. **Cost: medium — three repos, two new endpoints, both narrow.** |
| **"show what will be recovered" before proceeding** | **the primitive EXISTS; the flow does not** | `restic snapshots --json` + `restic stats --mode raw-data --json` are already how the box counts snapshots and sizes the repo (`offboxRecordStats`, `offbox.go:1234-1265`). Both are **read-only against the repository** — nothing is written, no lock is taken, no `init`. So a preview can show: **snapshot count, per-snapshot time, host, tags (= app names), paths, and the deduplicated repo size** — all before committing. **Committing requires only that the recovered password be written to `offboxPwPath()`**, and even that is reversible in principle. **The clean shape:** unseal → hold the recovered password **in memory** → run the two read-only probes against the existing repo → render → and only on the customer's confirmation call `InjectOffboxPassword`. **Cost: small — one handler, one template, no new restic capability.** **Caveat, measured not assumed:** `offboxRecordStats` today also persists `RepoSizeBytes` into settings as a side effect; a preview path must not reuse it verbatim. |
| **proceeding** actually recovers | **NO end-to-end** | `InjectOffboxPassword` → next `WriteOffboxSecrets` keeps the file → `ensureOffboxRepo` probes and clears `RepoState`. Every piece is implemented; **none has ever run against a recovered password** (§8). **Cost: zero new code, but it is precisely what the drill in §10 has to prove.** |
### Things the ruling implies that do not exist, priced
| missing piece | cost | note |
|---|---|---|
| persisted ACK escrow status + fail-closed mint refusal | **S** | controller only; the `ClaimSync` pattern applies directly |
| the "recovery is available" card + dismissal | **S** | one template, one flag; the orphan card is the template precedent |
| a customer-facing **recovery-code** form (distinct from the password form) | **S** | the password form's handler exists but has no UI; both need building |
| agent local-API `POST /escrow/unseal` | **M** | new privileged surface; mirror of `/escrow/ceremony/claim` |
| hub `GET /hosts/<id>/escrow` (opaque, per-host key) | **S** | new read surface on the hub; zero-knowledge preserved |
| preview handler + template driven by read-only restic probes | **S** | primitive already exists |
| **carrying `identity_blob` into `host_escrow_superseded`** | **S** | one column, one `SELECT` list — **and without it the whole feature protects only boxes that have not yet re-escrowed** (→ R-198). This is a prerequisite, not a nice-to-have |
### The security question the ruling raises — stated, not answered
**What gates the surface today.** The controller dashboard is behind `RequireAuth`
(`internal/web/auth.go:52`): a bcrypt password (`effectivePasswordHash()` — customer-set in
`settings.json`, else operator-provisioned in `controller.yaml`), a 7-day session cookie, 5 failed
logins per minute then lockout, and `CsrfProtect` on every mutating route. The claim gate serves only
the claim page on an unclaimed box. So a recovery form would sit **behind the customer's dashboard
password**, on a controller reachable at whatever address that box's dashboard is reachable at.
**What a preview would expose to someone who guesses or steals R.** Not file contents — but the
snapshot list is not nothing: **backup dates and cadence, the app names (restic tags), the paths, and
the repository size.** That is a map of the household's digital life, and it is precisely the metadata
the zero-knowledge posture otherwise keeps out of everyone's hands including the operator's.
**Three specifics the operator should weigh, without this session choosing:**
1. **Guessing R is not the threat; holding the dashboard password is.** R is ten EFF words
(~129 bits, `00-capability-map.md` agent v0.93.0 note) and `age`'s scrypt is deliberately slow —
online guessing is not a route. But **anyone with the dashboard password can already restore live
offsite data** (`_recovery-inventory-2026-07-28.md` A.1.3A.1.6). The marginal new exposure is
confined to the *orphaned/previous* repository, which today nobody can read at all.
2. **The form is an oracle.** A correct/incorrect R answer distinguishable in the UI turns the
dashboard into a verification service for a stolen recovery code. `UnwrapIdentity` already
fails closed with a single generic error (`identity.go:157`) — the UI must not do better than that,
and should rate-limit like the login path does.
3. **R will pass through the agent.** Whatever the form does, R crosses the local API to a
root-adjacent daemon. The ceremony already does this in the other direction, so the precedent is
set — but it means R is momentarily present in agent process memory on the Proxmox host, which is
the same host D6 says the operator has root on. **This is the same trade as spike option (c), in a
smaller and time-bounded form**, and it should be decided alongside it rather than separately.
---
## 10. Q8 — the drill design
**Why a deliberate cycle.** No recovery code exists for either demo box, and — per §7 — even one would
no longer open the old repositories. The proof therefore requires creating R, keeping it, and walking
the full circle. **This section is the design; the drill is not run in this session.**
### Target and preconditions
| | |
|---|---|
| **Box** | **`demo-hp`** (HP t740, node `felhom-host`, host `demo-hp-bb76ea`). Tier 0 — disposable, and the designated drill host by the 2026-07-25 operator ruling. **Not demo-felhom** — it is the N100 that also carries the ISO/rehearsal lineage; leave one demo box in a known-good state throughout |
| **Guest** | the customer guest on that host (rebuild target) |
| **Data** | one deliberately identifiable app + a sentinel file whose content is a known string and whose sha256 is recorded before the wipe |
| **Prereq** | the box is healthy, offsite target configured and `escrow_state: escrowed`, and its **current** `host_escrow.identity_blob` seals the password of the repo that will be tested |
| **Duration** | ~34 h wall clock, of which ~45 min is operator-attended (steps 2, 5, 8, 9) |
| **Blocking dependency** | **steps 89 cannot be executed as designed today** — links 6, 8 and 9 of the chain do not exist as automation. The drill as written uses the manual substitutes (§4a) and **that is the point**: it measures the *existing* chain honestly before anything is built |
### The cycle
| # | Step | Who | Observable that must be recorded |
|---|---|---|---|
| 1 | Record the starting state: `host_escrow.restic_pw_sha256`, `identity_blob` length, `$.offsite.snapshot_count`, `repo_size_bytes` | CC | a hash and two counters, from the hub DB |
| 2 | **Run the escrow ceremony through the customer wizard and KEEP R** | **HUMAN** | R written to the operator's password manager under a named entry; **its storage location recorded in the drill report — the code itself never** |
| 3 | Deploy an app, write the sentinel file, record its sha256 | CC | sha256 + path |
| 4 | Run an off-site backup through the real UI endpoint; wait for completion | CC | `$.offsite.last_status = ok`, `snapshot_count` increased by ≥1, `last_success` stamped |
| 5 | **Verify the snapshot from outside**: `restic snapshots --json` against the repo | CC | snapshot id + tag + timestamp — the pre-wipe truth to compare against |
| 6 | **Wipe**: destroy the guest and reinstall it (the same procedure that produced the 2026-08-03 rebuild) | **HUMAN** decision, CC executes | new `controller_started`; `claimed` drops to 0; a new `encryption.key` mtime |
| 7 | Let the box come back and reach the mint. **Do NOT run a new ceremony** | CC | the controller log carries the `escrow-confirm` mismatch WARN (§6); `escrow_state: pending`; `host_escrow.identity_blob` **unchanged** (this is the assertion that keeps recovery possible) |
| 8 | **Recover**: export `identity_blob` from the hub, unseal with the kept R, extract `restic_repo_password` | **HUMAN** (R is theirs) | `identity-consume` exits 0; the bundle JSON contains a non-empty `restic_repo_password`; **the value is never printed or logged** |
| 9 | **Inject** the recovered password (`POST /backup/offbox/inject-password`, by hand — there is no form), re-apply, run an off-site backup | **HUMAN**-assisted | `restic cat config` succeeds; **`snapshot_count` ≥ the step-5 count + 1**, not 1 |
| 10 | **Restore the sentinel** through the customer restore flow and compare sha256 | CC | **byte-identical sha256 to step 3** |
| 11 | Record everything, tear down nothing that would hide the evidence | CC | drill report |
### The single observation that makes this a FAILURE rather than a pass
> **Step 10's sha256 does not match step 3's — or step 9 reports a snapshot count of 1.**
A snapshot count of 1 means a **new, empty history** was started over the old one: the repository did
not reopen, and every earlier snapshot is orphaned. **Explicitly: "the repository opened" is not the
pass condition.** `restic cat config` succeeding proves the password is right; it does not prove the
customer's data came back. Only the sha256 comparison does.
### Secondary failures worth recording separately (each is a finding, not a pass/fail)
- Step 7 finds `identity_blob` **changed** — something re-escrowed automatically → the recovery window
closes without a human act (would be new and serious).
- Step 8's bundle has an **empty** `restic_repo_password` — a pre-fork-4 or hash-less blob shape.
- Step 9's `ensureOffboxRepo` classifies `orphaned` despite the correct password → the classifier or
the transport, not the key.
### What could make it fail for uninteresting reasons
The `age` binary absent on the machine running step 8; the hub DB export producing a truncated blob
(use `writefile`, verify the byte length against `length(identity_blob)` = 572); the CSRF token in
step 9 (`felhom_session` is dropped by curl's cookie jar — project memory
`controller-api-headless-access`); and the R-71a settle gate deferring the apply in step 9 (wait for
its GO line rather than retrying).
### Rollback, and whether the loss is acceptable
**If the recovery does not work, what has been lost is:** demo-hp's guest, its app data since the last
whole-guest archive, and its off-site history. **On that box that is acceptable** — it is Tier 0,
explicitly disposable, and it is the box the target-selection runbook names for drills. **The
rollback** is a whole-guest restore from the local/PBS archive taken immediately before step 6, which
is a proven path on this fleet (restore-test passed on demo-hp's host tier in 109.3 s on 2026-08-04).
**Take that archive deliberately as part of step 6 and verify it exists before destroying anything** —
do not rely on the nightly.
**Not acceptable, and therefore not proposed:** running this on demo-felhom (leaves no healthy demo
box), on DooPlex (Tier 2, it *is* the recovery chain), or on any real customer's box.
---
## 11. Every query used
| # | Source | Query / read | Value read |
|---|---|---|---|
| 1 | git | `git -C <repo> rev-parse HEAD`; `git status --porcelain` | the three commits in §1; all empty |
| 2 | hub DB | `PRAGMA integrity_check` | `ok` |
| 3 | hub DB | `SELECT MAX(received_at) FROM host_reports; SELECT datetime('now')` | `2026-08-04 10:00:40` / `2026-08-04 10:03:48` |
| 4 | hub DB | `SELECT host_id, length(blob), length(identity_blob), substr(restic_pw_sha256,1,12), created_at, updated_at FROM host_escrow` | `demo-felhom-8363b5, 383, 572, c60c8bc737a6, 2026-08-04T07:20:07Z, 2026-08-04 07:20:08` · `demo-hp-bb76ea, 383, 572, 8a9e33aa4da6, 2026-08-04T07:15:36Z, 2026-08-04 07:15:36` |
| 5 | hub DB | `SELECT id, host_id, length(blob), substr(restic_pw_sha256,1,12), created_at, superseded_at FROM host_escrow_superseded` | `3, demo-hp-bb76ea, 383, 8e03eddf9ff7, 2026-07-23T10:01:17Z, 2026-08-04 07:15:36` · `4, demo-felhom-8363b5, 383, 48741892f0ef, 2026-07-21T08:38:33Z, 2026-08-04 07:20:08` |
| 6 | hub DB | `PRAGMA table_info(host_escrow)` / `(host_escrow_superseded)` | the two column lists in §7 — `identity_blob` present in the first, **absent from the second** |
| 7 | hub DB | latest report per customer: `json_extract(report_json,'$.claimed' / '$.offsite.escrow_state' / '.snapshot_count' / '.repo_state' / '.last_status' / '.last_run')` | demo-felhom `2026-08-04 09:49:42`: `1, escrowed, 0, NULL, NULL, NULL` · demo-hp `2026-08-04 09:56:57`: `1, escrowed, 0, NULL, NULL, NULL` |
| 8 | hub DB | `claimed` grouped over reports since `2026-08-03 05:00` | demo-hp: `1` ×124, **`0` ×5 (06:12:19→06:21:03)** · demo-felhom: `1` ×120, **`0` ×4 (07:17:54→07:19:46)** |
| 9 | hub DB | `SELECT customer_id, event_type, severity, created_at FROM events WHERE created_at > '2026-08-03 00:00'` | no `escrow_stale`, no `offsite_reissued`, no `offbox_repo_orphaned`, no claim event in the window; `controller_started` ×4 demo-hp / ×4 demo-felhom around the rebuilds |
| 10 | guest 9201 (I3) | `ls -la --time-style=full-iso …/felhom-controller-data/_data/data{,/offbox}` | the mtime table in §5 |
| 11 | source (I4) | `grep -rn "InjectOffboxPassword" --include=*.go .` | 1 definition, 1 handler caller, 5 test uses, 1 comment — **no production caller besides the handler** |
| 12 | source (I4) | `grep -rn "inject" internal/web/templates/` | one unrelated hit (an XSS comment) — **no form** |
| 13 | source (I4) | `grep -rni "reenroll\|re_enroll" --include=*.go .` in `felhom-agent` | **zero hits** |
| 14 | source (I4) | `grep -rn "re-enroll\|recovery-mode\|restore-directive" --include=*.sh --include=*.md --include=*.py` workspace-wide | only design docs + project memory; no script, no runbook step |
| 15 | source (I4) | `grep -rn "ListSupersededEscrow"` in `felhom.eu` | 2 test callers, 0 production |
| 16 | source (I4) | `grep -rn "\.Escrow\b" --include=*.go .` in `felhom-controller` | one hit: `cmd/controller/main.go:641` |
| 17 | source | `felhom-agent/CHANGELOG.md` | `v0.77.0 — fork-4: escrow the offsite restic repo password under R (2026-07-09)` |
| 18 | source | `controller/Dockerfile:36-70` | runtime package list — **no `age`** |
---
## 12. What could not be established
Named, not guessed.
1. **The 2026-08-05 ~02:15 UTC scheduled run's outcome.** Still unmeasured — both boxes became
runnable only on 2026-08-04 morning and neither has run since (query 7: `last_run` absent on both).
Yesterday's spike predicts `ErrOffboxOrphaned`; that prediction is **unchanged and still unproven**.
2. **Whether demo-hp's data dir was fresh.** Established for demo-felhom by direct mtime measurement
(I3). demo-hp was not measured — it has no baked SSH key and reaching it needs the break-glass
credential, which this read-only session did not use. The hub-side `claimed 1→0→1` signature is
identical on both boxes, which is suggestive, not decisive.
3. **Whether the orphaned ciphertext still exists at the provider.** Unchanged from yesterday: reading
the endpoint needs the SFTP credential, which was not touched.
4. **Whether any pre-2026-07-21 escrow generation ever existed for demo-felhom.** The superseded table
holds two rows in the entire database; earlier generations are not retained (and, per §7, would not
have carried the identity blob anyway).
5. **Whether `age` is present on any machine that would run step 8 of the drill.** Not checked — the
agent declares `/usr/bin/age` as a runtime dependency but this session did not verify it on either
demo host.
---
## 13. Observations — noticed, not acted on
1. **A fresh `encryption.key` on every rebuild** (I3: 32 B, mtime `2026-08-03 07:17:26`) means every
pre-rebuild `app.yaml` `ENC:` secret on that box is undecryptable. This is exactly circular
dependency **C-1** in `_recovery-inventory-2026-07-28.md`, and it fired live on both demo boxes this
week without anyone naming it. It is a strictly larger blast radius than the offsite repo and it is
**not** covered by R-193. Worth its own item if the operator agrees it is not already implied by C-1.
2. **`Claimed` returning to `1` within minutes** of a rebuild, with no event of any kind recording it,
means "the customer claimed this box" has no audit trail. There is no `customer_claimed` event type.
3. **`offboxRecordStats` persists `RepoSizeBytes` as a side effect of counting** — harmless today,
a trap for anyone reusing it as a read-only preview primitive (§9).
4. **`handleReEnroll` returns the new API key in the response body** (`dr.go:148`) while also logging
the rotation. Not a defect (the box needs it), but it is a secret in an HTTP response on an endpoint
nothing currently calls — worth a second look before anything starts calling it.
---
## 14. The operator's decisions — stated, and NOT answered here
1. **Run the drill (§10) or not?** It is the only thing that converts *"the pieces look right"* into
*"a customer got their data back"*. It costs demo-hp for an afternoon and, if it fails, that box's
history. **Recommendation: yes — but fix `identity_blob` retention (R-198) first**, because a drill
that walks the current chain will pass or fail on a chain that is missing a link everyone believed
was there.
2. **The orphaned snapshots at the endpoint** (40.9 MB + 1.14 GB, presumed present, §12.3). They are
**unrecoverable by two independent mechanisms** now: no recovery code was kept, *and* the sealed
passwords were destroyed by the 2026-08-04 re-ceremonies. They consume the 50 GB soft quota
indefinitely; nothing prunes a moved-aside restic repo. **Delete, or leave?** This session did not
act on it and states it as the operator's.
3. **The unchanged R-193 question** — agent-retained repo password (spike option c) versus accepting
that every rebuild starts a new repository. **This session adds one input to that decision:** the
"accept it, provided the system says so loudly" branch currently rests on a retention feature that
does not retain the relevant secret. Under option (d)-alone as it stands today, *"the old history
stays recoverable with your recovery code"* is not true. Either R-198 is fixed, or that branch has
to be re-described honestly to the customer.
</content>
</invoke>
@@ -10,6 +10,39 @@ what it measured) · **Class:** spike — **no production code shipped for R-193
---
> ### ANNOTATION 2026-08-04 (later the same day) — read this before acting on §Q8
>
> A follow-up recon traced the escrow→recovery chain link by link
> (`audits/RECON-offsite-dr-chain-2026-08-04.md`). **Two corrections to this document, and they pull
> in opposite directions.**
>
> **(1) Q8 candidate (b) — "not implementable as specified" — is TOO STRONG and is overturned in
> part.** Zero-knowledge means the *hub* cannot open the blob; the *customer* can, with R, which is
> what the ceremony exists for. The mechanism to hand a rebuilt box its old repo password back —
> customer-present, not unattended — is a real design, and the operator has since ruled on its shape.
> What (b) got right is narrower than what it said: **an UNATTENDED rebuild cannot recover from a
> zero-knowledge escrow.** A customer-present one can. The recon prices that shape row by row.
>
> **(2) A LOAD-BEARING FACT THIS SPIKE DID NOT CHECK, and it makes (d)-alone weaker than ranked
> here.** `host_escrow_superseded` **has no `identity_blob` column**, and `demoteCurrentEscrowTx`
> (`hub/internal/store/store.go:2547-2556`) copies only the K-escrow `blob`. So the retained
> "superseded" blob holds the **PBS datastore key**, not the **restic repository password**. The
> retention feature does not retain the secret it was built to retain, and the **next escrow ceremony
> — which the system tells the customer to run whenever a rebuild leaves the escrow `pending` — is
> what destroys the last copy.** Both demo boxes crossed that line on 2026-08-04 at 07:15:36 and
> 07:20:08. → **R-198**.
>
> **Consequence for this document's recommendation.** "Ship (d), then decide (c)" still holds, but
> (d)'s implicit promise — *the old history stays recoverable with the recovery code* — is **false as
> the code stands**, and is repeated verbatim to the customer in Hungarian on the orphan card. (d) is
> not complete without R-198.
>
> **Unchanged and still correct:** Q1, Q2, Q4 (R-196), Q5, Q6, Q7 (R-192) and the Q8 (a)/(c)/(d)
> analyses. **Q3 remains UNMEASURED** — neither box has run since; the 2026-08-05 ~02:15 UTC run is
> still the decisive one.
---
## 0. Why this exists
Two candidate fixes were named when R-193 was filed, and neither could be weighed, because two prior
@@ -412,6 +445,12 @@ re-keyed, `host_leaf_changed` 2026-08-03 06:09:40), so re-enroll returns the exi
### (b) The credential becomes recoverable from escrow at re-bootstrap
> **ANNOTATED 2026-08-04 (recon) — this ruling is overturned in part. See the annotation at the top.**
> The constraint is real but narrower than written: an **unattended** rebuild cannot recover from a
> zero-knowledge escrow; a **customer-present** one can, and the operator has ruled on that shape.
> The recon also found that the chain this option would use is clientless at three links and lossy at
> a fourth (`RECON-offsite-dr-chain-2026-08-04.md` §3–§4).
- **BLOCKED AS STATED, and this is a hard constraint, not an estimate.** The escrow blob is
**R-wrapped and zero-knowledge** — the hub holds opaque bytes and has no recovery code
(`api/handler.go`, and D6 in `CONTEXT.md` S-3). **A rebuilt box cannot unseal it without the
File diff suppressed because one or more lines are too long