feat(hub): v0.69.0 — customer DELETE is the guided full-teardown cascade (R-25b)

POST /configs/{id}/delete now runs hosts -> RESET -> purge behind three
acknowledgements, a typed customer-id, a stale-preview check and the
ONLINE-host refusal (every gate before any write, so a refusal has zero
side effects). The shallow handleConfigDelete is gone.

Two invariants are asserted, not just commented: ruling 3 is preserved by
construction (leg 2 never sees a host row) and retained escrow custody is
purged exactly once, in leg 3 (leg 2 runs with purgeEscrow=false).

handleCustomerReset's committed half was extracted as commitCustomerReset;
the standalone RESET path is byte-identical to v0.68.1 and its suite is
untouched. Five red-proofs run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J55BQE1gE2V4ffud5jweGS
This commit is contained in:
2026-07-21 19:31:48 +02:00
parent f59aa97d0c
commit 61dbd870c3
13 changed files with 1087 additions and 208 deletions
+69
View File
@@ -1,5 +1,74 @@
# Felhom Hub — Changelog
## 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 `<div>`. The artifacts **`<form>` IS the CSS