Skip to main content
loomcycle
§ architecture · memory

The loomcycle memory architecture, drawn.

This is the companion to the memory testing story. That post told the chronology: which benchmarks landed, what they found, what got fixed. This post draws the memory system as it now stands: five storage planes reached through one Memory tool, a bi-temporal timeline that makes every correction non-destructive, ontology-declared placement so tenant-scale knowledge has somewhere to live, hybrid retrieval with honest source selectors, provenance on every write, and the RFC CT P2 bridge that turns a span the context distillation would have thrown away into a durable memory row.

Nothing in this post is speculative. Every element is in production as of v1.75.0.

The map. One Memory tool. Five storage planes: key/value (semantic recall), vector (embedding-backed nearest-neighbor), SQL Memory (per-scope relational store for structured queries), chunked-graph Documents (hierarchical Markdown with typed chunks), and an entity graph layered on the Document chunks that carries facts with subjects, types, edges, and bi-temporal validity. Four scopes: agent, user, tenant, global. Three time axes on every row: observed_at (when the thing was said), valid_at / invalid_at (when the thing was true), created_at (when loomcycle wrote it). Ontology-declared placement decides which scope a fact about a given entity type belongs in. Hybrid retrieval combines a vector search and a full-text search with Reciprocal Rank Fusion, filtered on as_of, source-selectable across facts / notes / documents. Every write carries a provenance envelope (tenant, session, run, agent, model, compaction generation, and an exact source-quote span the fact was derived from). And a span the context distillation was about to evict can be banked to this same memory through context.harvest_to_memory so nothing useful is lost when a run compacts.

The five planes, one tool

An agent reaches memory through one tool. The tool's ops fan out to the plane that stores the row.

flowchart LR
  A[Agent] --> M[Memory tool]
  M -->|"set/get/list/delete
recall/search"| KV["key/value plane"] M -->|"embedded via KV
vector search"| VEC["vector plane"] M -->|"sql_exec / sql_query"| SQL["SQL Memory
per-scope database"] M -->|"Document ops
reach chunks"| DOC["chunked-graph
Documents"] DOC -.->|"chunks carry
entity metadata"| ENT["entity graph
facts + subjects"] ENT -->|"graph_recall
list_facts"| M style KV fill:#e8f4ff,stroke:#4a90e2 style VEC fill:#e8ffef,stroke:#4a9e60 style SQL fill:#fff8e0,stroke:#c99a3c style DOC fill:#f8ecff,stroke:#8b6cbf style ENT fill:#ffe8e8,stroke:#c86a5c
The five storage planes, reached through one Memory tool. Facts in the entity graph are chunks in the Document plane that carry entity metadata.

The two right-hand planes are where the interesting structure lives. Documents carry Markdown bodies plus typed structure. A chunk that carries entity metadata (a subject name, an entity type, a fact class) participates in the entity graph. A chunk that does not is just prose. The same store; two different views.

Scopes: the four boundaries

Every memory op names a scope. The scope decides whose data the op reaches. The runtime resolves the scope's owner id server-side from the run identity, never the wire; a model-supplied owner id would let one tenant's agent read another's data.

