package store import ( "database/sql" "time" ) // ── The household's deliberate power-off (R-998, documentation/architecture/14-host-power-off.md) ────── // // The agent reports `deliberate_off.requested_at` from the moment the household pressed „A gép kikapcsolása" // until the box goes down; the next boot's reports do not carry it. The hub keeps the time on the host row. // While it is set and younger than DeliberateOffHold, every silence alarm about that box is held: host and // controller staleness, the backup deadlines, off-site and restore-test freshness, the OS alarms and the // kernel notice. After the hold, the alarms fire as usual — a box nobody switched back on in two weeks is // worth a look. Pinned by monitor/deliberate_off_test.go, api/deliberate_off_test.go and web/deliberate_off_view_test.go. // DeliberateOffHold is how long a deliberate power-off silences the box's alarms (operator default, 14 days). const DeliberateOffHold = 14 * 24 * time.Hour // DeliberateOffHeld reports whether a deliberate-off time still holds the alarms at now. func DeliberateOffHeld(at *time.Time, now time.Time) bool { return at != nil && !at.IsZero() && now.Sub(*at) < DeliberateOffHold } func parseDeliberateOff(v string) *time.Time { if v == "" { return nil } t, err := time.Parse(time.RFC3339, v) if err != nil { return nil } return &t } // SetHostDeliberateOff records the report's deliberate-off field. at == nil clears it (a report without the // field: the box is in a new boot). It returns the previous value, so the caller can emit an event on a change. func (s *Store) SetHostDeliberateOff(hostID string, at *time.Time) (prev *time.Time, err error) { var old sql.NullString if err := s.db.QueryRow(`SELECT deliberate_off_at FROM hosts WHERE host_id = ?`, hostID).Scan(&old); err != nil { return nil, err } prev = parseDeliberateOff(old.String) var val any if at != nil { val = at.UTC().Format(time.RFC3339) } if (prev == nil && at == nil) || (prev != nil && at != nil && prev.Equal(*at)) { return prev, nil } _, err = s.db.Exec(`UPDATE hosts SET deliberate_off_at = ?, updated_at = datetime('now') WHERE host_id = ?`, val, hostID) return prev, err } // HostDeliberateOffAt returns the host's deliberate-off time (nil = none). func (s *Store) HostDeliberateOffAt(hostID string) *time.Time { var v sql.NullString if err := s.db.QueryRow(`SELECT deliberate_off_at FROM hosts WHERE host_id = ?`, hostID).Scan(&v); err != nil { return nil } return parseDeliberateOff(v.String) } // CustomerDeliberatelyOff reports whether the customer is switched off by the household and still within the hold // at now — the checkers keyed by customer use it. EVERY host of the customer must be held (security review // 2026-10-13): one switched-off machine must not silence the alarms of the customer's other machine. A read error // or a customer with no host is NOT held (fail closed: the alarm fires). The time is the newest request. func (s *Store) CustomerDeliberatelyOff(customerID string, now time.Time) (time.Time, bool) { rows, err := s.db.Query(`SELECT COALESCE(deliberate_off_at, '') FROM hosts WHERE customer_id = ?`, customerID) if err != nil { return time.Time{}, false } defer rows.Close() var newest time.Time n := 0 for rows.Next() { var v string if rows.Scan(&v) != nil { return time.Time{}, false } n++ t := parseDeliberateOff(v) if !DeliberateOffHeld(t, now) { return time.Time{}, false } if t.After(newest) { newest = *t } } if rows.Err() != nil || n == 0 { return time.Time{}, false } return newest, true }