Skip to content

16 min read

Two values in conflict cannot be resolved from the values alone

On this page

An agent's memory is the durable set of facts an assistant keeps about a user between conversations so that the user does not have to repeat themselves — a shipping address, a plan tier, a preferred language, an allergy. Such a store goes wrong as time passes in two distinct ways, and both are the subject of this topic. The first is conflict, where a new fact arrives that cannot be true alongside one already on file; it announces itself, because somebody has said something that disagrees with the record. The second is decay, where a stored fact quietly stops being true and nothing ever arrives to contradict it, so the agent goes on offering a value that no longer matches the world. Any product whose assistant claims to remember you accumulates both, at the rate its users move house, change jobs, change plans and change their minds, and the two of them together are what this topic covers.

This chapter takes the first of those and asks what has to sit next to a fact for a contradiction to be decidable at all. The case that makes the question concrete is a pair of rows a hardware company's engineer pulled out of a store after a warranty replacement went to the wrong country:

json
[
  {"id": "mem_4a1f", "text": "User lives in Berlin."},
  {"id": "mem_c93e", "text": "User lives in Lisbon."}
]

Those two rows are a memory conflict: two stored facts that describe the same property of the same person and cannot both be true of that person now. Topic 10.11 settled which sentences are worth storing at all; what follows assumes the sentence passed that filter and now sits next to a neighbour it disagrees with.

Why nothing in the two strings can settle it

There is no procedure that reads those two objects and returns the right one. It is worth being precise about that claim, because "the model should be able to work it out" is the reflex and it is wrong for a reason that has nothing to do with model capability.

Whatever settles the conflict is some function that is handed the two records and returns one of them. Ask what that function has to look at. Both objects are well-formed assertions about the same property of the same person. Both are grammatical. Both were true at some point, so neither is false in the way a nonsense value would be. And the pair is symmetric: swap which row was written first and the two strings are unchanged. So any function whose inputs are only those two strings must return the same answer for a store where the user moved to Lisbon and for a store where the user moved to Berlin, since those two situations present it with identical inputs. A function that is right about one is necessarily wrong about the other.

That rules out the mechanisms people reach for in order. Sorting by primary key does not help when the keys are random UUIDs, since a random identifier carries no information about anything. Sorting by insertion order looks better and is the interesting failure: it encodes when a row was written, which is a different question from when the fact became true, and the two come apart more often than people expect. Handing both to the model and letting it choose produces an answer every time, usually the one that appears later in the prompt or reads as more specific, which is a behaviour rather than a decision — the same pair of records will produce a different winner when the prompt is assembled in a different order, and nothing in the system will report that as a change.

The fix is therefore not cleverer resolution logic. Nothing that operates on "User lives in Berlin." and "User lives in Lisbon." can succeed, because the information needed to choose was discarded at write time. The rest of this chapter is about what should have been kept.

The fields that make the question answerable

Ask a human colleague to adjudicate the two rows and they will ask three things before answering: when did we learn each of these, where did each come from, and how sure were we. Those map to fields, and a record carrying them looks like this.

json
{
  "id": "mem_c93e",
  "subject_id": "user_31882",
  "attribute": "home_city",
  "value": "Lisbon",
  "source": {"kind": "user_utterance", "channel": "chat", "turn_id": "t_77120"},
  "observed_at": "2025-10-14T09:31:00Z",
  "written_at": "2025-10-14T09:31:04Z",
  "confidence": 0.91,
  "evidence": "I moved to Lisbon last month, so update my shipping address."
}

The Berlin row from the same store, once the audit reconstructed it from logs, carried different metadata in a way that explains the incident precisely.

json
{
  "id": "mem_4a1f",
  "subject_id": "user_31882",
  "attribute": "home_city",
  "value": "Berlin",
  "source": {"kind": "crm_import", "batch": "sf_backfill_2025_11", "record": "0031x9"},
  "observed_at": "2023-03-02T00:00:00Z",
  "written_at": "2025-11-02T04:12:55Z",
  "confidence": 0.60,
  "evidence": "Salesforce contact field MailingCity"
}

Read the two written_at fields against the two observed_at fields and the shape of the trap is visible. Berlin was written three weeks after Lisbon, by a backfill job that loaded a CRM export whose contents were two and a half years old. Any policy that resolves conflicts by write order picks Berlin, correctly implemented, forever.

That is the failure worth dwelling on, because the belief behind it is a reasonable one. Write order is a genuine invariant: a database really does record rows in the order they arrived, the sequence is monotone, and nothing can perturb it. So it feels like solid ground. What varies, and what the invariant says nothing about, is whether write order tracks the order in which the facts became true. A store fed only by live conversation keeps the two aligned by accident, since a person says things in the order they happen. Add a single batch import and the alignment is gone, silently, for every row that import touches. The property that stays constant is being read as evidence about the property that moves.

