ADR 0007 — MemoryStore: cross-session memory is a scoped, invalidation-first store protocol; backends differ in engine, never in contract
- Status: PROPOSED — design review. Graduates #119 (deferred-with-intent, build trigger fired). Epic M1 #415, ambient-platform program #414.
- Date: 2026-08-06
- Context owner: Doug
- Scope:
packages/agent-core—src/molecules/memory-record.ts(new:MemoryRecord,MemoryScope,MemoryHit,MemorySearchQueryZod schemas + factories);src/atoms/base.ts(widenRenderContextwithrecall?);src/atoms/awareness.ts(fromRecallinstance render fn, mirroringfromScope).packages/agent-runtime—src/memory/(new module:store.tsprotocol +InMemoryMemoryStore,sqlite-store.tsSqliteMemoryStore,toolbox.tsMemoryToolbox,recall.tsrecall assembler,conformance.tsexported conformance kit);src/storage/load.ts(loadMemoryStore());src/events/types.ts(+3 event types,AgentEventunion);src/transport/sse-formatter.ts(4 coordinated edits) +sse-event-manifest.jsonregen;src/events/event-profiles.ts.packages/agent-dashboard— client event union (drift testsrc/__tests__/sse-events.drift.test.tsenforces this).
Context
We flatly lack cross-session memory (#119): write facts during a session, retrieve relevant ones into a later session. Scratchpad is run-scoped and ephemeral; ConversationStore is per-thread history; neither is recall. The build trigger recorded on #119 — “build when a conversational agent / agent-system #2 needs to remember prior sessions” — has fired: the ambient-platform program (#414) needs a coordinator agent that remembers users across sessions, and the production tier (a Postgres backend in codegen-patterns) must implement the same contract.
We surveyed the field before designing (Google ADK memoryService/Vertex Memory Bank, Letta/MemGPT, mem0, LangMem/LangGraph, Zep/Graphiti, ChatGPT memory, Anthropic’s memory tool). The convergent findings, compressed:
Table stakes (every serious system): composite scoping keys; explicit CRUD + search as the base API; a write-time reconciliation story — append-only stores are considered broken for evolving facts; a two-tier split (small always-in-context profile + large on-demand store); provenance timestamps and host-visible management.
Worth stealing: Zep’s time-invalidation instead of destructive overwrite (contradictions invalidate the old fact, audit trail survives); Letta’s shareable memory attached across agents; LangMem’s profile-vs-collection distinction; ADK’s minimal two-method abstract core with optional extensions; ADK preload_memory’s formatting discipline (timestamped entries wrapped in a delimited block).
Widely regretted: graph memory as default (mem0’s own paper shows ~2% gain; a plain file directory beat their graph variant on an independent LoCoMo run); automatic salience-decay math (no production system ships it); opaque auto-curation (ChatGPT’s “Dreaming” drew criticism for shrinking the audit trail); LLM extraction in the hot path.
ADK’s specific gaps we will not copy: no delete/forget on the interface at all; no memory lifecycle events; no top-k/filter parameters on search; unranked unbounded results from the reference in-memory impl; ingestion so manual that crash-before-save data loss is the community’s top complaint.
In-repo constraints that shaped the design (verified this session):
- No framework identity key exists.
SessionScopeis content-free by design (ADR-0005);StoredConversationcarries only a UUIDid+ free-formmetadata. Memory must define its own partition key and let the author bind it. - The SQLite store ladder is the wrong home.
EventStore → RunStore → EvalStore → SQLiteConversationStoreis a literalextendschain on one dev-telemetry file: all schema folded intoevent-store.ts, downgrade a hard throw, retention-pruned, lockstep-shipped. Memory is a system of record with the opposite lifecycle — and an external Postgres impl can implement a protocol but cannot inheritEventStore.ScratchpadStoreis the documented opt-out precedent (docs/store-family.md). - Render fns are a hard purity gate (ADR-0005: no fetching inside a render fn). Recall must be resolved upstream and passed in via
RenderContext. - No conformance kit exists. The two
ConversationStoreimpls are tested by duplicated hand-written files, with at least one documented parity trap (limit === 0) nothing enforces. The SQLite↔Postgres portability promise for memory requires mechanical enforcement. - The event vocabulary is triple-guarded at compile time — adding event types requires the four coordinated
sse-formatter.tsedits plus the dashboard drift test. Good: parity cannot silently rot. - No FTS/vector precedent anywhere — full-text search is net-new to the repo.
Decision
-
MemoryStore is a standalone protocol, not a ladder member. It lives in a new runtime
memory/module, modeled on theConversationStorepattern (structural protocol +InMemoryMemoryStorebeside it,SqliteMemoryStoreseparate), with its own SQLite file and its ownPRAGMA user_versionladder starting at 1. It does not extendEventStore, does not touchTARGET_SCHEMA_VERSION, and is never retention-pruned. The store ladder contributes mechanics (driver-agnostic.exec/.prepareonly, explicitBEGIN/COMMIT,loadMemoryStore()mirroringload.tsconventions including the bun named-param adapter) — not inheritance. -
The data contract lives in core; the machinery lives in runtime.
MemoryRecordis a Zod-validated, frozen value object incore/molecules/memory-record.ts— theRenderArtifactprecedent exactly. Core placement lets the external Postgres implementation (codegen-patterns) type against the record without importing runtime. -
Scope is a flat string map with subset-match semantics. A record’s scope is
Record<string, string>(e.g.{ tenant: "acme", user: "u_42" }), stored canonically (sorted keys). A search’s scope filter matches every record whose scope contains all the filter’s pairs — so{ tenant: "acme" }finds tenant-wide and per-user memories alike, giving namespace-like hierarchy without tuples. The framework does not invent identity: which fields form the scope is author-declared, typically read fromSessionScopefields (ADR-0005). Exact-match-only scoping (ADK Memory Bank) is rejected — it is that system’s top usability complaint. -
Curation is invalidation-first.
write()accepts an optionalsupersedes: id; the store atomically marks the superseded recordinvalidAt: nowand links the chain.invalidate(id)exists on its own. Invalidated records are excluded from search by default (includeInvalidated: trueopts in) but are never destroyed by curation — the audit trail survives, per Zep.delete(id)exists for true forgetting (privacy/host cleanup) and is deliberately not exposed to the agent toolbox. -
The search contract promises relevance ordering, never scores.
search()returnsMemoryHit[]ordered by relevance with an optional backend-specificscore. Backends differ in engine — SQLite reference uses FTS5/bm25; Postgres will use tsvector and/or pgvector — and declare power viacapabilities(): { search: "keyword" | "semantic" | "hybrid" }. The conformance kit tests ordering behavior and filter semantics, not score values.queryis optional: absent, search is a filtered, recency-ordered listing (unlike ADK, which cannot list). -
The protocol is small; extensions are explicit.
interface MemoryStore {write(inputs: MemoryWriteInput[]): Promise<MemoryRecord[]>;search(q: MemorySearchQuery): Promise<MemoryHit[]>;get(id: string): Promise<MemoryRecord | null>;invalidate(id: string, reason?: string): Promise<void>;delete(id: string): Promise<void>;capabilities(): MemoryStoreCapabilities;}All methods async (house rule). Anything beyond this (session auto-capture, batch import) arrives later as separate, optional interfaces — ADK’s “two abstract methods, optional extensions throw” shape, minus the throwing: we use capability flags and separate interfaces instead of
NotImplementedErrorsurprises. ADR-0008 defines the first such extension: store-resident promotion (promote/demote/corroborate+ promotion rows), declared viacapabilities()and pinned by the same conformance kit — promotion state is system-of-record data, so it lives here, not on the retention-pruned telemetry spine. -
The record is a fact with provenance and validity, not a scored embedding row.
MemoryRecord = {id: string;scope: Record<string, string>;kind: "fact" | "preference" | "episode" | "profile";content: string; // natural-language, prompt-readytags?: string[];provenance?: { conversationId?: string; runId?: string; author?: string };createdAt: string; updatedAt: string; // ISO 8601invalidAt?: string; supersededBy?: string; // invalidation chainexpiresAt?: string; // optional TTL (host-enforced)target?: MemoryTarget; // optional typed pointer into the// agent's composition — see ADR-0008payload?: unknown; // structured form for structured// targets — see ADR-0008supports?: Provenance[]; // corroboration evidence — see ADR-0008}No confidence/salience fields and no decay function — the field’s consensus regret.
kind: "profile"is the two-tier answer: profile records are the small always-injected tier (LangMem’s profile, Letta’s blocks, flattened into the same store rather than a parallel block system).targetis reserved now because it is breaking to add later: ADR-0008 promotes matured memories out of the recall blob and into the agent’s composition (aBackgroundentry, aJudgmentheuristic, anExample, anAwarenessdomain). In this ADR’s scope,targetis stored and returned untouched; untargeted records — all of v1 — behave exactly as described here. ADR-0008 also extends prompt-section attribution (AgentPromptSectionData.source) with a"memory"source carrying record ids, so learned prompt lines trace to the record — and throughprovenance— to the conversation that taught them. -
Two recall surfaces, both budget-capped.
- Turn-1 injection (the
preloadanalog): the runtime host assembles recall before rendering — profile-kind records for the scope first, thensearch(firstUserText, scope)hits — caps it by a character budget, and passes the finished block viaRenderContext.recall. Core renders it through anAwareness.fromRecallinstance fn, mirroringAwareness.fromScope(ADR-0005’s prompt seam; no newSectionsubclass). Truncation is marked, never silent (house rule, events/types.ts byte-cap precedent). Character budget, not tokens: model-agnostic, deterministic, and what OpenClaw/Hermes ship. - On-demand toolbox (the
load_memoryanalog):MemoryToolboxwithmemory_save,memory_search,memory_list,memory_invalidate— built withdefineTool(typed args, validated returns), Manual carrying a standing “you have memory” instruction (ADK precedent) and the curation protocol (save withsupersedeswhen correcting a fact — Letta-style self-editing).
- Turn-1 injection (the
-
Scope binds at instantiate time, not tool-call time.
MemoryToolboxis delivered per-conversation via the instantiate seam (ADR-0004) with the partition scope captured at construction. This sidesteps the known ADR-0005 limit that CC-runner tools don’t seehost.scope, and means a memory tool cannot write outside its conversation’s partition even if the model asks it to. -
Memory is observable. Three event types —
agent.memory.write,agent.memory.search,agent.memory.recall(the injection event: counts, byte size, truncated flag) — added through all foursse-formatter.tsguards, the manifest, event profiles, and the dashboard union. ADK ships zero memory observability; our event spine is a differentiator and we use it. -
The conformance kit is the portability contract.
runMemoryStoreConformance(makeStore)— an exported vitestdescribe-factory covering write/search/scope-subset/invalidation/ordering/limit semantics, with optional sub-suites keyed by capability flags.InMemoryMemoryStoreandSqliteMemoryStoreboth run it in-repo; the codegen-patterns Postgres subsystem imports and runs the same kit. This is the first conformance kit in the repo; theConversationStoreparity trap is the cautionary precedent for why it is not optional. -
v1 writes are explicit only. The agent (or host code) writes memories deliberately; there is no automatic extraction pipeline in the framework hot path. Session auto-capture and background consolidation are Phase-2 concerns that belong to the jobs/scheduler tier (codegen-patterns) and the AgencyHost lifecycle (program M3) — and LLM-driven reconciliation, when it comes, arrives as a composable Judgment/Play above the store, not inside it.
Recall assembly (the one place budget lives)
Render purity means the renderer never fetches. The flow:
runner/host (runtime) core (pure)───────────────────── ───────────scope = readScopeAs<...>(ctx)profile = store.search({scope, kinds:["profile"]})hits = store.search({query: userText, scope, limit})block = capToBudget(profile ⊕ hits, chars) ──► RenderContext.recallemit agent.memory.recall {count, bytes, truncated} Awareness.fromRecall renders blockrenderContinuation is untouched in v1 — recall injects at turn 1 only; the toolbox covers mid-conversation needs. Per-turn re-injection (ADK preload_memory style) is a known limit, revisited when AgencyHost lands.
Consequences
Good
- One protocol, three backends (in-memory dev, SQLite reference, Postgres production) with parity enforced mechanically, not by discipline. The SQLite→Postgres path the platform promises is a conformance run, not a rewrite.
- Invalidation-first curation gives contradiction handling and an audit trail without a graph, an extraction LLM, or destructive overwrites — the highest-value ideas from Zep/Memory Bank at a fraction of their machinery.
- The agent surface (toolbox + Manual protocol) and the ambient surface (turn-1 recall) compose from existing seams — instantiate (ADR-0004), scope (ADR-0005), Awareness render fn — with zero new section types and zero render impurity.
- Memory events make recall inspectable in the dashboard from day one; “what did it remember and why” is a query, not a mystery.
Costs / risks
- FTS5 is net-new to the repo and must hold under both bun:sqlite and better-sqlite3 through the
.exec/.prepare-only contract; the conformance kit needs to run under both drivers. - A second SQLite file (memory is not on the telemetry file) means one more path to configure and back up. Deliberate: memory must not inherit telemetry retention/pruning or its downgrade-hostile versioning.
- Subset-match scoping is more powerful than exact-match and therefore easier to misuse (an empty filter matches everything). The conformance kit pins the semantics; the toolbox never exposes an unscoped search.
- Character budgets are a blunt instrument versus token budgets; accepted for determinism and model-independence.
Known limits, stated rather than hidden
- No semantic/vector search in the reference backend — keyword FTS only. The capability flag exists precisely so the Postgres/pgvector backend can exceed the reference without breaking the contract.
- No automatic capture: a session that never calls
memory_saveleaves no memory. This is ADK’s top community complaint and we ship v1 with it anyway — the fix (lifecycle-hooked capture + background extraction) needs the daemon and jobs tiers, which are later program milestones. - Recall injects at turn 1 only; long conversations rely on the toolbox.
- Multi-agent sharing is by overlapping scope only — no Letta-style attach-by-reference blocks in v1.
expiresAtis stored and filtered on read; nothing sweeps expired rows in v1 (no scheduler in the framework tier).
Rejected alternatives
- Join the EventStore ladder (SCHEMA_V6). Forces a global version bump on every ladder consumer, couples memory to telemetry retention and downgrade-throw semantics, and cannot be mirrored by an external Postgres impl. Wrong lifecycle, wrong coupling.
- LLM extraction/reconciliation inside the store. mem0’s pipeline is real but belongs above the store (as a Judgment/Play in this framework), and hot-path extraction is a documented regret elsewhere. The store stays deterministic and cheap.
- Graph memory. ~2% measured gain in mem0’s own paper; beaten by a file directory in independent testing; Anthropic ships a literal folder as their memory product. Earn-in threshold not met; the protocol does not preclude a graph-backed implementation later.
- Salience scores + decay. No surveyed production system ships automatic decay; expiry + invalidation cover forgetting without speculative math.
- Token-budgeting inside the renderer. Violates the ADR-0005 purity gate and would drag a tokenizer into core. Budgeting happens where the data is fetched.
- A separate pinned-block subsystem (Letta blocks).
kind: "profile"+ always-inject-first gives the two-tier behavior with one store and one protocol; a block system can be layered later if profile records prove insufficient. - Exact-match scope (ADK Memory Bank). Its best-documented usability failure; subset-match is strictly more useful and conformance-testable.
Follow-ups
- ADR-0008 — Compositional memory: the layer above this store — matured memories compile into the agent’s composition (typed
targets,applyMemoryOverlay, tiered promotion gates, the evolution ledger). This ADR is its substrate; nothing here changes when 0008 lands. - Phase 2 — capture & consolidation: session auto-capture on conversation close (AgencyHost lifecycle, program M3), background reconciliation as a Play (mem0 ADD/UPDATE/INVALIDATE/NOOP shape, invalidation-first), expiry sweeping via the jobs tier.
- Phase 3 — production backend: codegen-patterns
memorysubsystem (drizzle/Postgres, tsvector; pgvector unlockssearch: "semantic"), importing the conformance kit. Un-parks the knowledge-family vector stub. - Dashboard: a memory lens (recall events already carry counts/bytes/truncation).
- Sibling #120 (
ArtifactStore) remains separate and unblocked by this ADR.