---
title: "The Graph Looks Right. The Merge Is Where It Breaks."
description: "A multi-agent graph can lose a verdict with no error and nothing turning red. The bug is one line in the state schema."
author: "Harry Floyd"
publication: "The Durability Curve"
canonical: "https://durabilitycurve.com/blog/where-agents-disagree/"
date: "2026-08-30"
series: "WALKTHROUGHS"
claims: "https://durabilitycurve.com/claims/where-agents-disagree/"
format: "markdown mirror of the canonical HTML page; figures are named, not embedded"
---

# The Graph Looks Right. The Merge Is Where It Breaks.

*A multi-agent graph can lose a verdict with no error and nothing turning red. The bug is one line in the state schema.*

By Harry Floyd · 2026-08-30 · canonical: https://durabilitycurve.com/blog/where-agents-disagree/

*Three agents disagreed, and the gate I built approved the release anyway. The framework had caught the conflict; the bug began the moment I made the error go away. Here is the one line that did it, and how to see it in your own graph.*

*Verified end to end on langgraph 0.6.11.*

---

I built a release gate out of agents. An orchestrator fanned out to three reviewers: one read the test run, one read the error metrics, one read the changelog. Each came back with a verdict, ship or hold or roll back. A final node read the verdict and acted on it. I drew the graph, and it looked like every orchestrator-worker diagram you have ever seen. After one wiring error I thought I had fixed, I ran it again, and it worked.

Then it approved a release that two of the three reviewers had rejected.

There was no exception, and nothing turned red in the log. The graph had done exactly what I wired it to do, and what it did was take the ship branch. I went looking for the bug in the reviewer prompts, and they were fine. Then in the routing, and that was fine too. The verdicts were correct. Two of them said stop. The code that read them looked like this:

```python
if state["decision"] == "roll back":
    abort_release()
else:
    ship()
```

And `state["decision"]` was `"ship it"`. One verdict was there, the other two were gone. That is the part that took me a while to accept: it was never three reviewers reaching a decision. It was three agents writing to one slot, and the code downstream read whichever write survived. I had never said who should own that slot, so something I had wired without noticing answered the question for me, and answered it wrong.

## The picture is not where the fault is

Put the broken graph next to a correct one. Orchestrator at the top, three workers below it, a node at the bottom that makes the call. The two diagrams are the same boxes and arrows, and the fault is in neither of them. It is one line down in the state schema, a line you probably wrote without thinking about it, that decides what happens when three workers write the same slot. Ranjan Kumar put the general point better than I will, in a piece worth reading in full: the rule that resolves those writes "appears on no diagram, in no edge list, and in no type checker's output," and it is the only thing they all miss that decides what the state value actually is.[^1] The difference between my two graphs lives there, in the schema, and the schema is what you have to read.

[Figure: Two release gates drawn as identical boxes and arrows, with their State schemas side by side. The broken schema gives decision a last-write-wins reducer and two of three verdicts vanish silently; the safe schema gathers findings from all three and lets one owner write decision. The diagrams are the same; the schemas are not, and the schema is where the verdict is kept or lost.]

## The error was the invariant

In LangGraph your state is a set of channels, and every channel has a rule for what happens when more than one node writes it in a single step. That rule is the whole game.

Wire three parallel workers to write a plain channel, the way my release gate did, and run it. LangGraph stops you, at the exact key:

```
InvalidUpdateError: At key 'decision': Can receive only one value per step.
Use an Annotated key to handle multiple values.
```

I read this as an inconvenience, which is the mistake. It is the one place the framework asked me a question I had not answered: who is allowed to write this field. Three writes arrived for one slot, and rather than pick a winner behind my back, it refused. Left here, the bug cannot ship, because the exception is holding an invariant I never wrote down.

So I made the exception go away, the way the message suggests: add a reducer. One documented reducer pattern is `operator.add`. On a `str` channel it concatenates the three verdicts into `"roll backship ithold"`, which is visible garbage you would catch. On a list channel it keeps all three, which is correct, and only a downstream `decision[0]` quietly throws two away. Neither of those is the silent single-survivor I hit. To get that I reached past the docs for the smallest reducer that makes the error stop, keep the latest write:

```python
class State(TypedDict):
    decision: Annotated[str, lambda old, new: new]
```

That is the trap, and it is worth being exact about where it comes from. LangGraph's own error page shows the lossless `operator.add`. But overwrite reducers like `lambda old, new: new` are a familiar pattern too, in examples and in real code, so this is not one careless engineer. The framework refused, I reached for a familiar reducer to silence it, and I did it on a field where overwriting meant throwing away a verdict. The run went clean, the channel held one verdict, and the other two were gone with no error:

```
$ python agent_graph_demo.py --trap
STEP 1 (plain channel): the framework refused -> InvalidUpdateError: At key 'decision': Can receive only one value per step. Use an Annotated key to handle multiple values.
STEP 2 (reducer added): no error. the decision channel now holds: 'hold'
        the other two reviewers' verdicts were silently dropped
```

