← Back to Blog
For: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

What Three Audits Found in My Own LangGraph Book

The factual claims held. What broke was scope - and the audit that caught the book's scope error made one of its own.

#langgraph#agent-runtime#durable-execution#testing#production-ai

LangGraph publishes three different defaults for recursion_limit, and only one of them is wrong - which is not the same as only one of them being right.

The source says 10007. The Graph API documentation says "starting in version 1.0.6, the default recursion limit is set to 1000 steps", while _config.py at the 1.0.6 tag reads 10000, so that sentence is out by a factor of ten about the version it names. The Platform SDK's Config schema says 25.

That third one is a different surface, and saying so is the whole point of this article. The SDK default governs runs submitted to the Platform; the library default governs a graph you invoke in your own process. Two of those three numbers describe different things, and only one pair is a genuine contradiction. Working out which is the job.

Part 6 of the measurement series got as far as the first two and filed the third as "the historical value older material still quotes". That was one scope short. It is not historical - it is the live default on a surface the library user never touches, which is a different and more useful thing to know.

I wrote a book about LangGraph. It says 10007, which is the value in the source, and it has said so for longer than the documentation has been wrong about it. That is the good news. Then I stopped reviewing the book and started measuring it.

The errors that survive review are scope errors

Here is the claim this article owns.

In a fast-moving framework, the wrong sentences that survive expert review are almost never false. They are true at a scope nobody wrote down.

Two things survive review, not one. Systems fail by composition, which is what the panel found when it discovered Atlas had never run end to end. Sentences fail by scope. Composition failures at least announce themselves the moment anyone runs the whole thing; scope errors are still sitting there afterwards, reading correctly, in a book that has been through three audits. They hold for the case the author had in mind, fail for the case the reader is in, and no amount of re-reading catches the difference, because re-reading checks whether a sentence is true and the defect is in the sentence's silent quantifier.

The consensus belief I am arguing against is the comfortable one: that a technical book about a fast-moving framework goes wrong by going stale. Versions move, APIs get renamed, the text rots. Everyone budgets for that failure, and it is the one that had not happened yet.

Be careful with that evidence, because it is weaker than it looks. The currency pass ran in July, weeks after the last chapter commit, and found zero drift. A staleness audit run that soon finding nothing is the expected result, not a refutation of anything. What it does establish is the part I care about. Before staleness had any time to happen, the errors that had already survived expert review were scope errors. Staleness is the failure that arrives later, and it is the one you already have a process for.

What did happen is that two claims turned out to be scoped to a case I had not stated, one whole subsystem was never exercised end to end, and then the pattern kept going: the audit that caught the first scope error committed one of its own while writing the correction down, and the one-word fix I drafted for that turned out to be scoped as well. Three layers, each written by someone who by then knew exactly what to look for. That last part I did not expect.

Three audits, three different failure classes

Building Real-World Agentic AI Systems with LangGraph is 27 chapters, 7 appendices, and one support assistant called Atlas built end to end - triage, retrieval, tools, a refund path behind a human approval gate. It has been audited three times, by three methods that do not overlap.

AuditMethodWhat it found
Currency passRe-verify every time-sensitive claim against the webZero drift. It also declined to chase a newer patch release, keeping the pin as a deliberate snapshot
Five-expert panelFive adversarial expert personas, one pass each, prompted to break rather than approveAtlas had never answered a customer question, in the book or in the repo. Middleware built across three chapters existed and none of it executed in the compiled graph
Measurement series77,568 words of probes against the running framework2 contradictions, 5 omissions, 0 dangerous errors

All three rows are self-assessed, which is worth saying before I lean on them. Every chapter was reviewed. Every code listing ran in isolation. The test suite passed. And the composition of those correct parts had never been executed, so nothing in the review process was looking at the thing that was broken.

That is a composition failure. Review is bad at those, because every part it looks at is correct. The third row is the failure this article is about. The fix took the companion suite from 353 tests to 379.

