package store import ( "context" "database/sql" "errors" "fmt" "time" ) // Nudge — one proactive send, with deferred outcome. outcomes are: pending | // acted | snoozed | ignored. outcome_ts set when resolved. the outcome column // IS the restraint-memory signal — no separate table for the feedback loop. type Nudge struct { ID int64 Ts time.Time // sent ts Rule string Channel string Message string Outcome string OutcomeTs sql.NullInt64 } const ( NudgePending = "pending" NudgeActed = "acted" NudgeSnoozed = "snoozed" NudgeIgnored = "ignored" ) // SnoozeDuration — how long one `snoozed` outcome keeps its rule quiet. // // The nudges table records THAT a snooze happened and when, never for how // long: nothing upstream can supply a length. ResolveNudge takes only // (id, outcome, ts), and so do the IPC method and the web/telegram callers // behind it. So a fixed default it is, rather than a new column no writer // could fill. // // Two hours: longer than every rule's base cooldown (15–60m) so a snooze // actually buys quiet instead of being swallowed by the cooldown, and short // enough that a snooze the operator forgets about clears the same day. A // snooze can never outlive this window, so Maven cannot go quiet forever. const SnoozeDuration = 2 * time.Hour var ( ErrNudgeNotFound = errors.New("store: nudge not found") ErrNudgeOutcome = errors.New("store: nudge already resolved") ) // RecordNudge inserts a pending nudge (sent). returns the id. the loop writes // one row per proactive send per tick (one nudge per tick, max severity). func (s *Store) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { res, err := s.db.ExecContext(ctx, `INSERT INTO nudges (ts, rule, channel, message, outcome) VALUES (?,?,?,?, 'pending')`, ts.UnixMilli(), rule, channel, message) if err != nil { return 0, fmt.Errorf("record nudge: %w", err) } id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("record nudge: last insert id: %w", err) } return id, nil } // ResolveNudge sets the outcome of a still-pending nudge. acted | snoozed | // ignored — caller decides by user response (or lack of it). idempotency is // rejected here: a nudge can only be resolved once, by design — re-resolving // would silently corrupt the feedback signal (the table IS the learning input). func (s *Store) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { switch outcome { case NudgeActed, NudgeSnoozed, NudgeIgnored: default: return fmt.Errorf("store: unknown outcome %q", outcome) } res, err := s.db.ExecContext(ctx, `UPDATE nudges SET outcome = ?, outcome_ts = ? WHERE id = ? AND outcome = 'pending'`, outcome, ts.UnixMilli(), id) if err != nil { return fmt.Errorf("resolve nudge: %w", err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("resolve nudge: rows affected: %w", err) } if n == 0 { // either no such row, or it was already resolved — distinguish so callers // can tell a bug from a race. var cur string err := s.db.QueryRowContext(ctx, "SELECT outcome FROM nudges WHERE id = ?", id).Scan(&cur) if errors.Is(err, sql.ErrNoRows) { return ErrNudgeNotFound } if err != nil { return err } return fmt.Errorf("%w: currently %s", ErrNudgeOutcome, cur) } return nil } // RecentOutcomes returns the last N outcomes (in reverse chronological order) // for a given rule — the feedback loop's only input. used to compute // ignored_rate → cooldown sizing. dead simple at mvp: a ratio over last N. func (s *Store) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT outcome FROM nudges WHERE rule = ? AND outcome != 'pending' ORDER BY ts DESC, id DESC LIMIT ?`, rule, n) if err != nil { return nil, fmt.Errorf("recent outcomes: %w", err) } defer rows.Close() var out []string for rows.Next() { var o string if err := rows.Scan(&o); err != nil { return nil, err } out = append(out, o) } return out, rows.Err() } // UnackedTelegramRules returns rule names that have at least one still-pending // (un-acked) nudge sent over the telegram channel. the dispatcher's // RepeatUnacked re-sends these each tick until MarkAcked. // // telegram is the sev4-away channel by routing-table construction // (ChannelsFor sends sev4 away → telegram, nothing else to telegram), so // `channel='telegram' AND outcome='pending'` already implies sev4 ops-hard — // no severity column on the nudges table, and none needed: the routing table // is the authority. grouped by rule so the repeat stream is one-per-rule // (the ack key IS the rule name). func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT rule FROM nudges WHERE channel = 'telegram' AND outcome = 'pending' GROUP BY rule ORDER BY rule`) if err != nil { return nil, fmt.Errorf("unacked telegram rules: %w", err) } defer rows.Close() var out []string for rows.Next() { var r string if err := rows.Scan(&r); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // SnoozedUntil — per rule, when its most recent snooze runs out. This is the // read behind the gate's snooze check: the `snoozed` outcome already in the // nudges table IS the restraint memory, so there is no snooze table. // // Rules with no live snooze are absent from the map, which is what the gate // wants (a missing key means "not snoozed"). Expired snoozes are filtered out // in SQL, so an old snooze can never come back as a silent forever-mute. // // Called every tick (~60s). One indexed lookup over the snoozed rows only. func (s *Store) SnoozedUntil(ctx context.Context, now time.Time) (map[string]time.Time, error) { cutoff := now.Add(-SnoozeDuration).UnixMilli() rows, err := s.db.QueryContext(ctx, `SELECT rule, MAX(outcome_ts) FROM nudges WHERE outcome = 'snoozed' AND outcome_ts > ? GROUP BY rule`, cutoff) if err != nil { return nil, fmt.Errorf("snoozed until: %w", err) } defer rows.Close() out := make(map[string]time.Time) for rows.Next() { var rule string var tsMilli int64 if err := rows.Scan(&rule, &tsMilli); err != nil { return nil, err } out[rule] = time.UnixMilli(tsMilli).UTC().Add(SnoozeDuration) } return out, rows.Err() } // RecentNudges — the newest n nudges across all rules, with outcomes, for the // monitoring dash. Newest first. func (s *Store) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, ts, rule, channel, message, outcome, outcome_ts FROM nudges ORDER BY ts DESC, id DESC LIMIT ?`, n) if err != nil { return nil, fmt.Errorf("recent nudges: %w", err) } defer rows.Close() var out []Nudge for rows.Next() { var ng Nudge var tsMilli int64 if err := rows.Scan(&ng.ID, &tsMilli, &ng.Rule, &ng.Channel, &ng.Message, &ng.Outcome, &ng.OutcomeTs); err != nil { return nil, err } ng.Ts = time.UnixMilli(tsMilli).UTC() out = append(out, ng) } return out, rows.Err() } // LastNudge — newest nudge for a rule regardless of outcome. used by the gate // for cooldown enforcement. returns ErrNudgeNotFound if the rule has never fired. func (s *Store) LastNudge(ctx context.Context, rule string) (Nudge, error) { row := s.db.QueryRowContext(ctx, `SELECT id, ts, rule, channel, message, outcome, outcome_ts FROM nudges WHERE rule = ? ORDER BY ts DESC, id DESC LIMIT 1`, rule) var n Nudge var tsMilli int64 var outcomeTs sql.NullInt64 if err := row.Scan(&n.ID, &tsMilli, &n.Rule, &n.Channel, &n.Message, &n.Outcome, &outcomeTs); err != nil { if errors.Is(err, sql.ErrNoRows) { return Nudge{}, ErrNudgeNotFound } return Nudge{}, err } n.Ts = time.UnixMilli(tsMilli).UTC() if outcomeTs.Valid { n.OutcomeTs = outcomeTs } return n, nil }