This is why the record needs two timestamps rather than one. observed_at is when the fact was true or was asserted to be true, and written_at is when the row landed in the database. Temporal databases have carried this distinction for decades under the names valid time and transaction time, and SQL:2011 standardised both as application-time and system-time periods; the reason it survived into agent memory is that any system fed by both live conversation and batch imports will violate the assumption that later rows describe later facts.

Two fields in that record are doing jobs that are easy to conflate with each other, and the pair is what makes the whole scheme workable. value holds "Lisbon" — the thing itself, comparable against another value by equality — while evidence holds the sentence the value came from. Keeping them apart is what lets a machine decide the conflict and a human audit the decision: comparison operates on the value, and every question a person will ask afterwards ("did the user really say that?", "was this a hypothetical?") is answered from the evidence. A store that keeps only the sentence has to re-parse it every time it wants to compare anything, and a store that keeps only the value has nothing to show a user who disputes it.

source does more than order records. It carries a trust level, so that a value read from an authoritative system beats an inference drawn from behaviour, and a first-person statement beats a third-party mention. It also gives you a handle for repair: when the November backfill was identified as the culprit, the remediation was to delete every row whose source.batch matched that job, which is possible only because the batch identifier was stored. A store whose rows say nothing about where they came from cannot be repaired selectively, only truncated.

confidence is the weakest of the three and the most often stored as decoration. In the audit that followed this incident, 94% of rows in the store carried a confidence of either 0.9 or 1.0, because the extractor emitted a near-constant number and nothing downstream combined it with source trust. That distribution is the whole problem with the field as it was being used, and the reasoning is short: a field that takes essentially one value across the store carries almost no information, so no read path can distinguish anything by consulting it. Improving how the number is used cannot help while the number itself barely varies.

A confidence field earns its place when it is a function of things that actually vary — how hedged the utterance was, how reliable that source kind has been, how many independent observations support the value — and when the read path uses it. Treat it as an ordering rather than a probability, because extractor-reported certainty is poorly calibrated and a value of 0.7 does not mean the fact is true seven times out of ten.

FieldThe question it answersWhat breaks without it
source.kindHow much should I trust this?A CRM export outranks the user; an inference outranks a statement
source.batch / turn_idWhere exactly did this come from?Bad writes can only be fixed by wiping the store
observed_atWhen was this true?Backfills of old data overwrite fresh facts
written_atWhen did we record it?No way to reconstruct what the agent believed on a given day
confidenceHow sure were we?Every observation carries equal weight, including guesses

The right-hand column is the one to read, and it should be read as five separate incidents rather than as five degrees of the same one. Each row names a failure the other four fields cannot prevent: without source.kind you resolve in the wrong direction, without source.batch you can resolve correctly and still be unable to clean up, without observed_at you resolve backwards, without written_at you cannot explain a past answer to anyone, and without confidence a guess and a certainty are the same input. Adding four of the five leaves one whole class of incident open, which is why the fields are not a maturity ladder to be climbed in order.

Trust ordering across source kinds is worth writing down explicitly rather than leaving implicit in comparison code, and it is not a single ranking for the whole store. The reason follows from what "authoritative" means: a source is authoritative for an attribute when it owns that attribute, so that if it disagrees with any copy the copy is what is wrong, and ownership is a property of the pairing rather than of the source. For a shipping address, the billing system is authoritative and a user's chat message is a request to change it; for a dietary preference, the user is authoritative and any system record is a stale copy of something they once told a form. A workable default orders first-person statements above tool results above behavioural inference above third-party mentions, then overrides that order per attribute wherever a genuine system of record exists. The override matters because roughly a third of the conflicts in that store were between a live utterance and an import, and the correct winner depended on which field was in dispute.

One value, or several

Metadata tells you which record is newer and which came from a better source. It does not tell you whether the older one should stop being true, and that is a separate property belonging to the attribute rather than to either record. A person has one date of birth, so a second, different date of birth is a correction and the first value has to go. A person can have several active projects, so a second project name is an additional true value and deleting the first would be data loss dressed up as conflict resolution.

It is worth noticing what has to be true for that question to even arise. The two records must already be known to concern the same attribute of the same subject, which is what makes "should the older one stop being true" a meaningful question rather than a category error. Nothing in this chapter establishes that; it is assumed throughout, and the assumption is doing a great deal of work.

Cardinality is the property that decides which of those two situations you are in, and it has to be declared per attribute, because no algorithm can infer it from a single pair of disagreeing values. Lisbon versus Berlin and billing migration versus search reindex are structurally identical as pairs of strings; they differ only in what the attribute permits.