The wrong way: driving a resume loop with snapshot.next

Chapter 9 of my book shipped with this comment on the snapshot API:

code
snapshot.next        # node(s) pending; empty tuple () means the run finished

That sentence is true. For the scenario the chapter is about, a process that died mid-run, it is exactly right, and the surrounding prose calls .next the resumption boundary made visible.

Now carry it into the other place a LangGraph thread pauses, which is a human approval gate, and write the obvious driver:

code
from langgraph.types import Commandconfig = {"configurable": {"thread_id": "t-1"}}app.invoke(inputs, config)snapshot = app.get_state(config)while snapshot.next:    decision = ask_a_human(snapshot)    app.invoke(Command(resume=decision), config)    snapshot = app.get_state(config)print("run complete")

Now measure it. This is langgraph 1.2.6, the version the book pins:

code
PROBE A - one interrupt() in the node  after first invoke (paused)   next=('gate',)   interrupts=1  after resume (finished)       next=()          interrupts=0PROBE B - two interrupt() calls in one node  after first invoke (pause 1)  next=('gate',)   interrupts=1  after resume #1 (pause 2)     next=()          interrupts=1

Look at the last line. The graph is paused, holding a pending interrupt, waiting for a second answer. snapshot.next is empty. The loop above exits, prints run complete, and the node's work never finishes. No exception. No warning. The final state reads like a clean run.

The condition is narrower than it looks: a node containing more than one interrupt() call, from the first resume onward - which turned out to be a proxy rather than the condition, and probe H below breaks it. On a single-interrupt node at first pause the interrupting node stays in .next and the loop behaves. So the bug is invisible in every simple example, including the ones in my book, and it shows up on the multi-gate approval flows that are exactly the systems careful enough to be driving a graph in a loop.

The fix I reached for first, and why it was worse

The obvious repair is one word:

code
while snapshot.interrupts:

StateSnapshot.interrupts is documented as "interrupts that occurred in this step that are pending resolution", which is the question the loop is actually asking. I wrote that down, felt good about it, and then ran five more probes before publishing. Same version, five graph shapes.

code
PROBE C  update_state() on a paused thread     next=('gate',)    interrupts=0PROBE D  one interrupt() inside a subgraph     next=('child',)   interrupts=1PROBE E  static breakpoint, interrupt_before   next=('gate',)    interrupts=0PROBE F  node raised, ERROR write on the task  next=('gate',)    interrupts=0PROBE G  two parallel gates, one answered      next=('gb',)      interrupts=2PROBE H  one interrupt, resumed, then raised   next=()           interrupts=1

Probes E and F are the ones that matter, and I did not think to run them until a reviewer asked. A thread paused at a static breakpoint has real pending work and zero interrupts. A thread whose node raised has real pending work and zero interrupts. That is the crash-recovery case my own book chapter is about, and my improved loop never enters it. It prints run complete over an abandoned thread, which is the exact failure I had just spent two sections condemning.

All eight cases. The last two columns are the predicate read at that state, not a loop driven to termination:

Case.next.interrupts.next predicate.interrupts predicate
one interrupt(), paused('gate',)1okok
two interrupt() in a node, after resume 1()1nook
update_state() on a paused thread('gate',)0okno
static breakpoint('gate',)0okno
node raised, no interrupt('gate',)0okno
one interrupt() in a subgraph('child',)1okok
two parallel gates, one answered('gb',)2okok, but stale
one interrupt(), resumed, then raised()1nook, but stale

.next fails two cases in eight. My replacement fails three. I proposed a one-word fix for a scope error and drafted a wider one.

The last row is the one that should bother a production reader, and it is the shape of my own book's running example: a human approves a refund, the payment call throws. The node holds a resume write and an error, real work is outstanding, and .next is empty. Retrying with invoke(None, config) completes it and the approval survives - but only if something told you to retry.

The condition is not the interrupt count

