package store import ( "database/sql" "encoding/json" "fmt" "time" ) // Customer RESET (v0.61.0) — the middle lifecycle tier (host delete < RESET < customer delete). The // journal is F-14-style provenance: one row per attempt, per-leg status, resumable. External teardown // runs FIRST and the DB-purge runs LAST (publish-last) so a re-run reads the journal to know what still // needs tearing down. Provenance/events are NEVER wiped — audit outlives every tier. // CustomerReset is one reset provenance/journal record. type CustomerReset struct { ID int64 CustomerID string StartedAt time.Time CompletedAt *time.Time EscrowAcked bool Legs map[string]string // hetzner | pbs | db_purge → pending|ok|failed|manual } // ResetInventory is the live count of what a RESET would destroy (the confirm surface, ruling 4). // Sub-account name + PBS namespace are added by the web layer from the external clients. type ResetInventory struct { HostCount int // ruling 3: RESET refuses while any host row exists SupersededBlobs int // M — retained recovery-key custody destroyed (ack-gated) DRRecipePresent bool OneTimeSecretPresent bool ClaimPresent bool } // CustomerResetInventory builds the hub-DB side of the confirm inventory. func (s *Store) CustomerResetInventory(customerID string) (*ResetInventory, error) { inv := &ResetInventory{} hosts, err := s.ListHostsByCustomer(customerID) if err != nil { return nil, err } inv.HostCount = len(hosts) queries := []struct { dst *int q string arg string }{ {&inv.SupersededBlobs, `SELECT COUNT(*) FROM host_escrow_superseded WHERE host_id IN (SELECT host_id FROM host_deletions WHERE customer_id = ? UNION SELECT host_id FROM hosts WHERE customer_id = ?)`, customerID}, } for _, qq := range queries { if err := s.db.QueryRow(qq.q, qq.arg, customerID).Scan(qq.dst); err != nil { return nil, err } } present := func(q string) (bool, error) { var n int err := s.db.QueryRow(q, customerID).Scan(&n) return n > 0, err } if inv.DRRecipePresent, err = present(`SELECT COUNT(*) FROM dr_recipe WHERE customer_id = ?`); err != nil { return nil, err } if inv.OneTimeSecretPresent, err = present(`SELECT COUNT(*) FROM one_time_secrets WHERE customer_id = ?`); err != nil { return nil, err } if inv.ClaimPresent, err = present(`SELECT COUNT(*) FROM customer_claims WHERE customer_id = ?`); err != nil { return nil, err } return inv, nil } // StartCustomerReset opens a reset journal row and returns its id. func (s *Store) StartCustomerReset(customerID string, escrowAcked bool) (int64, error) { acked := 0 if escrowAcked { acked = 1 } res, err := s.db.Exec(`INSERT INTO customer_resets (customer_id, escrow_acked, legs_json) VALUES (?, ?, '{}')`, customerID, acked) if err != nil { return 0, err } return res.LastInsertId() } // UpdateResetLeg merges one leg's status into the journal (read-modify-write; the reset is single-flight // per customer at the handler, so no lock contention). func (s *Store) UpdateResetLeg(resetID int64, leg, status string) error { var raw string if err := s.db.QueryRow(`SELECT legs_json FROM customer_resets WHERE id = ?`, resetID).Scan(&raw); err != nil { return err } legs := map[string]string{} if raw != "" { _ = json.Unmarshal([]byte(raw), &legs) } legs[leg] = status out, err := json.Marshal(legs) if err != nil { return err } _, err = s.db.Exec(`UPDATE customer_resets SET legs_json = ? WHERE id = ?`, string(out), resetID) return err } // FinishCustomerReset stamps completion. func (s *Store) FinishCustomerReset(resetID int64) error { _, err := s.db.Exec(`UPDATE customer_resets SET completed_at = datetime('now') WHERE id = ?`, resetID) return err } // LatestCustomerReset returns the customer's most recent reset record (nil = never). func (s *Store) LatestCustomerReset(customerID string) (*CustomerReset, error) { var ( cr CustomerReset startedAt string completedAt sql.NullString acked int legs string ) err := s.db.QueryRow(` SELECT id, customer_id, started_at, completed_at, escrow_acked, legs_json FROM customer_resets WHERE customer_id = ? ORDER BY id DESC LIMIT 1`, customerID). Scan(&cr.ID, &cr.CustomerID, &startedAt, &completedAt, &acked, &legs) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } cr.StartedAt = parseSQLiteTime(startedAt) if completedAt.Valid && completedAt.String != "" { t := parseSQLiteTime(completedAt.String) cr.CompletedAt = &t } cr.EscrowAcked = acked != 0 cr.Legs = map[string]string{} _ = json.Unmarshal([]byte(legs), &cr.Legs) return &cr, nil } // PurgeCustomerResetDBState is the DB-purge phase — runs LAST, after the external teardown legs. In one // tx it removes the customer-scoped operational state a RESET destroys: retained escrow custody // (ack-gated), the one-time repo password, the DR recipe, and customer-scoped log bundles. It does NOT // touch customer_configs identity/config (SURVIVES), provenance (host_deletions/customer_resets), or // events (audit). The claim reset rides the claim engine separately; the offsite descriptor clear rides // the offsite package (config_json). Idempotent — a re-run deletes nothing extra. func (s *Store) PurgeCustomerResetDBState(customerID string, escrowAcked bool) error { tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if escrowAcked { // Retained (superseded) blobs — the hosts are gone by the RESET precondition, so they are found // only via the F-14 host_deletions provenance (UNION current hosts as a belt-and-suspenders). if _, err := tx.Exec(`DELETE FROM host_escrow_superseded WHERE host_id IN ( SELECT host_id FROM host_deletions WHERE customer_id = ? UNION SELECT host_id FROM hosts WHERE customer_id = ?)`, customerID, customerID); err != nil { return fmt.Errorf("purge superseded escrow: %w", err) } } for _, q := range []string{ `DELETE FROM one_time_secrets WHERE customer_id = ?`, `DELETE FROM dr_recipe WHERE customer_id = ?`, `DELETE FROM log_bundles WHERE scope_id = ?`, `DELETE FROM log_bundle_requests WHERE scope_id = ?`, } { if _, err := tx.Exec(q, customerID); err != nil { return fmt.Errorf("reset purge %q: %w", q, err) } } return tx.Commit() } // DeleteClaim removes a customer's claim row (RESET → unclaimed / pre-first-install). The claim ENGINE // re-mints a fresh code via EnsureIssued on the next onboarding — this is the store primitive the // engine's ResetToUnclaimed rides (no parallel claimed-flag mechanism). func (s *Store) DeleteClaim(customerID string) error { _, err := s.db.Exec(`DELETE FROM customer_claims WHERE customer_id = ?`, customerID) return err }