Skip to main content
Status: shipped, default-off. This ADR was accepted on 2026-10-27, and the open questions at the end stay open. The pass ships on the worker behind FORGETTING_PASS_ENABLED (default off), and its proposals are reviewed through review_proposals. It only ever writes proposals. The thresholds are starting values until the eval sets them. The report-only harness and its synthetic mirror ship in eval/ (eval/src/forgetting-eval.mjs, run nightly, never gated); the private golden set is still to come.

Context

A long-running agent’s memory fills up with decisions that were overtaken, preferences that drifted, and facts that went out of date. Drew Breunig puts it plainly in “The State of Context Engineering” (2026-10-24): “the instructions and memories start to build up and conflict with one another”, and strategic forgetting is unsolved. 3ngram already has the data model for forgetting without loss:
  • append-and-supersede, where nothing is merged or deleted on a write path (ADR-0005, memory model);
  • type-aware consolidation that never auto-applies an edge to an event memory (ADR-0005 §3: at most an advisory extends proposal);
  • an archive transition that leaves history readable.
In practice, forgetting is still weak: Two things follow:
  • Supersession only happens if something proposes it. People do not maintain lineage by hand.
  • Proposals cannot be applied blindly. In the S1 data, high similarity could not tell “same fact, restated” from “same ritual, new day”.
Commitments are out of scope here: the session closer owns their lifecycle.

Decision (proposed)

Add a worker job, forgetting-pass. Once a day, for each tenant that opted in to judgment, it:
  1. Selects candidates: stale single memories, plus pairs of same-type, same-scope memories that likely conflict.
  2. Asks for verdicts: sends them in batches to Gateway.decide() (Jev, pinned model) for typed verdicts with calibrated confidence: keep, archive, supersede-by:<id> or conflict-with:<id>.
  3. Writes proposals: confident non-keep verdicts become proposals that the user reviews through review_proposals.
It never archives, supersedes or edits anything itself. Accepting a proposal reuses the existing audited write paths.

Candidate query

The pass reads two candidate streams per tenant. Both use the live-row predicate (valid_to IS NULL AND status = 'active') and the tenant selector of briefing. Stale singles. Exactly briefing’s stale predicate (packages/db/src/briefing-read.ts):
  • type in STALE_CANDIDATE_TYPES (decision, preference, blocker, fact);
  • freshness GREATEST(updated_at, last_reviewed_at) older than STALE_WINDOW_DAYS (30);
  • ordered most stale first;
  • capped at 60 per tenant per run.
Blockers are only ever judged keep or conflict-with, because blockers settle through resolve. A stale blocker with no newer neighbour is therefore not judged at all. Conflict pairs. The findSimilarPairs skeleton (packages/db/src/search.ts), with these conditions added inside the lateral kNN:
  • event is excluded from pairing, as ADR-0005 §3 requires. Recurring episodic entries are near-identical by construction.
  • note is excluded in v1. In the S1 data, notes were 79% of destructive-merge targets, and their free form makes “conflict” hard to judge. Revisit after the eval.
  • Pair order: pairs are oriented by recency, newer first, as consolidation orients them. Pairs whose older side is stale rank first. The cap is 40 per tenant per run.
  • Anti-joins:
    • an existing edge;
    • a consolidation proposal in any status;
    • a retention proposal in any status created after both memories’ freshness.
    So a decision stays final for a pair until a newer memory reopens it (the memory model rule).
A single that also appears in a pair is judged once, inside the pair. Each memory is judged at most once per run: a pair that shares a memory with a higher-ranked pair waits for a later run, so one run never stages two contradictory verdicts on one memory. A pair verdict is recorded on the older memory. A supersede or conflict verdict also names the newer one, which is skipped while that proposal is open; otherwise the newer one can still come up later as a stale single.

Judgment: the Jev state shape

  • Calls: one decide() call per batch of up to 12 items, through the composed gateway. JEV_API_KEY and JEV_MODEL are pinned (jev-1.14.0), the same configuration the session closer uses.
  • Size limits: the judged memory’s content is cut to 1,200 characters, and each newer neighbour’s to 300. A single item then carries at most about 2,100 characters of content and a pair about 2,400. Twelve items plus JSON overhead stay under 40k characters, against Jev’s limits of 64k (MAX_DECIDE_STATE_CHARS) and 100 questions. The batcher measures the rendered state and splits a batch that would pass 48k, so an unusually long topic cannot trip assertDecideRequest.
  • Metering: each call is metered as a new operation, memory.forget.decide. That takes two registry edits in packages/llm/src/operations.ts:
    • an llmOperations entry with its own cost ceiling;
    • an addition to METERED_JUDGMENT_OPERATIONS.
    The pass uses the same injected BudgetEnforcement and the closer’s metered-call shape: reserve, send, record usage, release.
  • Opt-in: isJudgmentEnabled(userId) is read immediately before every send.
  • No fallback: there is no fallback to complete(). A tenant without the opt-in, or a deployment without a judgment gateway, gets no forgetting pass at all.
The state is the closer’s untrusted-data preamble followed by one fenced JSON block. It is built with the closer’s helpers:
  • fenceExcerpt, which is exported from session-closer-prompt.ts;
  • jsonLiteral, which is private to session-closer-judgment.ts today. PR 2 moves it beside fenceExcerpt and exports it.
newer lists up to three newer live memories of the same type and scope, ranked by similarity. That makes “superseded by” answerable for singles. Each item gets one choice question, so Jev returns a confidence and a full probability map: The answer maps to a typed verdict:
  • keep, keep_both: keep.
  • archive, archive_older: archive.
  • supersede_by:…, supersede_older: supersede-by:<newer id>.
  • conflict: conflict-with:<other id>.
