← Back to Guides
5

Series

Building Real-World Agentic AI Systems with LangGraph· Part 5

GuideFor: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

LangGraph Compaction Deletes the View, Not the Record

Your summarizer removed the message from state. Eighteen checkpoints on disk still have it, and an ordinary fork puts it back.

#langgraph#agent-memory#context-engineering#data-retention#checkpointing

I wrote the erasure routine before I ran the measurement. It called update_state with a RemoveMessage, confirmed the thread's state was clean, and returned success. It was correct code.

Then I counted the rows. Nothing had moved. Not one. The object my routine cleared had been emptied by the summarizer six turns earlier, and twenty stored locations still held the value I was asked to destroy.

None of that is a failure. The summarization middleware did exactly what it advertises: it removed the message from state["messages"] and replaced the span with a summary. The checkpointer did exactly what it advertises: it wrote an immutable snapshot at every superstep and kept them all. Both components are correct. The composition is what nobody specified.

This is Part 5 of a series on building agent systems with LangGraph. Part 4 established the rollback surface: the set of things a checkpointer restores. That article showed a bound coming back to full because its spent-condition lived inside the surface. This one is about the same surface running through your memory architecture: one property, producing an undeletable transcript on one side of it and an assertion that outlives its evidence on the other.

The thesis: LangGraph's rollback surface is your retention policy

LangGraph has two memory systems. Graph state is checkpointed, versioned, and restorable. The BaseStore is cross-thread, unversioned, and untouched by any thread operation. The boundary between them was chosen for durability reasons, and it is documented as a scope boundary - short-term versus long-term, thread-scoped versus cross-thread.

That boundary is also, silently, your data-retention policy - and nobody chose it for that.

One mechanism, two harms. Inside the surface, a deletion your application performs is recovered on any branch taken from an earlier checkpoint. Outside it, a write survives the branch that abandons the transcript which justified making it. "Restores everything inside, touches nothing outside" is the definition of a rollback surface, so this is not two findings; it is one property producing an undeletable transcript on one side and an assertion that outlives its evidence on the other.

I call this Retention Inversion. Nothing about it is specific to LangGraph, so here it is stated without reference to LangGraph at all:

A system exhibits Retention Inversion when its recovery mechanism restores state to a prior point, a secondary store sits outside that mechanism's reach, and the deletion primitive available to the application acts only on state inside the recovery boundary. For data carrying an erasure obligation, both consequences then run against the operator: deletions inside the boundary are impermanent, and writes outside it are permanent.

That qualifier is doing real work, so I want to be precise about it. The boundary is correct for the job it was designed for. Scoping short-term state to a thread and long-term memory across threads is the right split, and restoring everything inside the boundary is what a rollback surface means. The inversion is not that the defaults are wrong in general. It is that the same boundary silently becomes your retention policy, a job nobody designed it for, and for the one class of data you may be compelled to destroy it works against you from both sides.

Defining it that way is deliberate. It follows what Part 2 did with Order-Coupled State - define the property framework-neutrally so the term survives a change to the implementation that revealed it. LangGraph could ship a namespace sweep next month and the shape would still describe Temporal workflows with an external database, event-sourced systems with a projection store, or any agent runtime that pairs snapshots with a side cache.

Three questions test any system for it, and none of them require reading the framework's source:

  1. What object does your deletion primitive actually act on? Not what it is named - what it mutates.
  2. Is that object inside the recovery boundary? If yes, any restore undoes the deletion.
  3. Is there a second store outside the boundary that your deletion never reaches? If yes, it accumulates content no thread operation can remove.

Answer those for LangGraph. The current value of one channel. Yes. And yes, the BaseStore. Answer them for your own stack before assuming it differs.

The consensus belief this challenges is narrow and specific. Compaction is discussed as a cost mechanism, and since mid-2026 as a safety hazard. Both framings assume it removes things. It removes them from one place.

That the conflation is real and not a strawman is easiest to see in LangGraph's own issue tracker: issue #5112 is titled "RemoveMessage Does Not Remove Messages from State". The reporter's confusion there is about live state rather than history, so it is not this bug - but it is the same wrong mental model of what a message deletion reaches. Treating "the model can no longer see it" as "it is no longer there" is the mistake, and the documentation does nothing to correct it.

What the compaction literature already covers

I need to be precise about the ground here, because this area got crowded fast in 2026 and most of the adjacent claims are already taken.

Shiyang Chen's Governance Decay (arXiv:2606.22528, June 2026) owns the loss direction. In-context governance constraints that an agent reliably obeys while visible get silently dropped by compaction, and the agent then performs prohibited actions. Across 1,323 episodes, violation rises from 0% with the policy in full context to 30% after compaction, reaching 59% on the worst model. When the constraint survives the summary, violation stays at 0%; when it is dropped, violation reaches 38%.

That paper tested LangGraph directly. Section 8 reports that in a LangGraph StateGraph with a summarization-memory node, violation rises from 0% to 65% on DeepSeek-V4 across 20 episodes. LangMem's SummarizationNode reached 95%. One scoping note: that LangGraph figure comes from a 20-episode sub-experiment, which is small next to the paper's 1,323-episode main result. The direction is unambiguous and the number is not mine to argue with.

So compaction dropping load-bearing content is settled. This article is about the other direction. Same mechanism, opposite harms. Chen measures what leaves the context window. I am measuring what does not leave the disk.