While running probe G I also found that my "two or more interrupts in one node" rule was a proxy, not the mechanism. Finish the source read from earlier and it falls out. ERROR and INTERRUPT writes are skipped; RESUME is not. So a task that has taken a resume write and has not finished carries writes, and if not t.writes drops it out of .next while it still holds work.

Probe H is what discriminates the two. That node has exactly one interrupt(), so the interrupt-count rule predicts it is fine. It is not: it resumes, raises, and leaves .next empty with work outstanding. The count was never the condition. A task that has taken a resume write and has not finished is, and two interrupts in one node was just the smallest arrangement that let me observe it.

Probe G is consistent with that and proves nothing on its own, which is worth saying because I originally wrote it up as the decisive test. Both explanations predict its output identically.

And the whole finding was quantified over an accessor I never named

A reviewer asked which snapshot accessor the probes used. All of them use get_state(config) on a bare thread id. So I ran the other one at probe B's paused state:

code
get_state           : next=()          interrupts=1get_state_history[0]: next=('gate',)   interrupts=1

Same thread, same checkpoint, same field, two answers. _prepare_state_snapshot folds pending writes in only when apply_pending_writes is set; get_state sets it when you have not pinned a checkpoint_id, and get_state_history never sets it at all. So .next empties through one accessor and does not through the other.

The obvious next thought is to poll the history accessor instead and be done. That trades one scope error for another. apply_pending_writes gates the apply_writes call as well, so the snapshot you get back has the correct .next and channel values that do not yet reflect the writes the thread has taken. Right answer about what runs next, stale answer about what the state holds.

Nothing I measured is wrong. But the correct statement of this finding is "get_state(config), with no checkpoint_id pinned, on a task carrying a resume write that has not completed" - and until someone asked, I had written down the second half of that and not the first. Fourth layer, same shape, in the article that exists to point at the shape.

What to actually do: branch on both signals

There is no single field to poll. If you must drive a graph generically, branch:

code
snapshot = app.get_state(config)while snapshot.next or snapshot.interrupts:    if snapshot.interrupts:        # map form, not scalar: a scalar resume raises when more than        # one interrupt is pending        answers = {i.id: ask_a_human(i) for i in snapshot.interrupts}        app.invoke(Command(resume=answers), config)    else:        app.invoke(None, config)          # crash, error, or static breakpoint    snapshot = app.get_state(config)

Then I ran it, because every probe so far had read a field at one sampled state and none had driven a loop. Against row eight's graph:

code
iter 1: next=('gate',) interrupts=1 -> resume branch    asked a human (call 1) -> 'yes'    invoke raised: payment provider timed outiter 2: next=()        interrupts=1 -> resume branch    asked a human (call 2) -> 'yes-again-2'loop exited after 2 iterationshumans asked : 2  ['yes', 'yes-again-2']final values : {'answers': ['yes', 'done']}

It terminates and the run completes, which is the good news. It also asks a human to approve the same refund twice, and throws the second answer away - the mapped resume lands after the cached one and interrupt() reads index zero. invoke(None, config) was the correct call on iteration two and the loop never reaches it, because the interrupt write survives on a checkpoint that never advanced.

That convicts .interrupts on a softer version of the charge I brought against .next. Its docstring says "interrupts that occurred in this step that are pending resolution". The interrupt did occur in the step being reported; it is no longer pending resolution.

Row seven has a cheaper version of the same problem. .interrupts reports 2 there when only one gate is genuinely outstanding, and the loop asks a human about every entry it finds, so that row also re-prompts for an approval already given - it just delivers the remaining answer correctly afterwards.

So: progresses and terminates on all eight, and takes the branch a human would want on six or seven of them. I drove row eight end to end; on the others the branch is derived from the sampled predicate and that row's measured single-step outcome, and row three is the one I am least sure of, because invoke(None, config) on a thread waiting for a human re-runs the node before the next iteration gets round to asking. I am not claiming the loop is complete. The last two prescriptions in this article were not either.

