# Memory API

Endpoints for managing [memory artifacts](../knowledge/managing-memory.md) and the [promotion & review pipeline](../knowledge/memory-promotion.md).

## Endpoints

### Management (#1416)

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/memory` | List memories with optional filters |
| `GET` | `/api/memory/{fqn}` | Fetch a single memory by FQN |
| `POST` | `/api/memory` | Create a memory (skips the review queue — use the candidate endpoint instead for governed flows) |
| `PATCH` | `/api/memory/{fqn}` | Update content and/or allowed metadata |
| `DELETE` | `/api/memory/{fqn}` | Delete a memory |

### Promotion & Review (#920)

| Method | Endpoint | Description |
|---|---|---|
| `POST` | `/api/memory/candidates` | Propose a new candidate (user or agent). Rate-limited per conversation. |
| `GET` | `/api/memory/candidates` | List the review queue (defaults to `status=candidate`; also accepts `?status=pending_review`). |
| `POST` | `/api/memory/{fqn}/approve` | Approve a candidate; stamps `reviewed_by` / `reviewed_at` and a lineage entry. Optional `edits` body applies content / category overrides before approval. |
| `POST` | `/api/memory/{fqn}/edit-and-approve` | Alias for approve that **requires** an `edits` body. Fails 400 when missing. |
| `POST` | `/api/memory/{fqn}/reject` | Reject a candidate. Requires a non-empty `reason` (max 2000 chars). |

### Retention & Pinning (#625)

| Method | Endpoint | Description |
|---|---|---|
| `POST` | `/api/memory/{fqn}/pin` | Pin a memory so the retention worker skips it by default. |
| `POST` | `/api/memory/{fqn}/unpin` | Remove the pin flag from a memory. |
| `GET` | `/api/memory/retention-preview` | Dry-run the current retention policy. Returns the would-be-purged set; never mutates; never writes to the audit log. |
| `POST` | `/api/memory/retention-purge` | Governed trigger for a retention pass. Requires `{"confirm": true}` body; reviewer identity is resolved via `_reviewer_id_from_request` and stamped on each `memory.purge` audit entry (`user_id` + `details.reviewer_id`). Response echoes it back as `purged_by`. |

All endpoints require the normal session cookie + CSRF token that the web UI sets on first load (`anteroom_session`, `anteroom_csrf`).

## List memories

```
GET /api/memory?scope=user&status=active
```

Query parameters (all optional):

| Name | Type | Notes |
|---|---|---|
| `scope` | string | One of `user`, `project`, `local` |
| `status` | string | One of `active`, `candidate`, `pending_review`, `rejected`, `archived` |
| `category` | string | One of `preference`, `project_fact`, `decision`, `workflow_hint` |
| `namespace` | string | Exact namespace match |

Returns up to **500** memories ordered by `updated_at DESC`. Pagination is intentionally deferred — track follow-up work in a new issue if memory volumes grow.

Invalid query values return `400`; they do not silently fall back to no filter.

## Get one

```
GET /api/memory/@user/memory/dark-mode
```

`404` if the FQN does not resolve to a memory artifact. Non-memory artifacts with the same FQN shape are intentionally not returned — the service layer refuses to cross the type boundary.

The FQN path component must match `^@[a-z0-9][a-z0-9._-]{0,62}/memory/[a-z0-9][a-z0-9._-]{0,62}$`. Malformed FQNs (including traversal attempts) return `400`.

## Create

```
POST /api/memory
Content-Type: application/json

{
  "content": "prefers dark mode",
  "scope": "user",
  "category": "preference",
  "name": "dark-mode",          // optional; auto-slug if omitted
  "project_slug": null,          // required when scope is "project"
  "status": "active",            // defaults to "active"
  "created_by": "user"           // "user" or "agent"
}
```

Returns the created artifact row. `400` for:

- Invalid scope, category, status, or created_by.
- Missing `project_slug` when scope is `project`.
- `project_slug` that collides with a reserved namespace (`user` or `local`).
- Empty content (Pydantic `min_length=1`).

## Update

```
PATCH /api/memory/@user/memory/dark-mode
Content-Type: application/json

{
  "content": "updated content",
  "metadata": {
    "memory_status": "archived"
  }
}
```

Either `content`, `metadata`, or both may be supplied (at least one is required — `400` if both are absent).

Allowed metadata keys are the current memory_service allowlist:

- `memory_status`
- `memory_category`
- `promoted_by`
- `last_recalled_at`
- `recall_count`
- `provenance`
- `reviewed_by`, `reviewed_at`, `rejected_reason`, `lineage` (added by #920 for governed promotion writers)

Any other key returns `400` with detail `Unknown metadata field(s): [...]`. The review-state fields (`reviewed_by`, `reviewed_at`, `rejected_reason`, `lineage`) are technically writable through this `PATCH` endpoint for recovery scenarios, but the supported writer is the governed promotion pipeline — see [Memory Promotion & Review](../knowledge/memory-promotion.md) for the lifecycle and the Promotion & Review endpoint table at the top of this page.

`memory_scope` is immutable (encoded in the FQN). Attempting to change it returns `400`.

## Delete

```
DELETE /api/memory/@user/memory/dark-mode
```

Returns `{"status": "deleted"}` on success. `404` if the FQN does not resolve to a memory artifact. Repeated deletes return `404`.

## Error shapes

All errors follow FastAPI's standard JSON envelope:

```json
{"detail": "Invalid memory FQN"}
```

The `detail` field is safe to render inline to end users; it never exposes stack traces, SQL fragments, or internal paths.

## Propose a candidate

```
POST /api/memory/candidates
Content-Type: application/json