Here is the idea I want you to keep, because it travels past LangGraph. A reducer answers one question: when two writes land on this field, how do they combine. It cannot answer the prior one: should two writes ever land on this field at all. That first question is about ownership, and the type does not answer it. A `str` can be a log fragment or an authoritative verdict; an `int` can be a counter, a balance, or a version number. The same type permits a merge that is right for one meaning and ruinous for another, because the safe concurrency policy depends on what the value means, and the meaning is not in the type. Last-write-wins itself is not the villain: it is right when overwrite is part of the field's contract, and wrong when competing writes are evidence that has to be reconciled first. So the order matters: **decide who owns a field before you decide how its writes merge.** For a decision, exactly one node owns it. The `InvalidUpdateError` was enforcing the same-step half of that, no more than one write to this field in a single step, and I silenced the one guard I got for free. The other half, that no other node writes it in a later step, the error cannot see, and we get to it below.

One point of precision, since a careful reader will want it. This is a deterministic super-step conflict, not a thread-level data race; LangGraph batches the writes from a step and applies the channel's rule to them. And which verdict survives is not random: in this static fan-out on 0.6.11, the write from the node whose name sorts last wins, which I know only because I reordered the nodes and watched it change. It is stable across re-runs and it is an undocumented detail you should never build a decision on.

## The graph that keeps the disagreement

Here is the same release gate, wired so ownership is explicit. The move is to separate two jobs that were fighting over one channel: gathering what the reviewers found, and deciding what that means. Gathering has many writers. Deciding has one.

```python
Verdict = Literal["ship it", "hold", "roll back"]

class Finding(TypedDict):
    reviewer: str
    verdict: Verdict

class State(TypedDict):
    findings: Annotated[list[Finding], operator.add]   # gather: many writers append
    decision: Verdict                                  # decision: one owner writes

def reviewer(source):
    def node(state):
        return {"findings": [{"reviewer": source, "verdict": call_model(source)}]}
    return node

def decide(verdicts: list[Verdict]) -> Verdict:
    # the policy, named and owned. this one is any-veto by severity; swap it for
    # majority / unanimous / weighted / threshold as your gate actually needs.
    if "roll back" in verdicts: return "roll back"
    if "hold" in verdicts:      return "hold"
    return "ship it"

EXPECTED = {"tests", "metrics", "changelog"}

def writer(state):
    reviewers = [f["reviewer"] for f in state["findings"]]
    if sorted(reviewers) != sorted(EXPECTED):   # missing or duplicate is not a ship
        return {"decision": "hold"}
    return {"decision": decide([f["verdict"] for f in state["findings"]])}
```

The reviewers write concurrently to the same gather channel, and here the merge is legitimate: keeping every contribution is exactly what the field means. Concurrency was never the bug; a wrong ownership rule was. Then one node, and only one, reads all three findings and applies a policy that is written down where you can argue with it. Notice the policy is a veto by severity: one roll back overrides two ships, because that is the release rule I want. You can pick any rule you like. What matters is the split: gathering the evidence and ruling on it are different operations, so they get different channels, and the ruling has an owner. Ownership is only part of the gate. `decide` assumes every reviewer reported, so a reviewer that drops out without a finding is a silent ship in a different coat, and one that reports twice can tip a majority or a weighted rule. The writer fails closed on both: it rules only when every expected reviewer reported exactly once, and anything else is a hold. Ownership says one node decides; completeness says everyone reported; uniqueness says each reported once. Run it with all three present and it reconciles three of three, on a decision a node made after seeing them.

Two things a builder asks here. When you do not know how many reviewers you will have, `Send` gives you dynamic fan-out, and the footgun is identical: I wired a `Send` fan-out into a shared decision channel and it raised the same `InvalidUpdateError`, so the same split fixes it. And swapping the stub for a real model is two lines, a `ChatAnthropic` or `ChatOpenAI` in place of the stub; the graph, the channels, and the ownership rule do not change, because the model is a node and the safety is in the wiring.

