Phase 7 follow-up: 1-month body retention and actions_log cap (AUD-75)

Implements the AGENTS.md §7 data rule: keep message bodies for one month, and
size-cap the append-only action log.

- internal/store/retention.go: Store.Retain clears messages.body_text older
  than the window (metadata stays) and deletes actions_log rows beyond the
  newest N, in one transaction.
- internal/config: EMAIL_AGENT_RETENTION_DAYS (30) and
  EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS (50000).
- cmd/email-agent: bounded -retain flag for the n8n schedule.
- Tests for body clearing, metadata retention, log cap, and disabled policy.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 10:45:16 +11:00
parent 55a08524b5
commit 17d605dfd5
9 changed files with 380 additions and 5 deletions

View File

@@ -0,0 +1,97 @@
// 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) {
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 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
}