{
  "content": "prefers dark mode",
  "scope": "user",
  "category": "preference",
  "proposer": "user",              // "user" or "agent"
  "proposer_id": "u-123",          // optional, max 63 chars
  "name": "dark-mode",             // optional, auto-slugged if omitted
  "project_slug": null,             // required when scope is "project"
  "provenance": {
    "conversation_id": "uuid",
    "message_id": "uuid",
    "workflow_run_id": null,
    "source_artifact_id": null
  }
}
```

Returns the newly-created memory in its landing status (`candidate`, `pending_review`, or — when `memory.promotion.local_auto_approve=true` — `active`).

Status codes:

- `200` — candidate created.
- `400` — invalid scope / category / proposer / reserved `project_slug` / Pydantic validation.
- `403` — agent proposals are disabled (`memory.promotion.agent_proposals_enabled=false`) and `proposer="agent"`.
- `409` — duplicate FQN (same `name` already in use at that namespace).
- `429` — per-conversation candidate cap exceeded. Body includes `conversation_id`, `cap`, `current`, `retry_hint`.

## List the review queue

```
GET /api/memory/candidates?status=candidate&namespace=user&limit=50
```

Query parameters:

| Name | Type | Notes |
|---|---|---|
| `status` | string | Defaults to `candidate`. Also accepts `pending_review`. Any other value returns 400. |
| `namespace` | string | Exact namespace match. |
| `limit` | int | Hard-capped to 500. |

## Approve / edit-and-approve

```
POST /api/memory/@user/memory/dark-mode/approve
Content-Type: application/json

{
  "reviewer_display": "Troy",          // optional human-readable label
  "edits": {                            // optional content / category overrides
    "content": "refined body",
    "category": "decision"
  }
}
```

`/edit-and-approve` is identical to `/approve` but **requires** `edits` — `400` if the field is absent.

Status codes:

- `200` — memory transitioned to `active` with `reviewed_by` / `reviewed_at` stamped and a `lineage` entry appended.
- `400` — already-approved memory, invalid state transition, or missing `edits` for `/edit-and-approve`.
- `404` — memory not found.

## Reject

```
POST /api/memory/@user/memory/dark-mode/reject
Content-Type: application/json

{
  "reason": "Not accurate",
  "reviewer_display": "Troy"
}
```

Status codes:

- `200` — memory transitioned to `rejected` with `rejected_reason`, `reviewed_by`, and `reviewed_at` set.
- `400` — already-rejected memory or invalid state transition.
- `422` — Pydantic validation (empty `reason`, too long, etc).
- `404` — memory not found.

## Pin / unpin

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

Both endpoints take an empty body and return the updated memory dict with `metadata.pinned` set accordingly. Pinned memories are load-bearing: the retention worker skips them by default (see [Data Retention](../security/data-retention.md)).

Status codes:

- `200` — memory updated; response body is the full memory dict.
- `400` — invalid FQN shape.
- `404` — memory not found.

## Retention preview (dry-run)

```
GET /api/memory/retention-preview
```

Returns the would-be-purged set under the current `memory.retention` policy. Never deletes, never emits audit entries. Use this before enabling a policy or before running a purge.

Example response:

```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
    },
    {
      "fqn": "@user/memory/stale",
      "reason": "status",
      "age_days": 45,
      "last_recalled_at": "2026-01-01T00:00:00Z",
      "recall_count": 3,
      "status": "rejected",
      "pinned": false
    }
  ]
}
```

When `memory.retention.enabled=false`, the response returns `purged_count: 0` and an empty `items` list.

## Retention purge (governed)

```
POST /api/memory/retention-purge
Content-Type: application/json

{"confirm": true}
```

Runs the same service the scheduled worker runs, but on demand. Two gates stack:

1. **`confirm: true`** in the body, to guard against accidental POSTs.
2. **Reviewer identity**. The endpoint resolves the reviewer via the same `_reviewer_id_from_request` helper used by the `#920` approve / reject endpoints — session `user_id` when present, falling back to `app.state.config.identity.user_id`, then a synthetic default. The reviewer id is stamped into each `memory.purge` audit entry (`user_id` field + `details.reviewer_id`, `details.triggered_by="reviewer"`) and echoed back in the response under `purged_by` so the web UI can show who ran the pass. The scheduled worker leaves the reviewer id unset — its entries carry `details.triggered_by="scheduler"`.

Emits one `memory.purge` audit entry per deletion when `config.audit.enabled` is true. Response shape matches `retention-preview` with `dry_run: false`, a real `purged_count`, and `purged_by` set to the reviewer id string.

Status codes:

- `200` — purge completed (may have purged zero memories if the policy is disabled or nothing matches).
- `400` — `confirm: false` in the body (guard).
- `422` — Pydantic validation (missing `confirm` field).
- `401` / `403` — standard auth guards.
