All blogs
Architecture

When the same memory is written twice

Blind last-write-wins was never the right default. In v0.2.4 Memoir resolves a write collision by what kind of fact it is — events accumulate, facts consolidate by confidence, procedures merge, scratch memory is replaced. One typed merge against history, not an overwrite.

A coding agent learns you indent with tabs and writes it to preferences.coding.style. A week later it watches you reformat a file with spaces and writes that to the same path. Two facts, one key. What should happen?

Until v0.2.4, Memoir's answer was the laziest one possible: the second write clobbered the first. Last-write-wins, no questions asked. The classifier is deliberately write-blind — it never reads what's already there — so a collision was a blind overwrite. The only safety net was git history.

# the agent learns a preference, twice
memoir remember "use tabs"   -p preferences.coding.style
memoir remember "use spaces" -p preferences.coding.style

# before v0.2.4: the second write silently clobbers the first.
# the projected value is now "use spaces" — "use tabs" is gone
# from view (recoverable only by digging through git history).

A conflict is never resolved in general. How you resolve it depends on what kind of fact it is. A preference should consolidate to the current best answer. An event log should accumulate. A workflow should fold in the refinement. A scratchpad should just be replaced.

That's the whole idea behind the v0.2.4 conflict model. A write to an occupied key is no longer an overwrite — it's a typed merge against history, and the type is read straight off the taxonomy path.

Blind overwrite versus typed merge Before v0.2.4 a second write to a key clobbered the first. After, the write is resolved by the memory type into append, gate, merge, or replace. BEFORE — BLIND OVERWRITE write A "use tabs" write B "use spaces" key = B only A vanishes from view AFTER — TYPED MERGE "use tabs" "use spaces" two writes, one key 🟣 RESOLVE by memory type append · gate · merge · replace one outcome chosen by type nothing is lost from history
A collision used to be an overwrite. Now it's a merge whose rule is read off the path.

Step 1 — store facts, not a string

You can't merge what you've already thrown away. So the substrate changed first: a memory's content is no longer a single string but a list of timestamped facets (schema_version: 2). Each entry carries its own content, confidence, and a statusactive entries contribute to what you read, superseded ones are kept for the audit trail.

{
  // projected top-level — always present, legacy-compatible
  "content": "use spaces",
  "confidence": 1.0,
  "timestamp": 1718841600.0,
  "key": "preferences.coding.style",

  // the new facet layer (schema_version 2)
  "schema_version": 2,
  "entries": [
    { "content": "use tabs",   "confidence": 1.0, "status": "superseded" },
    { "content": "use spaces", "confidence": 1.0, "status": "active" }
  ]
}

The trick that makes this safe is the projection: the top-level content is a deterministic roll-up of the active entries, joined exactly the way the old append code did. It is byte-for-byte what readers saw before — so the 22+ existing readers and the entire web UI keep working untouched. A bare v1 blob is just a single implicit entry, upgraded lazily on its next write. No bulk migration, no flag day.

One rolled-up string at the top is the single lever that let the storage model change underneath without touching a single reader.

The facet model and its projection A list of timestamped entries, some active and some superseded; the active ones project up to the legacy top-level content field. entries[] "use tabs" conf 1.0 · 🪦 superseded "use spaces" conf 1.0 · ✓ active "2-space soft tabs" conf 0.92 · ✓ active project 🔵 TOP-LEVEL content active entries, rolled up what every legacy reader and the UI still sees superseded entries stay for blame — they just don't project
Active entries roll up to the legacy content field; superseded ones are kept, not deleted.

Step 2 — resolve by memory type

With facets in place, the resolver can do something smarter than overwrite. Cognitive science has long split memory into four kinds — working, episodic, semantic, procedural — and each one wants a different conflict rule. Memoir's taxonomy already encodes the type in the path's first segment, so the right strategy comes for free.

Memory type determines the default strategy Four rows mapping taxonomy roots to a memory type to a default conflict strategy: episodic appends, semantic gates on confidence, procedural LLM-merges, working replaces. TAXONOMY ROOT MEMORY TYPE DEFAULT STRATEGY experience.*metrics.code.* Episodic events / a log APPEND knowledge.* preferences.*profile.* context.project.* Semantic facts / preferences CONFIDENCE-GATED workflow.*behavior.* Procedural how-to / skills LLM-MERGE context.current.*metrics.turn.* Working transient scratch REPLACE Only episodic keys append by default — which is exactly why blobs stop growing without bound.
The path's first segment is the memory type, and the type picks the default strategy.

