Skip to content

14 min read

Attribution needs write-time lineage, not a log search

On this page

Day forty-one of mem_7f3a91 opened with a question in an incident channel that sounded answerable: was this record written by an injected page, or did the user type it? Everything the store knew about the row was its text, its user_id, its kind, and created_at: 2026-04-02T09:41:07Z. The first attempt was the obvious one — search the trace store for the record's text, find the session, read what happened. It returned nothing, for two reasons that were independently sufficient.

The stored sentence was never spoken by anyone. Chapter 3 showed the extractor rewriting the payload into its own register, and the practical consequence lands here: the stored text and the source text shared no seven-word sequence, so a literal search over traces could not match, and a fuzzy search over 2.3 million transcript turns returned 340 candidates ranked by embedding distance, of which the top result was an unrelated conversation about enterprise pricing. That second failure is worth pausing on, because a semantic search is supposed to survive a paraphrase. It did not survive this one because the two texts were not paraphrases of each other in the relevant sense — the payload was a note addressed to assistants and the record was a claim about a person, and chapter 3 measured their similarity on the same model the retriever uses at well under the store's own floor. A ranked list is only useful when the right answer ranks; at that distance the right answer is somewhere in the tail of 340 results, indistinguishable from noise.

The second reason was more prosaic. Traces were retained for thirty days and the write was forty-one days old, so even a search that could have matched had nothing left to match against.

Those two failures are independent, and noticing that is what stops a team from fixing the wrong one. Extending trace retention to a year leaves the paraphrase problem exactly where it was — you would have more turns to search and still no string in common. Solving the paraphrase problem with better search leaves the retention problem exactly where it was, since the sessions are gone. Both would have to be fixed, and only one of them is a knob. The thing that fixes both at once is not a search at all.

What a timestamp alone supports

It is worth being precise about what remains provable in that state, because the honest inventory is short and stating it is the difference between an investigation and a guess.

You can establish that a row exists with that text and that it was created at 09:41:07 UTC on 2 April. If session records outlive traces — they often do, being small — you can establish which sessions that user had open around that minute, and if the extractor runs at session end you can usually narrow it to one. From there everything is inference. You cannot establish which turn produced the candidate, whether the candidate arrived in a user turn or a tool result, which URL or attachment was involved, whether the write passed through a policy check or bypassed one, what the candidate looked like before it was stored, or whether the same source produced records on other accounts. You also cannot rule out that the user typed it, which matters, because the remediation for a self-inflicted preference and the remediation for a hostile page are different and one of them involves telling customers.

The most expensive gap is the last one. Without a link from the record back to a source, every poisoned record is a singleton, and you are left deleting one row at a time and hoping the campaign was a campaign of one. There is no way to promote a singleton into a population after the fact, because the relation that would group them — these rows came from the same bytes — was a fact about an event that has finished happening and left nothing behind.

The record to write at write time

The information that answers all of those questions is cheap to capture and impossible to reconstruct. It has to be written in the same transaction as the memory row, in an append-only table, because a lineage record that can be lost independently of the memory it describes will be lost exactly when it matters.

That last clause is a claim about failure modes rather than a preference, and it is worth walking. Two separate writes can fail in three ways: both succeed, the memory write fails, or the lineage write fails. The third case leaves a memory row with no lineage, which is precisely the state the whole design exists to prevent. It is also the case that becomes more likely under load, during a partial outage, or when a retry storm is running — the conditions that coincide with an attack. Putting both writes in one transaction removes the third case by construction, so the invariant "every memory row has a lineage row" holds without anyone monitoring it.

json
{
  "write_id": "wr_01HZ8K3M",
  "memory_id": "mem_7f3a91",
  "subject_user_id": "usr_20481",
  "actor_user_id": "usr_20481",
  "session_id": "ses_4b19c0",
  "turn_index": 6,
  "origin": "tool_result",
  "tool_call_id": "toolu_01Wq8Z",
  "tool_name": "fetch_url",
  "source_uri": "https://status.vendor-example.net/status",
  "source_sha256": "9f2c1e...c7a4",
  "source_bytes": 1940,
  "extractor": {"model": "claude-sonnet-4-6", "prompt_version": "extract-v7"},
  "candidate_sha256": "41ab90...d3e1",
  "stored_sha256": "41ab90...d3e1",
  "policy": {"version": "mem-policy-2026-03-18", "slot": "freeform_note",
             "decision": "admit", "rule_hits": ["length_ok", "no_secret_match"],
             "classifier": {"instruction_shaped": false, "score": 0.19}},
  "admitted_at": "2026-04-02T09:41:07.221Z"
}