The confidence is the chosen key’s probability. A proposal is written only when the verdict is not keep and its probability clears that verdict’s threshold. A verdict below its threshold is stored dismissed with its real action and probabilities, so calibration can use it and the pass does not judge an uncertain memory again every day. The thresholds start at 0.85 for archive and supersede and 0.6 for conflict. The eval sets the final values.

Proposals

A new kind is needed. resolution_proposals cannot be reused:
  • its session_run_id is NOT NULL with a foreign key to agent_sessions, and a forgetting pass has no session;
  • it has no action column;
  • it only stages open or waiting commitments;
  • it means “this commitment is done”.
consolidation_proposals can express a supersede edge, but not an archive. Its similarity column means cosine, so storing a judge probability there would blur the per-type precision audit that ADR-0005 asks for. So the pass gets a fourth review_proposals kind, retention, backed by a new table:
  • Keep verdicts, and verdicts below their threshold, are stored with status dismissed and are never listed. Only the pass sets dismissed; a reviewer’s no is rejected, with decided_at set, so calibration can tell the two apart. They stop the pass from re-judging a memory until that memory’s freshness moves past the verdict, through an edit, a reaffirm or a newer neighbour.
  • Table count: this is the 32nd physical table. Its JTBD is “a memory the judge thinks is finished waits for a human yes instead of lingering in every briefing, or being dropped”, and no existing proposal table can carry it, as argued above.
  • Accepting:
    • archive: archiveMemoryIn with the disposition policy, the same rejection rules and an audited archive event.
    • supersede: a supersedes edge from the newer to the older memory, with applyProposal’s predecessor-close semantics. It refuses event-type and no-longer-live memories.
    • conflict: records the review only, with no memory change. The user resolves it with revise.
  • Rejecting: final for that memory and action, until the memory’s freshness moves.
The MCP surface. A fourth kind changes the strict review_proposals output counts, so this ships as a V4 output under ADR-0011. V4 itself left the tool description unchanged; a follow-up rewrote it to name all four kinds, regenerated the tool-selection embeddings, replaced one consolidation scenario with a retention one, and reran the eval gate at the current floors. Hard rule 8 still requires exactly 5 scenarios per tool.

Flag and schedule

The pattern is SESSION_CLOSER_ENABLED:
  • FORGETTING_PASS_ENABLED in packages/config, a stringbool that defaults to false, loaded with loadForgettingPassConfig().
  • Resident worker: with the flag on, upsert a job scheduler at 0 30 4 * * *, daily at 04:30 UTC, clear of the hourly consolidation tick. With it off, remove the scheduler, and gate the processor so a residual job completes as a no-op.
  • One-shot: add forgetting-pass to ONCE_JOBS. With the flag off it exits 0 without reading anything. Add a row to the operate job table (30 4 * * *).
  • Budget: the injected BudgetEnforcement reserves a slot before every decide() call, as the closer does, and the pass rejects instead of billing past the cap.

Eval plan

Golden set. It is built from real memories in the 3ngram project of one consenting account, so it never goes into the public eval/fixtures. It lives in a private repository. A small synthetic mirror in eval/ tests the harness wiring in CI. Target slices: The recurring-episodic slice must stay at zero false collapses. The fixture-backed eval measures judge verdicts on supplied candidates; it does not exercise production candidate selection. Neither event nor note rows enter stale singles or conflict pairing in the shipped pass. The core forgetting-pass.int.test.ts integration suite checks both exclusions through dbForgettingRepo.readCandidates, with otherwise-eligible recurring episodes and a decision-pair positive control. Metrics.
  • Headline: precision on archive and supersede proposals, each separately and then combined, reported per memory type as ADR-0005 asks. A wrong archive costs more than a missed one, so recall is secondary.
  • Also reported: conflict precision, the keep rate on still-valid items, and calibration (expected calibration error) of the judge’s confidences.
Protocol. It follows the #166 pre-registration:
  • labels are frozen before the first judged run;
  • thresholds are chosen on a development split, and precision is reported on a held-out split;
  • recorded Jev responses become fixtures for deterministic CI, as jev-recorded.test.ts does;
  • a live re-measurement runs whenever JEV_MODEL changes.
Bars. The pass stays report-only, like the revise#disposition slice, until both hold on the held-out split:
  • archive precision of at least 0.95 with at least 40 proposals;
  • zero recurring-episodic collapses.
After four weeks of stability it may become a gated floor. Floors only move up.

Rollout

It ships as a stack of small PRs to staging, all default-off:
  1. Migration and db functions for retention_proposals (RLS + FORCE), plus export and account-deletion coverage.
  2. Core candidate selection and judgment rendering, tested with FakeGateway.
  3. Worker job, flag and one-shot entry, plus the operate docs row.
  4. review_proposals V4 with the retention kind; the description rewrite, embeddings regeneration and eval-gate rerun followed in their own PR.
  5. Private golden set and report-only harness.
A staging trial follows with one opted-in tenant. Production only on an explicit go.

Open questions

  1. Should confident keep verdicts also propose a reaffirm? It would shrink the stale list, but it is a write the user did not ask for. The v1 answer is no: a keep stays silent.
  2. Should notes join candidate selection after the eval, as singles, pairs, or neither?
  3. Should accepting a conflict offer the two resolutions inline (archive the older, or supersede), instead of sending the user to revise?
  4. How does the pass share the daily budget with the session closer when both are on?