feat(hub): v0.70.0 — a deleted customer actually disappears (residue leg + ghost cleanup)

Found validating v0.69.0 against the live hub. demo-vm-felhom was deleted
on 07-18 and was still on the Customers list AND still raising offsite_stale
(10 events, latest 07-21 17:34, operator email at 19:34) — because
GetCustomers() is report-derived and no lifecycle tier ever deleted a report.

New leg 3 (residue), before the record purge: reports, app_telemetry,
app_log_tails, log_tail_requests, customer_notifications, plus the
credential-bearing appliance_registrations and selfbind_tokens. Audit
(events, notification_log) and F-14 provenance still survive.

Ghost customers are now deletable: 404 means "nothing here", not "no config
row". With no config row the offsite descriptor is unknowable, so the Hetzner
and descriptor legs record skipped_no_config rather than a bare "skipped".

Two more red-proofs.

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 20:28:06 +02:00
parent a1d503be98
commit 9b3381be0a
13 changed files with 521 additions and 45 deletions
@@ -893,13 +893,23 @@
if (d.dr_recipe_present) dies.push('DR recipe');
if (d.one_time_secret) dies.push('one-time password');
if (d.claim_present) dies.push('claim state');
if (d.residue && d.residue.reports) dies.push(d.residue.reports + ' report row(s)');
if (d.residue && d.residue.app_telemetry) dies.push(d.residue.app_telemetry + ' app-telemetry row(s)');
if (d.residue && d.residue.appliance_registrations) dies.push('appliance registration (token)');
if (d.residue && d.residue.selfbind_tokens) dies.push('self-bind token(s)');
if (d.residue && d.residue.notification_prefs) dies.push('notification preferences');
dies.push('customer record');
var custody = d.superseded_blobs > 0
? d.superseded_blobs + ' retained escrow blob(s) + every current host escrow'
: 'every current host escrow';
inv.innerHTML = '<strong>Will be destroyed:</strong> ' + dies.join(', ') +
'. <strong>Custody:</strong> ' + custody + ' (purged in the final leg). ' +
'<strong>Survives:</strong> the audit event stream and the deletion provenance.';
'<strong>Survives:</strong> the audit event stream, the notification log and the deletion provenance.';
if (d.has_config === false) {
inv.innerHTML = '<strong style="color: var(--warn)">Ghost customer:</strong> the configuration record ' +
'is already gone, but ' + d.residue_total + ' row(s) of report/telemetry state keep it on the ' +
'Customers list and keep it raising staleness alerts. This clears it.<br>' + inv.innerHTML;
}
if (d.online_host_present) {
inv.innerHTML += '<br><strong style="color: var(--crit)">Refused:</strong> a host is ONLINE. ' +
'Decommission the box first — the cascade never deletes a live host.';