Run a LangGraph Graph in Shadow Mode: Off, Shadow and Live
Shadow mode is not traffic splitting: it is a permission system where an unconfigured graph resolves to off, every node that can touch the world hands one gate an intent instead of acting, and live needs three separate yeses.
LangGraph in production, a 10-part series. Part 2 →
The tempting build is a MODE string compared inside each node. That shape goes live by accident: absence does it — a graph whose setting was never written goes live on its first deploy.
Silence Is Off, and a Typo Can Only Ever Switch a Graph Off
Resolution happens once, at the edge, as a parsed enum. No configured value means Mode.off, except for the earned production default below. A configured one is parsed as Mode(configured.strip().lower()) inside except (ValueError, AttributeError, TypeError). Catching only ValueError leaves the hole: a YAML true raises AttributeError, and a bare 1 resolves the same way.
In Production the Switch Stops Being an On-Switch
Some graphs are read-only enough that you want them shadowing wherever real data exists. That default has to be earned twice over: the graph must have no model-chosen action at all, and the process must demonstrate both where it is running and what it is writing to. looks_like_prod() returns false when either fact is missing, and false when reading them raises. For a graph that earned the default, the setting in production is the switch that stops it; for every other graph an unset mode is off, and setting it turns the graph on. Nothing defaults to live, ever.
One Gate Beats a Mode Check in Every Node
Per-node checks are local and reviewable — and they fail the day someone adds a ninth node who has never read the mode code.
So nodes never ask what mode they are in. They describe the action and hand it to one gate, which returns a verdict that goes straight into graph state. In shadow that verdict is {'go': False, 'held': 'not live'}. Gate coverage becomes a single testable property — a spy stands in for the gate, and every effect-capable node must be seen calling it. live is a conjunction, not a setting: requester, graph configuration and outbound permission must all agree, and if the gate raises while deciding, that is also a no, with the reason recorded rather than thrown.
class State(TypedDict, total=False):
zone: str
trail: Annotated[list[dict], operator.add] # appended, not replaced
Declare trail with operator.add or the checkpoint keeps only the last write. Keep verdicts as plain dicts: LangGraph's persistence layer saves graph state through checkpointers keyed by thread_id, and custom classes do not round-trip cleanly under strict msgpack.
The Sandbox Objection Is Right, and Still Loses the Evidence
The best argument against all of this: run the shadow in its own process with its own credentials, and leave the graph code naive. But a denied print is an absence, and a held verdict is a statement, and only the second can be compared with what the live system did. These are layers, not rivals.
The alternatives each cost something real. interrupt() pauses for approval, but per the interrupts documentation the node re-runs from its start on resume, and every held action waits for a person. A canary lets a slice of traffic actually act: users eat the new system's mistakes, and you get no paired record. A flag service lets you turn a graph off without a deploy, at the price of a hot-path lookup and a second source of truth — where an absent flag has exactly the permissive hazard you started with.
Practical takeaways
Resolve the mode once, at the edge, into an enum, with every unrecognised and non-string value falling to off. Earn a shadow default only for graphs with no model-chosen action, and only when both production facts hold. Route every world-touching node through one gate and write its verdict into an appended trail. Wrap the whole shadow invocation so the scheduler receives skipped | not_landed | crashed | recorded and never an exception. not_landed is a scheduler contract for a lazily imported graph not in the build yet.
A mode system that only refuses is a fence. The StateGraph API will compile whatever you hand it; the reason to build three modes around it is that the shadow's checkpoint is the only place the comparison can come from. Promotion should be read from that record, never from elapsed time or anyone's confidence.