One more caveat on the probe above: the fault it models is transient, so the retry succeeds. A node that fails permanently keeps taking the resume branch and re-prompting a human every iteration.

Better, avoid needing it:

  • One interrupt() per node, and chain the nodes. The maintainers recommend this themselves. LangGraph issue #6208 is open on the related re-execution hazard, where a maintainer states that a node with two interrupts re-runs after only one resume. That re-execution is the more dangerous half for most systems: the series measured a node body running interrupts + 1 times, so any side effect above the first interrupt() fires once per gate and once more. Chaining removes both problems. The shipped HumanInTheLoopMiddleware already batches gated calls into a single interrupt, so if your shape fits it you get this without writing anything.

  • Use the map form Command(resume={interrupt_id: value}) when more than one task is paused at once, which is the probe G shape. A scalar resume is rejected outright once two interrupts are pending, and the map binds a value to a specific task.

    I first wrote that bullet claiming the map form also protects a node with two gates against reordering. It does not. The interrupt id comes from the task namespace, not the call site, so both interrupt() calls in one node carry the same id - I measured 0fbf5f30de6403a3bc5a54bc249b88ef for both. One key, one slot. Inside a node the map form is positional exactly as the scalar form is, and there is no id-based remedy at all. Chaining is the only fix, which is why it is the maintainers' advice rather than a workaround.

  • Do not mutate a paused thread mid-loop. If operator tooling calls update_state() on a thread a driver is polling, the driver's view of pending work is no longer trustworthy.

All of this is measured on the in-process OSS API. The LangGraph Platform SDK exposes its own state surface and carries an open report of the inverse failure, interrupts arriving empty while __interrupt__ streams correctly. If you are on Platform, probe before trusting any of it.

When a correct guarantee is scoped to the wrong unit

I coined a name for this shape in Part 8 of the measurement series, and I am going to use that one rather than invent a second one. 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. All three properties are required. The -Blind refers to the artifact, not the reader and not the mechanism.

.next qualifies, and the third property is where it qualifies hardest. Its docstring reads "the name of the node to execute in each task for this step", which sounds like a clean statement of unit. But the implemented unit is not that. main.py builds the tuple as t.name for t in next_tasks.values() if not t.writes - it is tasks with no recorded pending write other than an error or an interrupt. After resume #1, the gate task genuinely will execute in the next step, and .next omits it. The rendered artifact does not merely under-specify the unit. It says something false about this thread.

Which raises the first property, so I should meet it rather than skip it: is a field that misdescribes itself still "correctly implemented"? I think yes, narrowly, but not for the reason I first wrote down. .next applies one rule uniformly - tasks with no recorded writes - and the rule is coherent. What it does not distinguish is a write that records a task's output from a write that supplies its input, and a resume value is an input. The docstring inherits that conflation rather than causing it. Which is, again, the subject of this article.

There is one more layer under this, and it is not the tidy one I first wrote. _loop.py defines an internal _pending_interrupts() that subtracts resumed interrupts from pending ones and uses it to decide whether to reject a scalar resume, and StateSnapshot.interrupts does no such subtraction. I was about to call that the honest number the runtime keeps to itself. It is not. The subtraction is keyed by task, so any task holding a resume write has its interrupt struck off as resolved even when the node has gates left. In probe B's state - the article's headline case, one genuinely pending interrupt - it returns the empty set. Per-interrupt keying could not have saved it either: probe I showed both interrupts in a node share one id, so there is nothing to key on. It is a different number, correct for the single decision it gates and wrong for the case this article is built on. Which is the same defect again, in the runtime's own private counter, written for a check that only needed to tell one from many.

Not everything qualifies, and I want to be careful about the boundary because I have been sloppy with it once already. max_concurrency is the obvious candidate and it does not make the category - not because its documentation is especially clear, but because its name carries its unit. It is called concurrency, which names simultaneity. A reader who thinks max_concurrency caps total dispatched work has ignored the word in the parameter. It fails the third property, not the second - an engineer can certainly assume the wrong unit here, and the next paragraph is my proof of it. What keeps it out of the category is that a rendered artifact, the parameter name, already states the unit. recursion_limit is called a limit, reads as a total, and nothing rendered corrects that.