AttributeCardinalityWhat a second, different value means
date_of_birth1The earlier record was wrong. Correct it.
home_city1Either a correction or a move. Replace, keep the old as history.
employer1 in most designsA job change, unless the product serves contractors
primary_email1A change of address; the old one may still need to work
active_projectmanyA concurrent second value. Both true.
allergymanyA newly disclosed allergy. Removing one needs an explicit retraction.
dietary_preference1A change of standing preference, or a one-off (chapter 4)

Two rows of that table are doing something the others are not, and both are worth pausing on. date_of_birth and home_city share a cardinality and differ entirely in what the old value means afterwards: a second birth date says the first was a mistake, a second city says the first has stopped applying. Same number, opposite treatment of history. And employer carries "in most designs", which is the table admitting that cardinality is a design decision rather than a fact about people. Nothing in the world says a person has one employer; a product that serves contractors will find that its single-valued employer slot is destroying real data every time a client is mentioned. The requirement is to represent the user's working life correctly. Choosing one value for employer is a decision made about a population, and it is wrong for a population you did not have in mind.

The mistake this table is designed to prevent is applying replacement semantics to a many-valued attribute. It happens because the code path is convenient: you already wrote update-on-conflict for home_city, the matcher fires on active_project too, and now every time the user mentions a project the previous three quietly disappear. The user says "I'm heads-down on the billing migration this week" and the agent forgets that they also own the search reindex and the on-call rotation, because a statement about one value was read as a statement about the complete set.

That is the closed-world assumption leaking into a place it does not belong, and since the term is borrowed it is worth saying exactly what is being borrowed and where the loan fails. In a database, the set of rows is the whole truth, so a row's absence means the fact is false — an employee with no row in the on-call table is not on call. In a memory store, absence means nobody mentioned it, which is a completely different statement. The borrowed intuition breaks precisely at the point where a system built on it starts treating silence as denial. For many-valued attributes, a new observation adds; retraction requires a separate, explicit signal such as "we shipped the search reindex" or "I'm off the on-call rotation now". A system that cannot represent retraction separately from addition will either accumulate stale values forever or destroy live ones, and which failure you get depends entirely on that one line of policy.

Cardinality also constrains what "resolved" means for reading, and the two cases want different code rather than different parameters. For a cardinality-1 attribute there is exactly one correct value to put in front of the model, and a read path that returns two is broken regardless of how it ranks them, because the model will have to choose and choosing is the thing that was supposed to have happened already. For a many-valued attribute the read path returns a set, and its job is to filter out the retired members rather than to pick a winner. Those are different retrieval behaviours, and they cannot both be served by "return the top-k most similar rows" — that call returns a ranked list of some fixed length, which is the wrong shape for both.

The policy the metadata cannot choose for you

Source, timestamp, confidence and cardinality make the conflict decidable. They do not decide it. Newest-observed-wins on a cardinality-1 attribute with a first-person source is a policy, and a reasonable default, and it will still be wrong in the cases where the user misspoke, quoted somebody else, tested the agent, or had a colleague typing into their session. Each of those produces a first-person utterance with a recent observed_at and a high source trust, which is to say each one satisfies the policy exactly. The metadata narrows the space of defensible policies; the policy itself is a choice you make and then have to justify, which is the work of the next several chapters.

It is also worth being clear that the metadata is not free. Every field above has to be produced by something: observed_at requires the extractor to resolve relative expressions like "last month" against the message timestamp, source.kind requires the write path to know whether it is being called from a conversation turn or a batch job, and confidence requires a scoring rule that somebody maintains. Teams skip these fields for a reason that is hard to argue with at the time. At the moment the code is written there is only ever one row for a given fact, so every field that exists to compare two rows looks like speculative work with no consumer, and the cost of the omission arrives months later as an incident with no evidence trail.

None of this is the deduplication problem. Two rows saying "User lives in Lisbon" and "The user's home city is Lisbon" agree, and collapsing them is a storage and retrieval concern that 10.11 dealt with at write time. Conflict is the case where the values are incompatible under the attribute's cardinality, and the two problems want opposite treatments: duplicates should merge, conflicts must not.

The larger gap is that everything above assumes the two records were placed side by side in the first place. The audit found the Berlin and Lisbon rows in seconds because it queried where subject_id = ... and attribute = 'home_city', and that query only exists because the rows carried a subject and an attribute. The production store did not have those columns. It had free text and an embedding per row, no attribute names, therefore no cardinality to consult, and a write path that had never once observed a conflict because nothing in it ever compared two rows for the same slot. Before any of this metadata can be used, a candidate write has to find the record it would contradict.

Memory — conflict and decay — read the current version at https://daviddominguez.dev/learn/en/memory-conflict-and-decay/two-values-in-conflict-cannot-be-resolved-from-the-values-alone

Back to top