The security literature owns the adversarial case. ACRFence (arXiv:2603.20625) named Authority Resurrection, where a restore brings back a consumed single-use token. Part 4 conceded that paper in full and positioned itself as the non-adversarial case. This article stands in the same relation: ACRFence's attack classes both need an adversary - someone to trigger the restore and reuse the credential. Nothing here requires one.

That distinction has a name in the survey literature. The MemTensor group's survey of long-term memory security (arXiv:2604.16548v1, April 2026) puts it directly:

A fourth property, weaker in tone but likely broader in real-world prevalence, is that persistent memory can fail without any adversary at all.

The same paper states that "confidentiality, availability, store/forget, and benign-persistence failures remain sparsely studied," and that "write-gate validation and post-deletion verification are shared blind spots across every system examined." One citation note: version 2 of that paper, from June 2026, was retitled and reduced its governance primitives from nine to five. The passages above are v1. Cite the version.

The strongest empirical support for benign persistence causing real harm is arXiv:2604.01350, which defines unintentional cross-user contamination as arising from "benign interactions whose scope-bound artifacts persist and are later misapplied," and reports contamination rates of 57-71% under raw shared state with no attacker present.

What none of these cover is the runtime mechanic: which component holds the bytes after your application deletes them, and which API can remove them.

The setup that makes the mistake available

Here is the pattern almost every LangGraph memory tutorial ships, and it is in the framework's own middleware. Atlas is the customer-support agent this series has been building since Part 1.

code
from langchain.agents import create_agentfrom langchain.agents.middleware import SummarizationMiddlewarefrom langgraph.checkpoint.sqlite import SqliteSaverwith SqliteSaver.from_conn_string("atlas.sqlite") as saver:    atlas = create_agent(        model=model,        tools=[lookup_ticket, check_entitlement, issue_refund],        middleware=[            SummarizationMiddleware(                model=model,                trigger=("messages", 8),                keep=("messages", 2),            )        ],        checkpointer=saver,    )

That is the whole configuration. It works. It keeps the context window bounded, it costs nothing to set up, and it is what the docs recommend.

Look at what the middleware returns. From langchain/agents/middleware/summarization.py in langchain 1.3.14, before_model ends with:

code
return {    "messages": [        RemoveMessage(id=REMOVE_ALL_MESSAGES),        *new_messages,        *preserved_messages,    ]}

This is a state write. It goes through the add_messages reducer - the same reducer Part 2 took apart - and REMOVE_ALL_MESSAGES instructs that reducer to discard everything it currently holds and accept the new list. The reducer is the only thing that acts on it. The checkpointer is downstream of the reducer, and its job is to snapshot whatever the reducer produced, at that superstep, forever.

So the deletion is real, and it is scoped to exactly one object: the current value of one channel. Every prior snapshot is untouched, because snapshots are immutable and nothing rewrites them.

The failure is not that the code is wrong. It is that "delete" in the application's vocabulary and "delete" in the runtime's vocabulary refer to different operations, and no artifact anywhere tells you that.

What LangGraph actually stores after compaction

I ran this rather than reasoned about it. Everything below is measured against langgraph 1.2.9, langchain 1.3.14, langchain-core 1.5.1, and langgraph-checkpoint-sqlite 3.1.0, pinned as of 30 July 2026. Version numbers move; the mechanism is what matters, and I will point at the mechanism each time.

The harness is create_agent with the real SummarizationMiddleware, a GenericFakeChatModel so there is no API key and no network, and a real SqliteSaver. A marker string stands in for whatever you would care about losing.

First question: does the middleware trim state at all? Yes, and it is not close.

code
turn   msgs   delta bytes  event----------------------------------------------    0      4        +9,444    1      6       +13,481    2      8       +17,519    3      4       +17,974  <-- COMPACTION    4      6       +14,977    5      8       +19,009    6      4       +18,721  <-- COMPACTION    7      6       +14,991    8      8       +19,020    9      4       +18,721  <-- COMPACTION

The whole argument is in the contrast between those two columns. The message count saw-tooths: 4, 6, 8, then back to 4. The stored bytes only ever rise.

Note what this table does not say. A compaction turn costs about the same as an ordinary turn, so compaction is not expensive per firing. I first wrote that it added storage, and a with/without control - further down, in the section on budgets - is what corrected me. It does not add storage.

Second question: after the marker leaves live state, where is it?

code
1. compaction fired on turn 3: 8 -> 4 messages   marker still in LIVE state          : False2. marker in stored rows AFTER deletion from state   checkpoint rows holding it          : 18 of 52   write rows holding it               : 1

Gone from state. Present in eighteen of fifty-two checkpoint rows, plus one row in the writes table. Both tables carry payload, which matters later.

Third question - and this is the one that turns a storage observation into a correctness problem: can it come back?

code
4. resume from the newest checkpoint that still holds it   marker back in live state           : True

Yes. Not recovered by a forensic tool, not read out of a backup. Returned to live application state by the framework's own supported time-travel API, in one call.

A terminology correction I owe the docs here, because it makes the point sharper rather than softer. The official guidance states that update_state "does not roll back a thread. It creates a new checkpoint that branches from the specified point. The original execution history remains intact." So this is a fork, not a rewind. Nothing is ever removed by going back. That is a stronger version of the problem than the one I set out to describe: the recovery mechanism cannot destroy history even in principle, because appending is the only thing it does.

Why compaction exists: two budgets that look like one

Step back from the retention problem for a moment. The trap is easy to fall into for a good reason: compaction solves something real, and it solves it well.