I know that inference is available, because I made it. My own chapter's learning objective and summary both say "bounded the fan-out with max_concurrency" while the chapter body says the correct thing, that it caps how many branches run at once. A 200-item fan-out at max_concurrency=10 still dispatches 200 workers, all of which execute - which is what Part 6 measured at that value - and so costs 200 model calls. That is the audit's second contradiction, the one I have not mentioned until now, and it sits in the objective and the summary rather than the prose - exactly where check three below says to look.

Beyond that, Part 8 already classified one store-API instance, BaseStore.search()'s limit=10 default. Sweeping the rest of the checkpointer and store surface I did not find a new one that clears all three properties. recursion_limit I am not re-filing either: a Self-Restoring Bound is the invocation-scoped instance of a Scope-Blind Guarantee, the way Part 6's Width-Blind Bound is the width instance. They are specialisations, not synonyms.

The correction gets snapshot.next wrong too

The reconciliation document I wrote after the measurement series says the book is wrong about snapshot.next. Its measurement line is properly scoped - ".next is () from resume #1 onward while .interrupts is still 1". Then, four lines down, the Consequence line drops the qualifier and says a driver loop "exits early on an interrupted thread", and the Fix line tells you to write that an interrupted thread "reports () while still holding a pending interrupt". The measurement kept its scope. The summary of it did not.

Before publishing that, I read the source. _prepare_state_snapshot builds next like this:

code
for tid, k, v in saved.pending_writes:    if k in (ERROR, INTERRUPT):        continue    if tid not in next_tasks:        continue    next_tasks[tid].writes.append((k, v))# ...tuple(t.name for t in next_tasks.values() if not t.writes)

An INTERRUPT write is skipped, so it never lands in t.writes, so a task holding only an interrupt has empty writes and does appear in next. The source says my correction was wrong.

Both readings were defensible and one of them was about to be published, so I ran the probe above. The result is the table you already saw: the source read is right about the first interrupt, the measurement is right about the resumed multi-interrupt case, and neither general statement is true.

Then I went back to the published article the measurement came from, and found the probe had discovered nothing. Part 7 prints its own probe output, and one of the lines reads:

code
invoke : effects=1 pending_interrupts=1 snap.next=('review',)         snap.interrupts=1 done=None

Part 7's probes ran on 1.2.9 and mine on 1.2.6, the book's pin, and both show .next populated at first pause. That is the single-interrupt case, with .next populated. The datum that falsifies ".next is () on an interrupted thread" was already in print, in my own article, in a code block, in the subsection preceding a bolded sentence that says .next is not a safe driver signal for a thread with interrupts. I wrote the summary while looking at the output that contradicted it.

So check three on the list below - compare the summary against the body - is not general advice I am passing along. It is the check that would have caught this, on my own article, and I did not run it.

My correction had taken a measurement of a node with two interrupts, after a resume and written it down as a rule about any paused thread. It widened the scope in the exact way the book had, one layer up, while documenting the book for widening scope.

mermaid
flowchart TD
    M["measured behaviour<br/>2+ interrupts in a node,<br/>after resume #1"]
    B["book says:<br/>next is the resumption boundary"]
    A["audit says:<br/>next is () on an interrupted thread"]
    R["reader writes:<br/>while snapshot.next"]
    F["exits early,<br/>reports a finished run,<br/>interrupt still pending"]

    M -->|"widened to<br/>any paused thread"| B
    M -->|"widened to<br/>any interrupt"| A
    B --> R
    A --> R
    R --> F

    style M fill:#6BCF7F,color:#2C2C2A
    style B fill:#FFD93D,color:#2C2C2A
    style A fill:#FFA07A,color:#2C2C2A
    style R fill:#4A90E2,color:#FFFFFF
    style F fill:#E74C3C,color:#FFFFFF

