Skip to content

15 min read

Update erases history; append moves the conflict to read time

On this page

A resolution policy produces a verdict: this candidate supersedes that incumbent. What the store does with the verdict is a separate decision, and it is made once, in the write path, usually in an afternoon, and it determines for the life of the system whether anybody can ever answer the question "why did the agent say that?"

There are two things the write can do. It can change the incumbent row so that it now holds the new value, or it can add a new row and leave the old one in place. Both are one statement. Neither is obviously wrong. The difference only becomes visible months later, in an incident, which is why the choice is usually made on the basis of which one sounds safer.

What retrieval was handed

Here is what the retrieval step handed the model in the conversation that shipped the unit to the wrong country.

text
query: "where should we send the replacement unit?"
retrieved (k=5, cosine over user_31882's memories):

  0.884  mem_4a1f  "User lives in Berlin."                       written 2025-11-02
  0.861  mem_c93e  "User lives in Lisbon."                       written 2025-10-14
  0.802  mem_7d20  "User's office is in Berlin-Mitte."           written 2024-06-11
  0.774  mem_0e5b  "User prefers delivery to a parcel locker."   written 2025-08-30
  0.769  mem_b114  "User asked about EU shipping times."         written 2025-09-02

Two contradictory rows, both retrieved, both true at some point, no marking on either to say which the store believes now. A third row reinforces the wrong one because the customer's old office was also in Berlin. The model produced a fluent answer naming Berlin, and it was not being careless — three of the five retrieved memories said Berlin and none of them said the Lisbon row had replaced anything.

The write path had appended. That was the safe-sounding choice, and it was made for good reasons: nothing is ever lost, every observation survives, and the store can answer questions about history. Those reasons are all true, and they are all about the store rather than about the agent. What the same choice also did was hand the entire conflict to the retrieval layer, which resolves conflicts by cosine similarity, which is not a conflict resolution algorithm.

That is the belief worth naming, because it is the one that makes bare append feel responsible. "You never lose data" is a claim about the row count, and it is exactly true: no row was deleted, every observation is still there, and you can verify that at any time by counting. What varies, and what the claim says nothing about, is which of the retained rows the agent believes on a given turn — and that is decided by a similarity score against whatever wording the user happened to use. The store's integrity is constant and checkable. The agent's belief is neither, and the property that was checked is not the property that matters.

The two shapes a resolved conflict can take

Given the matched pair from chapter 2 — incumbent Berlin, candidate Lisbon, attribute home_city, cardinality 1, candidate observed later and from a more trusted source — there are two things the write can do to the store.

Update in place mutates the incumbent row:

sql
update memory_fact
   set value       = '"Lisbon"'::jsonb,
       source      = $1,
       observed_at = $2,
       written_at  = now(),
       confidence  = $3
 where subject_id  = $4
   and attribute   = 'home_city';

After that statement there is exactly one row for home_city, retrieval cannot return a contradiction because none exists, and the read path needs no logic at all. What is gone is everything else. Nobody can answer when the value changed, what it was before, which observation caused the change, or whether the change was correct.

Two of those losses are worse than they look. If the matcher fired wrongly — the false-positive case from chapter 2, where a trip to Lisbon overwrote a home city — there is nothing to roll back to, and no signal that anything was destroyed; the row simply now says something else. And you lose the ability to detect oscillation, which is one of the more useful diagnostics available: a value that flips between two states week after week is telling you either that your matcher is wrong or that the attribute's declared cardinality is wrong, and in-place updates make that pattern invisible because each update leaves the same single row behind whichever way it went.

Append writes a new row and leaves the old one alone. Everything is retained, the change is reconstructible, mistakes are reversible. The cost is that the store now contains two rows that cannot both be true, and something downstream has to know which one counts. If nothing does, you get the trace above.

The distinction that actually matters is therefore not between updating and appending. It is between an append that records the supersession relationship — an explicit link from the retired row to the newer one that replaced it, so the store says which value it currently believes — and an append that does not, because the second one is what people mean when they say "just append, you never lose data" and it is the design that produced this incident.

sql
create table memory_fact (
  id            uuid primary key,
  subject_id    uuid        not null,
  attribute     text        not null,
  value         jsonb       not null,
  source        jsonb       not null,
  observed_at   timestamptz not null,
  written_at    timestamptz not null default now(),
  confidence    real        not null,
  superseded_by uuid        references memory_fact(id),
  superseded_at timestamptz,
  retracted_at  timestamptz
);

-- one live row per single-valued slot
create unique index memory_fact_live_slot
    on memory_fact (subject_id, attribute)
 where superseded_by is null and retracted_at is null;

The write becomes two statements in one transaction: insert the new row, then point the incumbent at it. The read path filters on superseded_by is null, which is an index condition rather than a ranking heuristic, and history is a query away.

sql
-- what the agent believes now
select attribute, value, confidence, observed_at
  from memory_fact
 where subject_id = $1
   and superseded_by is null
   and retracted_at is null;

-- how home_city got here
  with recursive chain as (
       select * from memory_fact where id = $2
        union all
       select f.* from memory_fact f join chain c on f.id = c.superseded_by)
select value, source->>'kind' as src, observed_at, superseded_at from chain;

The partial unique index is doing quiet work, and the mechanism is worth following because it moves an error from one place to another rather than removing it. The index says that for a given subject and attribute at most one row may have a null superseded_by. So if the matcher misses a conflict and a second live row is inserted for a single-valued slot, the insert is a constraint violation rather than a subtle retrieval bug, and it fails loudly at write time instead of silently at read time three weeks later. It does not make the matcher better; it makes the matcher's failures observable, which is the more valuable of the two. For many-valued attributes you drop the index and accept a set, which is the structural consequence of cardinality from chapter 1 appearing in the schema.

The recursive query underneath is the second half of what the link buys, and it is worth reading rather than skipping as boilerplate. It starts from one row and repeatedly follows superseded_by forward, so each step moves from a retired value to the value that replaced it, and the result is the ordered story of one slot: what was believed, sourced from where, observed when, retired when. That is a different artefact from the first query, which answers only what is believed now. A store that can answer the first and not the second can operate an agent and cannot explain one.

State diagram: start → Live (write passes the filter from 10.11); Live → Superseded (a matched candidate wins resolution); Live → Retracted (user or system explicitly withdraws it); Live → Expired (retention policy elapses); Superseded → Live (supersession undone (bad match)); Expired → start; Retracted → start.

The Superseded --> Live edge exists because the resolution decision is made with less information than a later reviewer has, and being able to undo it is most of the argument for keeping the old row.

The Expired state in that diagram is the one this chapter does nothing with, and it is worth flagging as a placeholder rather than letting it read as complete. Supersession retires a value because a better one arrived. Expiry retires a value because enough time passed with nothing arriving at all, which is a different trigger with a different mechanism behind it, and chapter 5 is where it gets built. The reason it appears on the diagram now is that both edges leave the same state and a design that implements one and not the other has a Live row set that grows without any bound the schema can express.

There is a version of bare append that works, and seeing why it is hard to reach explains the incident. The read path would have to group the candidate memories by slot, keep the most recently observed member of each single-valued group, and drop the rest before assembling the prompt. Every clause in that sentence needs an attribute name, so the resolution that was skipped at write time has to be performed at read time on rows that were never keyed, against a latency budget measured in tens of milliseconds rather than the hundreds the write path could afford.

In practice teams substitute a recency bonus in the ranking function and call it resolved, and the trace above shows exactly how far that gets. mem_4a1f scored 0.884 and mem_c93e scored 0.861, so the Berlin row leads by 0.023 of similarity, and any recency bonus small enough not to disturb ordinary retrieval is smaller than that. Make it large enough to overturn 0.023 of similarity and the older row loses this time — but that gap is a property of this query's wording, not of the pair. Ask the same question differently and the gap moves, so a bonus calibrated on one trace is uncalibrated for the next, and a bonus that size then distorts every other retrieval on the system. A recency bonus raises the older row's odds of losing without ever making it lose reliably, and reliability is the entire requirement. Read-time resolution is a legitimate design; it is only legitimate when the reader has the same keys and metadata the writer had.

Retraction deserves its own column instead of being folded into supersession, because the two describe different events. Supersession says a newer value replaced this one, and the chain preserves an ordering. Retraction says this observation should never have been recorded — the extractor misread a hypothetical, the user was quoting somebody, a bad import wrote 40,000 rows. The consequence of the difference is in what the agent may read back: a retracted row has no successor and must not appear in the history the agent reads, because there is no true story it belongs to, while a superseded row is part of a story that may be worth telling. Keeping them in separate columns also means a bad batch can be withdrawn with one update statement filtered on source->>'batch', which is the repair that chapter 1's source metadata was there to enable.

Resolving at write time also has a latency argument behind it that is easy to state backwards. The write path is not on the user's turn — the previous chapter put the slot-matching model call at roughly a third of a second and noted that this is only tolerable because the write happens after the answer has gone out. So the write path has hundreds of milliseconds to spend and the read path has tens. Putting resolution where the budget is is not a preference; it is the only placement that lets resolution be a model call rather than a heuristic.

Weighing the three designs

Update in placeBare appendAppend + supersede
Rows per slot after 5 changes155, one live
Read pathdirect lookupmust rank contradictionsindexed filter
Extra read latency0ranking + larger k~0
"What did we believe in March?"unanswerableanswerableanswerable
Undo a bad resolutionimpossiblenothing to undo, but nothing resolvedflip two columns
Detect oscillationinvisiblevisiblevisible
Storage growthflatlinear in writeslinear in writes
Erasure request for a subjectone deletedelete by subjectdelete by subject
Where the conflict is decidedwrite timeread time, implicitlywrite time, explicitly

Compare the middle column against the right-hand one row by row and bare append never wins. It is worse on the read path, worse on read latency and worse on resolution, and it is identical on storage, on history, on oscillation and on erasure. That is a dominance rather than a trade: there is no workload on which bare append is the right answer over append-with-supersession, because the two costs people imagine they are choosing between — disk and complexity — are the same in both columns. The only thing bare append saves is the transaction that writes the link.

The storage row is the one people worry about and it is the one that matters least, and it is worth doing the arithmetic once so it stops being a worry. At 432,000 writes a day, a year is a little over a hundred and fifty million rows. At roughly 400 bytes of row that is about 63 GB of facts. The embedding is the expensive part: a 1,536-dimension vector at four bytes per component is 6 KB, and a hundred and fifty million of those is about 950 GB of vectors. That is real money on a managed vector store, and it is still small against the cost of an agent confidently using a retired value. Storage is not the argument for updating in place.

The argument that does hold for update-in-place is that many teams already have change data capture (CDC), a feed of every row mutation, streaming into a warehouse, in which case history exists, it is just not reachable by the agent. That is a genuine position and it changes the question: the choice is then about whether the agent needs to read its own history — to say "you told me Berlin in March and Lisbon in October, which should I use?" — rather than about whether the history is retained anywhere. If the agent never needs it, in-place update with CDC behind it is a defensible design that keeps the read path trivial.

Where the chain and the answer come apart

The proviso about chains crossing subjects is worth unpacking, because it is the one way that caveat turns into an incident. A supersession link is a foreign key from one row to another, and nothing in the column type restricts the target to the same subject_id. So a matcher that has confused two people — the entity-resolution failure the previous chapter set aside — can write a link that joins one person's history to another's, and from then on an erasure request for either subject either leaves rows behind or deletes rows belonging to somebody who did not ask. The enforcement is a constraint that a link's two endpoints share a subject, and it costs one check on a path that is already inside a transaction.

The chain reconstructs what the store believed and it does not reconstruct what the agent said, and those come apart wherever anything sits between them. A record can be live and never retrieved, retrieved and dropped for token budget, or retrieved and ignored. A reviewer holding a supersession chain knows which value was current on the day in question and still does not know whether that value reached the model, which is the gap that the read-side logging from the previous topic fills and the schema here does not.

The chain is also only as trustworthy as the matcher that built it. Every link in it was written because something concluded that two records occupy the same slot, and chapter 2 established that this conclusion is wrong at a measurable rate in both directions. A chain assembled from false matches is a tidy, queryable, auditable account of a history that did not happen, and it will read exactly like a correct one. The Superseded --> Live edge is what makes that recoverable, and recovering requires somebody to notice, which brings the oscillation diagnostic back into view as the cheapest thing available for noticing.

The superseded_by pointer is also where a great deal of prior art sits. MemGPT's self-editing memory, which popularised the idea of an agent managing its own memory pages, treats replacement and retention as an explicit operation the system performs rather than a side effect of storage, and reading it is a good corrective to thinking of memory as a bucket that only grows.

The assumption underneath all of it is the same one, and it is worth saying plainly before the next chapter takes it apart. Every design above assumes the resolution step has already concluded that the candidate supersedes the incumbent. Chapter 1 gave the metadata that makes that conclusion possible and chapter 2 gave the matching that produces the pair, and both were demonstrated on a case where the conclusion is easy: the user said, in words, that they moved.

Most contradictions do not arrive like that. They arrive as a single observation that is inconsistent with a stored fact and consistent with the stored fact still being true — the vegetarian who orders chicken once, the punctual customer who misses one meeting, the user whose preferred language flips for a single conversation because they were forwarding a colleague's message. Treating those as corrections and firing the supersession machinery at them is how a well-built store with perfect metadata and correct matching ends up believing things nobody ever said.

Memory — conflict and decay — read the current version at https://daviddominguez.dev/learn/en/memory-conflict-and-decay/update-erases-history-append-moves-the-conflict-to-read-time

Back to top