The context window is the scarce resource in an agent loop, and it is scarce in a specific way. Every turn re-sends the entire history, so memory grows linearly with the conversation while tokens sent grow quadratically - turn n re-transmits everything from turns 1 through n-1.

Prompt caching softens that, and it is worth saying so rather than quoting the 2023 version of the cost story. Every major provider now discounts a repeated prefix heavily. It also cuts the other way: compaction rewrites the prefix, so the first call after a firing cannot reuse the cached prefix past the compaction point. The system prompt and tool block still hit, and the rewritten span is paid at cache-write rates before it earns anything back. The token saving is real but smaller than the naive arithmetic suggests, and part of it is paid back immediately. That is the pressure compaction exists to relieve, and it relieves it properly. Colaco and Lahjouji's rate-distortion treatment (arXiv:2607.08032) formalises the general shape: every compaction scheme, from key-value cache eviction to prompt pruning to agent-memory consolidation, is one decision about "what context-derived information to retain versus discard, at what fidelity, under a resource budget, so as to preserve downstream task utility."

Read the objective in that sentence again. That is where the two budgets separate. The distortion measure is downstream task utility. Every published compaction scheme optimises for whether the agent can still do its job. None of them carry a term for retention liability - the cost of still having something. Nobody has posed the problem that way. That is the gap, and it is not the framework's.

So there are two budgets in play, and they do not move the same way:

What compaction does to itCan it go down?
Tokens sent to the model per turnCuts it immediatelyYes, every time it fires
Bytes retained in the checkpointerCaps each snapshot, so it bounds the growth rateNo - the level only ever rises

I had this wrong in an earlier draft, and my own control run is what corrected me. I first wrote that compaction adds storage, then that it does nothing to storage. Both are false. Run the same ten turns with and without the middleware and the difference is unambiguous:

code
turn   with compaction        without   0             9,434          6,647   3            17,935         15,757   5            19,011         21,767   7            14,988         27,827   9            18,696         33,912TOTAL          167,790        205,661        (-18.4%)

Five of ten rows shown, and this is a different run from the saw-tooth table above, so the per-turn figures move by a few hundred bytes. The shape is what reproduces, not the digits. Turn 0 is the one row where compaction costs more: installing the middleware adds a before_model hook, so a turn spans more supersteps and writes more snapshots even before any summary fires. That is a retention cost of the summarizer that has nothing to do with summarizing.

Without compaction the per-turn cost climbs without limit, because every snapshot holds the whole message list. With it, the cost resets to roughly the same floor after each firing. Compaction is doing real storage work - it converts unbounded growth into a bounded saw-tooth, and over ten turns that is 18% fewer stored bytes.

What it never does is reduce the level. The saw-tooth in the message count - 4, 6, 8, back to 4 - has no counterpart in the byte column, which only ever increases. Bytes already written stay written. That is the distinction the rest of this article rests on, and it survives the correction intact: eighteen rows still hold the value after the summarizer has removed it from state.

This is also where Part 1's vocabulary earns its keep. That article distinguished a length bound - how far a run may go - from a shape bound - where it may go. Compaction is none of the three - not Part 1's length or shape bound, not Part 3's alphabet bound. It is a bound on what the model may see on one call, and it constrains nothing about what the runtime holds. Part 1's demarcation criterion applies here without modification: a mechanism specifies the runtime if the constraint holds regardless of whether the model complies. Compaction fails that test on the retention question, because the constraint it enforces is on the prompt, and the prompt is not where your data lives.

In-thread state vs cross-thread store: deciding which side of the line

Every field in an agent system sits on one side of the rollback surface or the other, and most teams choose by asking "how long should this live?" That is the wrong first question. The right one is "what happens when this thread is forked or deleted?"

PropertyGraph state (in-thread)BaseStore (cross-thread)
Recovered on a branch from an earlier checkpointYes - the deletion is not on that branchNo - unaffected
Removed by delete_threadYes, all of itNo
Concurrent-write policyA reducer, per key (Part 2)None - last write wins
Deletion granularityWhole threadOne item, no atomic namespace delete
Visible to other threadsNoYes, to all of them
Appears in the rendered graphNoNo

Read the first two rows together and the design rule falls out. Graph state is the right home for anything whose value is derived from the run and should track the run - if you fork back, you want the old value back, because that is what consistency means. The store is the right home for anything that must survive independently of any particular conversation.

Teams get hurt two ways. They put a fact about a person in graph state because it was convenient. Or they put a conclusion drawn during one conversation in the store because it seemed durable. The first becomes undeletable at the granularity you need. The second becomes an assertion that outlives the evidence for it - the store keeps the conclusion while the branch you are now on no longer contains the transcript that produced it.

That second failure has a measured cost. The cross-user contamination work reports 57-71% contamination under raw shared state from benign interactions alone, and characterises it as "scope-bound artifacts" persisting and being "later misapplied."

RemoveMessage vs delete_thread: two deletion vocabularies

LangGraph does ship erasure. An earlier draft of this article implied otherwise, and that draft was wrong.

BaseCheckpointSaver.delete_thread(thread_id) exists, along with the async adelete_thread. The base class defines it as part of the checkpointer interface, though the default body raises NotImplementedError - so a custom or third-party saver that skipped it constructs fine and fails at call time, which is worth checking before you build an erasure routine on top of it. SqliteSaver 3.1.0 implements it. It works exactly as advertised.

So let me run both deletions against the same thread and show what each one reaches.

The run below is a short thread - one turn, then a compaction node - so its row counts are smaller than the ten-turn run above. What matters is which counts move, not their size.

