Essay · Agent Security

You Found the Compromise. When Did It Start?

Detection is not recovery. After a memory compromise the useful question is not how fast you caught it — it is how much of what the agent learned you can prove was never touched. Chronology is not lineage and similarity is not lineage; only recorded provenance, parent edges, buys recovery back.

Published September 21, 2026 Topic Agent memory provenance & forensics

I admitted one untrusted document to an agent's memory in session 2. I did not start the investigation there. The scenario begins it later, at action A08, with M11 identified as the suspicious memory in that decision context. By then the belief had travelled. The original entry, M04, was still on disk, but it had three descendants, and six of the run's eleven actions had been taken with the root or one of its descendants in their decision context. The investigation was handed an effect. It said nothing about the cause.

So I asked the question that decides whether an agent incident is a contained event or an open-ended one. When did this start, and how much of what the agent had learned could I prove was clean. This is the companion problem to stopping an agent. That one asks what is still running, across components, in space. This one asks what history you can prove, across sessions, in time. I built a harness to measure it, the way I built one to watch execution outlive a kill.

Detection is not recovery

From the caught descendant M11, a responder with only ordinary forensic artifacts, timestamps, contents, action logs, and semantic search, can nominate suspects. It cannot defend a clean state. Those are different capabilities, and the gap between them is the whole essay.

The metric that measures the second one is collateral discard: the clean entries you have to discard under the recovery rule to be rid of the poison, over all the clean entries there were. A low number is not automatically a good one, for a reason the run makes concrete later. In this run there were thirteen clean entries. The metadata responder had to discard ten of them. Not because they were suspect, but because its rule could not clear them. The only entries it could keep were the three written before its clean cutoff, and that cutoff is a heuristic, not a proof. In this run it happened to land on the actual root. The other ten clean entries went with the poison. Its recovery number was 0.7692.

That is not a failure to find the entries. Its memory recall was 0.7500. It found most of the in-scope entries. It simply could not tell which of the survivors were safe to keep, and finding the suspects does not answer that.

The provenance gradient

The same run, scored against four levels of retained evidence, projected from one canonical history so the only variable is what the responder can see:

evidence retained collateral discard
current values only 1.0000
timestamps, contents, action logs, semantic search 0.7692
the above plus parent edges 0.1538
the above plus source trust class 0.0000

Each increment of recorded provenance buys back recovery. The rung to lead with is the third, because it needs nothing but the graph. Parent edges alone, with no trust labels at all, cut the collateral discard from 0.7692 to 0.1538, a factor of five, by proving which entries fall outside the descendant closure of either candidate root and keeping them. The caught entry was a merge of a poisoned parent and a clean one, so walking up from it reaches two roots, and the top rung uses trust_class to choose between them and clear the last two entries. That rung buys the final 0.1538, but it assumes trust metadata is present and correct, so I lead with edges alone and treat trust as the refinement it is.

Chronology is not lineage, and neither is similarity

The reason the gradient looks like that is a boundary on what you can actually prove. You cannot prove which stored belief motivated an action. That would mean reconstructing the model's reasoning, and you cannot instrument that. What you can prove is when an entry was written, which entries were retrieved into a decision's context, and which entries descend from which by recorded parents. Scope is built from those, not from a claim about what the model was thinking.

Metadata preserves write time and, where decision-context telemetry exists, which entries were retrieved. What it does not preserve is descent. The best its rule can defend keeping is everything written before its cutoff, which is why it kept three entries and discarded ten. But predating the cutoff is not the same as being untouched by the poison. Ten clean entries were written after the root and had nothing to do with it, and only the graph could prove that, because only the graph records descent. Chronology is not lineage.

Similarity is not lineage either, and this run showed it twice. Under the frozen embedder, beyond the already-caught M11, primary similarity search recovered no additional in-scope entries. Of the entries that cleared the threshold from the caught descendant, the only ones above it besides M11 itself were two clean entries, M07 at 0.6967 and M10 at 0.6298. The root sat at 0.4710, the diverged descendant at 0.5219, a near paraphrase at 0.4482, all below the line. Search ranked the clean entries above the poisoned ones. The additional recall came from session expansion, which is temporal locality, not meaning.

The obvious objection is that I picked a weak embedder, so I swept the threshold and swapped the model. Across thresholds from 0.20 to 0.95 the collateral discard never fell below 0.6154 while recall swung from 1.0 to 0.5. Then I re-ran under a conventional transformer bi-encoder, thresholds re-derived by the same rule on the same independent corpus. It fixed the ranking. The root and a real descendant rose above the line, the two clean entries fell below it. Candidate identification got genuinely better. The collateral discard was identical at 0.7692. A better embedder moved detection and did nothing for recovery, because finding the root does not expand the set you can prove clean. Only edges do that.

