package store import "fmt" // Customer DELETE residue (v0.70.0, R-25b follow-up). // // The v0.69.0 cascade tore down hosts, external state and the customer record — and a fully deleted // customer STILL came back on the Customers list, because `GetCustomers()` derives the list purely // from the REPORT stream (`SELECT ... FROM reports ... GROUP BY customer_id`). Nothing in any // lifecycle tier ever deleted a report. Worse than cosmetic: the staleness and offsite checkers // iterate the same report-derived list, so a deleted customer kept raising `offsite_stale` and kept // emailing the operator — observed live on `demo-vm-felhom`, deleted 2026-07-18, still alerting // 2026-07-21. // // Two of these tables 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 the row returns a still-living box to the unclaimed pool on its next // registration, which is exactly the right state for a decommissioned appliance. // - `selfbind_tokens` — an unconsumed 7-day bind token would remain a working path to bind a box // to a customer that no longer exists. // // What deliberately SURVIVES (unchanged from every other tier): `events` (the audit stream), // `notification_log` (the send-attempt audit), `host_deletions` and `customer_resets` (F-14 // provenance). The audit trail outlives every lifecycle tier — that rule is not relaxed here. // CustomerResidue counts what a customer has left behind OUTSIDE the config row: the report-derived // state that keeps a deleted customer visible and alerting, plus the credential-bearing bindings. // Counts only — never a token, hash or payload. type CustomerResidue struct { Reports int AppTelemetry int AppLogTails int LogTailRequests int NotificationPrefs int SelfBindTokens int ApplianceRegistrations int } // Total is the single number the "is there anything left at all?" decision hangs on. func (r *CustomerResidue) Total() int { if r == nil { return 0 } return r.Reports + r.AppTelemetry + r.AppLogTails + r.LogTailRequests + r.NotificationPrefs + r.SelfBindTokens + r.ApplianceRegistrations } // residueQueries is the ONE list both the counter and the purge walk, so a table can never be // counted-but-not-purged (or purged-but-not-counted) — the two drift silently otherwise. var residueQueries = []struct { table string dst func(*CustomerResidue) *int }{ {"reports", func(r *CustomerResidue) *int { return &r.Reports }}, {"app_telemetry", func(r *CustomerResidue) *int { return &r.AppTelemetry }}, {"app_log_tails", func(r *CustomerResidue) *int { return &r.AppLogTails }}, {"log_tail_requests", func(r *CustomerResidue) *int { return &r.LogTailRequests }}, {"customer_notifications", func(r *CustomerResidue) *int { return &r.NotificationPrefs }}, {"selfbind_tokens", func(r *CustomerResidue) *int { return &r.SelfBindTokens }}, {"appliance_registrations", func(r *CustomerResidue) *int { return &r.ApplianceRegistrations }}, } // CustomerResidue counts the customer's report-derived + binding residue. Read-only. func (s *Store) CustomerResidue(customerID string) (*CustomerResidue, error) { res := &CustomerResidue{} for _, q := range residueQueries { if err := s.db.QueryRow(`SELECT COUNT(*) FROM `+q.table+` WHERE customer_id = ?`, customerID). Scan(q.dst(res)); err != nil { return nil, fmt.Errorf("CustomerResidue %s: count %s: %w", customerID, q.table, err) } } return res, nil } // PurgeCustomerResidue removes the report-derived state and the credential-bearing bindings in ONE // transaction — the leg that actually makes a deleted customer disappear from the Customers list and // stop alerting. Idempotent: a re-run deletes nothing extra. It NEVER touches events, // notification_log, host_deletions or customer_resets. func (s *Store) PurgeCustomerResidue(customerID string) error { if customerID == "" { return fmt.Errorf("PurgeCustomerResidue: empty customer_id") } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() for _, q := range residueQueries { if _, err := tx.Exec(`DELETE FROM `+q.table+` WHERE customer_id = ?`, customerID); err != nil { return fmt.Errorf("PurgeCustomerResidue %s: purge %s: %w", customerID, q.table, err) } } return tx.Commit() }