Chapter 5 of 7 · 9 min read
Three readers and an on-call engineer mean shared state
On this page
Six motor claims in one week were assessed under the wrong provincial ruleset. The triage agent had read each first notice of loss — the claimant's initial report of an incident, which is the document a motor claim starts from — established that the loss occurred in Quebec, and written a routing configuration that said ruleset: qc_auto_2026, evidence_bar: standard, reserve_band: 5000-25000, fast_track: false. By the time the liability agent saw a routing configuration, it said ruleset: on_auto_2026. Nothing had been corrupted in transit. The evidence agent sitting between them had rebuilt the payload for its own handoff, and in rebuilding it had re-derived the jurisdiction from the address on the police report — which was the reporting detachment's address, in Ottawa, across the river.
The on-call engineer paged at 02:40 for a stalled settlement queue spent twenty-five minutes answering a one-field question: what routing configuration is run clm-77401 actually using right now? The value existed only inside messages that had already been consumed. Answering it meant pulling three agents' logs, finding the handoff payload embedded in each, and reading them in order to work out which version was current. The value that four different parties needed to see had no address.
One writer, four readers, and a chain that was never the right shape
The routing configuration has exactly one producer and four consumers, three of which are agents inside the run and one of which is a human outside it. A structured handoff, in the form chapters 1 through 4 developed, is a one-sender-one-receiver construction: the payload and the control transfer move together, and the receiver is the party the sender chose. Delivering a value to three receivers through that construction means forwarding it, and forwarding makes every intermediate agent the temporary custodian of a value it does not own. Custody without ownership is where drift enters, because the custodian is running its own reasoning over its own inputs and has no instruction distinguishing "pass this through unchanged" from "produce your best current answer for this field."
The three mechanisms available differ on more than topology. A structured handoff is the typed payload chapters 1 through 4 built, addressed by its sender to one named receiver and carrying control along with it. Message passing puts a message onto a channel and leaves the readership open, so the sender keeps running, does not choose who consumes it, and gets no guarantee about when. Shared state, which the literature also calls shared memory, gives a value an address that any participant can read or write for the life of the run, so nothing is transmitted and nobody is notified. The differences between them are the reason the choice is worth making deliberately rather than defaulting to whatever the framework makes easiest.
| structured handoff | message passing | shared state | |
|---|---|---|---|
| shape | one sender, one receiver, control moves with the payload | one publisher, unknown readers, control stays with the publisher | one writer per key, any number of readers, control unaffected |
| visibility to a third party | only by reading the payload out of a log | subscribe to the channel and see everything | read the key by run ID at any moment, including mid-run |
| coupling | sender must know what the receiver needs | publisher knows the event, not the consumers | writer and readers couple to a key and its type, not to each other |
| synchronisation | none needed; the exchange is the sequence point | none; readers may be arbitrarily behind | real, and yours to solve: concurrent writes, read-after-write, versions |
| replayability | feed the recorded payload back in and the receiver is deterministic | replay the channel from an offset | requires the store's contents at the right point, or a write log to fold |
| blast radius of a bad value | one receiver, on one run | every subscriber, from the moment it publishes | every reader after the write, including readers hours later on the same run |
| where validation happens | at the boundary, on arrival | at the boundary, on consume | on write, if you build it; otherwise nowhere |
The last row is the one that determines how much work shared state is. A handoff has a boundary, and a boundary is a place where a schema can be checked; that is the whole basis of chapters 1 and 2. Shared state has no boundary in the same sense, because a read is not an event that anyone arranged. If you want the guarantee that a routing configuration is well-formed, the only place to put it is the write, and if you want the guarantee that a reader saw a value that was still current, you have to give values versions and make readers record which one they read.
The solid arrows are reads of one value with one address; the dotted arrows are the handoffs that still have to exist, because a value sitting in a store does not cause anything to run. That pairing is the design, and collapsing it into one mechanism is the usual mistake in both directions: forwarding shared values through handoffs produces the drift above, and trying to sequence agents by writing to state and hoping someone notices produces a run that stops.
Making the store something you can reason about
A run-scoped store with a single writer per key, typed values and monotonic versions is about as much machinery as this needs, and each piece pays for itself in a failure that would otherwise be hard to see.
from dataclasses import dataclass, replace
from typing import Literal
@dataclass(frozen=True)
class RoutingConfig:
ruleset: Literal["qc_auto_2026", "on_auto_2026", "bc_auto_2026"]
evidence_bar: Literal["light", "standard", "enhanced"]
reserve_band: tuple[int, int]
fast_track: bool
basis: str # "fnol:loss_location=QC (stated by claimant, t=00:02:11)"
@dataclass(frozen=True)
class Versioned:
value: object
version: int
written_by: str
written_at: str
class RunState:
OWNERS = {"routing": "triage_agent", "reserve": "reserve_agent"}
def put(self, run_id: str, key: str, value: object, *, writer: str) -> int:
if self.OWNERS[key] != writer:
raise NotTheOwner(f"{writer} may not write {key}; owner is {self.OWNERS[key]}")
SCHEMAS[key].validate(value) # validate at the only write path
prev = self._store.get((run_id, key))
nxt = Versioned(value, (prev.version + 1) if prev else 1, writer, now())
self._store[(run_id, key)] = nxt
self._writes.append((run_id, key, nxt)) # append-only, for replay
emit("state.write", run_id=run_id, key=key, version=nxt.version, writer=writer)
return nxt.version
def get(self, run_id: str, key: str, *, reader: str) -> Versioned:
cur = self._store[(run_id, key)]
emit("state.read", run_id=run_id, key=key, version=cur.version, reader=reader)
return curOwnership declared per key is what stops the evidence agent from re-deriving the jurisdiction. It is not a permission model in the security sense — the agents are all inside the same trust boundary — it is a statement about which component is responsible for a value being right, enforced somewhere a code reviewer will see it. The basis field is chapter 3's argument applied to shared state: a routing configuration that says qc_auto_2026 and nothing else invites a downstream agent that sees an Ontario address to conclude the routing is wrong and act on its own reading, whereas one that says the ruleset was chosen from the claimant's stated loss location gives the reader something to disagree with explicitly rather than silently.
The read event is the part that is easy to omit and expensive to have omitted. A handoff leaves evidence of itself because someone had to send it; a read leaves none unless you emit one, which means that without this line you cannot answer whether the settlement agent ever consulted the routing configuration at all. In the six wrong-ruleset claims, the question that mattered during the postmortem was whether the liability agent had read a stale version or a wrong version, and those have different fixes.
What a shared store does not do for you
Shared state is not a general-purpose place to put context. The temptation once a store exists is to write everything into it, on the grounds that any agent might need any of it, which reproduces the over-passing failure of chapter 4 with an additional hop: instead of an executor sifting 11,400 tokens in its prompt, you have an executor pulling nine keys because it is not sure which three matter. The rule that only what changes the receiver's next action crosses still applies; the store changes where the value lives, not how much of it a given agent should be looking at. A key with four readers earns its place in the store. A key with one reader is a handoff field that has been given a longer life and a weaker validation story.
It also does not remove the handoff. The dotted arrows in the diagram are load-bearing: control transfer, the question of who is running and who is accountable for the outcome, is a message-shaped problem, and topic 11.11 covered why. Shared state and structured handoffs solve orthogonal problems and most real systems need both, with the payload carrying what the receiver must act on and the store carrying what several parties must agree on.
Neither of these arrangements is new. The blackboard architecture in Hearsay-II, the speech-understanding system built at Carnegie Mellon in the mid-1970s, had independent knowledge sources — one for acoustics, one for syntax, one for semantics — reading and writing hypotheses on a shared structure rather than calling each other, for exactly the reason above: several specialists needed the same evolving partial answer and no one of them owned the sequence. Message passing has an equally long provenance in Hewitt's actor model from 1973, where the discipline is the opposite one, that an actor's state is private and the only way to affect it is to send it something. Current agent frameworks land on one side or the other and are legible once you know which: LangGraph's state channels with reducers are a blackboard with a declared merge function per key, while the Agent2Agent protocol is explicitly for agents that share no memory at all and must therefore put everything into the message.
Replay is where the choice bites hardest, and it is worth being clear-eyed about it before adopting a store. A handoff replays trivially, because the recorded payload is a complete input; give it to the receiver and you get the receiver's behaviour. A store does not, because an agent's behaviour depends on the state at the moment it read, and reproducing that means reconstructing the store as of that moment. The append-only _writes log in the code above is what makes it possible: the state at any point is a fold over the writes up to that point, so a replay harness can rebuild it. Without that log, a store is a mutable object whose history is gone, and every debugging session becomes an argument about what the value probably was. This is the same reason reducers exist in the frameworks that offer them — a declared fold is replayable in a way that last-write-wins over a mutable dict is not.
The blast radius row in the table is not a theoretical concern either. When the reserve agent wrote a malformed reserve_band — a tuple with the bounds reversed, (25000, 5000) — the handoff-shaped version of that bug would have failed validation at one boundary and stopped one run. In the store, it was written once and read by three agents over the following four minutes, each of which produced a confidently wrong reserve figure, and by a fourth agent nineteen minutes later on a resumed branch of the same run, long after the write that caused it had scrolled out of anyone's attention. Validating on write, which is the one line SCHEMAS[key].validate(value) above, is what converts that into a single loud failure attributable to the writer.
None of this makes the loss visible in production. The store emits read and write events, and the handoffs have schema versions and validation failures, and both of those are things you have chosen to record because this chapter and the ones before it suggested them. In a running system nobody has yet decided what to keep, and the constraint that shapes that decision is usually not engineering taste.
Inter-agent communication — read the current version at https://daviddominguez.dev/learn/en/inter-agent-communication/three-readers-and-an-on-call-engineer-mean-shared-state