Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 at format_version 1.
  • Benchmark report: what the substrate delivers, measured. Reconstruction, bisect precision, blame accuracy, 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

  1. The substrate (now, 0.0.x): single-agent versioned memory. Local and deterministic.
  2. The collaboration layer: semantic merge that reasons about contradiction, a sync protocol between stores, and a review step. Pull requests, for agent memory.
  3. 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.

KeyValueMeaning
format_versionintegerthe format this store is written in, 1 for this line. A reader that supports up to version N accepts a store with format_versionN and refuses anything higher.
hash_algostringthe object hash. blake3 is the only accepted value.
default_branchstringthe 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

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 detached HEAD is 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:

TableKeyValueHolds
objects32 raw bytes, an ObjectIdthe object's canonical CBORevery memory node, state and commit
refsbranch name, UTF-832 raw bytes, a commit ObjectIdone row per branch
stagingnode id, UTF-832 raw bytes, a node ObjectIdthe nodes staged for the next commit
staging_tombstonesnode id, UTF-8emptythe nodes staged for deletion (ADR-0012)
commit_nodes32 raw bytes, a commit ObjectIdCBOR { 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. content numbers 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.

FieldCBOR typeRequiredNotes
kindtext stringyes"memory_node"
idtext stringyesthe stable logical key
contentany JSON valueyesstored verbatim; string-keyed maps, finite numbers
content_kindtext stringyes"note" or "claim". 0.0.x only writes and reads "note"; "claim" is defined for a later era
provenancemapomitted when emptysee below; an entirely empty provenance is not encoded
event_timeintegeromitted when absentwhen 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).

FieldCBOR typeRequiredNotes
kindtext stringyes"state"
nodesmapyesnode 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.

FieldCBOR typeRequiredNotes
kindtext stringyes"commit"
parentsarray of 32-byte stringsyesempty for the first commit, one normally, two or more for a merge. Order is significant; parents[0] is the first parent
state32-byte stringyesthe ObjectId of this commit's state
messagetext stringyesfreeform
authortext stringyeswho or what made the commit, opaque to the core
timeintegeryesthe 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_time is 1b 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.
  • seats is 18 28, an unsigned integer in one trailing byte (40).
  • Inside provenance the keys are note, 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 memorya dicta JSONL logMnemosyne
what does it believe nowyesyesyes
what did it believe at step tnoyesyes
when did belief X first go wrongnoyes, O(L)yes, O(log L)
which observation set Xnonoyes
what did step t changenoyesyes
merge two agents' memories, surfacing conflictsnonoyes
storage after L stepsO(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.

MetricWhat it checksResult
reconstruction_exactstate_at(commit) equals the working memory recorded at that commit100.00%
golden_bytes_stablethe frozen canonical encoding still hashes identicallytrue
bisect_exactbisect returns the exact commit a planted monotonic fault began100.00%
bisect_error_maxthe largest |found - k| seen0
blame_commit_accblame resolves to the correct introducing commit, linear history100.00%
blame_commit_acc_mergecorrect origin commit when the value arrived on a merged-in side100.00%
blame_source_acccorrect provenance source string100.00%
merge_invariantssymmetry, 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.

Stepsbackendwrite p50write p99read (full)bytes / step
256dict~0 ms~0 ms0
256JSONL0.03 ms0.19 ms830
256Mnemosyne12 ms24 ms1.5 ms10,320
1024JSONL0.04 ms0.65 ms955
1024Mnemosyne12 ms24 ms1.5 ms7,470

Reading:

  • Write latency is ~12 ms, flat with run length, and it is the durable write: a commit fsyncs a new State, a Commit and an index entry. A dict is free; a JSONL append is a small fsync. 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 State and Commit objects are the floor. This is the number a prolly-tree State would later share down, and the trigger for revisiting it is a real store's .mnem exceeding ~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 as merge_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:

InvariantAssertion
Totalityevery id across base ∪ ours ∪ theirs is in merged or in conflicts, never both, never neither
No lost writesfor 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 idsnothing in merged or conflicts that was not in an input
Conflict orderingconflicts come back in id order, no duplicates, each carrying the true (base, ours, theirs) triple
Clean-merge symmetryswapping ours and theirs gives the identical merged map and the identical conflict id set (the kinds mirror: edit/delete ↔ delete/edit)
Idempotencemerge_state_maps(base, ours, ours) is clean and returns ours unchanged
Base identitymerge_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 always Merged, FastForwarded or AlreadyUpToDate;
  • a real merge commit has exactly two parents, ours first;
  • convergence: merging main back into feature afterwards 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.

SweepTrialsResult
CI (every push)600 pure + 24 storepass
Manual, this report50,000 pure + 250 storepass, 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-core and the CLI crate mnem-cli. Because those names were already taken on crates.io, before the first publish the core crate became mnem-store and the CLI crate mnem-git (the binary is still mnem); the published packages are mnem-store, mnem-git, and on PyPI mnem-agents, mnem-mcp, mnem-langgraph. import mnem and the .mnem/ store directory are unchanged. See the CHANGELOG.md entry under Unreleased.

ADRTitleStatus
0001Record architecture decisionsAccepted
0002On-disk object formatAccepted
0003The memory node modelAccepted
0004The Rust core and Python SDK boundaryAccepted
0005Commit identity, hashing and signingAccepted
0007Versioning and release policyAccepted
0008Object encoding and the store engineAccepted
0009The ref modelAccepted
0010What 1.0 means, and the 0.0.x roadmapAccepted
0011Conventional commits and the issue lifecycleAccepted
0012The branch and checkout modelAccepted
0013The deterministic merge algorithmAccepted
0014The conflict object and the resolution APIAccepted
0015The provenance index, blame and bisectAccepted
0016The MCP tools and the adapter contractAccepted
0017The benchmark and its metricsAccepted
0018The Era 2 seam: the semantic merge trait and the sync protocolAccepted

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:research ticket 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.md for 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.
  • prollytree as the core data structure would make our frozen format someone else's, resting on their release cadence.
  • Git4Data needs a database server; DVC and oras are 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 redb table 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. If redb ever breaks its format without an upgrade path, that is our problem to absorb in a mnem migrate.
  • Anyone wanting to read a store needs our spec, not just git cat-file. The inspection commands and docs/format/ are load-bearing, not optional.
  • A store cannot be inspected or repaired with generic tools while mnem is 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.

FieldTypeRequired in v0.1Notes
idstringyesa stable logical key
contentstring or any JSON valueyesfreeform, stored verbatim
content_kindenumyes, always notenote or claim
provenanceobjectyes, may be emptyhow the node came to exist
event_timetimestamp or nullnowhen 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.

FieldTypeNotes
agent_stepstring or nullwhich step of the run produced it
observationreference or nullthe observation or input it was drawn from
tool_callreference or nullthe tool call, if one produced it
sourcereference or nullan external source identifier
notestring or nullfree 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:

FieldTypeNotes
subjectreferencethe entity the claim is about
predicatestringthe attribute or relation
valueany JSON valuethe asserted value
confidencefloat 0 to 1 or nullthe agent's stated confidence
evidencelist of referenceswhat 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, provenance and an optional event_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.
  • blame quality depends on the agent populating provenance. An agent that passes nothing gets blame that 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-semantic crate, 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 variantPython exception
NotFoundNotFoundError
InvalidRefInvalidRefError
ConflictConflictError
CorruptStoreCorruptStoreError
FormatVersionFormatVersionError
StoreExistsStoreExistsError
NoStoreNoStoreError
IoStoreIoError

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 redb or pyo3 limitation, or the graph code fighting the borrow checker past reasonable effort), the maintainer switches mnem-core to 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

