// Retention for the data model in AGENTS.md §7: "keep message bodies for one // month. Logs are append-only and size-capped." // // Retention never touches message metadata. It only blanks `messages.body_text` // once a message is older than the window, and it trims the oldest rows from // the append-only `actions_log` so that log cannot grow without bound. It is a // bounded, explicit job (the CLI `-retain` flag), not something the HTTP server // runs on a request. package store import ( "context" "fmt" "time" ) // DefaultRetentionDays is the AGENTS.md §7 body window: one month. const DefaultRetentionDays = 30 // DefaultActionsLogMaxRows caps the append-only action log. The exact number is // a policy choice (see docs/retention.md); it is large enough to hold a long // history of triage actions and small enough that the SQLite file stays modest. const DefaultActionsLogMaxRows = 50000 // RetentionPolicy configures one retention pass. A non-positive field disables // that part of the pass, so a dry run can, for example, cap only the log. type RetentionPolicy struct { // BodyMaxAge is how long a message body is kept. Bodies of messages with a // received_at older than now-BodyMaxAge are set to NULL. Non-positive // disables body clearing. BodyMaxAge time.Duration // ActionsLogMaxRows is the number of newest actions_log rows to keep. Older // rows are deleted. Non-positive disables the cap. ActionsLogMaxRows int } // RetentionResult reports what one retention pass changed. type RetentionResult struct { // BodiesCleared is the number of messages whose body_text was blanked. BodiesCleared int64 // ActionsDeleted is the number of oldest actions_log rows removed. ActionsDeleted int64 // BodyCutoff is the received_at cutoff used for body clearing (UTC). It is // the zero time when body clearing is disabled. BodyCutoff time.Time } // Retain enforces the retention policy in a single transaction so a partial // pass never leaves the database in a mixed state. It is safe to run on a // schedule: every statement is idempotent. func (s *Store) Retain(ctx context.Context, p RetentionPolicy) (RetentionResult, error) { return s.RetainWithAudit(ctx, p, "", "") } // RetainWithAudit is Retain plus one actions_log row (FR-13), appended inside // the same transaction before the cap is applied so the audit row is itself // counted by the cap. An empty detail writes no audit row. This keeps the // action log bounded even though every run records that it ran. func (s *Store) RetainWithAudit(ctx context.Context, p RetentionPolicy, actor, detail string) (RetentionResult, error) { var res RetentionResult tx, err := s.db.BeginTx(ctx, nil) if err != nil { return res, fmt.Errorf("begin retention transaction: %w", err) } defer func() { _ = tx.Rollback() }() if p.BodyMaxAge > 0 { res.BodyCutoff = time.Now().UTC().Add(-p.BodyMaxAge) // received_at is stored in sqlTimeLayout (UTC), so compare with the // same format. body_text is already NULL or empty for a cleared row. r, err := tx.ExecContext(ctx, ` UPDATE messages SET body_text = NULL WHERE body_text IS NOT NULL AND body_text <> '' AND received_at < ?`, res.BodyCutoff.Format(sqlTimeLayout)) if err != nil { return res, fmt.Errorf("clear message bodies older than %s: %w", p.BodyMaxAge, err) } if res.BodiesCleared, err = r.RowsAffected(); err != nil { return res, fmt.Errorf("count cleared message bodies: %w", err) } } if detail != "" { if _, err := tx.ExecContext(ctx, ` INSERT INTO actions_log (message_id, action, actor, detail) VALUES (NULL, 'retain', ?, ?)`, nullString(actor), detail); err != nil { return res, fmt.Errorf("log retention action: %w", err) } } if p.ActionsLogMaxRows > 0 { // Keep the newest N rows and delete the rest. id is monotonic, so it // is the stable ordering for an append-only table. r, err := tx.ExecContext(ctx, ` DELETE FROM actions_log WHERE id NOT IN ( SELECT id FROM actions_log ORDER BY id DESC LIMIT ? )`, p.ActionsLogMaxRows) if err != nil { return res, fmt.Errorf("cap actions_log at %d rows: %w", p.ActionsLogMaxRows, err) } if res.ActionsDeleted, err = r.RowsAffected(); err != nil { return res, fmt.Errorf("count deleted action log rows: %w", err) } } if err := tx.Commit(); err != nil { return res, fmt.Errorf("commit retention: %w", err) } return res, nil }