package store import ( "database/sql" "fmt" "sort" "strconv" "time" ) // Operator actions (R-314 / R-279 / R-177, `09` §3 decision 185 — D1, design option A of // documentation/audits/day-2026-10-08/design-R-314-279-177.md). // // The operator presses a button on the host page; a row is stored here and the box's intent generation // is bumped, so its wait channel wakes; the report ACK lists every pending row as // `operator_actions: [{id, action, arg}]`; the controller acts once per id and sends // `operator_action_results: [{id, outcome, message}]` on its next report; the row is closed and stops // being listed. The hub never connects into the box. // // THE LIST IS CLOSED, and the hub refuses an unknown action, job or argument BEFORE storing anything — // the controller refuses them again on its side. Nothing on the list deletes data, starts a countdown or // shortens one; `03` §4 asks for a signing key only to destroy or overwrite the only copy. The list is // pinned on both sides (TestOperatorActions_ClosedList here, TestOpActions_ClosedList in the controller) // so a new entry is an operator decision, not an edit. const ( OperatorActionOffsiteBackupNow = "offsite_backup_now" OperatorActionAbandonStop = "abandon_stop" OperatorActionAbandonExtend = "abandon_extend" OperatorActionRunJob = "run_job" // Outcomes: the box's three, plus two the hub sets itself. OperatorActionDone = "done" OperatorActionRefused = "refused" OperatorActionFailed = "failed" OperatorActionExpired = "expired" // the box did not answer within OperatorActionTTL OperatorActionCancelled = "cancelled" // the customer was RESET while it was pending OperatorActionExtendMinDays = 1 OperatorActionExtendMaxDays = 30 // OperatorActionTTL: a pending action the box has not answered in a day is closed as expired and // no longer listed — an off-site run pressed for a box that was off for a week must not start the // moment it comes back, unasked. OperatorActionTTL = 24 * time.Hour // operatorActionMessageMax bounds the box's message stored per row. operatorActionMessageMax = 500 // operatorActionsListed caps how many pending rows one ACK carries. operatorActionsListed = 10 ) var operatorActionNames = []string{OperatorActionOffsiteBackupNow, OperatorActionAbandonStop, OperatorActionAbandonExtend, OperatorActionRunJob} // operatorJobNames is the fixed set run_job may name — the controller's scheduler job names. var operatorJobNames = []string{"fill-watch", "offsite-integrity", "offsite-proof", "disk-health-check"} // OperatorActionNames returns the closed action list (sorted copy). func OperatorActionNames() []string { return sortedStrings(operatorActionNames) } // OperatorJobNames returns the fixed run_job set (sorted copy). func OperatorJobNames() []string { return sortedStrings(operatorJobNames) } func sortedStrings(in []string) []string { out := append([]string(nil), in...) sort.Strings(out) return out } func inList(list []string, v string) bool { for _, x := range list { if x == v { return true } } return false } // ValidateOperatorAction refuses anything outside the closed list. A nil error is the only way a row // is stored. func ValidateOperatorAction(action, arg string) error { switch action { case OperatorActionOffsiteBackupNow, OperatorActionAbandonStop: if arg != "" { return fmt.Errorf("%s takes no argument", action) } case OperatorActionAbandonExtend: d, err := strconv.Atoi(arg) if err != nil || d < OperatorActionExtendMinDays || d > OperatorActionExtendMaxDays { return fmt.Errorf("the number of days must be %d-%d", OperatorActionExtendMinDays, OperatorActionExtendMaxDays) } case OperatorActionRunJob: if !inList(operatorJobNames, arg) { return fmt.Errorf("unknown job %q", arg) } default: return fmt.Errorf("unknown action %q", action) } return nil } // OperatorActionDirective is ONE entry of the report ACK's operator_actions list — the wire type, // named so scripts/wire_contract_gate.py can check it field by field against the controller's // report.OperatorAction. type OperatorActionDirective struct { ID int64 `json:"id"` Action string `json:"action"` Arg string `json:"arg,omitempty"` } // OperatorActionRow is one stored row, for the host page. type OperatorActionRow struct { ID int64 CustomerID string Action string Arg string RequestedAt time.Time RequestedBy string DoneAt *time.Time Outcome string Message string } func (s *Store) migrateOperatorActions() error { _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS operator_actions ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id TEXT NOT NULL, action TEXT NOT NULL, arg TEXT NOT NULL DEFAULT '', requested_at DATETIME NOT NULL, requested_by TEXT NOT NULL DEFAULT '', done_at DATETIME, outcome TEXT NOT NULL DEFAULT '', message TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_operator_actions_customer ON operator_actions(customer_id, done_at);`) return err } // CreateOperatorAction validates against the closed list and stores a pending row. by names who // pressed (the operator's channel and address — never a credential). func (s *Store) CreateOperatorAction(customerID, action, arg, by string) (int64, error) { if customerID == "" { return 0, fmt.Errorf("operator action: empty customer id") } if err := ValidateOperatorAction(action, arg); err != nil { return 0, err } res, err := s.db.Exec(`INSERT INTO operator_actions (customer_id, action, arg, requested_at, requested_by) VALUES (?, ?, ?, ?, ?)`, customerID, action, arg, time.Now().UTC(), by) if err != nil { return 0, err } return res.LastInsertId() } // PendingOperatorActions returns what the next ACK lists for this customer, oldest first. Rows older // than OperatorActionTTL are closed as expired first and are not listed. func (s *Store) PendingOperatorActions(customerID string) ([]OperatorActionDirective, error) { now := time.Now().UTC() if _, err := s.db.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ? WHERE customer_id = ? AND done_at IS NULL AND requested_at < ?`, now, OperatorActionExpired, "the box did not answer within a day", customerID, now.Add(-OperatorActionTTL)); err != nil { return nil, err } rows, err := s.db.Query(`SELECT id, action, arg FROM operator_actions WHERE customer_id = ? AND done_at IS NULL ORDER BY id LIMIT ?`, customerID, operatorActionsListed) if err != nil { return nil, err } defer rows.Close() var out []OperatorActionDirective for rows.Next() { var d OperatorActionDirective if err := rows.Scan(&d.ID, &d.Action, &d.Arg); err != nil { return nil, err } out = append(out, d) } return out, rows.Err() } // RecordOperatorActionResult closes a pending row with the box's result. It matches on BOTH the id and // the reporting customer, so a result naming another customer's action changes nothing (recorded=false). // An outcome outside the box's three is refused. Returns the closed row when recorded. func (s *Store) RecordOperatorActionResult(customerID string, id int64, outcome, message string) (*OperatorActionRow, error) { switch outcome { case OperatorActionDone, OperatorActionRefused, OperatorActionFailed: default: return nil, fmt.Errorf("unknown outcome %q", outcome) } if r := []rune(message); len(r) > operatorActionMessageMax { message = string(r[:operatorActionMessageMax]) } res, err := s.db.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ? WHERE id = ? AND customer_id = ? AND done_at IS NULL`, time.Now().UTC(), outcome, message, id, customerID) if err != nil { return nil, err } if n, _ := res.RowsAffected(); n == 0 { return nil, nil } return s.getOperatorAction(id) } func (s *Store) getOperatorAction(id int64) (*OperatorActionRow, error) { rows, err := s.db.Query(`SELECT id, customer_id, action, arg, requested_at, requested_by, done_at, outcome, message FROM operator_actions WHERE id = ?`, id) if err != nil { return nil, err } defer rows.Close() list, err := scanOperatorActions(rows) if err != nil || len(list) == 0 { return nil, err } return &list[0], nil } // ListOperatorActions returns the customer's newest rows (pending and closed), newest first. func (s *Store) ListOperatorActions(customerID string, limit int) ([]OperatorActionRow, error) { rows, err := s.db.Query(`SELECT id, customer_id, action, arg, requested_at, requested_by, done_at, outcome, message FROM operator_actions WHERE customer_id = ? ORDER BY id DESC LIMIT ?`, customerID, limit) if err != nil { return nil, err } defer rows.Close() return scanOperatorActions(rows) } func scanOperatorActions(rows *sql.Rows) ([]OperatorActionRow, error) { var out []OperatorActionRow for rows.Next() { var r OperatorActionRow var done sql.NullTime if err := rows.Scan(&r.ID, &r.CustomerID, &r.Action, &r.Arg, &r.RequestedAt, &r.RequestedBy, &done, &r.Outcome, &r.Message); err != nil { return nil, err } if done.Valid { t := done.Time r.DoneAt = &t } out = append(out, r) } return out, rows.Err() }