FieldTypeNotes
kindstring, always "commit"ADR-0002's self-describing objects
parentslist of commit ids[] for the first commit, one normally, two or more for a merge. No sentinel.
statestate idthe state this commit points at
messagestringfreeform
authorstringopaque 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.
timeintegerUnix 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 one author is enough.
  • a run reference: grouping commits by agent run is deferred; not in v0.1.
  • signature: held separately, see below.
  • format_version: lives in config, 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_algo field reserves that path; it is not built.
  • A bare-string author means the core offers no identity guarantees in v1. The v2 agent-identity work fills this in.
  • time as Unix milliseconds is unambiguous and trivial to serialise, at the cost of not carrying a timezone. mnem renders 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 changeformat_versionSoftware bumpMigration
noneunchangedPATCH or MINOR per the APInone
additive (a new object kind, a new optional field)+1MINORnone. 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)+1MAJORmnem 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:

  1. main is green.
  2. The version in Cargo.toml is bumped to the intended X.Y.Z and matches the tag about to be pushed.
  3. CHANGELOG.md has a dated section for X.Y.Z, moved down from Unreleased.
  4. The tag vX.Y.Z is pushed.
  5. A release workflow 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, and mnem --version will 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-object decodes 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.
  • redb and ciborium are the two dependencies the frozen format rests on. Both are widely used with stable formats. A break in either is absorbed by a mnem migrate under ADR-0007.
  • mnem commit fsyncs, so a commit-heavy workload is bounded by disk sync latency until the --no-fsync flag 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>. HEAD follows the branch; a commit moves the branch and HEAD with it.
  • Detached: <64-hex-commit-id>. HEAD points straight at a commit; committing from here is refused until a branch is created.
  • Fresh store, no commits: ref: main. main does not exist in refs yet. 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 HEAD points 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 redb table 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.2 through v0.0.7. The ROADMAP.md table, the README.md arc, 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.x period and schedules when the rest applies; it supersedes nothing.
  • Until 0.1.0, the format_version integer, not the software version, is the stability signal. A reader who sees 0.0.4 knows 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 main conflates "in main" with "shipped". A store written by v0.0.2 is the contract, not whatever is on main.

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 --oneline become 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 a refs row at the given start point, or where HEAD resolves. Fails if the name already exists (no silent move) or is invalid (ADR-0009 rules).
  • Store::branches() is refs::list.
  • Store::delete_branch(name) refuses the branch HEAD is 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 attached HEAD; a commit target gives a detached HEAD.
  • Refused when staging or staging_tombstones is non-empty, with a message naming the pending ids, unless discard is set (then both are cleared).
  • Writes HEAD atomically (temp file plus rename). Nothing else is touched. No lock is taken.
  • Checkout of the current branch, or of the commit HEAD already 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 HEAD commit's State, minus the ids in staging_tombstones, with staging overlaid.

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 show with no argument prints working memory.

