agent_memoryis hot identity memory plus prompt injection. It is keyed by stableAgentIdentity, stored by realm, and injected during create/resume/materialize and ordinary identity-first turns.meta/memory/*is the operational assertion ledger: assertions and conflict signals used by routing/gating flows. The runtime owns it and holds it in memory; it is persisted as a local JSON file underpersistent_state, optionally gated by an HTTP health check. Query semantics are exact filters over stored assertions plus an optional substringqueryfilter. It is not theagent_memoryinjection ledger or turn-surface ledger, which are SQLite tables inside the agent-memory realm database (see Configuration).
Architecture
The bundled liveagent_memory provider is the per-realm SQLite store under
<persistent_state>/agent-memory. It makes an explicit remember write
available to the next turn without requiring an external service or
LLM-powered extraction pipeline. Legacy per-identity markdown files are
import-only: selecting store: "markdown" is refused at gateway init, while
the SQLite provider imports a realm’s files when that realm is first accessed
and renames each source to .md.imported.
No external memory hub is integrated today. A real hub integration - a deep-memory
hub with a knowledge graph, document/vector retrieval, truth maintenance,
provenance, and ABAC/redaction - is the wire-boundary provider described in the
memory hub roadmap (docs/design/memory-hub-roadmap.md in the repository).
Nothing in the current runtime sends data to an external memory hub.
Meerkat’s session semantic memory is a separate layer again: it indexes
compacted session content and exposes semantic recall through Meerkat’s
memory_search tool. Meta’s agent_memory injector can be backed by a
provider that consults session memory or other stores, but the Meta runtime
does not assume semantic/vector search from meta/memory/*.
The assertion ledger backend persists assertions and conflict signals as a
local JSON file, optionally gated by an HTTP health check at the startup load
and before every persist. Configuration is provided through
LocalJsonMemoryBackendConfig:
local_json is the only backend kind. Meta 0.9 removed the predecessor’s
backend shape and its kind value with no alias: configuration that still
names that value is refused at decode (and at gateway init) with an error that
names local_json, never reinterpreted. Rewrite it as kind = "local_json",
with the old endpoint as health_check_endpoint.
The runtime owns the assertion ledger
The assertion ledger is a runtime-owned snapshot, not a shared append log.MetaRuntimeHandle reads state_path exactly once, at runtime bootstrap,
and from then on the in-memory assertions and conflict signals are the
authority. Every meta/memory/index serializes that entire in-memory state
and atomically replaces the file (write to state_path.with_extension("tmp"),
then rename over state_path). The scratch path replaces the extension:
memory-ledger-state.json uses memory-ledger-state.tmp. Nothing re-reads
the file, takes a lock, or checks its modification time first. If the persist
fails, the in-memory write is rolled
back and the index call returns the backend error.
Persists are not only host-originated. When agent_memory enables the
distiller or the memory Steward, rpc_gateway bridges Steward conflict
signals into memory_index(conflict: true), so the file can be rewritten with
no RPC call from the host at all.
What this means for anything else that touches the file:
- An external edit is never observed by the running runtime and is overwritten by the next persist, whether a host call or a Steward signal triggers it.
- A well-formed hand-added entry is picked up only at the next restart.
Loading trims and ASCII-lowercases
entity,topic, andstore;entityandtopicmust remain non-empty, andstoremust normalize to one of the five supported store names. Thefactis trimmed and must remain non-empty. Rows that fail these checks are dropped at load without a diagnostic; mixed case or surrounding whitespace alone is not a failure. - A structurally malformed file (an assertion missing a required field, or
invalid JSON) refuses the next boot with
MetaRuntimeError::MemoryBackend. - Retention is capped: the runtime keeps at most 4,096 assertions
(
MEMORY_ASSERTIONS_MAX_RETAINED), evicting the oldest at index time and at load. Conflict signals are keyed byentity/topic/store, one per key. Do not treat the file as a durable audit log. - Single writer is by convention: no Meta lock guards
state_path. Run one runtime per state file.
meta/memory/index over
JSON-RPC, MobHandle.memory_index(...) in Python, handle.memoryIndex(...)
in TypeScript, or UnifiedRuntime::memory_index /
MetaRuntimeHandle::memory_index from Rust (the Rust MobHandle has no
ledger method). There is no agent-facing tool that wraps the ledger; a host
that wants members to write assertions exposes its own tool over one of those
handles.
When using the Python or TypeScript SDK through the stock rpc_gateway, configure the ledger with memory.local_json() (Python) / memory.localJson() (TypeScript), optionally passing a health-check endpoint, and enable .persistent_state(path). The gateway derives the state file as <persistent_state>/memory-ledger-state.json and never reads another name. A ledger file the predecessor (pre-0.9) gateway left under persistent_state refuses startup, whether or not memory is configured, until the stopped installation is adopted once with meta_gateway storage-adopt-predecessor --state-dir <persistent_state> --apply; the refusal names the exact directory. Adoption renames the committed file in place, byte for byte, and refuses to touch a leftover stage of an interrupted write or a renamed ledger already beside it. Unsupported fields such as stores, space_id, and collection are rejected at init so unsupported configuration does not become a silent no-op.
Indexing
Index an operational assertion with aMemoryIndexRequest:
conflict: Some(true), optionally with conflict_reason, to record or update a conflict signal. For a conflict-only write, set fact: None; a non-empty fact together with conflict: Some(true) records both an assertion and a conflict signal.
Querying
Query memory with aMemoryQueryRequest:
entity, topic, and store (exact matches on trimmed, ASCII-lowercased tokens). Empty normalized filters and unsupported store filters are treated as absent, not as validation errors. The optional query parameter is applied afterwards as a case-insensitive substring filter across entity, topic, and fact (reason for conflict signals). Semantic/vector search should be supplied by a provider or upstream Meerkat memory surface rather than assumed from this RPC.
Agent memory injection
Gateway users can enable identity-first injection with:<persistent_state>/agent-memory. Records are injected as a quoted observation block for the matching durable identity. Contextual recall receives the raw turn or RPC query_text, ignores common stopwords, scores matching records, and size-caps injected records. Existing markdown files under a realm’s legacy subdirectory are parsed only when that realm is first accessed, imported through the SQLite validator, and renamed rather than deleted; malformed records are skipped instead of being promoted into runtime context. Automatic build/turn injection is time-bounded by recall_timeout_ms (default 500) and defaults to recall_failure_policy: "skip", so provider slowness or outage does not block normal delivery. Set recall_failure_policy: "fail" for fail-closed deployments. Explicit meta/agent_memory/recall calls still return provider errors to the caller.
Apps write identity-scoped agent memory explicitly through meta/agent_memory/remember, TypeScript handle.rememberAgentMemory(...), or Python handle.remember_agent_memory(...); read it through meta/agent_memory/recall, handle.recallAgentMemory(...), or handle.recall_agent_memory(...); and delete individual records through meta/agent_memory/forget, handle.forgetAgentMemory(...), or handle.forget_agent_memory(...). A successful remember means the configured hot identity-memory provider durably accepted the record and it is available to the next identity-first send for that identity, plus build context on materialization/resume/respawn/reset. It does not imply external memory hub entity extraction, truth maintenance, or document/vector indexing has completed. Custom providers receive both raw query_text and normalized query_terms so they can implement stronger retrieval without changing Meta runtime authority.
Agent memory is identity-scoped rather than session-scoped. A session respawn or identity reset does not delete stored memory; apps that need privacy reset semantics can call meta/agent_memory/forget for specific records or use a provider that clears memory as part of the app’s identity lifecycle. forget removes records from future recall and injection, but it cannot remove text already delivered into an active model context; reset or respawn the identity after deletion when immediate context revocation matters.
On disk, the agent-memory root under the state directory is
agent-memory/ — the canonical name owned by MetaStorageLayout; the
legacy agent-memory-sqlite/ spelling remains readable through
canonical-name-first probing, and both existing at once refuses startup as
file-name twins (run meta/storage/doctor). Per-realm files beneath the
root stay feature-owned. Like every durable slot, the agent-memory store
appears in the storage census (meta/status → storage.slots) with its
durability class and resolution; see
Configuration → Storage layout and durability.
The memory recorder tool
With the SQLite store (the default), identity-first members get a capability-gated memory tool: remember, update, forget, recall, and propose_to_mob. Writes carry real agent authorship and always enter at the agent_observed trust tier or below — an agent that verified a fact records the verification as a claim in provenance (epistemic: "verified_claim" plus verification_evidence); the trust-tier upgrade is a steward-only reviewed operation. update supersedes a record within its own lineage in the agent’s own identity scope; cross-identity updates are rejected by the store validator. propose_to_mob queues a proposal for mob scope — a steward or operator must commit it. Tool results are an indexing-excluded message class, so confirmations and recall payloads never echo into session semantic memory. Disable the tool with agent_memory.recorder_tool: false (default true when agent memory is enabled).
Content trust and session taint
A memory tool that can be poisoned by prompt injection is worse than no memory tool, so the recorder ships with a deterministic firewall:- Content-trust classification (
agent_memory.content_trust): dispatch-time classification joins recognized tool results to the request tool catalog’sToolDef.provenance. Typed MCP provenance usessource_idas the server name even when the tool name is unqualified. The asynchronous observe-stream fallback remains name-based. Classification precedence is: always-untrusted builtin names (web_search,web_fetch,fetch,http_request),untrusted_tools,trusted_tools, typed MCP server attribution againsttrusted_mcp_servers, qualifiedmcp__<server>__<tool>fallback when provenance is absent or non-MCP, then trusted. MCP servers not in the allowlist are untrusted. Explicit tool rules precede server rules but cannot override the always-untrusted builtin names. Provider-nativeServerToolContentis always untrusted. Use explicit overrides for intentional policy or tools without usable attribution, not merely because an MCP tool name is unqualified. - Session-sticky taint: once a session ingests untrusted tool content, every LLM-authored memory write from that session lands
quarantined— stored, auditable, but never injected or recalled until review. Taint clears only at a fresh-context boundary (reset, respawn, fresh spawn); compaction does not clear it. - Write posture (
agent_memory.llm_writes):"observed"(default) quarantines only tainted-session writes;"quarantined"is the stricter policy and quarantines every LLM-authored write pending review.
rpc_gateway with its SQLite agent-memory stack and
UnifiedRuntime’s full-stack persistent_agent_memory_stack composition fill
the DispatchTaintSlot and decorate member LLM clients. Recognized untrusted
tool results are marked before the LLM request that consumes them; typed
provider-native server-tool blocks are marked before the model response returns
for tool dispatch. These paths do not wait for the asynchronous observer before
a derived LLM memory write reaches the shared tracker-backed store gate. The
observer remains a fallback.
This is a composed-path ordering guarantee, not general prompt-injection
immunity or a guarantee for unclassified content. An unfilled slot is
pass-through; custom or classic-provider compositions need equivalent tracker,
decorator, and write-gate wiring. The stricter "quarantined" posture is not a
workaround for an unshipped stock dispatch-order fix.
Application/operator writes (the meta/agent_memory/* RPCs) are non-LLM principals and are never quarantined by this machinery. Quarantined records leave quarantine only through review: the memory steward’s dream, the gating flow for steward-staged promotions, or an operator decision (see Reviewing quarantined records). llm_writes and content_trust require store: "sqlite". Selecting store: "markdown" is refused before a runtime can start; combining it with these SQLite-only knobs is rejected earlier during config validation.
Conflict detection
Conflict signals are recorded throughmeta/memory/index with
conflict: true and an optional conflict_reason. When competing claims exist
about the same entity/topic, Meta’s gating subsystem checks for an active
conflict before approving an action.
A MemoryConflictSignal carries:
- The
entity,topic, andstorethe conflict is scoped to - An optional free-form
reason - The
updated_at_mstimestamp of the most recent signal
Health checks
Whenhealth_check_endpoint is configured, the ledger adapter performs a TCP-level health check against it once at the startup load and before every persist (the whole-file rewrite that follows each meta/memory/index). There are no per-query file reads to gate. The health check:
- Resolves the endpoint hostname
- Opens a TCP connection with
MEMORY_LEDGER_HEALTHCHECK_TIMEOUT(2s) - Sends an HTTP GET to
/v1/health - Verifies a 2xx status response
LocalJsonMemoryStoreError::ExternalCallFailed with diagnostic details. Without a configured endpoint, no health check is performed.
Memory store info
meta/memory/stores returns a MemoryStoreInfo entry per supported store:
record_count counts stored assertions plus active conflict signals for the store. This is available through the RPC API for monitoring and diagnostics.
Reviewing quarantined records
A quarantined record waits for review. Without a memory steward (or alongside one), an operator decides it from the console withmeta/memory/quarantine/decide:
- release makes the content active memory of the same identity. The store creates the successor
<memory_id>-releasedatagent_observedwithderived_fromthe original and tombstones the original. Nothing is deleted: the original keeps its body, provenance and timestamps. A quarantined update (one thatsupersedesan active record) is released as a supersede of that record, so only one version stays active. - tombstone discards the record; it is never recalled.
- The successor stays capped at
agent_observedforever: it carries the durableever_quarantinedmarker, and any later retier or update aboveagent_observedis rejected. - Content matching a secret pattern is never released (
secret_detected); tombstone is its only exit. expected_content_hashbinds the decision to the content the reviewer read; a different hash is refused (content_mismatch).- Repeating a decision returns
already_releasedoralready_tombstonedwith the original decision and writes nothing. A replay is recognized only from the review’s own audit evidence; a row that merely holds the successor id is refused (successor_conflict), and a record tombstoned by a plain forget is not reported as reviewed. The receipts show the records as they stand now, so a successor forgotten later stays forgotten. - A record a gated promotion is waiting on cannot be released (
gate_pending, withexpires_at_ms) until the gating flow decides that promotion or it expires. Gated promotions expire after seven days, the same rule the steward’s dream applies; a review after that expires the promotion itself (discarding its staged batch), so a promotion orphaned by a restart never blocks review for good, steward or not. - An operator can still tombstone it. The tombstone invalidates the record’s pending promotions in the same transaction (their mappings expire and their staged batches are discarded; the decision lists them as
invalidated_promotions), so a later approval publishes nothing, and a late denial or escalation leaves the invalidated promotion as it is. If the promotion was approved first, it stands and the tombstone is refused (not_quarantined). Only an operator’s tombstone has this exception. - Releasing an update whose earlier version is gone (missing, forgotten or replaced) is refused (
stale_update); tombstone it instead.
-32043 with data.kind = "memory_quarantine_review_refused" and data.reason. A result carries outcome, receipts for origin and successor (status, trust, ever_quarantined, content_hash, lineage) and the committed decision: its audit token and time, and the review itself (verdict, reviewer, origin, successor, bound hash, the original’s quarantine reason, expired promotions, invalidated promotions, rationale). Each applied op writes one audit row whose detail.review holds that review. The reviewer is the authenticated console principal, never a caller-supplied name; on a host running without app authentication it records that no principal was known.
Deciding needs memory.quarantine.review plus agent.view and agent.memory.read on the identity, and agent.memory.write to release or agent.memory.delete to tombstone (see Access control). With access control on, an anonymous caller is held to the same grants. Read-only consoles refuse it. The identity’s session taint is unchanged: later writes from a still-tainted session quarantine again and are reviewed the same way. Mob-, operator- and realm-scoped quarantine stays with the steward and the gating flow.
Quarantined content stays reviewer-only after it is decided: a released or tombstoned original, and a quarantined write removed by forget, still need memory.quarantine.review to read, while a released successor is ordinary memory. A review emits its memory.quarantine.verdict timeline event only once the decision has committed; the event names the record, the verdict and any successor, never the reviewer or the rationale. The steward’s open-loop nudge (verdict open_loop_escalated) uses the same event and names only the record, without the dream’s rationale.
The store operation behind it is StewardStore::review_quarantined, which the bundled SQLite store implements. The memory steward’s dream runs the same operation for its release and tombstone verdicts, recorded with reviewer {"kind": "steward", "run_id": ...}: whichever reviewer commits first decides the record, and the other gets the replay or a refusal. A steward hold changes nothing and emits no verdict event; a promote_pending_gate verdict reports through its gate (memory.promotion.pending_gate).
Console Memory panel
When the bundled SQLite agent-memory store is wired, the console gains a Memory panel: trust through inspectability. Its reads are served by tenmeta/memory/panel/* RPC methods, subject to provider capability and the caller’s access grants. Its one mutation is the quarantine decision above, offered as Release and Discard (the tombstone verdict) on a quarantined record’s detail view:
These six additional methods accept optional
realm selection; all except
overview also accept limit. The current injections handler does not parse
identity, session-key, or surface filters proposed in the design notes.
Memory lifecycle events (memory.dream.*, memory.record.promoted, memory.quarantine.verdict, memory.write.quarantined, memory.taint.transition, memory.hygiene.*, …) flow onto the console timeline as standard envelopes and render in the timeline and Signals surfaces.
Visibility is governed by the access-control memory actions: identity-scoped records need agent.memory.read + agent.view on the identity, mob-scoped records need mob.memory.read, operator-scoped records need operator.memory.read, realm-scoped records and dream history need an unscoped agent.memory.read grant, and quarantined content (including a tombstoned record that was ever quarantined) needs memory.quarantine.review. The other realm-level reads (overview, proposals, injections, harvests and audit_verdicts) are called on the unscoped grant and return only the rows the caller may read by these rules: a scope row or a proposal by its scope, a harvest by its identity, an audit verdict by its record, and an injection-ledger row, here and in a record’s detail, only when the caller may read both the record and the identity it went into. Rows are judged under the name the console gives an identity: on a shared Console that is <namespace>/<identity>. An identity whose attributes the console does not know (memory written for a name no member holds) is withheld while a deny scoped by a role, a label or another agent applies to agent.memory.read or agent.view. Dream history (dreams, dream_runs) spans a realm’s scopes and can describe quarantined content, so it lists a realm’s runs only to a caller who may read every memory scope the realm names (the scopes of its records, proposals and gated promotions, and its harvested identities) and, while the realm holds quarantined content, also holds memory.quarantine.review. For any other caller those runs are left out; withheld_by_access is true when access withholds the dream history of at least one realm, whether or not that realm has runs (false otherwise). The record listing cuts each page from the rows the caller may read, and its next_cursor names the page’s last row and comes exactly when more of them follow; a page holds at most limit rows, more only to keep together the rows two realms hold under one memory id and update time. The other filtered listings apply limit to the store’s rows before the access filter, so a restricted caller can receive fewer rows than limit. The panel’s nav entry appears when the experience projection reports memory.can_read.
Embedders wire the panel by handing the runtime any panel-capable store handle (Arc<dyn MemoryPanelStore>; the bundled SQLite store advertises it via provider.as_memory_panel_store()): runtime.set_memory_panel_store(panel) before building the console router. Providers that do not advertise MemoryPanelStore leave the panel methods unadvertised. A custom MemoryPanelStore should implement holds_quarantine_evidence and realm_scopes, which the dream history check uses: under access control the defaults answer that every realm holds quarantined content, so the history goes only to memory.quarantine.review holders, and list only the scopes of its records, from scope_overview, which reads every record. It should also implement proposal_scopes, which resolves the source of a gated promotion made from a proposal: the default resolves none, so under access control such a promotion is listed to no one. Its records_page returns rows past the cursor it is given, and a next_cursor past that cursor too: the record listing refuses a store whose cursor does not move.
See also
- Gating — conflict-aware approval gates
- Access control — the memory read actions governing panel visibility
docs/design/memory-hub-roadmap.md(in the repository) — the roadmap for a real external memory hub- RPC API —
memory.*methods
