# Data Retention

Anteroom retention is **off by default** in both its dimensions:

- **Conversation retention** (existing) — purges conversations older than a configured age.
- **Memory retention** (#625) — purges memory artifacts based on age, idle period, or status.

This aligns with Anteroom's data-sovereignty posture: nothing is deleted unless an operator opts in, documents the policy, and the install is configured for it. Every actual purge is written to the audit log (when audit is enabled) with the FQN and the reason — never content.

## The retention worker

Both retention dimensions are enforced by a single background loop, `RetentionWorker`, that runs one pass per cycle (default hourly). The worker is started by `app.py` when **either** gate is configured:

```yaml
storage:
  retention_days: 90           # conversation retention (existing)

memory:
  retention:
    enabled: true              # memory retention (#625)
    max_age_days: 180
```

Setting only the memory gate is supported — the conversation branch is explicitly skipped when `storage.retention_days` is `0`. A failure in one branch is logged and does not abort the other.

## Memory retention policy

The `memory.retention` config block:

| Field | Default | Description |
|---|---|---|
| `enabled` | `false` | Master switch. When false, zero memories are touched regardless of the other fields. |
| `max_age_days` | `null` | Purge memories older than N days (by `created_at`). `null` disables the rule. |
| `idle_days` | `null` | Purge memories not recalled in N days (by `metadata.last_recalled_at`, falling back to `created_at` when recall has never fired). `null` disables the rule. |
| `min_age_days` | `1` | Grace floor against `idle_days`. Memories younger than this are skipped regardless of idle time. Protects freshly-approved memories. |
| `purge_statuses` | `["rejected"]` | Statuses eligible for status-based eviction. Restrict / expand as needed (`archived`, `rejected`). |
| `respect_pins` | `true` | Skip memories with `metadata.pinned == true`. Flip to false to override (escape hatch). |

Environment-variable overrides use the `AI_CHAT_MEMORY_RETENTION_*` prefix — see [Environment Variables](../configuration/environment-variables.md).

### Reason codes

When a memory is eligible, the first matching rule determines the audit reason:

1. `max_age` — `max_age_days` rule matched first.
2. `idle` — past the idle threshold and the `min_age_days` grace floor.
3. `status` — the memory's status is in `purge_statuses`.

## Pinning

Any memory with `metadata.pinned == true` is load-bearing: the retention worker skips it by default. Flipping `memory.retention.respect_pins` to `false` overrides that. Mutate the pin flag from any surface:

=== "CLI"

    ```bash
    aroom memory pin @user/memory/my-note
    aroom memory unpin @user/memory/my-note
    ```

=== "REPL"

    ```
    /memory pin @user/memory/my-note
    /memory unpin @user/memory/my-note
    ```

=== "Web UI"

    Toggle the pin icon on any memory row in the memory panel. The Retention tab marks pinned items with a "pinned" badge.

=== "API"

    ```bash
    curl -X POST http://127.0.0.1:8080/api/memory/@user/memory/my-note/pin
    curl -X POST http://127.0.0.1:8080/api/memory/@user/memory/my-note/unpin
    ```

## Preview before you enable

Every install gets a dry-run endpoint + CLI subcommand + REPL slash command that lists the would-be-purged set **without** mutating anything or writing to the audit log.

=== "CLI"

    ```bash
    aroom memory retention preview
    ```

=== "REPL"

    ```
    /memory retention preview
    ```

=== "Web UI"

    Open the memory panel, switch to the "Retention" tab.

=== "API"

    ```bash
    curl http://127.0.0.1:8080/api/memory/retention-preview
    ```

The preview response shape (also the body of the governed purge endpoint):

```json
{
  "dry_run": true,
  "purged_count": 2,
  "skipped_pinned_count": 1,
  "items": [
    {
      "fqn": "@user/memory/old-note",
      "reason": "max_age",
      "age_days": 200,
      "last_recalled_at": null,
      "recall_count": 0,
      "status": "active",
      "pinned": false
    }
  ]
}
```

## Running a purge on demand

Two governed paths trigger a real purge (the worker already runs one per cycle when enabled):

=== "CLI"

    ```bash
    aroom memory retention purge --confirm
    ```

=== "REPL"

    ```
    /memory retention purge --confirm
    ```

=== "Web UI"

    Retention tab → "Run purge now" button → confirm in the modal.

=== "API"

    ```bash
    curl -X POST http://127.0.0.1:8080/api/memory/retention-purge \
      -H "Content-Type: application/json" \
      -d '{"confirm": true}'
    ```

Two gates stack on every on-demand purge:

1. **`confirm: true` body flag** (or `--confirm` on CLI) — prevents an accidental POST from deleting memories.
2. **Reviewer identity** — the HTTP endpoint resolves the reviewer via the shared `_reviewer_id_from_request` helper (the same identity function used by the `#920` approve / reject endpoints). The reviewer id is written into the `memory.purge` audit entry's `user_id` field and into `details.reviewer_id`, and echoed back in the response body under `purged_by` so the web UI can display who ran the pass. The scheduled worker leaves the reviewer id unset and its audit entries record `details.triggered_by="scheduler"`.

Dry-run preview is always available first and is not gated beyond the standard auth + CSRF stack — it's informational.

## Audit log

When `config.audit.enabled` is true, every purge emits one `memory.purge` entry via the existing [audit log](audit-log.md) tamper chain. Fields:

| Field | Description |
|---|---|
| `event_type` | Always `memory.purge`. |
| `user_id` | Reviewer identity when the purge was triggered on-demand; empty string when the scheduled worker ran the pass. |
| `details.fqn` | The memory's fully-qualified name. |
| `details.reason` | One of `max_age`, `idle`, `status`. |
| `details.age_days` | Integer days since `created_at`. |
| `details.last_recalled_at` | ISO timestamp or `null` when never recalled. |
| `details.recall_count` | Integer recall count. |
| `details.status` | The status at purge time. |
| `details.pinned` | Boolean. When `true`, `respect_pins` must be `false` for the purge to have happened. |
| `details.reviewer_id` | Reviewer identity string for on-demand purges; `null` for the scheduled worker. |
| `details.triggered_by` | `"reviewer"` for on-demand purges; `"scheduler"` for the background worker. |

**Content is never logged** — the entry only captures structural metadata. The dry-run preview path deliberately does **not** emit audit entries; preview must stay off the tamper chain.

## Guarantees

1. **Default off.** Zero-config installs never evict memories.
2. **Pin is load-bearing.** Pinned memories survive unless `respect_pins=false`.
3. **Pack-sourced memories are never evicted.** Artifacts with `source != "local"` are skipped because they'd re-install on the next pack sync.
4. **Dry-run never mutates.** Preview paths never delete and never emit audit entries.
5. **`--confirm` is required.** Governed purge endpoints refuse without an explicit confirm flag.
6. **Audit every real purge.** When audit is enabled, every deletion leaves a tamper-chained trail.
7. **Grace floor for new memories.** `min_age_days` prevents an `idle_days` rule from immediately evicting freshly-approved memories.

## See also

- [Audit Log](audit-log.md) — HMAC-chained event types including `memory.purge`.
- [Managing Memory](../knowledge/managing-memory.md) — CRUD + pin toggle in the UI.
- [Memory Promotion](../knowledge/memory-promotion.md) — the review pipeline that produces reviewable candidates before they reach this retention policy.
- [Environment Variables](../configuration/environment-variables.md) — `AI_CHAT_MEMORY_RETENTION_*` overrides.