code
AFTER RemoveMessage(REMOVE_ALL_MESSAGES) -- the app-level "delete":   live state has marker : False   checkpoint/write rows : (3, 1)   store still has it    : TrueAFTER saver.delete_thread("t1") -- the runtime-level erasure:   checkpoint/write rows : (0, 0)   store still has it    : True   <-- outside the surface   store value           : {'v': 'SENSITIVE-PLACEHOLDER-0001'}

Two operations, both called deletion, with no code path between them.

RemoveMessage is what your application calls when a user asks you to remove something, and it is what the framework's own summarizer calls on every compaction. It reaches the current channel value and stops there.

delete_thread is what actually clears durable storage, and it took the rows to zero. But look at its granularity: the whole thread. There is no documented API to delete one message, one span, or the turns a summarizer just compacted away. The granularity your application deletes at - a message, a customer, a field - has no corresponding primitive.

And neither one touches the store.

LangGraph Platform adds time-based TTL, including a keep_latest strategy that "retains the thread and latest checkpoint, but deletes older checkpoint data that subsequent runs won't need." That is a genuine mitigation for the checkpoint side, and if you are on Platform you should know it exists. It is thread-scoped and off by default: with no TTL configured, checkpoints do not expire. And it fires on a clock, not on the erasure request. Store items have their own separate TTL block, with no coupling to the checkpointer's.

The practitioner consequence is the part I would put on a whiteboard. Your compliance process deletes at the granularity of a person. Your application deletes at the granularity of a message. Your runtime deletes at the granularity of a thread, or of one store item at a time. Nothing reconciles those three, and the framework does not tell you they differ.

One detail from the measurement changes what "delete the checkpoints" has to mean. The marker appeared in eighteen checkpoints rows and one writes row. Those are two tables with different lifecycles. checkpoints holds the committed snapshot per superstep; writes holds pending channel writes, which is the mechanism Part 4 relied on when it established that pending writes prevent re-execution of a task whose writes already landed. Both carry payload. Any cleanup routine that targets only the snapshot table leaves content behind in the other one, and delete_thread is documented as clearing both precisely because both are needed.

This is also where the choice of checkpointer stops being an implementation detail, and where you should not generalise my row counts. I measured SqliteSaver, the one you can run with no infrastructure, and it carries payload across checkpoints and writes. Other backends do not use that table set - the Postgres saver splits channel values into their own blob table - so a cleanup job written against "the two tables" can leave payload sitting in a third. Enumerate your backend's tables rather than trusting mine. Rows are only the first layer anyway: write-ahead logs, replicas, and point-in-time-recovery snapshots are downstream copies no framework API reaches. The community checkpointer discussed below is explicit about this limit, and it is the right instinct - deleting a row is not the same claim as the data being unrecoverable, and conflating them is how a compliance statement becomes false.

There is a narrower alternative. It changes the exposure without changing the architecture. trim_messages drops messages from what you send the model without routing a RemoveMessage through the reducer at all. That means state keeps the full history - strictly more retained content, not less - while the token budget still gets managed. If your problem is cost and you have no deletion story, trimming is more honest about what it is doing: it never implies a deletion happened.

Why BaseStore data survives a thread rollback and delete_thread

The second half of the inversion is quieter. It runs in the opposite direction, and it is worse.

Cross-thread memory is the correct pattern for anything that should outlive a conversation - user preferences, entitlements, a summary of who this customer is. Atlas needs it. The docs are clear that this data "is shared across conversational threads" and "can be recalled at any time and in any thread."

What the docs do not say anywhere is what happens to it when you fork the thread that wrote it.

code
from langgraph.config import get_storedef remember(state: AtlasState) -> dict:    store = get_store()    store.put(("users", state["customer_id"]), "profile", {"tier": "gold"})    return {}

That write happens inside a node, inside a superstep, inside a thread that has a checkpointer. Every intuition the last four parts have built says it is part of the run. It is not. It is a side effect on a separate system that the checkpointer has never heard of.

Part 3 named a related problem for tools: an Unrendered Edge is a constraint that governs real behaviour while appearing on no artifact the framework draws. A store write is not one of those - it is a side effect, not a constraint on a transition, and stretching the term to cover it would dilute a handle that took a whole article to earn. The analogy that does hold is the invisibility. A store write appears in draw_mermaid(), in get_graph(xray=True), and in the checkpoint diff exactly nowhere, and unlike a state write it never touched a channel to be diffed against.

Fork the thread back to before that node ran, and the store item is still there, unchanged, visible from any other thread. I checked five official documentation pages - persistence, memory, time travel, short-term memory, and the original long-term-memory launch post - and the store's rollback semantics appear on none of them. The time-travel documentation does not mention the store at all. The launch post explains the split as flexibility, not durability.

The boundary is documented. What it means for your data is not, on any page I could find. Your agent's most persistent memory sits on the side of the line where nothing can undo a write.

You can audit the store, and an earlier draft of this article said you could not. store.search(namespace) lists what is there without needing a key registry, and list_namespaces(prefix=("users",)) enumerates namespaces. I wrote 25 keys and asked for them back:

code
25 keys writtenstore.search(NS)             -> 10 itemsstore.search(NS, limit=100)  -> 25 items

search defaults to limit=10. A sweep written the obvious way deletes ten keys, raises nothing, and reports success - which is the failure this whole article is about, appearing a second time in the same system. Paginate or pass an explicit limit.