Four of those fields carry most of the weight. origin distinguishes a sentence the user typed from one a server returned, which is the question the incident channel actually asked and which no amount of later analysis recovers. source_sha256 is the hash of the exact bytes the tool returned, which is what makes two records from the same payload recognisably related even after the extractor has paraphrased both differently. candidate_sha256 alongside stored_sha256 records whether anything modified the text between the gate and the database, so a mismatch tells you a component you had forgotten about is rewriting candidates. And policy.version with rule_hits tells you which ruleset was in force, which is how you answer the follow-up question — whether the write would still be admitted today — without speculating.

The hash is doing the job the failed searches could not, and the reason is that it is computed over something the extractor never touched. A paraphrase changes every word of a candidate and changes nothing about the bytes that were fetched, so two records the extractor rewrote in two different directions still carry the same source_sha256. The grouping relation is therefore preserved through exactly the transformation that destroyed the lexical and semantic ones. source_bytes of 1940 matches the status page from chapter 3, which is the sort of cross-check that costs one integer and settles an argument.

The schema is unremarkable, and the constraints are the part to get right:

sql
CREATE TABLE memory_write_log (
  write_id          text PRIMARY KEY,
  memory_id         text NOT NULL,
  subject_user_id   text NOT NULL,
  actor_user_id     text NOT NULL,
  session_id        text NOT NULL,
  turn_index        int  NOT NULL,
  origin            text NOT NULL CHECK (origin IN ('user_turn','tool_result','agent_inference')),
  tool_call_id      text,
  source_uri        text,
  source_sha256     bytea,
  candidate_sha256  bytea NOT NULL,
  stored_sha256     bytea NOT NULL,
  policy_version    text NOT NULL,
  decision          text NOT NULL CHECK (decision IN ('admit','reject','quarantine')),
  rule_hits         jsonb NOT NULL DEFAULT '[]',
  admitted_at       timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX ON memory_write_log (source_sha256) WHERE source_sha256 IS NOT NULL;
CREATE INDEX ON memory_write_log (subject_user_id, admitted_at DESC);

The two CHECK constraints are what make origin and decision usable as evidence rather than as free text, since a column that can hold anything eventually holds several spellings of the same thing and no query over it is trustworthy. The partial index on source_sha256 skips the rows where it is null — every candidate that came from a user turn — which keeps the index small while covering exactly the lookups the scenario below depends on.

Rejections go in the same table, which is the detail teams skip because rejected writes produce no memory row and feel like nothing happened. They are the highest-signal rows in the system. The reason is structural: a rejected candidate leaves no trace anywhere else in the product. There is no memory row, nothing enters the index, nothing appears in a profile screen, and the session trace shows an ordinary conversation. If the write log does not record it, the attempt did not happen as far as every other system is concerned. A host that generates forty rejected candidates across nine accounts in an afternoon is visible in this table on the day it happens, and invisible everywhere else.

What the lineage lets you conclude

QuestionRecord plus timestampWith the write log
Which session produced it?Inferred from session times, usually one candidatesession_id, exactly
Did a human type it?Unknowableorigin, recorded at write time
What was the source?Unknowablesource_uri plus the hash of the bytes
Was the text altered before storage?UnknowableCompare candidate_sha256 with stored_sha256
Which policy admitted it?Unknowable; the policy has changed sincepolicy_version and rule_hits
How many other records share the source?Requires reading every rowOne indexed query on source_sha256
Can it be revoked in bulk?NoYes, by source, in one statement
Would today's policy admit it?SpeculationReplay the candidate hash against the current ruleset

The middle column says "unknowable" five times and "inferred" once, and that distribution is the argument rather than the table's decoration. These are not questions that are expensive to answer without the log; they are questions with no answer, because the information was never written down. The gap between the two columns is therefore not a cost saving but a change in what an investigation is able to say at all, and the two rows where the middle column offers something — the session, inferred from timestamps — are the two where a second system happened to keep a record for its own reasons.

The sixth row is where the operational value concentrates. When the vendor status page was finally identified by hand, a query against a store that did have lineage on a comparable incident found the same source_sha256 behind 1,412 memory rows across 380 distinct users, written over nine weeks. Finding those by inspection would have meant reviewing 2.3 million rows; finding them by text search would have failed for the paraphrase reason. Revocation became a statement rather than a project:

sql
UPDATE agent_memory m
   SET revoked_at = now(), revoked_reason = 'source 9f2c1e poisoned'
  FROM memory_write_log w
 WHERE w.memory_id = m.id
   AND w.source_sha256 = decode('9f2c1e...c7a4', 'hex')
   AND m.revoked_at IS NULL;

Detection, not just forensics

Lineage that is only read after an incident is worth less than lineage that is queried on a schedule, and the signals are strong enough to alert on directly.

A single source_sha256 producing admitted writes for more than three distinct subject_user_id values is close to conclusive, and the reasoning behind the threshold is what makes it usable. A genuine personal fact about a user does not arrive from a page that also describes four other people, because a fact about a person originates with that person or with a system of record about them, and neither produces one document that yields profile claims for a spread of unrelated accounts. So the pattern has essentially no legitimate generator, which is what the base rate confirms: in six months of the store's history the legitimate rate of that pattern was two occurrences, both from an internal onboarding document. Two false positives per six months is the cost of the alert, which is why it can page someone.

Writes with origin = 'tool_result' landing in high-risk slots are the pattern chapter 4 refuses outright, so any that appear indicate a code path that bypassed the gate. That is a different kind of signal — not evidence of an attack but evidence that a control is not where you think it is, which is the harder thing to discover by any other means. A rise in decision = 'reject' clustered on one source_uri is an attack in progress rather than a completed one, and it is the only signal in this list that arrives early enough to matter. That is the operational payoff of logging rejections at all.

None of this proves intent. A hash tells you two records came from the same bytes; it does not tell you whether the author meant harm, and a page can be poisoned by someone other than its owner. Attribution in the legal sense is not what the write log delivers, and claiming otherwise in an incident report will not survive review. What it delivers is causal reachability — this record came from those bytes, through that tool call, admitted by that rule — which is what remediation needs.

Two limits are worth planning for. A hash matches exact bytes only, so a page that changes daily produces a new hash daily and the grouping fragments; a single-character edit produces a completely different digest, which is the property that makes hashes useful for integrity and useless for similarity. This is the reason chapter 3 insisted on storing the payload itself rather than a URL, since the stored bytes are what a later hash can be compared against. And the write log has to outlive the memory it describes, plus whatever your realistic detection lag is. Setting the log's retention to the memory TTL, the time-to-live after which a stored record expires on its own, plus ninety days is a defensible default, and it is a decision to make deliberately rather than inherit from a table that was created without one. The log is also a privacy-relevant dataset in its own right, holding source URLs and session identifiers under the same access rules topic 10.13 established for the store itself.

Retrofitting onto a store that has none

Everything above describes writes from the day you deploy it, and it does nothing for the rows already sitting in the table. That is the position most teams are in when they first read an incident report, and there is no recovering the lineage of a record written eight months ago; the information was never captured and no amount of analysis conjures it. The only honest options are to treat the existing rows as a population with unknown provenance and decide what to do about the population as a whole.

Three moves are available, and they can be ranked by how much retrieval quality they cost. The cheapest is to re-score every stored row against the current policy and delete what would not be admitted today, which on this store meant running 2.3 million rows through the schema validator in a batch job of about forty minutes and removing 3,100 that no longer fit a slot. The next is to delete the free-text rows outright, since those are the ones carrying the risk that structured slots do not: 61,000 of the 2.3 million were free text, and dropping them reduced retrieval hit rate by 0.9 percentage points, which turned out to be an easier conversation than expected once the number was on the table. The most aggressive is to expire everything older than the date the write log went live and let the store rebuild itself, which costs the most personalisation and is the only option that gives you a table where every surviving row is attributable.

The three are ordered the same way on both axes, which is what makes the choice a straight trade rather than a puzzle: each costs more retrieval quality than the one above it and leaves less unattributable residue. The first removes rows that are demonstrably out of policy and leaves the rest unknown. The second removes the class where the risk concentrates and leaves the typed slots unknown. Only the third leaves nothing unknown, and it is the only one that costs a measurable amount of what the feature was for.

Whichever you pick, mark the boundary. A lineage_from timestamp recorded once, somewhere the investigation will find it, prevents the next incident from spending two days establishing that rows before a certain date have no write log because the feature did not exist rather than because someone deleted the evidence.

Write-time lineage is not a defence. It changes nothing about whether the poisoned record gets in; it changes what happens afterwards, which is why it belongs in the same pipeline as the controls that do prevent things rather than in the observability stack. That placement is worth being literal about, because the two stacks have different guarantees and the difference is what the first section of this chapter ran into. Traces are sampled, retained on an operational schedule and keyed by session; the write log is complete, keyed by record, and retained on a forensic schedule. Sampling and a thirty-day clock are correct settings for debugging a request that failed an hour ago and wrong for a record whose harm surfaces after six weeks. A candidate that arrives at the gate without a populated lineage record is not a candidate that should be evaluated at all, which makes provenance the first layer of the write path rather than an accessory to it.

Memory — as an attack surface — read the current version at https://daviddominguez.dev/learn/en/memory-as-an-attack-surface/attribution-needs-write-time-lineage-not-a-log-search

Back to top