The separation, made unarguable

If recovery is bounded by evidence rather than by search quality, then something that changes only when the poison sits in time should move metadata's recovery and leave the graph untouched. It does.

Hold every memory text and graph relationship fixed and slide the poisoned subgraph through a twenty-session horizon. The similarity structure never moves, because no text changes. Only the poison's position in time does.

plant session metadata discard metadata recall edges-only discard edges+trust discard
S01 1.0000 0.7500 0.1538 0.0000
S03 0.8462 0.7500 0.1538 0.0000
S05 0.6923 0.7500 0.1538 0.0000
S07 0.6154 0.7500 0.1538 0.0000
S09 0.5385 0.7500 0.1538 0.0000
S11 0.4615 0.7500 0.1538 0.0000
S13 0.3846 0.7500 0.1538 0.0000

Metadata's recovery swings by a factor of 2.6 on plant position alone, from 1.0000 when the poison lands first to 0.3846 when it lands late, because all it can defend as clean is whatever happened to precede its cutoff. Its memory-scope recall stays flat at 0.7500 the whole way. The lineage number does not move at all, at either rung, because reachability from the root does not care when the root was planted. One variable swings metadata's recovery by more than half and never touches its detection, and the thing that stays still is the thing built on edges.

The trap in the headline metric

Then the sweep found something the design did not predict, and it is the most important result in the run.

Metadata recovered the root in the canonical run only because the first consolidation landed close enough for session expansion to reach back to it. Hold the plant fixed and widen that gap. At a gap of three the responder loses the root, and rule B's clean cutoff moves to whichever entry similarity nominated earliest. That entry is a clean bystander with no relationship to the compromise at all. The boundary of the defensible-clean set becomes anchored to noise. And because the root is now outside the declared set, the recovery policy clears the root itself as defensible-clean. At a gap of four the collateral discard improves to 0.5385, its best number on that axis, at the exact moment the policy is retaining the poison.

The headline metric flattered the run when containment had failed. That is why the harness carries unsound_clean_count, the number of in-scope entries a view cleared as clean. A lower discard bought by clearing poisoned state is a worse outcome, not a better one, and without that guard the table would have read the improvement as a win. It generalizes past this harness: a recovery-cost metric without a soundness guard can reward you for keeping poisoned state. Lineage cleared zero in-scope entries at every point on both axes. Its number cannot be gamed this way, because it discards by descent and the root always descends from itself.

What provenance cannot fix

Memory provenance is necessary and it is not sufficient, and the run says so precisely. Lineage reconstructed the in-scope memory perfectly, four of four, and still reached only four of the six in-scope actions. The two it missed were taken before decision-context logging began, so no projection could see what memory was in front of the agent when it acted. Against the actions any view could see, lineage was four of four. Ancestry tells you how persisted state descends. Only action telemetry tells you what state was presented when the system decided. A team that records the first and not the second can prove the state and still not prove the act.

Why the numbers can be trusted

Two things kept this honest. The integrity gate caught a defect in my own fixture before anything was scored, a decision context that retrieved a memory twenty-five minutes before that memory was written. It aborted rather than producing numbers, I fixed the fixture, re-registered, and nothing had been scored against the broken version. And the two thresholds were never allowed to see the fixture. They were calibrated on an independent sixty-pair corpus in a different subject area, with ties broken toward the metadata responder, the view the essay argues against. The scenario fixture and the calibration corpus were SHA-256 hashed before the run, the embedding weights and tokenizer were fingerprinted, and the derived thresholds were recorded in the manifest. The harness refuses to score if the fixture or the model fingerprint changes. Freezing a number answers "were these tuned" with a promise. Calibrating off the fixture and hashing it answers with a method.

The pair

Two halves of one forensic requirement, on two axes. Containment lineage asks what work descended from this task, across components, in space, at the moment you act. Memory provenance asks what state descended from this input and which later decisions consumed it, across sessions, in time, after you have found the effect. Both are unanswerable without a thread the system had to lay down before the incident, not during it. One threads identity across handoffs. The other threads descent across time.

Close

The useful measure after a memory compromise is not how fast you detected it. It is how much of what the agent learned you can prove was never touched, and whether the number you use to say so can tell the difference between a clean recovery and a retained poison. In this run, ordinary metadata's recovery rule kept three entries out of thirteen and, in one configuration, improved its own score by keeping the root. Edges alone established eleven of thirteen as outside the candidate-root closure using nothing but reachability, and could not be flattered into keeping the poison at all. Which of those you get was decided before the incident, by whether the store wrote down where its entries came from and what descended from what.