Green is what was measured. The two middle boxes are the same error at different altitudes, mine both times, three weeks apart. Restating a measurement in general terms is what writing is, and the generalisation step is unreviewable by the person who made it: to them the wider claim and the measured one feel like the same sentence. I reread that correction four times before I ran the probe.

Why LangGraph's recursion_limit does not bound total work

The recursion_limit story from the opening has a second half that makes the same shape, and this one has a two-line proof.

code
self.step = self.checkpoint_metadata["step"] + 1self.stop = self.step + self.config["recursion_limit"] + 1

That runs on every loop entry. stop is derived from the step recorded in the checkpoint, plus the limit, every time. So the allowance is per invocation, not per thread. Resume a thread and it arrives back at full. Take an ordinary second turn and it arrives back at full. The measurement series recorded 8, 10, 10, 10 node executions across four invocations at recursion_limit=8 - 38 executions under a limit of 8.

Part 4 calls this a Self-Restoring Bound: a bound whose remaining allowance is re-derived from checkpointed state on every entry, rather than carried as a spent total. It guards one invocation against a runaway loop, which is what it was built for. Raising it buys you a bigger single invocation and nothing else. If you need a bound on total work, keep the counter in your own state - it will not survive a crash under async durability, or a fork, but it will terminate, which recursion_limit on a resumable thread will not.

Two caveats the docs will not give you. LANGGRAPH_DEFAULT_RECURSION_LIMIT overrides the default, so "the default" is deployment-dependent. And passing recursion_limit=10007 explicitly is silently dropped during config merge, because the code compares against the default to decide whether you meant it - a sentinel-as-magic-number problem a maintainer flagged in issue #7313.

Two more LangGraph scope errors: BaseStore and the serializer

The measurement series found five things the book never mentions. Two are the same shape as everything above, and both are cheap to check in your own system.

The cross-thread store has no compare-and-swap, which Part 8 measured in full. BaseStore.put() takes (namespace, key, value, index) plus a keyword-only ttl. No expected_value, no version, no etag. A concurrent read-modify-write on the same key loses a write with no exception and no second row. I measured 2 writers leaving 1 survivor, 8 leaving 1, 32 leaving 2, with the race window deliberately widened - read those as loss rates by construction, not as production rates. Nothing in the API prevents any of it. A request for an expected_value parameter has sat on the LangChain forum since October 2025, with the Postgres SQL already written in the post, and no maintainer has replied in the nine months since.

The contrast is what makes it a scope problem rather than a missing feature. In-thread, two nodes writing the same key in one superstep with no reducer raises InvalidUpdateError, which has its own documented error code. Cross-thread, the same conceptual mistake is silent. A reader who learned the loud case reasonably assumes the quiet one behaves like it.

Note the qualifier. An earlier draft did not have it. Annotate that key with a reducer, which is what most real state does, and the in-thread case goes quiet too. The loud-versus-silent contrast is narrower than it first looks, and narrower in the direction that makes the store gap easier to walk into.

The default serializer does not preserve tuples. A ("8812", 4999) in state comes back as ['8812', 4999], because tuples pack as msgpack arrays and there is no extension handler for them. Sets survive, which I measured; frozenset and deque have handlers too, which is a source read rather than a run. The failure mode is the worst available: within a single invocation the next node still sees a tuple, so every in-process test passes, and it breaks on the first comparison after a resume.

I want to be careful not to overclaim from any of this. The series did find one case where a documented guarantee failed to hold - middleware resolution order, reproduced as LangChain issue #38720 - so "everything was correctly implemented" is not a claim I can make. Most of these behaviours are documented somewhere. What the sentence does not carry is its scope, and a reader does not import what the sentence does not carry.

How to audit a technical claim for scope: six checks

