docs/retention.md covers the 30-day body window, the actions_log row cap, the
audit-row ordering, configuration, unit + deployed verification, deployment on
.13 (commit 453d8a2), scheduling, and rollback. Phase 7's deferred note now
points at the completed work.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
178 lines
8.1 KiB
Markdown
178 lines
8.1 KiB
Markdown
# Retention — one month of bodies, a capped action log
|
|
|
|
Status: **complete.** Built, tested, and deployed on `.13` (own container,
|
|
port 8790). This closes the AGENTS.md §7 data-model rule that was still
|
|
outstanding after Phase 7 ([AUD-73](/AUD/issues/AUD-73)). Parent
|
|
[AUD-68](/AUD/issues/AUD-68). Code commits `17d605d`…`d9f8d34`.
|
|
|
|
**DoD:** a stored message older than 30 days has its body cleared and its
|
|
metadata stays; `actions_log` is size-capped; tests green; deployed on `.13`.
|
|
— **met** (§4, §5).
|
|
|
|
## 1. What was built
|
|
|
|
AGENTS.md §7 says:
|
|
|
|
> Retention: keep message bodies for **one month**. Logs are append-only and
|
|
> size-capped.
|
|
|
|
The bounded `-retain` job enforces both halves.
|
|
|
|
| Piece | Where | Behaviour |
|
|
|---|---|---|
|
|
| `Store.Retain` / `Store.RetainWithAudit` | `internal/store/retention.go` | One transaction. Blanks `messages.body_text` for messages received before the cutoff, deletes the oldest `actions_log` rows beyond the cap. |
|
|
| Config | `internal/config/config.go` | `EMAIL_AGENT_RETENTION_DAYS` (default 30), `EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS` (default 50000). |
|
|
| CLI | `cmd/email-agent/main.go` | `-retain` opens the store, runs one pass, prints a one-line result, exits. |
|
|
| Tests | `internal/store/retention_test.go`, `internal/config/config_test.go` | Body clearing, metadata retention, log cap (with and without the audit row), disabled policy, config defaults/overrides. |
|
|
|
|
## 2. Decisions
|
|
|
|
1. **The window is 30 days from `received_at`.** `received_at` is the time the
|
|
service stored the message (`internal/mailfetch` sets it during the IMAP
|
|
scan), which is the same clock the rest of the app uses. It is not the
|
|
`Date` header, which a sender controls.
|
|
2. **Clear the body, keep the metadata.** `body_text` is set to `NULL`. Sender,
|
|
recipient, subject, source address (`X-Pm-Original-To`), classification,
|
|
folder, Gmail label, and all timestamps stay. A cleared body is much smaller
|
|
than a deleted row and keeps the inbox history truthful. The UI already
|
|
renders an empty body (`COALESCE(body_text, '')`), so nothing else changes.
|
|
3. **`NULL`, not `''`.** The update predicate is `body_text IS NOT NULL AND
|
|
body_text <> ''`, so a second pass is a true no-op and never re-counts a
|
|
cleared row. The test asserts this.
|
|
4. **The cap is a row count, not an age.** AGENTS.md says "size-capped", and the
|
|
log is append-only, so the job keeps the newest `N` rows by monotonic `id`
|
|
and deletes the rest. The default `N=50000` holds a long triage history
|
|
while keeping the SQLite file modest.
|
|
5. **Retention is logged, and the audit row counts against the cap.** The job
|
|
writes one `retain` row to `actions_log` (FR-13: every action is logged).
|
|
That row is written **inside the same transaction and before the cap**, so
|
|
after a pass `actions_log` holds at most `N` rows *including* the audit row.
|
|
Without that ordering the table would sit at `N+1` between runs.
|
|
6. **One transaction.** Body clearing and the cap commit together, so a partial
|
|
pass never leaves the database mixed. Both statements are idempotent.
|
|
7. **Bounded and explicit.** The job is a CLI flag, like `-fetch` and
|
|
`-classify`. The HTTP server never runs retention on a request, so no page
|
|
load can start a slow write.
|
|
8. **Re-fetch is harmless.** If an old message is re-fetched inside the lookback
|
|
window, `UpsertMessage` restores its body; the next retention pass clears it
|
|
again. The stored metadata is never lost.
|
|
|
|
## 3. Configuration
|
|
|
|
| Variable | Default | Purpose |
|
|
|---|---|---|
|
|
| `EMAIL_AGENT_RETENTION_DAYS` | `30` | Body window in days. |
|
|
| `EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS` | `50000` | Newest `actions_log` rows kept. |
|
|
|
|
Both are passed through by `docker-compose.yml` and listed in `.env.example`.
|
|
A non-positive value disables that half of the pass, which is useful for a
|
|
diagnostic run.
|
|
|
|
## 4. Verification
|
|
|
|
### 4.1 Unit tests (green)
|
|
|
|
`go test ./internal/store/ ./internal/config/` — all pass. The retention tests:
|
|
|
|
- `TestRetainClearsOldBodiesKeepsMetadata` — a 45-day-old body becomes `NULL`;
|
|
a 2-day-old body is untouched; `MessageByID` still returns the subject and
|
|
source address; a second pass clears 0.
|
|
- `TestRetainCapsActionsLog` — 5 rows with a cap of 2 leaves the 2 newest; a
|
|
smaller table is untouched.
|
|
- `TestRetainWithAuditCountsItsOwnRow` — 5 rows + 1 audit with a cap of 3 leaves
|
|
exactly 3 rows, newest is the `retain` row.
|
|
- `TestRetainDisabledPolicyIsNoOp` — a zero policy changes nothing.
|
|
- `TestRetentionDefaultsAndOverrides` — config defaults and overrides.
|
|
|
|
### 4.2 Deployed binary, seeded scratch database
|
|
|
|
Run with the deployed image (`email-agent:latest`, commit `d9f8d34`) against a
|
|
scratch database that held one 45-day-old and one 2-day-old message and 10
|
|
action rows, with the cap set to 5:
|
|
|
|
```
|
|
$ email-agent -retain # EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS=5
|
|
bodies_cleared=1 body_cutoff=2026-09-12 actions_deleted=7 actions_max_rows=5
|
|
|
|
# message rows afterwards
|
|
message: ('retention-fresh', 'Fresh mail', 'fresh body content', 'hello@where-woof.com')
|
|
message: ('retention-old', 'Old forwarded mail', NULL, 'hello@where-woof.com')
|
|
|
|
actions_log rows after cap: 5
|
|
newest action: ('retain', 'retention_days=30 max_rows=5 bodies_cleared=1', 'scheduler')
|
|
```
|
|
|
|
The old body is `NULL`; its subject and source address are intact; the fresh
|
|
body is untouched; `actions_log` is exactly at the cap, including the audit row.
|
|
|
|
### 4.3 Live deployment, production database
|
|
|
|
```
|
|
$ docker exec email-agent email-agent -retain
|
|
bodies_cleared=0 body_cutoff=2026-09-12 actions_deleted=0 actions_max_rows=50000
|
|
```
|
|
|
|
`bodies_cleared=0` is correct: the oldest stored message was received on
|
|
2026-10-11, well inside the 30-day window. The same command was run twice and
|
|
the database is unchanged, which shows the job is idempotent. The live database
|
|
holds 103 messages (102 with a body) and one `retain` audit row per run:
|
|
|
|
```
|
|
audit: ('2026-10-12 23:44:28', 'scheduler', 'retention_days=30 max_rows=50000 bodies_cleared=0')
|
|
schema_version: 6
|
|
```
|
|
|
|
No schema change was needed; retention operates on existing columns, so the
|
|
live database stayed at `schema_version: 6`.
|
|
|
|
## 5. Deployment
|
|
|
|
On `.13`, from the repo, with the host secrets in scope:
|
|
|
|
```sh
|
|
set -a; . ~/.config/environment.d/10-secrets.conf; set +a
|
|
EMAIL_AGENT_VERSION=retention EMAIL_AGENT_COMMIT=$(git rev-parse --short HEAD) \
|
|
docker compose build
|
|
docker compose up -d
|
|
```
|
|
|
|
`https://mail.lab.audasmedia.com.au/health` → `200`, reporting
|
|
`version: retention`, `commit: d9f8d34`. The container is the project's own
|
|
`email-agent` on port `8790`; no other service changed.
|
|
|
|
## 6. Scheduling
|
|
|
|
The job is a bounded one-shot, so the n8n schedule (FR-1) can call it the same
|
|
way it calls `-fetch` and `-classify`, for example once a day:
|
|
|
|
```sh
|
|
docker exec email-agent email-agent -retain
|
|
```
|
|
|
|
No n8n workflow was added in this change. The build spec's own wording allows
|
|
either an n8n step **or** a bounded CLI flag ("Run it on the existing n8n
|
|
schedule, or add a bounded CLI flag"), and the flag is the deployable unit. Note
|
|
that no `email_agent` workflow exists in n8n yet, so wiring the schedule is a
|
|
separate task that touches the shared n8n service.
|
|
|
|
## 7. Rollback
|
|
|
|
1. `git revert <docs-commit> d9f8d34 3766ce1 17d605d && docker compose up -d --build`.
|
|
2. No schema change to undo. Bodies already cleared are not recoverable from the
|
|
database; a later `-fetch` restores the body of any message still inside the
|
|
IMAP lookback window.
|
|
3. The action-log cap only deletes rows; there is no undo for a trimmed log.
|
|
|
|
## 8. Not done / notes
|
|
|
|
- **Reclassification after a clear.** A cleared body is unavailable to
|
|
`-classify-all` and to the LLM reply draft. That is the intended trade-off of
|
|
the one-month rule; the message keeps its existing classification. Run
|
|
classification before retention if a full reclassification is needed.
|
|
- **The n8n schedule is not wired** (§6). This is a shared-service change and
|
|
was not made here.
|
|
- **The audit row detail** records `retention_days`, `max_rows`, and
|
|
`bodies_cleared`; `actions_deleted` is not in the detail because the row is
|
|
written before the cap runs. The CLI output and the `actions_log` count show
|
|
the deleted number.
|