What is genuinely missing is not enumeration. It is an atomic one. There is no delete_namespace, so a read-then-delete loop races any concurrent put, and you can never attest to a consistent point-in-time zero. And anything your code wrote under a different namespace scheme is still missed - a namespace-registry problem rather than a key-registry problem.

This has a parent concept. Agentic Unlearning (arXiv:2602.17692) calls the general shape parameter-memory backflow: content returning through a path the deletion primitive did not cover. Retention Inversion is the framework-runtime instance of backflow.

mermaid
flowchart TD
    APP["Application calls delete"]

    subgraph INSIDE["INSIDE the rollback surface"]
        RM["RemoveMessage<br/>REMOVE_ALL_MESSAGES"]
        RED["add_messages reducer<br/>replaces channel value"]
        LIVE["Live state: gone<br/>the only thing that changed"]
        CKPT[("checkpoints 18 + writes 1<br/>17 snapshots still resumable")]
        BACK["Content back in live state"]
    end

    subgraph OUTSIDE["OUTSIDE the rollback surface"]
        STORE[("BaseStore write<br/>1 key")]
        SURV["Survives the fork<br/>survives delete_thread"]
    end

    DT["delete_thread<br/>whole-thread granularity"]
    ZERO["Rows to zero<br/>takes the other 6 turns with it"]

    APP --> RM --> RED --> LIVE
    RED -.->|"immutable snapshot<br/>per superstep"| CKPT
    CKPT -->|"fork to an earlier checkpoint"| BACK
    APP -.->|"NO CODE PATH"| DT
    DT --> ZERO
    APP --> STORE --> SURV

    style APP fill:#4A90E2,color:#FFFFFF
    style RM fill:#4A90E2,color:#FFFFFF
    style RED fill:#7B68EE,color:#FFFFFF
    style LIVE fill:#6BCF7F,color:#2C2C2A
    style CKPT fill:#FFD93D,color:#2C2C2A
    style BACK fill:#E74C3C,color:#FFFFFF
    style DT fill:#98D8C8,color:#2C2C2A
    style ZERO fill:#6BCF7F,color:#2C2C2A
    style STORE fill:#FFD93D,color:#2C2C2A
    style SURV fill:#E74C3C,color:#FFFFFF

The counts are measured, from the walkthrough below: 18 checkpoint rows, 17 of them still resumable, 1 write row, 1 store key. I am not going to fold those into a single headline number, because the resumable snapshots and the checkpoint rows are the same bytes reached two ways. Twenty stored locations is the honest count, and it is enough.

The two red nodes are the inversion. The dotted line is the whole problem: the deletion you call and the deletion that works are not connected, so the only box that changes is the one marked "the only thing that changed" - and it was already empty. One is content you deleted, back in live state. The other is content you never scoped for permanence, permanent.

Is this a LangGraph bug? No, and Anthropic shipped it too

I want to be fair to the framework. The same shape shows up in products built by teams who thought hard about it.

Anthropic's Compaction API takes the opposite approach on the boundary question, and it is instructive. When the API receives a compaction block, all content blocks before it are ignored - and the caller is told, in the documentation, that they now have a choice: "Keep the original messages in your list and let the API handle removing the compacted content" or "Manually drop the compacted messages and only include the compaction block onwards." The API itself stores nothing. The retention decision is explicitly the caller's, and it is surfaced as a decision.

That is the fix in design form: make the boundary visible and make the choice explicit.

Now the counter-example from the same company. A user filed an issue in March 2026 reporting that Claude Desktop writes full uncompacted conversations to local transcript files: "878KB, 9,124 lines. The entire conversation - user messages, assistant responses, tool calls, thinking blocks. Everything." And: "Nothing in the UI tells the user any of this. No docs, no changelog, no support article mentions it."

The architecture there is correct. Keeping the full transcript is what makes compaction reversible - the model can read back what it summarized, which beats destroying it. What went wrong was that nobody told the user which side of the boundary their data was on.

The issue was closed as not planned.

A worked erasure, and why the obvious routine does nothing

Here is the whole failure end to end. A customer gives Atlas a sensitive value on turn one. Six turns follow. Compaction fires. Days later an erasure request arrives.

I audited it by every route that can reach the content: live state, the two storage tables, the snapshots you can resume from, and the store.

The harness is seven turns at the same trigger and keep settings, so the row counts line up with the ten-turn run rather than the short one. Before anyone touches anything:

code
STAGE 0 - after compaction, before anyone asks for erasure     live state          : gone     checkpoint rows     : 18     write rows          : 1     reachable snapshots : 17     store keys          : 1

Live state is already clean, because the summarizer did its job three days ago. Everything else still has it.

Now the routine most teams write. Something asks for the data to be removed, so the handler removes it from state - which is where the data lives, as far as the application is concerned.

code
agent.update_state(cfg, {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]})
code
STAGE 1 - the naive erasure: remove it from state     live state          : gone     checkpoint rows     : 18     write rows          : 1     reachable snapshots : 17     store keys          : 1     -> 20 stored locations still hold it. Ticket closed anyway.

The marker-holding counts are identical. The erasure routine removed nothing, because the only object it acts on had already been emptied by compaction days earlier. It raised no error and returned no warning, and twenty stored locations still held the content it was called to destroy.

It did change one thing. update_state writes a new checkpoint, so the database is now one row bigger than before the deletion ran. The erasure request was a net write.

Not a routine that does too little - a routine that does exactly what it says and accomplishes nothing, while every signal available to the engineer running it says success. There is no exception to catch and no count to check. The state is clean, which was the thing being asserted.

