The memory model, and what the approval gate actually holds
One table behind a LangGraph BaseStore, scope carried in the key, a read-only filesystem mount, and a middleware that puts approved rules on the system message. Plus the migration renumbering the release train forced, and an honest account of the one write path the gate does not structurally cover. Release notes 2026-09-16.
Status: shipped. Commits 38c975e4, 411574da, 5d2a1ffa, 537a78e4, 31589fca and 384a2d49 on main, in v2.82.0 (which is the merge commit 4c5af85a). Migrations 0103–0107. One follow-up commit, 7f5ebfaf, is on main but not in that tag — see the last section. Part of the 2026-09-16 release notes; the user-facing half is Memory you can read, edit, scope and approve.
The design constraint was one sentence: an agent must be able to read its memory and must never be able to write it. Everything below follows from taking that literally rather than prompting for it.
Two tables, and scope lives in the key
0103_memory_store.sql creates the store and its manifest.
memory is a generic key–value table shaped to back a LangGraph BaseStore: id, org_id, namespace text[], key, value jsonb, expires_at, last_used_at, updated_at, created_at, unique on (org_id, namespace, key).
Two of those columns are decisions worth naming. expires_at is null for a rule and set for an episode, and every read carries (expires_at is null or expires_at > now()) — expiry is a read predicate, not a cleanup job. last_used_at is its own column specifically so stamping a mount does not churn updated_at; the UI shows never used for a namespace nothing reads, and that would be worthless if every read looked like an edit.
memory_namespace is the whitelist: name, scope_kind, scope_ref, path, title, description, preamble, agent_slugs. It replaces the old learning_step table. A namespace with no row here cannot be mounted, which is the mechanism behind several of the guarantees below.
The BaseStore namespace tuple is ['memories'] for everything. Tenancy rides org_id as a column, and scope rides the key, which is a path:
scopeKind | key path |
|---|---|
workspace | workspace/<name> |
object | workspace/objects/<ref>/<name> |
agent | agents/<ref>/<name> |
user | users/<ref>/<name> |
workflow | workflows/<ref>/<name> |
mission | missions/<ref>/<name> |
run | runs/<ref>/<name> |
Putting scope in the key rather than in the namespace array is what makes prefix search do the work: episodes are like(key, '/runs/%'), and the filesystem mount is a path rewrite rather than a lookup table.
The store
DrizzleMemoryStore extends BaseStore in libs/memory/store.ts. It throws on construction without an org:
DrizzleMemoryStore requires an orgId — the store is tenant-scoped by construction
That is the isolation guarantee, and it is a constructor invariant rather than a filter someone could forget. One of the six store tests puts identical namespace-and-key rows in two orgs and asserts neither can see the other.
batch() dispatches to get / put / search / list. A put with a null value deletes; upsert is onConflictDoUpdate on (orgId, namespace, key). Search orders by (namespace, key) ascending because the backend paginates by offset and an unstable order would silently skip rows. Filters support $eq $ne $gt $gte $lt $lte against top-level value fields.
There is no semantic search, and the file says so: a search with query returns the same rows as one without, unranked. No embeddings on rules. Worth knowing before designing against it.
The mount is read-only, structurally
The agent loop composes its filesystem and wraps the memory mount:
backend: new CompositeBackend(new StateBackend(), {
'/memories/': readOnlyBackend(new StoreBackend({
store: new DrizzleMemoryStore(orgId),
namespace: MEMORY_STORE_NAMESPACE,
})),
}),
readOnlyBackend is a Proxy intercepting write, edit and uploadFiles, refusing with:
read-only: approved memories can only change through the review queue (approval is the only write path). Propose a rule instead of writing this file.
It is deliberately not the framework's own permission rules: those throw, and a thrown tool error aborts the whole turn — verified live. A refusal the model can read and act on is a better failure than a dead turn.
The container runtime has no database, so its composite mounts a read-only StateBackend instead and the files ride the invocation payload. The two readOnlyBackend implementations are byte-identical apart from a header comment, because the artifact cannot import core modules.
Getting approved rules to the model
The bug that started this line: rules were mounted and nothing announced them. The framework's skills auto-loader enumerates /skills/ and /playbooks/ only, so an approved rule applied only if the model happened to ls.
The fix is a LangChain middleware, createMemoryDigestMiddleware(), mounted unconditionally in both loops. It renders every file under /learnings/ and /memories/ into an <approved_learnings> block appended as an extra text block on the existing system message — not a second system message — followed by guidance that calls the rules requirements, states that approval is the only write path, and names remember_preference as the single exception.
Two details that matter if you build something like it. Files are sorted lexicographically and packed whole against MAX_DIGEST_CHARS = 24_000; an oversized file is dropped, never truncated mid-sentence, and a trailing line reports how many were omitted and where to read them. And the middleware declares files in its stateSchema — without that the files channel is invisible to it and the digest is silently empty, which is exactly the failure mode being fixed.
Precedence is not applied in the digest. It is applied earlier, in assembleAgentMemory, which decides which files exist: workspace policies → agent procedures → workflow learnings → mission context → user preferences, ranked by layer, then occurrence count, then recency, packed greedily into a 24,000-character budget. A namespace preamble is pinned so it cannot age out before its own rules.
The framework's own memory middleware was considered and rejected: its prompt tells the agent to edit memory files itself, which would walk straight through the gate.
The gate — and where it is instructed rather than structural
decideCandidate is the enforcing function. Statuses are pending | approved | rejected; there is no edited state, because an edit writes edited_rule_text and leaves the candidate pending. Anything not pending returns already_decided. On approve it resolves the target namespace, runs the near-duplicate check, removes any rules the candidate declares it replaces before adding the merged one, writes the rule, then stamps status, created_memory_key, decided_by and decided_at. A rejection without a reason is refused. A pending candidate exists only as a learning_candidate row — nothing is in memory until approval.
Now the honest part, because this is the sort of claim a release post should not round up.
The filesystem route is sealed. An agent cannot write, edit or upload into /memories/, and that is enforced by a proxy, not a prompt.
The tool route is not. add_learning, update_learning and remove_learning are registered for every agent and call the rule-writing service directly. There is no human-in-the-loop interrupt on them and no approval requirement in their configuration; what governs them is their own description — "ONLY call this after the user has explicitly approved the candidate." That is instruction, not structure, and by this codebase's own standard (structural over prompting) it is a gap rather than a design.
There are three other non-candidate write paths, all human-driven rather than agent-driven: workspace apply seeding rules authored in YAML, the REST and oRPC admin surfaces, and the preference fast lane. The fast lane is genuinely fenced — it refuses a turn with no signed-in user, writes only to that user's own preference layer, still runs the duplicate guard, and lands an already-approved candidate on the decided list so a reviewer can revoke it.
So: every consolidation output is gated, and every agent filesystem write is refused. "Every write is gated" is the intent, and it is true of the paths this work built. It is not yet true of the three learning tools, and naming that is more useful than repeating the slogan.
Episodes and consolidation
An episode is not a table. It is a row in memory keyed /runs/<runKind>/<runId>/episodes/e<base36 ms>.md with expires_at = now + VOCION_EPISODE_TTL_DAYS (30). Three fire-and-forget writers: review decisions (carrying the proposal's confidence in the text), eval run outcomes, and run ratings.
Episodes have no memory_namespace row, and since assembly iterates manifest rows, that is the structural reason they cannot reach a prompt. The comment is blunt about why: episodes as live prompt input would be an ungated write path.
ConsolidationService runs three passes on the classifier role at temperature 0, all failing closed — an unparseable answer proposes nothing:
- compaction fires when a namespace exceeds
NAMESPACE_BUDGET(30) or any pair scores similarity ≥0.35. That threshold is deliberately below the add-gate's 0.72: the add gate catches restatements, and compaction is hunting related rules that drifted in through different wording. A merge naming fewer than two resolvable keys is dropped. - episode mining batches 40 and proposes nothing below a floor of three episodes. It goes through the same recorder human feedback uses, so deduplication and occurrence counting apply identically.
- amendment drafting targets workflow-scoped namespaces at or above five rules with no amendment already pending.
All three emit pending candidates only.
Scheduling is not Temporal and not cron. It rides the feedback worker's poll loop as an hourly in-process check, gated on ENABLE_CONSOLIDATION === '1' — off by default — and then per organisation on a cursor stored, pleasingly, in the memory table itself at /consolidation/state.json. npm run consolidate:run -- --org <id> runs one organisation once.
The migration renumbering
The scoped-memory work was built as 0099–0103 while the release/2026-09-15-rail train was independently building its own 0099–0102 — decision alignment, team measures, artifact version, Slack posts. Both landed the same night. 384a2d49 renumbers memory's five up past the train:
| was | is |
|---|---|
0099_memory_store | 0103_memory_store |
0100_memory_contract | 0104_memory_contract |
0101_candidate_type_scope | 0105_candidate_type_scope |
0102_candidate_eval_run | 0106_candidate_eval_run |
0103_candidate_replaces | 0107_candidate_replaces |
plus the concurrent index, with journal indices and timestamps moved to follow the train's last entry. The final chain is base 0000–0098, rail 0099–0102, memory 0103–0107; check:migrations is clean at 112 files.
The same commit wraps the expand step's copies and backfills in IF to_regclass('public.learning_step') IS NOT NULL guards, so re-applying against a database the contract step already cleaned is a no-op rather than an error. That is the property you want when two trains are renumbering around each other.
Two merge artefacts worth recording
The reconciliation merge kept both sides' additions rather than picking: conversation gained surface from the rail and context_json from main; learning_candidate kept the scoped-memory columns (created_memory_key, eval_run_id, replaces_keys) and the rail's source_ref. The canvas grid was deleted in favour of the rail's artifact model, which had already superseded it the previous afternoon.
And the merge shipped broken. Resolving a duplicated column block in Schema.ts removed the declarations but left the block's /** opener behind, which commented out everything after it — conversationMessageSchema and its relations vanished from the parse and the Next build failed with Cannot find name 'conversationMessageSchema'. The one-line fix had been made locally and never staged before the merge commit.
This is a tag caveat, not just an anecdote. v2.82.0 is the merge commit, so the tag carries the broken schema; 7f5ebfaf landed on main eight minutes after it and will be in the next tag. If you are pinning, pin main or cherry-pick that line.
Where this lives in the repo
- Store and read-only proxy:
packages/core/src/libs/memory/store.ts,readOnlyBackend.ts(mirrored atpackages/agent-runtime/src/readOnlyBackend.ts) - Assembly, namespaces, episodes:
packages/core/src/services/MemoryService.ts - The digest middleware:
packages/core/src/services/agents/memoryDigest.ts(mirrored inpackages/agent-runtime/src/) - The gate:
packages/core/src/services/LearningCandidateService.ts - Consolidation:
packages/core/src/services/ConsolidationService.ts, scheduled fromservices/FeedbackWorkerService.ts - Bucket selection and the hallucination guard:
packages/core/src/services/feedback/classifier.ts - Re-homing a misfiled rule:
packages/core/src/scripts/rehome-learning.ts - Loop wiring:
packages/core/src/services/agents/harness.ts,packages/agent-runtime/src/loop.ts - Schema:
packages/core/src/models/Schema.ts; migrations0103–0107 - Tests:
libs/memory/store.test.ts,services/MemoryService.test.ts,MemoryService.assembly.test.ts,agents/memoryDigest.test.ts,LearningCandidateService.fastLane.test.ts,ConsolidationService.test.ts,adoption/AdoptionService.{approvalTrend,confidenceAlignment}.test.ts
Environment variables, none of them yet in the example env file: ENABLE_CONSOLIDATION (default off), VOCION_CONSOLIDATION_INTERVAL_HOURS (24), VOCION_MEMORY_NAMESPACE_BUDGET (30), VOCION_EPISODE_TTL_DAYS (30), VOCION_DEBUG_MEMORY_DIGEST, VOCION_DEBUG_CONSOLIDATION.
Not implemented, verified by reading rather than assumed: no semantic search on the store; the workflow layer is handled by assembly but no caller passes a workflow slug, so it is reachable only through workspace YAML and the amendment pass; object scope cannot be created from the review card or the feedback loop, only from workspace YAML; run scope exists only as episode keys and consolidation skips it; memory_type, scope_kind and status are plain text columns with no database check constraints; eval_run_id has no foreign key, on purpose, because eval runs are prunable and a dangling id must never block deleting one.
Reported, not proven. The commit bodies carry live-verification notes — a classification landing in the right namespace, a four-into-one compaction, an eval pass rate stamped on a decision. There is no artefact in the repository backing them, and the post-merge state's claim of 3,364 passing unit tests is the author's own run. Treat them as reported.