Time travel reads any commit

  • Store::state_at(commit) -> BTreeMap<String, MemoryNode> loads a commit's State and its nodes, decoded. Store::state_map_at(commit) -> BTreeMap<String, ObjectId> is the raw form for callers that do not need content (diff uses it internally).
  • Any commit that exists in objects and is a Commit. 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 new staging_tombstones table (a set of node ids). This table is local working state, like staging: it is not part of the portable history and does not travel with a store.
  • rm of an id that is neither in the HEAD state nor staged is an error. rm of an id that is only a staged add (not in HEAD) unstages it instead of tombstoning.
  • commit builds the new State from the parent's State plus staging, then removes every tombstoned key. A commit with only tombstones staged is valid.
  • unstage(id) clears the id from both staging and staging_tombstones.

CLI: mnem rm <id>.

Structural diff

  • Store::diff(from: DiffTarget, to: DiffTarget) -> Vec<NodeChange> where DiffTarget is Commit(ObjectId) or Working.
  • NodeChange is Added { id, new }, Removed { id, old }, or Modified { id, old, new }, carrying ObjectIds only. Computed as a merge-join over the two sorted State maps: O(nodes). Any two states; no ancestry required.
  • The SDK returns list[NodeChange], a frozen dataclass (id, kind, and old / new as MemoryNode | 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 h1 at HEAD, checkout h1. Entering with dirty staging raises, per checkout's rule.
  • On exit, normal or exception: checkout back to the branch that was current on enter; leave h1 in place for the caller to merge (Phase 3) or delete_branch; re-raise on exception.

No automatic merge. Merge is Phase 3.

Deferred

  • The prolly-tree State form. Phase 2 keeps the flat State (a full id-to-ObjectId map per commit). Storage grows as commits times nodes, which is fine at the scale an agent runs at. A prolly form (a second State kind, a format_version bump) 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 from mnem log for now.

Consequences

  • Branching and checkout cost one small write each. The design adds no on-disk format structure that a store reader sees; format_version stays 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_tombstones is a second piece of local-only state. commit, unstage and status all have to account for it.
  • An agent can now forget a stale belief (rm), fork memory to try an approach (branch plus the context manager), see what it changed (diff, status), and read what it knew at any past point (show <commit>).
  • The flat-State ceiling 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):

botresult
xxxkeep x
xyxkeep y (ours changed)
xxykeep y (theirs changed)
xyykeep y (convergent edit)
xyzconflict EditEdit
xabsentxabsent (ours deleted)
xxabsentabsent (theirs deleted)
xabsentabsentabsent (convergent delete)
xabsentzconflict DeleteEdit
xyabsentconflict EditDelete
absentyabsentkeep y (ours added)
absentabsentzkeep z (theirs added)
absentyykeep y (convergent add)
absentyzconflict 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 resolutions entry 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 to o; Some(Theirs) to t. An explicit resolutions entry overrides the strategy for that id.
  • If, after applying resolutions and any strategy, some conflicts remain unresolved, merge returns Conflicts with 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, so log --first-parent follows the branch we were on)
  • message = the caller's, or merge <theirs> into <branch>
  • author, time from the caller, as with commit
  • 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:

  • HEAD is detached (merge only from a branch, like commit)
  • staging or 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

  • merge is 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_version change: a merge commit and a merged State are 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 merge with 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 --resolve flags or --strategy.
  • --resolve with a set (a supplied node) is not offered on the CLI in v0.0.4; use the SDK or resolve to ours / 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 ours is 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 Conflict shape 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 same Added / Removed / Modified classification Store::diff already produces (ADR-0012). The first commit's entry is every node id in its state, all Added.
  • Merge commits. Diffed against parents[0] only, consistent with the first-parent model log and blame use. 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::merge and Store::commit each 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 on open: a fresh 0.0.5 store 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> --stat prints 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>;
}
}
  • at is a commit-ish (branch name or hex prefix, resolve_commitish), defaulting to HEAD. A detached HEAD or 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 a bisect predicate or a future mnem log --follow, not blame.
  • Walk the first-parent chain from at. At each commit Ci with first parent Cp: if state_map_at(Ci)[node_id] != state_map_at(Cp)[node_id], then Ci changed the value. The root commit (no parent) always counts.
  • Through a merge. If Ci is a merge and its value for node_id differs from parents[0] but equals some parents[k], continue the walk from parents[k] instead of stopping. This follows the value to the commit that actually wrote it, rather than reporting "a merge happened".
  • Index use. blame may consult commit_nodes as a skip filter — if a commit's change set does not contain node_id, its state need not be loaded. The index is an accelerator, never the source of truth.
  • provenance is read off Blame.node.provenance; an empty provenance is legal and means blame resolved 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
}
}
  • bad and good are commit-ishes. good defaults to the root commit reached by the first-parent walk from bad.
  • Up-front checks: good is an ancestor of bad (is_ancestor); the predicate is false at good and true at bad. 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: false up to a boundary, true from 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 bad back to good, giving an ordered list, and binary-search it: O(log N) state_at evaluations. 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 plan prints the short commit id, the effective time, the author, and the provenance fields that are set, then the node content.
  • mnem bisect builds the predicate from --node plus one of --equals (content equals this JSON value), --absent, --present. On success it prints the boundary commit and its blame for --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 run style) is a later ticket.
  • SDK: store.blame(node_id, at="HEAD") -> Blame; store.bisect(bad="HEAD", good=None, predicate=...) -> str taking a Python callable over a {id: MemoryNode} dict; store.changed_by(commit) -> dict. Blame is 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_nodes is 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 --stat output now and the Era 2 audit view later — not speculative infra.
  • blame and bisect are correct with an empty or absent index; the index only makes blame skip work. A store can always rebuild_index().
  • blame follows values through merges, so its answer is "who wrote this", which is what a user debugging a belief wants.
  • bisect inherits git bisect's monotonicity assumption and its linear-history simplification. Documented; revisited only if a real multi-agent history needs more.
  • blame quality still depends on the agent populating provenance (ADR-0003). An agent that passes nothing gets blame that 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/ -> PyPI mnemosyne-mcp, import mnem_mcp, console script mnem-mcp.
  • packages/mnem-langgraph/ -> PyPI mnemosyne-langgraph, import mnem_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:

ToolDoesArguments
rememberrecord one fact, one commitid, content, source?, step?, observation?, summary?
revisesame as remember; the name signals "this changes a belief"same
remember_manyrecord several facts as one commititems: [{id, content, ...}], summary?
forgettombstone one node, one commitid, summary?
recallread one node, or the whole current memoryid?
recall_atthe memory as of a past commitcommit, id?
historyrecent commits, newest firstlimit?
whythe commit and provenance that set a nodeid, at?
when_didfirst commit where a node reaches a value / is absent / is presentid, one of equals / absent / present, good?
whats_newwhat a commit changedcommit

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

URIBacked byCache
mnem://memoryworking_memoryshort ttlMs, cacheScope per-store, invalidated on any write
mnem://memory/{node_id}working_nodeas above
mnem://loglog(limit)short ttlMs, per-store
mnem://commit/{id}state_at + changed_bylong 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:

BaseStoreMnemosyne
namespace: tuple[str, ...]an id prefix: the node id is ":".join(namespace) + ":" + key
key: strthe node id within the namespace
value: dictthe 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 _meta key of the opaque value dict ({"...": ..., "_meta": {"source": ..., "step": ...}}), which the adapter lifts into Provenance and strips from stored content. Absent _meta means empty provenance, which is legal (ADR-0003).
  • search is 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 in asyncio.to_thread so the event loop is not blocked.
  • Extra methods. store.branch(name), store.switch(name), store.why(namespace, key) and store.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:

  1. mnem.agents (remember / remember_many / forget) and the to_dict() methods, above.
  2. A bounded retry-on-ConflictError wrapper for the atomic write helpers (a few attempts with a short backoff), then a raised ConflictError the caller maps to busy.
  3. to_dict() round-trips on Blame, NodeChange, Commit, MemoryNode, Conflict, MergeResult.
  4. Store thread-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:

  1. SDK - a support agent that records beliefs with provenance, gets a plan tier wrong, and uses bisect + blame to find the misread observation (the running example, the buggy_run fixture as a real script).
  2. LangGraph - an agent using MnemosyneStore as BaseStore across two threads, with a hypothesis branch around a sub-task.
  3. MCP - a client script driving mnem-mcp over stdio through remember / recall / why.

Consequences

  • The core and the SDK are untouched except for one additive module (mnem.agents) and to_dict() methods. No format_version change.
  • mnem-mcp is 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 BaseStore subclass plus a handful of extra methods. An agent author swaps their store backend and gets history, blame and branching, with search degrading to a filter.
  • The stateless, one-store-per-process design means an agent runtime scales memory the way it scales agents: one mnem-mcp each.
  • Provenance survives the LangGraph path only if callers populate _meta. Documented; the MCP remember tool 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):

MetricDefinitionTarget
reconstruction_exactstate_at(commit) equals the working memory recorded at commit time, over every commit of every seed100.00%
golden_bytes_stablethe frozen format vectors still hash identically (reuses the golden_vectors assertion)true
bisect_exactbisect returns the exact commit k where a monotonic planted fault began100.00%
bisect_error_maxthe largest |found - k| seen0
blame_commit_accblame resolves to the correct introducing commit, linear history100.00%
blame_commit_acc_mergecorrect origin commit when the value arrived on the merged-in side100.00%
blame_source_acccorrect provenance source string100.00%
merge_invariantsthe #49 chaos invariants (totality, no lost writes, symmetry, convergence) at the bench trial counttrue

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):

MetricDefinition
write_ms_p50, write_ms_p99latency of one commit, a 100-key memory
read_ms_p50latency 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 L commits; 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 the bisect predicate key == wrong_value is 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.

QuerydictJSONLMnemosyne
current memoryyesyesyes
memory as of step tnoyesyes
when did key X first become value Vnoyes, O(L)yes, O(log L)
which observation set X's current valuenonoyes
what did step t changenoyesyes
merge two agents' memories, surfacing conflictsnonoyes

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 with cargo test at a small trial count; MNEM_BENCH_RUNS / MNEM_BENCH_LENGTHS env overrides drive the published sweep.
  • benchmarks/overhead.py: a new top-level directory (mirroring examples/). Builds a dict, a JSONL file and a .mnem store over the same synthetic run, times the operations with time.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.md is 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 of docs/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 rust job already runs benchmark.rs (it is a test) at the small trial count: L in {16, 64}, 40 seeds.
  • A new benchmark job: Python plus the SDK, runs benchmarks/overhead.py, asserts the audit-query table, and fails if write_ms_p50 or bytes_per_step exceeds 2x the value in benchmarks/baseline.json. read_ms_p50 is reported, not gated (it is dominated by content decode).

The published sweep