The correct routine reaches both systems:

code
STAGE 2 - the correct erasure: delete_thread + store keys     live state          : gone     checkpoint rows     : 0     write rows          : 0     reachable snapshots : 0     store keys          : 0     -> 0 stored locations still hold it.

Zero everywhere, and it took two calls the naive version never made. Note the cost: delete_thread removed the entire thread, including six turns that had nothing to do with the request. That is the granularity mismatch billed as a real price. There is no supported way to remove one customer's value and keep the rest of the conversation.

One caveat on the last line, because "0 routes" is a claim about this database and not about the world. It means nothing reachable through the framework's APIs or these two tables. The downstream copies named earlier are out of scope, which is why the audit prints location counts instead of declaring the data unrecoverable.

Why compaction schemes do not fix this

Structured context eviction and Constraint Pinning both address the compaction problem, and neither one moves the retention direction. Both are good work. Reaching for either as a fix here would still be a mistake.

Structured context eviction (arXiv:2606.11213) replaces LLM summarization with a deterministic, dependency-aware eviction policy. The agent annotates its trajectory as typed episodes and a rule-based policy evicts in priority order when the budget is exceeded. It claims one session of 89 sequential tasks across 80 million tokens with no measurable accuracy loss against isolated per-task sessions - a single self-reported run, so treat it as an existence proof rather than a benchmark.

It is genuinely better than summarization on the axis it targets. Deterministic eviction is auditable: you can state exactly what was dropped and why, where an LLM summary can only be inspected after the fact. If Governance Decay is your problem, this is a real answer.

It does nothing here. Eviction decides what leaves the context window. Everything in this article happens after that decision, on the other side of the reducer.

Constraint Pinning (arXiv:2606.22528) is the mitigation from the Governance Decay paper itself, and it restores violation rates to 0% without retraining by keeping designated constraints out of the compactable span. Constraint Pinning works, costs almost nothing, and you should adopt it. It also protects content from removal, which is the wrong direction when the thing you need is removal that sticks.

I went looking for a counter-example. Plenty of work scores retained bytes - the whole key-value cache eviction literature optimises exactly that, and the rate-distortion framing above explicitly spans it. What I could not find was a scheme that scores retention liability: the cost of still holding something after you were asked to destroy it. If that paper exists, I have not read it, and I would like the citation.

How to design LangGraph memory for deletion

Four changes, in the order I would make them.

1. Write down where the boundary is

Before any code, answer these for every field your agent handles: which memory system holds it, what deletes it, at what granularity. If a field is in the store, "what deletes it" is "an explicit store.delete() call with the exact key" and nothing else. If it is in graph state, the answer is "nothing, until delete_thread."

Most teams cannot answer this, which is the finding. The EDPB's 2026 coordinated enforcement report on the right to erasure surveyed 764 controllers with 32 data protection authorities participating, and and its recurring finding was that erasure fails on the operational side: undocumented procedures, and responsibility split across teams and systems that never reconcile. That is a paraphrase of the report's findings, not a quotation of one sentence. That is a description of this problem at organisational scale.

2. Keep the irreversible out of the reversible

The cheapest structural fix is to stop putting things in graph state that you will later need to prove are gone.

code
import operatorfrom typing import Annotated, Literal, NotRequired, TypedDictfrom atlas.types import Evidence  # source: str; text: str; rank: float  (Part 2)class AtlasState(TypedDict):    customer_id: str    intent: NotRequired[Literal["refund", "status", "other"]]    ticket_id: NotRequired[str]    refund_cents: NotRequired[int]    refund_allowed: NotRequired[bool]    refund_grant_id: NotRequired[str]    evidence: Annotated[NotRequired[list[Evidence]], operator.add]    # New in Part 5: a handle, never the payload.    pii_ref: NotRequired[str]

pii_ref is a pointer into a system you control, with its own deletion primitive that works at the granularity you need. The checkpoint history then holds a reference to something deleted rather than the thing itself.

The pattern is not new and I am not claiming it. Event sourcing, Kafka and Datomic have all had to answer the immutable-log-versus-erasure question, and the standard answer is tokenization or crypto-shredding: keep the immutable record, put the payload somewhere deletable, or throw away the key. What is specific here is where the line falls. Part 4 made the same move with refund_grant_id for authority; the argument in both cases is that the checkpointer faithfully restores whatever is inside the surface, so the design question is what you put there.

This does not solve everything. A user's message is a user's message, and you cannot indirect the conversation itself. But the fields that most often trigger an erasure request - identifiers, payment details, contact information - are exactly the fields that never needed to be in the transcript.

3. Make erasure reach both systems, and prove it

An erasure routine that calls delete_thread and stops is half a routine.