Whether to split the work across agents at all is a separate question, and I argued it in [Your Multi-Agent System Is an Org Chart](https://durabilitycurve.com/blog/your-multi-agent-system-is-an-org-chart/). This walkthrough assumes you have decided to split, and shows the line where the split silently is not real.

## Read it off your own schema, then off your own run

Your real graph has more than three channels, and you will not remember which carry decisions. You can have a script find the risky ones. Kumar ships one that inspects a compiled graph and reports each channel's merge policy; the small version I use reads the `State` schema itself:

```
python audit.py            # or: from audit import audit_schema; audit_schema(MyState)
```

It works because a fan-out cannot silently collide on a plain channel: the framework refuses that write out loud. The only place a fan-out's writes merge without a word is a channel that carries a reducer, so the reducer channels are the whole fan-out surface, and the tool reads them off the annotation:

```
audit of State
  ! classify    findings       REDUCER (add)
  fan-out safe  decision       plain
```

For each flagged channel, ask how its value gets read downstream. Does anything branch on it, or act on its value as one answer? Then it is a decision channel: give it one writer and no reducer. Is it only ever folded into an aggregate? Then it is a gather, and the reducer belongs there.

That check is real, and it is only half. It is a reducer-surface audit: it finds where a merge is permitted. It does not prove a decision channel has exactly one writer, because which channels a node writes is a dictionary it returns at runtime, not a type you can read ahead of time; a node's introspectable channels are its reads, not its writes. So a plain decision channel that two nodes write in sequence overwrites silently, no error, and the schema cannot see it. The second check is a runtime one: in a test, assert that across the whole run, every write to a decision channel came from the same one node. That holds even for a looping graph, where the owner may write many times but no one else may write at all. The first check finds where merges are allowed; the second finds where ownership is broken.

[Figure: Two ways a decision channel is silently overwritten. On the left, the fan-out: three workers write it in one step, the type carries a reducer, and the schema audit catches it. On the right, in sequence: two nodes write it in different steps, the type is a plain str with nothing to flag, and only a runtime check catches it. The reducer makes the fan-out visible to the schema; the overwrite in sequence hides in a plain type, so the run is the only place to catch it.]

## Cap the loop, count the cost, gate the write

Ownership is the idea to carry out of this. Three smaller safeguards finish a graph once you have made it safe on that.

A loop with no exit does not run forever; LangGraph stops it with `GraphRecursionError` at a default limit, 25 on the 0.6.11 this piece was built on and far higher on current releases. The reflex is to crank that limit up, which only buys a stuck agent more steps and a larger bill. Right-size it a little above your real longest path, and read a trip as "this agent is stuck," not "this agent needs more room."

Fan-out is not free. Three parallel workers are three model calls, which the demo counts for you, and running them in parallel buys you wall-clock time and nothing on the bill, because you pay for every branch. None of the failures in this piece is fixed by a better model; they are decisions about ownership and cost that a smarter model makes faster, not safer.

And the decision node, being the one whose output the graph acts on, is where a human gate belongs if you want one. LangGraph's `interrupt()` gives you that, with one caveat worth its own walkthrough: on 0.6.11, resuming needs a checkpointer, and on resume the node runs again from the top, so the irreversible work goes below the interrupt, not above it. That is a whole article; here it is enough to know the reliability budget belongs on the write, because the write is the part that is owned.

## Most of the time, do not build a graph

Most of this you avoid by not reaching for a graph. A single agent with tools and a step that compresses its own history handles more than people expect, and it avoids the fan-out and merge failure this walkthrough has been about, because there is nothing to fan out and nothing to merge. It can still loop, lose context, or run up a bill; it just cannot lose a verdict in a merge. Single prompt, then tools and retrieval, then a fixed workflow, then one agent, then several: climb a rung only when the one below it measurably fails.[^2] A graph is a cost, and you pay it in exactly the channels this walkthrough has been about.

## What to do this week

Take one graph you already run. Pass its `State` schema to the audit, and for every reducer channel it flags, ask whether anything downstream acts on it as a single value; give each one that does a single writer. Then, on purpose, wire a fan-out into a plain decision channel and watch LangGraph refuse it, add a last-write-wins reducer to make the error stop, and watch two verdicts disappear. The next time that sequence happens, let it be in a test you wrote and not a release you shipped.

My release gate has one decision owner now. The three reviewers still disagree; they simply do not get to settle it by racing. The reducer was never meant to decide who was right.

---

*The three programs here, `verify.py`, `agent_graph_demo.py`, and `audit.py`, run with no API key and are yours to keep: [durabilitycurve.com/tools/agent-graph](https://durabilitycurve.com/tools/agent-graph/).*

*And if you want the paper version to run on a graph you already have, The State Channel Audit is a free three-step worksheet, classify every channel, guard every decision, run the two checks. [link to the free download]*

[^1]: Ranjan Kumar, [LangGraph Reducers Are a Concurrency Policy](https://ranjankumar.in/langgraph-reducers-concurrent-state-writes), 27 July 2026, reached all of this before I did: the reducer as a concurrency policy, the review-surface blind spot, the sequential silent overwrite, and a compiled-graph merge-policy auditor. His piece is the one to read for the general case. What is new here is the ownership-before-merge-policy framing, the worked release-gate failure, and a runnable no-key demo.

[^2]: The architecture-level sources behind the single-writer rule and the orchestrator-worker pattern are Cognition's [Don't Build Multi-Agents](https://cognition.com/blog/dont-build-multi-agents) (2025) and its 2026 follow-up [Multi-Agents: What's Actually Working](https://cognition.com/blog/multi-agents-working), which finds these systems work best when writes stay single-threaded and the extra agents add intelligence rather than actions; and Anthropic's [Building Effective Agents](https://www.anthropic.com/engineering/building-effective-agents) (2024) and [multi-agent research write-up](https://www.anthropic.com/engineering/multi-agent-research-system) (2025). On how these systems fail in the field, the [MAST study](https://arxiv.org/abs/2503.13657) (Cemri et al., 2025) finds the largest category of observed failures is specification and system design.