docs/benchmark.md reports:

  • correctness: L in {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 benchmark job.
  • docs/benchmark.md is the artefact for the launch: honest, reproducible, and it says plainly what version control does and does not buy.
  • The bytes_per_step and read_ms_p50 numbers are the evidence for the prolly-tree decision, without this ADR making it.
  • Two more files to keep current on a core change (benchmark.rs is automatic; baseline.json and docs/benchmark.md need 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 SemanticMerge trait: 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 fetch cheap.
  • A Proposal: a cross-store change does not write a shared branch directly. It opens a Proposal (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 from v0.0.7.
  • Store::merge and MergeOutcome are untouched, so ADR-0014 stays frozen with the rest of the Era 1 surface.
  • format_version 2's contents are now named in one place (docs/format/ links here): the stored Contradiction, whatever sync adds to a commit or the refs, and the prolly-tree State if 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 SemanticMerge impl that fails (a model call errors) surfaces as MnemError. 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_id and claim_ref as 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:

Fieldv0.1Notes
idyesa stable logical key, so updates across commits target the same node. Caller-provided, or generated if absent. Not content-derived.
contentyesfreeform: a string or an arbitrary JSON value
content_kindyes, always notenote or claim. claim follows the claim schema.
provenanceyes{agent_step, observation, tool_call, source}, all optional individually
event_timeyeswhen 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_time in v0.1: include it now, as recommended, or rely on the commit timestamp until v2. One field, and blame reads 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

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. gitoxide gives 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. redb uses 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 objectsbranch coststructural diffmergeformat stabilitybuild effort
1 Git loose + packpoor without packingO(1)nonoexcellenthigh (build packing + GC)
2 KV object storegoodO(1)nonogood (redb stable)low to moderate
3 prolly state + KVgoodO(1), delta-costyes, cheapyes, node-levelours to fixmoderate to high
4 prollytree crategoodO(1)yesyesnot ourslow, but risky
5 Git4Datan/an/ayesyesn/an/a, wrong shape
6 DVC / orasn/an/anonon/an/a, wrong shape

Recommendation for ADR-0002

Option 2 now, option 3 by Phase 3. Concretely:

  1. .mnem/ is one redb file (store.redb), plus a plain-text HEAD and a plain-text config for legibility and easy inspection.
  2. An object database: a redb table objects: hash -> bytes holding memory nodes, states and commits. The hash function and commit header are ADR-0005's call; assume a 32-byte content hash for now.
  3. Refs: a redb table refs: name -> hash, with HEAD mirrored to the text file.
  4. State: a plain sorted content-addressed map in v0.1 (option 2). The prolly tree lands in Phase 2, when diff first 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; prollytree is a reference and a spike target, not a core dependency.
  5. No packfiles, no bespoke garbage collection in v0.x. redb handles compaction. A mnem compact command can come later if a store grows unreasonably.
  6. Legibility is recovered with mnem cat-object, mnem verify and mnem fsck style 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 redb acceptable as the one storage dependency to freeze on for years. Its format is documented and stable, but name it explicitly as load-bearing.
  • One redb file, or redb for 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

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-256securitystandardRust
SHA-1~2xbroken (SHAttered)legacyyes
SHA-256baseline (~3 GB/s)strongNIST, FIPSyes
SHA-3-256~0.3x (slower)strongNIST, FIPSyes
BLAKE2b~3xstrongRFC 7693yes
BLAKE34 to 10x, parallel (tens of GB/s on many cores)strong, same profile as SHA-2 and SHA-3not a NIST standardreference 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 mnem accepting 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_ed25519 key, 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 redb table signatures: commit hash -> signature. The commit object has no signature field, so signing does not change a commit's hash. mnem verify checks 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

FieldTypeNotes
kindstring, always commitADR-0002's self-describing objects
parentslist of commit hashes0 for the first commit, 1 normally, 2 or more for a merge
statestate hashthe state this commit points at
messagestringfreeform
authorstringwho 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.
timetimestampthe 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 author is 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_algo recorded in config for 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 verify checks signatures where present. Sigstore deferred.
  • Commit header: {kind, parents, state, message, author, time}. One author, no committer. 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_algo built 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 sign and mnem verify ship 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

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

  1. Deterministic and canonical (ADR-0005): the same logical object must produce the same bytes, so the same hash.
  2. Self-describing and evolvable (ADR-0002, ADR-0007): objects carry a kind tag, and additive changes (a new field, a new kind) must not break an older reader of the objects it does understand.
  3. Legible (ADR-0002): mnem cat-object must produce something a person can read.
  4. Compact and quick (ADR-0002): many small objects per commit.

The candidates

FormatDeterministic modeSelf-describingSchema evolutionLegibilitySize and speed
bincodenonenono (field order is declaration order)nonefastest, small
postcardnonenononone~1.5x bincode, ~70% its size
MessagePack (rmp-serde)weak, no standardpartly (type tags)tolerablepoorsmallest on the wire, slower to decode
CBOR (ciborium, cbor2)RFC 8949 §4.2, plus the CDE and dCBOR profilesyesyes (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 standardyesyesbestlargest, 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 kind tag 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 its kind. 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-object can 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, tables objects and refs as 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: ciborium for 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. cbor2 is the alternative, with §4.2 canonical encoding built in but a shorter track record. The grilling picks.
  • Framing: none beyond CBOR itself. kind is the first map entry. format_version stays in config (ADR-0007), not per object. redb frames 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)

  • ciborium plus a hand-rolled §4.2 pass, versus cbor2's built-in canonical encoding, versus a fully hand-rolled canonical binary form. This survey leans ciborium plus a thin pass.
  • Which determinism profile exactly: RFC 8949 §4.2 core, or CDE, or dCBOR.
  • Confirm content maps straight to the CBOR data model, and how floats inside content are canonicalised.
  • Confirm no per-object framing header: kind as the first CBOR map entry is enough.

Sources

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:

  1. How do Git and Jujutsu keep branch creation constant time, and does Mnemosyne already have that?
  2. What does checkout have to touch, and what is "working memory" here?
  3. Is the flat state 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 a refs row pointing at where HEAD resolves.
  • Store::branches()refs::list, already there.
  • Store::delete_branch(name)refs::delete, plus a guard so the branch HEAD is 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 in objects and be a Commit.
  • The HEAD write 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 State plus 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 State maps once in lockstep and classifies each id as added / removed / modified (different ObjectId) / unchanged: O(nodes). BTreeMap makes 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 working table now?
  • checkout with a dirty staging: 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 merge or drop later?
  • mnem show with no argument: working memory, or the HEAD commit's state?
  • Diff output shape: the NodeChange list above, or a richer object that also carries the decoded old and new content for rendering?
  • Confirm flat State for Phase 2 and the prolly triggers above.
  • checkout and the reflog: still reserved, or does Phase 2 start writing it?

Sources

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:

  1. What does a three-way merge of two State maps do, per node id?
  2. How is the merge base found, and what happens on a criss-cross history?
  3. How are conflicts represented, and what does the resolution API look like?
  4. What invariants must the chaos harness check, and is flat State still 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):

botresult
xxxkeep x (unchanged)
xyxkeep y (ours changed, theirs did not)
xxykeep y (theirs changed, ours did not)
xyykeep y (both changed the same way, convergent)
xyzconflict: edit/edit
xabsentxabsent (ours deleted, theirs unchanged)
xxabsentabsent (theirs deleted, ours unchanged)
xabsentabsentabsent (both deleted, convergent)
xabsentzconflict: delete/edit
xyabsentconflict: edit/delete
absentyabsentkeep y (ours added)
absentabsentzkeep z (theirs added)
absentyykeep y (both added the same, convergent)
absentyzconflict: 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. State is 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 ort strategy 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 O is an ancestor of T (or vice versa), the merge is a fast-forward: the result is T, no merge commit. merge should 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 same State object id. Conflict sets are equal; the merge commit differs only in parent order and message.
  • Idempotence. merge(O, O) is a fast-forward to O; merging an ancestor is a no-op.
  • Base identity. merge(O, B) where B is the base is a fast-forward or a no-op (O already contains the base).
  • Convergence. After resolving conflicts, merge(O, T) then merge(T, O') (where O' 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 State kept, prolly deferred again.
  • ADR-0014 (conflict object + API): Conflict { id, kind, base, ours, theirs }; transient (returned) rather than stored for v0.0.4, stored form deferred to the Era 2 review model; resolutions are ours | theirs | base | delete | set(node); merge / merge_resolve / merge_continue / merge_abort; pending resolutions in a local table; mnem merge + --continue / --abort, mnem status shows unresolved ids.

Open questions for the grillings (#43, #44)

  • #43: single-base-or-refuse versus recursive virtual base. Fast-forward: silent, or announced? Does merge ever touch HEAD other 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 --continue flow, or must a merge be resolved in one process. What mnem status shows mid-merge. The set(node) resolution: does it go through add (staging) or a dedicated path.

Sources

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_versionblame and bisect are read-only walks over objects that already exist.

Four questions:

  1. What does blame resolve, and how, given that provenance lives on the node?
  2. 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?
  3. What does bisect search, and how is the range given?
  4. 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: is not in memory at "). Walk the first-parent chain from C towards the root. For each commit Ci with first parent Cp:

  • if state_map_at(Ci)[id] differs from state_map_at(Cp)[id], then Ci is the introducing commit for the value as of C. 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 equals parents[k], continue the walk from parents[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:

OperationWithout an indexWith an index
blame <id>O(history) first-parent walk, ~µsO(1) lookup of the introducing commit
bisectO(log n) state_at readsunchanged — 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 commit for the current tip (answers blame in 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 (diff the new commit against parents[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:

  1. records that blame/bisect walk the graph directly and why that is fine at this scale (the numbers above);
  2. 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 where blame latency or an Era 2 UI needs it;
  3. 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), and blame reads 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 ", "not an ancestor").

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 , written by " in one shot — this is the phase's headline demo.


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. plan flips from enterprise to pro off 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) bisect with the predicate plan == "pro" and asserts the boundary is exactly commit k; (b) blame plan at HEAD and asserts it resolves to commit k and 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 (default HEAD) 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 new redb table, 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 between good (predicate false, ancestor) and bad (predicate true, default HEAD); predicate is Fn(&state_at map) -> bool; assumes monotonic; linear history only; O(log N) state_at reads. Helper for the "one node, one value" case.
  • Fixture (#54): seeded synthetic run, wrong belief at a known commit, tests that bisect finds it and blame explains 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?
  • blame through merges. Report the merge commit (simple) or recurse to the true origin (a few more lines, better answer)?
  • blame output. Just the introducing commit, or also the chain of every commit that touched the node (a mnem log --follow <id> in disguise)?
  • bisect predicate. 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 like git bisect run)?
  • bisect range. Require good explicitly, 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 blame care 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, run with a predicate command
  • How git bisect works (Julia Evans) — the binary-search framing
  • ADR-0003 (the memory node model) — provenance fields, event_time, "bisect operates 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:

  1. Which SDK operations become MCP tools, and which become MCP resources?
  2. What is the write contract: one commit per call, or a stage/commit split?
  3. How is a store bound to a server, given MCP 2026-07-28 is stateless?
  4. What does the LangGraph adapter implement (BaseStore, BaseCheckpointSaver, or both), and how does its namespace/key model map onto Mnemosyne?
  5. What is the shared adapter contract, and does the SDK need anything new?
  6. 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 ttlMs and cacheScope. 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:

ToolSDK call(s)Notes
rememberadd + committhe common case: record one fact with provenance, in one commit. { id, content, source?, step?, observation? }
reviseadd + commitsame shape; a separate name so the model signals "this changes an existing belief"
forgetrm + committombstone one node in a commit
recallworking_node / working_memoryread one node or the whole current memory. Also a resource (below); the tool form is for when the model decides it needs it
recall_atstate_atthe memory as of a past commit (time travel)
historylogrecent commits, newest first, { limit? }
whyblameresolve a node to the commit and provenance that set it
when_didbisectfirst commit where a node reaches a value, is absent, or is present
whats_newchanged_bywhat 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

URIBacked bycacheScope
mnem://memoryworking_memoryper-store; short ttlMs, invalidated on any write
mnem://memory/{node_id}working_nodeper-store
mnem://loglog(limit=N)per-store
mnem://commit/{id}state_at + changed_byimmutable, 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.open is cheap, a redb handle), acts, closes. Stateless, trivial, correct. This is the v0.0.6 recommendation: an agent runtime launches one mnem-mcp per agent, pointed at that agent's memory.
  • (b) Store selected per call. A store argument on every tool, or the Mcp-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, key a string, value an 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:

BaseStoreMnemosyne
namespace: tuple[str, ...]a prefix on the node id (":".join(namespace)), or a branch, per the ADR
key: strthe node id (within the namespace)
value: dictMemoryNode.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 / Commit to 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 / forget atomic helpers (above).
  • A retry-on-write-conflict wrapper, or a clear ConflictError the caller can catch (compare_and_set already raises one in the core).
  • to_dict() on Blame, NodeChange, Commit, MemoryNode for the JSON boundary (the binding already emits dicts; the SDK dataclasses should round-trip).
  • Confirm thread-safety of a Store handle, 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/ (or adapters/) directory in this repo, each its own pyproject.toml, not a Cargo member, published to PyPI separately. One repo, one issue tracker, and CI can test them against the built wheel. Recommended for v0.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 mcp Python 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 / merge behind 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 BaseStore first; branch / why as extra adapter methods; BaseCheckpointSaver deferred with a trigger.
  • Contract: a shared remember / forget / to_dict helper set in the SDK, consumed by both adapters.
  • Packaging: a packages/ directory in this repo, PyPI mnemosyne-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) in v0.0.6, or is one-fact-one-commit enough to ship?
  • The _meta channel. How does provenance travel through BaseStore.put's opaque value dict: a reserved _meta key, a separate put argument the adapter adds, or dropped (provenance only via the MCP remember tool)?
  • search. Prefix/substring filter over working_memory, or "not in v0.0.6, raise NotImplementedError"? Mnemosyne is explicitly not a retrieval layer (README, prior work).
  • Namespace. Does a BaseStore namespace 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 / merge from the model by default, or expose everything and trust the framework?
  • One repo or many. packages/ here, or Nabzx/mnem-mcp + mnem-langgraph from the start?
  • Checkpointer. Confirm BaseCheckpointSaver is deferred, and name its trigger (someone wants a rewindable run).

Sources

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:

  1. What does the benchmark measure?
  2. What synthetic workload produces the runs?
  3. What are the baselines to compare against?
  4. What is the metric for each property, and its expected value?
  5. Where does the harness live, and does CI run it?
  6. 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:

PropertyThe claimMeasured as
Reconstructionstate_at(commit) is the exact memory of that momentover 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 precisionbisect finds the exact commit a belief went wrongplant 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 accuracyblame resolves a belief to the commit and observation that set itplant provenance on every write; blame every node at HEAD; report the fraction resolving to the correct introducing commit and the correct source
Merge correctnessa structural merge never loses a write or leaves a node in limbore-run the chaos harness (#49) at a large trial count and report totality, no-lost-writes, symmetry and convergence
Overheadversion control has a bounded, small costwrite 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 L commits. 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 the buggy_run.rs fixture, 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 querydictJSONLMnemosyne
current memoryyesyes (last line)yes
memory as of step tnoyes (line t)yes (state_at)
when did key X first become value Vnoyes, O(L) scanyes, O(log L) bisect
which observation set key X's current valuenono (no provenance)yes (blame)
what did step t changenoyes, diff two linesyes (changed_by)
merge two agents' memories, surfacing conflictsnonoyes
storage after L steps of a 100-key memoryO(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

MetricDefinitionExpectedFails the release if
reconstruction_exactstate_at == recorded over all commits, all seeds100.00%any seed is not exact
golden_bytes_stablethe frozen format vectors still hash identicallypassany drift
bisect_exactfound == k100.00%any miss on a monotonic planted fault
bisect_error_bitshistogram of |found - k|all zeroany non-zero
blame_commit_acccorrect introducing commit, linear history100.00%below 100
blame_commit_acc_mergecorrect origin commit when the value came via a merge100.00%below 100
blame_source_acccorrect source string100.00%below 100
merge_*the #49 invariants over a large sweepall holdany violation
write_ms_p50 / p99per-commit latency, 100-key memoryreport; expect single-digit ms p50regression vs the last published number by > 2x
read_ms_p50working-memory read latencyreportas above
bytes_per_step.mnem growth per commit, vs baseline Breport the multiple; expect « JSONLas 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 (or benches/, decided in the ADR), hand-rolled, seeded. CI runs it at a small trial count as an ordinary test; a MNEM_BENCH_* env override drives the large sweep.
  • The overhead-and-baseline comparison: benchmarks/overhead.py (a new top-level dir, like examples/). It builds a dict, a JSONL file and a .mnem store 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 of docs/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_memory is 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_step and read_ms numbers are the evidence for whether the flat State is still enough (ADR-0012's reassess trigger). The benchmark surfaces the number; it does not decide.
  • The v2 seam (#63, ADR-0018). Declaring the SemanticMerge trait 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.md and 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.md prose? 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 with cargo test) or benches/ (needs a bench harness, and cargo bench is not in CI today)?
  • The Python baseline dir. benchmarks/ at the top level, or a script under scripts/?
  • 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_step or read_ms threshold 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 extends
  • docs/chaos-report.md: the output format docs/benchmark.md follows

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.md reports two things. First, correctness: over thousands of seeded runs, reconstructing memory at any past commit is exact, bisect lands on the exact commit a wrong belief entered, blame names 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_version 1. Every 0.0.x release 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 SemanticMerge trait and a Store::merge_with entry 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.py and the README GIF: an agent is told a past answer was wrong, then uses bisect and blame to 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_version 1), now frozen for the 0.0.x line.
  • 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.
  • mnem does 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-memory speaks the Model Context Protocol over stdio. Any MCP-capable agent (Claude Desktop, an SDK client) can call tools: remember a fact (with where it came from), recall one or all, why did the agent conclude this, when_did a 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. MnemosyneStore is a drop-in BaseStore. Point a LangGraph agent at it and its long-term memory gains history: every write is a commit. Extra methods let a graph node branch to test a hunch, or ask why a memory says what it does.
  • mnem.agents in 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 to mnem-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, blame and bisect.
  • Still one on-disk format (format_version 1).

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 search is 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 plan tells you which commit last set the memory called plan, when, and — if the agent recorded it — which step of the run and which observation it came from. Like git 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 where plan became "pro". It does about log2(n) checks, not n, 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 blame on 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_version 1).

What it still does not do (next phases):

  • No bisect run <command> yet (handing bisect an arbitrary script). The built-in --node predicates 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 bisect makes.
  • 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 experiment takes everything that happened on the experiment branch 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 MemoryNode as 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_version 1). 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 merge asks 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 experiment makes a cheap copy of the current memory line. It costs almost nothing: a branch is just a pointer, like in Git.

  • Switching. mnem checkout experiment moves you onto that branch; mnem checkout main moves 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 diff shows what is different between two points: which memories were added, changed (old value then new value), or removed. mnem status is 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_version 1) that every 0.0.x release 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 init in a folder. It creates a hidden .mnem folder that holds everything, like .git does 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 log lists 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, then store = mnem.init(path), store.add(...), store.commit(...), store.log(). Same features, for agent code. Installs as mnemosyne-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.x series. 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 YC27 to mnemosyne.
  • Rust workspace: mnem-core (library, one placeholder function and a test) and mnem-cli (the mnem binary, --version and a declared command tree with every subcommand stubbed out). Rust CI (fmt, clippy -D warnings, test, build --release) green on main.
  • Python SDK skeleton: mnem exposes 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 on main.
  • CI: a rust job and a python job on every pull request and on push to main.
  • 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 the docs/format, docs/research, examples placeholders.
  • Issue and pull request templates, including the Wayfinder ticket template.
  • Licence: Apache 2.0.
  • Labels: phase-0 to phase-7, the wayfinder:* set, ready-for-agent, track-a / track-b, adr, format, benchmark, spike.
  • Milestones: one per roadmap phase (renumbered to v0.0.2 to v0.0.7 in 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_by dependency 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.1 pushed.
  • 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 an msrv CI job at Rust 1.85 (the floor set by clap's edition2024 transitive requirement).
  • ADR-0007, versioning and release policy: Accepted (#17). SemVer 2.0.0; format_version on its own integer track; additive format changes are a MINOR software release, breaking ones a MAJOR with mnem migrate; hand-written CHANGELOG.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.1 GitHub 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 for gh 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 --system fails, and setup-uv with python-version already creates the .venv, so a second uv venv clashes. Settled on: setup-uv with python-version, then uv pip install and uv run straight into that venv.
  • The initial commit on main shows a red CI run: it predates the Cargo and pyproject files. main has 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, ciborium plus 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

TicketModuleWhat
#22object, idObject (kind-tagged), MemoryNode, State, Commit, Provenance; ObjectId (BLAKE3, hex, CBOR byte string)
#23codec, objects, errorcanonical CBOR encode/decode; put/get/has over a redb txn; MnemError
#24store, configStore::init/open (upward discovery, nested-store guard), config parsing with the format_version gate
#25refs, headthe refs table with compare-and-swap; Head::{Attached,Detached}; Store::head/set_head/resolve_head
#26commit, stagingpersistent staging; Store::stage/staged/unstage/commit (one write txn per commit)
#27logStore::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.