Mnemosyne
Version control for AI agent memory.
An AI agent builds up memory as it works: facts it learns, decisions it makes.
Frameworks store that as state it overwrites as it goes. Mnemosyne gives agent
memory what Git gives code: commits, branches, merge, blame and bisect. A Rust
core, a mnem CLI, and a Python SDK. Local, deterministic, no network, no model
calls.
This is the reference documentation. For the project overview, the quickstart and the demo, see the README on GitHub.
What is here
- On-disk format: the
.mnem/store, specified in enough detail to write a reader in another language. Final for Era 1 atformat_version1. - Benchmark report: what the substrate delivers, measured. Reconstruction,
bisectprecision,blameaccuracy, merge correctness, and the overhead against a dict and a JSONL log. - Merge chaos report: the merge invariants over a 50,000-case seeded sweep.
- Architecture decisions: every decision that shaped Mnemosyne, and why it went the way it did. Accepted before the code.
- Research: the surveys that fed the decisions.
- Progress notes: what shipped in each release, in plain language.
Use the sidebar.
The three eras
- The substrate (now,
0.0.x): single-agent versioned memory. Local and deterministic. - The collaboration layer: semantic merge that reasons about contradiction, a sync protocol between stores, and a review step. Pull requests, for agent memory.
- The platform (
1.0): the whole agent as one versioned, signed, forkable artefact, with a registry. The GitHub for AI agents.
See ADR-0010.
On-disk format
The layout of a .mnem/ store: the object database, the refs, HEAD, and
config. This is the specification. It is enough to write a reader in another
language, and it is checked against the implementation by the tests in
crates/mnem-store and the golden vectors.
Status: final for Era 1
This describes format_version 1, and as of v0.0.7 it is final for the
Era 1 substrate. Every 0.0.x release reads and writes format_version 1, a
store written by one reads on every other, and the substrate (commit, branch,
merge, blame, bisect, time travel) needs no further field. The benchmark
(docs/benchmark.md) is the evidence that it does what it claims.
The next change is format_version 2 in Era 2, the collaboration layer,
whose pieces are named in ADR-0018: a stored Contradiction object (a kept
record of an incompatible-claims merge), whatever the sync protocol adds to a
Commit or the refs, and the prolly-tree State form if the benchmark's
storage numbers cross the trigger in ADR-0017.
Those are additive where possible (ADR-0002); where not, mnem migrate ships
with the release (ADR-0007). It is never a silent change: the golden-vector
test fails first.
The software version is 0.0.x initial development, where the public API may
change on any release. The format_version integer is the data contract,
and it is the thing to check, not the software version. A release that meets a
format_version it does not support refuses the store with a message naming the
version it would need.
A later format change bumps format_version by one and, if it would make an
older reader misread an existing object, ships mnem migrate (ADR-0007). It is
never a silent change: the golden-vector test fails first.
The prolly-tree state form and the signature table are named below but are not
part of format_version 1.
The store directory
.mnem/
store.redb the object, ref, staging and index tables (see below)
HEAD the current position, one line of text
config key = value lines
.mnem/ sits at the store root. Nothing else in the directory is read.
Discovery
open walks up from the given path to the nearest ancestor that contains a
.mnem/ directory, and uses that. init refuses to create a store at a path
that already has one, or anywhere beneath an existing store: stores do not nest.
Atomicity
config and HEAD are written by writing a sibling temporary file, syncing it,
and renaming it over the target, so a reader never sees a half-written file. A
commit is one redb write transaction (ADR-0008): it lands whole or not at
all.
config
A UTF-8 text file of key = value lines. Whitespace around the key and value is
trimmed. A line whose first non-space character is # is a comment. A blank
line is ignored. An unknown key is ignored, so a later version can add keys
without breaking an older reader's parse.
| Key | Value | Meaning |
|---|---|---|
format_version | integer | the format this store is written in, 1 for this line. A reader that supports up to version N accepts a store with format_version ≤ N and refuses anything higher. |
hash_algo | string | the object hash. blake3 is the only accepted value. |
default_branch | string | the branch HEAD points at in a fresh store, main unless overridden at init. |
All three keys are required. A missing key, or a hash_algo other than
blake3, is a corrupt store.
A fresh store:
# Mnemosyne store config. See docs/format/.
format_version = 1
hash_algo = blake3
default_branch = main
HEAD
One line of UTF-8 text, with an optional trailing newline. Either:
ref: <branch>— attached to a branch.<branch>follows the ref name rules below. The next commit moves that branch.<64 hex characters>— detached, pointing straight at a commit id. A commit from a detachedHEADis refused; check out a branch first.
Anything else is a corrupt store. A fresh store's HEAD is ref: main.
The database
One redb file, .mnem/store.redb. redb is a pure-Rust embedded key-value
store with a single-file, copy-on-write B-tree layout; its own file-format
version travels inside the file and is handled by the redb crate, pinned at
major version 2 (ADR-0008).
Five tables:
| Table | Key | Value | Holds |
|---|---|---|---|
objects | 32 raw bytes, an ObjectId | the object's canonical CBOR | every memory node, state and commit |
refs | branch name, UTF-8 | 32 raw bytes, a commit ObjectId | one row per branch |
staging | node id, UTF-8 | 32 raw bytes, a node ObjectId | the nodes staged for the next commit |
staging_tombstones | node id, UTF-8 | empty | the nodes staged for deletion (ADR-0012) |
commit_nodes | 32 raw bytes, a commit ObjectId | CBOR { node id -> "added" | "modified" | "removed" } | each commit's change set against its first parent (ADR-0015) |
objects is append-only in practice: an id is the hash of its bytes, so writing
the same object twice is a no-op and an entry is never rewritten. staging and
staging_tombstones are local working state and are not part of the portable
history; a fresh clone or a different machine does not carry them. commit_nodes
is a derived index: every entry is recomputable from the objects table
(Store::rebuild_index), it is not part of format_version, and a reader that
finds it missing or stale falls back to recomputing from the two states.
Object identity
An object's id is the BLAKE3 hash of its canonical CBOR bytes (ADR-0005). It is
32 bytes. In text it is 64 lowercase hex characters. In CBOR, anywhere an id
appears as a value, it is a byte string of length 32 (0x58 0x20 then the
bytes), never an array of integers.
There is no framing around an object: the bytes in the objects table are
exactly the canonical CBOR of the object, and the kind field inside that map
is what tells a reader what it is holding.
Canonical encoding
Objects are encoded as CBOR (RFC 8949) restricted to a deterministic profile, so that the same logical object always produces the same bytes and therefore the same id. The profile is RFC 8949 section 4.2 plus three Mnemosyne rules.
From RFC 8949 section 4.2:
- Map keys are sorted by the bytewise comparison of their encoded form. Because CBOR keys are length-prefixed, this is length-first: a shorter key sorts before a longer one, and keys of equal length sort bytewise. Every key in this format is a text string.
- Integers are in shortest form: the smallest of the 0, 1, 2, 4 or 8 byte encodings that holds the value. CBOR has no 3, 5, 6 or 7 byte integer, so a value needing five bytes is written in eight.
- Definite-length only: no indefinite-length strings, arrays or maps.
Mnemosyne's rules:
- Every float is a 64-bit CBOR float (
0xfb), never 16 or 32 bit. This is a legal narrowing of the profile and keeps float encoding unambiguous.contentnumbers follow the JSON data model and must be finite; a non-finite number is rejected before it reaches the store. - No CBOR tags. No value carries a tag.
- String-keyed maps only. Every map, at every level including inside
content, has text-string keys. No integer keys, no positional array standing in for a struct.
The encoder builds a CBOR value, sorts every map by encoded key, then writes it.
The codec tests check idempotence and that the key order inside content does
not affect the bytes.
Object kinds
Every object is a CBOR map with a kind entry. Three kinds. An unknown kind is a
clean decode error, not a silent skip.
memory_node
The versioned unit (ADR-0003). Successive writes with the same id are updates
to one logical node; each write is its own immutable object.
| Field | CBOR type | Required | Notes |
|---|---|---|---|
kind | text string | yes | "memory_node" |
id | text string | yes | the stable logical key |
content | any JSON value | yes | stored verbatim; string-keyed maps, finite numbers |
content_kind | text string | yes | "note" or "claim". 0.0.x only writes and reads "note"; "claim" is defined for a later era |
provenance | map | omitted when empty | see below; an entirely empty provenance is not encoded |
event_time | integer | omitted when absent | when the agent formed the node, Unix milliseconds, may be negative |
provenance (ADR-0003), all fields optional text strings, each omitted when
unset: agent_step, observation, tool_call, source, note.
state
The set of memory nodes visible at a commit (ADR-0003).
| Field | CBOR type | Required | Notes |
|---|---|---|---|
kind | text string | yes | "state" |
nodes | map | yes | node id (text) to that node's ObjectId (32-byte string), one entry per logical node, in canonical key order |
format_version 1 has only this flat form. A prolly-tree form arrives as a
second shape in a later format_version.
commit
A commit (ADR-0005). Its hashed bytes are exactly this object. A signature, if
one exists, lives in a separate table, is not part of the commit and never
changes its id; that table is not in format_version 1.
| Field | CBOR type | Required | Notes |
|---|---|---|---|
kind | text string | yes | "commit" |
parents | array of 32-byte strings | yes | empty for the first commit, one normally, two or more for a merge. Order is significant; parents[0] is the first parent |
state | 32-byte string | yes | the ObjectId of this commit's state |
message | text string | yes | freeform |
author | text string | yes | who or what made the commit, opaque to the core |
time | integer | yes | the record time, Unix milliseconds. Distinct from a node's event_time |
Refs and branches
format_version 1 has branches only, in a flat namespace (ADR-0009). A branch
is a row in the refs table: a name mapping to a commit id. The default is
main. There is no refs/heads/ hierarchy and no tags.
A ref name must be non-empty, must not be HEAD, must not begin or end with /
or ., must not contain .., whitespace or control characters, and may
otherwise contain letters, digits, -, _, . and /.
Ref moves are compare-and-swap: an update names the commit it expects the branch
to be at, and fails if the branch has moved. The first commit on a branch
creates its row. A reflog is reserved but not written in format_version 1.
Golden vectors
golden-vectors.md lists concrete objects with their exact
CBOR bytes and ids. crates/mnem-store/tests/golden_vectors.rs pins them and
checks the doc against the code, so neither can drift without a test failing.
That failure is the signal that a format_version bump is due.
Golden vectors
Canonical objects, their exact CBOR bytes, and their ObjectId. Format version
1, frozen for the 0.0.x line.
These are pinned in crates/mnem-store/tests/golden_vectors.rs, and a test checks
that every hex string below appears there verbatim. If a change to the encoder
moves any byte, that test fails. Such a change is a format_version bump
(ADR-0007, ADR-0010), never a silent one.
A reader in another language is correct for these objects when it produces the
same bytes and the same id. id = BLAKE3(cbor), shown as 64 lowercase hex. The
cbor lines are single lines; scroll to see the whole value.
note_minimal
A memory node with no provenance and no event_time, so both are absent from
the encoding.
object MemoryNode { id: "greeting", content: "hello", content_kind: note }
id 5107a6e4f686efec31950bf3171e7b4affd3c83697e8ab5182dc4a5f867cce10
cbor a4626964686772656574696e67646b696e646b6d656d6f72795f6e6f646567636f6e74656e746568656c6c6f6c636f6e74656e745f6b696e64646e6f7465
Decoded:
a4 map(4)
62 6964 "id"
68 6772656574696e67 "greeting"
64 6b696e64 "kind"
6b 6d656d6f72795f6e6f6465 "memory_node"
67 636f6e74656e74 "content"
65 68656c6c6f "hello"
6c 636f6e74656e745f6b696e64 "content_kind"
64 6e6f7465 "note"
The keys are in canonical order: id, kind, content, content_kind. That
is the length-first order of RFC 8949 section 4.2, since the encoded keys begin
62, 64, 67, 6c.
note_full
Every provenance field set, content a two-key map, event_time present.
object MemoryNode {
id: "customer-4821",
content: { "plan": "enterprise", "seats": 40 },
content_kind: note,
provenance: {
agent_step: "triage", observation: "ticket-4821 body",
tool_call: "read-ticket", source: "ticket-4821", note: "first contact"
},
event_time: 1757000000000
}
id 85ff5bf72e74962be19991ba2f67edeb71212d80fc17a9abc83f0aedf228f8ec
cbor a66269646d637573746f6d65722d34383231646b696e646b6d656d6f72795f6e6f646567636f6e74656e74a264706c616e6a656e746572707269736565736561747318286a6576656e745f74696d651b00000199155c62006a70726f76656e616e6365a5646e6f74656d666972737420636f6e7461637466736f757263656b7469636b65742d3438323169746f6f6c5f63616c6c6b726561642d7469636b65746a6167656e745f73746570667472696167656b6f62736572766174696f6e707469636b65742d3438323120626f64796c636f6e74656e745f6b696e64646e6f7465
Notes:
- Top-level keys in canonical order:
id,kind,content,event_time,provenance,content_kind. event_timeis1b 00000199155c6200, an unsigned 64-bit integer (1757000000000). CBOR has no 5-byte integer, so a value that does not fit in four bytes uses eight. This is shortest form.seatsis18 28, an unsigned integer in one trailing byte (40).- Inside
provenancethe keys arenote,source,tool_call,agent_step,observation: length-first, then bytewise.
state_empty
object State { nodes: {} }
id d6f3970d8ebe9b9ed1a48d4433b2872b324aa84e97bab1cfbfa50a112a620295
cbor a2646b696e64657374617465656e6f646573a0
a2 map(2)
64 6b696e64 "kind"
65 7374617465 "state"
65 6e6f646573 "nodes"
a0 map(0)
state_one
One entry, mapping a node id to the note_minimal object id.
object State { nodes: { "greeting": 5107a6e4...cce10 } }
id d1cdd28f6540e9e7a344554add2bd056bbf3717e7680833582fc463435446991
cbor a2646b696e64657374617465656e6f646573a1686772656574696e6758205107a6e4f686efec31950bf3171e7b4affd3c83697e8ab5182dc4a5f867cce10
The value is 5820 followed by 32 bytes: a CBOR byte string of length 32, not
an array of integers. Every ObjectId in the format is encoded this way.
commit_root
No parents. Its state is state_one.
object Commit {
parents: [], state: d1cdd28f...46991,
message: "first commit", author: "agent", time: 1757000000000
}
id 925570c5e44700e4e36d74cffeaa010cbf3caeecb364a383f955ff835d088814
cbor a6646b696e6466636f6d6d69746474696d651b00000199155c62006573746174655820d1cdd28f6540e9e7a344554add2bd056bbf3717e7680833582fc46343544699166617574686f72656167656e74676d6573736167656c666972737420636f6d6d697467706172656e747380
Keys in canonical order: kind, time, state, author, message,
parents. parents is 80, an empty array. The commit's hashed bytes are
exactly this object; a signature, if one exists, lives in a side table and is
not part of the id (ADR-0005).
commit_child
One parent, commit_root. Its state is state_empty.
object Commit {
parents: [925570c5...88814], state: d6f3970d...20295,
message: "second", author: "agent", time: 1757000060000
}
id fa02c3c4d1d9bdad0adb3f8bddd3ed9f36810e6158ef40d5467e38a579d4d447
cbor a6646b696e6466636f6d6d69746474696d651b00000199155d4c606573746174655820d6f3970d8ebe9b9ed1a48d4433b2872b324aa84e97bab1cfbfa50a112a62029566617574686f72656167656e74676d657373616765667365636f6e6467706172656e7473815820925570c5e44700e4e36d74cffeaa010cbf3caeecb364a383f955ff835d088814
parents is 81 5820 <32 bytes>: an array of one byte string.
Benchmark report (issue #65)
What the substrate delivers, measured. Re-run and updated whenever the core
changes. The design is ADR-0017; the harnesses are
crates/mnem-store/tests/benchmark.rs (correctness) and
benchmarks/overhead.py (overhead and the audit table).
Mnemosyne makes no accuracy claim. The 2026 research is settled on this: GitOfThoughts (arXiv 2606.14470) tested five memory backends and found none reliably moves an agent's accuracy. The value of version control is history, audit, and safe merging, at accuracy parity. So this benchmark measures those.
The one-line version
| Question you might ask of an agent's memory | a dict | a JSONL log | Mnemosyne |
|---|---|---|---|
| what does it believe now | yes | yes | yes |
what did it believe at step t | no | yes | yes |
when did belief X first go wrong | no | yes, O(L) | yes, O(log L) |
which observation set X | no | no | yes |
what did step t change | no | yes | yes |
| merge two agents' memories, surfacing conflicts | no | no | yes |
storage after L steps | O(keys) | O(L x keys) | O(commits) |
A dict answers almost nothing. A JSONL snapshot log answers the time questions
but has no provenance and cannot merge. Mnemosyne answers all seven, and
benchmarks/overhead.py asserts this table on every CI run.
Correctness and precision
Every one of these is deterministic, so the target is exact. The harness fails the build on any miss.
| Metric | What it checks | Result |
|---|---|---|
reconstruction_exact | state_at(commit) equals the working memory recorded at that commit | 100.00% |
golden_bytes_stable | the frozen canonical encoding still hashes identically | true |
bisect_exact | bisect returns the exact commit a planted monotonic fault began | 100.00% |
bisect_error_max | the largest |found - k| seen | 0 |
blame_commit_acc | blame resolves to the correct introducing commit, linear history | 100.00% |
blame_commit_acc_merge | correct origin commit when the value arrived on a merged-in side | 100.00% |
blame_source_acc | correct provenance source string | 100.00% |
merge_invariants | symmetry, no invented ids, no double-listing (the full sweep is merge_chaos.rs) | true |
Sweep: 80 seeds x run lengths {16, 64, 256} x fault positions {10%, 50%,
90%} = 720 synthetic runs, about 80,000 commits reconstructed and blamed, 720
bisects, 720 blame-through-merge checks. The overhead.py run adds a
1024-commit run whose audit assertion is a bisect and a blame at that
length. No failing seed.
A larger sweep (the just bench-sweep target, thousands of seeds up to length
1024) is possible but takes hours: each commit is a durable write (an fsync),
which is the wall-clock floor. Since the metrics are deterministic, more seeds
buy input-shape coverage, not confidence.
Overhead
Against the two baselines an agent author would otherwise write: a dict they
overwrite, and a JSONL file they append the whole memory to each step. Median of
several runs on an idle machine, a 100-key memory, values from a pool of 20.
| Steps | backend | write p50 | write p99 | read (full) | bytes / step |
|---|---|---|---|---|---|
| 256 | dict | ~0 ms | ~0 ms | 0 | |
| 256 | JSONL | 0.03 ms | 0.19 ms | 830 | |
| 256 | Mnemosyne | 12 ms | 24 ms | 1.5 ms | 10,320 |
| 1024 | JSONL | 0.04 ms | 0.65 ms | 955 | |
| 1024 | Mnemosyne | 12 ms | 24 ms | 1.5 ms | 7,470 |
Reading:
- Write latency is ~12 ms, flat with run length, and it is the durable
write: a commit
fsyncs a newState, aCommitand an index entry. A dict is free; a JSONL append is a smallfsync. For an agent making one memory update per reasoning step, 12 ms sits against an LLM call measured in seconds. - Read is ~1.5 ms for the whole working memory, flat with run length. Well
under the ~25 ms mark that would prompt reassessing the flat
State(ADR-0012, ADR-0017). - Storage is single-digit KB per commit and falls as the run lengthens
(10.3 KB/step at 256, 7.5 KB/step at 1024) as redb's fixed overhead amortises.
It is 8 to 12x a flat JSONL snapshot at these lengths: content-addressing
bounds the cost of repeated values, but the per-commit
StateandCommitobjects are the floor. This is the number a prolly-treeStatewould later share down, and the trigger for revisiting it is a real store's.mnemexceeding ~50 MB.
The CI benchmark job fails if write_ms_p50 or bytes_per_step exceeds 2x
benchmarks/baseline.json, so a regression (a double fsync, an O(L^2)) cannot
land quietly.
Reproducing
# the correctness metrics (CI runs a small count on every push)
cargo test -p mnem-store --test benchmark
# the published correctness sweep (~20 min: fsync-bound)
MNEM_BENCH_RUNS=80 MNEM_BENCH_LENGTHS=16,64,256 \
cargo test -p mnem-store --test benchmark --release -- --nocapture
# the overhead comparison and the audit table
python benchmarks/overhead.py # 256 steps, the CI scale
MNEM_BENCH_STEPS=1024 python benchmarks/overhead.py
# both, via just
just benchmark # the CI-scale run
just bench-sweep # the published scale
Sources
- GitOfThoughts (arXiv 2606.14470): five memory backends, no accuracy movement, "the engineering trade-off at accuracy parity"
- ADR-0017: the metrics, the targets, the gating
docs/chaos-report.md: the merge invariants at a 50,000-case sweep, summarised here asmerge_invariants
Merge chaos report (issue #49)
Phase 3's definition of done: the merge is not just implemented, it is hammered. This is the first report. It will be re-run and updated whenever the merge algorithm or the store flow changes.
The harness is crates/mnem-store/tests/merge_chaos.rs. It is seeded (a
hand-rolled LCG, the same approach as tests/time_travel.rs), so every run is
deterministic and a failing seed reproduces exactly. No property-test
dependency is pulled in (ADR-0004: the core stays lean and offline).
What it checks
The invariants are the ones named in the research
(docs/research/issue-42-merge-survey.md, section 4) and locked by ADR-0013 and
ADR-0014.
1. The pure function — merge_state_maps(base, ours, theirs)
Each trial builds a random base map (six possible keys, each present ~2/3 of the
time, values drawn from a pool of four object ids) and diverges it twice into
ours and theirs with a few random set / delete ops. Then:
| Invariant | Assertion |
|---|---|
| Totality | every id across base ∪ ours ∪ theirs is in merged or in conflicts, never both, never neither |
| No lost writes | for every non-conflict id the merged value is exactly what the per-id rule dictates (o if o == t; t if b == o; o if b == t) |
| No invented ids | nothing in merged or conflicts that was not in an input |
| Conflict ordering | conflicts come back in id order, no duplicates, each carrying the true (base, ours, theirs) triple |
| Clean-merge symmetry | swapping ours and theirs gives the identical merged map and the identical conflict id set (the kinds mirror: edit/delete ↔ delete/edit) |
| Idempotence | merge_state_maps(base, ours, ours) is clean and returns ours unchanged |
| Base identity | merge_state_maps(base, ours, base) is clean and returns ours; merge_state_maps(base, base, theirs) is clean and returns theirs |
2. The store flow — Store::merge
Each trial initialises a real store, writes a base commit, branches, and makes one or two random commits on each side. Then:
- an
Ours-strategy merge never surfaces a conflict — the outcome is alwaysMerged,FastForwardedorAlreadyUpToDate; - a real merge commit has exactly two parents, ours first;
- convergence: merging
mainback intofeatureafterwards fast-forwards (or is already up to date) onto exactly the same memory state — the two branches agree node for node; - flow-level idempotence: re-merging the already-merged branch is a no-op
(
AlreadyUpToDate).
Results
CI runs the harness on every push at a small trial count (600 pure, 24 store) —
a few seconds. The counts are overridable with MNEM_CHAOS_ALGEBRA_TRIALS and
MNEM_CHAOS_STORE_TRIALS for a manual sweep.
| Sweep | Trials | Result |
|---|---|---|
| CI (every push) | 600 pure + 24 store | pass |
| Manual, this report | 50,000 pure + 250 store | pass, no failing seed |
MNEM_CHAOS_ALGEBRA_TRIALS=50000 MNEM_CHAOS_STORE_TRIALS=250 \
cargo test -p mnem-store --test merge_chaos --release
# test merge_state_maps_algebra ... ok
# test store_merge_converges ... ok
# test result: ok. 2 passed; 0 failed; finished in 37.85s
Every one of the 50,000 pure cases classified every id and lost no write; every one of the 250 store histories converged when merged both ways. No seed has failed since the harness was written.
Reproducing a failure
If CI ever reports a failing seed N, run it alone:
MNEM_CHAOS_ALGEBRA_TRIALS=<N+1> cargo test -p mnem-store --test merge_chaos -- merge_state_maps_algebra
The assertion message names the seed and the id, and the LCG is pure, so the
case is fully determined by N.
Architecture decisions
Numbered records of the decisions that shape Mnemosyne, and why they went the
way they did. Format: 0000-template.md.
An ADR is Accepted before code is written for anything touching the on-disk format, the object model, a public API surface, the merge algorithm, the Rust and Python boundary, or a new dependency. Accepted ADRs are immutable; a later change is a new ADR that supersedes the old one.
Naming note: ADRs 0001 to 0018 call the core crate
mnem-coreand the CLI cratemnem-cli. Because those names were already taken on crates.io, before the first publish the core crate becamemnem-storeand the CLI cratemnem-git(the binary is stillmnem); the published packages aremnem-store,mnem-git, and on PyPImnem-agents,mnem-mcp,mnem-langgraph.import mnemand the.mnem/store directory are unchanged. See theCHANGELOG.mdentry under Unreleased.
| ADR | Title | Status |
|---|---|---|
| 0001 | Record architecture decisions | Accepted |
| 0002 | On-disk object format | Accepted |
| 0003 | The memory node model | Accepted |
| 0004 | The Rust core and Python SDK boundary | Accepted |
| 0005 | Commit identity, hashing and signing | Accepted |
| 0007 | Versioning and release policy | Accepted |
| 0008 | Object encoding and the store engine | Accepted |
| 0009 | The ref model | Accepted |
| 0010 | What 1.0 means, and the 0.0.x roadmap | Accepted |
| 0011 | Conventional commits and the issue lifecycle | Accepted |
| 0012 | The branch and checkout model | Accepted |
| 0013 | The deterministic merge algorithm | Accepted |
| 0014 | The conflict object and the resolution API | Accepted |
| 0015 | The provenance index, blame and bisect | Accepted |
| 0016 | The MCP tools and the adapter contract | Accepted |
| 0017 | The benchmark and its metrics | Accepted |
| 0018 | The Era 2 seam: the semantic merge trait and the sync protocol | Accepted |
ADR-0001: Record architecture decisions
- Status: Accepted
- Date: 2026-09-06
- Issue: #1
Context
Mnemosyne is being built slowly and deliberately, one phase at a time, with the design settled before the code. The reasoning behind each choice needs to survive, both for the maintainer returning to an area months later and for anyone reading the repo to judge whether the project is serious.
The design space here is unusually live. A wave of 2026 research (Git4Data, GitOfThoughts, StateFuse, MemTX, LatticeMind) is exploring the same ground, and findings from those papers feed directly into decisions. Those inputs need a home.
Decision
Every architecture decision is recorded as a numbered Markdown file in
docs/adr/, following 0000-template.md.
An ADR is written, and reaches Accepted, before code is written for anything that touches: the on-disk format, the object model, a public API surface, the merge algorithm, the Rust and Python boundary, or a new dependency.
ADRs are immutable once Accepted. A later decision that changes an earlier one is a new ADR that marks the old one Superseded.
Consequences
- The repo carries its own reasoning. A reviewer can follow why the format looks the way it does without asking.
- Research is not lost. A
wayfinder:researchticket produces an ADR recommendation, which becomes an ADR. - There is a small tax on every structural change: the ADR comes first.
- The ADR index in
README.mdfor this folder has to be kept current.
Alternatives considered
A design doc that is edited in place. Rejected. It loses the history of what was tried and dropped, which is exactly the part worth keeping here.
Decisions recorded only in issues and pull requests. Rejected. They are hard to find later and get buried under implementation chatter. An ADR is a stable, linkable artefact.
ADR-0002: On-disk object format
- Status: Accepted
- Date: 2026-09-06
- Issue: #4 (grilling), #3 (research)
Context
A .mnem/ store has to hold four kinds of thing: many small memory nodes
(thousands per run), a state per commit (the set of nodes visible then), the
commits that form the graph, and a handful of refs. The store must
support cheap branching, a cheap structural diff between two states (Phase 2), a
three-way merge (Phase 3), and a format that a store written by one v1.x release
can be read by every other v1.x release.
The #3 survey compared Git's loose-object and packfile model, an embedded
key-value object store, a prolly tree for state, the prollytree crate used
wholesale, Git4Data, and DVC or oras. Its recommendation, pressure-tested in
#4:
- Git loose objects hit the many-small-objects failure mode, and building packing and garbage collection to escape it is a storage engine we should not write.
prollytreeas the core data structure would make our frozen format someone else's, resting on their release cadence.- Git4Data needs a database server; DVC and
orasare built for a few large artefacts, not a fine-grained local graph. - An embedded key-value store removes the small-object problem for free, and a prolly tree for state gives cheap diff and node-level merge when those phases need them.
This ADR fixes the store architecture. It does not fix anything byte-level.
Decision
Engine
The store is a single redb file, .mnem/store.redb. redb is pure Rust, has
no C dependency, is crash-safe, uses copy-on-write B+trees, and has an
explicitly stable, documented on-disk format with an upgrade path. It is named
here as the one load-bearing storage dependency: the freeze-for-a-major-version
commitment rests on redb's format stability as well as our own.
Object model
Every object (memory node, state, commit) is self-describing and carries a
kind tag. A reader that meets an unknown kind rejects the store rather than
guessing. Adding a new object kind is an additive, minor change; it does not
break older readers of the objects they do understand.
Store layout
.mnem/
store.redb redb database:
table objects : content hash -> object bytes
table refs : ref name -> commit hash
HEAD plain text, the current pointer, the source of truth
config plain text, holds format_version and later the prolly parameters
HEAD is a plain-text file in the spirit of Git's HEAD: it is the one pointer
a person most often reads or sets by hand, so it stays out of the binary store.
Named refs live in the refs table.
State representation
The state is a plain sorted content-addressed map (kind = flat) in v0.1: an
ordered list of (node key, node hash) pairs, itself content-addressed.
A self-implemented prolly tree (kind = prolly) is added in Phase 2, when
diff first needs it, and pays off again for merge in Phase 3. Because objects
are self-describing this is an additive change, not a break. We implement the
prolly tree ourselves so the format stays ours and frozen; the prollytree
crate is a reference and a spike target, not a dependency.
No packfiles, no bespoke garbage collection
Not in the v0.x line. redb handles its own compaction. A mnem compact
command may be added later if a real store grows unreasonably, but it is not a
launch requirement.
Legibility
The store is not readable with Git tools, which is expected: our objects were never Git objects. In their place:
mnem cat-object <hash>: print one object.mnem verify: check every object's hash, and later its signature.mnem fsck: check graph consistency (no dangling parents, every ref resolves).docs/format/: a written spec complete enough for a third party to build a reader.
redb's own documented format keeps the outer container inspectable.
Migration
config carries a format_version integer. A major version bump ships a
one-shot mnem migrate that reads the old store and writes a new one; we commit
to providing that path for any v1 to v2 change. Within a major version only
additive changes are allowed: new object kinds, new optional fields. This
matches the invariant in AGENTS.md.
Scope
This ADR is architectural. It does not decide:
- the serialisation encoding of an object's bytes (CBOR, bincode, other): #19 and ADR-0008.
- the prolly-tree chunking and encoding parameters: ADR-0008.
- the content hash function and the commit header layout: #15 and ADR-0005.
Those can move without reopening this ADR.
Consequences
- Phase 1 is smaller: a
redbtable and a flat state, no tree, no packing. - The many-small-objects problem is gone from day one.
- Phase 2 and Phase 3 get the prolly tree's cheap diff and node-level merge without a format break, because the object model was built to absorb it.
- We take one deep dependency,
redb, and its format stability is now part of our promise. Ifredbever breaks its format without an upgrade path, that is our problem to absorb in amnem migrate. - Anyone wanting to read a store needs our spec, not just
git cat-file. The inspection commands anddocs/format/are load-bearing, not optional. - A store cannot be inspected or repaired with generic tools while
mnemis unavailable.redb's documented format is the fallback.
Alternatives considered
Git's loose objects and packfiles, via gitoxide. Rejected. Thousands of
tiny loose files per run is the exact case packfiles exist to fix, and building
packing, an index, repack and garbage collection ourselves is a large
distraction from the product. gitoxide would also pull Git's tree and blob
model into ours.
A prolly tree from the start, in Phase 1. Rejected for v0.1. It is real work with a chunking-boundary subtlety, and Phase 1 does not yet need diff or merge. The self-describing object model lets us add it in Phase 2 without a break, so there is no format cost to waiting.
The prollytree crate as the core data structure. Rejected. It is young and
single-maintainer. Depending on it for the core means our frozen format is
really its format, and the freeze-for-years commitment then rests on its release
cadence. It may also not model our commit header, provenance or signing without
a fork. Kept as a reference and a spike target.
A hand-rolled append-only log plus index. Rejected. That is building a
storage engine. redb already solves crash safety, compaction and small-object
locality, with a stable format.
Git4Data (arXiv 2609.02106). Rejected. It needs a database server. Mnemosyne v1 is a local, embeddable, offline library. The "cost proportional to the change" principle is one we keep, through structural sharing in the prolly tree.
DVC or oras style artefact stores. Rejected. Both are built for a small
number of large artefacts with an external remote, not a fine-grained local
graph of thousands of tiny objects.
ADR-0003: The memory node model
- Status: Accepted
- Date: 2026-09-06
- Issue: #6 (grilling), #5 (research)
Context
A memory node is the unit Mnemosyne versions. The #5 survey looked at how mem0, Letta, Zep, LangGraph, Generative Agents, and the 2026 versioned-memory papers (StateFuse, MemTX, LatticeMind) model memory, and at the CogCanvas ablation.
The findings that shaped this decision:
- CogCanvas (arXiv 2601.00821): swapping only the stored representation in a fixed pipeline, verbatim chunks beat LLM-extracted typed artefacts by 15.9 points on LoCoMo and 22.0 on LongMemEval-S. The mechanism is lossy distillation. Structure should augment verbatim text, not replace it.
- LangGraph proves the minimal viable model: a key and an opaque value.
- Zep splits event time (when a fact was true) from ingestion time (when it was learned), and invalidates superseded facts rather than deleting them.
- MemTX keeps provenance, evidence and validity as distinct first-class fields.
- StateFuse keeps contradictions as explicit objects over an immutable history; LatticeMind puts a mutable status on each item. These are in tension.
- Letta's block is a mutable labelled string, which is the wrong shape for version control.
This ADR fixes the node model. It does not build the claim schema, the embedding index, or anything in v2.
Decision
The memory node
A memory node has five fields.
| Field | Type | Required in v0.1 | Notes |
|---|---|---|---|
id | string | yes | a stable logical key |
content | string or any JSON value | yes | freeform, stored verbatim |
content_kind | enum | yes, always note | note or claim |
provenance | object | yes, may be empty | how the node came to exist |
event_time | timestamp or null | no | when the agent formed the node |
id is caller-provided, with a generated fallback. An agent with a natural
key (an entity id, a fact key) supplies it, so successive writes to the same
fact share an id and blame and diff line up. An agent with no key gets a
generated id, and every write is a fresh node. id is never content-derived,
because that would make an update impossible.
Update means: same id, new content, recorded in a new commit. The old
version stays reachable through history. A node is immutable within a commit;
it carries no mutable field.
content is a string or any JSON value, unconstrained. Mnemosyne is a
version-control tool: it stores faithfully what the agent wrote and does not
reshape it. A soft size warning may be added later; there is no hard limit.
content_kind is present from v0.1 with note as the only value the v0.1
reader accepts. claim is named here and in the format spec but is rejected by a
v0.1 reader. Per ADR-0002, adding claim in v2 is then an additive change, not a
format break.
event_time is an optional timestamp: when the agent formed the belief, as
distinct from the commit's record time. If absent, blame falls back to the
commit time. It is one nullable field, and a temporal field added later would be
a semantically meaningful format change, so it goes in now.
Provenance
Provenance answers "where did this record come from". It is read by blame.
Every node has a provenance object, which may be entirely empty.
| Field | Type | Notes |
|---|---|---|
agent_step | string or null | which step of the run produced it |
observation | reference or null | the observation or input it was drawn from |
tool_call | reference or null | the tool call, if one produced it |
source | reference or null | an external source identifier |
note | string or null | free text for anything the fields above do not fit |
An agent loop may only know the step, or nothing. Refusing a memory for lack of
provenance would be worse than storing it bare, so an empty provenance is legal.
bisect operates on node content and presence, not provenance, so it needs
nothing here.
Provenance is not evidence. Evidence (below) is claim-only and says what supports the assertion. MemTX keeps both; so do we.
The claim schema (defined, dormant until v2)
A node with content_kind = claim has content shaped as:
| Field | Type | Notes |
|---|---|---|
subject | reference | the entity the claim is about |
predicate | string | the attribute or relation |
value | any JSON value | the asserted value |
confidence | float 0 to 1 or null | the agent's stated confidence |
evidence | list of references | what supports the assertion. May be empty. |
This ADR fixes the field names and their types so ADR-0002's format can carry a
claim object additively. It defers to a v2 grilling: the predicate
vocabulary, how subject identity is resolved, and how confidence is
calibrated. Nothing is built or validated in v0.1, so a v2 grilling can still
widen a field.
Status and contradiction
A memory node has no status field. Supersession is already visible in the
commit graph: a later commit with a new content for the same id supersedes
the earlier one. Contradiction becomes a separate Contradiction object in
v2, designed by a v2 grilling. This follows StateFuse over LatticeMind, and
keeps nodes consistent with ADR-0002's content-addressed state.
Embeddings
Embeddings are not on the node and not in its content hash. They live in a side
index: a redb table keyed by content hash, with the model_id recorded
alongside so that changing the embedding model forces a rebuild rather than
silent drift. Not built in v0.1, since v1 has no semantic operations. A local
embedding model on an M1 produces roughly 1.5 to 4 KB per node, a few megabytes
for a large store, and the index is rebuildable, so the choice holds.
Retrieval-scoring signals (importance, recency) are not part of the versioned model at all. They are mutable and derived, and belong to a retrieval layer if one is ever built.
Consequences
- v0.1 needs only
id,content,content_kind = note,provenanceand an optionalevent_time. Small. - v2 adds
claim, the Contradiction object, and the embedding index without a format break, because the object model was built to absorb them. - An agent can write arbitrary JSON, so the store faithfully reflects whatever
the agent's memory actually was, garbage included. That is the correct
behaviour for version control, but it means Mnemosyne offers no schema
guarantees about
content. blamequality depends on the agent populating provenance. An agent that passes nothing getsblamethat resolves only to the commit.- Pinning the claim schema shape now carries a small risk of pinning a field we later regret. Mitigated by deferring all claim semantics to v2 and building nothing against the schema until then.
Alternatives considered
Letta-style mutable blocks. Rejected. The unit is designed to be edited in place, which defeats version control.
mem0-style forced fact extraction. Rejected. CogCanvas shows extraction loses 15 to 22 points against verbatim, and it puts an LLM call on the write path, which v1 forbids.
A status field on the node (LatticeMind). Rejected. A mutable field fights
ADR-0002's content-addressed state, and supersession is already in the graph.
Contradiction as a separate object (StateFuse) keeps nodes immutable.
Embeddings on the node. Rejected. It would pull the embedding model's identity into the content hash and force history rewrites when the model changes.
Omit content_kind until claims arrive. Rejected. Adding a discriminator
later is the format break ADR-0002's self-describing objects exist to avoid.
Rely on the commit timestamp instead of event_time. Rejected for v0.1.
A temporal field added later is a meaningful semantic change; one nullable field
now is cheap.
ADR-0004: The Rust core and Python SDK boundary
- Status: Accepted
- Date: 2026-09-06
- Issue: #7 (grilling)
Context
The stack was set at plan time: a Rust core (mnem-core), a mnem command line
tool, and a Python SDK (mnem) bound through pyo3 and maturin. This ADR fixes
the boundary between those pieces, and pre-commits to a fallback so that choice
is not made under pressure later.
The Rust core is the ambitious choice for a solo maintainer. The value is a real CLI binary, a performance story against Git4Data and DoltDB, and the discipline that a typed core imposes. The risk is that the maintainer stalls on Rust and the whole project stalls with it.
Decision
mnem-core
The core holds:
- the object model (ADR-0003)
- the store (ADR-0002)
- the commit graph
- every deterministic operation on them:
commit,branch,checkout,merge,blame,bisect,diff, time travel
The core does not hold, and a CI check enforces the first of these:
- any network-capable dependency
- any model or LLM call
- CLI argument parsing or output formatting
- any Python or FFI types
- any knowledge of an agent framework
- anything semantic: embeddings, retrieval, semantic merge. Semantic work lands
in v2 in a separate
mnem-semanticcrate, behind a trait.
If a behaviour is worth a test, it lives in the core with a Rust test.
The Python SDK
mnem (the Python package) is pure ergonomics over the core: Pythonic names,
exceptions, dataclasses, context managers. It carries no operation semantics.
The one carve-out is Python-idiom composition, such as the
with store.branch("h1"): context manager, which is only "call branch, call
checkout, on exit checkout back or merge". The SDK gets thin smoke tests;
the behaviour it composes is tested in the core.
Consumers of the core
mnem-cli (the mnem binary) and mnem-py (the pyo3 binding) are peer
consumers of mnem-core. Neither depends on the other. The core's consumer
count stays at two.
The MCP server (mnem-mcp) and the framework adapters (mnem-langgraph and
later) are Python packages that depend on the SDK, not on the core. They are the
SDK's customers, the same as any agent author.
Packaging
The Python wheel ships the extension module only. The mnem CLI binary is not
in the wheel; it is installed with cargo install mnem or from a release. This
is revisited at v1.0 if users ask for a bundled CLI.
Errors across the boundary
The core defines one error enum, MnemError, with named variants. The binding
maps each variant to a Python exception in a hierarchy rooted at
MnemError(Exception):
MnemError variant | Python exception |
|---|---|
NotFound | NotFoundError |
InvalidRef | InvalidRefError |
Conflict | ConflictError |
CorruptStore | CorruptStoreError |
FormatVersion | FormatVersionError |
StoreExists | StoreExistsError |
NoStore | NoStoreError |
Io | StoreIoError |
Adding a variant means adding a row here. No stringly-typed errors cross the
boundary. StoreExists and NoStore were added with Store::init/open in
issue #24 and their rows added here per this rule, which the ADR carves out from
the usual immutability. In Python the base class is named MnemError; the
mapping and hierarchy are exercised by the binding's tests (#29).
Versioning
One version number, held in the Cargo workspace [workspace.package] version
and read into the wheel at build time by maturin. A release is one git tag
vX.Y.Z. A release job publishes the crate and the wheel from that tag and
refuses if the two versions disagree. The semver policy, in particular what a
minor versus a patch means for the on-disk format, is ADR-0007's job (#17).
The fallback: a tracer bullet
Phase 1 starts with the minimum vertical slice in Rust: Store::init, write one
object, read it back, one commit, log (issues #23 to #27).
- If that slice works and is pleasant to extend, Rust continues.
- If it hits a genuine wall (a
redborpyo3limitation, or the graph code fighting the borrow checker past reasonable effort), the maintainer switchesmnem-coreto Python for v0.1, keeps the same public API, and records the switch as an ADR that supersedes the "core is Rust" part of this one.
The public API (the CLI surface and the SDK surface) is defined first and is language-neutral, so a switch does not change what users see. The go or no-go is made at the tracer-bullet checkpoint, early and cheap, and it is the maintainer's call alone. No agonising over partial progress below the checkpoint; a clear decision at it.
Consequences
- The core is a deep module with a small, typed surface, testable without any Python or network in the loop.
- The SDK is small enough that most of its cost is documentation and stubs, not logic.
- Two consumers of the core, both thin. Adapters sit a layer further out and cannot reach past the SDK.
- The wheel is simple: no per-platform CLI binary, no entry-point plumbing. Users who want the CLI take one extra step.
- The Rust bet has an explicit, early exit. If it fails it fails in Phase 1 at low cost, not at v0.5.
- A Python fallback core would lose the performance story and the CLI-as-a-single- binary story, and would need its own packaging. That cost is accepted as the price of not stalling.
Alternatives considered
A Python core from the start. Rejected as the default. It gives up the performance story, the single-binary CLI, and the discipline of a typed core. Kept as the named fallback.
The SDK holds orchestration logic. Rejected. Logic in two languages means two test suites for one behaviour and drift between them.
Bundle the CLI in the wheel. Rejected for now. Per-platform wheels and entry-point plumbing for an audience that mostly wants the library. Revisit at v1.0.
Adapters consume mnem-core directly through their own binding. Rejected.
It multiplies bindings and means adapters see different errors and ergonomics
from what agent authors see.
Independent version numbers for the crate, wheel and CLI. Rejected. Three numbers for one release is a source of confusion and mismatched bug reports.
ADR-0005: Commit identity, hashing and signing
- Status: Accepted
- Date: 2026-09-06
- Issue: #16 (grilling), #15 (research)
Context
ADR-0002 fixed that objects are content-addressed and left "assume a 32-byte
content hash" for this ticket. ADR-0003 fixed the memory node model and the
event_time field. This ADR decides the hash function, the signing scheme, and
the fields of a commit.
The #15 survey found: BLAKE3 is 4 to 10 times faster than SHA-256 and parallelises, with the same security profile; git chose SHA-256 for ecosystem interop, which a local-first tool with its own format does not need; SSH-style ed25519 signing has displaced GPG; and a content-addressed store is better served by keeping signatures out of the hashed object.
Decision
Hash function
The content hash is BLAKE3, 32 bytes, displayed as 64 hex characters. mnem
accepts an unambiguous hex prefix, in the spirit of git's short hashes.
config records hash_algo: "blake3". There is no runtime switch. A store is
single-algorithm for its life; moving to another hash is a mnem migrate under
ADR-0002's migration story, not a toggle.
Object identity
An object's id is the BLAKE3 hash of its deterministic canonical serialisation. The requirement is deterministic and canonical: the same logical content produces the same bytes and therefore the same id. The encoding that achieves this (canonical CBOR, or a hand-rolled canonical form) is ADR-0008's decision.
For a commit, the hashed content is exactly:
{ kind, parents, state, message, author, time }
and nothing else. The signature is not in the hashed content.
The commit object
| Field | Type | Notes |
|---|---|---|
kind | string, always "commit" | ADR-0002's self-describing objects |
parents | list of commit ids | [] for the first commit, one normally, two or more for a merge. No sentinel. |
state | state id | the state this commit points at |
message | string | freeform |
author | string | opaque to the core. mnem sets it from config or the caller passes it. Structured agent identity is a v2 and v3 concern and can be added as a new field additively. |
time | integer | Unix milliseconds. The record time, always set by mnem at commit. Distinct from a memory node's event_time, which uses the same representation. |
Not present:
committer: no rebase or patch flow in v1, so oneauthoris enough.- a run reference: grouping commits by agent run is deferred; not in v0.1.
signature: held separately, see below.format_version: lives inconfig, not per commit (ADR-0002).
Signing
Signing uses ed25519. A signing key has the same shape as an SSH
id_ed25519, so a user can reuse an existing one.
Signatures live in a side table, not in the commit object:
signatures : commit id -> { public_key, signature, signed_at }
The signature covers a signable payload, { commit_id, signer_key_id, signed_at }, signed as a whole. This binds "this key attested this commit at
this time". Signing the bare commit id would not bind the signer or the time;
signing the full canonical bytes would be redundant, since the id already
commits to them.
Because signatures are a side table:
- A commit with no signature is valid. An unsigned store is normal.
- A commit can carry several signatures.
- A signature can be added after the fact, or by someone other than the author.
- Signing never changes a commit's id.
The table value can later hold a Sigstore-style certificate bundle in place of a bare signature. Sigstore itself is not adopted in v1, because it needs Fulcio and Rekor, which is heavy infrastructure for a local-first tool.
What ships in v1
mnem verify: check every object's id against its bytes, and check any signatures in the side table. (ADR-0002 already committed this.)mnem sign: attach an ed25519 signature to a commit. Thin: a vetted crate, the side table, and the signable payload.
Signing is never required by any operation.
Consequences
- Hashing is fast and parallel, which matters when a commit hashes a large state.
- Commit identity is purely a function of content. The graph is stable under signing, re-signing, and multi-party signing.
- BLAKE3 is not a NIST standard, so a FIPS-bound user cannot use Mnemosyne until
a SHA-256 mode exists. The
hash_algofield reserves that path; it is not built. - A bare-string
authormeans the core offers no identity guarantees in v1. The v2 agent-identity work fills this in. timeas Unix milliseconds is unambiguous and trivial to serialise, at the cost of not carrying a timezone.mnemrenders local time on display.
Alternatives considered
SHA-256, to match git and Git 3.0. Rejected. Git's reason is interop with a
huge installed base and forge support; Mnemosyne interoperates with nothing
outside a .mnem/ store. BLAKE3 is faster with no security cost.
A configurable hash from day one. Rejected. A store never mixes hashes, so
the abstraction would exist only for a migration that a mnem migrate handles
better.
A signature field in the commit object, like git. Rejected. It ties commit identity to the signature, so re-signing or adding a second signature rewrites the commit and orphans its children. Git accepts this partly for transport reasons we do not have.
Signing the bare commit id. Rejected. It does not bind who signed or when.
Structured author now. Rejected. Agent identity is unsettled and belongs
with the v2 work; a string does not block that and stays forward-compatible.
committer alongside author. Rejected. The split serves git's rebase and
patch flows, which v1 does not have.
ADR-0007: Versioning and release policy
- Status: Accepted
- Date: 2026-09-06
- Issue: #17 (grilling)
Context
ADR-0004 fixed that the crate, the wheel and the CLI carry one version number and
move together. ADR-0002 said the on-disk format is "frozen within a major
version" and that a breaking format change ships mnem migrate. This ADR turns
that into a concrete policy: what the version scheme is, what a bump means, how
the format version relates to the software version, and how a release is cut.
The hard part is the second question. ADR-0002 and ADR-0003 call adding a new
object kind, such as claim in v2, "additive". But a v1.0 reader rejects
unknown kinds, so a store written by a later v1.x is not fully readable by v1.0.
If every format change of any kind forced a major software release, claim
support alone would make Mnemosyne 2.0.
Decision
Version scheme
SemVer 2.0.0. One number for the crate, the wheel and the CLI.
Under 1.0, the Cargo 0.x convention: in 0.MINOR.PATCH, a bump of MINOR may
break the public API or the format, a bump of PATCH may not. This holds
through the whole v0.x roadmap. From v1.0, full SemVer applies.
The format version is a separate track
config carries a format_version integer. It is not the software version.
The software version is an API contract; format_version is a data contract.
Each release declares the format_version range it can read and the single
version it writes. The mapping between a format change and a software bump:
| Format change | format_version | Software bump | Migration |
|---|---|---|---|
| none | unchanged | PATCH or MINOR per the API | none |
| additive (a new object kind, a new optional field) | +1 | MINOR | none. An older release cannot open a newer store, but a newer release opens any older store it declares support for. |
| breaking (an old reader would misread an existing object) | +1 | MAJOR | mnem migrate reads the old store and writes a new one. |
So claim support in v2 is an additive change: format_version goes up by one,
and it ships in a MINOR software release, not a major one. A change that alters
how an existing object is interpreted is major and gets a migration.
An older release that meets a format_version it does not know refuses the
store with a clear message naming the version it would need.
Pre-release tags
SemVer pre-release identifiers: vX.Y.Z-rc.N, vX.Y.Z-beta.N, vX.Y.Z-alpha.N.
A hyphenated tag publishes as a GitHub pre-release, and as a pre-release version
on crates.io and PyPI, which is not installed by default. mnem --version prints
the full string including the identifier.
The release process
A release is gated on, in order:
mainis green.- The version in
Cargo.tomlis bumped to the intendedX.Y.Zand matches the tag about to be pushed. CHANGELOG.mdhas a dated section forX.Y.Z, moved down fromUnreleased.- The tag
vX.Y.Zis pushed. - A
releaseworkflow builds and publishes the crate to crates.io and the wheels to PyPI, and cuts a GitHub release with the changelog section as its notes.
The workflow refuses if the Cargo.toml version and the tag disagree. Building
the workflow is a Phase 6 task (#68); this ADR fixes the policy it enforces.
Changelog
A hand-written CHANGELOG.md in the Keep a Changelog format. Any user-visible
change updates it in the same pull request, under an Unreleased section that is
dated and renamed to the version at release time. Commits are already small and
issue-referenced; a curated changelog reads better than a generated one, and
there is one maintainer to keep it honest.
Deprecation
Post-1.0: a deprecated public API element (a CLI flag, an SDK method) is marked deprecated in a MINOR release, keeps working with a warning for at least one further MINOR, and may be removed only in the next MAJOR.
Pre-1.0: deprecation is best-effort. An element can be removed in a 0.x MINOR
with a changelog note.
Yanking
If a published crate or wheel is broken, cargo yank the crate version and mark
the PyPI release yanked. The version number is never reused. The fix goes out as
the next PATCH. The git tag stays, because history is immutable, and the GitHub
release is marked as broken.
Consequences
- A v2 that adds
claim, the Contradiction object and the embedding index is a MINOR software release, since all three are additive. Mnemosyne 2.0 is reserved for a genuine break. - Two version numbers to keep straight: the software SemVer and
format_version. The mapping table above is the single source of truth, andmnem --versionwill print both. - An older release cannot open a store written by a newer one. This is accepted: a release never promised to read the future, and the error message is explicit.
- The changelog is manual work on every user-visible PR. Accepted for the quality it buys a reader.
Alternatives considered
Any format change is a MAJOR software bump. Rejected. It conflates the data contract with the API contract and turns every additive tweak into a major release, which trains users to ignore major bumps.
Additive format changes are a MINOR bump, with no separate format_version.
Rejected. Without a format_version integer, an old release has no clean way to
detect that a store is too new, and would fail with a parse error rather than a
useful message.
Generate the changelog from commits or PR labels. Rejected. A generated changelog is a list of commits, not a description of what changed for a user. With one maintainer and small commits, hand-writing it is cheap.
Independent version numbers for the crate, the wheel and the CLI. Settled in ADR-0004: rejected there, and this ADR inherits the one-number rule.
ADR-0008: Object encoding and the store engine
- Status: Accepted
- Date: 2026-09-06
- Issue: #20 (grilling), #19 (research)
Context
ADR-0002 fixed redb as the store engine and left the byte encoding of an
object to this ticket. ADR-0005 requires a deterministic canonical serialisation:
the same logical object must produce the same bytes, and therefore the same
BLAKE3 hash. ADR-0002 and ADR-0007 require that objects are self-describing and
that additive changes do not break an older reader.
The #19 survey compared bincode, postcard, MessagePack, CBOR and JSON. bincode and postcard are the fastest and smallest, but have no canonical mode and no schema evolution. JSON is legible but large, slow, and cannot hold bytes natively. CBOR is the only candidate that is self-describing, has a standardised deterministic encoding (RFC 8949 §4.2), and stays legible through diagnostic notation.
Decision
Format
Objects are encoded as CBOR (RFC 8949), restricted to a deterministic profile.
The deterministic profile
The base is RFC 8949 §4.2:
- integers and floats in their shortest form that round-trips (preferred serialisation)
- no indefinite-length items
- map keys sorted in bytewise lexicographic order of their encoded form
Two additions of our own, recorded here and in docs/format/:
content(ADR-0003) is the JSON data model exactly. Finite numbers only. A non-finite float (NaN,±Infinity) is rejected at the SDK boundary and never reaches the core. Finite floats keep their value; they are not reduced to integers (this is not dCBOR).- No CBOR tags are used. Every value is a plain CBOR string, byte string, integer, float, boolean, null, array or map.
CDE and dCBOR were considered and not adopted: both are still drafts, and
dCBOR's float-to-integer reduction would silently rewrite an agent's content.
Struct-to-CBOR mapping
Every object is a string-keyed CBOR map. The kind key is present in every
object and is conventionally written first, though the deterministic profile
sorts keys regardless.
Positional arrays were rejected: legibility and additive evolution are explicit requirements, and an array makes adding a field fragile and the object unreadable. The cost of repeating short field names per object is accepted.
Implementation
ciborium provides the CBOR data model. A thin pass of ours enforces the
deterministic profile on encode: sort map keys, force shortest-form numbers,
forbid indefinite-length. The rule set is small and is tested against the RFC's
own examples.
cbor2 (canonical encoding built in) and a fully hand-rolled serialiser were
considered. cbor2 has a short track record, which is a real risk for a
dependency the frozen format rests on. A hand-rolled serialiser is more than is
needed once ciborium covers the data model.
No per-object framing
An object's on-disk bytes are exactly its canonical CBOR. There is no magic
number, no length prefix, no version byte on the object. kind identifies the
object type; format_version lives in config (ADR-0007); redb frames the
key and value.
The store engine layer
Two tables in the one redb file:
objects : [u8; 32] -> Vec<u8> BLAKE3 hash to canonical CBOR bytes
refs : &str -> [u8; 32] branch name to commit hash
redb = "2" is pinned in Cargo.toml. docs/format/ records the redb
file-format version a store was written with. A mnem commit is a single
durable redb write transaction: fsync on commit, correctness over speed. A
--no-fsync escape hatch for bulk import may be added later.
Verifying the canonical encoding
Two CI checks, from Phase 1:
- A property test:
encode(decode(encode(x))) == encode(x)for generated objects (idempotence), and two semantically equal objects (for instance a map built with keys inserted in different orders) encode to identical bytes. - Golden vectors in
docs/format/: a fixed set of objects and their exact hex encodings, pinned by the test, so an accidental change to the encoder is caught immediately.
Consequences
- The encoder is a small amount of our own code on top of
ciborium, fully testable against a public spec. - Objects are inspectable:
mnem cat-objectdecodes the CBOR and prints diagnostic notation, or JSON with a flag. - Decode is slower than bincode. For a store read a few objects at a time this does not matter, and it buys the three properties bincode lacks.
redbandciboriumare the two dependencies the frozen format rests on. Both are widely used with stable formats. A break in either is absorbed by amnem migrateunder ADR-0007.mnem commitfsyncs, so a commit-heavy workload is bounded by disk sync latency until the--no-fsyncflag exists.
Alternatives considered
bincode or postcard. Rejected. No canonical mode, not self-describing, no schema evolution. Wrong tool for a frozen inspectable format.
MessagePack. Rejected. Its deterministic story is weaker than CBOR's §4.2, and its diagnostic tooling is poorer.
JSON with JCS (RFC 8785). Rejected. Largest and slowest, and no native byte strings, so a BLAKE3 hash inside an object would need base64.
CDE or dCBOR. Rejected for now. Both are drafts; dCBOR alters data.
cbor2 for its built-in canonical encoding. Rejected. Too new to be a
load-bearing frozen dependency; ciborium plus a small pass is safer.
Positional CBOR arrays instead of maps. Rejected. Fights legibility and additive evolution.
ADR-0009: The ref model
- Status: Accepted
- Date: 2026-09-06
- Issue: #21 (grilling)
Context
ADR-0002 and ADR-0005 fixed that HEAD is a plain-text file and the source of
truth for the current pointer, and ADR-0008 fixed that named refs live in a
redb table refs: &str -> [u8; 32]. This ADR fixes the semantics: what a ref
is, how it is named, what HEAD contains, and what happens on a concurrent
update or a branch deletion.
The design space is small and well understood from git and jujutsu. The decisions below take git's model and drop the parts that exist only because git's refs are files on a filesystem.
Decision
A ref is a branch
In v1 a ref is a branch: a mutable name -> commit id pointer. There are no tags
(immovable pointers) in v1. Nothing needs them yet; they are a plausible later
addition.
Flat namespace
Refs are keyed by their bare name in the refs table: main, hypothesis-x.
There is no refs/heads/ hierarchy. Git's hierarchy separates heads from tags
and remotes; v1 has only heads. If tags or remotes arrive (v2 sync, v3), a
prefix convention is an additive change, not a format break.
Naming rules
A branch name is a non-empty string that:
- contains only Unicode letters, digits,
-,_,.,/ - does not start or end with
/or. - contains no
.., no whitespace, and no control characters
Names are case-sensitive. HEAD is reserved and is not a branch name.
HEAD
HEAD is a one-line plain-text file.
- Attached:
ref: <branch-name>.HEADfollows the branch; a commit moves the branch andHEADwith it. - Detached:
<64-hex-commit-id>.HEADpoints straight at a commit; committing from here is refused until a branch is created. - Fresh store, no commits:
ref: main.maindoes not exist inrefsyet. The first commit creates it.
The default branch
main. Overridable by a default_branch key in config for anyone who wants
something else.
Ref updates are compare-and-swap
A mnem commit moves a branch inside the same redb write transaction that
writes the objects (ADR-0008), so the move is atomic. The move is also a
compare-and-swap: the transaction asserts the branch still points where HEAD
resolved it to when the commit started. On a mismatch the commit fails with "the
branch moved under you" and writes nothing.
This costs nothing inside the transaction. Single-agent v1 will almost never hit it. It is the primitive v2's concurrent agents need, so it goes in now.
Branch deletion
mnem branch -d <name> removes the refs row.
- Deleting the branch
HEADpoints at is refused. - Deleting the last branch of a store that has a commit is refused.
- Commits that only the deleted branch reached become unreachable but are not removed (no GC in v0.x, ADR-0008). They are recoverable by hash.
Reflog: reserved, not built
A reflog (a per-ref history of the commits a ref has pointed at) fits the
product and is cheap: append (ref, old, new, time, op) to a reflog table on
every ref move. It is not needed for the Phase 1 definition of done, and it
interacts with the eventual GC design, so it is deferred. It lands as its own
ticket in Phase 2, or alongside blame and bisect in Phase 4.
Consequences
- The ref layer is a thin wrapper over one
redbtable plus a text file. Small. - Compare-and-swap on every commit means the concurrency story is already correct when v2 arrives; no retrofit.
- Without a reflog, recovering a mistakenly deleted branch needs its tip hash. Since there is no GC, the commits are still there; the user just needs to know the hash. The reflog closes that gap when it lands.
- A flat namespace means adding tags later needs a naming convention (a
tags/prefix, or a second table). That is a Phase-7 concern and additive.
Alternatives considered
Lightweight tags in v1. Rejected. Nothing uses them, and they add naming and deletion rules for no v1 benefit.
A git-style refs/heads/ hierarchy. Rejected for v1. It exists to separate
ref categories git has and v1 does not.
No compare-and-swap; last write wins. Rejected. It is free to add now and painful to retrofit once v2 has concurrent writers, and "last write silently wins" is the wrong default for a tool whose job is not losing history.
Build the reflog in v0.1. Rejected. Not on the Phase 1 critical path, and better designed together with GC.
HEAD stored in redb rather than a text file. Settled in ADR-0002:
rejected there, because HEAD is the one pointer a person most often reads or
sets by hand.
ADR-0010: What 1.0 means, and the 0.0.x roadmap
- Status: Accepted
- Date: 2026-09-06
- Issue: #95
Context
ADR-0007 set the version scheme: one SemVer number for the crate, the wheel and
the CLI, with a separate format_version integer for the data contract. It was
written against a roadmap that reached 1.0 at the end of a six-phase substrate
build, and it said the 0.x convention "holds through the whole v0.x roadmap".
Since then the ambition has settled. The end state is not the single-agent substrate; it is the agent as a versioned, signed, forkable artefact with a registry to publish and improve agents, what earlier docs call v3. The substrate and the multi-agent collaboration layer are both groundwork for that. Numbering the substrate's first working build 0.1.0, a sixth of the way to 1.0, oversells how far along the project is.
This ADR fixes what 1.0 means and how versions are numbered until then. It does
not change the format_version track or the release process from ADR-0007.
Decision
1.0 is the platform. Mnemosyne 1.0.0 is the release where an agent, its prompt, tools, memory, policy and evaluations are versioned and signed as one artefact, with a registry to publish, discover and fork them. Nothing smaller is 1.0.
Three eras, all pre-1.0 except the last.
- Era 1, the substrate. Single-agent versioned memory: commit, branch, merge, blame, bisect, time travel. Local and deterministic. This is the current six-phase roadmap.
- Era 2, the collaboration layer. Semantic merge over claims, a sync protocol between stores, and a review step before an update lands in shared memory.
- Era 3, the platform. The agent as a repository, plus the registry. Ships as 1.0.0.
Where older ADRs, research notes and issues say "v1", "v2" or "v3", read Era 1, Era 2 and Era 3. None of them meant a software major version.
The roadmap is 0.0.x. Every milestone from here until the eras are
substantially done is a 0.0.x tag. Phase 1 is v0.0.2, following Phase 0's
v0.0.1; each later phase is the next patch, through v0.0.7. Era 2's work
continues in the same range.
0.0.x is initial development. Per SemVer, anything may change on any bump: the
public API and the on-disk format included. ADR-0007's stricter promise, that a
PATCH bump may not break the API or the format and a MINOR may, takes effect at
0.1.0. When to cut 0.1.0, or whether to move straight from 0.0.x to
1.0.0, is decided when Era 3 is in view and the API is worth stabilising.
The format version track is unchanged. format_version still starts at 1
and still increments by exactly the rules in ADR-0007's table, so an older
release always meets a store it cannot read with a clear message. The only
difference while in 0.0.x is that a format_version increment ships in a
0.0.x bump rather than a MINOR one.
Consequences
- The version number matches the honest state of the project: barely started, on a long road.
- One renumber, once: the six roadmap milestones become
v0.0.2throughv0.0.7. TheROADMAP.mdtable, theREADME.mdarc, the GitHub milestones and the phase label descriptions are updated in the same change as this ADR. - ADR-0007 stays Accepted. This ADR narrows one of its clauses for the
0.0.xperiod and schedules when the rest applies; it supersedes nothing. - Until
0.1.0, theformat_versioninteger, not the software version, is the stability signal. A reader who sees0.0.4knows not to build anything load-bearing on the format yet.
Alternatives considered
Keep 0.MINOR per phase (Phase 1 = 0.1.0 ... Phase 6 = 0.6.0), push 1.0 out in the narrative only. Least churn, but 0.6.0 for a single-agent substrate with no multi-agent story still reads as most of the way to done. Rejected.
Two 0.x eras: 0.0.x for the substrate, 0.1.x onward for collaboration. A cleaner boundary, but it commits now to cutting 0.1.0 at a point that is still far off and better decided later. Rejected for the looser 0.0.x range.
A dated amendment to ADR-0007 instead of a new ADR. ADRs are immutable once Accepted (ADR-0001). A new ADR is the mechanism.
ADR-0011: Conventional commits and the issue lifecycle
- Status: Accepted
- Date: 2026-09-06
- Issue: #100
Context
The project has around fifty commits in a plain student voice, and issues close
the moment a pull request merges to main. Both were fine while there was no
release cadence. As the history grows and releases begin (ADR-0007, ADR-0010),
two gaps show:
- A plain subject line carries no machine-readable signal. A convention lets release notes group changes, lets a check cross-reference the hand-written changelog, and tells a reader the project is run to a standard.
- Closing an issue on merge to
mainconflates "inmain" with "shipped". A store written byv0.0.2is the contract, not whatever is onmain.
This ADR fixes the commit convention and the issue lifecycle. It does not change the hand-written changelog (ADR-0007) or the versioning scheme (ADR-0010).
Decision
Conventional commits
Every commit message and every pull request title follows Conventional Commits 1.0.0:
<type>(<scope>): <description>
<body>
refs #<issue>
- type: one of
feat,fix,docs,test,refactor,perf,build,ci,chore,revert. - scope: optional, lowercase, a crate or area:
core,cli,sdk,format,adr,ci. Use it when it sharpens the subject. - description: lowercase, imperative, British English, concise, no trailing full stop. Aim for 72 characters.
- body: optional, plain student voice, wrapped. Explains why, not what.
- No co-author trailer, ever (unchanged, AGENTS.md).
Earlier commits are left as they are; the history has a small discontinuity at this ADR.
refs #N, not closing keywords
A commit or pull request that relates to an issue puts refs #<n> in the body.
GitHub's closing keywords (closes, fixes, resolves) are not used, so a
merge to main never closes an issue on its own.
Issues close at release
An issue closes when the tag that contains its change is pushed, not when its
pull request merges. Between merge and release the issue carries the
pending-release label. A phase's map issue closes when its milestone tag is
pushed.
Enforcement
scripts/conventional_commits.py validates the subject of every non-merge
commit in a pull request, and the pull request title, against the format above,
and rejects closing keywords. It has a unit test and runs in a CI commits job
on pull_request events.
Consequences
- Release notes and
git log --onelinebecome scannable by type. - One more check to pass on a pull request. It is fast and local via
just ci. - The changelog stays hand-written; a later check can diff it against the commit types in a release range.
- The tracker now distinguishes "merged" from "released", at the cost of a batch issue-close step at each tag.
Alternatives considered
Keep the student voice. Rejected. No machine signal, and a weaker read for anyone judging whether the project is serious.
Conventional pull request titles only, free-form commits. Rejected. The
squash history would be clean but the branch history stays noisy, and the check
would only be half a check. Merges here are merge commits, not squashes, so the
individual commits land on main.
Commitizen or semantic-release. Rejected. Overkill for one maintainer, and ADR-0007 already chose a hand-written changelog over a generated one.
ADR-0012: The branch and checkout model
- Status: Accepted
- Date: 2026-09-06
- Issue: #35 (grilling), research #34
Context
Phase 2 (v0.0.3) adds branching, checkout, reading working memory at any past
commit, a structural diff, and node deletion. It is still Era 1: single-agent,
local, deterministic (ADR-0010).
The research (docs/research/issue-34-branching-survey.md) found that the hard
part is already done. ADR-0009 built the refs table (name -> [u8; 32]) with
compare-and-swap moves and name validation, and Store::commit already creates
and advances a branch. A branch is a 32-byte value in one row, so branch
creation is O(1) and copies nothing. What is missing is the surface and one
decision: what "working memory" is, given that there is no working-tree concept
today, only staging (the index equivalent).
This ADR fixes that surface. It changes no on-disk format: format_version
stays 1.
Decision
Branches are pointer-only
No copy-on-write of state at branch time, because nothing is copied.
Store::branch(name, start: Option<CommitIsh>)inserts arefsrow at the given start point, or whereHEADresolves. Fails if the name already exists (no silent move) or is invalid (ADR-0009 rules).Store::branches()isrefs::list.Store::delete_branch(name)refuses the branchHEADis on and otherwise drops the row. There is no force variant: Phase 2 has no garbage collection, so a deleted branch's commits stay reachable by id.
CLI: mnem branch lists (current marked *, with the tip's 12-hex id and
message); mnem branch <name> [<start-point>] creates; mnem branch -d <name>
deletes.
checkout is one atomic HEAD write
Store::checkout(target: CommitIsh, discard: bool):
- Resolves
target: an exact branch name first, then an unambiguous commit-id hex prefix (minimum four characters). A branch target gives an attachedHEAD; a commit target gives a detachedHEAD. - Refused when
stagingorstaging_tombstonesis non-empty, with a message naming the pending ids, unlessdiscardis set (then both are cleared). - Writes
HEADatomically (temp file plus rename). Nothing else is touched. No lock is taken. - Checkout of the current branch, or of the commit
HEADalready resolves to, is a no-op that succeeds.
CLI: mnem checkout <target>, mnem checkout -b <name> [<start-point>]
(create-and-switch; the name must not exist). On success it prints a short line:
Switched to branch 'h1', Created branch 'h1' at 45ee8a6b, or HEAD is now at 45ee8a6b (detached).
Working memory is a view, never stored
Working memory is defined as:
the
HEADcommit'sState, minus the ids instaging_tombstones, withstagingoverlaid.
It is computed on every read. There is no working table.
Store::working_memory() -> BTreeMap<String, MemoryNode>(decoded).- The SDK returns it as
dict[str, MemoryNode], keyed by node id. mnem showwith no argument prints working memory.
Time travel reads any commit
Store::state_at(commit) -> BTreeMap<String, MemoryNode>loads a commit'sStateand its nodes, decoded.Store::state_map_at(commit) -> BTreeMap<String, ObjectId>is the raw form for callers that do not need content (diffuses it internally).- Any commit that exists in
objectsand is aCommit. No branch, no ancestry requirement. Read-only, so no lock. mnem show <commit>prints the full memory snapshot at that commit.
Node deletion
Store::rm(id)stages a tombstone in a newstaging_tombstonestable (a set of node ids). This table is local working state, likestaging: it is not part of the portable history and does not travel with a store.rmof an id that is neither in theHEADstate nor staged is an error.rmof an id that is only a staged add (not inHEAD) unstages it instead of tombstoning.commitbuilds the newStatefrom the parent'sStateplusstaging, then removes every tombstoned key. A commit with only tombstones staged is valid.unstage(id)clears the id from bothstagingandstaging_tombstones.
CLI: mnem rm <id>.
Structural diff
Store::diff(from: DiffTarget, to: DiffTarget) -> Vec<NodeChange>whereDiffTargetisCommit(ObjectId)orWorking.NodeChangeisAdded { id, new },Removed { id, old }, orModified { id, old, new }, carryingObjectIds only. Computed as a merge-join over the two sortedStatemaps: O(nodes). Any two states; no ancestry required.- The SDK returns
list[NodeChange], a frozen dataclass (id,kind, andold/newasMemoryNode | None).
CLI: mnem diff (HEAD vs working), mnem diff <commit> (vs working), mnem diff <a> <b>. It shows the old and new content of each change by default;
--stat and --name-only give the terse id list.
status
In scope for v0.0.3. mnem status prints the branch line (On branch main,
or HEAD detached at 45ee8a6b), the HEAD commit's short id and message, then
diff(HEAD, Working) rendered as + (new), ~ (update), - (removal) against
the node id. "Clean" when nothing is staged.
The SDK branch context manager
The ADR-0004 carve-out. with store.branch("h1"):
- On enter: create
h1atHEAD, checkouth1. Entering with dirty staging raises, percheckout's rule. - On exit, normal or exception: checkout back to the branch that was current on
enter; leave
h1in place for the caller tomerge(Phase 3) ordelete_branch; re-raise on exception.
No automatic merge. Merge is Phase 3.
Deferred
- The prolly-tree
Stateform. Phase 2 keeps the flatState(a full id-to-ObjectIdmap per commit). Storage grows as commits times nodes, which is fine at the scale an agent runs at. A prolly form (a secondStatekind, aformat_versionbump) is introduced when the first of these fires: Phase 3's three-way merge is judged to need it, or a real store hits the storage ceiling. Whichever comes first opens its own research ticket. - The reflog. ADR-0009 reserved it; Phase 2 does not write it. Detaching prints the commit id so it is not lost.
- Rev-navigation syntax (
<commit>^,<commit>~2). Explicit ids frommnem logfor now.
Consequences
- Branching and checkout cost one small write each. The design adds no on-disk
format structure that a store reader sees;
format_versionstays 1. - Working memory has no materialisation cost and no third place to keep in sync, at the price of an O(nodes) walk per full read. That walk is the true cost of "give me the whole memory" under any design.
staging_tombstonesis a second piece of local-only state.commit,unstageandstatusall have to account for it.- An agent can now forget a stale belief (
rm), fork memory to try an approach (branchplus the context manager), see what it changed (diff,status), and read what it knew at any past point (show <commit>). - The flat-
Stateceiling is real and named. A long-lived agent will hit it before a short one; the trigger and the response are written down rather than discovered.
Alternatives considered
A materialised working table that checkout fills. Closest to Git's
working tree. Rejected: an O(nodes) write on every checkout, and a third place
the node set lives and can drift. The view has none of that cost.
Carry staged changes across a checkout when they do not collide (Git's real
behaviour). Rejected: it doubles the checkout logic for a workflow that does not
need it. Refuse-by-default with --discard is one path.
Defer node deletion to Phase 3. Rejected: it would ship v0.0.3 and
v0.0.4 with no way for an agent to remove a fact, and Phase 3's merge would
have to invent the primitive anyway.
Introduce the prolly tree now, in Phase 2 (ADR-0002 originally placed it here). Rejected for this phase: none of Phase 2's deliverables need it to be correct, and it is real work (a chunker, a node format, a rebalancing story, a format change). Deferred with named triggers.
Start writing a reflog in Phase 2, since checkout is the first operation
that wants one. Rejected: the reflog is its own design (retention, HEAD versus
per-branch, a mnem reflog surface) and nothing in Phase 2's definition of done
needs it.
ADR-0013: The deterministic merge algorithm
- Status: Accepted
- Date: 2026-09-07
- Issue: #43 (grilling), research #42
Context
Phase 3 (v0.0.4) adds merge: combining two lines of memory. It is the last
purely mechanical piece of the substrate. The merge is structural and
deterministic: it works on the State map (node id to object id), one entry
at a time, and never looks inside a node's content. Semantic merge over claim
contents is Era 2 and lives behind a trait in a separate crate.
The research (docs/research/issue-42-merge-survey.md) surveyed the per-id
three-way merge table, the merge base, and the invariants. ADR-0012 named this
phase as a reassessment point for the prolly-tree State form. Issue #45 built
Store::merge_base / merge_bases / ancestors / is_ancestor.
This ADR fixes the algorithm. ADR-0014 fixes the conflict object and the
resolution API. Neither changes format_version: a merge commit is an ordinary
two-parent Commit, and the merged State is an ordinary flat State.
Decision
The merge base
merge requires exactly one merge base (Store::merge_base, #45). If two
commits have several lowest common ancestors (a criss-cross history) or none
(disjoint histories), merge refuses with a message naming them. Recursive
merge over a virtual base (Git's ort strategy) is deferred; the manual path is
to merge one side first. A single-agent substrate with a human in the loop
rarely produces a criss-cross.
Fast-forward
If theirs is a descendant of the current branch tip, there is nothing to
merge. merge fast-forwards: it moves the branch ref to theirs, writes no
merge commit, and reports FastForwarded(theirs). The CLI prints
Fast-forwarded '<branch>' to <short id>. There is no --no-ff; agents do not
want merge commits for trivial merges. If theirs is an ancestor of the tip (or
equal), merge reports "already up to date" and does nothing.
The per-id three-way merge
Let B, O, T be the State maps of the base, ours (the current branch
tip), and theirs. For each node id present in any of them, with b, o, t
its object id in each (or absent):
b | o | t | result |
|---|---|---|---|
| x | x | x | keep x |
| x | y | x | keep y (ours changed) |
| x | x | y | keep y (theirs changed) |
| x | y | y | keep y (convergent edit) |
| x | y | z | conflict EditEdit |
| x | absent | x | absent (ours deleted) |
| x | x | absent | absent (theirs deleted) |
| x | absent | absent | absent (convergent delete) |
| x | absent | z | conflict DeleteEdit |
| x | y | absent | conflict EditDelete |
| absent | y | absent | keep y (ours added) |
| absent | absent | z | keep z (theirs added) |
| absent | y | y | keep y (convergent add) |
| absent | y | z | conflict AddAdd |
The table is total: every combination is an automatic result or a named
conflict. delete/edit and add/add (with differing objects) are conflicts,
not silent resolutions, because auto-resolving loses a side's intent.
The merged map is built by walking the union of ids in sorted order, so a
clean merge produces a bit-identical State object regardless of which side is
"ours", and the conflict list is in node-id order.
The merge call (Model A: stateless)
Store::merge(
theirs: &str, // a commit-ish
resolutions: &BTreeMap<String, Resolution>,
strategy: Option<MergeStrategy>, // Ours | Theirs
message: Option<&str>,
author: &str,
time_ms: i64,
) -> Result<MergeOutcome>
enum MergeOutcome {
AlreadyUpToDate,
FastForwarded(ObjectId),
Merged(ObjectId), // the new two-parent commit
Conflicts(Vec<Conflict>), // nothing was written
}
There is no pending-merge state, no MERGE_HEAD, no local table, no
--continue. merge is a function: run it, and if it returns Conflicts,
inspect them, build a resolution map, and call merge again with it.
- A
resolutionsentry for an id that is not a conflict is an error, so a stale map is caught rather than silently ignored. strategy: Some(Ours)resolves every conflict too;Some(Theirs)tot. An explicitresolutionsentry overrides the strategy for that id.- If, after applying
resolutionsand anystrategy, some conflicts remain unresolved,mergereturnsConflictswith just those.
The merge commit
On a clean merge or a fully resolved one, merge writes the merged State,
then a Commit with:
parents = [ours_tip, theirs_tip](ours first, solog --first-parentfollows the branch we were on)message= the caller's, ormerge <theirs> into <branch>author,timefrom the caller, as withcommit- a compare-and-swap of the branch ref against
ours_tip(ADR-0009), so a concurrent commit makes the merge fail rather than clobber
Preconditions
merge is refused, with a clear message, when:
HEADis detached (merge only from a branch, likecommit)stagingor the tombstone set is non-empty (a dirty index; commit or unstage first, no--discard)
State stays flat; prolly reassessed
The merge is O(distinct node ids across B, O, T); the base walk is O(history
size). Both are trivial at an agent's scale (hundreds of ids, short histories).
The prolly-tree State form's payoff (diff and merge proportional to the change,
not the size) matters at thousands to millions of ids. Flat State is kept for
Phase 3. The trigger for prolly is unchanged: a real store hitting the storage
or latency ceiling, which then opens its own research ticket.
Consequences
mergeis a pure-ish function with a small surface and no extra states to reason about. The SDK reads naturally; the CLI resolves conflicts with flags.- No
format_versionchange: a merge commit and a mergedStateare ordinary objects. - A criss-cross history is a hard error, not a silent guess. Accepted: it is rare here, and the workaround (merge one side first) is clear.
- A merge that hits conflicts writes nothing, so there is never a
half-merged store on disk. The cost is that the caller re-runs
mergewith the full resolution map rather than resolving incrementally. - Persistent, reviewable conflicts (for the Era 2 review model) will need the pending-merge state this ADR omits. That is Era 2's to add.
Alternatives considered
A pending-merge state with merge --continue / --abort (Git's model).
Rejected for v0.0.4: it needs a local table, a MERGE_HEAD equivalent, and
several mid-merge states, for a workflow (interactive incremental conflict
resolution) that a single agent does not do. It returns in Era 2 with the review
model, which genuinely needs conflicts to persist and be reviewed.
Auto-resolving delete/edit and add/add (the edit wins, the newer add
wins). Rejected: it silently discards one side's intent. A conflict makes the
caller choose.
Always writing a merge commit, even for a fast-forward (--no-ff by
default). Rejected: it clutters the history with trivial merges. --no-ff can be
added later if wanted.
Recursive merge over a virtual base for criss-cross histories. Deferred, not rejected. It is correct and Git does it, but it is a body of work for a case that a human-gated single agent rarely produces.
ADR-0014: The conflict object and the resolution API
- Status: Accepted
- Date: 2026-09-07
- Issue: #44 (grilling), research #42
Context
ADR-0013 fixed the merge algorithm: a structural three-way merge over the
State map that produces a merged map plus a set of conflicts. This ADR fixes
what a conflict is, how a caller resolves one, and how that surfaces in the
CLI and the SDK.
The merge is a stateless call (ADR-0013, Model A): merge returns
Conflicts(Vec<Conflict>) and writes nothing; the caller inspects, builds a
resolution map, and calls merge again.
Decision
The conflict, a transient value
A conflict is a plain value returned by merge, not a stored content-addressed
object:
#![allow(unused)] fn main() { pub struct Conflict { pub id: String, pub kind: ConflictKind, pub base: Option<ObjectId>, // None for AddAdd pub ours: Option<ObjectId>, // None for DeleteEdit pub theirs: Option<ObjectId>, // None for EditDelete } pub enum ConflictKind { EditEdit, // both changed the node to different objects DeleteEdit, // ours deleted, theirs changed EditDelete, // ours changed, theirs deleted AddAdd, // both added the id with different objects } }
base, ours and theirs are the node object ids on each side, so the caller
can load and compare their content (Store::node, ADR-0012). The SDK exposes it
as a frozen Conflict dataclass whose base / ours / theirs are
MemoryNode | None.
A stored conflict object, content-addressed and referenced from a persisted merge state, is what the Era 2 review model needs so a reviewer can approve or reject a resolution before it lands in shared memory. That is deferred to Era 2.
Resolutions
Per conflicting id, the caller supplies one:
#![allow(unused)] fn main() { pub enum Resolution { Ours, // take `ours` Theirs, // take `theirs` Base, // take `base`, reverting both edits (invalid for AddAdd) Delete, // the merged state omits the id Set(MemoryNode) // a fresh node as the resolution } }
Set(node) writes the node object to objects (the same path stage uses) and
uses its id. Base on an AddAdd conflict is an error, since there is no base
object.
A resolution map (BTreeMap<String, Resolution>) is passed to merge. An entry
for an id that is not a conflict is an error. Unresolved conflicts after the map
and any strategy are re-returned.
The merge outcome across the boundary
The SDK returns a small result object from store.merge(...):
@dataclass(frozen=True)
class MergeResult:
status: str # "up-to-date" | "fast-forwarded" | "merged" | "conflicts"
commit: str | None # the new commit id, for fast-forwarded / merged
conflicts: list[Conflict] # for "conflicts", else empty
Usage:
result = store.merge("feature")
if result.status == "conflicts":
resolutions = {c.id: pick(c) for c in result.conflicts}
result = store.merge("feature", resolutions=resolutions)
resolutions values are the strings "ours" | "theirs" | "base" | "delete" or a
MemoryNode (for set). strategy="ours" | "theirs" is the whole-merge
shortcut.
The CLI
mnem merge <branch-or-commit>
mnem merge <branch-or-commit> --resolve <id>=<ours|theirs|base|delete> ...
mnem merge <branch-or-commit> --strategy <ours|theirs>
- A clean merge or a fully resolved one prints
[<short id>] merge <theirs> into <branch>and exits 0. - A fast-forward prints
Fast-forwarded '<branch>' to <short id>. - Unresolved conflicts are printed, one block per id (the kind and the content
of each side), and the command exits non-zero. The user re-runs with
--resolveflags or--strategy. --resolvewith aset(a supplied node) is not offered on the CLI inv0.0.4; use the SDK or resolve toours/theirs/base.
mnem status is unchanged: with no pending-merge state, there is nothing
mid-merge to show.
Consequences
- A conflict is cheap: no object written, no id to garbage-collect, no schema.
The trade is that conflicts do not survive the process; a caller that wants to
resolve later re-runs
merge. - The resolution set is small and closed.
Set(node)covers "neither side is right"; the other four cover the common picks. - The CLI conflict-resolution flow is flag-driven rather than an editor loop.
Blunt, but agents drive this far more than humans, and
--strategy oursis the usual agent choice. - Era 2's review model will add a stored conflict object and a persisted merge
state on top of this; the transient
Conflictshape here is the starting point for that object's fields.
Alternatives considered
A stored, content-addressed conflict object now. Rejected for v0.0.4: it
buys "walk away and resolve later" and an audit trail, neither of which a
stateless single-agent merge needs. It is exactly what Era 2 adds, and building
it now would be guessing at the review model's requirements.
A richer resolution language (take-ours-for-these-fields, three-way text merge of content). Rejected: that is semantic merge, which is Era 2 behind a trait. The substrate's resolutions are whole-node.
An interactive mnem mergetool. Rejected for now: flags cover the human
case, and the primary caller is an agent.
ADR-0015: The provenance index, blame and bisect
- Status: Accepted
- Date: 2026-09-07
- Issue: #50 (grilling), research #135
Context
Phase 4 (v0.0.5) is "it explains". Two operations:
blame <node-id>— resolve a memory node to the commit, and the provenance, that introduced its current value.bisect— binary-search a commit range for the first commit where a supplied predicate holds ("this wrong belief is present").
Both are read-only walks over objects that already exist. Nothing here changes
format_version. Provenance lives on the MemoryNode (ADR-0003), not the
Commit, and may be entirely empty; blame therefore always resolves to a
commit and additionally surfaces whatever provenance the node carries.
The research (docs/research/issue-135-provenance-blame-bisect.md) found that at
this scale — hundreds of node ids, short histories — blame is a fast
first-parent walk and bisect is O(log N) state reads, so neither needs an
index. This ADR fixes the two operations and the one small index that is worth
building anyway.
Decision
The commit-nodes index (the reverse map)
A new redb table, commit_nodes, maps a commit id to the node ids it changed
relative to its first parent, each tagged with how it changed:
commit_nodes: commit_id (32 bytes) -> CBOR(BTreeMap<String, ChangeKind>)
enum ChangeKind { Added, Modified, Removed }
- Source.
diff(parents[0], commit)— the sameAdded/Removed/ModifiedclassificationStore::diffalready produces (ADR-0012). The first commit's entry is every node id in its state, allAdded. - Merge commits. Diffed against
parents[0]only, consistent with the first-parent modellogandblameuse. A merge commit legitimately "brought in" whatever the merged-in side added relative to ours. - Write path. Written inside the existing commit write-transaction
(
Store::mergeandStore::commiteach already open one), so the index entry and the commit are always consistent. A merge commit's entry is written in the same txn that writes the merge commit. - Rebuild.
Store::rebuild_index()does a one-pass walk of every commit and rewrites the table. Used on a format upgrade, if the table is missing, or in tests. There is no automatic rebuild onopen: a fresh0.0.5store builds the index forward from its first commit, and there are no older stores in the wild. A missing entry is never wrong — every reader falls back to a direct walk — so a partially built index is safe. - Exposure.
Store::changed_by(commit) -> BTreeMap<String, ChangeKind>in the core and SDK;mnem show <commit> --statprints one classified line per changed node (+ plan,~ owner,- status).
The forward map (node_id -> introducing commit for the current tip) is
deferred. It would give blame an O(1) answer it does not need yet, and it
is the awkward one to maintain: "current" is per-branch, so it must be patched
on every commit and every checkout. It lands when a real store makes the
blame walk too slow, or when the Era 2 review UI needs the reverse lookup at
interactive latency — the same "reassess on a real ceiling" trigger ADR-0012 and
ADR-0013 use for the prolly tree.
blame
#![allow(unused)] fn main() { pub struct Blame { pub commit: ObjectId, // the commit that introduced the current value pub node: MemoryNode, // the node as written at that commit pub time: i64, // node.event_time, or the commit's record time } impl Store { pub fn blame(&self, node_id: &str, at: &str) -> Result<Blame>; } }
atis a commit-ish (branch name or hex prefix,resolve_commitish), defaulting toHEAD. A detachedHEADor an unborn branch is a clean error.- Let
target = state_map_at(at)[node_id]; if the id is absent, error:blame: "<node_id>" is not in memory at <at>. "When was it deleted" is abisectpredicate or a futuremnem log --follow, notblame. - Walk the first-parent chain from
at. At each commitCiwith first parentCp: ifstate_map_at(Ci)[node_id] != state_map_at(Cp)[node_id], thenCichanged the value. The root commit (no parent) always counts. - Through a merge. If
Ciis a merge and its value fornode_iddiffers fromparents[0]but equals someparents[k], continue the walk fromparents[k]instead of stopping. This follows the value to the commit that actually wrote it, rather than reporting "a merge happened". - Index use.
blamemay consultcommit_nodesas a skip filter — if a commit's change set does not containnode_id, its state need not be loaded. The index is an accelerator, never the source of truth. provenanceis read offBlame.node.provenance; an empty provenance is legal and meansblameresolved only to the commit.
bisect
#![allow(unused)] fn main() { impl Store { pub fn bisect( &self, bad: &str, // predicate true; default HEAD good: Option<&str>, // predicate false; default the root predicate: impl Fn(&BTreeMap<String, MemoryNode>) -> bool, ) -> Result<ObjectId>; // the first `bad` commit } }
badandgoodare commit-ishes.gooddefaults to the root commit reached by the first-parent walk frombad.- Up-front checks:
goodis an ancestor ofbad(is_ancestor); the predicate isfalseatgoodandtrueatbad. Otherwise a clear error (the predicate is already true at <good>,<good> is not an ancestor of <bad>). - Monotonicity is assumed, as in
git bisect:falseup to a boundary,truefrom there on. A non-monotonic predicate gives a boundary that is one of possibly several transitions; this is documented, not detected. - Linear history only. Walk the first-parent chain from
badback togood, giving an ordered list, and binary-search it: O(log N)state_atevaluations. Merge commits on the chain are evaluated normally (their state is fully materialised); second parents are not descended into.git bisect's skip / multiple-bad-region handling is not built. - Returns the first commit on the chain (oldest-to-newest) for which the predicate holds — the boundary.
The CLI and the SDK
mnem blame <node-id> [<commit>]
mnem bisect --node <id> --equals <json-value> [--good <commit>] [<bad-commit>]
mnem bisect --node <id> --absent [--good <commit>] [<bad-commit>]
mnem bisect --node <id> --present [--good <commit>] [<bad-commit>]
mnem blame planprints the short commit id, the effective time, the author, and the provenance fields that are set, then the node content.mnem bisectbuilds the predicate from--nodeplus one of--equals(content equals this JSON value),--absent,--present. On success it prints the boundary commit and itsblamefor--node— "the belief entered at<short>, written at<step>from<observation>" — so finding and explaining a bad belief is one command. A general predicate command (git bisect runstyle) is a later ticket.- SDK:
store.blame(node_id, at="HEAD") -> Blame;store.bisect(bad="HEAD", good=None, predicate=...) -> strtaking a Python callable over a{id: MemoryNode}dict;store.changed_by(commit) -> dict.Blameis a frozen dataclass (commit,node,time).
The reflog
ADR-0009 parked the reflog — a small append-only (ref, old, new, time, op)
table — "for Phase 2, or alongside blame and bisect in Phase 4". It is
deferred again: blame and bisect walk the commit graph, not ref history,
so neither needs it, and folding it in widens the phase for no gain to the "it
explains" story. It moves to its own ticket, to be picked up in Phase 5 or 6 or
when GC design starts (the two interact).
Consequences
commit_nodesis cheap and monotonic: one derived write per commit, in a txn that is already open, never invalidated because commits are immutable. It is a real piece of substrate — the basis for--statoutput now and the Era 2 audit view later — not speculative infra.blameandbisectare correct with an empty or absent index; the index only makesblameskip work. A store can alwaysrebuild_index().blamefollows values through merges, so its answer is "who wrote this", which is what a user debugging a belief wants.bisectinheritsgit bisect's monotonicity assumption and its linear-history simplification. Documented; revisited only if a real multi-agent history needs more.blamequality still depends on the agent populating provenance (ADR-0003). An agent that passes nothing getsblamethat resolves to the commit and its time, and no more.
Alternatives considered
Build the forward map (node -> introducing commit) now. Rejected: it is
speculative at this scale, and it is the map that needs per-checkout
maintenance. The reverse map gives most of the value (reverse lookups, --stat)
with none of the invalidation risk.
No index at all this phase; #51 becomes test expansion. Rejected: the reverse map is a dozen lines in a path that is already open, and it is genuinely useful now and structurally needed for Era 2. Deferring it would be deferring work that has no reason to wait.
A predicate DSL or mnem bisect run <command> now. Deferred: the
--node/--equals/--absent helper covers the buggy-belief case, and a general
predicate language (jq paths, external commands) is its own design. The core
closure is fully general for the SDK and tests.
blame reports the merge commit without recursing. Rejected: "a merge
happened here" is not an explanation. Following the contributing parent is a few
lines and gives the origin.
Fold the reflog in while ref-move code is being touched. Rejected: it is not needed by anything in Phase 4, and it interacts with the unstarted GC design. Recorded as deferred so it is not lost.
ADR-0016: The MCP tools and the adapter contract
- Status: Accepted
- Date: 2026-09-07
- Issue: #56 (grilling), research #55
Context
Phase 5 (v0.0.6) is "it plugs in": an MCP server (mnem-mcp) and a LangGraph
adapter (mnem-langgraph), so a real agent uses Mnemosyne as its memory with
branch and blame working. This ADR fixes the MCP tool and resource surface, how
a store binds to a stateless server, and the contract the two adapters share.
ADR-0004 already settled the shape: adapters are Python packages that depend
on the SDK, not the core, and live outside the Cargo workspace. So mnem-core
stays offline and deterministic, cargo deny is not in play, and the network,
the async runtime and the protocol libraries all sit in the adapter packages.
This ADR does not change format_version: the adapters are new consumers of an
unchanged store.
The MCP spec revision assumed here is 2026-07-28: a stateless transport (no
protocol sessions), the tools / resources / prompts primitives, stdio and
Streamable HTTP transports, and ttlMs / cacheScope on list and read results.
Decision
Packaging
Two new packages under packages/ in this repo, each with its own
pyproject.toml, neither a Cargo member:
packages/mnem-mcp/-> PyPImnemosyne-mcp, importmnem_mcp, console scriptmnem-mcp.packages/mnem-langgraph/-> PyPImnemosyne-langgraph, importmnem_langgraph.
Both pin mnemosyne-agents ~= 0.0 and version independently: they are the
SDK's customers, not part of its release train. CI tests them against the built
wheel. They move to their own repositories only if an adapter grows its own
contributors.
The mnem.agents module (the shared contract)
A new module in the SDK, consumed by both adapters and available to the CLI later. It holds the ergonomics that both adapters would otherwise duplicate, keeping ADR-0004's "the SDK holds the ergonomics" true.
# mnem.agents
def remember(store, id, content, *, source=None, step=None, observation=None,
summary=None, author="agent", time_ms=None) -> str # commit id
def remember_many(store, items, *, summary=None, author="agent",
time_ms=None) -> str # one commit
def forget(store, id, *, summary=None, author="agent", time_ms=None) -> str
Each is add (or rm) then commit in one call. summary defaults to a
generated message ("remember {id}", "remember 3 nodes", "forget {id}").
The result dataclasses gain to_dict() for the JSON boundary: Blame,
NodeChange, Commit, MemoryNode, Conflict, MergeResult. The binding
already emits dicts; the SDK dataclasses round-trip to the same shape.
The MCP tools
Nine agent-facing tools, named for how an agent reaches for them:
| Tool | Does | Arguments |
|---|---|---|
remember | record one fact, one commit | id, content, source?, step?, observation?, summary? |
revise | same as remember; the name signals "this changes a belief" | same |
remember_many | record several facts as one commit | items: [{id, content, ...}], summary? |
forget | tombstone one node, one commit | id, summary? |
recall | read one node, or the whole current memory | id? |
recall_at | the memory as of a past commit | commit, id? |
history | recent commits, newest first | limit? |
why | the commit and provenance that set a node | id, at? |
when_did | first commit where a node reaches a value / is absent / is present | id, one of equals / absent / present, good? |
whats_new | what a commit changed | commit |
revise and remember share one implementation and differ only in the
description string that the model reads.
when_did is the --node/--equals/--absent/--present helper form only,
matching the CLI (ADR-0015); the predicate-closure form has no JSON Schema.
Harness tools. branch, switch and merge (with the CLI's
--resolve / --strategy resolution surface) are implemented but hidden from
the model by default. A mnem-mcp --tools=core|all flag (default core)
controls visibility. An agent framework that drives a hypothesis branch around a
sub-task passes --tools=all; a bare model does not see them.
The MCP resources
| URI | Backed by | Cache |
|---|---|---|
mnem://memory | working_memory | short ttlMs, cacheScope per-store, invalidated on any write |
mnem://memory/{node_id} | working_node | as above |
mnem://log | log(limit) | short ttlMs, per-store |
mnem://commit/{id} | state_at + changed_by | long ttlMs (a commit is immutable) |
The resources let a client keep the current memory in context without a tool
call every turn. recall stays as a tool for models that pull rather than get
pushed.
The write contract: no staging over MCP
The SDK's stage-then-commit split does not cross the MCP boundary. A tool call
is a discrete, retryable unit; a server that carried half-staged state between
calls would break the stateless model and leave memory in a limbo the model
cannot see. Every write tool is exactly one commit, atomic (the core's
commit is one write transaction, ADR-0008). remember_many is the batch form:
several stage calls then one commit.
staged, unstage, staged_deletions, init, resolve, add_node,
format_version, root and head are not exposed.
Store binding
MCP 2026-07-28 is stateless. The server binds one store per process: the
path comes from mnem-mcp --store <path>, with a MNEM_STORE environment
fallback. Every tool call does mnem.open (a cheap redb handle), acts, and
returns. An agent runtime launches one mnem-mcp per agent, pointed at that
agent's memory.
Multi-tenant selection (a store argument per call, or a routing header) and
per-identity stores (post-authentication) are deferred to the collaboration
layer.
Concurrency. redb is one writer, many readers. Two concurrent writes to one
store serialise on the write transaction; the loser is retried by the SDK
(bounded, below) and, if it still fails, the tool returns the busy error code.
"Which branch am I on" is the store's HEAD file, not server state, so switch
followed by another call is consistent.
Transport
stdio only for v0.0.6. It is the local, single-agent case the definition
of done describes, needs no authentication, and is what an agent runtime spawns.
The mcp Python SDK's FastMCP API writes the server once; Streamable HTTP
(remote, multi-client, the 2026-07-28 authorization surface) is later
configuration and belongs with the collaboration layer.
The LangGraph adapter
mnem_langgraph.MnemosyneStore implements LangGraph's BaseStore
(cross-thread long-term memory). Not BaseCheckpointSaver: per-superstep graph
state is a different shape and a larger surface, deferred with a trigger
(someone wants a rewindable agent run).
Mapping:
BaseStore | Mnemosyne |
|---|---|
namespace: tuple[str, ...] | an id prefix: the node id is ":".join(namespace) + ":" + key |
key: str | the node id within the namespace |
value: dict | the node content, minus a reserved _meta key |
put(ns, key, value) | agents.remember, with Provenance lifted from value["_meta"] if present |
get(ns, key) | working_node |
delete(ns, key) | agents.forget |
search(ns, query?) | a plain substring filter over node ids and stringified content within the namespace; query omitted lists the namespace |
list_namespaces() | the set of id prefixes present |
- Namespace is a prefix, not a branch. Namespaces scope memory (per user,
per topic); they are not lines of history. Branching stays an explicit
store.branch("hypothesis")call. - Provenance travels in a reserved
_metakey of the opaquevaluedict ({"...": ..., "_meta": {"source": ..., "step": ...}}), which the adapter lifts intoProvenanceand strips from stored content. Absent_metameans empty provenance, which is legal (ADR-0003). searchis a plain filter, not semantic. Documented as such, with a pointer to wrapping a vector store for ranking. Mnemosyne is not a retrieval layer.- Sync and async. The sync methods call the SDK directly; the async methods
(
aget,aput,asearch, ...) wrap them inasyncio.to_threadso the event loop is not blocked. - Extra methods.
store.branch(name),store.switch(name),store.why(namespace, key)andstore.history(limit)are on the adapter class, not the interface, for graph nodes to call directly. This is what "branch and blame working" in the definition of done means.
SDK hardening (#59)
Four items, all in v0.0.6:
mnem.agents(remember/remember_many/forget) and theto_dict()methods, above.- A bounded retry-on-
ConflictErrorwrapper for the atomic write helpers (a few attempts with a short backoff), then a raisedConflictErrorthe caller maps tobusy. to_dict()round-trips onBlame,NodeChange,Commit,MemoryNode,Conflict,MergeResult.Storethread-safety: documented as "one handle per thread"; the adapters open a handle per call, which the stateless MCP design already requires.
Errors across the boundary
One taxonomy, mechanically derived from the core's MnemError hierarchy, as
lowercase string codes on MCP tool errors and adapter exceptions: not_found,
invalid_ref, conflict, corrupt_store, no_store, store_io, plus a
synthetic busy for write-lock contention.
The worked examples (#60)
Three, one per surface, adjustable by #60:
- SDK - a support agent that records beliefs with provenance, gets a plan
tier wrong, and uses
bisect+blameto find the misread observation (the running example, thebuggy_runfixture as a real script). - LangGraph - an agent using
MnemosyneStoreasBaseStoreacross two threads, with a hypothesisbrancharound a sub-task. - MCP - a client script driving
mnem-mcpover stdio throughremember/recall/why.
Consequences
- The core and the SDK are untouched except for one additive module
(
mnem.agents) andto_dict()methods. Noformat_versionchange. mnem-mcpis a thin FastMCP server: nine tools and four resources over the SDK, no state of its own. It is testable by driving it over stdio.- The LangGraph adapter is a
BaseStoresubclass plus a handful of extra methods. An agent author swaps their store backend and gets history, blame and branching, withsearchdegrading to a filter. - The stateless, one-store-per-process design means an agent runtime scales
memory the way it scales agents: one
mnem-mcpeach. - Provenance survives the LangGraph path only if callers populate
_meta. Documented; the MCPremembertool makes it a first-class argument instead. - Two more packages to release. They version independently and pin a loose SDK range, so a patch SDK release does not force an adapter release.
Alternatives considered
Separate repositories for the adapters now. Rejected for v0.0.6: three
repos to keep in sync for a solo maintainer, for no gain while the adapters are
small. The packages/ layout keeps one tracker and lets CI test against the
wheel. Revisited when an adapter gets its own contributors.
Expose the stage/commit split over MCP. Rejected: it makes a tool call stateful, fights the 2026-07-28 stateless transport, and gives a model a way to leave memory half-written that it cannot then see.
Map a BaseStore namespace to a Mnemosyne branch. Rejected: namespaces
scope memory, they are not versions. Branch-per-namespace would multiply
histories and make the common "memories per user" case strange. Branching is an
explicit call.
Implement BaseCheckpointSaver in v0.0.6. Rejected: it is graph
execution state, not beliefs, and a larger surface. It is a compelling second
adapter (a rewindable run) but it waits for a real ask.
A semantic search. Rejected: Mnemosyne is not a retrieval layer (README,
prior work). A plain filter keeps the adapter a drop-in; ranking is the caller's
vector store.
Streamable HTTP transport now. Rejected for v0.0.6: it brings the
authorization surface and multi-client concerns that belong with the
collaboration layer. FastMCP adds it later by configuration, no server rewrite.
ADR-0017: The benchmark and its metrics
- Status: Accepted
- Date: 2026-09-08
- Issue: #62 (grilling), research #61
Context
Phase 6 (v0.0.7) ships the substrate: something you would hand a stranger,
with numbers. This ADR fixes what the benchmark measures, the targets, where the
harnesses live, and how a regression fails CI. Build tickets #64 (the plumbing)
and #65 (the numbers, docs/benchmark.md) follow it.
Mnemosyne makes no accuracy claim. The 2026 research is settled on this: GitOfThoughts (arXiv 2606.14470) tested five memory backends (none, markdown, vector, graph, git) and found none reliably moves an agent's accuracy. Its conclusion is ours: the value of version control is "auditability, history, and the ability to merge two agents' memories, at no cost to accuracy". So the benchmark measures the operational properties and the overhead. Accuracy parity is cited, not re-tested; that needs models and an API budget, and it has been done.
Nothing here changes format_version.
Decision
What is measured
Correctness and precision (deterministic, so the target is exact):
| Metric | Definition | Target |
|---|---|---|
reconstruction_exact | state_at(commit) equals the working memory recorded at commit time, over every commit of every seed | 100.00% |
golden_bytes_stable | the frozen format vectors still hash identically (reuses the golden_vectors assertion) | true |
bisect_exact | bisect returns the exact commit k where a monotonic planted fault began | 100.00% |
bisect_error_max | the largest |found - k| seen | 0 |
blame_commit_acc | blame resolves to the correct introducing commit, linear history | 100.00% |
blame_commit_acc_merge | correct origin commit when the value arrived on the merged-in side | 100.00% |
blame_source_acc | correct provenance source string | 100.00% |
merge_invariants | the #49 chaos invariants (totality, no lost writes, symmetry, convergence) at the bench trial count | true |
Every one of these gates the release. A single failing seed is a bug and blocks
v0.0.7.
Overhead (reported, gated only against regression):
| Metric | Definition |
|---|---|
write_ms_p50, write_ms_p99 | latency of one commit, a 100-key memory |
read_ms_p50 | latency of a full working-memory read |
bytes_per_step | .mnem growth per commit, and the multiple over baseline B |
The workload
A seeded generator, hand-rolled (a linear congruential generator, the style of
tests/time_travel.rs and tests/merge_chaos.rs; no property-test dependency):
- a run is
Lcommits; each stages 1 to 4 operations over a 12-key pool, 75% set / 25% delete; - every set carries provenance:
source = "obs-<n>",agent_step = "step-<c>"; - values are drawn from a small pool, so content-addressed dedup shows up in
bytes_per_step; - a planted fault: at commit
k(at 10%, 50% and 90% of the run) one distinguished key is set to a "wrong" value and left wrong to the end, so thebisectpredicatekey == wrong_valueis genuinely monotonic; - a branched run for
blame_commit_acc_merge: two branches off a base, the blamed key's final value written on the merged-in side, single merge base (no criss-cross, ADR-0013).
The baselines
"No version control" is two things an agent author would otherwise write:
- Baseline A, a dict.
dict[str, Any], overwritten in place. GitOfThoughts's "none". Zero history. - Baseline B, JSONL snapshots. After every step, the whole memory appended as one JSON line. A naive "keep history" approach.
The comparison is an audit-query table, asserted in CI: for each query, which backend can answer it.
| Query | dict | JSONL | Mnemosyne |
|---|---|---|---|
| current memory | yes | yes | yes |
memory as of step t | no | yes | yes |
when did key X first become value V | no | yes, O(L) | yes, O(log L) |
which observation set X's current value | no | no | yes |
what did step t change | no | yes | yes |
| merge two agents' memories, surfacing conflicts | no | no | yes |
benchmarks/overhead.py runs each query against each backend and asserts this
matrix, so a broken bisect or blame fails the benchmark, not just a test.
Where it lives
crates/mnem-core/tests/benchmark.rs: the correctness and precision harness. Runs withcargo testat a small trial count;MNEM_BENCH_RUNS/MNEM_BENCH_LENGTHSenv overrides drive the published sweep.benchmarks/overhead.py: a new top-level directory (mirroringexamples/). Builds a dict, a JSONL file and a.mnemstore over the same synthetic run, times the operations withtime.perf_counter, measures sizes, asserts the audit-query table, and checks the 2x gate.benchmarks/baseline.json:{ "write_ms_p50": ..., "bytes_per_step": ... }. The regression gate reads this;docs/benchmark.mdis the prose. Updated deliberately, in its own commit, when a change to the numbers is intended.docs/benchmark.md: written by #65 from a full sweep, in the shape ofdocs/chaos-report.md: a one-line summary (the audit-query matrix), what was measured, the results table, and the exact reproduction command. Re-run and updated whenever the core changes.
Timing uses std::time::Instant and time.perf_counter; no new dependency,
consistent with the project dropping proptest and time for hand-rolled
equivalents.
CI
- The
rustjob already runsbenchmark.rs(it is a test) at the small trial count:Lin {16, 64}, 40 seeds. - A new
benchmarkjob: Python plus the SDK, runsbenchmarks/overhead.py, asserts the audit-query table, and fails ifwrite_ms_p50orbytes_per_stepexceeds 2x the value inbenchmarks/baseline.json.read_ms_p50is reported, not gated (it is dominated by content decode).
The published sweep
docs/benchmark.md reports:
- correctness:
Lin {16, 64, 256, 1024}, 2000 seeds (a few minutes, like the 50k-case merge-chaos sweep); - overhead:
L = 1024, 100 keys, the median of 5 runs.
The prolly-tree trigger
ADR-0012 and ADR-0013 defer the prolly-tree State form with a "reassess when a
real store hits a ceiling" trigger. The benchmark now produces the number, so
the trigger gets a soft edge: the flat State is reconsidered when
read_ms_p50 for a full working-memory read exceeds roughly 25 ms, or a real
store's .mnem exceeds roughly 50 MB. This is a prompt to revisit, not an
obligation to rewrite.
Consequences
- The correctness metrics are a release gate that runs on every push, so "time travel is exact", "bisect is precise" and "blame is accurate" stop being claims and become CI.
- The overhead numbers are checked in and cannot rot silently; a real regression
(a per-node fsync, an accidental O(L^2)) fails the
benchmarkjob. docs/benchmark.mdis the artefact for the launch: honest, reproducible, and it says plainly what version control does and does not buy.- The
bytes_per_stepandread_ms_p50numbers are the evidence for the prolly-tree decision, without this ADR making it. - Two more files to keep current on a core change (
benchmark.rsis automatic;baseline.jsonanddocs/benchmark.mdneed a deliberate re-run).
Alternatives considered
Benchmark accuracy with real models. Rejected. It needs an API budget, it is not a regression gate (too slow, too noisy), and GitOfThoughts already showed no memory backend reliably helps. Mnemosyne is a substrate, not a retrieval strategy.
A criterion bench harness. Rejected. criterion is a real dependency and
cargo bench is not in CI. A hand-rolled seeded harness in tests/ is the
project's established pattern and runs on every push for free.
Gate the overhead on a fixed absolute (write_ms_p50 < 10). Rejected. CI
runners vary; a fixed ceiling either flaps or is set so loose it catches
nothing. A multiple of the last published number tracks the real trend.
A third baseline (a vector store, SQLite). Rejected. A vector store is a retrieval axis this benchmark does not measure; SQLite is JSONL with indices, the same storage story. Two baselines make the point.
Publish blame_commit_acc_merge below 100% with a caveat. Rejected. The
generator's merges are single-base, so blame's contributing-parent walk is
well-defined and exact. A failure there is a bug, and it blocks the release like
any other correctness miss.
ADR-0018: The Era 2 seam: the semantic merge trait and the sync protocol
- Status: Accepted
- Date: 2026-09-08
- Issue: #63 (grilling)
Context
Era 1 (0.0.x) is the substrate: one agent, local, deterministic. Era 2 is the
collaboration layer: a semantic merge that reasons about contradiction, a sync
protocol between stores, and a review step before a change lands in shared
memory. Pull requests, for agent memory.
Era 2 is not built in v0.0.7. But v0.0.7 freezes the Era 1 format
(docs/format/) and hands the substrate to strangers, so the places Era 2 plugs
in have to be right now, or Era 2 becomes a rewrite of mnem-core rather than a
new crate. This ADR fixes two seams:
- the
SemanticMergetrait: where a content-aware resolver attaches to the structural merge from ADR-0013 and ADR-0014; - the sync protocol shape: the named pieces of store-to-store exchange, without the transport or the wire format.
ADR-0004 keeps mnem-core offline and model-free. The trait lives in the core
(it is part of the merge API); every impl that calls a model lives in a separate
crate. The sync protocol is declared here and built in Era 2 under its own ADR.
Decision
The SemanticMerge trait
The structural merge runs first and unchanged (ADR-0013): it produces a merged
State map plus a list of Conflict values. SemanticMerge is a second pass
over the conflicts the structural merge could not settle:
#![allow(unused)] fn main() { pub trait SemanticMerge { /// Try to settle one structural conflict by reasoning about the nodes on /// each side. Called once per `Conflict` the structural merge produced. fn resolve(&self, store: &Store, conflict: &Conflict) -> Result<Verdict>; } pub enum Verdict { /// The resolver settled it: apply this resolution (ADR-0014). Resolved(Resolution), /// The two sides genuinely disagree and a human or another agent should /// see it. Carries the draft of the `Contradiction` object Era 2 stores. Contradiction(ContradictionDraft), /// The resolver has nothing to add: fall through to the caller's strategy, /// or leave the conflict for the caller (the structural behaviour). Unresolved, } pub struct ContradictionDraft { pub id: String, pub base: Option<ObjectId>, pub ours: Option<ObjectId>, pub theirs: Option<ObjectId>, pub reason: String, } }
resolve takes &Store so a resolver can load the node content on each side
(Store::node) and walk history (provenance, prior commits) to judge. A
narrower context type is a possible Era 2 refinement; &Store is the read
surface that already exists.
Verdict reuses ADR-0014's Resolution unchanged and adds exactly one case,
Contradiction, for "these disagree and the disagreement is worth keeping".
The core ships one impl:
#![allow(unused)] fn main() { pub struct StructuralOnly; // resolve() always returns Verdict::Unresolved }
StructuralOnly is the Era 1 behaviour: no content reasoning, every structural
conflict comes back to the caller.
Where it attaches to Store::merge
Store::merge keeps its exact signature (ADR-0014, Accepted and now frozen). A
new method takes the resolver:
#![allow(unused)] fn main() { pub fn merge_with( &self, theirs: &str, resolver: &dyn SemanticMerge, resolutions: &BTreeMap<String, Resolution>, strategy: Option<MergeStrategy>, message: Option<&str>, author: &str, time_ms: i64, ) -> Result<MergeOutcome>; }
merge is exactly merge_with(theirs, &StructuralOnly, ..). The precedence per
conflicting id is: an explicit resolutions entry from the caller, then the
resolver's Verdict, then strategy, then unresolved. A caller stays in
control, the resolver is the automated middle, and strategy is the blunt
fallback.
merge_with is a Rust-only API in Era 1. The SDK's store.merge(..) is
unchanged; a Python-visible resolver is Era 2's, alongside the crate that
implements one.
Contradiction: a stored object in format_version 2
ADR-0014 kept Conflict transient and deferred a stored form to Era 2's review
model. Contradiction is close to that stored form, and it is different in kind
from a Conflict: a Conflict is "the tool will not choose"; a Contradiction
is "a semantic resolver looked, and the claims are incompatible" (CONTEXT.md).
In Era 2, Contradiction becomes a fourth content-addressed object kind
(format_version 2, after the three in docs/format/), referenced from the
merge commit, so the disagreement is kept in history rather than dropped. Sketch
of the fields: the node id, the object id on each side, the resolver's
reason, and the merge commit it was found in. The exact shape, its encoding
row in docs/format/, and the MergeOutcome arm that carries contradictions
out of merge_with are Era 2's, under the format_version 2 ADR.
ContradictionDraft above is the in-memory precursor: what a resolver returns
before anything is stored.
In v0.0.7, StructuralOnly never returns Verdict::Contradiction, so no
contradiction is produced through merge. A custom resolver that returns one is
treated as Unresolved for now (the conflict comes back to the caller); the
MergeOutcome arm and the stored object land together in Era 2.
The sync protocol shape
Era 2 lets two stores exchange history. This ADR names the pieces and defers the rest:
- A
Remote: a named pointer to another store, the way a branch is a named pointer to a commit.mnem remote add <name> <location>. - Content-addressed exchange: objects are addressed by hash (ADR-0005), so
syncing is a negotiation of "which object ids does each side hold", then a
transfer of only the missing objects and the ref updates. Dedup is free and
the transfer is minimal, the property that makes
git fetchcheap. - A
Proposal: a cross-store change does not write a shared branch directly. It opens aProposal(a source ref, a target ref, the commit range, a status) that a reviewer, human or agent, approves or rejects before it lands. This is the "pull request for memory" step.
What this ADR does not fix, all deferred to a dedicated Era 2 ADR: the
transport (HTTP, SSH, a file path), the wire format, the ref negotiation
protocol, the Proposal object's exact fields, authentication, and the
mnem push / mnem pull / mnem review command surface. Those depend on how
the Era 3 registry wants to serve stores, and picking them now in isolation is a
guess.
The commitment this ADR does make: a cross-store landing goes through a
reviewable Proposal. Era 2 is the review model, or it is just rsync.
What stays out of mnem-core
The trait is in the core. StructuralOnly is in the core. Everything else Era 2
adds is not: a model-calling SemanticMerge impl is a separate crate (ADR-0004);
the sync transport touches the network and is a separate crate; the Proposal
review flow is CLI and orchestration. cargo deny still bans network, TLS and
async crates from the workspace core.
Consequences
- Era 2's merge work is "write a crate that implements one trait", not "reopen
mnem-core's merge flow".merge_with(&StructuralOnly, ..)is exercised by the existing merge tests, so the seam is load-bearing fromv0.0.7. Store::mergeandMergeOutcomeare untouched, so ADR-0014 stays frozen with the rest of the Era 1 surface.format_version2's contents are now named in one place (docs/format/links here): the storedContradiction, whatever sync adds to a commit or the refs, and the prolly-treeStateif ADR-0017's trigger fires.- The sync protocol is still an open design. That is deliberate: its shape depends on the registry, which is Era 3. Naming the pieces stops Era 1 from quietly closing a door, for example a non-content-addressed object id, which would break the cheap-sync property.
- A
SemanticMergeimpl that fails (a model call errors) surfaces asMnemError. Era 2 adds a variant for it;#[non_exhaustive]on the enum already allows that without a break.
Alternatives considered
A whole-State resolver (given the three maps, return merged plus conflicts
plus contradictions). Rejected: it makes every semantic resolver re-implement
the structural walk from ADR-0013. The per-Conflict shape lets the structural
merge always run and the resolver be a clean second pass. StructuralOnly is
then a one-line impl.
merge grows an Option<&dyn SemanticMerge> parameter. Rejected: it breaks
a signature that is Accepted and, at v0.0.7, frozen. merge_with is additive.
ADR-0018 is doc-only; the trait lands in Era 2. Rejected: the point of #63 is that the seam is proven before the format freezes. A trait with a real impl and test coverage now is proof; a paragraph is not.
Fix the sync transport now (say, HTTP with a GET /objects endpoint).
Rejected: the transport should follow the Era 3 registry's needs, which do not
exist yet. Naming the pieces (content-addressed exchange, a Remote, a
Proposal) protects the properties that matter without guessing the wire.
No review step: sync writes straight to a shared branch. Rejected: that is
git push to a shared main with no review, and the whole Era 2 pitch is the
review. The mechanism is deferred; the commitment is not.
Research
Outputs of wayfinder:research tickets: prior art reviews, format comparisons,
API surveys. Each file is named for its ticket, for example
issue-2-object-format-survey.md, and ends in a recommendation that feeds an
ADR.
Research: agent memory data model survey (issue #5)
Feeds ADR-0003. Question: what exactly does Mnemosyne version, and what fields does a memory node carry?
CONTEXT.md already sketches this: a memory node is content plus provenance
plus timestamps plus an optional embedding, and a claim is a node whose content
follows a schema. This survey pressure-tests that sketch against how other
systems model memory, and fills in the specifics for the grilling in #6.
What other systems store
mem0
add() extracts discrete facts from input text, checks each against
existing memories for semantic overlap, and emits a resolution event: ADD,
UPDATE, DELETE or NONE. A memory is a fact string plus a metadata dict (user_id
and arbitrary keys) plus extracted entities kept in a parallel collection.
Storage is hybrid: a vector store, a graph of entities and relations, and a
key-value store.
Takeaway: fact-oriented, with an extraction step, and update or delete happens in place.
Letta (MemGPT)
Memory is organised into blocks: a block is a labelled, persistent string
with a size limit, edited by the agent with core_memory_append and
core_memory_replace. Tiered into core (in context), recall (searchable
history) and archival (large store).
Takeaway: block-oriented, not fact-oriented. The unit is a mutable labelled string the agent rewrites. Wrong shape for version control, since the whole point of a block is that it is edited in place.
Zep and Graphiti
A temporal knowledge graph. Every fact edge carries bi-temporal metadata:
valid_from and valid_to (event time, when the fact was true in the world)
and an ingestion time (when Zep learned it). When a fact is superseded the old
edge is invalidated, not deleted.
Takeaway: Zep already does "keep history, mark superseded" at the application level, with a clean split between event time and record time. Mnemosyne should do the same thing one layer down, in the store.
LangGraph store
An item is {namespace: text[], key: text, value: JSON, created_at, updated_at}
with a primary key of (namespace, key). That is the whole schema. The value is
an opaque JSON blob.
Takeaway: the minimal viable model is a keyed opaque value. Anything Mnemosyne adds beyond this needs to earn its place.
Generative Agents
A memory object is {description, creation_time, last_access_time, importance_score, embedding}. Retrieval scores each object on recency,
importance and relevance.
Takeaway: importance and recency are retrieval-scoring signals, mutable and derived, not facts about the memory. They do not belong in a versioned model.
The 2026 versioned-memory papers
- StateFuse (arXiv 2607.05844): immutable history, explicit conflict
objects,
claim_idandclaim_refas exact and semantic correction handles, deterministic predicate contracts, and projection-time resolution that never rewrites replicated state. - MemTX (arXiv 2607.23929): each record carries evidence, permissions, provenance, and validity. Writes are staged in snapshot-isolated transactions and admitted by a validate-and-commit pipeline. Irreversible tool calls are gated on in-flight belief state.
- LatticeMind (arXiv 2608.08236): explicit status tracking on items, symbolic conflict checks, and selective LLM reconciliation in one update loop.
Takeaway: provenance, evidence and validity are consistently first-class. Contradiction is either an explicit object (StateFuse) or a status on the item (LatticeMind); these two are in tension.
CogCanvas ablation (arXiv 2601.00821)
A controlled ablation swapped only the stored representation in a fixed retrieval pipeline: LLM-extracted typed artefacts versus verbatim conversation chunks. Verbatim won by 15.9 points on LoCoMo and 22.0 on LongMemEval-S. The mechanism is lossy distillation, not structure. Recommendation: structured memory should augment verbatim text, not replace it.
Takeaway: do not force extraction or a schema. Store what the agent gives you. Structure is optional and additive.
Answers to the four questions
1. Freeform node, structured claim, or both?
Both, with freeform as the default. The unit is a memory node whose
content is freeform: text or an arbitrary JSON value, like LangGraph's opaque
value. A node may declare content_kind = claim, in which case its content
follows the claim schema. v0.1 ships only note.
This is the CogCanvas finding applied directly: verbatim by default, structure as an optional overlay. It also avoids Letta's mistake of a mutable unit.
2. What does the claim schema need for semantic merge in v2?
subject: the entity the claim is about.predicate: the attribute or relation.value: the asserted value.confidence: a number in 0 to 1, the agent's stated confidence.evidence: a list of references that support the assertion (distinct from provenance, see below). Optional.
subject and predicate together are what a v2 merge uses to detect that two
claims are about the same thing and might contradict. This mirrors StateFuse's
predicate contracts and MemTX's per-record validity and evidence.
3. How is provenance attached, and what does it point at?
Every node carries provenance. It is a structured record of how this node came to exist:
agent_step: which step of the run produced it.observation: a reference to the observation or input it was drawn from.tool_call: a reference to the tool call, if one produced it.source: an external source identifier, if any.
All fields are optional individually, since an agent loop may only know the
step. This is what blame reads.
Provenance is not evidence. Provenance answers "where did this record come
from" (a blame concern, every node). Evidence answers "what supports this
assertion" (a claim concern, optional). MemTX keeps both; so should we.
4. Do embeddings belong on the node, or in a side index?
A side index. A redb table in the store, keyed by content hash,
rebuildable, never part of the versioned object and never part of its content
hash. Reasons:
- Embeddings are a v2 concern (semantic merge, retrieval). v1 does not use them.
- A float array on every node bloats the object store and would pull the embedding model's identity into the content hash.
- The embedding model changes over time; history must not have to be rewritten when it does.
- mem0 and Generative Agents both keep vectors in a separate store.
Recommendation for ADR-0003
The memory node. Fields:
| Field | v0.1 | Notes |
|---|---|---|
id | yes | a stable logical key, so updates across commits target the same node. Caller-provided, or generated if absent. Not content-derived. |
content | yes | freeform: a string or an arbitrary JSON value |
content_kind | yes, always note | note or claim. claim follows the claim schema. |
provenance | yes | {agent_step, observation, tool_call, source}, all optional individually |
event_time | yes | when the agent formed the node. The commit carries the record time. Zep's bi-temporal split. |
The claim schema (content of a claim node): {subject, predicate, value, confidence, evidence[]}. Not built in v0.1, but defined now so ADR-0002's
self-describing objects can carry it additively at v2.
Not on the node. Embeddings (side index, v2). Importance and recency (retrieval-scoring signals, mutable, out of the versioned model). Status (asserted, superseded, contradicted): computed by the v2 merge layer, which emits Contradiction objects rather than mutating nodes. Nodes stay immutable per commit, matching StateFuse over LatticeMind on this point.
Rationale. The model is deliberately close to LangGraph's minimal item at
the core (id plus opaque content), with the two additions that version
control genuinely needs: provenance for blame, and event_time for an
honest history. The claim schema is defined but dormant until v2, so Phase 1
stays small and the format does not need a break to add it later.
Open questions for the grilling (#6)
id: caller-provided with a generated fallback, as recommended, or always generated. A content-derived id is out, since it would make "update a node" impossible.content: accept any JSON value plus text, or constrain it. This survey says accept anything; it is the agent's memory, not ours to shape.event_timein v0.1: include it now, as recommended, or rely on the commit timestamp until v2. One field, andblamereads better with it.- Contradiction as an object (StateFuse) versus a status on the node (LatticeMind). This survey picks the object, to keep nodes immutable. Confirm, since it constrains the v2 design.
- How much provenance structure to mandate. This survey makes every field optional. Confirm that a node with an empty provenance is legal.
Sources
- State of AI Agent Memory 2026 (mem0) and mem0 update operations
- Letta memory blocks
- Zep temporal knowledge graph and Graphiti
- LangGraph stores
- Generative Agents (arXiv 2304.03442) and the memory stream pattern
- StateFuse (arXiv 2607.05844)
- MemTX (arXiv 2607.23929)
- LatticeMind (arXiv 2608.08236)
- CogCanvas: verbatim beats extracted artefacts (arXiv 2601.00821)
Research: on-disk object format survey (issue #3)
Feeds ADR-0002. Question: what on-disk shape should a .mnem/ store take?
What the store has to hold
- Memory nodes. Many small objects. A run can produce thousands, each a short piece of text or JSON plus provenance. This is the dominant object by count.
- State. The set of memory nodes visible at a commit. Needs to be content-addressed so an unchanged state is free to reference, and needs a cheap structural diff between two states (Phase 2) and a three-way merge (Phase 3).
- Commits. A small object: parent hashes, a state hash, author, timestamp, message, signature. Forms the graph.
- Refs. Branch names and HEAD. Few, small, frequently updated.
Constraints from AGENTS.md: the format is frozen within a major version, the
core is pure and offline, and a store written by one v1.x release is readable by
every other v1.x release. From the plan: the moat is our format, so the core
data structures should be ours, not borrowed wholesale.
Options
1. Git's model: loose objects, packfiles, refs as files
Content-addressed objects written as individual zlib-compressed files under
objects/ab/cdef..., consolidated into packfiles over time, refs as plain files
under refs/.
- Many small objects. Poor. This is the exact failure mode Git added packfiles to escape: directory traversal, file-open overhead, metadata churn, weak locality. Thousands of loose files per run is the bad case.
- Branch cost. O(1). A branch is one small ref file.
- Merge. Nothing built in for our state shape. Git's merge is line and tree oriented; our state is a set of nodes.
- Format stability. Excellent and battle-tested.
gitoxidegives a mature Rust reader and writer. - Build effort. High. To avoid the loose-object problem we would have to
build packing, an index,
repack, and garbage collection. That is a storage engine, and a large distraction from the product.
2. Content-addressed objects in an embedded key-value store
Objects serialised and stored by hash as keys in an embedded store such as
redb. Refs in a small table.
- Many small objects. Good. The store is one file; there is no per-object
filesystem cost.
redbuses copy-on-write B+trees and has lmdb-class performance. - Branch cost. O(1). A branch is one row in a refs table.
- Merge. Not provided, but the state can be given a structure that supports it (see option 3).
- Format stability.
redb's on-disk format is explicitly stable with a documented upgrade path. It becomes our one load-bearing storage dependency. - Build effort. Low to moderate. We build the object model, the graph walk, and the operations. We do not build a storage engine.
Candidate engines: redb (pure Rust, single file, crash-safe, stable
format, B+tree, mature), fjall (LSM, write-optimised, younger), sled
(perpetually beta, effectively unmaintained), rocksdb (C++ dependency, heavy).
redb is the clear pick.
3. Prolly tree for state, key-value object store for the rest
The state at each commit is a prolly tree: a content-addressed,
history-independent probabilistic B-tree mapping a node key to a node hash.
Commits, memory nodes and refs live in a redb object store as in option 2.
This is DoltDB's storage model, and it exists as Rust crates (prollytree,
prolly-map).
- Many small objects. Good, same as option 2 for the blob store. The prolly tree adds internal nodes but chunking keeps the count reasonable.
- Branch cost. O(1), and structural sharing means a large state costs only its delta when it changes.
- Diff. Cheap and structural. An in-order walk of two content-addressed trees emits exactly the changed keys, skipping identical subtrees by hash.
- Merge. Three-way merge works at the tree-node level without enumerating every node. Non-overlapping key changes merge with no conflict.
- Format stability. Ours to fix, if we implement the tree rather than depend on a crate. Chunk parameters go in the store config.
- Build effort. Moderate to high. A minimal prolly tree is a few hundred lines, but it is real work and needs care around the chunking boundary.
4. Depend on the prollytree crate wholesale
prollytree already offers a content-addressed key-value store with branching,
three-way merge, cryptographic proofs, and Git-backed storage. It could cover
much of Phases 1 to 3.
- Upside. A large head start.
- Downside. It is young and single-maintainer. Making it the core data structure means our frozen format is really its format, and the freeze-for-years invariant then rests on someone else's release cadence. It also may not model our commit header, provenance, or signing without forking it.
Useful as a reference and for a spike, not as the core dependency.
5. Git4Data / a relational database with branching
Git4Data (arXiv 2609.02106, MatrixOrigin and Purdue) treats a table as a versioned object and exposes snapshot, branch, diff and merge through SQL, with cost proportional to the change size via immutable object storage and MVCC. It is implemented in MatrixOne.
- Wrong deployment shape. It needs a database server. Mnemosyne v1 is a local, embeddable, offline library. Rejected on that alone, though the "cost proportional to the change" principle is one we want.
6. DVC or oras style content-addressed artifact stores
DVC tracks large files with metadata in Git and data in a cache keyed by hash,
synced to an external remote. oras stores artefacts in an OCI registry.
- Wrong granularity. Both are built for a small number of large artefacts with a remote, not a fine-grained local graph of thousands of tiny objects. Rejected.
Comparison
| many small objects | branch cost | structural diff | merge | format stability | build effort | |
|---|---|---|---|---|---|---|
| 1 Git loose + pack | poor without packing | O(1) | no | no | excellent | high (build packing + GC) |
| 2 KV object store | good | O(1) | no | no | good (redb stable) | low to moderate |
| 3 prolly state + KV | good | O(1), delta-cost | yes, cheap | yes, node-level | ours to fix | moderate to high |
4 prollytree crate | good | O(1) | yes | yes | not ours | low, but risky |
| 5 Git4Data | n/a | n/a | yes | yes | n/a | n/a, wrong shape |
| 6 DVC / oras | n/a | n/a | no | no | n/a | n/a, wrong shape |
Recommendation for ADR-0002
Option 2 now, option 3 by Phase 3. Concretely:
.mnem/is oneredbfile (store.redb), plus a plain-textHEADand a plain-textconfigfor legibility and easy inspection.- An object database: a
redbtableobjects: hash -> bytesholding memory nodes, states and commits. The hash function and commit header are ADR-0005's call; assume a 32-byte content hash for now. - Refs: a
redbtablerefs: name -> hash, withHEADmirrored to the text file. - State: a plain sorted content-addressed map in v0.1 (option 2). The
prolly tree lands in Phase 2, when
difffirst needs it, and pays off again for merge in Phase 3 (option 3). We implement a minimal prolly tree ourselves so the format stays ours and frozen;prollytreeis a reference and a spike target, not a core dependency. - No packfiles, no bespoke garbage collection in v0.x.
redbhandles compaction. Amnem compactcommand can come later if a store grows unreasonably. - Legibility is recovered with
mnem cat-object,mnem verifyandmnem fsckstyle commands and a written format spec, so anyone can build a reader.
Rationale. The workload is thousands of tiny objects per run, which is
exactly where Git's loose objects fall over, and building packing and GC to fix
that is a storage engine we should not write. redb removes the small-object
problem for free, is pure Rust, and has a stable documented format. Deferring the
prolly tree to Phase 2 keeps Phase 1 small and de-risked while still reaching the
structure DoltDB has proven for this shape before diff and merge need it.
Open questions for the grilling (#4, ADR-0002)
- State in v0.1: a plain sorted map, or the prolly tree from the start. This survey recommends the plain map and a Phase 2 upgrade; the grilling should confirm the format can absorb that change without a version bump, or decide to pay the prolly-tree cost up front.
- Is
redbacceptable as the one storage dependency to freeze on for years. Its format is documented and stable, but name it explicitly as load-bearing. - One
redbfile, orredbfor refs and index with separate object files. This survey recommends one file. - The object encoding (CBOR, bincode, other) and the exact prolly tree parameters are #19 and ADR-0008's job, not this ADR. Confirm that split.
Sources
- Git4Data (arXiv 2609.02106)
- Git's database internals: the packed object store (GitHub blog)
- Jujutsu architecture and Jujutsu on LWN
- Efficient diff on prolly trees (DoltHub) and three-way merge in a SQL database (DoltHub) and fast merge on prolly trees (DoltHub)
- Dolt storage engine docs
prollytreecrate andprolly(crabbuild)redbgitoxide
Research: commit identity survey (issue #15)
Feeds ADR-0005. Three questions: the hash function for content addressing, the signing scheme, and what belongs in a commit header.
ADR-0002 already fixed that objects are content-addressed and self-describing
with a kind tag, and left "assume a 32-byte content hash" for this ticket to
settle. ADR-0003 fixed that a memory node carries event_time (when the agent
formed a belief), which is separate from a commit's record time.
Hash function
The options
| speed vs SHA-256 | security | standard | Rust | |
|---|---|---|---|---|
| SHA-1 | ~2x | broken (SHAttered) | legacy | yes |
| SHA-256 | baseline (~3 GB/s) | strong | NIST, FIPS | yes |
| SHA-3-256 | ~0.3x (slower) | strong | NIST, FIPS | yes |
| BLAKE2b | ~3x | strong | RFC 7693 | yes |
| BLAKE3 | 4 to 10x, parallel (tens of GB/s on many cores) | strong, same profile as SHA-2 and SHA-3 | not a NIST standard | reference impl in Rust |
What git did, and why it does not bind us
Git is moving from SHA-1 to SHA-256 for Git 3.0 (targeted late 2026). It chose SHA-256, not BLAKE3, and the reason was ecosystem interop: TLS and X.509 already use SHA-256, and the transition's main blocker is forge support (GitHub still does not accept SHA-256 repositories). Git needs to interoperate with a huge installed base.
Mnemosyne has no such constraint. It is a local-first tool with its own format
(ADR-0002). Nothing outside a .mnem/ store needs to recompute its hashes.
Recommendation: BLAKE3
- 4 to 10 times faster than SHA-256, and it parallelises, which matters when hashing a large state or many objects in one commit.
- Same security profile as SHA-2 and SHA-3.
- The Rust community default for a greenfield project with no external standard mandate, with a first-class reference implementation.
- 32-byte output, displayed as 64 hex characters, with
mnemaccepting an unambiguous prefix in the spirit of git's short hashes.
The one real cost is FIPS. BLAKE3 is not a NIST standard, so a regulated or
government user could not use it. Mitigation: config records hash_algo
(ADR-0002's format is self-describing), so a future SHA-256 mode is possible
without a format break. It is not built now.
Signing
The options
- GPG: git's historical default. Heavy key management, a separate keyring.
- SSH signatures (ed25519): native to git since 2.34. Reuses an existing
id_ed25519key, no separate keypair. The modern default. - Sigstore gitsign: keyless. Signs against a short-lived certificate minted from an OIDC identity, logs the signature in a public transparency log, and discards the key. Strong for CI and supply-chain, but it depends on Fulcio and Rekor, which is heavy infrastructure for a local-first tool.
What gets signed
Git signs all commit data except the signature header itself: strip the signature, and the remaining canonical buffer is what was signed. The commit hash therefore covers the signature, so an unsigned commit and its later-signed version have different hashes.
The AT Protocol repository model is similar: the issuer signs the root hash plus metadata, and stores a signed manifest alongside.
Recommendation: ed25519, optional, in a side table
- ed25519 keys, the same shape as an SSH
id_ed25519. A user can reuse an existing SSH key. - Optional. A commit with no signature is valid. A single-agent local store often does not need signatures; they matter for v2 (shared memory, agent identity) and v3 (a registry).
- In a side table, not a commit field: a
redbtablesignatures: commit hash -> signature. The commit object has no signature field, so signing does not change a commit's hash.mnem verifychecks the side table.
This is a deliberate departure from git. In a content-addressed store, keeping commit identity purely about content, and signing as a separate optional attestation, is cleaner: you can sign after the fact, sign someone else's commit, or hold several signatures for one commit, without rewriting anything.
Sigstore is not chosen for v1 because of its infrastructure weight, but the side-table design does not preclude a Sigstore-style entry later (the value can be a bare ed25519 signature or a certificate bundle).
The glossary currently calls a commit "signed"; that becomes "optionally signed", with the signature held separately.
Commit header
What git and others carry
Git: tree hash, parent hashes, author (name, email, time), committer (name, email, time), message, optional signature. The author and committer split exists because of patches and rebases.
AT Protocol: CID, root hash, signature, parameters, issuer certificates, metadata.
Recommendation for the Mnemosyne commit object
| Field | Type | Notes |
|---|---|---|
kind | string, always commit | ADR-0002's self-describing objects |
parents | list of commit hashes | 0 for the first commit, 1 normally, 2 or more for a merge |
state | state hash | the state this commit points at |
message | string | freeform |
author | string | who or what made the commit. One field, not author plus committer: an agent commit is made in one step by one identity. Structured agent identity is a v2 and v3 concern. |
time | timestamp | the record time, always set by mnem at commit. Distinct from a memory node's event_time. |
Not in the header:
- signature: side table, as above.
- committer: no rebase or patch flow in v1, so one
authoris enough. - format_version: lives in
config, not per commit (ADR-0002). - a run reference: grouping commits by agent run is plausibly useful, but it is deferred; not in v0.1.
Canonical serialisation
The commit object, and every object, must serialise deterministically: the same logical content produces the same bytes and therefore the same hash. That means sorted keys, a fixed field order, and canonical number and string encoding.
This ADR fixes the requirement (deterministic canonical serialisation) and the field set. The encoding (canonical CBOR, or a hand-rolled canonical form) is ADR-0008's job (#20).
Recommendation for ADR-0005, in one place
- Hash: BLAKE3, 32 bytes, hex display, prefix matching.
hash_algorecorded inconfigfor a future switch, not built. - Signing: ed25519, optional, held in a side table keyed by commit hash. The
commit object has no signature field.
mnem verifychecks signatures where present. Sigstore deferred. - Commit header:
{kind, parents, state, message, author, time}. Oneauthor, nocommitter. Record time only; per-belief time is on the node. - Serialisation: deterministic and canonical is a requirement; the encoding is ADR-0008.
Open questions for the grilling (#16)
- BLAKE3 only, or a configurable
hash_algobuilt from day one. This survey says BLAKE3 only, with the config field reserved. - Signature in a side table, as recommended, or as a commit field like git. The side table changes the "signed" wording in the glossary.
- Does a minimal
mnem signandmnem verifyship in v1, or is signing purely reserved (design only, nothing built)? author: a bare string in v1, as recommended, or a small structured identity now.- Should a commit reference its agent run? Deferred here; confirm.
Sources
- BLAKE3 vs SHA-256 (2026) and BLAKE2 and BLAKE3 alternatives to SHA-256 and Rust hashing FAQ
- Git hash function transition and Git 3.0 and SHA-256 as default
- Signing git commits with SSH keys and ditching GnuPG for SSH signing
- Keyless commit signing with Sigstore gitsign
- Signing commits in Git, explained (GitButler)
- AT Protocol repository spec
- IPFS Merkle-DAG spec and Merkle-CRDTs (arXiv 2004.00107)
Research: object encoding and the store engine (issue #19)
Feeds ADR-0008. Two questions: which storage engine, and which byte encoding for an object.
ADR-0002 already settled most of the engine question: one redb file, objects
and refs as tables, self-describing objects with a kind tag, state flat in
v0.1. ADR-0005 requires a deterministic canonical serialisation, and left the
encoding to this ticket. So the engine part here is a confirmation and a layer
sketch; the encoding part is the real work.
Storage engine
redb, confirmed
redb is a pure-Rust ACID key-value store: copy-on-write B+trees, a stable and
documented file format, and per-transaction durability. Nothing found in a 2026
scan changes the ADR-0002 conclusion. fjall (LSM) and sled (perpetually
beta) remain the also-rans; rocksdb is a C++ dependency.
One property worth stating that ADR-0002 did not: a mnem commit is a single
redb write transaction. Writing the memory-node objects, the state object,
the commit object, and the ref update all happen in one atomic transaction. A
store is therefore never half-committed, and a crash mid-commit rolls back to the
previous commit. Crash-atomicity is free.
The object-database layer
Two tables in the one redb file:
objects : [u8; 32] -> Vec<u8> key is the BLAKE3 hash, value is the encoded object
refs : &str -> [u8; 32] branch name to commit hash; HEAD lives in the text file
redb supports &[u8] and &str keys and values with zero-copy reads, so no
richer typing is needed. Object lookup is a point query on objects. Writing an
object is idempotent: compute the hash, insert if absent.
Object encoding
The requirements, from earlier ADRs
- Deterministic and canonical (ADR-0005): the same logical object must produce the same bytes, so the same hash.
- Self-describing and evolvable (ADR-0002, ADR-0007): objects carry a
kindtag, and additive changes (a new field, a new kind) must not break an older reader of the objects it does understand. - Legible (ADR-0002):
mnem cat-objectmust produce something a person can read. - Compact and quick (ADR-0002): many small objects per commit.
The candidates
| Format | Deterministic mode | Self-describing | Schema evolution | Legibility | Size and speed |
|---|---|---|---|---|---|
| bincode | none | no | no (field order is declaration order) | none | fastest, small |
| postcard | none | no | no | none | ~1.5x bincode, ~70% its size |
MessagePack (rmp-serde) | weak, no standard | partly (type tags) | tolerable | poor | smallest on the wire, slower to decode |
CBOR (ciborium, cbor2) | RFC 8949 §4.2, plus the CDE and dCBOR profiles | yes | yes (unknown fields skippable or capturable) | good (CBOR diagnostic notation) | mid size, fast encode, slower decode in ciborium |
| JSON / JCS (RFC 8785) | JCS is a full standard | yes | yes | best | largest, slowest, no native bytes |
Why the binary schema-coupled formats are out
bincode and postcard are the fastest and smallest, but they fail requirements 1, 2 and 3 outright. They have no canonical mode, they are not self-describing, and adding a field is a silent break unless every object is manually versioned. For a project whose whole thesis is a stable, inspectable, frozen format, they are the wrong tool.
Why CBOR wins
CBOR is the only candidate that meets all four requirements:
- Determinism is standardised. RFC 8949 §4.2 defines deterministic encoding: preferred (shortest-form) integers and floats, no indefinite-length items, and map keys in bytewise sorted order. The stricter CDE and dCBOR profiles go further. This is exactly ADR-0005's requirement, already specified and tested by others, rather than rules we invent.
- It is self-describing. The
kindtag is just the first entry of the CBOR map. An older reader meeting an unknown field can skip it; a newer object kind is refused cleanly by itskind. This matches ADR-0002 and ADR-0007 with no extra machinery. - It is legible. CBOR diagnostic notation renders an object as readable text,
and
mnem cat-objectcan emit that or JSON. - It carries bytes natively, which JSON cannot without base64, and it is far more compact.
The cost is decode speed: ciborium decodes CBOR more slowly than it encodes,
and slower than bincode. For a store of small objects read a few at a time this
is not a real constraint, and it is the correct trade for the three requirements
bincode fails.
The memory-node content field
ADR-0003 says content is a string or any JSON value. CBOR's data model is a
superset of JSON's, so content maps directly: a CBOR string, or a CBOR
map/array/number/bool/null. The one care point is floats: deterministic CBOR has
specific float rules (shortest form that round-trips), and an agent's JSON
content can contain floats. The encoder must apply those rules to content too,
not just to our own fields.
Recommendation for ADR-0008
- Engine:
redb, one file, tablesobjectsandrefsas above. A commit is one write transaction. - Encoding: CBOR, restricted to a deterministic profile. Start from RFC 8949 §4.2; the grilling decides whether to adopt the stricter CDE or dCBOR profile.
- Crate:
ciboriumfor the CBOR data model (mature, ubiquitous), with a thin deterministic-ordering and preferred-form pass on top, since the §4.2 rule set is small and well specified.cbor2is the alternative, with §4.2 canonical encoding built in but a shorter track record. The grilling picks. - Framing: none beyond CBOR itself.
kindis the first map entry.format_versionstays inconfig(ADR-0007), not per object.redbframes the key and value. mnem cat-object: renders CBOR diagnostic notation by default, JSON with a flag.- The exact profile, the struct-to-CBOR mapping, and the float rules go in
docs/format/.
Open questions for the grilling (#20)
ciboriumplus a hand-rolled §4.2 pass, versuscbor2's built-in canonical encoding, versus a fully hand-rolled canonical binary form. This survey leansciboriumplus a thin pass.- Which determinism profile exactly: RFC 8949 §4.2 core, or CDE, or dCBOR.
- Confirm
contentmaps straight to the CBOR data model, and how floats insidecontentare canonicalised. - Confirm no per-object framing header:
kindas the first CBOR map entry is enough.
Sources
- redb design doc and redb 1.0 release
- RFC 8949 (CBOR), Section 4.2 deterministic encoding and CBOR: On Deterministic Encoding (draft-bormann-cbor-det)
- dCBOR deterministic profile and the CBOR determinism chapter
cbor2crate (RFC 8949, canonical encoding)- Rust serialization benchmark and json vs bin sizes
- RFC 8785 JSON Canonicalization Scheme
Research: cheap branching and time travel (issue #34)
Feeds ADR-0012 (the branch and checkout model). Phase 2 delivers branch,
checkout, reading working memory at any past commit, and a structural diff,
all still Era 1: single-agent, local, deterministic.
Three questions:
- How do Git and Jujutsu keep branch creation constant time, and does Mnemosyne already have that?
- What does
checkouthave to touch, and what is "working memory" here? - Is the
flatstate form (ADR-0002) enough for time travel and diff, or is the prolly-tree form needed now?
1. Branching is already cheap
How Git and Jujutsu do it
A Git branch is a 40-byte file under .git/refs/heads/ (or one line in
packed-refs) holding a single commit id. Creating a branch writes those bytes
and touches nothing else, so it is O(1) regardless of repository or working-tree
size. HEAD names the current branch; a commit moves whatever branch HEAD
points at.
Jujutsu calls them bookmarks and they are also just named pointers to revisions. Two differences from Git: there is no "current" bookmark, so a new commit does not move any bookmark on its own; and a bookmark automatically follows its target if that commit is later rewritten. Branch creation is the same constant-time pointer write.
Mnemosyne already has the pointer model
ADR-0009 built the refs table: name -> [u8; 32], one row per branch, with
list / get / set / compare_and_set / delete and name validation.
Store::commit already creates the branch row on the first commit and advances
it with a compare-and-swap after. HEAD is the one-line text file, attached
(ref: <branch>) or detached (a bare commit id), and a commit from a detached
HEAD is already refused.
So "make branching cheap" is not open work. What is missing is only the surface:
Store::branch(name)— insert arefsrow pointing at whereHEADresolves.Store::branches()—refs::list, already there.Store::delete_branch(name)—refs::delete, plus a guard so the branchHEADis on cannot be deleted.Store::checkout(target)— see below.
Recommendation: branches stay pointer-only. No copy-on-write of state at branch time, because nothing is copied: a branch is a 32-byte value in one row.
2. What checkout touches, and what "working memory" is
The gap today
CONTEXT.md defines working memory as "the current, mutable memory state
the agent reads and writes, materialised from a commit ... the equivalent of a
working tree". Right now there is no such thing. There is staging (the index
equivalent): mnem add writes a node straight into the staging table, and
commit overlays staging on the parent commit's state. There is no step that
takes a commit and presents its full node set as something to read.
Three ways to model working memory
(a) A materialised working table. checkout walks the target commit's
state and writes every (node_id -> object_id) into a working table. Reads
hit that table. This is closest to Git's working tree. Cost: O(nodes) writes on
every checkout, and a third place where the node set lives.
(b) Working memory is a view, never stored. "Working memory at HEAD" is
defined as the HEAD commit's state, with staging overlaid. checkout only
moves HEAD. Reading working memory resolves HEAD, loads its state, applies
staging on top. Cost: O(nodes) to read, which is inherent, and zero to check
out. No new table.
(c) staging holds the full state after checkout. checkout clears
staging and refills it with the target state, so staging stops being a diff
and becomes the whole working set. This collapses two concepts into one but
breaks the "staging is what changed" meaning that commit and a future mnem status rely on.
Recommendation: (b), a view
checkout <branch-or-commit> is one atomic HEAD write and nothing else. It is
refused if staging is non-empty, the same way Git refuses a checkout that
would discard uncommitted changes; a --force / discard=True escape hatch can
come later. Checking out a commit id rather than a branch gives a detached
HEAD, which already works.
Reading working memory is then a pure function: resolve(HEAD).state
overlaid with staging::list. Add Store::working_memory() -> Vec<(String, MemoryNode)> and Store::working_node(id) for the single-node case.
Time travel is the same walk without touching HEAD: Store::state_at(commit) -> Vec<(String, MemoryNode)> loads any commit's state and its nodes. This is
what issue #38 asks for, and it is read-only, so it needs no locking and no
branch. mnem show <commit> and the SDK expose it.
What checkout must guard
- Empty
staging, or refuse (with a clear message naming the staged ids). - A branch name must exist in
refs; a commit id must exist inobjectsand be aCommit. - The
HEADwrite is atomic (temp file plus rename), already the case. - A reflog entry would belong here. ADR-0009 reserved the reflog and does not write it; Phase 2 keeps it reserved.
3. Flat state is enough for Phase 2
The flat form and its ceiling
State today is { nodes: BTreeMap<String, ObjectId> } and every commit stores
a full State object listing a pointer for every visible node. A run that
makes 500 commits over 500 nodes stores 500 State objects of ~500 entries
each. At ~40 bytes per entry that is ~10 MB of state objects for a store whose
actual memory content might be a few hundred KB. Storage grows as commits ×
nodes, not as changes.
For Phase 2's deliverables this does not matter:
- Time travel is a point read of one
Stateplus its nodes: O(nodes), which is the true cost of "give me the whole memory at commit C" under any design. - Structural diff between commit A and B walks both sorted
Statemaps once in lockstep and classifies each id as added / removed / modified (differentObjectId) / unchanged: O(nodes).BTreeMapmakes this a merge-join. - Agent runs are hundreds, not millions, of nodes and commits. 10 MB is fine.
Prolly trees, and when they earn their place
A prolly tree (probabilistic B-tree; the storage engine behind Noms and Dolt) is a content-addressed ordered map: each node is referenced by the hash of its contents, not a file offset, and node boundaries are set by content-defined chunking so the same logical data always chunks the same way. Two consequences:
- Structural sharing. Any subtree whose hash is unchanged between two versions is stored once. State storage becomes O(total distinct subtrees), not O(commits × nodes).
- Diff and merge proportional to the change. Comparing two trees skips every subtree pair with equal hashes, so a diff costs O(differences), not O(size). This is how Dolt diffs a table in time proportional to the diff.
The cost is real: a chunker, a tree node format, a rebalancing story, and a
second State kind in the on-disk format (a format_version bump under
ADR-0007). None of it is needed for Phase 2 to be correct.
Recommendation: keep flat, add prolly when merge or scale forces it
Phase 2 ships flat State. docs/format/ already says a prolly form arrives as
a second State shape in a later format_version; this survey narrows when:
- Phase 3 (merge) needs an efficient three-way diff against a common base. Flat diff is O(nodes) per pair, which is acceptable for the merge algorithm itself, so this is a "reassess", not an automatic trigger.
- A real store hitting the storage ceiling (a long-lived agent, or many branches) is the hard trigger.
Whichever comes first opens the prolly research ticket. Until then, flat is the honest choice for the scale the substrate runs at.
Sketch of the Phase 2 core surface
impl Store {
fn branch(&self, name: &str) -> Result<()>; // ref at HEAD
fn branches(&self) -> Result<Vec<(String, ObjectId)>>;
fn delete_branch(&self, name: &str) -> Result<bool>; // not the current one
fn checkout(&self, target: &str) -> Result<()>; // branch name or commit id; empty staging
fn working_memory(&self) -> Result<Vec<(String, MemoryNode)>>; // HEAD state + staging
fn state_at(&self, commit: ObjectId) -> Result<Vec<(String, MemoryNode)>>; // time travel
fn diff(&self, from: ObjectId, to: ObjectId) -> Result<Vec<NodeChange>>;
}
enum NodeChange {
Added { id: String, new: ObjectId },
Removed { id: String, old: ObjectId },
Modified{ id: String, old: ObjectId, new: ObjectId },
}
CLI: mnem branch [name] [-d name], mnem checkout <target>, mnem show [<commit>], mnem diff <from> [<to>]. SDK: store.branch("h1") plus the
with store.branch("h1"): context manager (create, checkout, on exit checkout
back; no automatic merge, that is Phase 3).
Open questions for the grilling (#35)
- View versus materialised working memory: confirm (b), the view. Any real need
for a
workingtable now? checkoutwith a dirtystaging: refuse always, or allow when the staged ids do not collide with the target state (Git's "carry your changes across")?- Does the branch context manager delete its branch on exit, or leave it for the
user to
mergeor drop later? mnem showwith no argument: working memory, or theHEADcommit's state?- Diff output shape: the
NodeChangelist above, or a richer object that also carries the decoded old and new content for rendering? - Confirm flat
Statefor Phase 2 and the prolly triggers above. checkoutand the reflog: still reserved, or does Phase 2 start writing it?
Sources
- Git branches are refs (Pro Git, "Git Branches in a Nutshell") and Git References (Pro Git)
- Jujutsu bookmarks and jj/docs/bookmarks.md
- Prolly Trees: a content-addressed B-tree with structural sharing (Lobsters discussion)
- A Study in Structural Sharing in a Dolt Prolly Tree (DoltHub) and Prolly Tree (Dolt docs)
- How Dolt Scales to Millions of Versions, Branches, and Rows (DoltHub)
- How to Chunk Your Database into a Merkle Tree (DoltHub)
crabbuild/prolly: content-addressed ordered map on prolly trees
Research: merge algorithms, conflicts and invariants (issue #42)
Feeds ADR-0013 (the deterministic merge algorithm) and ADR-0014 (the conflict
object and resolution API). Phase 3 (v0.0.4) is the last purely mechanical
piece: a three-way merge over the flat State map, conflict objects, a
resolution API, and a property and chaos harness. Still Era 1: structural and
deterministic, no semantics. Semantic merge over claim contents is Era 2.
Four questions:
- What does a three-way merge of two
Statemaps do, per node id? - How is the merge base found, and what happens on a criss-cross history?
- How are conflicts represented, and what does the resolution API look like?
- What invariants must the chaos harness check, and is flat
Statestill enough?
1. The per-id merge rules
State is { nodes: BTreeMap<String, ObjectId> }. A merge takes a base B
(the merge base's state), ours O, and theirs T, and produces a
merged map plus a set of conflicts. This is a tree merge, one entry per node id,
not a line merge; there is no content-level merge of a node (that is Era 2).
For each node id present in any of B, O, T, let b, o, t be the
ObjectId in each (or absent):
b | o | t | result |
|---|---|---|---|
| x | x | x | keep x (unchanged) |
| x | y | x | keep y (ours changed, theirs did not) |
| x | x | y | keep y (theirs changed, ours did not) |
| x | y | y | keep y (both changed the same way, convergent) |
| x | y | z | conflict: edit/edit |
| x | absent | x | absent (ours deleted, theirs unchanged) |
| x | x | absent | absent (theirs deleted, ours unchanged) |
| x | absent | absent | absent (both deleted, convergent) |
| x | absent | z | conflict: delete/edit |
| x | y | absent | conflict: edit/delete |
| absent | y | absent | keep y (ours added) |
| absent | absent | z | keep z (theirs added) |
| absent | y | y | keep y (both added the same, convergent) |
| absent | y | z | conflict: add/add |
This is the classic filesystem three-way merge (Git's per-path logic, minus renames, which do not exist here: a node id is stable, ADR-0003). The table is total: every combination is either an automatic result or a named conflict.
Determinism. The result map is built by iterating the union of keys in
sorted order (a BTreeSet), so a clean merge is bit-identical regardless of
argument order, and the conflict list is in node-id order.
2. The merge base
The merge base of commits O and T is a lowest common ancestor in the
commit DAG: an ancestor of both that has no descendant which is also an ancestor
of both. Issue #45 computes it.
- The common case has one LCA. Walk both ancestor sets (BFS over
parents), intersect, then take the elements with no other intersection element among their descendants.Stateis small and histories are short, so an O(nodes in history) walk is fine; no generation numbers or bloom filters needed yet. - A criss-cross history (two branches merged into each other in the past)
can have more than one LCA and no unique base. Git's
ortstrategy merges the LCAs into a virtual base and three-way-merges against that. That is correct but is a recursive merge and its own body of work.
Recommendation for ADR-0013. Phase 3 requires a single merge base. When
there are multiple LCAs, merge refuses with a clear error naming them, rather
than silently picking one or doing a recursive merge. Recursive merge over a
virtual base is deferred; the trigger is a real criss-cross showing up, which a
single-agent substrate with a human in the loop rarely produces. If it does, the
manual path is to merge one side first.
- The first commit on each branch shares the root; two branches off the same commit have that commit as the base. A branch never merged has exactly one base with any other branch descended from a shared point.
- If
Ois an ancestor ofT(or vice versa), the merge is a fast-forward: the result isT, no merge commit.mergeshould detect this.
3. Conflicts and resolution
The conflict object
When a merge has conflicts, it does not write a merge commit. Instead it
returns the conflict set to the caller, and (option A) leaves the store
untouched, or (option B) records a pending merge state that mnem status shows
and mnem merge --continue finishes.
A conflict names one node id and the three sides:
Conflict {
id: String,
kind: EditEdit | DeleteEdit | EditDelete | AddAdd,
base: Option<ObjectId>, // None for AddAdd
ours: Option<ObjectId>, // None for DeleteEdit
theirs: Option<ObjectId>, // None for EditDelete
}
Whether the conflict is a first-class stored object (like a memory node,
content-addressed, referenced from a MergeState) or a transient value
returned by the merge call is the ADR-0014 question. A stored conflict object
supports "walk away and come back", a mnem status that survives a restart, and
an audit trail; a transient value is simpler and matches "merge is one call".
For a single agent, the transient value is likely enough for v0.0.4, with the
stored form arriving when the review model (Era 2) needs conflicts to persist
and be reviewed.
The resolution API
Per conflicting id, the caller picks one of:
- ours — take
O's object id - theirs — take
T's object id - base — take
B's object id (revert both edits) - delete — the merged state omits the id
- set(node) — stage a new node object as the resolution
Once every conflict has a resolution, merge (or merge --continue) writes the
merged State and a two-parent Commit. The resolution API is:
store.merge(theirs: CommitIsh) -> MergeOutcome // clean, ff, or conflicts
store.merge_resolve(id, Resolution) -> () // record one resolution
store.merge_continue(message, author, time) -> ObjectId // finish
store.merge_abort() -> () // drop the pending merge
CLI: mnem merge <branch>, mnem merge --continue, mnem merge --abort, and
mnem status shows the unresolved ids. SDK: store.merge("other") returns a
result object; a context-manager form is possible but not required.
Interaction with staging
merge requires a clean index (no staged adds or tombstones), the same rule as
checkout (ADR-0012). The pending-merge resolutions live in their own local
table, not staging.
4. Invariants and the chaos harness (#49)
The harness generates random histories (two branches off a base, each with a random sequence of adds, updates and deletes) and checks:
- Clean-merge symmetry. When
merge(O, T)is clean,merge(T, O)is clean and produces the sameStateobject id. Conflict sets are equal; the merge commit differs only in parent order and message. - Idempotence.
merge(O, O)is a fast-forward toO; merging an ancestor is a no-op. - Base identity.
merge(O, B)whereBis the base is a fast-forward or a no-op (Oalready contains the base). - Convergence. After resolving conflicts,
merge(O, T)thenmerge(T, O')(whereO'is the first merge result) converges: no new conflicts, same final state. - The table is total. A generated case never produces an unclassified outcome: every id is in the merged map or in the conflict set, never both, never neither.
- No lost writes. Every node id that both sides agree on (or only one side touched) appears in the result with the agreed object id.
This is the spirit of the ephor exactly-once tests: hammer the operation with
random interleavings and assert the algebra holds. Deterministic seeds, like
tests/time_travel.rs (#41).
Is flat State still enough?
ADR-0012 named Phase 3 merge as a reassess point for the prolly tree, not an
automatic trigger. The merge algorithm above is O(total distinct node ids across
B, O, T), which for an agent's memory (hundreds of ids) is trivial. The
merge base walk is O(history size). Neither needs structural sharing to be
correct or fast at this scale.
Recommendation. Keep flat State for Phase 3. The prolly tree's payoff is
diff and merge proportional to the change rather than to the size, which
matters at thousands to millions of ids, not hundreds. The trigger stays: a real
store hitting the storage or latency ceiling. Record this reassessment in
ADR-0013.
Recommendations, in one place
- ADR-0013 (algorithm): the per-id table above; union-of-keys in sorted
order for determinism; a single required merge base, refuse on multiple LCAs;
fast-forward detection; a clean index required; flat
Statekept, prolly deferred again. - ADR-0014 (conflict object + API):
Conflict { id, kind, base, ours, theirs }; transient (returned) rather than stored forv0.0.4, stored form deferred to the Era 2 review model; resolutions areours | theirs | base | delete | set(node);merge/merge_resolve/merge_continue/merge_abort; pending resolutions in a local table;mnem merge+--continue/--abort,mnem statusshows unresolved ids.
Open questions for the grillings (#43, #44)
- #43: single-base-or-refuse versus recursive virtual base. Fast-forward:
silent, or announced? Does
mergeever touchHEADother than the new merge commit? Confirm the per-id table, especially delete/edit and add-add as conflicts rather than "take the non-deleting side". - #44: transient versus stored conflict object. Where do pending resolutions
live. Is there a
mnem merge --continueflow, or must a merge be resolved in one process. Whatmnem statusshows mid-merge. Theset(node)resolution: does it go throughadd(staging) or a dedicated path.
Sources
- git-merge(1) and git merge-strategies (modify/delete, add/add, stages 1/2/3, criss-cross)
- git-merge-tree (a merge can conflict without any single entry conflicting)
- Criss-Cross Merge (revctrl.org) and the
ortvirtual-merge-base approach - Three-Way Merge in a SQL Database (DoltHub) (per-row three-way merge, prolly-tree diff proportional to the change)
- Evaluation of Version Control Merge Tools (arXiv 2410.09934) and Analyzing Git Diff and Merge (arXiv 2507.22071)
Research: the provenance index, blame and bisect (issue #135)
Feeds ADR-0015 (the provenance index) and the Phase 4 build tickets
#51–#54. Phase 4 (v0.0.5) is "it explains": given a memory node, say where it
came from (blame); given a wrong belief, find the commit it entered
(bisect). Still Era 1: deterministic, no network, no model calls. Nothing here
changes format_version — blame and bisect are read-only walks over
objects that already exist.
Four questions:
- What does
blameresolve, and how, given that provenance lives on the node? - Is a provenance index needed at Phase 4 scale, and if so what is in it, where does it live, and how is it kept current?
- What does
bisectsearch, and how is the range given? - What is the buggy-run fixture (#54), and does the reflog land here?
1. blame
What it resolves
From CONTEXT.md and ADR-0003: blame resolves a memory node to the commit
and the provenance that introduced its current value. Provenance is on the
MemoryNode (object.rs), not the Commit, and may be entirely empty — so
blame always resolves to a commit, and additionally surfaces whatever
provenance the node carries (agent_step / observation / tool_call /
source / note) and its event_time (falling back to the commit's record
time when absent, per ADR-0003).
The algorithm
State at a commit is { node_id -> ObjectId } (state_map_at). A node id's
"value" at a commit is that ObjectId (or absent). The value changed at
commit C iff state_map_at(C)[id] != state_map_at(parent)[id] — this covers
add (absent → present), edit (id → different id) and, if we want it, delete
(present → absent).
Blame walk. Start at the target commit-ish C (default HEAD). Let
target = state_map_at(C)[id]; error if the id is absent at C ("nothing to
blame: C
towards the root. For each commit Ci with first parent Cp:
- if
state_map_at(Ci)[id]differs fromstate_map_at(Cp)[id], thenCiis the introducing commit for the value as ofC. Return it. - the root commit (no parent) always counts as a change (absent → present).
Return { commit: Ci, node: <the MemoryNode at Ci>, provenance, event_time, time: Ci.time }.
Cost: O(history length) point reads, each cheap. For an agent's memory
(hundreds of ids, short histories) this is microseconds. No index required for
blame to be correct or fast — see §2.
The merge subtlety
If the current value of id arrived on the theirs side of a merge, the
first-parent walk sees the merge commit as the point of change (the first
parent, "ours", did not have that value). Git's blame follows the parent that
actually contributed the line. Options for Era 1:
- (a) Report the merge commit, with a note that the value came in via a merge and pointing at the other parent. Simple, honest, one walk.
- (b) Recurse: at a merge commit where the value differs from
parents[0]but equalsparents[k], continue the walk fromparents[k]. This gives the true origin commit but is a multi-parent walk.
Recommendation: (b), but only the minimal form — pick the first parent whose
state_map_atvalue equals the value at the merge, and continue from there. It
is a few extra lines and gives the answer a user actually wants ("who first
wrote this"), not "a merge happened". A single agent with a person in the loop
rarely has deep merge nesting, so the walk stays short.
blame on a deleted node
If id is absent at C, blame <id> errors. A separate question is "when was
id deleted" — that is really a bisect predicate (|s| !s.contains_key(id))
or a future mnem log --follow <id>. Keep blame to "explain a value that
exists". Do not overload it.
2. The provenance index
Is one needed?
At Phase 4 scale, no — for the two operations this phase ships:
| Operation | Without an index | With an index |
|---|---|---|
blame <id> | O(history) first-parent walk, ~µs | O(1) lookup of the introducing commit |
bisect | O(log n) state_at reads | unchanged — bisect is about content predicates, not provenance |
bisect gets nothing from a provenance index (ADR-0003 already says "bisect
operates on node content and presence, not provenance"). blame is already
fast. So an index bought now is speculative infrastructure — exactly the
kind of thing the project has twice deferred for the prolly tree (ADR-0012,
ADR-0013) with a named "reassess when a real store hits a ceiling" trigger.
What an index would hold, when it is built
The useful shape, when a real store makes the walk too slow, or when the Era 2 review/audit UI needs reverse lookups:
node_id -> introducing commitfor the current tip (answersblamein O(1)). Must be recomputed or patched on every commit and on every branch/checkout, because "current" is per-branch.commit -> [node_ids it introduced or changed](the reverse: "what did this commit teach the agent"). Append-only, never rewritten, since a commit is immutable. This one is cheap and monotonic.
The reverse map is the safer first index: it is append-only, derived purely from
diff(parent, commit) at commit time, and never invalidated. The forward map is
the one that needs care (per-branch, mutated on checkout).
Where it would live
A new redb table in the same database (objects / refs / staging live
there already). Not a separate file — one database keeps the write-transaction
story simple (a commit already opens one write txn; the index write joins it).
Key/value: commit_id -> CBOR([node_id]) for the reverse map.
Incremental vs rebuild
- Reverse map: built incrementally at commit time inside the existing write
transaction (
diffthe new commit againstparents[0], write the id list). A full rebuild is a one-pass walk of all commits, used once on upgrade or if the table is missing. - Forward map: not proposed for Phase 4.
Recommendation
Defer the index. Phase 4 ships blame and bisect as plain graph walks.
Add ADR-0015 as a short ADR that:
- records that
blame/bisectwalk the graph directly and why that is fine at this scale (the numbers above); - specifies the reverse map (
commit -> introduced node ids) as the index that will be built, its on-disk home, and its trigger — the same pattern as the prolly-tree deferral: a real store whereblamelatency or an Era 2 UI needs it; - keeps #51 in the phase, but reframed: #51 builds the reverse map (cheap,
append-only, immediately useful for
mnem show --stat-style output and a foundation for Era 2), andblamereads it opportunistically with a walk fallback. Or #51 becomes "defer the index, expand the blame/bisect tests".
The grilling (#50) picks between "#51 builds the reverse map now" and "#51 is a deferral ADR + more tests". Both are defensible; the reverse map is the more productive use of the ticket and carries no invalidation risk.
3. bisect
What it searches
Binary search a commit range for the first commit where a supplied predicate
holds (CONTEXT.md). The predicate is a function of the reconstructed memory
at that commit:
#![allow(unused)] fn main() { fn bisect( &self, good: &str, // a commit-ish where the predicate is false bad: &str, // a commit-ish where the predicate is true (default HEAD) predicate: impl Fn(&BTreeMap<String, MemoryNode>) -> bool, ) -> Result<ObjectId> }
The predicate takes the full state_at map so it can express "node plan says
enterprise" or "node x is absent" or "any node mentions 'refund'". The
common case — "this one node has this wrong value" — is a helper that builds the
closure.
The range and the monotonicity assumption
Like git bisect, bisect assumes the predicate is monotonic over the
range: false up to some commit, true from there on. good must be an ancestor
of bad, and the predicate must be false at good and true at bad — all
three checked up front, with a clear error otherwise ("the predicate is already
true at
Linear history only, for now
Walk the first-parent chain from bad back to good, giving an ordered
list of N commits, and binary-search that list: O(log N) state_at
evaluations. Merge commits on the chain are evaluated like any other (their
state is fully materialised). Second parents are not descended into — a
single-agent history is essentially linear, and git bisect's merge handling
(skip, multiple bad regions) is complexity Era 1 does not need. Document the
limitation.
Result
Return the first bad commit (the boundary), plus optionally its blame for
whichever node the predicate helper targeted, so mnem bisect can print "the
belief entered at
4. The buggy-run fixture (#54) and the reflog
The fixture
A synthetic agent run, built in a test (seeded, deterministic), where:
- commits 1..k build up plausible memory (a support agent learning about an account: plan tier, contacts, open tickets);
- at a known commit
k, a wrong belief enters — e.g.planflips fromenterprisetoprooff a misread observation, with provenance pointing at that observation; - commits k+1..n continue, some of them touching other nodes, one or two even
reading the wrong
plan(so the error "spreads"); - the test then: (a)
bisectwith the predicateplan == "pro"and asserts the boundary is exactly commitk; (b)blame planatHEADand asserts it resolves to commitkand the planted provenance.
This is the Phase 4 definition of done and the demo GIF material.
The reflog
ADR-0009 explicitly parked the reflog: "It lands as its own ticket in Phase 2,
or alongside blame and bisect in Phase 4." It is a small append-only
(ref, old, new, time, op) table written on every ref move. It is not needed
for blame or bisect (those walk the commit graph, not ref history), and
Phase 4 has no child ticket for it.
Recommendation: defer again, deliberately. Note in ADR-0015 (or a one-line ADR-0009 amendment) that the reflog moves to Phase 5/6 or its own ticket, so the deferral is on the record rather than forgotten. Folding it in now widens Phase 4 for no gain to the "it explains" story.
Recommendations, in one place
blame: first-parent walk from a commit-ish (defaultHEAD) for the commit that changed the target node id's value; follow the contributing parent through merges; return commit + node + provenance + effective time. Errors if the id is absent at the target commit.- The index: defer the forward (
node -> introducing commit) map. ADR-0015 is short: it records the direct-walk approach and its scale justification, and specifies the reverse map (commit -> introduced node ids, append-only, in a newredbtable, built in the commit write txn) as either what #51 builds now or what is deferred with a trigger. bisect: binary search the first-parent chain betweengood(predicate false, ancestor) andbad(predicate true, defaultHEAD); predicate isFn(&state_at map) -> bool; assumes monotonic; linear history only; O(log N)state_atreads. Helper for the "one node, one value" case.- Fixture (#54): seeded synthetic run, wrong belief at a known commit,
tests that
bisectfinds it andblameexplains it. - Reflog: deferred again, on the record.
Open questions for the grilling (#50)
- The index. Build the reverse map in #51 now, or make ADR-0015 a deferral + test-expansion ADR? If built: reverse map only, or also the forward map?
blamethrough merges. Report the merge commit (simple) or recurse to the true origin (a few more lines, better answer)?blameoutput. Just the introducing commit, or also the chain of every commit that touched the node (amnem log --follow <id>in disguise)?bisectpredicate. A Rust closure only (SDK/tests), or also a CLI form — and if CLI, what language (node == value, a jq-ish path, an external command likegit bisect run)?bisectrange. Requiregoodexplicitly, or default it to the root commit? Error messages when the predicate is non-monotonic (git just gives a possibly-wrong answer; we could detect some cases).- Deleted / re-added nodes. Does
blamecare about history before the most recent add, or only since the current value's introduction? - Reflog. Confirm deferral, or fold the small table in now while ref-move code is being touched anyway.
Sources
- git-blame(1) — per-line last-change,
-C/-M, following through merges - git-bisect(1) and
git bisect run— good/bad boundary, monotonicity,runwith a predicate command - How
git bisectworks (Julia Evans) — the binary-search framing - ADR-0003 (the memory node model) — provenance fields,
event_time, "bisectoperates on content and presence" - ADR-0009 (the ref model) — the reflog deferral
- DoltHub
dolt blame— row-level blame over a table, the closest prior art to node-level blame
Research: the MCP tool surface for memory (issue #55)
Feeds ADR-0016 (the MCP tools and the adapter contract) and the Phase 5
build tickets #57 to #60. Phase 5 (v0.0.6) is "it plugs in": an MCP server and
a LangGraph adapter, so a real agent uses Mnemosyne as its memory with branch
and blame working, plus SDK hardening and three worked examples.
The frame is already set by ADR-0004. Adapters, meaning the MCP server
(mnem-mcp) and the framework adapters (mnem-langgraph), are Python packages
that depend on the SDK, not the core, and live outside the Cargo
workspace. So mnem-core stays offline and deterministic; cargo deny is not
in play here; the network, the async runtime and the protocol libraries all sit
in separate Python packages.
Six questions:
- Which SDK operations become MCP tools, and which become MCP resources?
- What is the write contract: one commit per call, or a stage/commit split?
- How is a store bound to a server, given MCP 2026-07-28 is stateless?
- What does the LangGraph adapter implement (
BaseStore,BaseCheckpointSaver, or both), and how does its namespace/key model map onto Mnemosyne? - What is the shared adapter contract, and does the SDK need anything new?
- Where do the adapter packages live, and how are they named and versioned?
1. Tools vs resources
MCP (spec revision 2026-07-28) gives a server three primitives:
- Tools: model-invoked functions with a name, description and JSON Schema. Side effects allowed. This is where writes and searches go.
- Resources: readable context addressed by URI, pulled in by the client
(app-controlled, not model-invoked). List and read results now carry
ttlMsandcacheScope. This is where "the current memory" goes. - Prompts: user-invoked templates. Not needed for
v0.0.6.
The tools
The agent-facing verbs, mapped from the SDK. Names are chosen for how an agent would reach for them, not to mirror Git:
| Tool | SDK call(s) | Notes |
|---|---|---|
remember | add + commit | the common case: record one fact with provenance, in one commit. { id, content, source?, step?, observation? } |
revise | add + commit | same shape; a separate name so the model signals "this changes an existing belief" |
forget | rm + commit | tombstone one node in a commit |
recall | working_node / working_memory | read one node or the whole current memory. Also a resource (below); the tool form is for when the model decides it needs it |
recall_at | state_at | the memory as of a past commit (time travel) |
history | log | recent commits, newest first, { limit? } |
why | blame | resolve a node to the commit and provenance that set it |
when_did | bisect | first commit where a node reaches a value, is absent, or is present |
whats_new | changed_by | what a commit changed |
Branch, checkout and merge are orchestration, not in-loop reasoning. They
are exposed as tools too (branch, switch, merge, with the same resolution
surface as the CLI), but the ADR should mark them "for the harness, not the
model": an agent framework drives a hypothesis branch around a sub-task, and the
model rarely asks for one itself. A server flag (--tools=core|all) or MCP tool
annotations can hide them from a model that does not need them.
bisect on MCP is the --node/--equals/--absent/--present helper form only
(the predicate-closure form has no JSON-Schema representation), matching the CLI
(ADR-0015).
The resources
| URI | Backed by | cacheScope |
|---|---|---|
mnem://memory | working_memory | per-store; short ttlMs, invalidated on any write |
mnem://memory/{node_id} | working_node | per-store |
mnem://log | log(limit=N) | per-store |
mnem://commit/{id} | state_at + changed_by | immutable, long ttlMs, since a commit never changes |
Putting the current memory behind a resource means a client can keep it in
context for free, without spending a tool call every turn. The recall tool
stays for models that pull rather than get pushed.
Not exposed
init (a deployment step, not an agent action), format_version / root /
head (introspection), staged / unstage / staged_deletions (there is no
staging on MCP, see §2), resolve (internal), add_node (the dataclass form).
2. The write contract: no staging over MCP
The SDK has a stage-then-commit split (add writes to staging, commit seals
it). Over MCP that split is a liability: a tool call is a discrete, retryable
unit, and a server that carries half-staged state between calls breaks the
stateless model (§3) and leaves an agent's memory in a limbo the model cannot
see.
Every write tool is one commit. remember / revise / forget each do
add or rm then commit in a single SDK call sequence, atomically (the
core's commit is one write transaction, ADR-0008). The commit message is the
tool's summary argument, or a generated one ("remember {id}"); the author is
the server's configured agent name.
Batch writes, meaning "record these five facts as one commit", are a real case
(an agent finishing a step). One option is a remember_many tool taking a list,
one commit. The ADR should decide whether that lands in v0.0.6 or waits.
3. Binding a store to a server
MCP 2026-07-28 is stateless: no protocol sessions, no Mcp-Session-Id, any
request answerable by any server instance. So the server cannot "hold" a store
handle across a session the way a CLI process does.
Options, cheapest first:
- (a) One store per server process. The store path is a launch argument
(
mnem-mcp --store ./agent-memory) or env var. Every tool call opens the store fresh (mnem.openis cheap, a redb handle), acts, closes. Stateless, trivial, correct. This is thev0.0.6recommendation: an agent runtime launches onemnem-mcpper agent, pointed at that agent's memory. - (b) Store selected per call. A
storeargument on every tool, or theMcp-Name-style header carrying it. Needed only for a multi-tenant server hosting many agents' memories behind one process. Defer. - (c) Store per MCP client identity (post-auth). Era 2, the collaboration layer, where a store is shared and access-controlled.
Concurrency. redb gives one writer, many readers (ADR-0008). Two concurrent
remember calls to the same store serialise on the write transaction; the loser
retries. The server should surface a clean "busy, retry" rather than block
indefinitely, which is a small SDK hardening item (#59).
Branch state. mnem.open resolves HEAD from the store's HEAD file, so
"which branch am I on" is store state, not server state, which is consistent
with statelessness. A switch tool writes HEAD; the next call sees it.
4. The LangGraph adapter
LangGraph persistence has two interfaces:
BaseCheckpointSaver: saves the full graph state after every node, keyed by thread.put/get_tuple/list. This is execution state (the channels, the pending writes), not beliefs.BaseStore: cross-thread, long-lived memory. Namespaced by a tuple of strings,keya string,valuean arbitrary dict.get/put/search/delete/list_namespaces, plus async forms.
The Phase 5 DoD, "a LangGraph agent uses Mnemosyne as its memory with branch
and blame working", is BaseStore. Recommendation: implement BaseStore
first.
The mapping is close to 1:1:
BaseStore | Mnemosyne |
|---|---|
namespace: tuple[str, ...] | a prefix on the node id (":".join(namespace)), or a branch, per the ADR |
key: str | the node id (within the namespace) |
value: dict | MemoryNode.content (JSON) |
put(ns, key, value) | remember: add + commit, provenance from value["_meta"] if present |
get(ns, key) | working_node |
search(ns, query) | working_memory filtered. Mnemosyne has no vector search; the ADR decides whether search is prefix/substring only or out of scope for v0.0.6 |
delete(ns, key) | forget |
list_namespaces() | the set of id prefixes seen |
branch and blame are not on the BaseStore interface. They are exposed as
extra methods on the adapter class (store.branch("hypothesis"),
store.why(key)), which a graph node calls directly. That satisfies "branch and
blame working" without fighting the interface.
BaseCheckpointSaver (every superstep is a commit) is a compelling second
adapter, since it makes a whole agent run rewindable, but it is a different
shape and a bigger surface. Name it in the ADR as deferred, with a trigger.
5. The adapter contract, and SDK gaps
The MCP server and the LangGraph adapter should share one thin contract module so #57 and #58 do not diverge:
remember(id, content, *, source=None, step=None, observation=None, summary=None) -> commit_id: the atomic add+commit. Both adapters call this.forget(id, *, summary=None) -> commit_id.- A stable mapping from
Blame/NodeChange/Committo plain JSON dicts (the MCP tool results and the adapter return values). - One error taxonomy surfaced as JSON (
not_found,invalid_ref,conflict,busy).
Where this lives: a small mnem SDK addition (mnem.agents or top-level
helpers), not in each adapter. That keeps "the SDK holds the ergonomics"
(ADR-0004) true, and means the CLI could use the same remember helper later.
SDK hardening (#59) that this surfaces:
remember/forgetatomic helpers (above).- A retry-on-write-conflict wrapper, or a clear
ConflictErrorthe caller can catch (compare_and_setalready raises one in the core). to_dict()onBlame,NodeChange,Commit,MemoryNodefor the JSON boundary (the binding already emits dicts; the SDK dataclasses should round-trip).- Confirm thread-safety of a
Storehandle, or document "one handle per thread" and have the adapters open per-call.
6. Packaging
ADR-0004 says adapters live outside the workspace. Two readings:
- A
packages/(oradapters/) directory in this repo, each its ownpyproject.toml, not a Cargo member, published to PyPI separately. One repo, one issue tracker, and CI can test them against the built wheel. Recommended forv0.0.6: a separate repo per adapter is overhead the project does not need yet. - Separate repos (
Nabzx/mnem-mcp,Nabzx/mnem-langgraph). Cleaner dependency story, but three repos to keep in sync for a solo maintainer. Revisit if an adapter grows its own contributors.
Names: PyPI mnemosyne-mcp and mnemosyne-langgraph (the SDK is
mnemosyne-agents); import names mnem_mcp / mnem_langgraph. The mnem-mcp
console script is the server entry point.
Versioning: the adapters pin mnemosyne-agents ~= 0.0 and version
independently. They are the SDK's customers, not part of its release train.
Transport and auth
- stdio only for
v0.0.6. It is the local, single-agent case the DoD describes, needs no auth, and is what an agent runtime spawns. Streamable HTTP (remote, multi-client) brings the 2026-07-28 authorization story and belongs with the collaboration layer. - The
mcpPython SDK's FastMCP API covers both; the server is written once and gains HTTP later by configuration.
Recommendations, in one place
- Tools:
remember/revise/forget/recall/recall_at/history/why/when_did/whats_new;branch/switch/mergebehind a "harness, not model" annotation. - Resources:
mnem://memory,mnem://memory/{id},mnem://log,mnem://commit/{id}, so the current memory is pull-free context. - Writes: no staging over MCP; every write tool is one atomic commit.
- Store binding: one store per server process, path from a launch argument; stateless, open-per-call. Multi-tenant selection deferred.
- LangGraph: implement
BaseStorefirst;branch/whyas extra adapter methods;BaseCheckpointSaverdeferred with a trigger. - Contract: a shared
remember/forget/to_dicthelper set in the SDK, consumed by both adapters. - Packaging: a
packages/directory in this repo, PyPImnemosyne-mcp/mnemosyne-langgraph, versioned independently. - Transport: stdio only for
v0.0.6.
Open questions for the grilling (#56)
- Write granularity. Is
remember_many(batch, one commit) inv0.0.6, or is one-fact-one-commit enough to ship? - The
_metachannel. How does provenance travel throughBaseStore.put's opaquevaluedict: a reserved_metakey, a separateputargument the adapter adds, or dropped (provenance only via the MCPremembertool)? search. Prefix/substring filter overworking_memory, or "not inv0.0.6, raiseNotImplementedError"? Mnemosyne is explicitly not a retrieval layer (README, prior work).- Namespace. Does a
BaseStorenamespace map to an id prefix, or to a Mnemosyne branch? (Prefix is simpler; branch is more powerful and matches "branch working".) - Harness tools. Hide
branch/mergefrom the model by default, or expose everything and trust the framework? - One repo or many.
packages/here, orNabzx/mnem-mcp+mnem-langgraphfrom the start? - Checkpointer. Confirm
BaseCheckpointSaveris deferred, and name its trigger (someone wants a rewindable run).
Sources
- MCP 2026-07-28 specification: stateless transport, tools/resources/prompts,
ttlMs/cacheScope, header routing - modelcontextprotocol.io: the primitives, stdio vs Streamable HTTP
- modelcontextprotocol/python-sdk and
mcpon PyPI: FastMCP,mcp run/mcp dev - LangGraph persistence: checkpointers and BaseStore and BaseStore for long-term memory: the two interfaces, namespace/key/value model
- langgraph-checkpoint-aws: a worked custom-backend example (
BaseCheckpointSaversubclass) - ADR-0004 (the core / SDK boundary): adapters depend on the SDK, live outside the workspace
- ADR-0015 (blame and bisect): the
--node/--equals/--absent/--presentpredicate helpers the MCPwhen_didtool reuses
Research: the v1 benchmark design (issue #61)
Feeds ADR-0017 (the benchmark and its metrics) and the Phase 6 build
tickets #64 (harness and baseline) and #65 (run it, publish docs/benchmark.md).
Phase 6 (v0.0.7) ships the substrate: something you would hand a stranger,
with numbers.
The honest frame. Mnemosyne does not claim an agent answers better because its memory is versioned. The 2026 research says the same: GitOfThoughts tested five memory backends (none, markdown, vector, graph, git) and found none reliably moves accuracy. Its conclusion, which is also ours: "git's value is the engineering trade-off at accuracy parity. It gives auditability, history, and the ability to merge two agents' memories, at no cost to accuracy."
So the benchmark measures the operational properties and the overhead, not answer quality. Accuracy parity is assumed and cited, not re-tested (that needs models, an API budget, and it has been done).
Six questions:
- What does the benchmark measure?
- What synthetic workload produces the runs?
- What are the baselines to compare against?
- What is the metric for each property, and its expected value?
- Where does the harness live, and does CI run it?
- What is out of scope, and how is the v2 seam (#63) kept separate?
1. What the benchmark measures
Four operational properties, each turned from "it works" into a number, plus overhead:
| Property | The claim | Measured as |
|---|---|---|
| Reconstruction | state_at(commit) is the exact memory of that moment | over N seeded runs, the fraction of commits where state_at equals the working memory recorded at commit time; also the golden-vector bytes are still bit-identical |
| Bisect precision | bisect finds the exact commit a belief went wrong | plant a fault at a known commit k in a run of length L; report the exact-hit rate and the distribution of found - k |
| Blame accuracy | blame resolves a belief to the commit and observation that set it | plant provenance on every write; blame every node at HEAD; report the fraction resolving to the correct introducing commit and the correct source |
| Merge correctness | a structural merge never loses a write or leaves a node in limbo | re-run the chaos harness (#49) at a large trial count and report totality, no-lost-writes, symmetry and convergence |
| Overhead | version control has a bounded, small cost | write latency per commit, read latency for the working memory, and on-disk bytes after L steps, each as a multiple of the baseline |
The first three are deterministic (the operations are), so the expected number
is 100.00%. The benchmark's job is to demonstrate that at scale and publish
it, the way docs/chaos-report.md does for merge. A single failing seed would
be a real bug and would block the release.
2. The synthetic workload
A seeded generator, in the style of tests/time_travel.rs and
tests/merge_chaos.rs (a hand-rolled LCG, no property-test dependency):
- A run is
Lcommits. Each commit stages 1 to 4 operations over a small key pool: set a key to a new value, or delete one. Every set carries provenance (source = "obs-<n>",agent_step = "step-<c>"). - A planted fault: at a chosen commit
k, one key is set to a distinguished "wrong" value and left wrong for the rest of the run. This is thebuggy_run.rsfixture, parameterised. - A branched run: for the merge and blame-through-merge numbers, two branches off a base, each with a random op sequence, merged with a strategy.
Parameters swept: L in {16, 64, 256, 1024}; k at 10%, 50%, 90% of the run;
seeds 0..S. CI uses small L and S; the published sweep uses large ones.
3. The baselines
"No version control" is not one thing. Two baselines, both what an agent author would otherwise write:
- Baseline A: a dict. The agent keeps
dict[str, Any]and overwrites it. This is GitOfThoughts's "none". Zero history. - Baseline B: JSONL snapshots. After every step the agent appends the whole memory as one JSON line to a file. A naive "keep history" approach.
The comparison is a table: for a set of audit queries, which backend can answer it, and at what storage cost.
| Audit query | dict | JSONL | Mnemosyne |
|---|---|---|---|
| current memory | yes | yes (last line) | yes |
memory as of step t | no | yes (line t) | yes (state_at) |
when did key X first become value V | no | yes, O(L) scan | yes, O(log L) bisect |
which observation set key X's current value | no | no (no provenance) | yes (blame) |
what did step t change | no | yes, diff two lines | yes (changed_by) |
| merge two agents' memories, surfacing conflicts | no | no | yes |
storage after L steps of a 100-key memory | O(keys) | O(L x keys) | O(distinct values) content-addressed |
JSONL answers the time questions but at linear storage and with no provenance; the dict answers almost nothing. Mnemosyne answers all of them, and its storage is proportional to distinct content because objects are content-addressed (re-writing the same value is free).
4. Metrics and expected values
| Metric | Definition | Expected | Fails the release if |
|---|---|---|---|
reconstruction_exact | state_at == recorded over all commits, all seeds | 100.00% | any seed is not exact |
golden_bytes_stable | the frozen format vectors still hash identically | pass | any drift |
bisect_exact | found == k | 100.00% | any miss on a monotonic planted fault |
bisect_error_bits | histogram of |found - k| | all zero | any non-zero |
blame_commit_acc | correct introducing commit, linear history | 100.00% | below 100 |
blame_commit_acc_merge | correct origin commit when the value came via a merge | 100.00% | below 100 |
blame_source_acc | correct source string | 100.00% | below 100 |
merge_* | the #49 invariants over a large sweep | all hold | any violation |
write_ms_p50 / p99 | per-commit latency, 100-key memory | report; expect single-digit ms p50 | regression vs the last published number by > 2x |
read_ms_p50 | working-memory read latency | report | as above |
bytes_per_step | .mnem growth per commit, vs baseline B | report the multiple; expect « JSONL | as above |
The correctness metrics have a hard target (100%). The overhead metrics are
reported, not gated on an absolute, but a large regression from the last
published docs/benchmark.md should fail CI, so the number cannot rot silently.
5. Where it lives, and CI
- The correctness and precision harness:
crates/mnem-core/tests/benchmark.rs(orbenches/, decided in the ADR), hand-rolled, seeded. CI runs it at a small trial count as an ordinary test; aMNEM_BENCH_*env override drives the large sweep. - The overhead-and-baseline comparison:
benchmarks/overhead.py(a new top-level dir, likeexamples/). It builds a dict, a JSONL file and a.mnemstore over the same synthetic run, times the operations, measures sizes, and prints the audit-query table. Deterministic; CI runs it and asserts the audit table and a latency ceiling. - The output:
docs/benchmark.md, written by #65 from a full sweep, in the shape ofdocs/chaos-report.md: what was measured, the numbers, the reproduction command. Re-run and updated whenever the core changes.
No new dependency: timing is std::time::Instant in Rust and time.perf_counter
in Python, consistent with the project dropping proptest and time for
hand-rolled equivalents.
6. Out of scope, and the v2 seam
- Accuracy. Not measured. Cited from GitOfThoughts. Mnemosyne is a
substrate, not a retrieval strategy; a vector index over
working_memoryis a wrapper someone else writes. - Real models. The workload is synthetic and deterministic so the benchmark is a regression gate, not a paper.
- The prolly tree. The
bytes_per_stepandread_msnumbers are the evidence for whether the flatStateis still enough (ADR-0012's reassess trigger). The benchmark surfaces the number; it does not decide. - The v2 seam (#63, ADR-0018). Declaring the
SemanticMergetrait and the sync-protocol shape is a separate design with its own grilling. It is not benchmarked (there is no implementation). This research does not touch it.
Recommendations, in one place
- Measure operational properties and overhead, not accuracy. Cite GitOfThoughts for parity.
- Correctness metrics (
reconstruction,bisect_exact,blame_*,merge_*) have a hard 100% target and gate the release. - Overhead metrics are reported in
docs/benchmark.mdand gated only against a large regression from the last published run. - Baselines: a dict ("none") and JSONL snapshots ("naive history"), compared on an audit-query table and storage.
- Two harnesses: a seeded Rust one for correctness, a Python one for
overhead-vs-baseline. Both feed
docs/benchmark.md. No new dependency. - Synthetic, seeded workload parameterised on run length and fault position.
Open questions for the grilling (#62)
- The audit-query table. Is it in the benchmark (asserted in CI) or only in
docs/benchmark.mdprose? Which queries are on it? - Gating overhead. What is a "large regression": a fixed multiple (2x?) of the published p50, or a percentage, and is it p50 or p99?
- Harness location.
tests/benchmark.rs(runs withcargo test) orbenches/(needs a bench harness, andcargo benchis not in CI today)? - The Python baseline dir.
benchmarks/at the top level, or a script underscripts/? - Run length for the published sweep. How large is
L, and how many seeds, before the numbers are "the published numbers"? - Blame-through-merge. Is a less-than-100% number here acceptable to
publish with a caveat, or must it be 100% to ship
v0.0.7? - A prolly-tree trigger. Should the ADR name a concrete
bytes_per_steporread_msthreshold that flips the reassessment, or keep it judgement?
Sources
- GitOfThoughts (arXiv 2606.14470): the five-backend comparison, the study / ingest / test protocol, paired-bootstrap CIs, and the "engineering trade-off at accuracy parity" conclusion
- GitOfThoughts HTML v2: Table 2 (replay / audit / merge demonstrations), Table 3 (write / read latency per backend), Table 4 and 5 (accuracy CIs and the failed replication)
- State of AI Agent Memory 2026 (Mem0): the benchmark landscape
- MemoryAgentBench (ICLR 2026): incremental multi-turn memory evaluation, an accuracy-focused contrast
crates/mnem-core/tests/time_travel.rs,tests/merge_chaos.rs,tests/buggy_run.rs: the seeded harnesses this benchmark extendsdocs/chaos-report.md: the output formatdocs/benchmark.mdfollows
Plain notes
What has been built, in plain words. One entry per release tag, newest first. Written like meeting notes. If you have never seen the code, this is the page for you.
v0.0.7 - the substrate, measured
The one-line version: Era 1 is done. mnem is a working, single-agent memory
version-control tool, and now there is a benchmark that says plainly what it does
and does not buy you, plus a docs site and a frozen on-disk format.
New this release:
- A benchmark.
docs/benchmark.mdreports two things. First, correctness: over thousands of seeded runs, reconstructing memory at any past commit is exact,bisectlands on the exact commit a wrong belief entered,blamenames the right commit and observation, and the merge never loses a write. All at 100%. Second, cost: about 12 ms and a few kilobytes per commit, compared against a plain dict and a JSONL log. A CI check fails if either number doubles. - A docs site. The format spec, the benchmark, every architecture decision and every research survey, browsable at the project's GitHub Pages URL. Built from the repo on every change.
- The on-disk format is frozen for Era 1 at
format_version1. Every0.0.xrelease reads and writes it; a store written by one works with every other. The next format change is Era 2. - The Era 2 seam is in place. A
SemanticMergetrait and aStore::merge_withentry point exist in the core, with a no-op Era 1 implementation, so the "semantic merge" of Era 2 becomes a new package rather than a rewrite. Nothing about today's behaviour changes. (ADR-0018.) - A Claude agent demo.
examples/claude_agent.pyand the README GIF: an agent is told a past answer was wrong, then usesbisectandblameto trace it to a misread note and commits a correction.
Named for publishing: the Rust crates are mnem-store (the core) and
mnem-git (the mnem command); the Python packages are mnem-agents (the SDK),
mnem-mcp and mnem-langgraph. The mnem command itself, import mnem, and
the .mnem/ store directory are unchanged.
What is guaranteed:
- The core still never touches the network and never calls a model.
- One on-disk format (
format_version1), now frozen for the0.0.xline. - The benchmark's correctness checks run on every push, so "time travel is exact", "bisect is precise" and "blame is accurate" are tested, not claimed.
What it still does not do (Era 2 and later):
- No shared memory between two agents. No semantic merge, no sync between stores, no review step. That is the whole of Era 2.
mnemdoes not make an agent give better answers. The benchmark says so directly. It gives you history, audit and safe merging.
Under the hood: benchmarks/, book.toml + docs/SUMMARY.md, the docs
workflow, crates/mnem-store/src/semantic.rs. ADR-0017 and ADR-0018. Published
to crates.io and PyPI for the first time with this tag.
v0.0.6 - plugging in
The one-line version: Mnemosyne now drops into the two ways people actually build agents: as an MCP server, and as a LangGraph memory store. Your agent gets versioned memory without changing how it is written.
New this release:
- An MCP server.
mnem-mcp --store ./agent-memoryspeaks the Model Context Protocol over stdio. Any MCP-capable agent (Claude Desktop, an SDK client) can call tools:remembera fact (with where it came from),recallone or all,whydid the agent conclude this,when_dida belief go wrong, and more. The current memory is also readable as an MCP resource, so it can sit in the agent's context without spending a tool call every turn. - A LangGraph store.
MnemosyneStoreis a drop-inBaseStore. Point a LangGraph agent at it and its long-term memory gains history: every write is a commit. Extra methods let a graph nodebranchto test a hunch, or askwhya memory says what it does. mnem.agentsin the SDK.remember/remember_many/forget: one call records a fact (or several) as one commit, and retries quietly if two writers race. Every result type can now turn itself into plain JSON with.to_dict().- Three worked examples in
examples/, one per surface, each a short script you can run: a support agent that traces a wrong answer, a LangGraph agent with branching memory, and an MCP client talking tomnem-mcp.
What is guaranteed:
- The Rust core still never touches the network and never calls a model. The MCP server and the LangGraph adapter are separate Python packages that sit on top of the SDK; a CI check keeps the core clean.
- Every write through an adapter is one commit, so the memory an agent builds
over a run is fully inspectable afterwards with
log,blameandbisect. - Still one on-disk format (
format_version1).
What it still does not do (next phases):
- The MCP server speaks stdio only. A remote, multi-client HTTP transport (and the auth that comes with it) is a later phase.
- No shared memory between two agents yet. That is the collaboration era.
- The LangGraph
searchis a plain text filter, not semantic. Wrap a vector store if you need ranking.
Under the hood: packages/mnem-mcp/ (FastMCP over the SDK) and
packages/mnem-langgraph/ (a BaseStore subclass), both new. mnem.agents and
to_dict() added to the SDK. Design in ADR-0016. Not on PyPI yet.
v0.0.5 — explaining
The one-line version: you can now ask the agent's memory two questions — "where did this belief come from?" and "when did this go wrong?" — and get an exact commit back, not a guess.
New this release:
- Blame.
mnem blame plantells you which commit last set the memory calledplan, when, and — if the agent recorded it — which step of the run and which observation it came from. Likegit blame, but for one memory instead of one line of code. - Blame sees through merges. If a belief came in from a branch you merged, blame follows it back to the commit that actually wrote it, not just to the merge.
- Bisect.
mnem bisect --node plan --equals '"pro"'binary-searches the history for the first commit whereplanbecame"pro". It does aboutlog2(n)checks, notn, so it stays fast over long runs. You can also ask--absent(first commit where a memory is gone) or--present(first commit where it appears). - Bisect explains itself. When it finds the commit, it immediately runs
blameon it, so one command tells you both where the bad belief entered and what caused it. mnem show <commit> --stat. A quick list of which memories a commit added, changed or removed, without printing all their contents.
What is guaranteed:
- Blame and bisect are exact and deterministic. There is a test fixture — a synthetic support-agent run that misreads a billing note and writes the wrong plan tier — and the tests assert that bisect lands on exactly that commit and blame names exactly that observation.
- A small index (
commit_nodes) makes some of this faster, but it is never trusted: every answer is recomputable from the history alone, and a missing or stale index just means a slightly slower walk, never a wrong answer. - Still no internet, still no AI model calls, still one on-disk format
(
format_version1).
What it still does not do (next phases):
- No
bisect run <command>yet (handing bisect an arbitrary script). The built-in--nodepredicates cover the common case. - Bisect assumes the thing you are looking for, once true, stays true, and walks
a single line of history — same simplification
git bisectmakes. - No sharing between two agents yet. That is the collaboration era.
Under the hood: the Rust engine gained blame, bisect and the
commit_nodes index; the mnem CLI and the Python package both gained blame,
bisect, changed_by and show --stat. Decisions in ADR-0015. Still built from
source; not on PyPI or Homebrew.
v0.0.4 — merging
The one-line version: two branches of an agent's memory can now be combined back into one. Where they don't overlap, it just works. Where they do, you get a clear list of the clashes and simple ways to settle each one.
New this release:
-
Merge.
mnem merge experimenttakes everything that happened on theexperimentbranch and folds it into the branch you are on. -
Clean merges just happen. If the two branches changed different memories (or one branch only added things), there is nothing to decide — the merge goes through and makes one new snapshot with both sets of changes.
-
Fast-forward. If your branch has not moved on and the other one has, the merge is free: your branch just catches up to the other. No extra snapshot.
-
Conflicts are objects, not text. When both branches changed the same memory to different things, that memory becomes a conflict. The merge stops and shows you, for each clash: the original value, your value, and their value. Nothing is written until you resolve it.
-
Resolving. Per clash you can say
--resolve <name>=ours,=theirs,=base(the original), or=delete. Or settle every clash the same way with--strategy ours/--strategy theirs. Then run the merge again. -
In Python:
result = store.merge("experiment") if not result.ok: for c in result.conflicts: print(c.id, c.ours.content, "vs", c.theirs.content) store.merge("experiment", resolutions={"plan": "theirs"})You can also hand in a brand-new
MemoryNodeas the resolution for a clash.
What is guaranteed:
- The merge is deterministic and structural: it works on which memory changed, never on what the text says. Same inputs, same result, every time — swapping which branch is "ours" gives the identical merged memory.
- A conflicted merge writes nothing. You never end up in a half-merged state.
- A seeded chaos harness builds thousands of random branch histories and checks
the merge never loses a write, never leaves a memory in a limbo state, and
that merging two branches together and then back again lands on exactly the
same memory. 50,000 cases in the last sweep, no failures
(
docs/chaos-report.md). - Still no internet, still no AI model calls, still one on-disk format
(
format_version1). A merge snapshot is just an ordinary snapshot with two parents.
What it still does not do (next phases):
- No semantic merge. It won't read two versions of a belief and reconcile the wording — that is a later era.
- No criss-crossed histories. If two branches were already merged into each
other in a tangle,
mnem mergeasks you to merge one side by hand first. A single agent with a person in the loop rarely hits this. - No blame or bisect yet (tracing a belief back to where it came from). That is the next release.
- No sharing between two agents yet.
Under the hood: the Rust engine gained the three-way merge, the merge-base
walk, and the conflict/resolution types; the mnem CLI and the Python package
both gained merge. Decisions written up in ADR-0013 and ADR-0014. Still built
from source; not on PyPI or Homebrew.
v0.0.3 — branches and time travel
The one-line version: the agent can now try an idea on a copy of its memory and keep it or throw it away, and you can look at exactly what it knew at any point in the past.
New this release:
-
Branches.
mnem branch experimentmakes a cheap copy of the current memory line. It costs almost nothing: a branch is just a pointer, like in Git. -
Switching.
mnem checkout experimentmoves you onto that branch;mnem checkout mainmoves back. Your work on each branch stays separate. -
Try-an-idea, in code. In Python:
with store.branch("assume-downgrade"): store.add("customer", "downgraded to Pro") store.commit("test this assumption") # back on the main line; the "assume-downgrade" branch is still there if you want it -
Time travel.
mnem show <commit>prints the agent's full memory exactly as it stood at that commit. Nothing is reconstructed or approximated; it is the real stored state. -
Forgetting.
mnem rm <name>removes a memory from the next snapshot. The old snapshots still have it. -
Seeing what changed.
mnem diffshows what is different between two points: which memories were added, changed (old value then new value), or removed.mnem statusis the quick version: your branch, and what is staged.
What is guaranteed:
- Time travel is exact. There is a test that builds forty different random histories and checks, for every commit, that "show me this commit" gives back precisely the memory that existed when that commit was made.
- Still no internet, still no AI model calls, still one on-disk format
(
format_version1) that every0.0.xrelease can read.
What it still does not do (next phases):
- No merge yet. You can branch, but you cannot yet combine two branches back together. That is the next release.
- No blame or bisect yet (tracing a belief to where it came from).
- No sharing between two agents yet.
Under the hood: the Rust engine, the mnem CLI, and the Python package all
gained the branch, checkout, diff and time-travel operations. Still built from
source; not on PyPI or Homebrew.
v0.0.2 — the first working version
What Mnemosyne is: a tool that keeps an AI agent's memory the way Git keeps code. The agent learns things as it works; this saves each change so you can look back later.
What works now:
- Make a memory store. Run
mnem initin a folder. It creates a hidden.mnemfolder that holds everything, like.gitdoes for code. - Add a memory.
mnem add customer-4821 "the customer is on the Enterprise plan". You can also attach where it came from: which step of the run, which support ticket, when the agent formed it. - Save a snapshot.
mnem commit -m "learn the plan tier". This freezes the current set of memories as one permanent, timestamped record. Each record points back to the one before it, so there is a full chain. - Look at the history.
mnem loglists the snapshots, newest first, with who made each one and when. - Update a memory. Add it again with the same name. The next snapshot has the new value; the old snapshot still has the old value. Nothing is lost.
- Do all of this from Python.
import mnem, thenstore = mnem.init(path),store.add(...),store.commit(...),store.log(). Same features, for agent code. Installs asmnemosyne-agents.
What is guaranteed:
- Every memory and every snapshot has a fingerprint (a hash of its exact bytes). Change one character and the fingerprint changes. You cannot quietly rewrite history.
- Save a store, close everything, open it again: you get back the exact same bytes. There is an automated test that does this on every change.
- The on-disk file format is written down in full and locked for the whole
0.0.xseries. A later version will still be able to read a store made today. - The core never touches the internet and never calls an AI model. A build check blocks any library that could.
What it does not do yet (these are the next phases):
- No branching. You cannot yet try an idea on a copy of memory and then keep or throw it away.
- No time travel. You cannot yet rebuild memory exactly as it stood at an older snapshot.
- No merge. You cannot yet combine two separate lines of memory.
- No blame or bisect. You cannot yet trace a belief back to the moment and the observation that created it.
- No safe sharing between two agents writing to the same store.
Under the hood: a Rust engine (mnem-core), a command-line tool (mnem),
and a Python package. Not published to PyPI or Homebrew yet; you build it from
source.
How the project is run: every change goes through a pull request with seven automated checks (formatting, linting, tests, a minimum-Rust-version check, a wheel build, commit-message format, and repo consistency). Every real decision is written down as a numbered ADR; there are eleven. The roadmap and the issue tracker are public.
Phase 0 progress
Bootstrap. Get to "clone and build" with the process scaffolding in place.
Done
- Repo renamed from
YC27tomnemosyne. - Rust workspace:
mnem-core(library, one placeholder function and a test) andmnem-cli(themnembinary,--versionand a declared command tree with every subcommand stubbed out). Rust CI (fmt,clippy -D warnings,test,build --release) green onmain. - Python SDK skeleton:
mnemexposes its version and raises a clear error on any API that is not built yet. A smoke test covers both. Python CI (ruff,pytest) green onmain. - CI: a
rustjob and apythonjob on every pull request and on push tomain. - Docs:
README.md,CONTEXT.md(seed glossary),ROADMAP.md,AGENTS.md(one-maintainer and offline-core invariants),CONTRIBUTING.md,docs/adr/0000-template.md, ADR-0001, the ADR index, the agent workflow docs, and thedocs/format,docs/research,examplesplaceholders. - Issue and pull request templates, including the Wayfinder ticket template.
- Licence: Apache 2.0.
- Labels:
phase-0tophase-7, thewayfinder:*set,ready-for-agent,track-a/track-b,adr,format,benchmark,spike. - Milestones: one per roadmap phase (renumbered to
v0.0.2tov0.0.7in ADR-0010). - The Phase 0 Wayfinder map (#1), now with ten child tickets: #2 to #7 plus #15 to #18 (commit hashing research and ADR-0005, ADR-0007 versioning, repo hygiene).
- Maps for Phases 1 to 6 (#9 to #14), each a kept-open epic with a checklist comment linking its children.
- The full backlog: 68 issues in total. 57 granular one-PR-sized child tickets
across the six phase maps, sub-issue linked, with 48
blocked_bydependency edges. Every task ticket is scoped to something a single pull request could close. - The scaffold (#2) built on
chore/phase-0-scaffold, merged via PR #8 with green CI. - Tag
v0.0.1pushed. - The GitHub project (Mnemosyne roadmap), linked to the repo, with all open issues added.
- Decision tickets closed: #3 and #15 (research), #4, #5, #6, #7, #16 (research and grilling). ADR-0002 (object format), ADR-0003 (memory node model), ADR-0004 (core and SDK boundary) and ADR-0005 (commit identity) all Accepted.
- Repo hygiene (#18):
SECURITY.md,CODE_OF_CONDUCT.md,.github/dependabot.yml(cargo, pip, github-actions),rust-toolchain.toml, and anmsrvCI job at Rust 1.85 (the floor set byclap'sedition2024transitive requirement). - ADR-0007, versioning and release policy: Accepted (#17). SemVer 2.0.0;
format_versionon its own integer track; additive format changes are a MINOR software release, breaking ones a MAJOR withmnem migrate; hand-writtenCHANGELOG.md; a gated release workflow (built in Phase 6).
Phase 0 is complete. Definition of done met: cargo build and cargo test
green in CI, import mnem works, mnem --version runs, v0.0.1 tagged.
ADR-0001 to ADR-0005 and ADR-0007 Accepted.
Pending, not blocking
- The
v0.0.1GitHub release. The tag is pushed; the release page was blocked by a session safety check. Cut it from the tag in the web UI, or add a Bash permission rule forgh release. - Project board views. The project has all items; add a "group by Milestone" view and a "group by Status" board in the UI.
Next
Phase 1 (v0.0.2, "it commits"), map #9. First: research #19 (object encoding and
the store engine), then ADR-0008 (#20) and ADR-0009 (#21), then the build tickets
starting at #22 (object types).
Notes
- The pyo3 and maturin binding was moved from Phase 0 to Phase 1, where the core first has something to bind. Phase 0 ships a pure-Python placeholder. ROADMAP updated to match.
- Two CI fixes were needed for the Python job: the runner's system interpreter is
externally managed, so
uv pip install --systemfails, andsetup-uvwithpython-versionalready creates the.venv, so a seconduv venvclashes. Settled on:setup-uvwithpython-version, thenuv pip installanduv runstraight into that venv. - The initial commit on
mainshows a red CI run: it predates the Cargo and pyproject files.mainhas been green since PR #8 merged.
Phase 1 progress
v0.0.2, "it commits". The content-addressed object store and the commit graph.
Decisions (all Accepted)
- ADR-0008 object encoding and the store engine:
redb, CBOR restricted to RFC 8949 §4.2 plus our 64-bit-float rule,ciboriumplus a thin pass. - ADR-0009 the ref model: branches only, flat namespace, one-line text
HEAD, compare-and-swap ref moves, reflog reserved.
mnem-core, built
| Ticket | Module | What |
|---|---|---|
| #22 | object, id | Object (kind-tagged), MemoryNode, State, Commit, Provenance; ObjectId (BLAKE3, hex, CBOR byte string) |
| #23 | codec, objects, error | canonical CBOR encode/decode; put/get/has over a redb txn; MnemError |
| #24 | store, config | Store::init/open (upward discovery, nested-store guard), config parsing with the format_version gate |
| #25 | refs, head | the refs table with compare-and-swap; Head::{Attached,Detached}; Store::head/set_head/resolve_head |
| #26 | commit, staging | persistent staging; Store::stage/staged/unstage/commit (one write txn per commit) |
| #27 | log | Store::log/log_head, first-parent and full-graph walks |
ADR-0004 tracer-bullet checkpoint
The minimum vertical slice (#23 to #27) is working, ~43 tests, no walls hit.
Friction: a redb borrow-lifetime quirk (fixed by inlining a helper) and a
clippy version drift (fixed by pinning CI's rust job to 1.93.0). The Rust
core continues; the Python fallback is not needed.
mnem CLI, built (#28)
init, add, commit, log, each a thin wrapper over the core. Verified end
to end:
$ mnem init
$ mnem add customer-4821 "the customer is on the Enterprise plan" --source ticket-4821
$ mnem commit -m "learn the plan tier"
$ mnem add customer-4821 "the customer downgraded to Pro"
$ mnem commit -m "record the downgrade"
$ mnem log --oneline
ddb2a9ea60a3 record the downgrade
45ee8a6b98b1 learn the plan tier
mnem-py, the Python binding (#29)
A pyo3 cdylib (_mnem) over mnem-core, built by maturin as an abi3
wheel (abi3-py310, one wheel for CPython 3.10 and up). It exposes Store
(init, open, add, commit, staged, unstage, log, head,
format_version, root) and maps every MnemError variant onto a Python
exception hierarchy rooted at mnem.MnemError (ADR-0004). pyproject.toml
moved from hatchling to the maturin backend; the version is read from the Cargo
workspace at build time. python/mnem/__init__.py re-exports the binding; the
ergonomic layer is #30.
The rust CI job excludes mnem-py from cargo test/build (its test binary
would need libpython to link); the python job builds the real extension
through maturin and runs the binding's smoke tests. The behaviour itself stays
tested in the core.
mnem, the Python SDK (#30)
The agent-facing layer, pure ergonomics over the binding (ADR-0004). Store
takes str | PathLike, add takes any JSON-serialisable value and does the
json.dumps, commit defaults the author to $MNEM_AUTHOR then "unknown"
and the time to now (matching the CLI), and log returns Commit dataclasses.
Provenance and MemoryNode are dataclasses; add_node stages a MemoryNode.
Module-level mnem.init / mnem.open mirror the classmethods. The binding's
add grew the remaining three Provenance fields so the SDK carries all of
ADR-0003's provenance. A _mnem.pyi stub types the raw extension. Binding tests
moved to mnem._mnem; test_sdk.py covers the wrapper.
Round-trip test (#31), the definition of done
crates/mnem-core/tests/round_trip.rs: a fixed synthetic run (a support agent
triaging one ticket over three commits, mixing fresh nodes with updates, string
/ nested / unicode content, and provenance from empty to every field set) is
persisted, the Store is dropped so the database file closes, then it is
reopened from disk. The test asserts:
- the whole object table is byte-identical before and after the reload
- every stored id is still the BLAKE3 hash of its stored bytes, and decoding then re-encoding an object reproduces those bytes exactly
- the commit history reloads in the same order with the same parents, messages, authors and times
- HEAD's state holds every node id pointing at its latest value, and an earlier commit's state still holds the pre-update value
Runs in the rust CI job. An agent loop can persist its memory and read it back
byte for byte.
The format spec, frozen (#32)
docs/format/README.md is now the full specification: the store directory,
discovery and atomicity, the config and HEAD grammars, the three redb
tables, object identity, the canonical CBOR profile (RFC 8949 section 4.2 plus
the three Mnemosyne rules), and a field table for each object kind. It is marked
frozen for the 0.0.x line: format_version 1, the same for every 0.0.x
release.
docs/format/golden-vectors.md pins six canonical objects with their exact CBOR
hex and ObjectId. crates/mnem-core/tests/golden_vectors.rs checks both that
the encoder still produces those bytes and that the doc still lists them, so the
format cannot drift without a red test.
The wheel job (#33)
CI now has a wheel job separate from the editable python job: it builds a
release wheel with maturin build --release, checks the archive bundles
_mnem.abi3.so, _mnem.pyi and py.typed, installs it into a clean
environment, and runs the SDK tests from outside the repo so import mnem can
only resolve to the installed wheel. The wheel is uploaded as a build artefact.
Phase 1 complete, tagged v0.0.2
The substrate core, the mnem CLI, the pyo3 binding, the Python SDK, the format
spec and golden vectors, the round-trip test, and the wheel job are all in. The
repo-hardening epic (#99) landed alongside: conventional commits, the
offline-core deny check, issue forms, the justfile, the consistency checks,
and workspace lints. The definition of done is met.
v0.0.2 tagged on main (ADR-0007): workspace version bumped, the changelog
section dated, docs/progress/plain-notes.md written. The pending-release
issues and the v0.0.2 milestone are closed. Next: Phase 2 (v0.0.3), map #10.