A support agent asks a customer to approve three refunds. The customer approves all three. The refunds go out, the ticket closes, and the transcript looks perfect: three approval requests, three approvals, three confirmations, in order, with the right amounts.
The money moved six times. The first customer was refunded three times.
I want to be careful here, because I got this wrong in my own draft. I first wrote that the effect fired three times and only the code above the approval gate re-ran. Then I measured it, and the reassuring version was false. For three approval-gated refunds dispatched in one assistant message, the tool body runs twelve times, the code below the gate runs six times, and the first ticket in the batch is charged once for every ticket in the batch.
The transcript still shows three refunds. Three approval requests, three approvals, three confirmations, right amounts, right order. Your test asserts on those and passes. Your trace shows one node. The thread afterwards holds three checkpoints, zero pending interrupts, and no field anywhere that records the number six.
Every measurement in this article was run on langgraph 1.2.9 while writing it. This part is about the shape those measurements keep turning up, rather than about any one of them.
The thesis: the feature list is the wrong question
Teams choose an agent runtime from a feature list. Does it have checkpointing. Does it have interrupts. Does it have typed state. Does it put control flow on inspectable edges.
LangGraph has all four. Every one of them works. Over seven parts I measured the runtime rather than reading about it, and outside one ordinary bug - LangChain #38720, where _resolve_schemas violates a documented last-position rule, which Part 3 reproduced and which is a defect rather than this article's shape - I did not find a case where a guarantee failed to hold over the unit it names. The checkpointer checkpoints. The recursion limit fires. The reducer merges. The compaction compacts.
What I found instead, seven times, is that the guarantee holds over a unit that is not the unit the reader assumed, and that the unit is not written down anywhere you would think to look. The reducer's tie-break rule is scoped to one superstep. Across supersteps the order comes from control flow instead, and neither rule is written down. The recursion limit is scoped to one invocation, not to the thread. A message deletion reaches the live channel value, not the thread's durable record. The drawing is sound at node granularity, not at action granularity.
So the question that predicts production pain is not does this runtime have the feature. It is:
For each guarantee this runtime gives me, what is its unit of scope, and which artifact tells me?
That is the second demarcation criterion this series has produced. Part 1 asked whether a constraint holds regardless of whether the model complies, which is a question about the model. This one asks whether a constraint's scope survives contact with an engineer who did not write the runtime, which is a question about the reader. A mechanism can pass the first and fail the second. Part 6's recursion_limit is the clearest case: the runtime enforces it whether the model complies or not, and its axis is unstated.
I call a guarantee that fails the second criterion a Scope-Blind Guarantee.
Prior art: who already owns this shape
This shape is old, and the honest version of this article starts by saying who owns it.
Kleppmann and Jepsen own the ancestor. In 2015 Martin Kleppmann wrote Please stop calling databases CP or AP. His argument is that "within one piece of software, you may well have various operations with different consistency characteristics." Shoehorning a system into one bucket, he wrote, means "inevitably changing the meaning of 'consistency' or 'availability' to whatever definition suits them." Five years later Kyle Kingsbury's Jepsen analysis of etcd 3.4.3 caught etcd's own documentation claiming that "all API calls exhibit sequential consistency, the strongest consistency guarantee available from distributed systems." Sequential consistency is strictly weaker than linearizability, so the claim was wrong. A real guarantee, correctly enforced, described on the vendor's own artifact with a label that does not match what a reader will assume. That is this article's shape, in a different field, eleven years earlier.
There is an inversion worth noticing. etcd's documentation was wrong about the rank of its guarantee. It made a claim you could check and fail. LangGraph's artifacts make no claim about the unit at all. Nothing to check, nothing to fail, nothing to correct. That is a harder failure to catch, not an easier one, and it is the only part of the shape I am claiming.
The software engineering literature already measured the symptom. Zhu and colleagues' Bugs in Modern LLM Agent Frameworks: An Empirical Study, at FSE '26, mined issue trackers across agent frameworks. It found that problems "arise during scheduling, tool invocation, and graph execution, where mismatches between developer expectations and execution semantics often cause duplicated runs, stalled workflows, or premature termination." Their top two root-cause categories, API Misuse at 32.97% and API Incompatibility at 22.34%, are more than half of everything they classified.
That is the strongest objection to this article and I want it stated before the argument rather than after. Their finding is that developer expectations and execution semantics diverge. This article is about why a careful engineer's expectation diverges: not because the guarantee is weak, but because it holds over a unit that no artifact names. Their categories are the symptom the unit question predicts.
diagrid published the closest critique. In Still Not Durable (2 March 2026), Yaron Schneider writes that "checkpointing is a storage operation, not a reliability guarantee." He says LangGraph's checkpointing features "fall far short of durable execution" because "they save state, but leave failure detection, automatic recovery, and duplicate prevention entirely to you."
That is one instance of this shape, on the durability axis, and Schneider got there first. Two differences matter. His is a sufficiency critique: the guarantee is not strong enough, so build the rest yourself. Mine is a scope critique: the guarantee is strong enough for what it covers, and the problem is that what it covers is unstated. His own text concedes the premise when it calls the checkpoint system "well-designed as a building block." Note also that diagrid sells a competing durable runtime, which does not make the critique wrong, but you should know it when you read it.
The framework selection advice already exists. Alex Rivera's builder's guide states the heuristic plainly: "Don't choose based on features. Choose based on what your team can build around the framework and what the framework needs to give you." I agree with it and I am not coining it. What that advice does not supply is a procedure. This article supplies one.
The temporal version of the shape has a name. Igor Santos-Grueiro's Temporary Authority, Permanent Effects (11 July 2026) requires that "a durable effect is authorized only if the witness that licensed its derived state remains fresh, causally prior, bound to the same effect, and eligible at commit time." That is scope divergence along time: authority valid at one moment, used at another. This article is scope divergence along unit. The two are different axes, and the paper's own precondition is that runtimes emit the signals its monitor consumes. The finding here is that LangGraph's artifacts do not emit the unit. So the paper's requirement and this finding fit together.
Two more ancestors, because they are what half of you are already thinking. Spolsky's Law of Leaky Abstractions (2002) is about an implementation detail showing through an abstraction that promised to hide it. That is close but not the same: nothing here leaks, and nothing promised to hide. The guarantee is stated plainly, and what is missing is the quantifier on it. The closer ancestor is Evans's aggregate from Domain-Driven Design (2003), which is literally the instruction to name the unit your consistency guarantee holds over, because engineers will otherwise assume a larger one. Gray's work on granularity of locks (1975) is the same move in databases. Memory scopes in CUDA and C++, memory_scope_device against memory_scope_system, are the same move in hardware, where the field eventually put the unit in the type name. That is the strongest evidence I have that naming the unit is what fixes this, and none of it is mine.
What is left, and what I am claiming: nobody has named this for agent runtimes, and nobody has run a cross-cutting audit of one agent runtime's guarantee scopes. That is what the last seven articles were, without my knowing it until the sixth.
What a Scope-Blind Guarantee is
A Scope-Blind Guarantee is a runtime guarantee that is real, correctly implemented and correctly enforced, whose unit of scope differs from the unit the adopting engineer assumes, and which appears on no artifact the framework renders that would correct the assumption.
Three properties, and all three are required:
- It is real. It fires. You can watch it fire. This is not a missing feature or a bug report.
- Its unit is not the assumed unit. The divergence is in what the guarantee ranges over, not in whether it holds.
- No rendered artifact names the unit. Not the diagram, not the type annotation, not the checkpoint, not the trace, not the test report.
Part 6 coined Width-Blind Bound for one instance of this: a run-level limit correctly enforced over the depth axis in a system where the width axis is chosen by the model. Its three properties were that it is real, that its unit is not the quantity the model chose, and that it reads as total. Those are the general form's three properties with "width" substituted in.
So a Width-Blind Bound is the width instance of a Scope-Blind Guarantee. They are not synonyms and neither replaces the other.
One clarification, because the suffix has to mean the same thing twice. In both terms the artifact is what is blind, never the reader and never the mechanism's correctness. recursion_limit is blind to width. LangGraph's rendered artifacts are blind to scope. Read it the other way - as the reader being blind - and the two terms come apart. Part 6 also drew a line between a width bound, which is a mechanism that mostly does not exist, and a Width-Blind Bound, which is a mechanism that does exist and is aimed elsewhere. That line still holds. This part sits one level of generality above Part 6. Nothing in Part 6 changes.
Seven LangGraph guarantees and the units they hold over
I have to be careful here, because the previous part taught me an expensive lesson about quantifiers. In Part 7 I wrote a sentence beginning "every defect this series measured," and peer review found it false in three separate ways before it shipped. So this section names each instance rather than quantifying over the series, and the exceptions get named too.
| Part | The guarantee | The unit it actually holds over | The unit a reader assumes | What is silent |
|---|---|---|---|---|
| 2 | the fold order among co-active writers follows the task-path sort | within one superstep | the whole run | the type annotation, the drawing |
| 3 | the rendered graph is a sound over-approximation | node granularity | action granularity | draw_mermaid() is byte-identical for 1, 3 and 20 tools |
| 4 | recursion_limit bounds the run | one invocation | the thread | nothing on the thread records spend |
| 5 | RemoveMessage removes content | the current channel value | the thread's durable record | no error; delete_thread is the only primitive that reaches storage, and its unit is the whole thread |
| 6 | recursion_limit bounds the run | depth | total work | one dotted edge, no worker count |
| 7 | the eval suite covers the agent | the first entry into a thread | the system | a green report |
| 8 | BaseStore holds cross-thread memory | one key, last write wins | shared memory with a merge policy | no exception, no version, no second row |
Part 1 is the frame rather than a row. A hand-rolled while loop is a runtime whose guarantees were never specified at all, which is the degenerate case of the same question: not a unit you got wrong, but no unit written down anywhere.
And the counter-examples, because they matter more than the pattern:
- Part 2's order-coupled defect does not need a second entry into a thread. It needs three things at once - a non-commutative reducer, two co-active writers, and something downstream that reads the order - and all three can be met on a cold one.
- Part 3's ungated refund fired on turn one. The prompt-only guard failed at the first opportunity, with no state involved.
- Part 6's Width-Blind Bound fires on a cold thread with no checkpointer at all.
Three of the seven do not need any accumulated state. If I claimed this series had found one mechanism, those three would refute it. What the series found is one question that exposes all seven.
The error always runs in the same direction
Look down the two middle columns of that table. In six of the seven rows the assumed unit is broader than the real one. Superstep becomes the whole run. One key becomes shared memory. First entry becomes the system.
Row 3 is the exception, and it runs the other way. The rendered graph is sound at node granularity while the reader assumes soundness at action granularity, which is a finer unit, not a broader one. The direction flips because a node contains actions rather than the reverse. The failure is the same in mirror image: the reader assumes the guarantee covers more than it does, which is the invariant that actually holds across all seven.
Not once does an engineer assume a guarantee is narrower than it is. That asymmetry is not luck, and I think it has two causes worth naming, because they tell you where to look before you have measured anything.
The first is grammatical. A guarantee arrives as a capability: this runtime has checkpointing, it has a recursion limit. A capability with no stated bound reads as total, in the same way that "this car has brakes" does not invite the question over what distance. Narrowing requires a second sentence, and the second sentence is the one that does not get written.
The second is a vocabulary mismatch, and it is the more useful of the two. The real units in that table are superstep, node, invocation, channel value, depth and key - six nouns straight from the runtime's internal model. The seventh, first entry, is a name I had to invent, because the runtime offers none. The assumed units are run, thread, conversation, the system - nouns from the user's model of their own product. The two vocabularies do not overlap, so the narrowing is expressible only in words the reader has no reason to be thinking in. A superstep is not a thing your product manager has ever needed. It is, however, exactly the thing your reducer is scoped to.
flowchart LR
subgraph RT["The runtime's vocabulary (where the guarantee really holds)"]
direction TB
R1["superstep"]
R2["invocation"]
R4["channel value"]
R5["one key"]
R6["first entry"]
R7["depth"]
end
subgraph US["Your product's vocabulary (what the reader substitutes)"]
direction TB
U1["the whole run"]
U2["the thread"]
U4["the record"]
U5["shared memory"]
U6["the system"]
U7["total work"]
end
R1 -->|"Part 2"| U1
R2 -->|"Part 4"| U2
R4 -->|"Part 5"| U4
R5 -->|"Part 8"| U5
R6 -->|"Part 7"| U6
R7 -->|"Part 6"| U7
style RT fill:#98D8C8,color:#2C2C2A
style US fill:#FFD93D,color:#2C2C2A
style R1 fill:#4A90E2,color:#FFFFFF
style R2 fill:#4A90E2,color:#FFFFFF
style R4 fill:#4A90E2,color:#FFFFFF
style R5 fill:#4A90E2,color:#FFFFFF
style R6 fill:#4A90E2,color:#FFFFFF
style R7 fill:#4A90E2,color:#FFFFFF
style U1 fill:#E74C3C,color:#FFFFFF
style U2 fill:#E74C3C,color:#FFFFFF
style U4 fill:#E74C3C,color:#FFFFFF
style U5 fill:#E74C3C,color:#FFFFFF
style U6 fill:#E74C3C,color:#FFFFFF
style U7 fill:#E74C3C,color:#FFFFFF
All six arrows point the same way, from a narrow internal noun to a broad product noun. Part 3 is left out of the figure because it is the one that inverts - a node contains actions rather than the other way round. A runtime that wanted to close this gap would not need better documentation. It would need a name on the left that is hard to confuse with the thing on the right.
That gives a cheap test you can apply to a runtime before you have written a probe:
Take each guarantee and state its unit. If you can only state it using a noun from the framework's internals, your users are not thinking in that noun, and they will assume the nearest noun from their own vocabulary - which is always the broader one.
It also explains why Temporal's disclosure works, and it is not because Temporal wrote more documentation. Workflow Execution and Workflow Function Execution are both internal nouns. But they are adjacent internal nouns that differ by one word, sitting in the same sentence, so the reader cannot pick up one without noticing the other exists. LangGraph's recursion_limit has no sibling noun. There is nothing beside it to make you ask which of the two things it means, and Part 4 and Part 6 measured that it means two different things.
Wrong way: BaseStore cross-thread memory silently loses concurrent writes
Part 5 promised a measurement of cross-thread store collisions and did not deliver it. Part 6 named the debt in its own text and left it open. This is it.
BaseStore is LangGraph's cross-thread memory surface. Graph state is per thread and has a declared concurrency policy, because Part 2 established that the reducer in the type annotation is that policy. The store is the thing you reach for when memory has to outlive a thread: user preferences, extracted facts, anything the next conversation should know.
Here is the shape almost every memory-extraction step takes. Read what is known about the customer, add what you just learned, write it back.
from langgraph.store.base import BaseStoreNS = ("memories", "customer-42")def remember(state: AtlasState, *, store: BaseStore) -> dict: """Extract a fact from this turn and add it to the customer's profile.""" item = store.get(NS, "profile") facts = item.value["facts"] if item else [] facts.append(extract_fact(state)) # the model call store.put(NS, "profile", {"facts": facts}) return {"log": ["remembered"]}That is a read, a modify, and a write, with a model call in the middle holding the window open. Run it in two threads for the same customer at the same time and one of the two facts is gone.
I measured it. The deterministic version first, because a race that only sometimes reproduces proves less than an interleave you construct on purpose:
thread A intended : ['seeded', 'A: prefers email']thread B intended : ['seeded', 'B: timezone IST']store now holds : ['seeded', 'B: timezone IST']A's write survived: FalseThen with real threads and the window deliberately widened, on InMemoryStore:
2 writers -> 1 facts survived ( 1 lost, 50% loss) 8 writers -> 1 facts survived ( 7 lost, 88% loss) 32 writers -> 2 facts survived (30 lost, 94% loss)Those percentages are a demonstration, not a workload. I put a barrier before the read and a sleep in the middle to make every writer overlap, which is the worst case rather than the expected one. Be clear about what those numbers are. With every writer reading the same pre-state, exactly one survives, so the figure is (N-1)/N by construction. The 32-writer row reads 94% rather than 97% only because two writers happened not to overlap. The measurement here is that nothing prevents the collision, not the rate at which it happens. How often it bites you depends on how long your window is, and the window is a model call.
Then the same thing across two LangGraph threads sharing one store, which is the configuration the store exists for:
two threads ran, each appended one factstore holds : ['bob']survived : 1 of 2Per-thread checkpoint state is isolated and intact: t-alice: log=['alice wrote'] t-bob: log=['bob wrote']Both threads believe they succeeded. Both checkpoints are correct. Alice's fact is gone. The contrast is the whole point: per-thread durability is the part LangGraph invests in, and I have written before about how that checkpoint architecture holds up under load. The cross-thread surface beside it has none of those properties.
Everything above ran on InMemoryStore, and the obvious rebuttal is that nobody runs that in production. So here is the load-bearing evidence, which is not the backend but the contract. put() has this signature at the pin, and it is BaseStore's, which every backend implements:
put(self, namespace, key, value, index=None, *, ttl=NOT_GIVEN) -> NoneThere is no expected_value, no version, no etag, no compare-and-swap. A search of the method's own source for compare, expected, version, etag, cas, swap and if_match returns nothing. A feature request for concurrency-safe store.put was opened on 30 October 2025, describing exactly this "get, run logic, update" race and proposing optimistic locking. It has no maintainer response.
Compare that to what the same runtime does one layer down. Part 2 measured that two nodes writing the same state key in one superstep with no reducer raises:
InvalidUpdateError: At key 'evidence': Can receive only one value per step.The in-thread surface refuses to guess. The cross-thread surface silently picks one. Three put calls on the same key produced zero warnings, zero stderr output, and zero exceptions. And afterwards there is nothing to find: the Item carries created_at and updated_at but no version field, and search() returns a single row. No history, no conflict record, no second copy.
So the guarantee is real, and it is exactly what a key-value store promises: your write lands. The unit is one key, last write wins. The assumed unit is shared memory with a merge policy, because that is what the rest of this runtime taught you to expect.
What that costs is not hypothetical, and it has been measured by people who were not looking at LangGraph. Yang and colleagues' No Attacker Needed: Unintentional Cross-User Contamination in Shared-State LLM Agents (1 April 2026) studies the case where "a single agent serves multiple users within a team or organization, reusing a shared knowledge layer across user identities." Their headline number: "under raw shared state, benign interactions alone produce contamination rates of 57 to 71%."
Note benign. No attacker, no prompt injection, no adversarial input. Just ordinary use, because, in their words, "information that is locally valid for one user can silently degrade another user's outcome when the agent reapplies it without regard for scope."
Their paper measures the semantic consequence: the wrong fact reaching the wrong user. My probe measures the mechanical cause one layer down: the write that never landed, on a surface with no compare-and-swap and no conflict signal. They are the same problem seen from two ends, and the phrase they reach for without having a concept for it - without regard for scope - is this article's subject.
Right way: one key per writer, merge at read time
Part 2 gave three fixes for a non-commutative reducer, in preference order: use a commutative reducer, carry an ordering key and sort at the point of use, or give each writer its own key. The store has no reducer and cannot be given one, so the first two are unavailable. The third works, and it works precisely because it removes the shared mutable cell.
from datetime import datetime, timezonefrom uuid import uuid4from langgraph.config import get_config # Part 4: Runtime has no .configfrom langgraph.store.base import BaseStore# AtlasState gains one key in this part:# log: Annotated[NotRequired[list[str]], operator.add]def remember(state: AtlasState, *, store: BaseStore) -> dict: """Write to a key nothing else ever writes.""" thread_id = get_config()["configurable"]["thread_id"] # One key per WRITE, not per writer. See below for why that matters. store.put( ("memories", state["customer_id"], "by_thread"), f"{thread_id}:{uuid4().hex}", { "facts": [extract_fact(state)], "written_at": datetime.now(timezone.utc).isoformat(), }, ) return {"log": ["remembered"]}def recall(customer_id: str, *, store: BaseStore) -> list[str]: """Merge at read time, which is where the merge policy can be stated.""" # Part 5: search() defaults to limit=10 and under-reports in silence. # Paginate above this; a hard limit is the same defect with a bigger number. items = store.search(("memories", customer_id, "by_thread"), limit=1000) facts: list[str] = [] for item in sorted(items, key=lambda i: i.value["written_at"]): facts.extend(item.value["facts"]) return factsOne key per writer is not enough, and I had it wrong first. My initial version keyed on thread_id alone. A thread is a conversation and a conversation has many turns, so turn two overwrites turn one on exactly the surface this section exists to fix. Measured: two turns on one thread, and the store holds ['timezone IST']. The rule is one key per write, not per writer, and it only removes the lost update if each key is written once. Two nodes calling remember in the same superstep would collide again on a per-thread key, and do not on a per-write one.
AtlasState and extract_fact are the series' running example, carried from Part 1: customer_id is caller-supplied and required, and the model never writes it. That separation is what makes the namespace above safe to build from state. log is new in this part and needs operator.add, since more than one node appends to it.
The limit=1000 is not decoration. Part 5 measured that BaseStore.search() defaults to limit=10, so a sweep that does not pass a limit under-reports in silence. That default is itself a Scope-Blind Guarantee of the small and irritating kind: search really does return items, and it really is scoped to ten of them.
And the merge now happens at read time in code you wrote, which means the policy is stated. That is the whole move. You have not made the store concurrent. You have removed the need for it to be, and you have put the merge somewhere a reviewer can see it. If you genuinely need a single authoritative row that many writers update, the store is the wrong place for it. Use the compare-and-swap in the system of record that Part 4 built for the grant ledger: UPDATE ... WHERE, check rowcount, and let the database be the thing that arbitrates.
How many tool calls can one LangGraph model turn emit?
Part 6 named the tool-call list in one assistant message as a width surface and left it unmeasured. Here it is.
First, a question the research for this article could not settle from public sources: how does ToolNode actually dispatch parallel calls? Blog posts say ThreadPoolExecutor; a GitHub issue says asyncio.gather. Reading the installed source settles it, and both are right about different paths:
L823: executor.map(self._run_one, tool_calls, input_types, tool_runtimes)L858: outputs = await asyncio.gather(*coros)The synchronous path goes through get_executor_for_config and executor.map. The async path uses asyncio.gather. This is the same mechanism Part 3 identified when it measured that parallel tool-call fold order follows the model's emitted token order rather than completion order, so nothing here contradicts that finding.
More interesting is what does not appear in that file at all. max_concurrency occurs zero times. Semaphore occurs zero times.
Then the width question. How many tool calls can one message carry?
1 tool calls -> 1 ToolMessages ( 11.8 ms) OK 10 tool calls -> 10 ToolMessages ( 11.7 ms) OK 100 tool calls -> 100 ToolMessages ( 89.9 ms) OK 1000 tool calls -> 1000 ToolMessages ( 651.7 ms) OK 5000 tool calls -> 5000 ToolMessages ( 2215.3 ms) OKNo cap, no warning, no error. And max_concurrency behaves on this surface exactly as Part 6 measured it behaving on Send:
n=50, no config -> peak simultaneity 12 | tools executed 50n=50, max_concurrency=2 -> peak simultaneity 2 | tools executed 50n=50, max_concurrency=5 -> peak simultaneity 5 | tools executed 50Simultaneity is bounded. Cardinality is not. All fifty ran every time. The unset peak of 12 is min(32, cpu + 4) on an eight-core machine, which is a default of the standard library rather than a decision LangGraph made about your agent.
The ToolNode reference documentation does not mention a cap either. Its only words about parallel dispatch are "Manages parallel execution, error handling."
The rendered graph carries none of this. draw_mermaid() for a graph containing a ToolNode is 292 characters and contains no count. It cannot contain one. The drawing is produced before any state exists, and the width only exists once a model has emitted a message. That is the same structural reason Part 6 gave for the single dotted Send edge.
One number I am deliberately not giving you: provider limits. Search results will tell you that one vendor supports 128 tools and another 64. Those are limits on how many tool definitions you may send, not on how many calls one turn may emit. I could not find a documented bound on calls per turn from any provider, and I am not going to invent one.
Why LangGraph interrupts cause duplicate tool execution in parallel tool calls
Now the failure this article opened with.
On 24 December 2025, James Heavey filed langgraph issue #6624: "ToolNode doesn't collect all interrupts from parallel tool execution." The report says that "only one interrupt gets captured in the state, despite all three tools executing and calling interrupt()."
That is Part 6's width surface meeting Part 7's rule that a node body executes interrupts + 1 times. I reproduced it at the pin, with three approval-gated tools in a single assistant message:
3 gated tool calls dispatched in ONE messagetool bodies that started : ['body-0', 'body-1', 'body-2']interrupts collected : 1 -> {'approve': 0}Three bodies ran. One interrupt was collected. So the human sees one approval request when three tools wanted one.
Then I drove it to completion, because a partial run tells you nothing about whether the thing terminates:
initial invoke: bodies=3 interrupts=1 next=('tools',)resume 1: +3 bodies (total 6) | interrupts pending 1resume 2: +3 bodies (total 9) | interrupts pending 1resume 3: +3 bodies (total 12) | interrupts pending 0converged : True after 3 resume(s)tool bodies executed : 12 for 3 logical approvalsToolMessages produced: 3 (expected 3) -> did-0:yes -> did-1:yes -> did-2:yesIt converges. It is not a hang and not a livelock, and I would have written that it was if I had stopped after the first resume. Three approvals, three tool messages, correct contents. The synchronous and asynchronous paths produced identical counts at every width I tested, all of which are below the sync executor's default peak of 12. Above that I would expect them to diverge and I have not checked.
It costs twelve executions of the tool body.
I then tried to turn that into a law, and failed, which is worth showing because the failure is the finding. My hypothesis was that width W needs W resumes and re-runs all W bodies each pass, giving W * (W + 1) executions. Measured across widths 1 to 6:
| Width | Body executions | Resumes | W * (W+1) | Fits |
|---|---|---|---|---|
| 1 | 2 | 1 | 2 | yes |
| 2 | 5 | 2 | 6 | no |
| 3 | 12 | 3 | 12 | yes |
| 4 | 17 | 4 | 20 | no |
| 5 | 23 | 5 | 30 | no |
| 6 | 42 | 6 | 42 | yes |
No formula fits. Worse, when I ran the sweep five times, width 5 was not even stable: 23, 24, 24, 24, 23, while the other widths repeated exactly. So the execution count is scheduling-dependent and I will not give you a growth law for it.
One thing does hold across every width I measured: bodies <= W * (W + 1), tight at widths 1, 3 and 6. I will state that as an upper bound and not as a law, because the mechanism is a thread pool and I have not tested past width 6.
What is also stable: the number of resumes always equals the width, and the final tool message count is always the width and always correct.
The number that actually matters is the one below the gate
Counting tool-body entries understates this, and understating it is what my own first draft did. The interesting count is how many times execution gets past the interrupt() to the part that moves money. So I instrumented above and below the gate separately.
That count is not scheduling-dependent. It is exact, and it repeated identically across five trials at every width:
| Width | Post-gate effects | W(W+1)/2 | Times the first ticket is charged | ToolMessages |
|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 1 |
| 2 | 3 | 3 | 2 | 2 |
| 3 | 6 | 6 | 3 | 3 |
| 4 | 10 | 10 | 4 | 4 |
| 5 | 15 | 15 | 5 | 5 |
| 6 | 21 | 21 | 6 | 6 |
Post-gate effects are exactly W(W+1)/2, and the first tool call in the batch executes its effect once for every call in the batch. The pattern is triangular because each resume re-runs every body from the top, and each pass carries one more of them past its now-satisfied gate.
I refused to give you a growth law for the tool-body count two paragraphs ago because it was unstable. This one I will state as a law, because five trials at six widths produced identical numbers with no variance at all. The difference between those two cases is the only reason either statement is worth anything.
Read the last two columns together. At width six the customer is charged twenty-one times, the first ticket is charged six times, and the conversation shows six refunds with the right amounts. Every assertion an evaluation harness can reach agrees that this worked. Distinct approval values land positionally, too. Feeding ALPHA, BETA, GAMMA to three gates produced exactly did-0:ALPHA, did-1:BETA, did-2:GAMMA. So the second symptom in issue #6624, mismatched resume values, did not reproduce in this configuration. I am not claiming it never happens; I am reporting that I looked for it here and did not see it.
Which leaves the finding that matters. Here is the entire assertion surface after the width-3 run:
final ToolMessages : 3 <- an output assertion passescontents : ['did-0:yes', 'did-1:yes', 'did-2:yes']checkpoints on the thread : 3metadata['writes'] non-None : 0 of 3pending interrupts at end : 0ACTUAL tool body executions : 12Nothing there distinguishes three executions from twelve. Part 7 argued that evaluation asserts on what the model said and what the world shows, and never on what the runtime kept. This is that argument with a number attached: the answer is right, the world is right, and the runtime did four times the work, and the runtime's own records do not contain the difference.
The interrupt guarantee is real. Approval is genuinely required, and nothing executes past the gate without it. Its unit is one interrupt per resume, not one interrupt per gated call. No artifact says so.
First, the part LangChain already solved
Before I show you a hand-rolled node, the honest disclosure: the shipped HumanInTheLoopMiddleware does not have this problem. Part 3 introduced it as an after_model hook that gets its own node. I measured it against the same three-call message:
3 gated tool calls in one messageinterrupts raised : 1 payload is a dict carrying an action_requests list of 3tool bodies run so far: 0One interrupt for the whole batch, carrying all three requests, raised before any tool body runs. That node is the checkpoint membrane doing exactly the job it exists for. That is the correct design, and it is first-party. If your gating need is approval, use it, and the twelve-executions failure never happens to you.
So be precise about what the wrong way actually is. It is not "approval in LangGraph is broken." It is that interrupt() called inside a tool body - a documented pattern, and the one issue #6624 reports - meets the re-execution rule and the width surface at the same time. The built-in avoids it by sitting one node earlier.
Right way: bound the width and gate the batch in one node
What the built-in does not do is bound the width. It will happily raise one approval for a forty-call batch and then dispatch all forty. So the node below is not a replacement for HumanInTheLoopMiddleware on the approval axis, where the middleware is better. It earns its place on the width axis, which nothing in the runtime covers, and it is the same shape Part 6 shipped as bounded_fan_out.
Put one node in front of the dispatch, where the whole tool-call list is visible at once, and make it do both jobs.
from langchain_core.messages import AIMessage, ToolMessagefrom langgraph.graph import END, START, MessagesState, StateGraphfrom langgraph.prebuilt import ToolNodefrom langgraph.types import interruptHARD_CEILING = 25 # above this, refuse outrightMAX_DISPATCH = 5 # above this, truncatedef _refuse(calls, reason: str) -> dict: """Answer EVERY call being refused, so no tool_call_id is left open.""" return {"messages": [ *[ToolMessage(content=reason, tool_call_id=c["id"], name=c["name"]) for c in calls], AIMessage(content=reason), ]}def bound_and_approve(state: MessagesState) -> dict: """Bound the dispatched width, then take ONE approval for the batch.""" msg = state["messages"][-1] calls = list(getattr(msg, "tool_calls", []) or []) if not calls: return {} assert msg.id is not None, "add_messages substitutes by id" requested = len(calls) if requested > HARD_CEILING: return _refuse(calls, f"refused: {requested} calls exceeds {HARD_CEILING}") dispatched = calls[:MAX_DISPATCH] decision = interrupt({ "action": "approve_batch", "width_requested": requested, # carry both, per Part 6 "width_dispatched": len(dispatched), "calls": [{"name": c["name"], "args": c["args"]} for c in dispatched], }) if decision != "approve": return _refuse(calls, "refused by reviewer") # Substitution by id REMOVES the dropped calls from history, so they must # NOT also be answered - a tool_result with no tool_use is the mirror defect. return {"messages": [msg.model_copy(update={"tool_calls": dispatched})]}g = StateGraph(MessagesState)g.add_node("gate", bound_and_approve)g.add_node("tools", ToolNode([refund]))g.add_edge(START, "gate")g.add_edge("gate", "tools") # dispatch only happens past the gateg.add_edge("tools", END)app = g.compile(checkpointer=InMemorySaver()) # interrupt() requires oneThe _refuse helper is not defensive coding. My first version of this node returned a bare AIMessage on both refusal paths, and peer review caught what that does. MessagesState uses add_messages, so a message with no id is appended, not substituted. The original assistant message stays in history carrying all its tool_calls, and nothing answers them. I measured it: the ceiling refusal left 40 orphaned tool_call_ids and the reviewer refusal left 3.
The toy graph never notices, because tools goes straight to END and there is no second model call. In the agent loop this node is written for, the next model call ships that history to the provider and gets a 400. OpenAI: an assistant message with tool_calls must be followed by tool messages responding to each tool_call_id. Anthropic: tool_use ids were found without tool_result blocks. That is Part 1's orphaned-handshake rule, and my own fix broke it.
Then the correction introduced its own defect, which is worth showing because it is the same trap one layer down. My first repair also answered the truncated calls on the approval path. But the approval path substitutes the message by id, so those calls no longer exist in history, and answering them produced 7 dangling tool_results with no matching tool_use - the mirror error. Refuse and you answer everything, because the calls stay. Approve and you answer nothing extra, because the substitution removes them. Both directions measured at zero only after that.
Measured:
requested= 3 -> interrupts=1 resumes=1 effects=3 toolmsgs=3 (capped at 5)requested= 5 -> interrupts=1 resumes=1 effects=5 toolmsgs=5 (capped at 5)requested= 12 -> interrupts=1 resumes=1 effects=5 toolmsgs=5 (capped at 5)requested= 40 -> interrupts=0 resumes=0 effects=0 refused above ceilingdenied -> effects=0 nothing firedWidth 3 goes from twelve tool-body executions and three resumes to three and one. More to the point, post-gate effects go from six to three: each refund fires exactly once, the first ticket is no longer charged three times, and the triangular growth disappears because the batch is approved once instead of once per member. A twelve-call message dispatches five. A forty-call message never reaches a tool. And the reviewer sees the whole batch in one decision, with width_requested beside width_dispatched, instead of approving one refund while two more happen invisibly. Batching the decision is also what makes a risk-based approval architecture affordable, because it costs one human interaction per batch rather than one per call.
Carrying both numbers is Part 6's rule and it matters here for a reason specific to this surface: width_requested is the only place the model's actual choice is recorded. Truncate without carrying it and you have destroyed the evidence that the model asked for twelve.
Two things this does not fix, and you should not let it look like it does.
It does not make the tool body run exactly once in general. Anything that re-enters this thread still re-runs bound_and_approve under Part 7's rule, and a fork charges again. What it does is remove the case where width multiplies the re-execution, and put every irreversible line below the gate rather than above it.
And it does not record anything. If you need to know how many times an effect fired, no arrangement of interrupts will tell you, because Part 7 measured that the count is not on the thread. That needs the ledger with an attempts counter that Part 7 ended on: a row you own, outside the rollback surface, incremented on conflict. Gating the batch is the cheap structural fix. The ledger is the one that makes the failure visible.
If you genuinely need one approval per call, Part 4's map form is the shape that survives: Command(resume={interrupt_id: value}), which Part 7 verified holds up across sequential single-gate resumes with the answers preserved in order.
flowchart TD
G["A guarantee the runtime gives you<br/>checkpoint, limit, reducer, interrupt"] --> Q1{"What unit does it<br/>actually hold over?"}
Q1 -->|"you can answer it"| Q2{"Which rendered artifact<br/>states that unit?"}
Q1 -->|"you cannot answer it"| SB["Scope-Blind Guarantee<br/>audit it or do not rely on it"]
Q2 -->|"a diagram, a type,<br/>a doc that names it"| OK["Specified<br/>a reviewer can check your assumption"]
Q2 -->|"none"| SB
SB --> F1["Part 2: within one superstep, not always"]
SB --> F2["Part 4: one invocation, not the thread"]
SB --> F3["Part 6: depth, not total work"]
SB --> F4["Part 8: one key last-write-wins, not merged memory"]
style G fill:#4A90E2,color:#FFFFFF
style Q1 fill:#7B68EE,color:#FFFFFF
style Q2 fill:#7B68EE,color:#FFFFFF
style OK fill:#6BCF7F,color:#2C2C2A
style SB fill:#E74C3C,color:#FFFFFF
style F1 fill:#FFD93D,color:#2C2C2A
style F2 fill:#FFD93D,color:#2C2C2A
style F3 fill:#FFD93D,color:#2C2C2A
style F4 fill:#FFD93D,color:#2C2C2A
LangGraph vs Temporal, DBOS, Convex and Microsoft: does the criterion discriminate?
A criterion that flags everything is not a criterion. So I applied it to four other durable runtimes, and this is the part of the article I was least sure about before doing the work.
It discriminates, and it is worth being precise about how. Under question 2 of the audit - which artifact states the unit - all four comparators pass and LangGraph mostly fails. That is a binary. The ranking I give below is a second and softer axis, how hard the unit is to miss, and it is a judgement rather than a test.
Start with the single most useful sentence I found while researching this article, from Temporal's workflow definition page:
"A Workflow Execution effectively executes once to completion, while a Workflow Function Execution occurs many times during the life of a Workflow Execution."
That is the phenomenon this article opened with. The function body runs many times per logical execution. Temporal states it in one sentence, on the page that defines the concept, using two distinct nouns for the two units. Part 7 measured LangGraph doing the identical thing and found no sentence, no noun, and no field.
Same physics. Opposite disclosure.
It is not an isolated sentence either. Temporal's retry policy documentation says a retry policy "instructs the Temporal Service how to retry a failure of either a Workflow Execution or an Activity Task Execution." It then states that activities retry by default, while "Workflow Executions do not retry by default." It then adds that retry policies "do not apply to Workflow Task Executions." Three units, three names, three different defaults, stated together. Its timeout documentation does the same thing: Workflow Execution Timeout covers "retries and any usage of Continue As New," Workflow Run Timeout "does not include retries or Continue-As-New," and Workflow Task Timeout is about a worker holding a task. The inclusion rule is stated for each.
The others do it in their own register, and not equally well. I would rank them by how hard the unit is to miss, which is a different question from how well documented it is.
Convex comes closest to Temporal. Its actions documentation puts the negative case in the same sentence as the positive, so you cannot read one without meeting the other: "multiple runQuery / runMutations execute in separate transactions and aren't guaranteed to be consistent with each other," while "a single runQuery / runMutation will always be consistent." The boundary is not in a note further down the page. It is in the clause.
Microsoft's durable extension does the single thing I would most want from LangGraph. It tells you outright that two different things are both called checkpointing, and that its own checkpointing "is different from checkpoint storage in standard workflows." That is a collided noun being disambiguated on purpose, which is exactly what recursion_limit needs and does not have.
DBOS states its unit clearly enough - the step, with "one database write per step" - and it is honest about what it does not promise. It never claims exactly-once. Instead it states the requirement that falls on you: "steps should be idempotent, meaning it should be safe to retry them multiple times." It also warns that a change to which steps run "makes the workflow difficult to recover." The unit is there, but you meet it while reading an architecture page rather than while typing a name, which is the weakest of the four positions.
Now the finding, which is narrower than a ranking.
Every one of those four discloses the unit in prose only. None of them renders scope into a diagram, and none puts it in the type system in a way a type checker enforces. Temporal's workflow sandbox and its Replayer determinism check are real enforcement, but they police determinism rather than scope. If you do not read the documentation, you are in the same position with Temporal as with LangGraph. What Temporal does consistently, and LangGraph does only once, is put the unit in the noun: WorkflowExecutionTimeout and WorkflowRunTimeout and WorkflowTaskTimeout are three different names because they are three different units, and you cannot type one while meaning another.
LangGraph has one recursion_limit. Part 4 measured it scoped per invocation. Part 6 measured it scoped to depth. Two different scope facts, one name, and the name says neither.
So the finding is: Temporal puts the unit in the noun for its three timeouts, and LangGraph does it for recursion_limit nowhere. Not "Temporal is better," and not "LangGraph never does it."
That second qualification is not a hedge, because LangGraph does do it once, and the counter-case is instructive enough that leaving it out would be dishonest. ToolCallLimitMiddleware(thread_limit=20) puts the unit in the parameter name. Part 3 recommended it for exactly that clarity. Then Part 4 went and measured it, and found something my three properties do not cover: thread_limit is named honestly, the reader's assumption about its unit is correct, and the guarantee is still only approximate, because the counter lives in graph state and rolls back with the checkpoint on a crash. Part 4 called that the Halfway pattern.
So thread_limit is not a Scope-Blind Guarantee. It fails the second property outright: the unit is not misread. It is a strength problem, which is diagrid's axis, not mine. That is the criterion doing its job rather than failing it. A test that flagged everything in the runtime would be worthless, and this one declines to flag the one parameter that names its unit properly.
max_concurrency sits in the same category, and this article measured it: it says simultaneity and it means simultaneity. Both times I checked, the LangGraph name that states its unit turned out to state it correctly. The problem is how few of them there are.
Why LangGraph production bugs never become bug reports
There is a reason this class of problem does not show up in the places you would look for it.
The state of the art in static verification for agent graphs is Agentproof, which extracts an abstract graph model from LangGraph, CrewAI, AutoGen and Google ADK, and checks reachability, isolation and temporal properties exhaustively. It is good work. Its threat model contains this line:
"The approach assumes that the framework API semantics (e.g., LangGraph's StateGraph, CrewAI's Crew) are correct: the extractor faithfully reads the graph structure that the framework will execute."
That assumption is reasonable, and it is exactly where these findings live. Agentproof would read a graph whose draw_mermaid() output is byte-identical whether the agent has one tool or twenty, and see nothing wrong, because nothing is wrong with the graph. It would read a fan-out that dispatches a thousand workers as one node, because it is one node. The verifier is sound with respect to the artifact. The artifact is silent about the unit.
This is also why these do not become bug reports. A bug report needs a violated expectation you can point at. "The reducer merged in an order I did not expect" gets closed with a link to the superstep documentation, correctly. "My recursion limit did not stop the run" gets closed by explaining that width is not depth, correctly. Every individual case has a correct answer that makes the reporter look like they did not read the docs. The pattern only becomes visible when you line seven of them up. Retrieval has the same structural problem, which I have called the evals blind spot: the measurement everyone runs cannot see the failure everyone has.
Which is also, I think, why the migration literature is empty. I looked for a dated post-mortem from a named company that moved off LangGraph and found none. The genre that exists is about LangChain, mostly from 2024, and it is the opposite complaint: too much abstraction, not too little. That is an absence of evidence rather than evidence of absence. My reading of it - that these produce incidents attributed to the model rather than migrations - is a hypothesis I cannot test from public sources.
When not to use LangGraph
The saturated answer is that you should not use a framework for simple flows. That is true and it is not worth your time.
Here is the answer this article's criterion produces. LangGraph is a low-level runtime, and its own documentation is straightforward about that: it "does not abstract prompts or architecture," and LangChain's product page positions it as the alternative to "a single black-box cognitive architecture." That is an accurate description of what you get and it is the reason to choose it. The machinery is exposed.
Exposed machinery has a cost, and the cost is that the units are yours to establish. Seven parts of this series are the invoice. Every one of those findings took a probe, a measurement, and in several cases a reversal after the first measurement was wrong. That is the work the framework leaves you, and it is the same trade Rivera's guide describes as the harness you build around the framework.
So LangGraph is the wrong tool when you cannot pay that. Concretely:
- Nobody on the team will read the runtime's source. Several findings in this series are source-only. Part 2's fold order is in
apply_writesand documented nowhere. If readingpregel/_loop.pyis not something your team will ever do, you are relying on assumptions you have no way to check. - You need the vendor to own the units. Your compliance story may require a documented guarantee with a stated scope that somebody else is accountable for. Then a runtime that names its units in its type names beats one that names them nowhere. A managed durable-execution service beats either.
- Your failure mode is money or data, and you have no system of record. Every fix in this series that had to survive a crash, a fork or a concurrent writer pushed the record outside the runtime's rollback surface: Part 4's grant ledger, Part 7's attempts counter, this part's per-writer keys. The purely structural fixes - Part 2's commutative reducer, Part 3's
wrap_tool_callrefusal - stay inside it, and that is fine, because nothing about them needs to outlive a rollback. If you are not going to build that, the checkpointer is not going to save you, which is the accurate half of diagrid's critique. - You need to change workflow code while executions are in flight. This one is not about your team at all. Temporal ships
patched()and worker versioning for exactly this, and DBOS warns you about it in the passage quoted above. LangGraph has neither. A thread parked on a Friday interrupt is resumed on Monday against whatever node graph is deployed then, and nothing checks that the two agree. For workflows measured in days or weeks that is a hard blocker, and you cannot read your way out of it. Durable timers for waits measured in days are a second one, and multi-language workers a third. - You do not have a second, deliberately warm test suite. Part 7's argument was that a first-entry harness cannot exercise these. If your testing discipline is "start every test from a clean database," it is structurally blind to the cases this series measured.
And the inverse, which matters just as much: a runtime that hides the machinery does not remove the units. It removes your ability to audit them. Every finding here exists because LangGraph let me look. That is a point in its favour, and it is why this series exists at all.
There is a harder version of this I have been circling for seven articles, and I should say it plainly. The explicit-control positioning is part of what licenses the silence.
A framework that promises to hide complexity owes you a statement of what it hides. That is the whole contract, and when it breaks, you file a bug. A framework that promises to expose the machinery makes the opposite promise, and "you have full control" quietly implies that you already know what you are controlling. It shifts the burden of knowing onto the reader by design. So when recursion_limit turns out to mean per invocation in one place and depth in another, nothing has been violated. You had the source. You could have read pregel/_loop.py.
I do not think this is cynical, and I do not think it is deliberate. It is what low-level positioning does to a documentation culture. The team building the runtime knows what a superstep is, so the unit is obvious to everyone in the room, and an obvious thing does not get written down. Expertise creates the gap.
Which is why "read the source" is not the answer, even though it is what I did. Seven parts of probes is not a review practice you can put in a sprint. The answer is that a runtime aimed at production should name its units in the API surface, the way Temporal named three timeouts instead of one. That is a design change. It is the specific thing I would ask LangGraph for.
The runtime scope audit: three questions to ask
Here is the procedure. It is short on purpose.
For each guarantee your runtime gives you, answer three questions in writing:
- What is the unit? Per invocation, per thread, per superstep, per node, per key, per entry into the state. Write the noun.
- Which artifact states it? A rendered diagram, a type name, a documentation sentence you can link, a field in the checkpoint. If the answer is "I inferred it from behaviour," that counts as no artifact.
- What happens at the boundary? What occurs on the second invocation, the concurrent write, the resume, the fork, the wider fan-out.
Any guarantee where question 2 has no answer is a Scope-Blind Guarantee. That does not mean stop using it. It means the unit is now your assumption to document and your test to write, and it should be in your design review rather than in your incident review.
Filled in for LangGraph 1.2.9, so you can copy the rows rather than re-derive them:
| Guarantee | Unit | Artifact that states it | What happens at the boundary |
|---|---|---|---|
| the task-path sort that breaks ties | within one superstep | none | across supersteps the order comes from control flow instead, and is still deterministic |
draw_mermaid() soundness | node | the drawing, at node granularity only | identical output for 1 and 20 tools; an undeclared Send target runs and is not drawn at all, which breaks soundness rather than coarsening it |
recursion_limit | one invocation, and depth | none | a resume grants it again; width costs one superstep |
RemoveMessage | the current channel value | live state, which shows the trim | delete_thread is the only primitive reaching storage, and it takes the whole thread |
interrupt() | one interrupt per resume | none | width multiplies tool-body re-execution |
BaseStore.put | one key, last write wins | none | a concurrent read-modify-write loses one write |
| checkpoint history | per thread | get_state_history | a fork adds checkpoints that are not marked as a fork |
| eval assertions | the first entry into a thread | the test report, which cannot say this | a warm thread exercises code the suite never built |
Only draw_mermaid() and get_state_history put anything on an artifact, and both are partial. That is the honest summary of this series.
For LangGraph specifically, the series ships four audit tools, and they compose. Part 2's print_merge_policy reports which state keys have a merge policy and tests commutativity on sampled values. Part 3's print_agent_surface reports the dispatch alphabet and middleware composition order. Part 6's print_width_surfaces finds the places model output becomes a dispatch. And this part adds the boundary checks the others do not cover:
from langgraph.store.base import BaseStoretry: # Part 6's checklist says not to import this; there is no public accessor from langgraph._internal._config import DEFAULT_RECURSION_LIMITexcept ImportError: # private path, may move DEFAULT_RECURSION_LIMIT = Nonedef print_scope_report(compiled, config: dict | None = None) -> None: """Report the guarantee units this runtime does not put on an artifact. Composes with print_merge_policy (Part 2), print_agent_surface (Part 3) and print_width_surfaces (Part 6). `config` is the config you intend to invoke with, because a compiled graph does not carry one. """ cfg = config or {} print("== bounds ==") limit = cfg.get("recursion_limit", DEFAULT_RECURSION_LIMIT) source = "your config" if "recursion_limit" in cfg else "the library default" print(f" recursion_limit = {limit} (from {source})") print(" NOT readable from the compiled graph: it is an invocation value.") print(" unit: ONE INVOCATION, and DEPTH. Not the thread, not total work.") print(" a resume grants it again; a fan-out of any width costs 1 superstep.") print("== durable state ==") cp = getattr(compiled, "checkpointer", None) print(f" checkpointer: {type(cp).__name__ if cp else 'NONE'}") if cp is None: print(" get_state and get_state_history raise: No checkpointer set.") print(" an eval harness compiled this way has no object to assert on.") print("== cross-thread memory ==") store: BaseStore | None = getattr(compiled, "store", None) if store is None: print(" no store: cross-thread memory is not in play") else: print(f" store: {type(store).__name__}") print(" unit: ONE KEY, LAST WRITE WINS. No compare-and-swap exists.") print(" read-modify-write from two threads loses one write silently.") print(" search() defaults to limit=10 - always pass an explicit limit.") print("== what this tool CANNOT check ==") for gap in ( "how many times a node body ran (not recorded on the thread)", "how many tool calls one model turn will emit (no cap exists)", "whether a side effect sits above an interrupt (read the body)", "whether your effects are idempotent (only your ledger knows)", ): print(f" - {gap}")Part 6's own checklist told you not to import langgraph._internal._config, because it is private and may move. The tool does it anyway, behind a try, because there is no public accessor for the default. That is a small instance of this article's subject showing up in the article's own tool.
That config parameter is not an API convenience. My first version of this function read compiled.config, which exists and is None, so the tool crashed with an AttributeError the first time I ran it. recursion_limit is an invocation value, not a property of the compiled graph. The bound's value is not on the artifact either, which is the article's own thesis turning up inside the article's own tool.
Run against a graph compiled with no checkpointer, it prints this:
== bounds == recursion_limit = 10007 (from the library default)== durable state == checkpointer: NONE get_state and get_state_history raise: No checkpointer set. an eval harness compiled this way has no object to assert on.Four lines, and both facts on them took a probe to learn.
The last block is not a joke at the tool's expense. A tool that reads the framework's artifacts inherits the framework's silences, which is the argument of the whole article compressed into a function. The four gaps it prints are the four things you have to establish yourself, and they split two and two. For the execution count and for idempotence, the shipped fix was the same shape: record it outside the rollback surface, in a table you own, with a compare-and-swap you wrote. The other two are structural and cost nothing to store: bound the width before the dispatch, and put every irreversible line below the gate.
What stays true as the ecosystem churns
This article is pinned to langgraph 1.2.9, langchain 1.3.14, langchain-core 1.5.1 and langgraph-checkpoint-sqlite 3.1.0 on Python 3.13.9, measured on 1 August 2026. Version 1.2.10 shipped on 28 July 2026. I checked it rather than assuming, by downloading the wheel and reading it without disturbing the pin: only two files differ from 1.2.9, and both changes are type annotations. pregel/_loop.py, which is where Part 7's re-execution rule lives, is byte-identical.
That will not stay true. The specific numbers in this series have a shelf life, and some of them will be wrong within a year. DEFAULT_RECURSION_LIMIT was 25, then 1000 in the documentation, and is 10007 in the shipped source that Part 6 measured. That is three values for one constant.
One debt goes unpaid. Part 6 owed you a traced and costed four-hundred-way fan-out, and moved it here. This part did not get to it. The shape of the answer is bounded by your provider's per-call price and the writes-table row growth Part 6 already published, and I would rather leave the debt named than pay it badly.
The question survives the numbers. It is not a LangGraph question. Ask it of the next runtime, and of the next version of this one:
For each guarantee, what is the unit, and which artifact tells me?
If the answer is a noun you can point at, the vendor did the work. If the answer is "I ran a probe and found out," you are holding a Scope-Blind Guarantee, and it is now yours to write down.
Part 2 of this series described its defect as a state-management failure wearing a reasoning failure's clothes. I still think that is right, and I would now say it more precisely. They are unit failures wearing a reasoning failure's clothes. The state was managed. The bound was enforced. The checkpoint was written. All of it was correct, over a unit nobody wrote down, and the model got the blame.
References
- Gray, J., Lorie, R., Putzolu, G., & Traiger, I. (1975). Granularity of Locks and Degrees of Consistency in a Shared Data Base. IBM Research Report RJ1654.
- Spolsky, J. (2002, November 11). The Law of Leaky Abstractions. Joel on Software.
- Evans, E. (2003). Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley. (The aggregate as consistency boundary.)
- Kleppmann, M. (2015, May 11). Please stop calling databases CP or AP. https://martin.kleppmann.com/2015/05/11/please-stop-calling-databases-cp-or-ap.html
- Kingsbury, K. (2020, January 30). Jepsen: etcd 3.4.3. Jepsen. https://jepsen.io/analyses/etcd-3.4.3
- Zhu, X., Wu, J., Zhang, X., Li, T., Mu, Y., Zhai, J., Shen, C., Fang, C., & Liu, Y. (2026). Bugs in Modern LLM Agent Frameworks: An Empirical Study. FSE '26 Companion. arXiv:2602.21806. https://arxiv.org/html/2602.21806v2
- Xavier, M., Vaisakh M A, Jolly, M., & Xavier, M. (2026, March 20). Agentproof: Static Verification of Agent Workflow Graphs. arXiv:2603.20356. https://arxiv.org/html/2603.20356v1
- Santos-Grueiro, I. (2026, July 11). Temporary Authority, Permanent Effects: Commit-Time Authorization for LLM Agents. arXiv:2607.10487. https://arxiv.org/abs/2607.10487
- Yang, T., Li, J., Nian, Y., Dong, S., Xu, R., Rossi, R., Ding, K., & Zhao, Y. (2026, April 1). No Attacker Needed: Unintentional Cross-User Contamination in Shared-State LLM Agents. arXiv:2604.01350. https://arxiv.org/abs/2604.01350
- Schneider, Y. (2026, March 2). Still Not Durable: How Microsoft Agent Framework and Strands Agents Repeat the Same Mistake. Diagrid. https://www.diagrid.io/blog/still-not-durable-how-microsoft-agent-framework-and-strands-agents-repeat-the-same-mistake
- Rivera, A. (2026). Best AI Agent Frameworks in 2026: A Builder's Guide. Agent Harness. https://agent-harness.ai/blog/best-ai-agent-frameworks-in-2026-a-builders-guide/
- Heavey, J. (2025, December 24). ToolNode doesn't collect all interrupts from parallel tool execution. langchain-ai/langgraph issue #6624. https://github.com/langchain-ai/langgraph/issues/6624
- Peilun-Li. (2025, October 30). Feature Request: support concurrency safe store.put operations. LangChain Forum. https://forum.langchain.com/t/feature-request-support-concurrency-safe-store-put-operations/2014
- LangChain. LangGraph overview. Official documentation. Accessed 2026-08-01. https://docs.langchain.com/oss/python/langgraph/overview
- LangChain. LangGraph. Product page. Accessed 2026-08-01. https://www.langchain.com/langgraph
- LangChain. ToolNode. API reference. Accessed 2026-08-01. https://reference.langchain.com/python/langgraph.prebuilt/tool_node/ToolNode
- Temporal. Workflow definition. Accessed 2026-08-01. https://docs.temporal.io/workflow-definition
- Temporal. Retry Policies. Accessed 2026-08-01. https://docs.temporal.io/encyclopedia/retry-policies
- Temporal. Detecting Workflow failures. Accessed 2026-08-01. https://docs.temporal.io/encyclopedia/detecting-workflow-failures
- Microsoft. van Valkenburg, E. Durable Extension, Microsoft Agent Framework. Updated 2026-07-10. https://learn.microsoft.com/en-us/agent-framework/integrations/durable-extension
- Convex. Actions. Accessed 2026-08-01. https://docs.convex.dev/functions/actions
- DBOS. Architecture. Accessed 2026-08-01. https://docs.dbos.dev/architecture
Related Articles
- LangGraph or a While Loop? You Already Have a Runtime
- LangGraph Checkpoints Restore Your Limits, Not Just Your State
- LangGraph Evals Test the Answer, Not the Thread
- State Architecture for Agent Networks: The Resume Is the Dangerous Part