code
from langgraph.checkpoint.base import BaseCheckpointSaverfrom langgraph.store.base import BaseStorePAGE = 100  # search() defaults to limit=10 and truncates in silence.def _cfg(thread_id: str) -> dict:    return {"configurable": {"thread_id": thread_id}}def erase_customer(    customer_id: str,    saver: BaseCheckpointSaver,    store: BaseStore,    ledger: GrantLedger,          # your thin wrapper over Part 4's grant_ledger table    thread_index: dict[str, list[str]],) -> dict:    """Erase across ALL THREE stores. Returns verified post-state, not intent."""    namespace = ("users", customer_id)    # thread_index is YOUR mapping, maintained on thread creation.    threads = thread_index.get(customer_id, [])    failures: list[str] = []    for thread_id in threads:        try:            saver.delete_thread(thread_id)        except Exception as exc:                   # partial erasure is a fact            failures.append(f"thread {thread_id}: {exc!r}")    # search() is PREFIX-scoped, so it returns sub-namespace items too. Deleting    # those with `namespace` is a silent no-op and this loop would never end.    while True:        batch = store.search(namespace, limit=PAGE)        if not batch:            break        progressed = False        for item in batch:            try:                store.delete(item.namespace, item.key)   # NOT `namespace`                progressed = True            except Exception as exc:                failures.append(f"store {item.namespace}/{item.key}: {exc!r}")        if not progressed:            failures.append("store sweep stalled; aborting")            break    # Only clear grants for threads that VERIFIED as gone. Clearing the    # consumption record while its thread survives re-arms the grant.    gone = [t for t in threads if not saver.get_tuple(_cfg(t))]    try:        ledger.delete_for_threads(gone)    except Exception as exc:        failures.append(f"ledger: {exc!r}")    # Verify. Report what is there now, not what was attempted.    return {        "customer_id": customer_id,        "threads_remaining": [t for t in threads if saver.get_tuple(_cfg(t))],        "store_items_remaining": [            (i.namespace, i.key) for i in store.search(namespace, limit=PAGE)        ],        "ledger_rows_remaining": ledger.count_for_threads(threads),        "failures": failures,        "out_of_scope": (            "media-level unrecoverability; database backups, "            "write-ahead logs and replicas are not reached by this routine"        ),    }

Three things in that routine are worth defending.

It returns what it verified, not what it attempted. An earlier version of this function returned the list of threads it had been handed and called that an auditable record. That is the failure this article diagnoses, shipped inside the function named "and prove it" - a routine reporting success on the strength of having run. It now re-reads and returns what remains, which should be two empty lists and a zero.

thread_index is still your code, but "no API exists" was too strong. Non-reserved keys in config["configurable"] land in checkpoint metadata, and saver.list(None, filter={"customer_id": ...}) searches across threads on that metadata. Platform additionally ships a threads-search endpoint. Both work. Both carry the same precondition as your own index - you had to decide to tag threads on day one - and the metadata route scans rather than indexes. Keep your own mapping, not because nothing else exists, but because the alternative is a scan you cannot bound.

It deletes Part 4's ledger, and that deserves saying out loud. Part 4 told you to move the refund consumption record into an external table precisely because it sits outside the rollback surface, where a restore cannot resurrect a spent grant. Run this article's three-question test against that advice and the ledger is a second store outside the boundary, which is where writes become permanent. Part 4's design is still right by its own rule: prefer the direction whose error is detectable and reversible. An over-retained consumption record is something you can find and delete; an under-recorded one is an unbounded double-spend. But outside-the-surface bought durability and billed retention, and an erasure routine built to Part 4's spec that skips the ledger leaves refund records behind that are linkable to the customer through your own thread index - personal data under Article 4(1), even though no column says so.

Then writing that routine turned up something I did not expect, and it is the sharpest thing in this article. Delete the ledger rows while a thread deletion has failed, and you have left a resumable checkpoint holding refund_grant_id next to a ledger that no longer records the grant as spent. Part 4's compare-and-swap consumes it a second time. The erasure routine re-arms the refund.

That is Part 4's Self-Restoring Bound, recreated on purpose, by the procedure written to satisfy a privacy obligation. The article's thesis is that the rollback surface silently sets your retention policy. The converse is just as true and nobody warns you about it either: your retention routine silently unsets your authority controls. Clear grants only for threads that verified as gone.

Agentic Unlearning's Synchronized Backflow Unlearning assumes you can enumerate the memory artifacts in order to invalidate them. You can. What you cannot get is an enumeration that is stable under concurrent writes, which is what an attestable erasure needs.

A community checkpointer explores the proof side of this. Its author's framing on the LangChain forum in July 2026 is the clearest statement of the gap I have seen from a practitioner: "If compliance or an auditor asks 'show me user X's thread was actually deleted,' the standard savers sign nothing and attest nothing." That project issues signed erasure receipts binding a hash of the thread's checkpoint history before deletion to the empty state after it. It is unproven, has had almost no engagement, and its author is careful that it "is not a claim of media-level unrecoverability." I would not put it in production. I would treat the question it asks as the right one.

4. Set retention deliberately, not by default

If you are on LangGraph Platform, configure a checkpointer TTL. Two strategies are documented: delete removes the whole thread, and keep_latest "retains the thread and latest checkpoint, but deletes older checkpoint data that subsequent runs won't need" - which collapses most of the exposure described here. Neither runs unless you configure it; with no TTL set, checkpoints do not expire. Set the store's TTL separately, because it is a separate block and nothing links the two. On open source, PostgresStore accepts its own TTL configuration, so the store side is less bare than the checkpointer side.

If you are on open-source LangGraph, this is a cron job you write. The community's working answer is periodic cleanup, and there is a warning attached that is worth repeating: practitioners in that thread advised against hand-deleting checkpoint write rows because doing so can break time travel.

The durability guarantee is what blocks the erasure. You cannot prune the history without weakening the recovery property the history exists to provide. There is no bug to file here. This is the trade, and it needs a decision.

What the framework-level fix would look like

Everything above is work you do around the framework. "Surface the boundary" is easy to say, so here are the specific missing pieces, which are smaller than you might expect.

Three would close most of it, in ascending order of difficulty.