There are six strategies in all; the four above are the per-type defaults:

  • append — add a new active entry. Episodic logs should grow.
  • confidence_gated — the new fact wins only if it's at least as confident as what's there. Otherwise it's a no-op (no commit).
  • llm_merge — a small model (haiku) consolidates the old and new into one entry; the priors are superseded.
  • replace — drop the prior actives, keep the new one. Plain last-write-wins, now a deliberate choice.
  • merge_on_read — store like append, defer consolidation to read time.
  • reject — refuse the write and hand back a machine-readable conflict signal. This is what powers the interactive resolver.

Why confidence gates the facts that matter

Semantic memory — your preferences, project facts, what the agent knows about you — is where blind overwrite did the most damage. A high-confidence belief shouldn't be flattened by a low-confidence guess just because the guess came later. confidence_gated fixes that: the incoming entry only takes over if its confidence meets or beats the best active one.

The confidence gate An existing entry at confidence 0.92. A more confident incoming write passes and replaces; a less confident one is dropped as a no-op. GATE existing 0.92 incoming · conf 0.97 ✓ takes over · prior superseded incoming · conf 0.70 ⛔ no-op — nothing committed
A less-confident write can't silently overwrite a more-confident fact.

One subtlety worth stating, because it's the difference between a surprise and a feature: the gate only bites the classifier branch. When you supply a path yourself with -p, the write is hard-set to confidence 1.0 — so the gate always passes and you get replace-like behavior, exactly as before. The gate only changes what happens when an LLM-classified write (confidence < 1.0) lands on something it isn't sure it should replace.

Who decides — and how to override

The per-type default is the floor, not the law. Strategy selection follows a strict precedence, so a one-off never forces you to change a key's nature:

Strategy precedence An explicit flag overrides the environment variable, which overrides the per-type default. --merge-policyexplicit, per write wins over MEMOIR_MERGE_POLICYsession-wide env wins over per-type defaultfrom the L1 path
A flag beats the environment beats the type default. They never fight.
# one-off override for a single write
memoir remember "use spaces" -p preferences.coding.style --merge-policy append

# session-wide override
export MEMOIR_MERGE_POLICY=replace      # the global escape hatch
export MEMOIR_FACET_MAX_ENTRIES=20      # cap growth on append keys

The escape hatch matters most for the rollout: defaults flip to the per-type table in this release (semantic keys becoming confidence_gated is the headline change), and MEMOIR_MERGE_POLICY=replace restores the old wholesale last-write-wins behavior for anyone who wants it back. A MEMOIR_FACET_MAX_ENTRIES cap keeps append keys from growing without bound.

When you'd rather just look first

Automation is the default, but some conflicts deserve a human. The reject strategy turns a collision into a structured signal instead of a write, and the CLI's --interactive flag uses it to show you both sides before anything is committed.

$ memoir remember "use spaces" -p preferences.coding.style -i

  conflict on preferences.coding.style
  ─────────────────────────────────────
  existing  "use tabs"     conf 1.00   2d ago
  incoming  "use spaces"   conf 1.00   now
  ─────────────────────────────────────
  [k]eep  [r]eplace  [a]ppend  [m]erge  [s]kip ?

The service itself never prompts — it returns a ConflictInfo. The CLI renders it; an agent over MCP reads it and runs its own read-merge-write loop. Same contract, different front ends.

Still just git underneath

Every one of these strategies writes a commit. replace supersedes the prior entry but the old value is still in history; llm_merge tombstones what it folded in. Nothing is ever destroyed in place — which is the same promise the write path made from the start, now extended to the moment two writes disagree.

This is where "Git for AI memory" earns its name: a write to an occupied key is a typed merge against history, not a blind overwrite.

One boundary worth drawing: this is about two writes hitting the same key on the same branch. Reconciling two branches that remember differently is a related but separate problem — three-way merge is deliberately out of scope here. What v0.2.4 ships is the typed, per-key resolution that makes each memory type behave correctly by default, on the substrate that the prolly-tree already gives us.

What you get

  • Correct by default — events accumulate, facts consolidate, procedures merge, scratch is replaced. No per-call configuration.
  • No silent loss — a low-confidence guess can't flatten a high-confidence fact; superseded values stay for blame.
  • Bounded growth — only episodic keys append, with a cap, so blobs stop ballooning.
  • An override at every layer — flag, env, or the global escape hatch back to last-write-wins.

Read the conflict & merge theory

Last-write-wins was the bug. The fix was to ask what kind of fact it is — and let the answer pick the merge.