package store import ( "database/sql" "encoding/json" "fmt" "sort" "strings" ) // R-244 (operator ruling 2026-10-12, S6, `09` §3 decision 201): a deleted customer's id must not // survive in `app_log_issues`. // // The table is a FLEET-WIDE aggregate keyed on app_name+fingerprint, with the customers who hit an // issue in `affected_customers` (a JSON list) and, for the row's sample log lines, `context_customer` // + `context`. So a delete must DE-REFERENCE, never `DELETE … WHERE customer LIKE`: on 2026-08-08, 27 // of the 71 rows naming a torn-down venue were shared with a LIVE customer, whose history a naive // delete would have destroyed. The rule, per row that names the customer: // - remove the id from affected_customers; // - if the sample lines were that customer's (context_customer = id), drop them (context, context_customer); // - delete the row only when no customer is left in affected_customers. // Pinned by TestDereferenceCustomerFromAppIssues and TestSweepDeletedAppIssueRefs. // appIssueRefs are the rows that name a customer, with what de-referencing would do to each. type appIssueRef struct { id int64 remaining []string // affected_customers without the id ownsCtx bool // context_customer == id } func appIssueRefsFor(q interface { Query(string, ...any) (*sql.Rows, error) }, customerID string) ([]appIssueRef, error) { rows, err := q.Query(`SELECT id, COALESCE(affected_customers,'[]'), COALESCE(context_customer,'') FROM app_log_issues WHERE context_customer = ? OR instr(affected_customers, ?) > 0`, customerID, `"`+jsonEscape(customerID)+`"`) if err != nil { return nil, err } defer rows.Close() var out []appIssueRef for rows.Next() { var id int64 var affected, ctxCust string if err := rows.Scan(&id, &affected, &ctxCust); err != nil { return nil, err } var list []string _ = json.Unmarshal([]byte(affected), &list) ref := appIssueRef{id: id, ownsCtx: ctxCust == customerID} named := ref.ownsCtx for _, c := range list { if c == customerID { named = true continue } ref.remaining = append(ref.remaining, c) } if named { // instr() is a pre-filter; only an exact list member or the context owner counts out = append(out, ref) } } return out, rows.Err() } func jsonEscape(s string) string { b, _ := json.Marshal(s) return strings.Trim(string(b), `"`) } // dereferenceCustomerFromAppIssues applies the rule above inside tx. Returns (rows changed, rows deleted). func dereferenceCustomerFromAppIssues(tx *sql.Tx, customerID string) (changed, deleted int, err error) { refs, err := appIssueRefsFor(tx, customerID) if err != nil { return 0, 0, err } for _, r := range refs { if len(r.remaining) == 0 { if _, err := tx.Exec(`DELETE FROM app_log_issues WHERE id = ?`, r.id); err != nil { return changed, deleted, err } deleted++ continue } left, _ := json.Marshal(r.remaining) if r.ownsCtx { _, err = tx.Exec(`UPDATE app_log_issues SET affected_customers = ?, context = '', context_customer = '' WHERE id = ?`, string(left), r.id) } else { _, err = tx.Exec(`UPDATE app_log_issues SET affected_customers = ? WHERE id = ?`, string(left), r.id) } if err != nil { return changed, deleted, err } changed++ } return changed, deleted, nil } // AppIssueResidue is the one-time sweep's plan (or result): per DELETED customer id, how many shared // rows would be de-referenced and how many would be deleted. type AppIssueResidue struct { CustomerID string Dereferenced int Deleted int } // SweepDeletedAppIssueRefs finds every customer id named in app_log_issues that the hub no longer // knows at all — no customer_configs row, no hosts row, no reports row — and, when apply is true, // de-references it with the rule above. apply=false changes nothing (the count shown to the operator // first, S6). A customer the hub still knows by ANY of the three is never touched. func (s *Store) SweepDeletedAppIssueRefs(apply bool) ([]AppIssueResidue, error) { ids := map[string]bool{} rows, err := s.db.Query(`SELECT COALESCE(affected_customers,'[]'), COALESCE(context_customer,'') FROM app_log_issues`) if err != nil { return nil, err } for rows.Next() { var affected, ctxCust string if err := rows.Scan(&affected, &ctxCust); err != nil { rows.Close() return nil, err } var list []string _ = json.Unmarshal([]byte(affected), &list) for _, c := range list { ids[c] = true } if ctxCust != "" { ids[ctxCust] = true } } rows.Close() var gone []string for id := range ids { if id == "" { continue } var n int if err := s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM customer_configs WHERE customer_id = ?) + (SELECT COUNT(*) FROM hosts WHERE customer_id = ?) + (SELECT COUNT(*) FROM reports WHERE customer_id = ?)`, id, id, id).Scan(&n); err != nil { return nil, fmt.Errorf("sweep: is %q known: %w", id, err) } if n == 0 { gone = append(gone, id) } } sort.Strings(gone) tx, err := s.db.Begin() if err != nil { return nil, err } defer tx.Rollback() var out []AppIssueResidue for _, id := range gone { if !apply { refs, err := appIssueRefsFor(tx, id) if err != nil { return nil, err } r := AppIssueResidue{CustomerID: id} for _, ref := range refs { if len(ref.remaining) == 0 { r.Deleted++ } else { r.Dereferenced++ } } out = append(out, r) continue } ch, del, err := dereferenceCustomerFromAppIssues(tx, id) if err != nil { return nil, fmt.Errorf("sweep %s: %w", id, err) } out = append(out, AppIssueResidue{CustomerID: id, Dereferenced: ch, Deleted: del}) } if !apply { return out, nil } return out, tx.Commit() }