16 KiB
REPORT — hub v0.93.0: the retention keeps the key it was built to keep (R-198) + the honesty pass (R-197, R-192, R-196)
Date: 2026-08-04 · Class: implementation. One additive schema change, no destructive operation.
Deployed: gitea.dooplex.hu/admin/felhom-hub:0.93.0, ArgoCD Synced, rollout complete, schema
change confirmed applied on the live database.
Supersedes the earlier 2026-08-04 report (the RECON session), per the overwrite convention. Nothing durable from it lived only here — it is in
audits/RECON-offsite-dr-chain-2026-08-04.md,OPEN-ITEMS.mdR-198…R-202 and the capability map.
1. Confirmed baselines, and the three landmarks
| Repo | on arrival | end state |
|---|---|---|
felhom.eu |
3f2b7bc02386, clean, == origin/main, hub v0.92.0 |
40687b0, hub v0.93.0 deployed |
felhom-controller |
0887fd676d60, clean |
untouched — Part 5 hit its gate (§7) |
§3's three landmarks, re-confirmed in the real source before editing — none had drifted:
host_escrow_superseded(store.go:377) hadbloband noidentity_blob.identity_blobwas added tohost_escrowby a laterALTER TABLE(store.go:414).demoteCurrentEscrowTx(store.go:2547) selected six columns and not that one.
Highest register ID on arrival: R-201. Grepped OPEN-ITEMS.md, ROADMAP.md, STATUS.md,
CONTEXT.md before minting. One new item: R-202.
2. §8.1's two facts — both LOOKED AT on the live database, not deduced
Read from a hub.db + -wal + -shm snapshot taken after the deploy (PRAGMA integrity_check →
ok; freshness proven positively: MAX(host_reports.received_at) = 2026-08-04 10:45:39 vs
datetime('now') = 10:58:59).
(a) Nothing is backfillable, and here is the looking:
| id | host_id | identity_blob IS NOT NULL |
superseded_at |
|---|---|---|---|
| 3 | demo-hp-bb76ea |
0 | 2026-08-04 07:15:36 |
| 4 | demo-felhom-8363b5 |
0 | 2026-08-04 07:20:08 |
Both retained rows are empty of it, and their source rows were overwritten at those same timestamps —
host_escrow now holds the new generation. No path preserved them. (The prompt asked me to check
whether some path had, because that would have been a better answer than expected. It had not.)
(b) Who the fix protects, i.e. whose NEXT ceremony now retains a recoverable off-site key:
| host_id | identity blob present | bytes |
|---|---|---|
demo-felhom-8363b5 |
yes | 572 |
demo-hp-bb76ea |
yes | 572 |
2 of 2 hosts with a current escrow. Both were one ceremony away from losing their repository password again.
Schema change confirmed applied live — PRAGMA table_info(host_escrow_superseded) on the running
pod's database returns identity_blob BLOB as column 8. It runs at startup, so this is the check that
the startup actually did it rather than the code merely containing it.
3. Files modified, and the commits
Commit 91cabdd — code + docs:
| File | Change |
|---|---|
hub/internal/store/store.go |
identity_blob on host_escrow_superseded (CREATE + additive ALTER); carried in demoteCurrentEscrowTx; ListSupersededEscrow reads it; HostEscrow.IdentityBlob; new CountCurrentEscrowWithIdentity; SaveHostEscrow returns the replaced hash; the table comment rewritten to describe what the code now does and to record that it once did not; three R-196 comment corrections |
hub/internal/api/handler.go |
maybeEmitRepoKeyChanged + eventRepoKeyChanged; wired into the superseded branch of the escrow PUT; one R-196 comment correction |
hub/internal/monitor/offsite_delivery.go |
deliveryShape (burned/regressed); two-shape message; offsite_reports_since_consume in the details; recordHealRefusal; refusal branches split so each names its reason |
hub/internal/notify/dispatcher.go |
offsite_repo_key_changed, offsite_delivery_stuck, offsite_credential_restaged → operatorOnlyEvents |
hub/internal/offsite/offsite.go |
one R-196 comment correction |
new: store/escrow_identity_retention_test.go, api/escrow_repo_key_changed_test.go, monitor/offsite_delivery_honesty_test.go |
Scenarios A–G |
11 existing _test.go files |
mechanical: SaveHostEscrow now returns three values |
hub/CHANGELOG.md, CONTEXT.md (S-28), REUSE.md, STATUS.md, 00-capability-map.md, 03-host-agent.md, OPEN-ITEMS.md, ROADMAP.md |
docs |
Commit 40687b0 — manifests/hub.yaml 0.92.0 → 0.93.0.
4. Tests, and every red-proof outcome
Green gate after each phase: go build ./... && go vet ./... && go test ./... in hub/ — rc=0,
full suite, run separately from every commit (never combined).
| Group | Scenario | Result | Red-proof — mutation → outcome |
|---|---|---|---|
| A | re-escrow retains both keys | PASS | drop identity_blob from the shared copy routine (= production ≤ v0.92.0) → FAIL: "the retained row carries NO identity blob — the off-site repository password was destroyed by the ceremony that was supposed to preserve it" |
| B | host delete demotes both | PASS | same mutation → FAIL. Plus the §8.2 mutation: fix only the re-escrow caller (patch the retained row in SaveHostEscrow instead of the shared routine) → A passes, B FAILS — the "fixed one caller" mistake, demonstrated |
| C | legacy host with no identity blob supersedes cleanly | PASS | — (a nullable-column check; no guard to remove) |
| D | changed key raises one signal, host named, no hash values | PASS | remove the comparison from the escrow PUT → FAIL: "the repository key demonstrably changed and NO signal was raised", while E still passes |
| E | unchanged key is silent | PASS | — |
| F | message states the measured state | PASS | restore the single hardcoded sentence → both message tests FAIL |
| G | heal refusal leaves a durable record | PASS | replace the regressed branch with a bare return (= production ≤ v0.92.0) → FAIL, and its cadence test with it |
| — | IsOperatorOnly for all three event types |
PASS | — |
| — | CountCurrentEscrowWithIdentity census |
PASS | — |
A mutation that did not remove every guard, recorded because rule §9.12 requires it. My first
Scenario-F mutation kept the switch's default branch, so the regressed test passed for the wrong
reason and only the burned one failed. That is not a red-proof. The mutation that does fail both is
deleting the shape switch entirely and emitting the one v0.92.0 sentence — done, observed, recorded.
Both callers proven separately (§8.2): TestSaveHostEscrow_RetainsIdentityBlob (re-escrow) and
TestDeleteHost_DemotesIdentityBlob (host delete). The pre-existing
TestSaveHostEscrow_RetainsSuperseded stayed green throughout the two months the bug existed,
because it asserts the mechanism (a retained row exists, carrying the old K-blob). The new tests assert
the consequence (the retained row can still yield a repository password) — CLAUDE.md's rule, applied
to the case that earned it.
Every -run filter was verified to have matched (-v, === RUN lines counted) before any result
was read as a proof.
5. The exact operator message text, quoted for review
R-197 — offsite_repo_key_changed (warning, operator-only, edge-triggered once per supersession):
Offsite repository key CHANGED for host
<host_id>: the new escrow seals a different repository password than the one it replaced. The previous off-site history is no longer opened by this box's current key. The superseding blob was retained (<N>held), so that history stays recoverable with the recovery code that sealed it — verify the box's off-site tier reports a repository rather than an orphan card, and expect the next backup to start a fresh history.
Details JSON: {"host_id": "...", "retained_count": N, "repo_key": "changed"} — no hash values.
R-192 — offsite_delivery_stuck, burned shape (unchanged in intent, corrected in wording):
Offsite delivery stuck (BURNED-credential shape): the one-time password was consumed
<age>ago; of the first<N>report(s) after that consume, NONE carried an offbox target, and the latest report carries none either. The credential never reached a persisted apply. Re-issue delivers a fresh one.
R-192 — regressed shape (the demo-hp case, which previously got the burned text):
Offsite delivery stuck (REGRESSED-apply shape): the one-time password was consumed
<age>ago; of the first<N>report(s) after that consume,<M>DID carry an offbox target — and the latest report carries none. The credential was applied and worked; the target was lost afterwards. Re-issue is NOT the indicated action: find what removed the offbox target (a guest rebuild does, R-193). Automatic restage is deliberately withheld for this shape. NOTE: the counts cover at most the first 500 reports after the consume, so on a long-lived box they describe the start of the window, not now (R-192, open).
R-192 — the refusal record (notification_log, channel operator, status refused), message
"Automatic offsite credential restage was NOT performed." with the reason in error_message, e.g.:
regressed-apply shape:
<M>of the first<N>report(s) after the consume DID carry an offbox target, so a burned credential is ruled out — a restage would treat a symptom whose cause is elsewhere. Operator's call (R-193).
6. The corrected comments — FIVE, not three, with their locations as found
The spec expected three and named two; a census of every restic mention in the hub found five.
| # | Location | What it claimed |
|---|---|---|
| 1 | hub/internal/offsite/offsite.go:198 |
"the restic repo password just changed" as the justification for MarkEscrowStale |
| 2 | hub/internal/api/handler.go:1067 |
"The re-issuer resets the restic repo password, which makes the OLD escrow blob stale" |
| 3 | hub/internal/store/store.go:423 (the stale_at ALTER) |
"stale_at is set when the offsite repo password is re-issued" |
| 4 | hub/internal/store/store.go MarkEscrowStale doc comment |
"called when the offsite repo password is re-issued" |
| 5 | hub/internal/store/store.go EscrowStatus.Stale field comment |
"true when the offsite password was re-issued" |
All five now state what the code does, name the correction with its date and item, and cite the recon. The staleness mark is documented as precautionary — the box's re-apply may mint a fresh repository password — with R-197's measured signal named as the evidential one. The behaviour is unchanged and R-196 stays OPEN, because the false-staleness alarm is a behaviour change that must not ride a comment-correction release. It also matters more now than when filed: under R-198 an unnecessary ceremony is no longer harmless bookkeeping, it supersedes a blob.
7. Part 5 — the gate was hit; the card is untouched; R-202 filed
The gate: ship it iff the hub can tell a box what it needs with one additional boolean on the escrow ACK it already sends.
The hub can compute such a boolean cheaply — "≥1 retained blob for this host carries an identity
blob", one correlated predicate in GetEscrowStatusForCustomer — and the controller even has the right
seam already (SetEscrowStale/StaleBlob is exactly this shape). But that boolean does not answer
the card's question. The card renders on RepoState == "orphaned", and its promise is about the key
THIS orphaned repository was written under. A box does not know which escrow generation the orphaned
remote belongs to. A box with a pre-v0.93.0 orphan and a post-v0.93.0 supersession would read the
boolean TRUE and the promise would still be false — a conditional falsehood that looks verified,
which on a customer-facing card is worse than today's hedged one. Making it truthful needs the orphan's
generation, which is the same knowledge R-199/R-201's unassembled chain needs.
So: stopped, filed R-202, left the card alone, per §8.6. Stated plainly rather than buried: the false sentence is still live, in Hungarian, on both demo boxes. The cheapest honest interim — drop the recoverability clause and say only that the old history is set aside and not deleted, which is true unconditionally — is recorded in R-202 and not taken here, because it is a customer-copy change and the gate said leave it alone.
8. Register
- R-198 → SHIPPED (hub v0.93.0), with the §8.1 facts and both red-proof outcomes recorded.
- R-197 → SHIPPED (hub v0.93.0).
- R-192 → HALF SHIPPED: both honesty halves done; the guard's 500-oldest-reports scoping stays OPEN, deliberately, because its correct shape depends on the recovery chain that is not yet assembled. The window is named inside the alert text so the limitation travels with the number.
- R-196 → comments corrected; the BEHAVIOUR stays OPEN.
- R-193 → still open, updated with the three operator rulings.
- R-202 → NEW (Part 5's gate).
- R-199, R-200 and R-201 remain OPEN and untouched — the hub's blob-serving endpoints still have no client, the injection seam still has no form, and nothing in the recovery path has ever been exercised. v0.93.0 makes the key survive; it does not assemble the chain that hands it back.
- The three operator rulings of 2026-08-04 are recorded in a dedicated block at the top of
OPEN-ITEMS.md(a ruling that lives only in a conversation binds nobody — the R-96 rule): run the drill after R-198 (R-198 has shipped; the drill is next); delete the orphaned ciphertext — STILL OWED, deliberately not done here because a destructive act on a protected endpoint does not ride a schema-change release; and accept the risk on R-193(c), which is what makes this retention load-bearing rather than tidy. - Capability map,
03-host-agent.md§8a (a new Custody across generations section — the contract changed, so S-1 applies),CONTEXT.mdS-28,REUSE.mdandROADMAP.mdall updated in the same commit.
9. Deploy & validation
- Clean-tree gate held per repo before the build; the image was built from a pushed tree.
./build.sh 0.93.0 --push→felhom-hub:0.93.0, 25 MB. Manifest bumped in git, committed, pushed.- ArgoCD hard-refresh + deliberate sync (never
kubectl set image). Synced;deploy/hubrolled out; live imagegitea.dooplex.hu/admin/felhom-hub:0.93.0; startup log clean (all checkers initialized,Listening on :8080, no errors). - Live validation was structural, not manufactured (§9.3): the schema column read from the running pod's database, and the two censuses in §2. No ceremony was run on any box — today, a ceremony is the act that destroys the key, and it is also the one thing that would have "proved" the retention by consuming a real customer's generation.
- Disk headroom checked before the build (
/mnt/5_hdd24%,/86%). - Nothing provisioned; nothing to tear down.
- Gates:
python3 scripts/repo_gates.py --fast→ all OK, after every docs change.
10. CI
- Code commit
91cabddand manifest commit40687b0— run numbers, task ids and conclusions quoted in the session summary;--no-verifywas NOT used (the pre-push hook ranrepo_gates.py --fastand passed on both pushes).
11. Observations — noticed, not acted on
ListSupersededEscrowstill has no production caller. v0.93.0 makes it return the identity blob, so a retained key is reachable from Go for the first time — but nothing reads it. That is R-199's territory and was deliberately not widened here.handleReEnrollreturns a freshly minted API key in its response body on an endpoint nothing currently calls. Worth a look before anything starts calling it (R-199).CountReportsOffsiteSince'sLIMIT 500is the only instrument in the delivery checker, and its window is now named in operator-facing text. If R-192's remaining half is ever fixed, that sentence must change with it — the R-100 corollary (an alarm whose text stopped matching what its verdict counts) applies directly.offsite_delivery_stuckhad been reaching the customer channel by construction for its whole life; the only reason no customer row exists is that the demo customers have no configured recipient. An absent notification row is not evidence a leg is blocked.