These are checks you can run on a paragraph in your own docs, your own runbook, or a book you paid for.

  1. Find the silent quantifier. Every claim about a runtime has one: any thread, any node, any invocation, this superstep. Write it in the margin. If you cannot, the claim has not been stated yet.
  2. Which case did the author have open? A claim written while debugging a crash will be scoped to crashes. My .next sentence is correct crash-recovery advice, sitting in a paragraph a reader arrives at with a paused thread.
  3. Check the summary against the body. Scope errors concentrate in summaries, learning objectives and callouts, where a careful sentence gets compressed into a memorable one and a skimming reader takes the rule from.
  4. Measure the second entry. Anything that reads state from a checkpoint behaves differently on entry two. A test suite that gives each case a fresh thread, which is correct and standard advice, is structurally incapable of exercising it. Part 7 calls that the First-Entry Assumption.
  5. Run a probe. Smaller than it sounds. The one that settled the .next question is a two-node graph, an interrupt() in the first node, an InMemorySaver, and a print of .next and len(.interrupts) after each call. No model, no API key, no fixtures. It runs in under a second.
  6. Record the case that produced the fix, not just the fix. My reconciliation document skipped that step, and that is how it inherited the defect.

Items 1 through 4 and 6 I ran over a morning on the .next chapter, and none of them caught anything. Item 5 caught it in twenty minutes.

What is inside the book

Buy on Amazon: United States | India

Building Real-World Agentic AI Systems with LangGraph, by Ranjan Kumar

Twenty-seven chapters across seven parts, seven appendices, and a single running system that each chapter advances by one increment. The services Atlas talks to are stubbed in the repository rather than hosted, so the whole book runs on a laptop, and the companion repository carries a tag per chapter if you want to read the code as it stood at the end of any one of them.

Parts I-II get you from a fragile while-loop to a real graph: why an agent needs a runtime, where LangGraph sits, state and reducers, conditional routing, tools and MCP, middleware. The middleware chapter is the one the panel caught.

Parts III-IV are the durability half, and the reason the book exists. Checkpointing, the refund path with its idempotency key and compensation, the approval gate a human has to pass before money moves, context budgets, and the split between short-term state and long-term memory. It is also the half that produced both scope errors in this article, which I do not think is a coincidence - it is where the number of things that can be true at different scopes goes up.

Parts V-VII run from the multi-agent decision through to governance, and end on a capstone that builds a second product entirely by reuse. That capstone runs the book's own stack-selection function, gets back "use something other than LangGraph", and then argues the override in front of you. It is the only place I know of where a book's decision procedure is allowed to rule against the book.

The book pins a tested-version matrix instead of promising "the latest". It names six concepts and stops - the glossary marks each as novel or as renamed prior art. Sixteen chapters were corrected in a revised printing after the panel found Atlas had never run end to end.

The .next comment quoted earlier is the one that shipped. Commit 048d49d corrected it in the manuscript, along with three other scope claims the series turned up - though while writing this I found the companion repository's atlas/run.py still carries the original wording, and a test whose name repeats it, so that fix reached one artifact and not the other.

Part 7 now carries a dated correction pointing here: its bolded sentence was scoped too widely, and the sentence after it - "snapshot.interrupts is" - was simply wrong, and is gone. Still outstanding: the companion repository, the printed chapter's comment, and the reconciliation document, all of which need the narrow scope and the probes that produced it.

The claim you cannot re-read your way out of

If you maintain documentation, a runbook, or a book, the useful question is not whether your claims are true. You have already checked that, and they probably are.

The question is what each one is quantified over, whether you wrote that down, and whether anything you ship would tell a reader who assumed something wider.

Mine did not, in two places. The document I wrote to correct the first place inherited the same defect. The one-word fix I drafted for that correction was wider still, and I only learned that because a reviewer asked what happened on a crashed thread.

Nobody has reported being bitten by any of it. I found them because I went looking.

Care produced all three of these. What broke the pattern was running the case I actually had against the version I actually ship.

References


Agentic AI

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


Get the next article by email

One email when a new piece goes up. No digest, no drip sequence.

One email per new article. Unsubscribe in one click.

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