Skip to main content
3ngram’s memory model is built on one rule: the write path never destroys data. Everything else — typed edges, bi-temporal validity, advisory consolidation — follows from it. Hard rule 1 has two audited carve-outs. Filing metadata (scope, project, tags) may change in place, through the revise tool’s move disposition (issue #233): the previous filing stays in the audit event, and an asOf read reports a moved memory under its current filing, not the one it had at that time. And status may move active → archived — through blocker resolve, the revise tool’s archive disposition (issue #217) or the REST archive route — each recorded by an archive event. That transition is the whole licence: content, topic, tags and the bi-temporal columns of an archived memory are untouched, so it stays readable with status: "archived" and through asOf.

Append-and-supersede

remember appends. There is no write-side similarity merge, no in-place edit, no delete on any write path. When a memory needs correction, revise creates a typed edge between the new memory and the old one: A superseded memory is marked invalid from that point — not deleted, not rewritten. Retrieval is supersession-aware: ranked search demotes superseded predecessors (it does not drop them). Chronological listing, briefing, and the dashboard list default to currently-valid rows (status = active AND valid_to IS NULL); an explicit status = archived filter still returns archived rows whose valid_to is NULL. asOf (validAt and/or asKnownAt) is time travel, so “what did I believe on date X” stays answerable. A memory that is over but was true has no successor to supersede it with. revise with kind: "archive" and up to 50 memoryIds takes each one out of the active set instead: its status becomes archived, nothing is appended, and each id reports its own outcome (archived, already_archived, or rejected with a reason — not_found, superseded, open_commitment, blocker). Open or waiting commitments and blockers are rejected because they leave through resolve. An archived memory leaves ranked search, briefing and stale candidates, keeps its valid_to NULL (the archived bucket, not the superseded one) and with it its content-hash slot, so re-remembering identical content is a duplicate until the archived row is superseded. A memory that is still true needs neither. revise with kind: "reaffirm" and the same bounded memoryIds records that it was reviewed and is still valid: it stamps last_reviewed_at and writes a reaffirm event, and that is all — no new row, no edge, no status change, and content, topic, recorded_at, valid time and updated_at stay as they were, so a review never poses as a revision. The briefing’s stale candidates key on the later of updated_at and last_reviewed_at, so a reaffirmed memory stays out of that section for one full stale window (30 days) and then comes back for another look. Each id reports reaffirmed or rejected (not_found, superseded, archived): a closed or archived row is already out of the queue. Ranked search reads neither column, so its ordering does not change. Episodic memories (dated sessions, events) and semantic memories (facts, preferences, patterns) are distinct and never collapsed into each other.

Typed memories

Every memory carries a memory_type, and each type keeps its own lifecycle instead of flattening into undifferentiated text:
  • decision — choices made, with their rationale
  • commitment — promises with an owner and a due date
  • blocker — things standing in the way
  • fact — structured statements about the world
  • preference — how you like things done
  • pattern — recurring approaches worth reusing
  • note — general knowledge worth keeping
  • event — dated, episodic entries (session debriefs, digests)
Commitments have an explicit state machine — open → waiting → resolved | expired — driven by the resolve tool. That lifecycle is a separate axis from memory status: resolving a commitment leaves its memory active, so a resolved commitment stays a valid historical search hit, labelled with commitmentStatus: "resolved" rather than hidden. Blockers are deliberately simpler: resolving one archives the blocker memory (active → archived, whatever status you pass), dropping it from the briefing’s active blockers. The briefing tool surfaces what is open, overdue, or going stale. A due date comes from the caller, never from the prose: a native remember sets it with an explicit dueAt (and optionally owner), import carries it over from the source system, and a revise keeps the commitment row, deadline and owner with the successor. A commitment past its due date stays open (or waiting), and therefore overdue in the briefing, for a grace window (COMMITMENT_EXPIRY_GRACE_DAYS, default 14 days) before the worker’s surfacing sweep expires it: expiry is housekeeping for items nobody acted on, not the first thing that happens when a date passes. A resolve may carry an optional reason (issue #234), the evidence for that transition. It lives on the commitment row (last_resolution_reason), written in the same transaction as the status change, and it always explains the latest transition: any later status change without a reason clears it, including the sweep’s expiry and the implicit resolve of a revise that demotes a commitment, while a same-state no-op stores nothing. A revise that keeps the commitment carries the reason with the row. It is never copied into the append-only memory_events payload, so account erasure can redact it in place alongside owner. Blockers have no commitment row, so a blocker resolve with a reason is rejected.

Bi-temporal facts

Facts track two timelines independently:
  • World time (valid_from / valid_to): when the statement was true in the world.
  • System time (recorded_at): when 3ngram learned it.
get_facts returns rows whose valid_to is NULL (or that overlap a requested asOf / range). The write path only INSERTs facts — it does not close a previous live row for the same (subject, predicate), and it does not write a supersedes edge between fact rows. Multiple currently-open facts for one subject are representable. Closing a generation is either a writer-supplied validTo on the insert, or future current-truth work. This separation matters because corrections arrive late: you might learn today that a value changed last month. Bi-temporal storage records both when the change happened and when you found out.

Consolidation is advisory

A background worker detects candidate duplicates and contradictions and proposes edges with a rationale — it does not apply them by default. Proposals surface through the review_proposals tool, where you accept or reject them. A decision is final for that pair: a rejected pair is not proposed again, and neither is a pair already joined by an edge. Only a new memory (a successor written by revise, or a fresh remember) can put the question back on the table. Reviewing is separate from deciding: listing proposals is a read-only transaction and never changes a proposal or a memory, and a queue of any size can be enumerated completely by following nextCursor (MCP review_proposals list over edge, fact and resolution proposals; REST GET /api/v1/proposals over edge proposals). The walk is a keyset over the live tables in one merged created_at DESC, id DESC order — proposal ids are uuidv7 and disjoint across tables, so the id breaks ties, including a batch written in one transaction that shares one timestamp. That gives four guarantees:
  • No proposal is returned twice in one walk.
  • A proposal accepted or rejected mid-walk, and so no longer matching a status filter, is simply not returned if the walk has not reached it yet; no other proposal is skipped or repeated because of it. Without a filter it is returned once, with its current status.
  • A proposal created after the walk started sorts newer than every cursor position, so it appears only in a fresh walk.
  • totals is read in the same snapshot as its page, so a page agrees with its own totals, but totals are live: they can change between pages.
A future proposal kind (resolution proposals, issue #229) joins the same contract as one more source in the merge and one more key in counts/totals; the cursor, which stores only the last (created_at, id) position, does not change. Event-type memories are never auto-linked, regardless of similarity: recurring episodic entries (daily digests, session debriefs) are textually near-identical by construction, which makes automatic merging the most dangerous exactly where it looks most confident.