flowchart TB
  R["Run
tenant + subject + agent"] R --> S1["scope: agent"] R --> S2["scope: user"] R --> S3["scope: tenant"] R --> S4["scope: global"] S1 --> D1["agent's own namespace
this agent, this tenant"] S2 --> D2["end-user's namespace
this user, this tenant"] S3 --> D3["shared tenant namespace
this tenant"] S4 --> D4["all tenants
ADMIN ONLY"] style S4 fill:#ffe8e8,stroke:#c86a5c style D4 fill:#ffe8e8,stroke:#c86a5c
Four scopes. Owner id resolved server-side from run identity, never wire-supplied. Global is admin-only and fails closed.

A memory read touches exactly one scope. This is a load-bearing invariant: an agent asking "what does the organisation know about X" against scope: user gets the user's answer, not the tenant's. The remedy is a second call at scope: tenant, merged by score by the caller. Both tools state this in their descriptions now, because "one read, one scope" was the source of enough operator confusion to earn a rule.

Bi-temporal validity: a correction never destroys the record

A fact on loomcycle carries three timestamps. observed_at is when the thing was said (a caller writes it). valid_at is when the thing became true in the world (defaults to now). invalid_at is when the runtime stopped believing it. created_at is loomcycle's own clock (when the row was written).

A correction never has to delete the old record. It writes a new fact and marks the old one superseded. The old fact stays queryable at any past instant, so "what was true on this date" answers correctly.

stateDiagram-v2
  [*] --> live: write fact
valid_at set live --> live: retrieval matches
if as_of >= valid_at
and (invalid_at IS NULL or as_of < invalid_at) live --> superseded: new fact written
invalid_at stamped on old
supersede edge points to new superseded --> superseded: as_of point BEFORE invalid_at
still returns old value live --> future_invalid: known end date written
invalid_at set in future future_invalid --> future_invalid: as_of BEFORE invalid_at
still valid, do not filter out future_invalid --> retired: wall-clock reaches invalid_at retired --> retired: as_of BEFORE invalid_at
still queryable at past point
Bi-temporal state machine. A fact never deletes; it either supersedes or retires. The as_of predicate reads the timeline at any point.

The design property that costs the most and buys the most: a fact with a known future end date does not disappear today. "The contract runs until 2027" stays valid in every default recall right now, because the filter compares against the current time. It stops being valid when the wall clock reaches 2027. That is a rule small teams don't always think through until a fact vanishes on a Tuesday morning; loomcycle enforces it at the retrieval layer.

Ontology-declared placement: which scope a fact belongs in

Not every fact belongs in one user's scope. "The checkout-api service requires two approvals" is a tenant-shared fact: every user in the tenant needs it, and each of them re-learning it from their own conversations is a bad outcome.

Placement is operator config, not per-fact inference. An entity type in the tenant ontology may declare which memory scope facts about that kind of thing belong in:

## service
- `@memory_scope` tenant
- `name`: what people call it
- `owner`: who to page

When the consolidator writes a new fact about a subject whose type is service, the placement resolver returns tenant and the fact lands in the tenant plane. The decision is made once per batch, before either half of the fact is written, by the writer that owns both halves.

flowchart TB
  F["Fact about subject S
type = T"] --> Q1{"Is T declared
in tenant ontology?"} Q1 -->|no| DEF["Place in user scope
the default"] Q1 -->|yes| Q2{"Does T declare
@memory_scope?"} Q2 -->|no declaration| DEF Q2 -->|declares tenant| Q3{"Is subject S
typed consistently?"} Q3 -->|inconsistent| DEF Q3 -->|consistent| Q4{"Is S the profile
owner name?"} Q4 -->|"yes, user's own fact"| DEF Q4 -->|no| Q5{"Is caller
isolated?"} Q5 -->|isolated user| DEF Q5 -->|not isolated| Q6{"Does writer hold
tenant on BOTH
memory_scopes and
sql_scopes?"} Q6 -->|no| DEF Q6 -->|yes| PLACE["Place in tenant scope
k/v row + entity chunk
both in tenant"] style PLACE fill:#e8ffef,stroke:#4a9e60 style DEF fill:#e8f4ff,stroke:#4a90e2
Ontology-declared placement decision tree. Every uncertainty declines to move the fact. Declining costs what the system already costs; moving one wrongly is not recoverable.

The asymmetry is deliberate. Every uncertainty (undeclared type, no ontology, inconsistently-typed subject, subject matches profile owner, isolated caller, missing grants) declines to move the fact. Declining costs exactly what the system already costs: the fact stays in one user's scope. Moving one wrongly is not recoverable.

Two halves of a fact travel together, or neither does. A fact is stored twice: the k/v row semantic recall searches, and a chunk mirror the entity graph walks. Split those across scopes and recall finds the fact in one place while graph_recall finds it in another, which is worse than never moving it. A tenant placement consequently needs the tenant grant on both memory_scopes and sql_scopes.

Retrieval: hybrid, source-selectable, time-aware

A memory read is not one call to a vector store. It is a pipeline.

flowchart LR
  Q["query text
+ scope
+ optional as_of
+ optional sources"] --> V["vector search
cosine over embeddings"] Q --> F["full-text search
BM25 or store-native"] V --> RRF["Reciprocal Rank Fusion
combine rankings"] F --> RRF RRF --> FILTER["filter by
observed_at window
as_of predicate
source selector
tenant fold"] FILTER --> RESULT["ranked results
with kind label
fact | note | document"]
The recall pipeline. Vector and full-text search happen in parallel; RRF fuses the rankings; predicates and selectors filter the result set; every hit carries a kind label.

Loomcycle exposes two ops on this pipeline. recall is the "what have I been told about X" call and defaults to facts + notes, because a distilled fact and a written note are what an agent asked "what do you remember about the user" should get first. search is the wider call: "where did I record this, across every plane." Explicit source selectors narrow it. A backend that ignores the selector must say so via a sources_applied: false flag; the zero value is false on purpose so a silently-widened result cannot pass as filtered.

The as_of predicate walks the bi-temporal timeline. Ask "what medication was I on in April" against a fact whose valid_at was March and invalid_at was May, and the fact returns. Ask the same question in July after the medication changed, and the current fact returns. Both queries answer correctly because the store keeps both rows.

Provenance: every write carries its receipt

Every row in memory (fact, note, document chunk) carries a provenance envelope. Not "who wrote this" in the loose sense; the exact context that produced the row.

ColumnWhat it holds
tenant_idServer-stamped from the run identity. Never wire-supplied.
source_session_idThe session the writing run belonged to.
source_run_idThe specific run that produced the write.
source_agentThe agent name that owned the run.
source_modelThe provider + model that produced the extraction (empty for operator writes).
originThe class of writer: consolidator, operator, agent, compaction. Server-stamped from the caller's identity; a model cannot promote a note to a fact by labelling it.
compaction_generationThe generation of the compaction that produced the row, when the row was extracted from a compacted span.
source_quoteThe exact transcript span the fact was derived from (RFC CC P1).
judged_by + judged_atWho reached the verdict on this fact and when. An agent's verdict carries the agent's name; an operator's verdict carries theirs.

An operator asking "why does the runtime believe this fact about a user" can follow the row back to the conversation the fact was extracted from, the model that extracted it, the compaction generation the extractor saw, the exact quote span it was drawn from, and whoever affirmed or refuted it later. Nothing is a black box.

Harvesting from distillation: how a fact born in a run outlives it

This is the bridge to the context distillation post. Every context retention mode discards work by design: a compaction drops the turns behind its cut, a recap keeps the reasoning and drops the rest, a stateful step feeds forward (Σ, O) and discards how it got there. That is the point of them, but the dropped span often contains the one useful fact the run learned.

RFC CT P2 (v1.74.0) makes those spans harvestable. An agent that opts into context.harvest_to_memory hands every evicted span to the memory consolidator, at the three distillation boundaries (compaction cut, recap boundary, stateful boundary). The consolidator extracts as usual, applies ontology-declared placement, and writes durable facts.

flowchart LR
  RUN["Run in progress
context window filling"] --> BOUND{"Distillation
boundary?"} BOUND -->|compaction cut| SPAN1["evicted span"] BOUND -->|recap boundary| SPAN2["evicted span"] BOUND -->|stateful boundary| SPAN3["evicted span"] SPAN1 --> BANK["banking callback
enqueue on
consolidation queue"] SPAN2 --> BANK SPAN3 --> BANK BANK --> CONS["consolidator
whole-batched extraction
keeps coreference facts"] CONS --> ONT["placement resolver
ontology-declared scope"] ONT --> WRITE["durable fact
in the right scope"] style WRITE fill:#e8ffef,stroke:#4a9e60
The harvest bridge. Distillation boundaries call back with the span that would have been discarded; the consolidator extracts and places facts. Whole-batched, not per-span, because per-span extraction loses coreference-dependent facts.

Two design decisions in that diagram deserve naming. First, banking, not inline extraction: raw spans go on the consolidator's queue, not through a per-span model call on the distillation hot path. RFC CU Probe 2 measured this against per-span isolated extraction on a coreference-dependent corpus: 0.75 vs 0.00 (McNemar p=0.0010). A subject named two spans earlier survives whole-batched extraction and dies in per-span. Broader context and no model call on the distillation path is the right shape.

Second, a banking failure never fails the run. The distillation itself is critical path; the harvest is opportunistic. If the consolidator's queue is full or the store is unreachable, the span is dropped from the harvest and the distillation proceeds. A misconfiguration (no store, no user scope, no user_id) surfaces as an EventError once, so operators see it, but no individual run fails.

The complete picture

Put the five planes, the four scopes, the three time axes, the placement decision tree, the retrieval pipeline, and the harvest bridge together, and one diagram covers what an agent actually touches when it says "remember" and "recall."

flowchart TB
  subgraph A["The Agent"]
    A1["set / recall / search
graph_recall / list_facts"] end subgraph SCOPES["scope layer"] SC1["agent | user | tenant | global
owner id server-stamped"] end subgraph PLACE["placement layer"] P1["ontology.@memory_scope
decision tree
fail-closed on uncertainty"] end subgraph STORE["five planes"] S1["k/v"] S2["vector"] S3["SQL"] S4["Documents"] S5["entity graph"] end subgraph TIME["time axes"] T1["observed_at
valid_at / invalid_at
created_at
+ as_of predicate"] end subgraph PROV["provenance"] PR1["tenant, session, run
agent, model
compaction_generation
source_quote
origin (server-stamped)
judged_by, judged_at"] end subgraph BRIDGE["distillation bridge"] B1["harvest_to_memory
at compaction | recap | stateful
banks span to consolidator"] end A1 --> SCOPES SCOPES --> PLACE PLACE --> STORE STORE --- TIME STORE --- PROV BRIDGE --> STORE style STORE fill:#fef7e6 style TIME fill:#e8f4ff style PROV fill:#f8ecff style BRIDGE fill:#e8ffef
The whole subsystem in one picture. Every op flows down through scope resolution, placement, and one of the five planes. Every row carries time axes and provenance. Distillation bridges into the store through the harvest path.

What this makes possible

The picture is the point of the arc. Not the individual features, the composition.

A chat agent asked "which medicine did I use in April" reaches into scope: user with an as_of in April. The recall pipeline runs vector + full-text with RRF, filters by the as_of predicate, and returns the fact that was valid then, even if the current medication is different. The fact carries the transcript span it was drawn from, so the answer surfaces a verifiable citation. If the fact turned out to be wrong, the operator overrules it via the memory console; judged_by records their name, and the fact is withheld from future recalls without being deleted.

An engineering team's tenant knowledge ("the checkout service requires two approvals") is filed under service, which the ontology declared as @memory_scope tenant. A new team member's agent reaches into scope: tenant and gets it without anyone re-teaching. The provenance chain shows which conversation established it originally.

A long-running data-analysis agent's context window fills. The compaction cuts the older turns; the recap keeps the reasoning; the harvest bridges the discarded span into the consolidator's queue. The consolidator extracts, places, writes. Six months from now the same agent (or another agent in the same tenant, if placement moved the fact) can recall what was learned in that dropped span.

That last case is where the memory testing story from the chronological post meets the architecture drawn here: unit tests would have missed every seam, benchmarks found each one, and the picture that lets you reason about a fact from the moment it was said to the moment it was recalled six months later is what the fixes composed into.

Companion reading: the memory testing story; context distillation with state diagrams; agentic memory (the v1.33-v1.49 arc that got this substrate started); the external data plane (v1.54). Reference docs: docs/MEMORY-BACKENDS.md.