An atomic namespace delete. store.delete_namespace(("users", customer_id)) does not exist. Enumeration is already solved by search; what is missing is a single round-trip delete that cannot interleave with a concurrent write, which is what lets you attest to a zero. The checkpointer already has its thread-level equivalent in delete_thread. The asymmetry between the two systems is not defended anywhere in the documentation; it just exists.

A documented statement of store rollback semantics. One paragraph on the time-travel page saying that store writes are not versioned and are unaffected by forking or thread deletion. This costs nothing and removes the entire class of wrong assumption this article is about. Its absence across five official pages is the actual root cause here, more than any API gap.

A deletion that spans the surface. The hard one, because it collides with the durability guarantee. A delete_span(thread_id, before_checkpoint_id) would let compaction actually compact - dropping superseded snapshots as it goes. It does not exist for a reason: pruning checkpoint history weakens time travel, which is the property the history exists to provide. That trade is real, which is why the right shape is a policy the operator sets, not a default the framework picks.

The first two are documentation and API-surface work, not architecture.

When Retention Inversion does not matter

Not every system needs to care, and pretending otherwise would be the kind of blanket advice this series avoids.

The exposure is bounded by three things: whether anything sensitive enters graph state, how long threads live, and whether anyone can fork them. A single-turn classifier with an in-memory checkpointer has no exposure worth the engineering. An internal agent operating on data your organisation already retains under a separate policy has a compliance story that does not change.

It matters when threads are long-lived, when a human can replay them, when the data belongs to someone who can ask for it back, or when you will be asked to prove a deletion happened. The EU AI Act's Annex III high-risk provisions are scheduled to begin applying on 2 August 2026 - product-embedded Annex I high-risk runs a year later, and a Commission proposal to defer parts of the Annex III timetable was live as this was written, so check the status before you plan around the date. Article 19(1) requires providers of high-risk systems to keep automatically generated logs "for a period appropriate to the intended purpose... of at least six months," while deferring to data-protection law. So one regime sets a retention floor and another compels erasure. Article 17(3)(b) does provide the joint: the right to erasure shall not apply to the extent that processing is necessary for compliance with a legal obligation. An Article 19 logging duty is such an obligation, so this is not a standoff.

The question it does not answer is the one that matters here. The exemption covers the logs Article 19 requires. Nothing establishes that a full checkpoint transcript is that log, rather than ordinary user data your runtime happened to retain by default. Checkpoint history sits somewhere between the two, and nobody has said which.

I am not going to tell you how that resolves. No regulator has ruled on versioned agent state, and the backup accommodations that regulators do offer - the ICO's guidance on putting backup data "beyond use" being the most quotable - are a poor fit for a store that is live, indexed, queryable on the primary read path, and replayable into production context by design. That is an argument, and I am flagging it as an argument rather than dressing it as a conclusion. But it is the argument your legal team will have to have, and they will have it faster if you can hand them an accurate diagram of where the data actually is.

Audit checklist for LangGraph memory retention

Run this against any LangGraph system holding data that belongs to someone else.

  • Run the three-question test from the top of this article against your own stack: what does the deletion primitive mutate, is that object inside the recovery boundary, and is there a second store outside it. Do this before assuming your system differs.
  • For every state field, name the system that holds it, the call that deletes it, and the granularity of that call. Fields with no answer are the finding.
  • Audit by route, not by assertion. Count live state, both storage tables, resumable snapshots, and store keys separately. A deletion routine that leaves those counts unchanged did nothing, and it will not tell you so.
  • Confirm whether RemoveMessage anywhere in your codebase is being treated as a deletion. In the summarizer it is a compaction. In an erasure path it is a bug.
  • Keep your own customer-to-thread index. The metadata-filter route (saver.list(None, filter=...)) and Platform threads-search both work, but both scan and both need you to have tagged threads on day one.
  • Enumerate every namespace scheme your code has written under. search covers the keys inside a namespace; nothing covers a namespace you forgot. Pass an explicit limit - the default of 10 truncates in silence.
  • Decide the checkpoint retention policy explicitly. On Platform, configure TTL and choose between delete and keep_latest. On open-source, write the cleanup job, and accept that pruning history weakens time travel.
  • Set the store TTL separately. It does not inherit from the checkpointer's.
  • Move identifiers and payment details behind a reference in state rather than storing the payload. Follow the refund_grant_id pattern from Part 4.
  • Decide whether you need to prove deletion, not just perform it. If an auditor will ask, the standard savers attest nothing.
  • Test it: write a marker, compact, fork to an earlier checkpoint, and confirm what comes back. The measurement takes twenty minutes and it is the only way to know what your configuration actually does.

What this means for multi-agent memory (Part 6)

Part 6 takes on multi-agent systems, and the store's position outside the rollback surface stops being a retention question there and becomes a correctness one. Cross-thread memory shared between agents is shared state with no reducer, no concurrency policy, and no rollback - which is Part 2's write relation with every guarantee removed. The 57-71% contamination figure from benign interactions in shared state is a preview of what that costs.

The sharpest version of the cost is the audit that did not move. A deletion ran, reported success, and left all twenty locations intact. The object it was built to clear had been emptied days earlier by a mechanism nobody thinks of as a deletion. Systems that fail loudly teach you where their boundaries are. This one returns cleanly and teaches you nothing.

So draw the line on your own architecture diagram this week, and run the three-question test against it. Everything else in this article is a consequence of not having drawn it.

References


Agentic AI

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Books by Ranjan Kumar

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments