In LangGraph the Model Only Suggests: A Run-Time Guard Routes Every Model Node to Its Checker
Wrap every model-backed node in a guard applied inside build(), so that whatever goto the node returns, the run goes to that node's checker and only the fields a model may write survive — the Command[Literal[...]] annotation is a drawing instruction, not a control.
LangGraph in production, a 10-part series. ← Part 3 · Part 5 →
The Annotation Draws the Edge; the Interpreter Ignores It
A node annotated Command[Literal["check_carton"]] can return Command(goto="pack", ...) and the graph goes to pack. Here is a planted defect that carries the honest annotation and jumps anyway:
def eager_suggest_carton(state: State) -> Command[Literal["check_carton"]]:
return Command(goto="pack", update={"suggestion": {"carton": "small"},
"packing": {"carton": "small"}})
Invoked unguarded, it prints {'carton': 'small'} from packing. The order is 30×20×15; the small carton's inner size is 25×20×10. It does not fit, the check was skipped, and the model wrote the decision field on its way past. Command merges control flow and state updates in a single return (LangGraph (n.d.)), and the annotation exists only for rendering (LangGraph (n.d.)). Rendering is not routing.
Static Typing Covers the Literal You Typed, Not the Route the Model Returned
mypy and pyright reject a literal goto="pack" against Command[Literal["check_carton"]], and a conditional edge whose path_map names only the checker pins the hop. If a node's destination is a literal in code and the model only fills a payload field, that stack is a complete contract and the wrapper is unnecessary.
Neither type checker flags a goto taken from model output, because that value is untyped — and a model-driven node is exactly that case. destinations= affects drawing only: the unguarded build above passes destinations=("check_carton",) and still reaches pack. The graph overview says "Edges define how the logic is routed and how the graph decides to stop" (LangGraph (n.d.)), yet the drawn edge is not the edge the interpreter takes. The toolchain covers the reviewed literal and leaves the model-supplied route and the model-written decision field open.
The Guard Belongs in build(), Because a Per-Node Rule Dies in the Next Refactor
The wrapper is applied once, at construction:
def only_suggests(node, checker, may_write=("suggestion",)):
def guarded(state: State) -> Command:
out = node(state)
wanted = out.goto if isinstance(out, Command) else None
update = (out.update if isinstance(out, Command) else out) or {}
kept = {k: v for k, v in update.items() if k in may_write}
stray = [f"field {k}" for k in update if k not in may_write]
if wanted not in (None, checker):
stray.append(f"route {wanted}")
return Command(goto=checker, update={**kept, "stray": stray})
return guarded
g.add_node("suggest_carton", only_suggests(model_node, "check_carton"),
destinations=("check_carton",))
The planted node now ends at the checker: guarded: {'carton': None, 'why': "'small' does not fit"} | taken away: ['field packing', 'route pack']. packing was dropped and never reached the interpreter. The model can still read the entire state; it cannot write it.
Because the rule lives in build, a node registered tomorrow is guarded the moment it is added. The property worth a test is coverage: walk the registry of model-backed nodes, assert each is wrapped, and let the suite fail when someone registers a bare node. And stray is rebuilt on every call — it records this call's strays, not a history.
A Checker Rules on Facts the Model Cannot Change
The checker is plain code: does the carton exist, is it in stock, do the item's dimensions fit. Pass goes to pack and writes packing; fail goes to hold with a reason, and a person or a fixed rule picks the carton. Keeping suggestion and packing separate makes a bad decision legible afterwards.
What It Costs, and When Something Else Is Cheaper
Each alternative has a price. Relying on types and review costs nothing and misses the model-supplied route. A conditional edge is safe with a fixed router or a checker-only path_map; drop the path_map and let the router read model output, and the run goes wherever the model says. Neither stops a node writing the decision field. Sending every model node to a human bounds the risk absolutely and scales only to low volumes. Asking the model to check itself produces a pass/fail decision that is probabilistic and cannot be tested the way a rule can.
Reach for the guard when a node's output triggers a side effect or writes a field another node acts on. Skip it when the destination is a code literal and the payload is inert.
The annotation stays — it is how the graph gets drawn and how a reader learns what the node intends. Just stop counting it as the thing that stops the model. The guard turns trust into coverage, and coverage is something your test suite can fail on.
