## v0.96.0 — the hub answers a rebuilt box's request (2026-08-05, R-204 item 4 / R-193 / R-192) The hub half of the last manual intervention the 2026-08-04 drill needed. The box declares (controller v0.199.0); this answers. ### `internal/offsiteheal` — the sibling of `pbsdrheal` Same shape, same restraint: **declared states only**, a debounce, **restage → escalate**, a distinct audit event per remediation, and a healthy box is a pure no-op. It never blind-timer-reissues and never re-runs a provisioning step. **The debounce is TWO distinct reports, derived rather than chosen.** The controller reports every ~15 minutes, so two distinct declarations mean the state survived a full report cycle — a restart, a slow first report or a transient config read all resolve well inside one. One report would act on a blip; three would leave a stranded customer waiting ~45 minutes for the one thing they cannot obtain any other way. The reconciler's own tick (5 min) is deliberately faster than the report cadence so it adds no latency of its own; the debounce counts **fresh evidence, not ticks**. **§8.4 — is there a deliberately-unhealed state, as `pbsdrheal` has in `verify_failed`?** Yes, and it is excluded **by construction** rather than by a case in the switch — worth stating plainly, because "there is nothing like that here" is usually wrong. The analogue is the **regressed** shape: a box that had a working tier and lost its target while still holding its repository password. Re-arming a credential would not help it. It cannot reach this reconciler at all, because the controller's declaration predicate requires the repository password to be **absent**. ### Restage before mint — and the fact it rests on `store.RestageOneTimeSecret` clears the consumed flag without touching the value, mirroring `RestageHostPBSSecret`. **That the value survives a consume was established from this schema and from `ConsumeOneTimeSecret` — which stamps `consumed_at` and nothing else — not inherited from the PBS analogy.** The two secrets are different objects with different lifecycles, and assuming a shared shape is how two earlier sessions confused the credentials. `TestRestageOneTimeSecret_ReArmsTheSameValue` asserts the **same** value comes back, so a future "hardening" that cleared the column would fail loudly instead of silently turning every rebuild into an external mint. ### `reportHasOffsite` tightened — the ninth comment-vs-code entry, caught before it shipped Its comment asserted *"the report builder attaches `offsite` only when the box actually has an offbox target configured, so presence == applied-on-the-box"*. Controller v0.199.0 breaks that premise deliberately. Left as bare presence, this would have read a **request for help** as proof the tier was **applied** — making `DeliveryStateFor` return `DeliveryApplied` for exactly the stranded boxes, and suppressing their stuck event. It now requires `enabled:true`, which is **provably a no-op for every report shape that existed before** (an attached object has always carried `enabled:true`), and the equivalence is measured in `TestReportHasOffsite_EnabledOnly` rather than argued. ### R-192's guard half — CLOSED BY REPLACEMENT, not repaired The delivery checker decided the shape by counting how many of the **oldest 500** reports after a consume carried an offbox target. On demo-hp all 500 predated the rebuild, so it confidently reported the *regressed* shape and declined to heal — for 108 reports, while the box sat stranded. A declaration needs no window, no count and no inference, so it **outranks both inferred shapes** (`shapeDeclared`), the stuck e-mail says what was declared instead of prescribing Re-issue, and `maybeHeal` **stands down with a record** because `offsiteheal` owns the remediation. Two mechanisms healing one customer would double-issue — and this one can only mint, so it would also skip the cheap path. ### What is deliberately NOT automated **No escrow ceremony is ever run or requested.** A credential is replaceable; the recovery code is not, because only the customer holds it. **Credential automatic, key customer-present.** **On security, weighed rather than skipped:** a re-issued credential is transport only. Anyone able to forge the declaration already controls the box, and the box already holds its own installed access key. The data stays sealed under the recovery code throughout. ### Rider — the pre-push hook refuses a clone outside the workspace One assertion, identical in all four repos. See the controller's v0.199.0 entry. ## v0.95.0 — a re-issue no longer marks a healthy escrow stale (2026-08-05, R-196 / R-204 item 2) `ReissueCredentials` called `MarkEscrowStale` on every re-issue that found an escrow row. The mark was **precautionary, not evidential**: it guessed that the box's re-apply MIGHT mint a fresh repository password. On the ordinary re-issue shape — a box that still holds its `/offbox/repo_password` — the password does not change, so it marked a HEALTHY escrow stale. The 2026-08-04 recovery drill (R-201) is what promoted this from a nit to a blocker. **What the mark actually did**, because "asks for an unnecessary ceremony" understates it: 1. `stale_at` set → `GetEscrowStatusForCustomer` WITHHOLDS `restic_pw_sha256` from the report ACK. 2. With no hash, the controller's SLICE-3 auto-confirm cannot flip pending→escrowed. 3. `OffboxRunnable() = OffboxConfigured() && EscrowState=="escrowed"` → **every off-site backup refused, indefinitely**, on a box whose key was never in doubt. 4. The customer is told to re-run the recovery ceremony — which mints a new recovery code and supersedes the sealed blob. During a recovery that is the one act that would have destroyed the key just recovered. A precautionary flag that stops the data protection it is guarding is not conservative. **Removing it leaves no gap — the case it guessed at is measured elsewhere, and better.** - **Continuous, box-side:** the controller compares the ACK's sealed hash against its CURRENT local repo password on every report ACK (`report.EscrowAutoConfirmer.reconcileEscrowed`, the Scenario-F re-check). In the guest-rebuild shape — the only shape where a re-issue is followed by a fresh repository password — that mismatches within one report cycle and raises the stale card plus the „create a new recovery code" CTA. Pinned by the controller's `TestEscrowStale_MismatchWarnsOnceAndFlags`. **And the mark was BLINDING it:** by emptying the hash (step 1) it removed the very value that comparison needs, leaving the box able to report only the hash-LESS reason, which is false. - **Edge-triggered, hub-side:** R-197's `offsite_repo_key_changed` fires on a proven hash difference across a supersession and pages the operator. Pinned by `TestEscrowPut_ChangedRepoKey_RaisesSignal`; red-proved by removing the `maybeEmitRepoKeyChanged` call. `offsite_reissued` is unchanged and still always fires — the customer must still learn that the credential moved. Only the stale marking and its `escrow_stale` event are gone. **`MarkEscrowStale` is kept, not deleted, and now has no caller** — deliberately. The `stale_at` flag is still live and correct (read by the ACK, the operator card and the PBS-DR view); the right way to set it is a future caller that has MEASURED a key change rather than guessed at one. Its doc now says so, and `TestEscrowStaleMechanism_StillWithholdsAndClears` keeps the mechanism from decaying while nothing writes it. **Disagreement recorded** (the R-96 standing rule): the task's Scenario D asked that a real key change also "mark the escrow stale". It must not, and nothing was changed to make it — the hub learns of a real change at the moment a supersession SEALS THE NEW PASSWORD, i.e. when the escrow is freshest. Marking it stale there would ask for a ceremony to fix the ceremony that just ran. The correct consequence at that instant is the operator alarm, which is what R-197 already does. `TestReissue_InvalidatesEscrow` is replaced in place by `TestReissue_DoesNotMarkAHealthyEscrowStale`, its exact inverse, so the reversal is visible in `git log -L`. The old test was not wrong about the effect it measured; it was wrong about the cause, and it pinned the defect. ## v0.94.0 — a box can fetch its own sealed recovery package (2026-08-04, R-199 link 6) **Chain link 6 had no client.** The hub has served the identity blob since slice 10D, from `handleReEnroll` / `handleGetRestoreDirective` — gated on operator-armed recovery mode and the global key. Nothing in the agent, the hub UI, any script or any runbook ever called either. The only documented retrieval was `sqlite3 … writefile(…)` by hand on a `kubectl cp`-ed database. **`GET /api/v1/hosts/{host_id}/escrow`** — the box-authenticated MIRROR of the PUT that put the blob there. Self-scoped: a per-host key reads only its own; the global key may read any, the same asymmetry the PUT has. A host with no sealed bundle gets `200 {present:false}` — a clean answer, because a 404 is indistinguishable from an unknown host and a bare empty 200 from a zero-length blob, and neither of those is what "no ceremony has run yet" means. **THE TRADE, RECORDED IN THE HANDLER RATHER THAN INFERRED.** Before this, obtaining the blob required the OPERATOR to arm recovery mode. Now whoever controls a rebuilt box can obtain it with that box's own credential. That is a real reduction in the number of parties required, and it is the whole of the trade — they still cannot open it, because the hub has never held R and a wrong code fails closed at age's scrypt KDF. **The mitigation is that the capability is audited rather than silent:** every successful retrieval raises `escrow_blob_served` (warning, operator-only), recorded before the bytes leave. A silent capability on this object is the shape the last two weeks were spent removing. `escrowSelfServiceRetrieval` is a single named constant — the §8.2/§8.3 decision point. Flipping it to false re-imposes the recovery-mode requirement on this endpoint and changes nothing else, so the operator can overrule the trade at the cost of a boolean rather than a redesign. **The operator-driven DR path is untouched** — same gate, same behaviour, pinned by a test that exercises re-enroll and restore-directive with recovery mode off and on. Red-proofs observed: removing the ownership check makes a cross-host read succeed (host A served host B's blob); removing the audit record makes the retrieval silent. **Not in this release:** the customer-facing flow. No card, no form, no preview — those are designed on ground that has been walked, and R-200/R-201 are that walk. ## v0.93.0 — the retention keeps the key it was built to keep, and three things stop lying (2026-08-04, R-198/R-197/R-196/R-192) ### R-198 — the superseded-escrow retention was preserving the wrong key, and the ceremony was destroying the right one `host_escrow_superseded` shipped in v0.60.0 with a `blob` column — the **K-escrow**, i.e. the PBS datastore encryption key. `identity_blob` was added to `host_escrow` **later**, by the slice-10D `ALTER TABLE`, and **never to the retained table**. The offsite **restic repository password** — the data key for every off-site app-data snapshot — lives inside `identity_blob`. So `demoteCurrentEscrowTx`, whose own comment calls it *"THE ONE escrow row-copy routine"*, retained the whole-guest key and silently dropped the off-site data key: **precisely the secret the reinstall-orphan incident the feature was built for was about.** **What makes it the most serious defect this project has recorded is not the missing column — it is which act destroys the key.** The copy happens as the new blob overwrites the old, so the destroying act is the **escrow ceremony**: the exact thing a rebuilt box tells its customer to run, on a card that promises in Hungarian that the old backups stay recoverable with their recovery code. Both demo boxes crossed that line on 2026-08-04 (07:15:36 and 07:20:08), and their previous repository passwords are now unrecoverable whether or not anyone kept the recovery codes. - `host_escrow_superseded` gains `identity_blob BLOB` (in the CREATE for fresh databases **and** as an additive `ALTER TABLE`, following the existing idiom; tolerated on re-run, changes no existing row). - `demoteCurrentEscrowTx` carries it — so **both** callers are fixed by one change: the re-escrow path (`SaveHostEscrow`) and the host-delete demotion (`DeleteHost`). - `ListSupersededEscrow` reads it back and `store.HostEscrow` gains `IdentityBlob`, so a retained blob is reachable from Go at all. `CountCurrentEscrowWithIdentity` is the census of who the fix protects. - The comment above the table now records that the ruling stated there was not met, and what it cost. - **Nothing is backfillable and the code says so:** rows superseded before this were written without the identity blob and their source rows are already overwritten. **Tests assert the CONSEQUENCE, not the mechanism** — the pre-existing `TestSaveHostEscrow_RetainsSuperseded` was green throughout, because it asserted that a retained row exists with the old K-blob. `TestSaveHostEscrow_RetainsIdentityBlob` asserts the retained row can still yield a repository password, and pins the load-bearing ordering (the identity blob is written **after** `SaveHostEscrow`, so the demote sees the previous generation). `TestDeleteHost_DemotesIdentityBlob` proves the shared routine through its **other** caller — a shared routine tested through one caller is how a fix gets believed on a path nobody exercised. Red-proofs: dropping the column from the copy fails both; fixing only the re-escrow caller fails the delete one while the re-escrow one passes. ### R-197 — the hub held both halves of the evidence and compared them nowhere `restic_pw_sha256` is stored on the current escrow and carried to the retained row on every supersession. Comparing the two is what answered the 2026-08-04 recon's hardest question in one query — and nothing in the hub did it. demo-felhom's repository password changed on 2026-08-03, orphaning 36 snapshots / 1.14 GB, and **no event, e-mail, card or log line said so for thirteen hours.** `SaveHostEscrow` now returns the hash it replaced, and `handleHostEscrowPut` raises **`offsite_repo_key_changed`** (warning, operator-only, edge-triggered once per supersession) when both hashes are known and differ. The in-between shapes are logged rather than dropped, so *"we chose not to alarm"* and *"the check did not run"* never look identical. **No hash value travels** — not in the message, not in the details; the event names the host, the customer and the retained count. **Severity chosen for the world this release creates, not the one it inherits:** before R-198 a changed key meant the previous history was unopenable by anyone, ever, which would have argued for `error`. From v0.93.0 the superseding ceremony retains the old identity blob, so the situation is *"this customer's off-site history now depends on an older recovery code"* — operator-actionable, not a loss. `warning` also routes: the dispatcher treats `info` as an intentional non-notify. ### R-192 (partially) — the alert describes what was measured, and a refusal leaves a trace demo-hp e-mailed this daily from 2026-08-03: *"one-time password consumed 284h19m0s ago and 500 report(s) since carry no offbox target — the credential is likely burned … Re-issue delivers a fresh one."* `OffsiteReportsSinceConsume` for that same call was **500**: every one of those reports carried a target. The message stated the precise negation of its own measurement and prescribed a remedy for a failure mode that had not occurred — while the self-heal correctly declined, **in complete silence**. - The one stuck state is now reported as the two situations it actually covers, **burned** and **regressed**, each stating its own measurement and carrying its own recommendation (the regressed text explicitly withdraws Re-issue and points at what removes an offbox target — a guest rebuild, R-193). `offsite_reports_since_consume` is in the details for the first time. - **The counting window is named in the text.** `CountReportsOffsiteSince` reads `ORDER BY id LIMIT 500` — the **oldest** 500 reports after the consume — so on a long-lived box the counts describe the start of the window, not the present. **That guard defect stays OPEN (R-192)** because its correct shape depends on the recovery chain that is not yet assembled (R-199/R-200/R-201); naming the window is how it stays visible instead of being laundered into a confident sentence. - **Every self-heal refusal now leaves a record** — a `notification_log` row on the operator channel with status `refused` and its reason, the R-182 suppressed-e-mail precedent. It rides the stuck event's 24 h cadence, so it sits beside the e-mail it explains rather than accumulating per tick. **The guard's logic is unchanged**: the set of situations in which the heal fires is what it was. - `offsite_delivery_stuck` and `offsite_credential_restaged` are added to `operatorOnlyEvents`. Neither was ever registered, and neither has a `customerMessages` entry — which is **not** a block, so a customer with a configured recipient was in line for an English e-mail about one-time passwords being *"likely burned"*. Narrowing only; the operator channel is untouched. ### R-196 — five comments corrected (the spec expected three) `ReissueCredentials` resets the **provider account** password. It does not and cannot touch the restic repository password, which is generated on the box and never leaves it except sealed under R. Five places in the hub said otherwise: `offsite/offsite.go` (the `MarkEscrowStale` justification), `api/handler.go` (the F3 re-enroll comment), and three in `store/store.go` (the `stale_at` ALTER, the `MarkEscrowStale` doc comment, and `EscrowStatus.Stale`). **That false belief sent two separate investigations the wrong way in one day.** All five now say what the code does, name the correction and cite the recon; the staleness mark is documented as **precautionary**, not evidential. **The behaviour is unchanged and R-196 stays OPEN** — the false-staleness alarm it causes is a behaviour change and must not ride a comment-correction release. ### Not in this release **R-199, R-200 and R-201 remain open** — the hub's blob-serving endpoints still have no client, the password-injection seam still has no form, and nothing in the recovery path has ever been exercised. **Part 5 hit its gate and the orphan card is untouched** — see R-202: a single ACK boolean can say whether a retained recoverable blob exists, but not whether one covers *the key this orphaned repository was written under*, and a conditional promise that is still capable of being false is worse on that surface than a hedged sentence. ## v0.92.0 — a customer with no machine ever bound does not alarm (2026-08-04, R-195) `david` is a real prospective customer whose record was created 2026-08-01 with **no host ever bound** — `hosts=0`, `host_deletions=0`, `host_reports=0`, `reports=0` — and it e-mailed an `expected_dbdump_missed` **error** at 03:00 UTC on 2026-08-02, 08-03 and 08-04. Nothing has ever been expected of that customer, so the honest verdict is UNKNOWN, not missed. **The mechanism is the interesting half, because the guard that should have covered this is keyed off the wrong thing.** `CheckBackupDeadlines` already skips nodes that are `down`, and that skip reads `StalenessChecker.GetState()`, whose map is seeded from `store.GetCustomers()` — **a query over the `reports` table**. A customer with zero reports is in no row, gets no state at all, and `GetState()` returns `""` rather than `"down"`. **The skip misses exactly the customer it would most obviously cover.** Corroborated on live data: `peti-felhom` is active with a host deleted 2026-07-15 and does *not* alarm, because it has 482 old reports and is therefore `down`. The backup half was already safe (`reportJSON == ""` → skip); the DB-dump half had no guard at all. - **`store.HasEverBoundHost(customerID)`** — a live `hosts` row **OR** a `host_deletions` tombstone. Consulted once per customer at the top of the deadline loop; a false verdict skips the customer with an INFO line and its own counter in the summary. - **The discriminator is "was a host EVER bound", NOT "has a report arrived", and that is the whole design.** A box that was installed, bound and then went silent is a real fault and must keep alarming — it has a `hosts` row, so it is judged. Only a customer that never had a machine is UNKNOWN. The tombstone is included for the same reason: a customer whose host was deleted *had* one, and that judgement belongs to the staleness checker, not to this predicate. - **Fail-OPEN on a read error.** An unreadable binding must never SUPPRESS a real alarm; it logs and judges anyway. - **The anchored-verdict structure is untouched** (R-81/v0.73.0). This is the same invariant — absence is UNKNOWN until an anchor elapses — applied one level up, at the question of whether there is a subject at all. **Tests, including the one that matters.** `TestCheckBackupDeadlines_BoundButNeverReported_StillAlarms` is a real shape — a machine installed and never phoned home — and it is indistinguishable from `david` on every signal except the one the guard reads. If the guard is ever "simplified" to key off report presence, customer age or a name pattern, it goes red. Plus a bound-then-went-quiet case, a deleted-host case, and the predicate's own three inputs. **Companion red-proof observed:** removing the guard fails `TestCheckBackupDeadlines_NeverBoundHost_Silent` with `got [expected_dbdump_missed]` — verbatim the event `david` sent three mornings running. Restored after. **`david`'s record was not modified.** The record is correct; the alarm was what was wrong. **Shipped alongside a spike that deliberately shipped NO code** — `documentation/audits/SPIKE-offsite-credential-recovery-2026-08-04.md` (R-193/R-192): what is one-shot in the offsite path, and what a guest rebuild really costs. It ends in ranked options and a question for the operator; R-196 and R-197 were minted from it and are not implemented here. ## v0.91.1 — observation may only WIDEN a tier's window, never tighten it (2026-08-03, R-86 Part 2) **Found by checking v0.91.0 against the live box before trusting it, not by review.** demo-felhom's offsite tier holds two retained snapshots — `2026-07-27T19:55:41Z` and `2026-07-28T04:49:43Z` — **8 h 54 m apart**, because one is a healing artefact and the other a real weekly run. The mean-gap estimator therefore reads a **weekly** tier as nine-hourly: ×4 gives 36 h, the 7-day floor lifts it to 168 h, and a weekly tier proved weekly reaches ~8.25 days of proof age. **The false alarm this whole task exists to prevent would have returned within a week, on the box it had just shipped to.** `restoreProvenWindow` now takes `max(observed, declared)`. Observation refines a tier's rhythm **upward** and is ignored downward, which is right on its own terms and not merely cautious: a gap SHORTER than the declared rhythm is routine and means nothing — a retry, a manual run, a heal, a catch-up after an outage — while a gap LONGER than it is real information, saying this tier genuinely receives archives less often than the model assumes and its window must widen or it alarms. **The cost, stated rather than hidden:** a tier that truly runs faster than its declared rhythm gets a wider window than it strictly needs, i.e. a slower `restore_test_stale` signal. That is the right direction for a signal whose message is *"unverified"*. *"Broken now"* is `restore_test_failed`, which is immediate and untouched. Three live-derived cases added to `TestRestoreProvenWindow_Contract`, including the exact 8 h 54 m gap measured on the box; red-proved by restoring the tighten-too branch (`window(pbs, observed=8h54m) = 168h, want 288h`). ## v0.91.0 — a tier's staleness window learns the tier's own rhythm (2026-08-03, R-86 Part 2) **This ships WITH the agent's v0.121.0, not after it.** The agent now proves a tier once per ARCHIVE GENERATION rather than on a 24h timer, so a tier backed up weekly is proved weekly — correctly, and in perfect health. `restoreProvenStaleAfter` was a flat 7 days, and its own comment derived that number from the cadence R-86 removes: > *"the restore-test cadence is 24h and rotation is oldest-first across two tiers, so each tier is > proven roughly every 2 days. 7 days therefore tolerates ~3 consecutive missed opportunities."* A weekly tier proved weekly reaches a proof age of **exactly one interval** just before its next proof — 168h against a 168h window, false by a hair — so it did not merely sit near that line, it sat ON it, and any ordinary delay (a late backup, one deferral behind a running backup) tipped it into a nightly alarm about a system that is working. Shipping the agent alone would have converted the improvement into a false alarm. **The window is now per tier**, `restoreProvenWindow(tier, observed, ok)`: - **the tier's own archive interval**, observed from the host-reports the hub already holds — `pbs_snapshots[]` plus successful `backups[]` attributed by TARGET TYPE (the slice-A.4 rule: a PBS-targeted vzdump appears in both arrays, and classifying by array membership would attribute an offsite archive to the host tier); - **× 4 generations** — the settle generation plus ~3 missed opportunities, deliberately the same tolerance the flat constant expressed. The change is to the RHYTHM, not to the patience; - **floored at 7 days** — the old constant kept as a floor, so no tier is judged more tightly than before; - **capped at 12 days** — strictly inside the 2-week offsite retention with two days to spare, so a tier is never called stale against an archive PBS has already pruned; - **falling back to the DECLARED rhythm** when a box's history is too short to observe one: `backupStaleAfter` (26h) for the host tier and `offsiteBackupStaleAfter` (8d) for the offsite tier — the very thresholds the backup-freshness checker already judges those tiers against. A fresh box with one snapshot has no observable interval, and falling back to the FLOOR there would recreate the false alarm on exactly the tier this task is about. **Kept, because both were earned:** absence is UNKNOWN until an anchored window has passed (R-81's structure, untouched), and the stale signal stays edge-triggered. `restore_test_failed` and `restore_test_stale` remain DISTINCT — one says your recovery is broken, the other says it is unverified, and the second is the one that quietly becomes the first. **Every reason string now states the window it was judged against** — R-100's corollary: when a verdict changes what it counts from, the alarm text has to change with it, or an operator reads "limit 168h" under a tier actually judged at 288h and dismisses a true alarm. **The window READ is unchanged in cost** (14 days), which is both enough to find proof inside the widest window and enough to see two generations of a weekly tier. ## v0.90.1 — the digest's per-app lines stop repeating the filesystem figures (2026-08-03, R-182) **Backfilled 2026-08-03 (R-86 session).** This version was built, deployed and recorded in `REPORT.md` and the R-182 row, but never given a CHANGELOG entry — and `REPORT.md` is overwritten every session, so the per-repo history under-reported what was actually running. The deployed image has been `felhom-hub:0.90.1` since `f21e7ca`. Found by reading the first REAL digest, not by design: every app row ended with the same usage clause the mail already prints once on its own Filesystem line. On a two-app box that is untidy; down a list of a dozen it is the same forty characters twelve times, pushing the part that DIFFERS off a phone screen at 07:00 — the only moment that mail has to work. The reserve's refusal message is authored for a single-app alert where naming the filesystem is right, so the message is unchanged and the DIGEST trims the duplicate when rendering. `trimRepeatedUsage` removes ONLY an exact "— :" suffix, so an unrelated reason is untouched and a reason that is nothing but the usage clause is left alone rather than emptied. Also inverted the operator half of `TestRecoveryUnitCaptureFailed_NeverReachesTheCustomer`: it required the operator to be e-mailed a per-app capture failure, which was correct when that event was the only signal and is wrong now that it is the RECORD and the digest is the notification. The customer-safety claim is unchanged — R-158's guarantee MOVED, it did not weaken. ## v0.90.0 — a dropped notification leaves a trace, and the backup digest arrives (2026-08-03, R-182) **The smallest change on the board with the largest effect on trust: `processOperator`'s cooldown no longer returns bare.** It used to drop the event *before* `LogNotification`, so a suppressed operator alert and an event that never happened were indistinguishable — from the operator's side **and from the hub's own records**. Measured 2026-08-03: nine `recovery_unit_capture_failed` events arrived, two were mailed, and **seven left no row anywhere**. That is why the defect took a day to get the right way round: there was nothing to read. A suppressed operator event now writes a `suppressed` row carrying the message and **the key that suppressed it**, so the collision is readable without reading code. This applies to **every** operator event, not only the one that exposed it. It deliberately does **not** change the cooldown's duration or semantics — it makes the drop visible, not absent. **`backup_run_failures` — the per-run digest.** One operator mail at the end of a backup run listing every app that failed, its leg and its reason, with the counts and the target filesystem's free space. Added to `allowedEventTypes` **and** to `operatorOnlyEvents` — allowlisting alone does not make an event operator-only, and `FormatCustomerEmail` falls back to the raw English message rather than blocking. A test demonstrates a customer with the type in their enabled list receiving nothing. **`recordOnlyEvents` — a third routing class.** Types that are STORED and RECORDED but never mailed. `recovery_unit_capture_failed` moves here: it is the durable per-failure record, and the digest is the notification. Deliberately a register rather than downgrading the severity to `info`, which would have had the same routing effect while relabelling a genuine failure as informational in the events table, the operator UI and every historical query. **`cooldownRunSuffix` — the run discriminator.** A sibling of `cooldownTierSuffix` rather than a branch inside it, so `tier` keeps byte-identical semantics and R-97a's tests are untouched. It makes the cooldown effectively inert for the digest, **which is the intent**: a digest is already rate-limited by construction, one per run and only when something failed, so there is nothing for a timer to collapse — while the periodic refresh sweep sends **no** `run_id` and therefore stays under the ordinary hourly cooldown. **The e-mail is rendered as a list, not a JSON blob** — the one operator mail with a variable-length payload, and a dozen apps on one line is unreadable on a phone at 07:00. An absent space reading renders as *unavailable*, never as zeros. ## v0.89.0 — the two halves of decision D-c (2026-08-02, R-167 · R-158) **Decision D-c routes two new signals to two different audiences, and the hub is where that routing is enforced.** A customer can free space, delete files or add a drive, so a FILL WARNING is theirs. A customer can do nothing about a per-app backup capture failure, so it is not. **New operator-only event type `recovery_unit_capture_failed`** (controller v0.191.0, R-158). Added to `allowedEventTypes` **and** to `notify.operatorOnlyEvents`. Deliberately NOT a reuse of `backup_failed`, which carries a `customerMessages` entry and sits in the controller's `DefaultEnabledEvents` — reusing it would email the customer, in Hungarian, about a failure they cannot act on. R-158's original proposal named `backup_failed`; D-c overrides it. **`disk_warning` / `disk_critical` lose their generic `customerMessages` entries.** These two types were allowlisted here, carried Hungarian copy, sat in the controller's default enabled events and had a UI checkbox — and **nothing in any repo emitted them**, a complete customer pipeline with no producer. Controller v0.191.0 becomes that producer, and it sends a **dynamic** Hungarian message naming the drive and its free space. A static entry would be actively harmful: `FormatCustomerEmail` PREFERS the entry over the message, so keeping one would discard the label and the byte figures and leave the customer with *"A lemezterület 90% felett van"* — a warning with nothing to act on. Same reason `offbox_enlarge_blocked` and `disk_health_degraded` have no entry. The deletion itself is pinned by a test. **New `notify.IsOperatorOnly`** — a read-only accessor so the `api` package can pin BOTH registers of a new event type in ONE test. Allowlisted-but-not-operator-only is invisible when the two are checked separately, and it is the defect v0.78.0 actually shipped. The register stays unexported so nothing can widen it at runtime. **Tests:** 574 → **579**. The operator half is proven through the real dispatch path under the *breaking* configuration — the customer has the event enabled and has an email address — because that is the only configuration in which a missing `operatorOnlyEvents` entry is visible. Red-proof: removing the entry shows the customer being emailed, and the `skipped/operator_only` log row is asserted as a positive observable rather than inferred from an absent delivery. ## v0.88.0 — the WAL that never was (2026-08-02, R-172) **The hub has never actually been in WAL mode.** `store.New` opened the database with `?_journal_mode=WAL&_busy_timeout=5000` — **mattn/go-sqlite3** syntax — while the driver is **modernc.org/sqlite**, whose `applyQueryParams` reads only `_pragma`, `_time_format`, `_time_integer_format`, `_txlock` and `_inttotime`. Everything else is **ignored without an error**. So the hub ran in the default rollback-journal mode with `busy_timeout=0` for its entire life, while its own source said otherwise — a configuration asserting an invariant the code did not provide. **How it surfaced.** A false `HOST STALE` banner for `demo-felhom-8363b5` while the agent was up two days and reconciling normally. In rollback-journal mode a reader excludes a writer, so rendering an operator page can block a host report; the hub then returns **HTTP 500**, the agent logs `hub: report failed; keeping current interval` and **waits its full 15-minute interval**, and staleness fires at 30 minutes. **Two consecutive collisions = a false alarm + an operator e-mail.** Measured: 13 `SQLITE_BUSY` collisions in one pod lifetime, and the alarm fired twice that day (19:12:32 and 20:42:32 CEST) for a host that was never down. **The observable that proved it:** a 128 MB `/data/hub.db` with **no `-wal`/`-shm` file beside it while the database was open**. In WAL mode those files must exist. **The fix is one DSN, and each parameter earns its place:** ``` ?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_txlock=immediate ``` - `journal_mode(WAL)` — readers and one writer proceed concurrently, so a page render can no longer block a report. It is a property of the database FILE, so it persists once set. - `busy_timeout(5000)` — writers still serialise; without a timeout SQLite returns `SQLITE_BUSY` *immediately* rather than waiting. - `_txlock=immediate` — **the one that is easy to miss, and WAL + busy_timeout alone would not cover it.** `database/sql`'s `Begin()` is DEFERRED, so a transaction that reads then writes must upgrade its lock, and a failed upgrade is `SQLITE_BUSY_SNAPSHOT`, which **`busy_timeout` does not retry**. This store has 10+ `db.Begin()` sites and they are all write paths (customer delete/reset, wg, appliance, pbsdr, telemetry, log bundles). Without this the fix would leave a known un-retryable path open. **Every test asserts what the DATABASE reports, never the DSN string** — a test on the string would have passed happily for the entire life of the bug. Five tests: the runtime pragma values; the `-wal`/`-shm` files existing beside an open DB (the production signature, pinned); a reader not blocking a writer (the consequence, not the mechanism); concurrent writers waiting instead of erroring; and racing read-then-write transactions. Plus `TestSQLiteDriverIgnoresMattnStyleParams`, a guard on the ROOT CAUSE: it fails if someone "tidies" the pragmas back to the familiar mattn form, and skips itself with instructions if a future driver starts honouring them. **Red-proof:** restoring the shipped DSN reproduces the live failure exactly — `journal_mode = "delete"`, the `-wal` absent, and `a write FAILED while a read was open: database is locked (5) (SQLITE_BUSY)`. **Operational consequence, handled rather than discovered later:** a WAL database cannot be copied by taking `hub.db` alone — a committed transaction may still be in `hub.db-wal`, so a bare `cat` yields a copy that opens cleanly and **silently omits the newest writes**. That is the worst shape for a credential lookup, and the break-glass retrieval in `documentation/operations/nodes.md` used exactly that command. Both it and the `_recovery-inventory` note are now WAL-aware (copy the `-wal`, shred both). **Retries (options b and c in R-172) were NOT added.** With readers no longer blocking writers and the upgrade path covered, a `SQLITE_BUSY` reaching an HTTP handler should now be rare enough to be a real signal. If any appear after this, they mean something else and a retry would hide it. Revisit only on evidence. ## v0.87.0 — the Setup tab stops claiming a host-install version it cannot know (2026-08-02) **R-94, all three legs, closed by deletion rather than derivation.** The customer page's Setup Command card read *"Day-0 host bootstrap for host-install **1.19.0**"*. The served script was **1.22.0**, and had been since 14 July — nineteen days of an operator-facing number that was simply wrong, with a version number's authority behind it. **Why deriving it is not achievable honestly.** The Option-1 command downloads `felhom-host-install.sh` from the website **at run time**, and the website git-syncs `main` every thirty seconds (R-110). The hub therefore cannot know which version a given box will run — not at build time, not at render time. Any literal there is a guess. The comment that guarded the old const already half-admitted this ("Display-only… the served script is always current"). R-94(a) offered *derive it, or delete it*; deleting removes the drift class permanently instead of automating it. **What changed.** - `internal/web/configs.go`: `const hostInstallVersion`, the `pageData.ScriptVersion` field and its assignment are **gone**. A NOTE stands in their place recording why there is deliberately no constant here, so the next person does not helpfully re-add one. - `internal/web/templates/customer_unified.html`: the sentence now says the command always fetches the **current** installer and renders no version at all. - `internal/web/render_test.go`: the assertion `strings.Contains(html, hostInstallVersion)` compared the constant to itself and **passed at any value** — demonstrated green with the const set to `9.9.9` while the served script was 1.22.0. Deleted, not replaced: there is no longer a version to assert. The `data-customer-id` and static-fallback assertions stay. - `scripts/hostinstall_gates.py` gate 1 **inverts**: it used to require the hub const to EQUAL `SCRIPT_VERSION`; it now asserts the hub carries **no host-install version literal at all**, matched in six code shapes across every `.go`/`.html` under `hub/`. Comments are deliberately not stripped — a `//` inside a URL string literal would truncate the scan and blind the gate — so the patterns match declarations, fields, assignments and the template action, never prose. - `scripts/felhom-host-install.sh`: **comment only**, `SCRIPT_VERSION` untouched. It claimed the gate keeps the hub copy equal, an invariant that no longer exists; a comment asserting an invariant the code does not provide is a wish. **Red-proofs.** Restoring the const fails the rewritten gate 1 on three of its six shapes. The old `render_test.go` assertion passes with the const at `9.9.9`. **Live verification:** endpoint-level (no browser on DooPlex) — the customer Setup tab is fetched and grepped for a version literal. ## v0.86.0 — Copy works without revealing, and every copy branch reports itself (2026-07-31) **Found by the operator, in the way that matters: it cost a real login.** The v0.84.0 Console access card shipped its **Copy button `disabled` until a Reveal**. Clicking it did nothing, silently — so the clipboard kept whatever was already in it, which was **another host's console password** from an earlier reveal. That got pasted into demo-hp's PVE login, which failed with no explanation. The box logged a plain `password check failed for user (root)`; the credential was never at fault, and there was nothing on screen to say the copy had not happened. **A copy button that silently no-ops is worse than no copy button**, because the operator has no way to distinguish "copied" from "did nothing" — and the stale value it leaves behind is a *valid secret for a different machine*, so the resulting failure looks like a stale-credential problem and sends you diagnosing the wrong thing. **Copy now works without revealing — and that is the safer default, not a concession.** The secret goes straight to the clipboard and never renders on screen, so it cannot be shoulder-surfed or caught in a screenshot. Reveal is still there for when you need to read it (typing at a console). **Three silent-failure branches closed, all in the same eight-line function:** | Branch | Was | Now | |---|---|---| | Not yet revealed | button `disabled`, click = no-op | fetches and copies | | `navigator.clipboard` absent (insecure context) | `if (navigator.clipboard)` → silently skipped | shows the password instead and says why | | `writeText()` promise REJECTED (permission / no user gesture) | promise ignored — the operator believes it copied | shows the password instead and reports the refusal | **The success path now names the host:** *"✓ Copied demo-hp-bb76ea's root@pam password to the clipboard."* The clipboard is fleet-wide and every box has a different console password, so "copied" alone cannot say copied for *which* box — precisely the confusion that produced the incident. **One retrieval path, shared.** `fetchConsolePassword` is used by both buttons, and the endpoint is defined once (the `data-reveal-url` attribute) and read back with `getAttribute`, so Copy cannot drift onto a different — unaudited — URL than Reveal. A test asserts the URL appears exactly once. Server-side nothing changed: both buttons hit the same CSRF-gated endpoint and both write the same `recovery_credential_revealed` event, which is correct — the register records **accesses**, and a copy is an access. Tests 566 → 568, both pinning this regression: the Copy button must not ship `disabled`, and every outcome branch must carry a message. Red-proof: re-adding `disabled` reproduces the shipped bug and turns the first test red. ## v0.85.0 — Network card: a host's addresses are visible at last (2026-07-31) **Pairs with agent v0.119.0 and is useless without it** — the agent is what reports the addresses. **A managed box's LAN IP was not shown anywhere in the hub, because nothing reported it.** The host report carried no address of any kind. The only IP reachable from the UI at all was the WireGuard one, on `/offsite`'s peer table keyed by pubkey — so an operator could go peer→host and never host→peer, which is the direction anyone actually asks in. **The host page grows a `Network` card:** every routable address the box holds, one row per (interface, address), plus a WireGuard row. On demo-felhom that is `vmbr0 192.168.0.162/24` and `tailscale0 100.70.170.35/32` — with the PVE web console reachable at `https://:8006`, which is the thing the operator wanted and could not get. **WireGuard is rendered as TWO facts, deliberately.** `WGAssignedIP` is the hub's own allocation (`wg_peers` — desired state, authoritative) and `WGConfirmed` is whether the box reports actually holding it. Showing the allocation alone would make a peer that was never applied look healthy — the same shape as reading a timestamp that records an *attempt* as if it recorded a *result*. A mismatch renders `not confirmed by the box`; there is a test for exactly that case, and a red-proof that pins it (hard-wiring `WGConfirmed = true` turns it red). **The split is keyed on the ALLOCATION, not on the interface name.** `wg-felhom` is the agent's current unit name; a UI keyed on that string would silently mis-render the day it changes. Comparing the reported address against the hub's allocated one uses the identity that survives a rename. **An old agent renders UNKNOWN, never "no addresses".** Below agent `0.119.0` the field is absent from the wire, and an absent signal is not a negative result — the page says *"this host's agent does not report its addresses — they are unknown, not absent"* and names the version needed. Rendering an empty list there would have stated something false about the host. Red-proofed: deleting the branch makes the page claim the host has no routable address. **No new store table and no new ingest path** — the report is already stored opaquely, and `GetWGPeerForHost` already existed with no UI consumer. This is parse + render. Files: `hub/internal/web/hosts.go` (`parseHostAddresses`, `hostNetworkView`, `hostNetwork`, `hostDetailData`), `hub/internal/web/templates/host_detail_body.html`, `hub/internal/api/testdata/host-report.golden.json` (the cross-repo contract, moved in lockstep with the agent's copy). Tests 559 → 566; four red-proofs (the inert view-model, unconditional confirmation, the old-agent branch, and the report fixture being the REAL wire from `--selftest=hub`) each run, observed failing, and reverted. ## v0.84.0 — Break-glass console credential on the host page (2026-07-31) **The credential existed and was not reachable when it was wanted.** Every Felhom-installed box has had a strong random `root@pam` console password since TASK G1 — set on the box by `felhom-host-install.sh` step 4b, vaulted in the hub at day 0, live for three hosts today, and used for real during the sshd incident. The only way to read it back was a hand-written `curl` against `/api/v1/admin/hosts//recovery-credential` carrying the **global operator key** — a different secret from the hub login password, kept out-of-band. In practice the PVE web console on a demo box felt locked. **The host page grows a `Console access` card.** By default it states only that a credential is vaulted, for which user, and when it was last set. A **Reveal** button fetches the plaintext on demand and shows it for 60 s with a Copy button; masking clears the JS variable, and the mask also fires on a second click and on `visibilitychange → hidden`. A host with nothing vaulted says so, and says why (byo host, or step 4b never ran), with no Reveal control at all. **The secret is never rendered into the page — that constraint shapes the whole change.** The render path calls a new `store.GetHostRecoveryMeta`, whose struct and whose `SELECT` both omit the `secret` column, so it is *structurally* incapable of carrying it; `hostDetailData` gains exactly three keys (`RecoveryVaulted`, `RecoveryUsername`, `RecoverySetAt`). The plaintext crosses the wire only in the response to `POST /hosts/{id}/reveal-recovery-credential` — `Cache-Control: no-store`, CSRF-gated at the `ServeHTTP` level, POST precisely so that gate applies and so a secret is never retrievable by URL alone (prefetch, history, referrer). The load-bearing test asserts the canary appears **nowhere** in the rendered response — attribute, comment, inline script or JSON blob. **Deliberately NOT the `customer_unified.html` `data-secret` widget**, which embeds the plaintext in the page HTML on every load: acceptable for one customer's retrieval passphrase, not for console root on every box in the fleet (it survives in the bfcache, in "save page as", and in any DOM-capturing screenshot). That widget is untouched and recorded as an observation. **Transparency, matching the log-pull precedent.** A delivered reveal writes one `recovery_credential_revealed` event (info, source `hub`, Hungarian) on the host's customer timeline — `SaveEvent` alone, no dispatcher call, so nobody is emailed. Two reveals write two events: the register records **accesses**, not states. A 404 is not an access and writes nothing. An unbound host reveals fine and writes no event (no customer to tell); the `[INFO]` hub line is then the only record, and it carries the username and a length — never the password. **The global-key API path is untouched, by design.** `handleAdminGetRecoveryCredential` is the break-glass route for when the hub *UI* is the thing that is broken; coupling it to the session layer would remove exactly the independence that makes it a fallback. **Recorded as a real trade, not a free one:** the hub session password alone now unlocks console root on every managed box, where retrieval previously also required the global API key. Accepted for a single-operator, HU-geo-fenced hub that already stores these passwords in plaintext at rest — and the plaintext-at-rest half is now filed as **R-133** (envelope-encrypt `host_recovery.secret` under a KEK held outside the DB, so a hub DB backup stops being a fleet-wide console-credential dump). Files: `hub/internal/store/host_recovery.go` (+`GetHostRecoveryMeta`, `HostRecoveryMeta`), `hub/internal/web/hosts.go` (+`handleHostRevealRecoveryCredential`, `hostDetailData`), `hub/internal/web/server.go` (route, **above** the bare `/hosts/` catch-all), `hub/internal/web/templates/host_detail_body.html` (card + fetch-on-demand script). Tests 550 → 559; four red-proofs (A page-leak, B audit event, D CSRF gate, E route order) each run, observed failing, and reverted. The E proof is a seam test driving `ServeHTTP`: a handler-level test cannot see that defect, because the handler is correct and simply never runs. ## v0.83.0 — R-109 + R-122: the recipe assembly stops dropping sections (2026-07-30) Pairs with **agent v0.118.0** (R-106 + R-109). The agent half is useless without this one. **`AssembleDRRecipe`'s two shape structs are ALLOW-LISTS, and nobody had noticed.** The doc comment sold `hostHalfShape`/`appHalfShape` as forward-compat — "encoding/json drops any unknown top-level key" — which is true and is also the trap: a section an emitter adds is **silently discarded** until it is named in both the shape struct and `AssembledRecipe`. No error, no log, no failing test. The section is simply not in the file the operator downloads. **R-122 (found this session) — that already happened, and it shipped.** The controller has emitted `offsite_restic` since fork-4 — the offsite restic repo's non-secret coordinates, whose entire purpose is "so DR knows WHERE to recover from". The hub stored it intact for **every real customer** (`peti-felhom`, `demo-felhom`, `demo-hp` all carry it in `dr_recipe.app_half_json` today) and `appHalfShape` never listed the key, so no delivered recipe has ever contained it. Verified both ways before the fix: the stored half has it, `GET /customers/demo-felhom/dr-recipe.json` did not. **R-109 — and it would have happened again the same day.** The agent's new `backup_target` (which storage holds the local whole-guest archives) is a new top-level host-half section. Without this commit it would have been stored and dropped exactly like `offsite_restic`, and the R-109 fix would have read as shipped while changing nothing an operator can see. Both keys are now on `hostHalfShape` / `appHalfShape` / `AssembledRecipe`, and the allow-list comment says what it actually is, plus the rule: **adding a recipe section is a TWO-REPO change.** Tests: 3 new, all consequence-level and all built on the halves production **really stores** — read verbatim out of the hub's own `dr_recipe` table (the pre-existing `drHostHalf`/`drAppHalf` constants are hand-written and OMITTED `offsite_restic`, which is precisely why the drop stayed green for the feature's whole life). `TestAssembleDRRecipe_CarriesEveryEmittedSection` enumerates every section both emitters produce and fails on any that does not survive assembly — the guard the allow-list needed and never had. `..._NamesTheLiveBackupTargetAmongTwoCandidates` asserts the delivered recipe names `felhom-backup` at `/mnt/hdd_1` and not the frozen `local`, and refuses to run if the fixture stops posing that problem. `..._UnknownBackupTargetSurvivesVerbatim` pins that the agent's explicit unknown reaches the operator AS an unknown and does not acquire a `storage_id` on the way through. Red-proofs: 2, each mutation asserted to have landed before running — drop `offsite_restic` from the allow-list (the R-122 defect restored) → 2 tests fail; drop `backup_target` → 4 fail, naming the section. `go build` + `go vet` rc=0; suite rc=0, 17 packages, 0 FAIL. - `internal/store/dr_recipe.go` — `backup_target` + `offsite_restic` on both the shape structs and `AssembledRecipe`; the allow-list warning. - `internal/store/testdata/dr-recipe.golden.json` — both new sections + `namespace_state`. - `internal/api/testdata/host-report.golden.json` — synced byte-identical with the agent's copy (`f4bc3554…`). ## v0.82.0 — R-120: the vouch path refuses a golden the fleet has already outrun (2026-07-30) **The mechanism half of R-120.** The golden's version *is* the controller it bakes (`felhom-agent configs/build-golden.sh:345` defaults `GOLDEN_VERSION` to `${CONTROLLER_IMAGE##*:}`), so a golden left behind the newest deployed controller means every **fresh install** lands on stale application code. On the R-120 occurrence that stale code shipped a customer-facing **falsehood**: a box installed from the 0.185.1 golden told a customer whose backup drive had fallen out that *"the backup is on the same disk as the system"* — false, the drive was gone — and offered a different drive as the remedy. 0.186.0 is the release that made that message true, and no new box had it. **Why a gate and not a reminder.** This gap has opened **three times** — **R-111** (the golden's agent 17 releases behind), **R-115** (an agent built and deployed but never published), **R-120** (this). The first two were closed by re-baking and remembering; remembering then failed again. And **R-29** is the standing proof that a check nobody runs is *worse* than none, because it reads as coverage: `hostinstall_gates.py` sat RED and invoked by nothing across three version bumps while every report said green, and `hub_confirm_gate.py` has never run at all. So the distinguishing property is not *does a check exist* but **does it block**: - It lives in **`handleSetArtifacts`** (`internal/web/configs.go`), immediately before the only write — the sole UI path to `store.SetArtifactManifest`. It therefore runs on every vouch **without anyone choosing to run it**. A script in `scripts/` asserting the same fact would have been a fourth orphan. - It **REFUSES** (operator ruling, 2026-07-30), with an operator-legible flash naming the remedy, rather than warning. - Signal: `store.NewestReportedControllerVersion()` — the highest controller version any box has reported, from `reports.controller_version` (the column `SaveReport` denormalises). **Semver-compared in Go, not `MAX()` in SQL**, which would rank 0.99.0 above 0.186.0 — a pair this fleet has actually shipped. No outbound call, no new credential. **Fail-open in exactly two cases, both deliberate:** an empty golden field (clearing the manifest is a legitimate act) and an unknown fleet version (a new hub must be able to vouch its first golden). **Known blind spot, stated rather than papered over:** a controller no box has ever run is invisible to this signal, so a golden baked behind an *unreleased* controller still passes. That is a real limit, and it is not the failure that has bitten — all three instances were "deployed newer than baked". **A near-miss worth recording.** The first draft read `guests.controller_version` — a column that exists in the schema (`store.go:294`) and that **nothing writes**. That gate would always have seen `""` and failed open: inert, i.e. precisely the R-29 shape it exists to prevent. Caught by grepping for a writer before trusting the column. **Tests: 4, through the production handler over `httptest`, never an injected seam** — because a gate that can be inert is the thing this gate exists to prevent, and three shipped defects in this project were fully green with the seam disconnected. Refusal asserts **both** the flash **and** that the manifest was not written (a gate that redirects and saves anyway reads as enforcement while providing none); plus the allow cases, both fail-open cases, and the semver-ordering case. Red-proof: deleting the block makes the stale golden vouchable and both refusal assertions fail. ## v0.81.0 — E-2: the absent backup target gets its own signal (2026-07-29) **Hub half of E-2, and it ships FIRST by necessity:** an event type the hub does not allowlist makes `POST /event` return 400 and the event vanishes (the R-97a failure). The controller cannot emit `backup_target_absent` until this is live. E-2's Phase 0 established that an absent backup target has **no prompt signal today**. The drive-gate path stops apps and logs a WARN but emits nothing — `NotifyStorageDisconnected` is defined and never called anywhere in the controller (verified against the gitignored-`cmd/` trap with a positive control). A drive that is *only* a backup target has no apps to stop, so it is entirely silent. The sole signal is the tier's own failure at its next due cycle, i.e. **up to ~24 h** on the daily local tier — the R-100 shape, where a real fault is visible only after a deadline elapses. Added to BOTH registers, because each half fails differently and the second failure is the quiet one: - `allowedEventTypes` (`internal/api/handler.go`) — without it the event is lost at the door; - `customerMessages` (`internal/notify/templates.go`) — without it the event IS delivered, but the customer receives the controller's raw operator English instead of Hungarian, and nothing looks broken. `backup_target_absent` is deliberately **not** folded into `storage_disconnected`. That one says "a drive went away and some apps may have stopped"; this one says "the thing that makes your backup survive a disk failure is gone" — a different customer action and a different operator urgency. Hungarian copy states the CONSEQUENCE, not just the fact: > „A rendszermentés meghajtója nem érhető el — amíg vissza nem csatlakoztatod, a teljes rendszermentés nem készül el." `backup_target_restored` is the paired recovery (`info` severity — the existing recovery pattern; `severityNotifies` is untouched and NOT widened). **Tests + red-proofs.** `api.TestBackupTargetEventTypesAreAllowlisted` and `notify.TestBackupTargetCustomerMessagesArePresent` pin the pair; a third test pins that the copy names what is at risk and what happens, so a future shortening to a bare „Meghajtó hiányzik." cannot pass. All three red-proofed with the mutation VERIFIED to have landed first — the initial attempt silently no-op'd (gofmt had realigned the map to three spaces) and the test "passed", which would have been a false proof. ## v0.80.0 — R-100: staleness counts from the last SUCCESS (2026-07-28) `OffsiteChecker.isStale` counted from `last_run`, which the controller writes **unconditionally** at the end of every run including failures. It therefore asked *"how long since we last TRIED"* — so a tier failing on every single night refreshed the clock nightly and read as perfectly fresh forever. It now counts from **`last_success`** (controller v0.181.0). **What the defect is NOT, corrected after checking.** The old comment here said *"a recent-but-failing run is NOT stale (backup_failed owns that signal)"*, and that was **true** — `backup_failed` does fire for a failing offsite run, nightly, and reaches the operator (live hub DB: 5 operator sends). The real defect is **defeated defence in depth**: this checker is the hub-side, *pull-based* net that exists to be independent of controller-*pushed* events, and anchoring it on a field the failing controller keeps refreshing made it depend on the very thing it backs up. F-HUB — this campaign's own finding, the hub dropping an event under `SQLITE_BUSY` with no retry — is exactly that loss. **Three branches, each deliberate:** - **never ran** (no `last_run`) — unchanged v0.73.0 anchored behaviour. Still keyed on `last_run`, not `last_success`, on purpose: `last_run` answers "has anything ever happened here", and a box whose *first* run failed has a `last_run` and no `last_success` — that is a run, not a newborn. - **legacy** (`last_run` set, no `last_success`) — degrades **explicitly** to the old `last_run` behaviour, logged **once** per customer. Treating absence as failure would alarm the whole un-upgraded fleet at once; treating it as success keeps the bug. Same degrade direction as R-88 Part 2's `age_state`. - **anchored** — counts from `last_success`. `last_status` is deliberately **not** consulted: "error ⇒ stale" pages on every transient blip, which is the F-A1 noise path. One bad night is tolerated because the threshold simply keeps running from the last good run. `running` is a real wire value (a report captured mid-run) and is likewise not a verdict. **The alarm text had to change with the verdict.** `emitStale` still said `last run 8h ago` while firing on a six-day-old success — a true alarm that reads as a false one. `staleAge` now separates the two diagnoses: *"runs are happening and failing — check the error, not the schedule"* versus *"the offsite leg is silently not running"*. `last_success` joins the event details. Fixtures are the **real** wire shapes from 4000 live reports (`ok` ×2269, absent ×541 always with an empty `last_run`, `error` ×27, `running` ×7), not invented JSON. Red-proofs, all observed failing: restore the `last_run` anchor → `a tier that has not succeeded in 6 days reads as FRESH`; delete the never-ran branch → `a newborn box alarmed`; collapse to `last_status == "error"` → `a single transient failure alarmed`; delete the legacy degrade → `a legacy controller alarmed — that is a fleet-wide alarm storm on an un-upgraded fleet`. # Felhom Hub — Changelog ## v0.79.0 — R-97c: make the operator-only claim TRUE (2026-07-27) v0.78.0 shipped a comment asserting that `whole_guest_backup_failed` / `_recovered` were operator-only because they have no `customerMessages` entry, "so the dispatcher **structurally cannot** route them to a customer". **That was false**, and the code says so plainly: - `templates.go` treats a missing entry as a **fallback to the raw message**, not a block — `hunMessage := customerMessages[eventType]; if hunMessage == "" { hunMessage = message }`; - the only customer gate is `isEventEnabled(prefs.EnabledEvents, ...)` — **configuration**. So a customer with `whole_guest_backup_failed` in their enabled list and an email set would have been sent the raw English operator text about a backup they can take no action on. Proven by running the new test against the v0.78.0 shape: it emails `customer@example.com`. This is the `EffectiveProtected` shape — a doc comment claiming a property the code stopped providing, which is how the samba false alarm survived. **The fix:** an explicit `operatorOnlyEvents` register, checked at the top of `processCustomer` **before prefs are consulted**, so no customer configuration can opt in. The skip is **logged** (`status=skipped`, `error_message=operator_only`, `channel=customer`) rather than dropped — a silent drop is indistinguishable from a delivery that never happened. Deliberately **not** implemented as "a missing `customerMessages` entry blocks delivery": several types rely on the raw-message fallback on purpose (`offbox_enlarge_blocked`'s dynamic Hungarian text is customer-grade and a template would discard its numbers), so turning the fallback into a gate would change behaviour well outside this concern. The recovery type is listed too, even though its customer leg is pairing-gated on a "sent" row that cannot exist — relying on that would make one type's safety a consequence of another type's routing, true today and silently untrue the moment the failed event became customer-visible. The `handler.go` comment now states the actual mechanism and warns that allowlisting a type does not make it operator-only. Tests +4, all run under the **breaking** configuration (customer has the event enabled AND an email), not today's safe one. 17 packages ok. ## v0.78.0 — R-97a: the whole-guest backup tier gets a voice (operator-only) (2026-07-27) `internal/quiesce` had no route to the hub at all. On 2026-07-27 three failed whole-guest backups and twelve app-stack stop/starts produced **zero** events. This is the hub half of the fix. **Two new operator-tier event types**, allowlisted with **no `customerMessages` entry**: `whole_guest_backup_failed` (error) and `whole_guest_backup_recovered` (info). **Deliberately NOT `backup_failed`/`backup_completed`.** Both of those carry customer-facing Hungarian templates AND sit in demo-felhom's live `enabled_events` — reusing them would have emailed the **customer** „A biztonsági mentés sikertelen" while the backup was still retrying behind the R-88 breaker. A customer can take no action on a failed whole-guest backup. Same pattern as R-85's restore-test types: in the allowlist so the chain works, out of `customerMessages` so the dispatcher *structurally cannot* route them to a customer. **The recovery rides the F11 pairing branch.** `whole_guest_backup_recovered` is severity `info`, and `severityNotifies` drops `info` — routing it normally would store the event and never mail it, so the operator would be told a tier broke and never told it healed. Adding it to `recoveredPairedDownTypes` puts it on the recovery branch, which runs BEFORE the severity gate. Its customer leg needs no special handling: that leg is pairing-gated on a customer-channel "sent" row for the down type, and the down type can never produce one. **Per-tier operator cooldown** (`cooldownTierSuffix`). The operator cooldown was keyed `customerID + ":" + eventType` — correct for an event describing ONE thing, wrong for one describing ONE TIER when a box has two: `felhom-pbs` failing at 09:00 would swallow `local` failing at 09:20 for the rest of the hour. The key now appends the `tier` from the event details **when present**, so no existing event type's cooldown behaviour changes. Widening it for everything would turn one hourly `app_start_failed` into one per app — a flood, not a fix. Tests +8. `build`/`vet`/`test` rc=0, 17 packages ok. ## v0.77.0 — R-85 Part 2: a restore-test result becomes a SIGNAL (2026-07-26) Until now a failed restore-test was a `[WARN]` line in the ingest handler and nothing else — no event, no notification, no gauge. That was true for the **local tier that was already being tested**, so the loudest DR signal this system produces was, in practice, inaudible. Rotating tiers (agent v0.104.0) without this would only mean two tiers can fail silently instead of one. ### Two signals, deliberately NOT merged | event | meaning | severity | |---|---|---| | `restore_test_failed` | a run completed and did **not** pass — something is broken NOW | error | | `restore_test_stale` | a tier has not been **proven** within its interval — nothing has necessarily broken; we no longer know | warning | Merging them would collapse *"your DR is broken"* into *"your DR is unverified"*, and the second is the one that quietly becomes the first. The staleness wording says **"unverified, not known-broken"** and a test asserts that phrasing. ### Anchored, per R-81 — not re-derived A tier never proven on a newborn box is **UNKNOWN**, not FAILED, until an anchored window elapses. This monitor family has made the opposite mistake three times (hub v0.12.0, v0.73.0, R-81); this is a NEW monitor written straight after the third, so it copies R-81's verdict structure and boundary-test discipline rather than inventing a fourth shape. The deferral is logged once — a quiet check must never be indistinguishable from one that did not run. `restoreProvenStaleAfter = 7d` is derived, not guessed: a 24 h cadence rotating oldest-first across two tiers proves each about every 2 days, so 7 days tolerates ~3 consecutive missed opportunities before alarming — and sits comfortably inside the 2-week offsite retention, so a tier is never called stale against an archive that is about to be pruned. ### How per-tier proof is recovered The agent reports only its **latest** restore-test and its store is in-memory, so the latest report alone cannot answer *"when was the OTHER tier last proven?"*. The hub's **retained host-report window** can — the same R-81 mechanism, reused rather than re-solved with a wire change. ### Also - Both types registered in `allowedEventTypes`. They are hub-generated, but that map is the project's single register of legitimate event types, and R-77's lesson was that a type missing from it ships as an inert seam. - **Operator-tier only** — neither has a `customerMessages` entry, so the dispatcher cannot route it to a customer. A customer can take no action on a failed restore-test, and *"a visszaállítási teszt nem sikerült"* would frighten without informing. A persistently unproven DR tier may eventually warrant a customer-visible statement; that needs copy review, not a side effect of this task. - A tier the box does not HAVE is never reported stale (the Slice-C gate, same reasoning). ### Fixed — a time bomb introduced in Slice C `TestCheckBackupDeadlines_RestartBlindWindow_NoEvent` hard-coded the literal incident timestamp `2026-07-18T18:31:06Z` while comparing against the REAL clock. Harmless while one 26 h threshold covered every tier; once Slice C gave the offsite tier an 8-day limit it became a bomb — the test passed all day on 2026-07-26 and began failing at **18:31 UTC**, exactly 8 days after that instant. Now relative. **A test that passes at commit time and fails hours later is worse than one that fails immediately**, because it lands on whoever is next in the file. ### Tests +10, full suite green (17 packages, `rc=0`, vet unpiped). Red-proofs observed: - **B** — log-and-stop (the pre-R-85 shape) yields `a FAILED restore-test must EMIT an operator event; got 0 event(s)`. The assertion is that a NOTIFICATION IS EMITTED; the hollow version checks for a log line, which passes against exactly the code this replaces. - **D** — removing the anchor yields `a newborn box must NOT alarm; got restore_test_stale: local tier: NEVER successfully restore-proven in 0s of watching`. ## v0.76.0 — R-82 Slice C: tier-aware backup thresholds (2026-07-26) R-81 merged every backup signal into one "newest" and judged it against a single 26 h limit. That was right while a box had exactly one whole-guest tier. `backupStaleAfter`'s own comment recorded why it stops being right: > *"The moment PBS moves to a WEEKLY cadence, a perfectly healthy weekly snapshot is >26h old six > days in seven and this constant alarms on it."* Each tier is now judged against **its own** threshold. **R-81's structure is preserved intact** — three-valued verdicts, absence anchored at first contact, one distinct reason per failure mode — and its boundary test is untouched. ### Added - **`offsiteBackupStaleAfter = 8d`** (7-day cadence + a day of headroom — the same ratio 26 h gives a 24 h cadence). `backupStaleAfter` keeps 26 h and now names the **host** tier only. - **`splitTiers`** — classifies a report's evidence into host and offsite tiers. - **`assessTier`** — R-81's exact logic, parameterised by tier and threshold. - **`newestBackupEvidenceByTier`** — the retained-window scan, per tier. ### The Slice-A.4 rule, implemented A PBS-targeted vzdump appears in **both** `backups[]` (as a `Backup` with `target_id:"felhom-pbs"`) and `pbs_snapshots[]` (enumerated independently from PBS). Classification is therefore by **TARGET TYPE** — `target_id` → `storage_targets[].name` → `.type == "pbs"` — **never by array membership**. Getting that wrong would let a PBS backup make a **stale host tier look fresh**, silently losing the daily tier's alarm. Pinned by `TestTierAware_PBSTargetedVzdumpIsNotHostEvidence`. `storage_targets` is used rather than `pbs_dr.storage_id` because the latter is null on a box that has a PBS storage but no DR descriptor yet (drill-r50 was exactly that shape). ### A tier is only judged when the box HAS it `expected` gates each tier on a configured storage of that kind, or evidence for it. Without that, every box without an offsite tier would alarm as soon as the anchor elapsed — the absence-is-not-failure mistake R-81 exists to prevent, re-introduced one level down. When NEITHER tier is identifiable (an old agent reporting no `storage_targets` and no `target_id`) the pre-Slice-C combined path runs unchanged, so nothing regresses on a fleet mid-upgrade. ### Changed behaviour (intended) A 30 h-old offsite snapshot no longer alarms — under a weekly tier it is healthy. Three existing fixtures asserted the old merged threshold; each still asserts an alarm, now at the correct limit (9 days for offsite, 30 h for host). **No assertion was weakened to make the code pass.** ### ⚠️ Recorded limitation — the hub infers cadence from storage TYPE "PBS ⇒ weekly" is an inference, not a fact the box tells us. `defaultBackupTarget` is `"felhom-pbs"`, so a box that never sets `local_backup_target` would run PBS as its **daily primary** tier and the hub would judge it against 8 days — **seven days of blindness**. No box is in that shape today (both demo boxes set `local`, and the installer pins it), but it is a latent mis-classification of exactly the kind that became R-80. The real fix is the agent reporting each tier's **actual cadence** in the host-report; own task. ### Tests Full suite green (17 packages). Red-proof observed: giving `tierOffsite` the host threshold — i.e. restoring the merged limit — fails the 6-day case with `offsite tier: newest backup is 144h0m0s old (limit 26h0m0s)`, verbatim the cry-wolf this slice removes. Restored. **Replayed against the live hub DB** through the real store queries: ``` demo-felhom host=07-26T14:38Z offsite=07-26T12:21Z -> OK demo-hp host=07-26T07:06Z offsite=none -> UNKNOWN (offsite watched 119h of a 192h grace) drill-r50 host=none offsite=not expected -> MISSED (host tier, no evidence in 29h) ``` **No customer email would be sent by this deploy.** demo-felhom is clean; demo-hp defers correctly and will alarm in ~3 days if its offsite tier stays empty (the true R-82 finding, arriving on schedule); drill-r50's alarm is a true positive and it has no customer channel. ## v0.75.0 — R-81: "no signal" is not "bad signal" — the backup deadline check is ANCHORED (2026-07-26) The third instance of one bug class, fixed as a class. `expected_backup_missed` fired on demo-felhom, demo-hp and drill-r50 simultaneously at 03:00 UTC, and the demo-felhom one reached the **customer** channel claiming `newest backup is 176h0m0s old`. Nothing was wrong: three vzdump archives were on disk (07-24, 07-25, 07-26). Full evidence: `documentation/audits/DIAG-backup-missed-2026-07-26.md`. **Cause.** The agent's backup record store is IN-MEMORY (`felhom-agent/internal/backup/store.go` — *"lost on restart; the cadence re-populates"*). The R-50 island migration restarted the fleet at 12:44 UTC; the next backup landed at 07:03 the following morning. In between, every host-report carried `backups: []`, and `assessBackupFreshness` read empty as *no backup exists*. For demo-felhom it then fell through to the only surviving evidence — a PBS snapshot from 07-18 — and reported its age as the customer's backup age. **The class.** hub v0.12.0 (`expected_backup_missed` daily for every healthy customer — looked for an event nobody emits), hub v0.73.0 (`offsite_stale` minutes after a *healthy* repair — never-ran branch had no time anchor), and now this. All three: **absence of signal treated as evidence of failure.** The invariant is now written at the head of `assessBackupFreshness` with all three instances named, and pinned by a boundary test whose name says what it protects. ### Changed - **`assessBackupFreshness` returns a three-valued verdict** — `verdictOK` / `verdictUnknown` / `verdictMissed`, replacing `missed bool`. Absence is UNKNOWN, not a fault. It becomes a fault only once it outlives an anchored window. Still **pure** (`now` and the evidence are injected) — that purity is why the incident was diagnosable and why this fix is provable. - **The check now reads hub HISTORY, not just the latest report.** New `store.GetHostReportsSince` + `monitor.newestBackupEvidence` answer *"when did I last SEE evidence of a backup?"* across a bounded 7-day lookback (`backupEvidenceLookback`). The agent's store is point-in-time and forgets across a restart; the hub's retained reports (90 d) do not. **This is the whole fix for the 07-26 shape** — no agent change, no new persisted state, and semantically exactly the right question. The scan stops at the first sufficiently-fresh evidence, so the healthy path reads one row; only the genuinely-broken path walks the lookback. - **The absence anchor is first contact** — new `store.GetFirstHostReportAt`. Absence is graded against how long the hub has been *watching*, reusing the existing `backupStaleAfter` (26 h) as the grace exactly as v0.73.0 reused offsite `staleAfter`. **No new knob.** A zero anchor fails toward visibility (the v0.73.0 legacy-shape precedent). - **`CheckBackupDeadlines` logs the deferral.** A deferred UNKNOWN emits one INFO naming the reason, and the summary line gained a `backup unknown (deferred)` counter — so a quiet check is never indistinguishable from a check that did not run (v0.73.0 Part-7 precedent). At most one line per customer per day. - **Reason strings split, not collapsed.** Absence-over-time, unanchored absence, deferred-newborn, stale-timestamp, failed-verify and unparseable each keep a distinct message. The entire 07-26 diagnosis turned on reading the exact string; a test enforces distinctness. ### NOT changed (deliberate) - `backupStaleAfter` stays 26 h, and no tier-awareness was built. ⚠️ **Landmine recorded in the constant's comment:** it applies to whichever tier is newest, so once PBS moves to a **weekly** cadence a healthy weekly snapshot is >26 h old six days in seven and this will alarm on it. Per-tier thresholds cannot be built before the per-tier cadence config exists — **R-82 owns both halves.** Building it now would be speculative generality. - `parseBackupTime` untouched — the agent emits clean RFC3339 `Z` and the parse branch is not implicated. Its silent `continue` on an unparseable timestamp is a **latent member of this same class** and is recorded as an observation only. - The DB-dump half of `CheckBackupDeadlines` is event-based and correct — untouched. - The customer-facing Hungarian copy (`notify/templates.go:106`) is untouched here. The DIAG found it overstates scope (it reads as *all* backups failed, but this check only covers the host/PBS tier); that copy change was not in this task's scope. - The agent's in-memory `Store` is the *cause*; making the host-report truthful rather than merely defensively interpreted is **R-84**. ### Tests 508 total (was 493), +15 in `internal/monitor/deadline_anchor_test.go`. Companion red-proofs observed and restored for all three acceptance scenarios: - **A** (restart blind window must not alarm) — removing the history fold-in reproduces `newest backup is 176h0m0s old (limit 26h0m0s)`, **verbatim the message demo-felhom actually sent**. - **B** (a genuinely dead box must still alarm) — the naive "absence is always silent" fix fails `TestBackupFreshness_NoEvidenceBeyondAnchor_Alarms` and two contract rows. This is the test that makes A safe: a suite proving only A would pass against an implementation that never alarms. - **C** (a fresh box is not born failing) — the literal pre-fix branch fails four contract rows plus the end-to-end newborn case. **Replayed against the real thing:** the actual host-reports the hub held at 2026-07-26 03:00 UTC (600 / 417 / 77 retained rows) fed through the new policy → demo-felhom **OK** (window evidence `2026-07-25T06:30:14Z`), demo-hp **OK** (`2026-07-25T10:23:31Z`), drill-r50 **UNKNOWN** (newborn, watched 17 h < 26 h grace). **All three silent.** ## v0.74.0 — allow `local_api_endpoint_drift` (controller v0.173.0 / R-77) (2026-07-26) One line in `allowedEventTypes`. It is **not optional**: `handleEvent` 400s an unknown `event_type` ("Invalid event_type"), so the controller's new drift alert would have been **silently inert** without it — the exact seam-wiring failure class this project has hit four times. Shipped with the controller that emits it, not after. Operator-only, `error` severity (drift never self-heals), and deliberately **not** an `agent_channel_*` type: during the 2026-07-25 island-migration outage the generic "agent unreachable" event was the only signal for 17.5 h and it hid a specific, fixable config fault. Naming the cause separately from the symptom is the whole point. No customer notification toggle, matching the other `agent_channel_*` and `host_*` operator events. Source: `documentation/audits/DIAG-agent-channel-2026-07-26.md`. ## v0.73.2 — sync `hostInstallVersion` → 1.19.0 (R-50 island host-install) (2026-07-25) `hostInstallVersion` (the script version the operator customer page's install-command generator advertises) bumped 1.16.0 → **1.19.0** to match `felhom-host-install v1.19.0` (R-50 island default). The `hostinstall_gates.py` F-1 gate requires the two move together; this also clears the pre-existing 1.16.0↔1.18.0 drift. Render test (`render_test.go`) confirms the value reaches the page. No behaviour change beyond the advertised version string. ## v0.73.1 — allowlist `disk_health_degraded` (controller v0.169.0 disk-health) (2026-07-24) Adds `disk_health_degraded` to `allowedEventTypes` so the controller's per-disk SMART degradation notification (v0.169.0) is ingested and delivered instead of 400-rejected. Like `offbox_enlarge_blocked`, there is deliberately **no `customerMessages` entry** — the controller sends a dynamic Hungarian message (disk label + the triggering attribute names), which the templates.go fallback preserves; a static entry would discard the specifics. Test `TestHandleEvent_DiskHealthDegradedAccepted` (red-proof: drop the allowlist line → 400). ## v0.73.0 — `offsite_stale` no longer cries wolf on a newborn tier (ISO-train v1.25.0 Part 7) (2026-07-23) Origin (operator, 2026-07-23 12:01 CEST): demo-hp's offsite was repaired and escrowed at 10:01Z and `offsite_stale` fired MINUTES later (`last_run:"" … threshold 48h0m0s`) — the never-ran branch had no time anchor, so "enabled + escrowed + never ran" was instantly stale on the first tick, with remedy copy ("check the controller/schedule") that was wrong for the moment's true state. **The fix (no new constant, one boundary) — `monitor.OffsiteChecker.isStale`:** - **Boundary ruling (recorded):** never-ran staleness is owned by `offsite_stale` ONLY in the v0.72.0 `applied` delivery state; pre-applied never-ran shapes belong to `offsite_delivery_stuck` alone — **one state, one owner, never both**. Structurally this was already true (`Check` nil-skips reports without the offsite object, and a report CARRYING the object IS `applied` by the v0.72.0 definition) — now it is pinned by an explicit boundary test that also asserts the delivery checker remains that shape's only voice. - **Anchor:** never-ran staleness = `applied` AND (now − anchor) > the EXISTING 48 h threshold, where anchor = the newest of **`one_time_secrets.consumed_at`** (delivery completed; via the v0.72.0 `GetOneTimeSecretInfo`) and the customer's **escrow-blob timestamp** (`host_escrow.updated_at`/`created_at` via the new `LatestEscrowTimeForCustomer` — reduced in Go, not SQL MAX, because the two timestamp formats would misorder lexicographically). Runs become possible only at the ceremony, so the ceremony anchors the clock. No separate grace knob: the existing threshold, anchored properly, IS the grace. - Anchor-less legacy shape (no secret row, no escrow row): pre-fix behavior kept — stale on sight, fail toward visibility. Ran-before behavior byte-untouched (explicit both-direction tests: old run + fresh anchor still fires; fresh run + old anchor stays silent). - One INFO log line on the FIRST observation of a deferred newborn (`never-ran within the anchored threshold (anchor …)`) — the anchored evaluation is provable live without per-sweep spam. Red-proof: never-ran branch reverted to the pre-fix `return true` → the fresh-anchor fixture and the escrow-anchor fixture both fired `offsite_stale:warning` (FAIL observed) → fix restored. Origin: `documentation/audits/DIAG-f10-demo-hp-offsite-2026-07-23.md` — demo-hp sat 2 days with the customer card claiming "Provisioned … delivered to the controller once" (static copy) while the box had NOTHING: the day-0 managed update killed the apply-bridge after password-consume, and the hub — holding both signals (`one_time_secrets.consumed_at`, 153 offbox-less reports) — read neither. **One detector, four consumers:** - **Detector** (`internal/offsite/delivery.go`, `DeliveryStateFor`): per-customer `OffsiteDeliveryState` from the secret row × report offsite-presence — `applied` (precedence: the box's own report wins) / `consumed_awaiting_apply` (the burned-credential shape) / `staged_awaiting_consume` / `no_secret`. The applied+stale-staged edge (demo-felhom's live shape: key-auth-first never consumes) stays `applied` PLUS a visible stale-staged flag. New store reads: `GetOneTimeSecretInfo` (timestamps only, value never selected), `LatestReportOffsitePresence`, `CountReportsOffsiteSince`, `LastEventAt`, + a test-only timestamp back-dater (the PBSDR seam pattern). - **Customer card** (`config_form_body.html` + `deliveryViewFor`): the static "delivered once" claim is GONE; the card renders the derived state with its age (badge idiom `n-ok/n-warn/ n-neutral`; consumed goes amber past 30 min; the stale-staged info line names the specimen's age). Render test per branch (the v0.70.1 template-gate rule). - **Loud event**: `offsite_delivery_stuck` (WARNING → operator email; severity gate untouched — explicit tests pin that info would be silent) when consumed_awaiting_apply persists ≥ 1 h; per-customer 24 h cooldown, DURABLE via `LastEventAt` over the events table (a hub restart neither floods nor resets). - **Self-heal (R-71c)**: `OffsiteDeliveryChecker` (shared 60 s ticker) invokes the EXISTING Re-issue path (`web.Server.ReissueOffsiteForCustomer` behind the narrow `monitor.OffsiteReissuer` interface — the pbsdrheal precedent; armed only when the provisioner is configured) when the shape is unambiguous: consumed ≥ 1 h + ≥ 4 consecutive offbox-less reports since consume + zero offbox evidence. One restage per customer per 24 h (durable); every firing surfaces as `offsite_credential_restaged` (WARNING → operator) — repeats are repeating events, never a silent retry loop. **The R-39(a) guard**: `SaveOneTimeSecret` clobbers by design (Re-issue depends on supersede), so the heal RE-READS the secret row immediately before acting and refuses unless it is still a consumed row — the TOCTOU (operator Re-issue landing mid-tick) is the shape the guard kills. Red-proofs run and recorded (guard removed → the operator's fresh secret gets clobbered, test fails on `reissue calls = 1`; both rate limits removed → duplicate fire). Companion: felhom-controller v0.161.0 (the box-side truthful empty state). The self-heal ships unit-proven + red-proofed, NOT live-fired — no broken box exists and none was broken for it; it arms on the next natural occurrence (or a staged drill). R-71(a) (day-0 ordering) stays open — separate spec. ## v0.71.0 — paired recovery mails, prefs seeding at claim, priority headers, operator test leg (2026-07-22) Origin: `documentation/audits/AUDIT-power-outage-recovery-2026-07-22.md` F11 (recovery is silent), F12 (prefs row optional → customer never notified), F14-light (delivered ≠ noticed). Live proof of the gap: the demo customer got „A szerver nem elérhető!" at 15:29 and was never told it recovered. - **Paired recovery notifications (F11)** — `dispatcher.go` `processRecovery`, an explicit eventType branch in `ProcessEvent` BEFORE the severity gate (`severityNotifies` and the checkers' `emitTransition` severities are byte-untouched; `*_recovered` stays `info`). Operator always gets both edges (existing 1 h per-type cooldown); the customer gets recovery **iff the customer was mailed the paired stale/down** — pairing evidence is `store.LastCustomerSentAt` over `notification_log` (customer channel, status=sent, `node_recovered→{node_stale,node_down}`, `host_recovered→{host_stale,host_down}`), ties resolve to no-mail (flap-safe). `enabled_events` is deliberately NOT consulted for recovery. Suppressions log at INFO with the reason. `FormatOperatorEmail` renders ✅ for `*_recovered`; `customerMessages` gains `host_recovered`. - **Prefs seeding at claim (F12)** — `claim.Engine.MarkClaimed` seeds `customer_notifications` from the registered `customer_configs.email` on the unclaimed→claimed transition via new `store.SeedNotificationPrefs` (INSERT OR IGNORE — never touches an existing row; empty email = no-op; a seed failure never fails the claim). Default set (critical-only, Viktor may adjust): node_down, backup_failed, disk_critical, host_disk_critical, storage_fill_critical, offbox_repo_orphaned. - **Empty-email no-clobber guard (F12)** — `handleSavePreferences`: a push with an empty email preserves a stored non-empty address (events + cooldown still apply); a non-empty push updates everything. Phase-0a fact: controller 0.160.0 guards both push legs itself (`cmd/controller/main.go:821` startup skips empty email; `web/handlers.go:1532` refuses empty-with-events), so the clobber was latent — this is the hub-side belt for older/rogue boxes. - **Priority headers (F14-light)** — `sendEmailFn`/`sendEmail` gain a `headers` param; Resend payload carries `"headers"` only when non-empty. `priorityHeaders(severity)`: error/critical → `X-Priority: 1` + `Importance: high`; warning/info → none. Mechanism probed live pre-implementation (Resend accepted, HTTP 200, mail id `34d3f7f3…`). - **Operator test leg** — the `test` event now also mails the operator (`✅ : teszt / operator channel OK`, priority headers forced) — one click proves customer channel + operator channel + header rendering. Fixed a latent nil-deref found here: `sendTestEmail` dereferenced `prefs.Email` while `GetNotificationPrefs` returns `(nil, nil)` for a customer with no row — a test event for such a customer (e.g. demo-hp) panicked the dispatcher goroutine. - Tests: 17 new (449 → 466) across store/notify/claim/api; 4 red-proofs run + reverted (pairing removed, upsert-seed, guard removed, unconditional headers) — see `REPORT.md`. ## v0.70.1 — the ghost customer's Delete button must exist (2026-07-22) **The fourth inert-seam defect: v0.70.0's ghost-delete path was fully implemented and fully unreachable.** `handleCustomerDelete`/`handleCustomerDeletePreview` accepted `cfg == nil`, the ghost dialog branch existed in the template JS (`customer_unified.html` `if (d.has_config === false)`) — but the Danger-zone card containing the Delete button sat inside the `{{if .HasConfig}}` block (old ~L736) that also wraps the RESET card. A ghost rendered no Danger zone → no button → dead UI. Handler tests passed because they POST directly; nothing asserted the rendered page. Observed live on `demo-vm-felhom` (2026-07-22): Edit tab showed Controller Update + Geo cards only. - **Handler** (`configs.go` `handleCustomerUnified`): new `Deletable` page flag — the **exact negation of the delete preview's 404 predicate** (`customer_delete.go`: `cfg == nil && no hosts && residue empty`), one truth, not a lookalike. Hosts were already fetched for the Host tab; only the residue count is an extra read, and it runs solely on the ghost shape. A lookup error logs and leaves `Deletable=false` — fail toward hiding a destructive control. - **Template**: the old gate split. RESET card stays `{{if .HasConfig}}` (identity-preserving re-onboarding — a ghost has no identity to preserve). Danger zone gates on `{{if .Deletable}}`; inside it the Block/Unblock forms gain their own `{{if .HasConfig}}` (blocking gates dashboard visibility of a configured customer — meaningless for a ghost). The `customerDeleteOpen/Submit` script moved out with the card (it was inside the old gate). Ghost shape gets a one-sentence intro prefix; the dialog already explains the rest. - **Render tests** (`customer_ghost_delete_render_test.go`), asserting the delete form's `action` and the `customerDeleteOpen(` call site: ghost-with-residue renders Delete only (no RESET, no Block); configured customer keeps every affordance byte-for-byte (incl. the blocked→Unblock branch); nothing-left 404s (there is no renderable `Deletable=false` state — `customer != nil` implies a report row implies residue > 0). Two red-proofs run and recorded in `REPORT.md`. - **The generalized seam-wiring rule now covers template gates:** any conditional UI affordance ships with a render test per branch — handler tests that POST directly prove nothing about reachability. ## v0.70.0 — a deleted customer actually disappears (the ghost + its alerts) (2026-07-21) Found while validating v0.69.0 against the live hub, on the operator's report that `demo-vm-felhom` "was deleted but is still here". The delete HAD worked — config row gone, both hosts deleted, escrow tables empty, the RESET journal complete. The customer was still on the list because **`GetCustomers()` builds the Customers list purely from the REPORT stream** (`SELECT ... FROM reports GROUP BY customer_id`), and no lifecycle tier — host delete, RESET or DELETE — has ever deleted a report. Not cosmetic: the **staleness and offsite checkers iterate that same report-derived list**, so the hub kept raising `offsite_stale` and kept **emailing the operator about a customer that no longer exists** — 10 events for `demo-vm-felhom`, the last one 3 days after its deletion. ### Leg 3: residue (new) The cascade is now `hosts → RESET → residue → purge`. The residue leg deletes, in one transaction: `reports`, `app_telemetry`, `app_log_tails`, `log_tail_requests`, `customer_notifications` — plus two rows that are not telemetry at all but **credential-bearing**, and outliving their customer is a security defect rather than noise: - `appliance_registrations` — a `token_hash` + `status='delivered'` row binding a box to the customer id. Deleting it returns a still-living box to the unclaimed pool on its next registration, which is the correct state for a decommissioned appliance. - `selfbind_tokens` — an unconsumed 7-day bind token is a working path to bind a box to a customer that does not exist. It runs BEFORE the record purge on purpose: `customer_configs` is the identifying descriptor and goes last. `events`, `notification_log`, `host_deletions` and `customer_resets` still SURVIVE — the audit trail outlives every lifecycle tier, and that rule is not relaxed here. The counter and the purge walk **one shared table list**, so a table can never be counted-but-not-purged. ### Ghost customers are deletable `handleCustomerDelete` / `handleCustomerDeletePreview` used to 404 whenever the config row was missing — so a customer deleted by any pre-v0.70.0 path could not be cleaned up by ANY operator surface. **404 now means "there is nothing here"** (no config, no host, no residue), not "there is no config row". With no config row the offsite descriptor is unknowable, so `commitCustomerReset` skips the Hetzner and descriptor legs and records **`skipped_no_config`** in the journal — never a bare `skipped`, which would read as "there was nothing to do". PBS is customer-id-keyed and idempotent, so it still runs. The dialog labels the case explicitly as a ghost and names the row count. ### Tests `TestDeleteCascade_PurgesResidueAndUnlistsCustomer` (residue zeroed, customer gone from `GetCustomers()`, appliance + self-bind rows gone BY NAME, audit/provenance intact, journal legs `residue=ok customer_delete=ok`), `TestDeleteCascade_GhostCustomerIsDeletable` (the exact `demo-vm-felhom` shape: preview 200 with `has_config:false`, cascade completes, journal records `skipped_no_config`), `TestDeleteCascade_404WhenNothingRemains`. **Two more red-proofs**: dropping the residue leg leaves 5 residue rows and the customer still listed; restoring the `cfg == nil` 404 makes the ghost preview 404 again. Full suite green. ## v0.69.0 — customer DELETE becomes the guided full-teardown cascade (R-25b) (2026-07-21) Implements the operator ruling of 2026-07-21. The customer page carried two half-truths: **RESET** was the real teardown but refused while any host row existed, and **DELETE** quietly removed only the `customer_configs` row (plus escrow custody) — leaving the Hetzner Storage-Box repo, the PBS namespace + credentials, the tunnel/zone plumbing and the host rows behind. DELETE now does what its name promises. ### The cascade `POST /configs/{id}/delete` (same route, new behaviour) runs three legs in a fixed order: 1. **hosts** — every host row deleted through host-delete's own rules: an **ONLINE host refuses the whole cascade** (checked for every host up front, so it never half-runs) and escrow is **DEMOTED** to retained custody, never destroyed. 2. **reset** — the committed RESET sequence verbatim (Hetzner FIRST → PBS → claim → descriptor → DB purge), reached through the newly extracted `commitCustomerReset`. 3. **purge** — `DeleteCustomerConfig`: the customer record **and all escrow ciphertext**. Nothing here is newly destructive: the cascade only *sequences* three operations that already existed, each keeping its own safety rules. Two invariants are load-bearing and asserted, not merely commented: - **Ruling 3 is preserved BY CONSTRUCTION** — leg 2 can only run after leg 1, so the RESET sequence never sees a host row. The standalone RESET handler's 409 gate is untouched. - **Custody is purged EXACTLY ONCE, in leg 3.** Leg 1 demotes; leg 2 is called with `purgeEscrow=false` so `PurgeCustomerResetDBState` leaves retained blobs alone; leg 3 is the one true purge point (v0.60.1). Both are proven from *inside* leg 2 by a fake that observes store state at the moment the PBS deprovision fires. ### Gates (all before any write — a refused delete has ZERO side effects) Three separate acknowledgements (`ack_hosts`, `ack_reset`, `ack_purge`, each must be exactly `1`), the **typed customer-id**, a **stale-preview** check (the acknowledged host count must still match live — otherwise 409 "re-open the dialog"), and the ONLINE-host refusal. No force flag, no skip flag, no partial-run downgrade anywhere in this path. ### Resume A failed leg retains the journal row (`customer_resets`, per-leg status) and the HTTP error **names the leg**. Re-opening the dialog renders the incomplete journal and offers **Resume**; a re-run is idempotent (leg 1 is a no-op once the hosts are gone). The acknowledgements are **not** cached across attempts — a resume passes every gate again. ### UI Danger zone → **Delete customer…** opens a guided dialog: live inventory panel (hosts by name + status, offsite repository identifier, PBS namespace, custody state), the three consequence checkboxes, the typed customer-id field, one submit. Mid-cascade failures render the journal state. The client-side checks are convenience — every gate is enforced server-side. ### Refactor (standalone RESET behaviour unchanged) `handleCustomerReset`'s committed half became `commitCustomerReset(ctx, cfg, resetID, purgeEscrow)`, returning a `resetLegError` (leg name + status + the exact operator-facing message). The standalone path is byte-identical to v0.68.1: same order, same leg names, same messages, same status codes; its suite is untouched and green. The shallow `handleConfigDelete` is **gone** — do not reintroduce a shallow delete path. ### Tests New `internal/web/customer_delete_test.go`: happy-path leg ORDER (observed from inside leg 2), nine fail-closed gate cases each asserting zero mutations *and* zero external calls *and* no journal row, resume-after-external-failure (custody + customer survive the failure, then converge), resume is still gated, the `purgeEscrow` flag's custody semantics, and a preview test asserting the inventory names real things and leaks no secret. **Five red-proofs run** (ack gate, stale-preview gate, ONLINE-host gate, leg order inverted, `purgeEscrow=true`) — all failed red with the wrong value visible, then restored. Full suite green. ## v0.68.1 — fix the Configuration page layout broken by the wrapper-sha field (2026-07-21) The v0.68.0 wrapper-sha256 row wrapped itself in a `
`. The artifacts **`
` IS the CSS grid** (`display: grid`, no inner container), so the stray `
` closed the surrounding CARD from inside the form, and the newly-opened `
` was never closed — it swallowed the submit button and ran to ``. The row rendered outside the card at full page width and "Save artifact manifest" landed inline. Reported by the operator on first use. The field still submitted (it remained inside the form), so this was layout damage rather than data loss — but the unbalanced markup put every section below it inside the wrong container. Fixed by making the row plain grid cells (`grid-column: 2 / 4` for the input and the hint), with no nested elements at all. **There was no render assertion on this form**, which is why a hand-edit could break it silently. `TestConfigurationArtifactsForm_Structure` now asserts the field is inside the form, the submit button has not escaped, the form contains **zero** `
`s, whole-page div balance holds, and the sections that render after it still exist. Red-proofed by restoring the broken shape. ## v0.68.0 — a credential re-issue finally re-arms the box (R-39 fleet fix) + wrapper drift is visible (R-50b(a)) (2026-07-21) **Coupling, stated honestly: this release is SAFE for agents at 0.90.0** — the new descriptor field is an unknown JSON key to them; they drop it and behave exactly as today (inert, not breaking). **The re-arm and auth-honesty guarantees require agent >= 0.91.0.** Raise MinAgent to 0.91.0 only after the fleet's agents have self-updated. ### The defect (R-39, fleet half) An ep0 credential re-issue re-keys the **secret of an existing token**. `token_id`, `fingerprint`, `datastore` and `namespace` all come back byte-identical — only the side-table `host_pbs_secrets` row rotates. The agent's re-apply trigger is a change in the **descriptor content hash** (`felhom-agent internal/pbsdr/manager.go` `descriptorHash`). Same hash → the converged agent short-circuits → the fresh secret is never consumed → the box keeps presenting a revoked credential → **401 forever, while both tiers report `applied`**. Proven on the N100 2026-07-18: the agent's `consumed-failed.json` hash was byte-identical to the `marker.json` written two minutes before the re-issue. ### The fix - **`host_pbs_secrets.generation`** — a monotonic per-host counter advanced by every fresh **mint** and by nothing else, stamped into the descriptor as `secret_generation`. That is now the only field a re-key moves, and it is what re-arms the agent. - **A re-stage deliberately does not advance it**: it re-arms the *same* secret, the descriptor content genuinely has not changed, and a bump would cause a pointless agent refetch loop. - `omitempty` is load-bearing — emitting a zero into every pre-existing descriptor would itself be a fleet-wide spurious re-apply. - **Deviation from the spec, deliberate:** the brief said to reuse "the new row's id … no schema change". There is no row id — the table is keyed by `host_id` and UPSERTed last-write-wins, so a new row never exists, and `created_at` collides for two mints in one second. An additive counter column (existing idempotent `ALTER TABLE` idiom) is the only monotonic source available. - **`pbsdrheal` gains an `auth_failed` trigger** — a NEW trigger in the existing machine, not a new machine. A box whose credential PBS rejects is escalated to a fresh mint (never a re-stage: that re-feeds the secret PBS just rejected), through the **existing damping** — a 401 flap must not become a secret-minting chain. This closes the loop end to end: agent proves the 401 → hub re-keys → generation advances → descriptor hash moves → agent re-consumes. - **`consumed_at` honesty gauge** — a staged secret left **unconsumed** past a 15-minute grace while the box reports `applied` is surfaced loudly with its own event. This is the exact July-18 fingerprint and a disagreement **no single tier can detect alone**. Deliberately a *surface*, not a heal: auto-re-issuing here would mint a second secret on top of an unconsumed one — the mint/consume race R-39(a) already recorded. - **Corrected a comment that stated a falsehood**: `ReissuePBSDR` claimed it refreshed the descriptor "with the NEW token_id/fingerprint". That is false for a re-key, and believing it is why nobody expected the descriptor to come back identical. ### R-50b(a) — wrapper drift is answerable `ArtifactManifest.WrapperSHA256` + an operator field. Unlike the agent binary and the golden, the PBS-DR wrapper is installed from `raw/branch/main` — unversioned, unpinned, absent from every manifest — yet it is root-owned 0755 and the pinned sudoers vector. Agents (>= 0.91.0) report the installed file's hash and the host page surfaces a mismatch. **An unknown on either side reads as quiet, never as drift** — lighting every host amber on rollout day is how a warning becomes noise. This does not fix the delivery channel; that stays R-50b(b)/(c). ### Tests Store-level generation monotonicity, per-host isolation and restage-leaves-it-alone; descriptor byte-change, `omitempty` and sibling-key round-trip; a **flow-level** test driving `ReissuePBSDR` against a fake that models a real re-key; `auth_failed` escalate/debounce/recovery; the honesty gauge incl. its grace window, the restage edge, the consumed case and the honest-stuck case; wrapper drift incl. both unknown directions. **Two red-proofs run at the assertion level** (not the compiler): removing the generation stamp makes the flow test fail with both byte-identical blocks printed; removing the `auth_failed` arm makes the escalation tests fail with `reissues=0`. ## v0.67.0 — the hub stops keeping things to itself: auto-minted self-bind link, post-RESET staleness, unprovisioned-offsite warning (2026-07-18) Four small items, each one a case where the hub already knew something and said nothing. Green: `go build ./... && go vet ./... && go test ./...` all pass. - **(a) The self-bind link is minted automatically — at customer creation AND at RESET completion** (R-36 sub-item). The box's console banner tells the customer to open „az e-mailben kapott link"; until now that email existed only once the operator remembered to press *Send self-bind link*, so the banner could be instructing someone to look for something that did not exist. During the 2026-07-18 rehearsal the box sat in pairing mode for ~11.7 minutes waiting on exactly that. `handleSelfBindLinkSend`'s body was extracted into a shared `mintAndSendSelfBindLink` core so the button and the two auto-mint call sites **cannot drift apart on the honesty rules**: F1 (no registered address → mint nothing, because a link nobody can receive is worse than none) and F2 (send failed → delete the token, never leave it silently live). The auto-mint wrapper **never fails the operation it rides on** — a customer create that provisioned Cloudflare, offsite and PBS must not 500 because a courtesy email bounced; every outcome is logged instead, and the operator can still re-send from the Setup tab. **Gap found and closed while wiring this:** `PurgeCustomerResetDBState` does **not** clear `selfbind_tokens`, so a capability link minted *before* a RESET would have stayed live across it. A successful mint already replaces it (minting is delete-then-insert, single-active), but the skip paths would not have — so the wrapper now clears stale tokens on those paths too. The invariant is now: after auto-mint runs, the only live link is one it just issued, or none. - **(b) Post-RESET staleness banner** (R-37). When a RESET **completed** after the newest report, every health figure on the customer page describes a lifecycle that no longer exists — and the page went on showing pre-RESET warnings as if they were current. It now says so, quoting the customer-facing phrasing („RESET óta nincs adat") and the reset's timestamp. Deliberately narrow: an in-flight reset does **not** trigger it (only a completed one), and it clears itself the moment a report arrives. Ties resolve to *stale* — SQLite timestamps are second-resolution, and a same-second report almost certainly arrived just before the reset destroyed what it describes; erring the other way would hide the banner exactly when it matters most. - **(c) Unprovisioned-offsite warning** (R-36 interim). `offsite.enabled == true` with no descriptor (`type == ""`) is a real, stable, silent state: provisioning is *Save*-triggered (`applyOffsite`), and the re-enroll auto-re-issue deliberately skips an unprovisioned target, so nothing self-heals it. The page now names the state and the fix (press Save once, then verify), reusing the exact predicate the offsite re-issue handler already refuses on. - **(d) `pbsdr_reissued` rendered an EMPTY flash box.** The flash key had no branch in the template, so re-issuing PBS credentials showed the operator a success box containing nothing — observed live on 2026-07-18. It now describes what was staged **and** carries the R-39 caveat: confirm `pvesm status` shows the entry *active*, because a converged agent can report `applied` while the storage still authenticates 401. - **Styling:** new `.flash-warn` (amber, `--warn`/`--warn-dim` tokens) for the deviation tier between success and error — per the exception-color principle it appears ONLY on deviation, never on a healthy page. - **Tests** (`customer_state_banners_test.go`, `selfbind_automint_test.go`) assert each banner is **absent** in the nominal cases as well as present in the deviating one — a banner that renders unconditionally is worse than none, because operators stop reading it. Auto-mint covers F1, F2, the no-mailer case, and the pre-RESET-token invariant. **Both red-proofed:** deleting the `pbsdr_reissued` branch reproduces the original empty-box bug, and neutering the staleness predicate fails the banner assertion. New store accessor `CountSelfBindTokens` (read-only, keyed by a customer id the operator already knows) makes the single-active invariant assertable. **Not in this train:** the R-39 hub-side generation-bump fix the pre-travel task made conditional. Its condition was **refuted** — `SetHostDesired` bumps unconditionally and `applyPBSDR` is idempotent as documented; the real mechanism is the agent's descriptor-hash convergence, which needs its own spec. Nothing was improvised here. ## v0.66.0 — Customer self-bind (R-27 slice 1): tokenized capability link + public two-factor `/bind/` page (2026-07-17) Lets a customer bind their OWN freshly-installed appliance without the operator. Until now every box booted from the universal secret-free ISO (R-21 slice C) had to be bound by the operator on the Hosts page; this adds the self-service path. **Viktor's three rulings, each honoured verbatim:** (a) *"only their own visible"* → the customer proves possession with the **console pairing code** shown on the box screen — **no appliance list is ever rendered** on any public surface; (b) *first-box entry* → an **operator-sent 7-day tokenized capability link** over Hungarian email (the claim-engine delivery pattern, a sibling sender — NOT routed through the claim engine); (c) **lockout after 5 failed attempts** → the token locks and the page says *"call support"*. Wrong code and wrong passphrase produce **one identical generic failure** (no oracle); an expired link falls back to operator-bind, unchanged. Does **not** touch the controller or agent. Green: `go build/vet/test` (full hub suite + 9 new self-bind tests), all 4 red-proofs verified red-then-green, hub confirm gate. - **Pairing code (Part 1).** `POST /api/v1/appliance/register` now returns an additive `pairing_code` (6 chars, ambiguity-free alphabet, `ABC-234` display) minted once at first registration and stable across the idempotent re-register/upsert (backfilled if a pre-existing row had none). Persisted on `appliance_registrations.pairing_code`; shown in the operator Hosts "Unclaimed appliances" table. The bootstrap (`felhom-bootstrap.sh`, ISO v1.20.0) parses it and prints a Hungarian **console banner** to `/dev/console` so the customer can read it off the screen. Old ISOs ignore the field; an old hub omits it and the banner prints nothing — additive both ways. - **Capability token (Part 2).** New `selfbind_tokens` table: `sha256(token)` at rest (never the token), **single-active per customer** (a re-mint deletes the prior row in one tx — an old link dies the instant a new one is sent), `attempts` locking at 5, one-shot `consumed_at`, `emailed_at` honesty. Operator **"Send self-bind link"** button on the customer Setup tab (`POST /customers/{id}/selfbind-link`) mints a 256-bit token and emails `https://hub.felhom.eu/bind/` (Hungarian, adult tone, no emoji, names both factors + the 7-day + 5-attempt limits). **F1** (no registered email → nothing minted, LOUD flash) and **F2** (email send fails → the just-minted token is deleted, not left silently live) are both honest. - **Public bind page (Part 3) — THE TRAP (§9.2).** One new public prefix `/bind/`, exempted from operator auth AND CSRF at the two gate sites the `/login` exemption occupies, via a **single** predicate `isPublicBindPath` (matched tightly: trailing slash → no sibling like `/bindsecret`; the ServeMux `..`-cleans before we see the path → no traversal reach; the handler also rejects a token containing `/`). `GET` renders form/consumed/locked/expired; `POST` normalizes both inputs, compares **both factors unconditionally** (constant-time passphrase vs the customer's retrieval passphrase; the ONE bindable appliance carrying the console code), then decides — identical generic failure either way. On success: the **same `BindAppliance`** the operator uses, a provenance event with source `customer_selfbind`, one-shot consume, and *"A doboz kb. egy percen belül folytatja a telepítést."* The box's ~30 s appliance poll picks up the delivery. Own per-IP rate limiter; the page is self-contained (it cannot link `/style.css`, which is itself operator-gated). No hub customer-login/session was built — the URL capability token IS the auth model; a cross-site POST without both secrets only burns attempts (accepted + documented). - **Tests + red-proofs (Part 4).** Scenarios A–F + F1/F2 (9 tests). The **4 red-proofs** were each applied and confirmed to turn exactly their scenario red, then reverted green: lockout removed → C1; oracle introduced → B; `/bind/` prefix widened (drop the slash) → E (and D); single-active DELETE dropped → C4. The passphrase is never logged/echoed/persisted; only attempt COUNTS are logged (`self-bind attempt N/5 … token <8hex>…`); the raw link token never enters logs or events. - **GC verdict (spec §3):** there is **no appliance-staleness GC** in the hub (`applianceStaleAfter` is a DISPLAY badge only; `pruneAll`/`PurgeExpiredLogBundles` touch reports/log-bundles, not appliances or selfbind tokens). The 7-day token TTL therefore stands alone and needs no reaper — single-active-per-customer means at most one row per customer, superseded rows are deleted on re-mint, and an expired row simply reads as expired (no security or storage pressure). ## v0.65.0 — PBS DR storage visibility (ep0 `usage` op) + Offsite tab split (Restic / PBS DR) + dual dashboard gauges (R-5) (2026-07-17) Makes the **PBS DR** storage visible like the restic pool box already is (v0.64.0), the two clearly differentiated. The scoping correction: "the restic box" and "the PBS box" are NOT two Hetzner Storage Boxes — **restic** = subaccounts on the shared Hetzner Storage Box (Hetzner API, v0.64.0); **PBS DR** = a PBS datastore (`felhom-offsite`) on the ep0 endpoint VM (NO Hetzner API). This adds the hub's read of the PBS datastore fill via **Option A (Viktor-ruled):** a new read-only `usage` op on the `felhom-tenantsync` ep0 forced command (the structural twin of the existing `fingerprint` op), polled by a new hub checker on the same 15-min throttle; splits the Offsite page into **Restic / PBS DR tabs**; and puts **two dashboard gauges** (restic %, PBS %). READ-ONLY against ep0 and Hetzner. Green: `go build/vet/test` + `bash -n` + the script harness; hub confirm gate OK. - **Phase-0 probe (gate, PASSED):** on ep0 (PBS 4.2.3), `df -B1 --output=size,used,avail ` (path from `proxmox-backup-manager datastore list --output-format json`) yields the datastore total/used/avail in **bytes** (live: 39990112256 / 7627939840 / 30686326784 → ~19%), read-only, in the existing sudo context, no admin token. (PBS 4.2 has no native `datastore usage` command.) - **`scripts/felhom-tenantsync.sh` → v1.2.0:** a read-only `usage` short-circuit (before the admin-token generation, like `fingerprint`) → `{"status":"ok","total","used","avail"}`. **No customer_id, no admin token, NO mutation.** - **`tenantsync.Client.Usage()`** (`internal/tenantsync/client.go`): `BoxUsage{Total,Used,Avail}` + the op; an endpoint ≤ v1.1.0 answers `bad_request "unknown op"` → typed `ErrUsageUnsupported` (the graceful-degradation signal). - **`monitor.PBSDRBoxChecker`** (`internal/monitor/pbsdr_box.go`, new): clones OffsiteBoxChecker over a `usageReader` seam (the tenantsync client) — 15-min throttle, cached `PBSBoxSnapshot`, escalation-only `pbsdr_box_fill` on the customer-less `"pbsdr-box"` scope (operator channel only, no SaveEvent), recovery re-arm. FILL ONLY (PBS uses namespaces, not quotas — no oversubscription). **THREE states:** `ok` (bands drive), `unavailable` (ErrUsageUnsupported — expected pre-update, neutral, NO alert, logged once, the gauge shows n/a), `degraded` (exec failed — keep last snapshot, no band transition). - **Config + wiring:** `Alerting.PBSDRBoxFill{Warn,Crit}Percent` (default 80/90, independently tunable); the checker is built ONLY when the tenantsync client exists (shares it), registered in the 60 s sweep, snapshot handed to the web server. **Graceful degradation: the hub deploy is INDEPENDENT of the ep0 update** — a hub v0.65.0 against an ep0 still on v1.1.0 shows the honest "n/a", lighting up on the next poll once ep0 is updated (no hub redeploy). - **Web (`internal/web/pbsdr_box.go` new, `offsite.go`, `templates/offsite.html`, `dashboard.html`, `style.css`):** the Offsite page splits into **Restic** (the v0.64.0 pool-box panel + per-customer rows) and **PBS DR** (a new datastore panel — capacity/used/fill bar with band + the endpoint cards, which belong here: the endpoint IS the PBS host) hash tabs (server-rendered, no JS dependency for the data). The single dashboard tile becomes two gauges — **RESTIC** (pct·ratio) and **PBS DR** (pct; "n/a" when unavailable) — each band-colored, each linking to its tab. - **Tests + red-proofs:** 10 Go tests (Usage parse + unknown-op→typed; checker throttle/bands/pbsdr-box operator-only/unavailable-no-alert/degraded-keeps-last; PBS panel render × ok/unavailable/not-configured) + a bash script harness (usage JSON + exit 0 + **zero mutation** + provision regression). Red-proofs (run-fail-restore): the op emitting a mutation → the harness zero-mutation assertion fails; the escalation-only guard removed → in-band re-emit fails; unavailable driving a band → the no-alert test fails. All confirmed red, then restored. ## v0.64.0 — offsite pool-box aggregate: fill, oversubscription, per-customer bars, operator alert (R-5) (2026-07-17) Ships **R-5**: the operator sees the shared pool box's real state on the hub — **total box fill vs capacity**, **Σ(shared soft quotas) vs capacity** (the oversubscription ratio), **per-customer usage/quota bars**, and a **box-level operator alert** (fill % + oversubscription ratio) riding the existing dispatcher's operator channel. Per-customer fill alerts already existed (OffsiteChecker, 90/95% of each quota); the box-level aggregate was the gap — the operator's early warning that the *pool itself* is filling, before any single customer breaches. All READ-ONLY against Hetzner (GET only). Green: `go build/vet/test` all pass; hub confirm gate OK. - **Phase-0 probe (gate, PASSED):** one authenticated GET of the live pool box (611714) pinned the API shape — capacity is `storage_box_type.size` (1 TiB for bx11), usage is a `stats` object (`size`/`size_data`/`size_snapshots`), all bytes; our token reads it (200). The type extension mirrors it. - **`hetznerapi` (`hetznerapi.go`, `fake.go`):** additive `StorageBoxType` + `StorageBoxStats` sub-structs on `StorageBox` (no existing field/method changed); fake carries them + a `GetBoxCalls` counter. Golden decode test against the (redacted) probe capture. - **`monitor.OffsiteBoxChecker` (`offsite_box.go`, new):** the OffsiteChecker-sibling for the box as a whole — **fetch-throttled** (one Hetzner GET per 15 min; ≈4/hour, never per-sweep or per-page-load), cached `BoxSnapshot`, escalation-only emits with silent recovery re-arm. Two independent signals: FILL (used/capacity, warn 80% / crit 90%) and OVERSUBSCRIPTION (Σ shared+enabled quotas / capacity, warn 2.0×) — both can fire, neither masks the other. Σ(quota) is read from the authoritative ConfigJSON `Descriptor` (`offsite.ReadDescriptor`, new), NEVER the report echo; dedicated + disabled customers excluded. Events carry the customer-less scope `"pool-box"` → operator channel ONLY (`processCustomer` no-ops on it) and are NOT SaveEvent'd (no customer row to key them). A failed fetch keeps the last snapshot marked degraded — missing data never becomes 0% and never drives a band transition. - **Config (`cmd/hub/main.go`):** `Alerting.OffsiteBoxFillWarnPercent` (80) / `OffsiteBoxFillCritPercent` (90) / `OffsiteOversubWarnRatio` (2.0), plumbed like `StorageFill*`. The checker is constructed inside the existing `HETZNER_TOKEN` branch (shares the client), registered in the 60 s sweep, and its snapshot handed to the web server. **Thresholds are Claude's encoding of the starter suggestion — Viktor's ruling pending; the named keys are the one-line flip.** - **Web surfaces (`web/offsite_box.go` new, `templates/offsite.html`, `dashboard.html`, `style.css`):** an Offsite-tab panel (capacity, used with data/snapshot split, fill bar, Σ quotas + ratio, fetched-at, + per-customer rows sorted by usage — shared with a usage/quota bar, dedicated listed without one, no-report customers show "no usage reported yet") and a compact Dashboard tile (`fill% · ratio`, band-colored, linking to /offsite). The web layer reads only the cached snapshot — it NEVER fetches. Nil provider → both render an honest "not configured". Exception color (neutral/amber/red, no green). - **Tests + red-proofs (run-fail-restore):** 10 new tests (throttle ≈4-not-≈60, Σ/ratio truth table, band transitions incl. in-band no-re-emit + recovery re-arm + oversub independence + pool-box operator-only scope, failed-fetch honesty, zero-capacity guard, golden decode, panel render × "not configured"/"with data"/"no usage reported yet"). Red-proofs: (i) drop the throttle → ≈60 calls; (ii) sum dedicated/disabled → wrong Σ; (iii) drop the escalation-only guard → in-band re-emit; (iv) zero the snapshot on a failed fetch → lost last-known. All confirmed red, then restored. ## v0.63.0 — system-initiated immediacy: wire the proven poke/bump notifiers into every mutation site that lacked one (2026-07-17) The immediate-sync arc (Dir-1 trigger, Dir-2b wait channel, Dir-2a agent poke) covered only **operator-initiated** desired-state changes. **System-initiated** mutations still bumped the generation silently, so a freshly onboarded box waited a full agent tick (≤15 min) for state the hub had already minted — observed live during slice-C onboarding. This wires the existing, live-proven notifiers (`poke.Notifier` for the agent plane, `intent.Hub.Bump` for the controller plane) into every system-initiated site that lacked one. No new mechanism — call-site wiring only. - **Agent-plane pokes (`internal/web/pbsdr.go`):** `PBSDRAutoProvision` (the exact observed lag — the WG-registration hands-free provision) now pokes on success; `ReissuePBSDR` (the shared core, which also gives the **pbsdrheal reconciler's escalation** its immediacy with zero reconciler changes) and the operator button `handlePBSDRReissue` poke after their descriptor bump. All fire ONLY after the successful `SetHostDesired`, never on a blocked/error path. - **Agent-plane Poker seam (`internal/api/handler.go`, `internal/api/wg.go`):** a new nil-safe `Poker` interface (`PokeHost`/`PokeAllHosts`, satisfied by `*poke.Notifier`) + `SetPoker`. `handleAdminSetDesiredState` pokes the target host after a successful admin desired-state write; `handleAdminSetOperatorPeer` fires a fleet `PokeAllHosts` — but only when `BumpAllHostGenerations` succeeded (fire-after-commit). - **Controller-plane bump (`internal/api/handler.go`):** `reissueOnReenroll` (the clean-slate F2 claim + F3 offsite re-issue) now `intentHub.Bump`s the customer so a long-polling controller wakes in seconds instead of on the 15-min cycle. Nil-guarded; one unconditional bump (coalesced, over-bump harmless). - **main.go:** one `poke.Notifier` instance now feeds BOTH planes' system sites — `webServer.SetPoke(n)` **and** `apiHandler.SetPoker(n)`; startup log: "web + api admin seams armed". - **Deliberate non-sites (unchanged, documented in REPORT audit table):** WG peer *register* (box tunnel doesn't exist pre-fetch — a poke is undeliverable; the agent fast-tick SECONDARY owns this leg), WG peer *delete* (the mutation removes the transport), and the **pbsdrheal Restage** path (no generation bump → the agent's 60 s pbsdr ticker is the pickup path; a poke there is a verified no-op). `internal/pbsdrheal/` is byte-unchanged. - **Tests + red-proofs (run-fail-revert):** 10 new non-hollow tests. Web (async, channel-synchronized fake sender): auto-provision pokes the resolved WG /32, blocked-precondition pokes nothing, reissue-core + operator-button poke, a failed reissue pokes nothing. API (synchronous fake Poker): admin-set pokes the target host only (0 on invalid-JSON), operator-peer fires one fleet poke, nil-seam no-panic; re-enroll advances the intent generation, nil-hub no-panic. Red-proofs demonstrated one representative removal per group (A/B web pokes, C admin poke, D re-enroll bump) — each FAILED red, then restored. Green: `go build/vet/test` all pass. ## v0.62.0 — R-21 slice C: the universal ISO — unclaimed-appliance registration + operator bind + one-shot delivery (2026-07-17) The hub half of the universal, **secret-free** bare-metal ISO. A box booted from the generic ISO registers itself as an UNCLAIMED APPLIANCE; the operator binds it to a customer on the Hosts page; the hub delivers the customer-id + retrieval passphrase on the box's next poll, ONCE. The distributed ISO carries no customer secret (§4.4). - **Store (`internal/store/appliance.go`, new):** `appliance_registrations` keyed by **(uuid, mac_set)** — serials are unusable (N100 DMI "Default string") and cheap boards duplicate SMBIOS UUIDs, so the MAC set is the tiebreaker (same uuid + different mac-set = distinct appliance). `token_hash` = sha256 of the appliance token (the token itself is never stored). `RegisterAppliance` (idempotent upsert; sticky-discard), `ApplianceByToken`, `BindAppliance`, `MarkApplianceDelivered` (atomic one-shot bound→delivered), `DiscardAppliance` (invalidates the token), `ListUnclaimedAppliances`. The table's own timestamps ARE the pre-bind provenance (no customer to scope an events row to yet). - **API (`internal/api/appliance.go`, new):** `POST /api/v1/appliance/register` — the ONE unauthenticated endpoint, per-IP rate-limited, returns a random 256-bit appliance token. `GET /api/v1/appliance/poll` (Bearer token): unknown/discarded → **404** (no oracle), unbound → **204**, bound → **200** + credentials (consumed once), delivered → **410**. The passphrase is read live from `customer_configs` (plaintext, as the day-0 command already needs it) and never logged. - **Web (`internal/web/appliances.go`, new):** the Hosts page grows an "Unclaimed appliances" section (uuid, MACs, hw, **SSH host-key fingerprints**, first/last seen, stale >7d badge) with **BIND** (customer picker showing host counts — display only, never a gate) and **DISCARD**. Bind stages the delivery + emits `appliance_bound`; delivery emits `appliance_credential_delivered`. - **Red-proofs (run-fail-revert):** the one-shot delivery (defeat the bound→delivered flip → second poll re-delivers the passphrase → FAIL) and register idempotency (drop the upsert → duplicate/UNIQUE violation → FAIL), both proven red then restored; plus 404-no-oracle + sticky-discard, bind staging/refusal, and the render test. Green: `go build/vet/test`; hub confirm gate OK. ## v0.61.0 — Customer RESET: the middle lifecycle tier (2026-07-17) One operator action returns a customer to **pre-first-install**: every OPERATIONAL trace dies (offsite repo, PBS namespace + backups, DR recipe, one-time secret, claim state, retained escrow custody), while **identity and the basic config survive** (the `customer_configs` row, all provenance rows, and the audit-event stream). It sits between the two existing tiers — *host delete* (< RESET) and *customer Delete* (> RESET, the one true purge point). Viktor's rulings: (1) destroying retained escrow custody gets its **own** separate acknowledgment; (2) RESET clears claim state (a fresh code next onboarding); (3) RESET **refuses while any host row exists** (delete hosts first — reset never deletes hosts); (4) the confirm surface shows a **live-counted** inventory. Orchestration discipline (spec §3): external teardown FIRST, DB purge LAST (publish-last), every leg idempotent → a partial run is simply re-run from the top; a failed external leg is a clean journal entry and the DB purge (which erases the descriptors that say what still needs tearing down) is withheld until every external leg is `ok`. Provenance + events are NEVER wiped. - **Store** (`internal/store/customer_reset.go`, new): `customer_resets` journal table (per-attempt, per-leg status, resumable); `CustomerResetInventory` (live counts: hosts, retained blobs via the F-14 `host_deletions` UNION, dr_recipe/one-time-secret/claim presence); `Start/UpdateResetLeg/Finish/ LatestCustomerReset`; `PurgeCustomerResetDBState` (ack-gated escrow-blob delete + one-time-secret, dr_recipe, log bundles — never touches identity/provenance/events); `DeleteClaim` primitive. - **Claim** (`internal/claim/engine.go`): `ResetToUnclaimed` DELETES the claim row so `EnsureIssued` mints a fresh first code on the next onboarding (no parallel revoked-flag, no stale generation). - **Offsite** (`internal/offsite/offsite.go`): `Deprovision` DELETES the labelled sub-account/box (idempotent — label-lookup, `len==0` = already gone); `OffsiteIdentifier` (preview name); `ClearProvisionedDescriptor` (keeps the tier CHOICE `enabled/type/quota/box_type`, drops every provisioned field). **PBS** (`internal/tenantsync/client.go` + `scripts/felhom-tenantsync.sh` `deprovision` op): destroys the customer's namespace + all backup groups + token; the shared `felhom@pbs` user is never touched; idempotent. - **Web** (`internal/web/customer_reset.go`, new): `GET /configs/{id}/reset` → live inventory JSON; `POST` → the orchestration (precondition + typed-id + escrow-ack gates BEFORE any write/external call). A distinct **amber** RESET card on the customer page (separate from the red Danger-zone Delete), with the typed-id confirm + the separate escrow-custody ack row. - **Red-proofs**: ack-gate (defeat → reset proceeds & destroys blobs → FAIL); partial-failure resumability (purge-not-withheld → DB purged despite external failure → FAIL); both proven red then restored. Plus store ack-gating, journal round-trip, offsite Deprovision idempotency + descriptor clear, and the RESET-card render test. Green: `go build ./... && go vet ./... && go test ./...`. ## v0.60.1 — host deletion DEMOTES escrow custody (never destroys) + S6b obsolete (2026-07-17) Closes the deletion-path gap in v0.60.0's review: `DeleteHost(deleteEscrow=true)` was still DELETING escrow rows (the same-customer reinstall flow funnels the operator straight into that tick). Principle (Viktor's standing ruling): host deletion is a lifecycle event — blob custody survives it; the customer Danger-zone Delete is the one true purge point. Green: `go build ./... && go vet ./... && go test ./...`. - **Scenario A — host delete demotes, never destroys.** `DeleteHost(deleteEscrow=true)` now DEMOTES the current `host_escrow` row into `host_escrow_superseded` (copy-BEFORE-delete, same tx) and SPARES existing superseded rows — no operator path through host lifecycle can lose a blob. Reuses THE one escrow row-copy routine (`demoteCurrentEscrowTx`, also used by `SaveHostEscrow`). The F-14 provenance row + gate semantics are unchanged (wording updated: demotion, not destruction). Edge: no escrow row → unchanged; the `ErrHostEscrowPresent` refusal without the flag is unchanged. Red-proof `TestDeleteHost_DemotesEscrowNeverDestroys`. - **Scenario B — customer delete is the purge point.** `DeleteCustomerConfig` (which previously deleted ONLY the `customer_configs` row) now, in one tx, purges `host_escrow` AND `host_escrow_superseded` for all the customer's hosts — INCLUDING already-deleted hosts (resolved via the F-14 `host_deletions` provenance) so a host-delete-then-customer-delete ordering leaves nothing orphaned. Danger-zone copy states it. Red-proof `TestDeleteCustomer_PurgesEscrowCustody`. - **Scenario C — wording.** The host-delete escrow checkbox now reads "Move key escrow to retained custody (required when escrow present)…"; the refusal message + customer Danger-zone copy match. Guard test `TestHostDeleteEscrowLabel_DemotionWording`. - **Scenario D — S6b verdict (docs): OBSOLETE.** Re-enrolling an existing host_id upserts cleanly (`UpsertHost` ON CONFLICT DO UPDATE, `store.go`; `handleAdminCreateHost` has no duplicate refusal) + the v0.57.0 re-enroll arc auto-fires the re-issues → no manual stale-host deletion needed before re-enroll. Scenario A also makes the funnel harmless either way. ROADMAP R-3 refined. - **Scope:** hub-only; no controller/agent change; ACK assembly + upload supersede path untouched. Deploy: bump `manifests/hub.yaml` tag to `0.60.1` and sync. ## v0.60.0 — offsite continuity Part B: superseded-escrow retention (data-first) (2026-07-17) Closes the data-loss half of the reinstall-orphaned-repo incident: `SaveHostEscrow`'s destructive `ON CONFLICT` overwrite meant a new escrow blob DESTROYED the old passphrase's only copy — so 18 snapshots keyed under the old password became unrecoverable. Viktor's ruling (data protection first): **retain superseded blobs** so the old passphrase stays customer-R-recoverable. Pairs with controller v0.142.0 (Part A orphaned-repo guard). Green: `go build ./... && go vet ./... && go test ./...`. - **`host_escrow_superseded` (new table) + `SaveHostEscrow` rewrite.** On upload, if the current row seals a DIFFERENT `restic_pw_sha256`, the old row is COPIED into the history table (in one tx) BEFORE the current row is overwritten; a same-sha re-upload (idempotent re-ceremony) refreshes the current row and creates NO supersede row. `SaveHostEscrow` now returns `superseded bool`. Retain ALL (no pruning — the blobs are tiny + R-encrypted, custody unchanged); the hub still never decrypts. **ACK/restore-serving read the CURRENT row (`GetHostEscrow`) — unchanged.** New `CountSupersededEscrow` / `ListSupersededEscrow` (the latter seeds the future R-26 recovery flow). `DeleteHost(deleteEscrow=true)` also drops the retained rows. - **Surfaces.** Upload handler emits the hub-internal `escrow_superseded` audit event + logs the retained count; the operator host-detail DR/Backup panel shows "N superseded escrow blob(s) retained". Registered `offbox_repo_orphaned` / `offbox_repo_reset` (controller v0.142.0 pushes) in `allowedEventTypes` + `customerMessages`. - Red-proof `TestSaveHostEscrow_RetainsSuperseded` (pre-fix destructive overwrite → old blob gone → FAIL; fixed → retained + retrievable; same-sha idempotent). - **Deploy:** bump `manifests/hub.yaml` image tag to `0.60.0` and sync. ## v0.59.0 — Direction-2a: agent-plane immediate-sync poke sender + ep0 felhom-poke surface (2026-07-16) Implements the AGENT-plane half of `documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md` option (a): an operator agent-plane change (a pbsdr descriptor, a MinAgent floor) now nudges the box in **seconds** via a CONTENTLESS UDP poke relayed hub → ep0 forced-command → wg0-origin → the agent's poke listener (felhom-agent v0.89.0). The spike reserved this transport for the agent plane and measured it at ~0.42 s/poke. Complements v0.58.0's `internal/intent` long-poll wait channel (Direction-2b, the CONTROLLER plane): intent is customer-keyed and wakes the config puller; poke is host-keyed and nudges the agent. Both are fire-and-forget; the 15-min report cycle stays the guarantee. Pairs with felhom-agent v0.89.0 (the listener). - **`internal/poke` — the SSH poke sender + notifier.** Third structural sibling of `internal/wgsync`/`internal/tenantsync`: a pinned-host-key (`ssh.FixedHostKey`, algorithm-pinned) in-process SSH client that reuses the peersync endpoint + host key, its OWN forced-command key. `Client.Poke(ctx, boxWGIP)` refuses any target outside `10.77.0.0/24` BEFORE dialing, then SSHes to ep0's `felhom-poke` with the box's WG /32 as the command string (→ `$SSH_ORIGINAL_COMMAND`); the forced command sends one empty UDP datagram to `:51822`. `Notifier.PokeHost`/`PokeAllHosts` are fire-and-forget (detached goroutine, nil-receiver-safe) — a poke NEVER blocks or fails the operator save; a missing peer / SSH error is logged and the report cycle reconciles. Tests: resolved-IP send, no-peer/store-error no-send, nil no-op, `Poke` pre-dial non-WG refusal. - **Wiring** (`internal/web/server.go`, `internal/web/pbsdr.go`, `internal/web/configs.go`, `cmd/hub/main.go`): `Server.SetPoke`; `applyPBSDR` fires `PokeHost(host.HostID)` after each generation-bumping descriptor save (disable, storage-id/re-enable change, fresh provision); `handleSetArtifacts` (the MinAgent-floor / vouched-agent save) fires `PokeAllHosts()`. **Source note (spec landmark vs source):** the artifact-manifest save does NOT itself bump per-host desired generation — the agent self-update dispatches via signed-ops on the next report — so the fleet poke there accelerates the next report cycle where the floor is applied, rather than delivering a desired-state delta. The sender is env-configured (`POKE_SSH_KEY_FILE`, reusing `WG_ENDPOINT_SSH_ADDR`/`_HOSTKEY`/user); absent Secret → "agent-plane poke disabled". - **ep0 surface** (`scripts/felhom-poke.sh` v1.0.0 + `documentation/runbooks/offsite-endpoint.md` §11): a NON-root (`felhom-peersync`, no sudoers grant — a datagram needs no privilege) forced-command that validates `$SSH_ORIGINAL_COMMAND` to the WG /24 and sends one empty datagram from wg0. Contentless + confined (the WG kernel independently refuses non-peer /32s — spike P1 EKEYREJECTED). Port **51822** is a shared cross-repo constant. - **`manifests/hub.yaml`:** `POKE_SSH_KEY_FILE` env + optional `Secret/agent-poke` mounted 0400 at `/etc/hub-secrets/agent-poke/key` (the private key stored out-of-band, per §11). Bump the image tag to `0.59.0` and sync. ## v0.58.0 — Direction-2 immediate-sync: the hub→box "sync now" wait channel (2026-07-16) Implements option (b) of `documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md`: an operator action on the hub now reaches the box in **seconds** instead of on the next ~15-min report cycle. The box holds a hanging authenticated `GET /api/v1/wait` over the existing outbound ingress; the hub completes it the instant any operator intent lands for that customer. The box then fires its ordinary out-of-cycle report — the ACK delivers config/escrow/claim/floor through the UNCHANGED machinery. The 15-min cycle stays the reconciliation backbone; every wait failure degrades to it. Pairs with controller v0.140.0 (the long-poll client). Ground truth #1 holds: the box pulls even the wake-up; the hub never connects inbound and no state ever rides the wait response. - **`internal/intent` — the in-memory operator-intent notifier.** A per-customer generation counter with a waiter registry: `Bump(customerID)` advances the generation and wakes every registered waiter (a burst coalesces into ONE completion carrying the LATEST generation — a counter, not a per-bump queue); `Wait(ctx, customerID, lastSeen, maxHold)` returns the instant the generation differs from `lastSeen`, on ctx-cancel, on `maxHold`, or on `Close`. A pre-register gen-check closes the bump-before-connect race (a bump is never lost). In-memory BY DESIGN — a hub restart resets generations; the box compares with `!=`, so a restart costs exactly one harmless full-state report, never a storm. No persistence, no store schema. Red-proofs: counter-vs-queue (return the as-of-register snapshot → `TestWait_CoalescesBurstToLatestGen` fails) and the race-closer (drop the pre-register check → `TestWait_RaceCloser_BumpBeforeWaitNotLost` fails); both run-fail-reverted. - **`GET /api/v1/wait` (api/wait.go).** Authed via `checkAuthCustomer`; per-customer only (a global operator key → 400; the customer is resolved from the key, no `customer_id` parameter is accepted, so A can never observe B). Holds up to **240 s**, writing a **25 s heartbeat newline** while it waits. nginx's `proxy_read_timeout` is measured BETWEEN upstream reads, so the heartbeat keeps the default 60 s from ever firing — **no ingress annotation / manifest timeout change is needed** (the transport spike measured that ceiling; §13 proves the heartbeat defeats it live). The response is contentless — a single `{"gen":N}` line. The connection's write deadline is lifted per-request via `http.NewResponseController().SetWriteDeadline` (the global `http.Server.WriteTimeout` of 60 s is deliberately untouched). - **Intent bumps (web).** Every operator-intent handler bumps the customer's generation AFTER its successful store write (fire-after-commit, never on an error path), via nil-safe `s.bumpIntent`: config create/update/delete, claim resend, offsite re-issue (UI + the re-enroll seam), offsite freeze/unfreeze, retrieval-password regen, block/unblock, per-customer floor, global floor (bumps every config-managed customer), controller log-tail request, and the CONTROLLER log-bundle request (the AGENT ring rides the heartbeat envelope — a separate plane, deliberately not bumped). - **Wiring + shutdown.** One `intent.New()` in `main.go`, injected into both the web server and the API handler; `intentHub.Close()` runs before `server.Shutdown` so held waits complete instantly instead of eating the 15 s grace window. nil-safe throughout (an unset hub → wait 503, bumps no-op). ## v0.57.0 — reinstall-of-existing-customer arc: claim continuity, offsite re-issue, escrow honesty (2026-07-16) Closes the N100 physical-run findings F2/F3 and the correctness edge behind F4→2.3 (`documentation/tests/VALIDATION-n100-baremetal-2026-07-16.md`). When an existing customer's box is clean-slate reinstalled, hub and box previously disagreed about claim, offsite, and escrow state. This makes the reinstall path first-class — the Peti (R-1) convergence prerequisite. - **F2 — claim continuity.** `claim.Engine.ReissueForReenroll` rides the existing rotation semantics: for a CLAIMED customer whose box re-enrolls (fresh, passwordless), it bumps the generation ONCE and emails a RESET code (delivery via the existing report ACK) — the customer no longer has to hunt for the manual "request a new code" button. No-op for an unclaimed customer (first-provision path owns the code). Hooked at the host-enroll **mint path** (`handleHostEnroll`), which fires exactly once per fresh host record — the single-bump-per-re-enroll guarantee. Emits `claim_reissued_reenroll`. *(Fork verdict, source-verified: the hub stores only the claim code + a claimed boolean — never the password hash, which is controller-owned by the arc's design. So fork B, not A.)* - **F3 — offsite continuity.** The re-enroll mint path also calls the same machinery as the manual "Re-issue offsite credentials" button (`web.Server.ReissueOffsiteForCustomer`, wired to the api handler via `SetOffsiteReissuer`) — the one-time offsite password only ever reached the OLD controller, so the fresh box gets a fresh one and a `ConfigVersion` bump. Emits `offsite_reissued`. - **2.3 — escrow honesty (correctness; red-proofed).** Re-issuing offsite credentials changes the restic repo password, so any existing key-escrow blob is now STALE (a recovery code minted against it would decrypt a password that no longer opens the repo). `offsite.ReissueCredentials` now marks the escrow stale (`store.MarkEscrowStale`, cleared by the next ceremony via `SaveHostEscrow`); the ACK **withholds** the now-mismatched `restic_pw_sha256` so the controller cannot auto-confirm against a dead key, and the DR-tier checklist shows **stale** instead of "ceremony done." Emits `escrow_stale`. Red-proof: with `MarkEscrowStale` gutted, the hub keeps advertising ceremony-done after a re-issue → `TestReissue_InvalidatesEscrow` FAILS; restored → passes. - **Out of scope (reported):** F4's general installer fix is NOT feasible — the DR storage id lives in the agent-domain pbs_dr descriptor (provisioned post-WG), not the installer-fetched config, and the underlying block is the agent's token-auth pre-check 403ing before its own root-run `felhom-pbs-apply grant`. Root fix is agent-side (ROADMAP agent-train item); the demo was unblocked live with a one-shot ACL grant. Controller (Part 3) unchanged: its escrow prereqs are already fetched live from the agent, so F4/Part-0 alone restore them (3.1 spec premise contradicted by source). scripts unchanged (v1.16.0). ## v0.56.0 — PBS-DR self-heal reconciler (re-stage a consumable secret) (2026-07-15) Implements `SPIKE-pbsdr-selfheal-2026-07-15` (`e8f8c44`). **⚠️ ARCHITECTURE IMPACT:** before this, there was **no automatic recovery** for the commonest real event — a customer box re-installed / restored / rolled back onto its **stable `host_id`**. The hub kept the durable `pbs_dr` descriptor (enabled) + a durable **consumed** one-time secret; the WG peer still existed (same pubkey → `changed==false`, so the provision cascade could not re-fire) and `applyPBSDR`'s "already provisioned → no-op" meant even a config re-save minted nothing. The agent sat in `pbs_dr.state="waiting_secret"` forever — PBS-DR never converged, so escrow could not run and offsite never armed. The spike proved (SQ-2b′) the **missing piece is a consumable secret, not the descriptor**: re-staging the stored secret converged a stuck box in one ~30 s agent tick, using the existing ep0 token, **zero churn**. This reconciler closes that gap. - **`internal/pbsdrheal/reconciler.go` (new):** a hub periodic reconciler (5 min, `wgsync` shape). For each host whose descriptor is **enabled + provisioned** and whose **latest report** `pbs_dr.state` is a stuck state held across a **debounce** (≥2 distinct reports — so a box briefly `waiting_secret` between provision and its first consume is not touched): `waiting_secret` → **re-stage the stored secret** (no ep0 call, **no generation bump**); no stored secret → **escalate to Re-issue**; `consumed_failed` → **escalate to Re-issue only** (a re-stage would re-feed a burned secret). A converged/`disabled`/`verify_failed`/unprovisioned/DR-OFF host is a **pure no-op**. Each heal emits a distinct audit event (`pbsdr_selfheal_restaged` / `_reissued` / `_consumed_failed`). Reads the hub DB only — never the box. - **`internal/store/pbsdr.go`:** `RestageHostPBSSecret(hostID) (bool, error)` — clears `consumed_at` IFF a row exists (no INSERT, no value change, **no generation bump**); `restaged=false` → the caller escalates. `PBSDRHealStates()` — one query joining each host's descriptor enable/provision flags to its latest report's `pbs_dr.state` + id (mirrors `GetHostOOBStates`). - **`internal/web/pbsdr.go`:** `ReissuePBSDR(ctx, customerID)` — the non-HTTP core of the operator Re-issue button (tenantsync reissue → fresh consume-once secret → descriptor refresh + gen bump), now the reconciler's escalation seam. The operator handler is unchanged. - **`cmd/hub/main.go`:** the reconciler is started unconditionally (the primary re-stage heal needs no endpoint). **`PBSDRHEAL_ONLY_HOST`** env scopes a supervised first rollout to one host (empty = whole fleet — the steady state). - **Tests:** `internal/pbsdrheal/reconciler_test.go` (Scenarios A–F + scope + no-re-heal, real store + fake action seam) and `internal/store/pbsdr_test.go` (re-stage semantics + the **no-generation- bump** guard + `PBSDRHealStates` parsing). All six §10 red-proofs verified (mutation → FAIL → revert). No agent changes (the agent already self-heals once a secret is consumable). - **Live-leg (drill guest qm300):** rolled back to `post_day0_golden136` (the re-install reproduction) → stuck `waiting_secret` (marker gone, `felhom-pbs` absent, hub secret consumed). Deployed scoped via `PBSDRHEAL_ONLY_HOST=demo-vm-felhom-2f4b00`. The reconciler observed `waiting_secret` across two reports (16:52 + 17:07 UTC) and at **17:10:00 re-staged** the stored secret (event `pbsdr_selfheal_restaged`; "no ep0 token minted, no generation bump"); the agent **re-consumed at 17:10:26** and **converged (`state=applied`) at 17:10:28** — hands-free, no operator click. The demo host `demo-felhom-01` was never touched (out of the scoped work set). **Fleet-wide widening (remove `PBSDRHEAL_ONLY_HOST`) is a deliberate operator follow-up** — not done in this supervised session. ## v0.55.0 — accept the offbox_enlarge_blocked event (Task 3a-fix delivery chain) (2026-07-15) The controller (v0.134.1) sends an `offbox_enlarge_blocked` warning when an app's enlarged offsite push is refused by the quota gate (config+DB still saved). This closes the **ingestion** link of its delivery chain. - **`internal/api/handler.go`:** `offbox_enlarge_blocked` added to `allowedEventTypes` — the event was 400-rejected before (the customer email was dropped at ingestion). Acceptance case + 400-red-proof added to `internal/api/event_test.go`. - **Deliberate NON-change:** NO `customerMessages` entry. `FormatCustomerEmail` (`templates.go:129`) gives the static per-type message PRIORITY over the raw message, so a static entry would DISCARD the controller's dynamic two-number Hungarian text (estimate + quota). The documented fallback (raw message survives) is the correct path; locked by a new `internal/notify/templates_offbox_test.go` assertion. `internal/notify/dispatcher.go` and the customer `EnabledEvents` whitelist are unchanged here — the controller side (v0.134.1) owns `DefaultEnabledEvents` + the prefs migration + the settings checkbox. ## v0.54.0 — operator login password changeable from the UI (2026-07-13) The hub login password was previously settable ONLY by editing the `auth.password_hash` field in the `hub-config` ConfigMap and redeploying — there was no in-app way to change it. Added a **"Login password"** card on the Configuration page. - **DB-override precedence** (same pattern as the controller-version floor). New `hub_settings` key `operator_password_hash` (store: `Get/SetOperatorPasswordHash`). The web server no longer reads a static field for auth: `Server.passwordHash` is renamed `configPasswordHash` (the hub.yaml SEED) and every auth check — the CSRF gate, `RequireAuth` session/basic-auth paths, and `handleLogin` — now goes through `effectivePasswordHash()` = **DB override wins, else config seed**. The ConfigMap value stays the **break-glass fallback**: blank the DB row (or edit the manifest + redeploy) to reset a lost password. - **`POST /configuration/password`** (`handleChangePassword`): requires the **current** password (verified against the effective hash), a new password of 8–72 bytes, and a matching confirmation; rejects a no-op change. On success it bcrypts the new password (cost 10, matching the seed) and persists the override. Existing sessions are intentionally kept valid — only the next sign-in and Basic-Auth use the new hash. CSRF-enforced (existing `ServeHTTP` gate); no secret is ever logged. - **UI**: change-password card on `configuration.html` with current/new/confirm fields, inline client-side mismatch pre-check, and six flash outcomes (`pw_changed`, `pw_current_wrong`, `pw_too_short`, `pw_too_long`, `pw_mismatch`, `pw_unchanged`). - **Tests + red-proofs** (`change_password_test.go`): override-wins precedence, happy-path end-to-end through `handleLogin` (new works, old dead), wrong-current rejection (security anchor), mismatch/too-short/no-op rejections, and template render. Red-proofs verified — dropping the current-password check writes the override anyway (WrongCurrentRejected fails); breaking the override precedence kills both the precedence and happy-path login assertions. ## (unreleased) hostInstallVersion 1.16.0 (2026-07-13) Display-const bump only, keeping `scripts/hostinstall_gates.py` green with the installer's v1.16.0 (FELHOM_ESCROW via the canonical sudoers fetch — see scripts/CHANGELOG.md). No behavior change; rides the next hub image train (no deploy for this). ## v0.53.0 — closing bundle: F-14 gated auto-Reissue + dead-host roll-up honesty + bearer out of git (2026-07-13) The last engineering items on the pre-tester board. Two operator rulings in force (CONTEXT.md): F-14 auto-re-issue only on a recorded escrow-acked deletion; customer status never better than its worst expected host. - **F-14 deletion provenance + gated auto-Reissue** (take-two MEDIUM: host delete + re-enroll with a surviving ep0 tenancy = DR re-attach dead-end, `token_exists` on both auto-provision and config save). New `host_deletions` table — host_id, customer_id, deleted_at, `escrow_acked` (= ack given over a PRESENT escrow row) — written INSIDE the DeleteHost transaction; NO backfill (pre-record deletions keep the manual path by design). The provision atom, on `token_exists`, reads the customer's MOST RECENT deletion record: escrow_acked → invoke the EXISTING tenantsync Reissue op, store the `pbsdr_auto_reissue` audit event ("Previous key destroyed (acknowledged deletion) — credentials re-issued automatically."), proceed; no record / un-acked → the pre-existing refusal byte-unchanged (never-silently-re-key law). Red-proofs: provenance-write drop → scenario-A fails; gate bypass → scenario-B's zero-reissue assertions fail (silent re-key visible as a 303). - **Dead-host roll-up honesty** (drill-1 observation, live on the Peti cluster: proxmox1 down 23h behind a GREEN customer row — controller reports ride the internet, independent of the agent). Customer status on the dashboard, /configs list and customer detail (header + strip) is now `worst(controllerDerived, hostStatusOf(each expected host))` via THE single staleness definition (`Server.hostStatus`; no second threshold anywhere): any host down/stale caps the customer at WARN with a cause chip naming the host ("host down: "); pending hosts worsen only after the customer has ever reported (onboarding exclusion). The three inlined controller-status chains collapsed into `controllerStatus()` (rollup.go). Display + derivation only — HostStalenessChecker alerting untouched. Red-proof: fold removal → the exact Peti fixture renders green → TestRollup_DeadHostMasking fails. - **Operator bearer out of git** (the two publish runbooks' ROTATION item): `manifests/hub.yaml` no longer commits `report_api_key` — the Deployment injects `REPORT_API_KEY` from out-of-band `Secret/report-api` (deliberately NOT `optional:` — a missing Secret fails Ready instead of booting an unauthenticatable hub); main.go gains the env override (RESEND_API_KEY twin). New gate `scripts/manifest_bearer_gate.py` blocks bearer-shaped (64-hex) literals in manifests/ (red-proven: reintroduction → exit 1). The controller repo's example-config copy of the literal is scrubbed. The exposed git-history value dies with the SUPERVISED rotation — procedure + full consumer list in documentation/runbooks/secrets.md §"Operator/global bearer key" (per-customer/per-host keys unaffected). Hub half of the polish batch (take-two findings F-15/F-16). Companion: controller v0.123.0. - **F-15 instant reset codes**: `POST /api/v1/claim/reset-request` now returns the ACTIVE code state in the response — `{claim: {code_hash, generation, issued_at}}`, the exact shape and bcrypt-only guarantee of the report ACK — so the box applies the rotated hash in the same request cycle and the emailed code works immediately (previously the box learned it only on its next report ACK, ~15 min — Viktor's take-two live failure). Served on every authorized outcome (a cap-reached refusal returns the unrotated row = controller-side no-op by generation). Never a plaintext code. Live-proven: apply 1 s after the request; code accepted on first try. The operator "Kód újraküldése" (no box round-trip) still has the ACK lag — its flash + data-confirm copy now say so ("A kód a doboz következő jelentésekor (~15 percen belül) aktiválódik."); the previously unmapped `claim-resent`/`claim-resend-failed` flashes render. - **F-16 inline confirms**: every native `confirm()` in the hub UI (offsite re-issue, freeze/ unfreeze, PBS re-issue, telemetry reset, dismiss-all-issues, regen-password, claim-resend, block/delete, geo-disable) replaced by the LIGHT inline two-step — the shared `inline_confirm.html` partial (`felhomConfirm` + `data-confirm` delegation, `requestSubmit` so formaction survives). Native confirms are OS-modals that froze CDP browser automation (F-16; the drill F-11 siblings). NOT the danger-zone typed-confirm — that heavyweight cascade flow is untouched. New gate `scripts/hub_confirm_gate.py` (zero native confirm/prompt; red-proven). Live-proven: the offsite re-issue completed under automation without freezing. ## v0.51.0 — DR-tier-by-default: per-customer flag + hands-free cascade + offsite coupling + capability chips (2026-07-12) Hub half of the DR-tier-by-default batch (DRILL-day0-vm-2026-07-12; operator decisions 1–5: capability BAKED on every install, activation is THIS flag, DR defaults ON for new customers, identity-only escrow PARKED by policy, WG is base infrastructure). Companion: installer v1.15.0 + agent v0.86.0 (capability `inactive` state). - **Per-customer `dr_tier` flag** (customer_configs column + form checkbox in the renamed "DR tier (PBS, ep0)" section, replacing the old `pbsdr_enabled` form field). NEW customers default ON; legacy rows were initialized FROM REALITY by a one-time migration backfill (host carries an enabled pbs_dr descriptor → ON, else OFF — never auto-cascade a legacy box; backfill runs only on the ALTER that adds the column, so later operator opt-outs survive). - **Cascade semantics** (scenario D): an UNMET precondition (no host / no WG peer / no tenantsync) is no longer a save-blocking error — the flag stores the intent and the edit form shows per-stage status (host enrolled → WG peer → descriptor provisioned → escrow present), reusing the fail-closed guard wording. REAL provisioning failures stay fail-closed (tenantsync error, token-exists → Re-issue). - **Hands-free auto-provision** (scenario A): a host's FIRST WG peer registration fires `PBSDRAutoProvision` (api `SetWGRegisteredHook`, wired when tenantsync is enabled) — a DR-ON customer's descriptor provisions with ZERO operator steps and applies on the agent's next desired-state tick. Detached goroutine; never delays/fails the registration response. - **Offsite requires the DR tier** (scenario C, drill F-6 CLOSED BY POLICY): `applyOffsite` refuses without the flag — exact message "Offsite backup requires the DR tier — enable it first (the escrow ceremony depends on the PBS key)". No more provisioning into the EscrowState-pending-forever dead end. - **Capability chips on the host page** (NEW render surface): the agent's privileged-capability self-check is now visible — ok (blue), degraded (warn / error when critical), and the agent v0.86.0 `inactive` state as a NEUTRAL chip (disabled ≠ degraded). The pre-v1.15.0 pbsdr "binary not found" signature surfaces the migration one-liner (never silently pretend). `.badge-ok` finally defined in style.css (was referenced, fell back to bare `.badge`). - Setup-tab installer copy: `hostInstallVersion` 1.12.0 → **1.15.0** (drill F-1), now gated against the installer's SCRIPT_VERSION by `scripts/hostinstall_gates.py`. - Tests + red-proofs (all four mutations proven red): coupling gate (guard removed → refused case fails), flag default (default flipped → form test fails), backfill (enabled:false ignored → disabled case fails), auto-provision (hook unhooked → scenario A test fails); plus cascade-wait, chips render (inactive-neutral / degraded-stays / migration hint), and the one-time-backfill-survives-reopen case. ## v0.50.0 — customer-claim password arc: code engine + email + ACK/config delivery (2026-07-12) Hub half of the customer-claim password gate (closes DRILL-day0-vm F-4/F-5; needs controller v0.122.0). The customer OWNS the dashboard password — the hub generates a one-time claim code, emails it (Hungarian) to the REGISTERED address, and stores only `bcrypt(code)`. No operator-set path; the plaintext code exists solely inside the email send (the retrieval-passphrase custody rule). - **`internal/claim`** — the code engine. `EnsureIssued` (idempotent — issue+email at the FIRST real config retrieve = Day-0, and at a live box's first report; repeated pulls/reports never rotate or re-send), `Resend` (operator button; rotates generation — unclaimed gets the claim template, claimed gets the reset template), `RequestReset` (controller-forwarded "Elfelejtett jelszó", rate-limited 3/day/customer), `MarkClaimed` (set-only; one confirmation email on the unclaimed→claimed transition). - **`store.customer_claims`** — per-customer `{code_hash, generation, issued_at, emailed_at, claimed_at, reset_day, reset_count}`. `RotateClaimCode` bumps the generation (single active code) and PRESERVES `claimed_at` (a reset never un-claims); `MarkClaimed` is set-only. - **Delivery**: `GET /api/v1/config/{id}` bakes `web.claim_code_{hash,generation,issued_at}` into the generated controller.yaml (gate-from-first-boot) and issues the first code; the report ACK serves the active `claim {code_hash, generation, issued_at}` (allowlisted) and ingests the controller's `claimed` flag (set-only). `POST /api/v1/claim/reset-request` (self-scoped by the box's report key). New emails via the notify dispatcher; `claim_lockout` event allowlisted. - **UI**: the customer page Setup tab shows a claim status chip (Nyitott — kód kiküldve / Claimed) + a "Kód újraküldése" button (`POST /configs/{id}/claim-resend`) — no plaintext code ever rendered (there is none to render). - 15 tests (engine, ACK/config, UI); the arc's red-proofs live in the controller repo (gate) + here (generation bump, reset non-DoS). ## v0.49.0 — Edit tab merge (edit-a), scoped auto-refresh, style.css cache-bust (2026-07-12) > The task spec targeted "v0.48.0", but v0.48.0 (app_start_failed, below) had already shipped + > deployed by the time this train ran — a published tag is never re-pointed, so this is v0.49.0. > Baseline `3e949bc`; commits `e740147` → `2e03de1` → `1d94b1a` → docs/manifest. - **Edit tab merge (edit-a)** (`templates/customer_unified.html`, `templates/config_form.html`, new `templates/config_form_body.html`, `web/configs.go`, `web/pbsdr.go`): the standalone customer edit page merged into the customer page's Settings tab, renamed **Edit**. The form body is a shared `{{define "config_form_body"}}` sub-template (the `host_detail_body` pattern) built by the one `configFormData` view-model builder; the standalone chrome keeps rendering it for the create flow (`/configs/new`) and the validation-error re-render. The Edit tab renders: config form, Controller Update card, Geo card, and a **Danger zone** card holding the Block/Unblock/Delete forms relocated verbatim from the Customer Info header (endpoints + `confirm()` unchanged) — all SIBLINGS after `` (nested forms are invalid HTML and would break the offsite/PBS `formaction` sub-buttons). The header keeps only the config-less Create Config action. `GET /configs/{id}/edit` → 302 `/customers/{id}#tab=edit`; tabs JS gains the `settings`→`edit` legacy-hash alias. - **Server-side required fields on update** (`web/configs.go` `handleConfigUpdate`): the twin of the form's `required` attributes (Display Name + Domain), checked BEFORE provisioning; the error path re-renders the standalone page with the SUBMITTED overrides so typed values are never lost (red-proofed: nil overrides → values reset → test fails). - **Redirect anchors**: update/block/unblock/offsite-reissue/offsite-freeze/pbsdr-reissue → `?flash=…#tab=edit`; regen-password → `#tab=setup` (its card lives there); delete unchanged (`/configs?flash=deleted`). - **Scoped auto-refresh** (`templates/customer_unified.html`): the 60s reload fires only while a live tab (`data-live-tabs="overview,applications,events,host"` on the nav) is active AND no form is dirty (delegated document-level input/change listener, never reset — a reload clears it). Skipped ticks reschedule; a muted `(paused)` hint shows next to the toggle on non-live tabs / dirty forms. Toggle, `hub_auto_refresh` localStorage key, cadence, default-on: unchanged. - **style.css cache-bust** (all `templates/*.html`): every stylesheet link is now `/style.css?v={{hubVersion}}` — closes the v0.47.0 gotcha (`max-age=3600` served stale styling for up to an hour after each deploy). Red-proofed (bare link fails the render test). - **Repo staging rule** (`CLAUDE.md`): never `git add -A` in this repo (the v0.47.0 `146d165` sweep incident) — explicit paths, pull-rebase, one writing session per clone. - Tests: +11 (Group A panel surface / sibling-form / header-count, Group B redirect + create + typed-values-preservation table, Group C refresh structural pins, Group D cache-bust sweep). Amended pins: `customer_tabs_test.go` (settings→edit), `pbsdr_test.go` (postUpdate supplies the now-required fields; FormRendersState asserts the embedded Edit-tab render). ## v0.48.0 — accept the app_start_failed event (controller fix-3, CAMPAIGN-3) (2026-07-12) - `app_start_failed` added to `allowedEventTypes` (`internal/api/handler.go`) + `customerMessages` (`internal/notify/templates.go`). Without the allowlist entry the controller's fix-3 event (a DEPLOYED app found not running — controller v0.120.0) would 400 at ingest and never reach the operator. No other hub change; pairs with controller v0.120.0 which closes the CAMPAIGN-3 finding set. ## v0.47.0 — UI reorganization: customer tabs, Host tab, stale-host removal, offsite multi-endpoint UI, button contrast (2026-07-11) Five hub-side deliverables; no agent/controller/protocol changes. Baseline `8e1a3f0` (v0.46.0); commits `9f29bf3` → `ae950e5` → `146d165`(swept WIP) → `068427a` → `0daddcd`. - **CSS button contrast** (`templates/style.css`): `.data-table td a` → `:not(.btn)` (base + hover) — `` inside data-table cells (host-detail Diagnostics View/Download, customer log-tail buttons) rendered blue-bright on blue-bright, i.e. invisible. Plain table links keep the bright-link style; `.btn` itself untouched, no `!important`. - **Customer page tabs** (`templates/customer_unified.html`, `style.css`): the ~18 stacked sections split into 8 client-side hash tabs (`#tab=` overview / applications / setup / settings / backup / events / notifications / host) + a sticky summary strip (name, status, controller version, last report, containers chip). Graceful degradation is load-bearing: panels hide only under a JS-added `body.js-tabs` class — no JS = every section visible, all existing render tests pass unmodified. Events tab carries a red error-count badge (reuses the already-fetched `CountEventsBySeverity` data — no new query). The auto-refresh reload preserves the hash → the active tab survives. No handler/data-model change for the tabs. - **Host tab + shared sub-template** (`templates/host_detail_body.html`, `web/hosts.go`, `web/configs.go`, `store.ListHostsByCustomer`): the host-detail body extracted into a `{{define "host_detail_body"}}` rendered by BOTH `/hosts/{id}` (chrome + call) and the new per-customer Host tab (a LIST by design — 1 host today, N for a later HA cluster; empty state otherwise). `handleHostDetail`'s data assembly extracted into `hostDetailData`. - **Stale host removal** (`store.CountHostArtifacts`/`DeleteHost`, `web/hosts.go` handlers, routes above the `/hosts/` catch-all): `GET /hosts/{id}/delete-impact` (counts/booleans ONLY) + `POST /hosts/{id}/delete` behind a type-to-confirm dialog (global-floor pattern). Gates: ONLINE host → 409 always (no override — a live agent would 401 forever; enroll is passphrase-gated mint-once); confirm mismatch → 400; escrow present without the explicit checkbox → 409 with the tx never started (`ErrHostEscrowPresent`, fail-safe-to-refuse). One transaction cascades guests, host_reports, signed_jobs, host_recovery, host_pbs_secrets, host-scoped log bundles (`scope_id == host_id` ONLY — customer-scoped bundles survive), the bound wg peer (inside the tx — no stranded peer on crash), escrow (only when acked), then the host row. The wgsync 5-min declarative push converges the endpoint afterwards — no reconciler change. Danger-zone card renders only when deletable, so the hosts-list zero-`