# Memory Promotion & Review

Durable memory should not appear silently. Anteroom routes every new memory through a **review queue**: users and agents propose candidates, a reviewer approves / rejects them, and only approved candidates become `active` and therefore visible to recall (#921).

This page covers the promotion pipeline end to end. For the management surface (list / show / edit / delete on memories that have already been through review), see [Managing Memory](managing-memory.md).

## Lifecycle

```
propose ─► candidate ─► (review) ─► active    (recallable)
                              └──► rejected   (dead, with reason)
```

- **propose** — a user or agent submits a candidate via the CLI, REPL, web panel, or `POST /api/memory/candidates`.
- **candidate** — the default landing state. Visible in the review queue, not visible to recall.
- **pending_review** — optional variant when your install has an explicit reviewer queue. Same semantics as `candidate`.
- **active** — the reviewer approved the candidate. Visible to recall.
- **rejected** — the reviewer refused it, with a reason stored on the memory.

Status transitions `candidate` / `pending_review` → `active` / `rejected` are governed by this pipeline. Manual status flips via `PATCH /api/memory/{fqn}` are still possible for recovery, but they bypass the review trail and do not stamp reviewer identity.

## Proposing a candidate

=== "CLI"

    ```bash
    aroom memory propose --content "prefers dark mode" --scope user --category preference
    aroom memory propose --content - --scope project --project-slug myapp --category project_fact < fact.txt
    ```

=== "REPL"

    ```
    /memory propose --content "prefers dark mode" --scope user --category preference
    /memory propose last --category decision    # promote the previous assistant turn
    ```

=== "Web UI"

    Click **Save as memory** on any assistant message. Pick a scope and category, hit **Submit for review**. The message's `conversation_id` + `message_id` are attached automatically as provenance.

=== "API"

    ```bash
    curl -X POST http://127.0.0.1:8080/api/memory/candidates \
      -H "Content-Type: application/json" \
      -d '{
        "content": "prefers dark mode",
        "scope": "user",
        "category": "preference",
        "proposer": "user",
        "provenance": {
          "conversation_id": "00000000-0000-0000-0000-000000000000",
          "message_id": "11111111-1111-1111-1111-111111111111"
        }
      }'
    ```

## Reviewing the queue

=== "CLI"

    ```bash
    aroom memory candidates                    # list the review queue
    aroom memory approve @user/memory/slug
    aroom memory reject @user/memory/slug --reason "stale"
    ```

=== "REPL"

    ```
    /memory candidates
    /memory approve @user/memory/slug
    /memory reject @user/memory/slug --reason "not accurate"
    ```

=== "Web UI"

    Open the Memory panel → **Review queue** tab. Each row has:
    - **Approve** — accepts as-is, stamps reviewer identity.
    - **Edit & approve** — edit content before approving.
    - **Reject** — requires a rejection reason, stored on the memory.

=== "API"

    ```bash
    curl -X GET    http://127.0.0.1:8080/api/memory/candidates
    curl -X POST   http://127.0.0.1:8080/api/memory/@user/memory/slug/approve
    curl -X POST   http://127.0.0.1:8080/api/memory/@user/memory/slug/edit-and-approve \
      -H "Content-Type: application/json" \
      -d '{"edits": {"content": "refined body", "category": "decision"}}'
    curl -X POST   http://127.0.0.1:8080/api/memory/@user/memory/slug/reject \
      -H "Content-Type: application/json" \
      -d '{"reason": "Not accurate"}'
    ```

## Reviewer identity and lineage

Every approve / reject stamps:

- `reviewed_by` — the reviewer's user id (from session auth, falls back to the local identity uid, finally to `"web-reviewer"`)
- `reviewed_at` — UTC ISO-8601 timestamp
- `rejected_reason` — only on `reject`, free-form text. Server-side truncated to `memory.promotion.max_reject_reason_chars` (default **500**). The router additionally enforces a Pydantic `max_length=2000` on the request body; the effective cap is the minimum of the two.

Plus a `lineage` entry in the memory's metadata. Lineage is an append-only list of decision events:

```json
{
  "lineage": [
    {"event": "proposed", "actor": "user", "actor_id": "u-123", "at": "2026-04-17T10:20:00Z"},
    {"event": "approved", "actor": "reviewer", "actor_id": "u-123", "at": "2026-04-17T10:25:00Z"}
  ]
}
```

Lineage caps at `max_lineage_entries` (default 50) by dropping the oldest non-creation entry and appending a `{"event": "truncated", "at": ...}` marker. The proposal event is always preserved.

## Config knobs

```yaml
memory:
  promotion:
    default_review_state: candidate         # or "pending_review"
    local_auto_approve: false               # escape hatch for solo/local mode
    agent_proposals_enabled: true           # allow proposer="agent" calls
    max_lineage_entries: 50                 # FIFO cap on decision history per memory
    max_candidates_per_conversation: 10     # per-conversation proposal cap (429 on exceed)
    max_reject_reason_chars: 500            # server-side bound on rejection reason
```

- **`local_auto_approve: true`** — proposals land straight in `active` with a `{"event": "auto_approved", "actor": "system"}` lineage entry. Use only for single-user local mode; in team / enterprise installs keep it off so the review trail is intact.
- **`agent_proposals_enabled: false`** — agents are blocked from proposing (user-only). The `save_memory` tool (#217) will fail closed with a `403` when disabled.
- **`max_candidates_per_conversation`** — per-conversation candidate cap; reached when a runaway agent or looping workflow floods the queue. Hits return `429` with `conversation_id` + `cap` + `current` fields in the error body.
- **`max_reject_reason_chars`** — service-side truncation of `rejected_reason`. The router also enforces a Pydantic `max_length=2000`; the effective cap is the minimum of the two.

See the [`memory.promotion` section](config-reference.md#memorypromotion) of the configuration reference for every field, default, and `AI_CHAT_MEMORY_PROMOTION_*` environment-variable override.

## Agent-proposed candidates

The `save_memory` tool (shipping with #217) calls the same `POST /api/memory/candidates` endpoint with `proposer="agent"` and the agent's id in `proposer_id`. By contract, agent proposals **always** land in the review queue unless `local_auto_approve=true`. There is no way to bypass the review step from an agent.

If the conversation's candidate cap is hit, the tool receives a structured `429` error and surfaces a summary rather than silently failing — the agent can see the cap, the current count, and a retry hint telling it to ask the user to review existing candidates first.

## Auto-propose from assistant turns (#1454)

When `memory.auto_propose.enabled` is true, after each final assistant turn (the no-tool-call branch — not intermediate tool steps) Anteroom runs a small LLM-driven extractor over the response and surfaces durable facts as **candidate** memories through the same `propose_candidate` pipeline. Auto-propose **never** creates `active` memories: every candidate still requires human approval, and the per-conversation cap from `memory.promotion.max_candidates_per_conversation` still applies.

```yaml
memory:
  auto_propose:
    enabled: true                  # default false
    max_candidates_per_turn: 1     # hard cap per turn
    categories: [preference, project_fact, decision, workflow_hint]
    min_confidence: 0.8
    cooldown_turns: 5
```

What you'll see:

- **CLI** — after a turn that produces a candidate, an inline notice appears under the assistant message: `💡 1 memory queued for review — first: @user/memory/pref-… · /memory candidates to review`. Run `aroom memory candidates` to review.
- **Web UI** — an inline banner appears on the assistant message bubble with a **Review** button that opens the Memory Review panel.
- **Audit** — every successful auto-propose emits a `memory.proposed` lineage entry with `details.proposer="agent"` and `details.auto_propose=true`, distinguishing auto-propose from manual `proposer="agent"` calls (e.g. the `save_memory` tool from #217).

Where it does **not** run:

- Non-interactive `aroom exec` mode — short-lived, often scripted, no reviewer present.
- Sub-agents (`run_agent` tool) — a sub-agent's "final turn" is mid-flight from the user's perspective.
- Workflow runners — opaque from the user's review queue.
- Plan mode — extraction is suppressed while planning.

Failure isolation: extractor errors (LLM unavailable, JSON parse failure, network) never break the assistant turn. The runner returns a quiet result and the stream continues. See `services/auto_propose_runner.py` and `services/memory_extractor.py` for the runtime.

Tuning notes: each enabled turn spends one extra non-streaming model call. Default `max_candidates_per_turn=1` keeps the noise floor low and lets the reviewer build up a base of approved memories before raising the cap.

## Security

- **Untrusted content**: memory content can originate from arbitrary user or agent text. When recalled (#921), it is always wrapped in `<untrusted-content>` envelopes before the model sees it — rejection reason and reviewer display name receive the same treatment.
- **Reviewer identity**: session-bound, never a free-form field on the request body. The `reviewer_display` field in the request is a human-readable label only; the canonical `reviewed_by` field is taken from session auth.
- **Rate limit**: conversation-level cap prevents candidate floods from runaway agents.

## Related

- [#919](https://github.com/troylar/anteroom/issues/919) — typed memory artifacts (shape + provenance)
- [#1416](https://github.com/troylar/anteroom/issues/1416) — management surface (list / show / edit / delete)
- [#921](https://github.com/troylar/anteroom/issues/921) — runtime recall injection (consumes `active` memories)
- [#923](https://github.com/troylar/anteroom/issues/923) — runtime context attribution
- [#625](https://github.com/troylar/anteroom/issues/625) — retention and eviction
- [#217](https://github.com/troylar/anteroom/issues/217) — `save_memory` tool (uses the promotion pipeline)
- [#1454](https://github.com/troylar/anteroom/issues/1454) — selective auto-propose from assistant turns
