# Felhom Hub — Changelog ## 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-`