Chapter 16 — Agent Communication
ShopOps now runs Intake → Resolution → Policy → Outreach as separate workers on O-1001. Resolution asks Policy whether Outreach may email Jordan Lee tonight. Policy denies — the send window is closed. Outreach never sees the denial. It read an older chat message (“permit send”) and drafts from its local scratchpad.
The failure is not bad prose. The system treated message delivery as shared state.
Resolution asks Policy → Policy writes verdict on ledger → Outreach must read ledger, not chat
Without that split, a delayed or dropped message erases a binding decision. Jordan could get mail the policy already denied.
This chapter adds two channels: a message bus assigns work between agents; a case ledger holds the ordered facts — send window, required fields, verdicts — that govern whether Outreach may act.
First principles
Use two channels with distinct jobs:
| Channel | Holds | Semantics |
|---|---|---|
| Message bus | Requests, responses, events | Delivery, correlation, maybe lossy |
| Ledger | Append-only facts / proposals / verdicts / conflicts | Ordered history, hash-linked |
Messages assign work: “Policy, evaluate proposal X.” The ledger records what the system believes about that order: contact_window=send_window, a policy verdict, and any conflict. This split matters because dropping a request must not erase a policy decision that was already made.
Correlation IDs join a request to its response; causation IDs join later work to the decision that triggered it. Explicit IDs and ledger sequence numbers let Outreach determine whether the fact it read is current. Without them, “I thought you knew” is not a protocol.
For ShopOps, Intake records customer and order facts, Resolution records a proposed remedy, Policy records the binding verdict, and Outreach reads that verdict before drafting or sending. Tool protocols can standardize message formats, but they do not provide authoritative case state.
Concrete example
Resolution appends a PROPOSAL and asserts contact_window=call_now. Policy appends a VERDICT and asserts contact_window=send_window. The ledger records CONFLICT — data, not a vibe. Outreach reads the ledger (or waits for resolution), not the last chat emoji.
Dropping the Policy message must not erase the CONFLICT entry if Policy already appended. That is the point of the exercise at the end.
Diagram
sequenceDiagram
participant S as Resolution
participant Bus as MessageBus
participant C as Policy
participant L as Ledger
S->>L: PROPOSAL + ASSERT call_now
S->>Bus: REQUEST correlate=c1
Bus->>C: REQUEST c1
C->>L: VERDICT + ASSERT send_window
Note over L: CONFLICT recorded
C->>Bus: RESPONSE c1
Bus->>S: RESPONSE c1
Bus vs ledger:
Bus: ephemeral delivery of tasks (can drop/reorder in failure drills)
Ledger: append-only truth-ish log for the case (verify_chain)
Implementation
Reference modules: shopops/messaging.py, shopops/ledger.py.
from shopops.ledger import EntryType, Ledger
from shopops.messaging import MessageBus
bus = MessageBus()
ledger = Ledger("O-1001")
ledger.append("resolution", EntryType.PROPOSAL, {"track": "standard", "propose_contact": True})
ledger.assert_fact("resolution", "contact_window", "call_now")
req = bus.request(
"resolution",
"policy",
"O-1001",
{"op": "review_proposal"},
)
msg = bus.receive("policy")
assert msg is not None and msg.correlation_id == req.correlation_id
ledger.assert_fact("policy", "contact_window", "send_window")
assert any(e.etype is EntryType.CONFLICT for e in ledger.entries)
bus.respond(msg, "policy", {"ok": True, "see_ledger": True})
# Fault injection: drop does not rewind ledger
bus.request("resolution", "policy", "O-1001", {"op": "noop"})
bus.drop_next("policy")
ok, _ = ledger.verify_chain()
assert ok
Message payloads stay small: IDs and pointers into the ledger/checkpoint, not full CRM dumps.
Failure modes
- Chat as source of truth — unparseable prose overrides ledger.
- No correlation IDs — responses apply to the wrong case.
- Stale messages — act on a response older than a newer CONFLICT. Check ledger seq.
- Dual writes — same fact in memory store and ledger with different values (Ch 9–10). Pick an owner.
- Huge payloads — bus becomes a second database.
Production considerations
- At-least-once delivery ⇒ idempotent handlers (Ch 6, 32).
- Per-
case_idordering often matters more than global bus order. - Redact PII in bus logs; ledger may need retention/WORM policy (Ch 30).
- Prefer “event + ledger pointer” over “event embeds full decision.”
- When crossing org boundaries, authenticate senders (Ch 27); protocols ≠ trust (Ch 47).
Stale messages, concretely
Outreach receives a RESPONSE c1 saying permit=true. Before it acts, Policy appends a new ASSERT that opens CONFLICT on contact_window. If Outreach only looks at the RESPONSE body, it sends. If Outreach reads ledger.belief("contact_window") and refuses while disputed, it waits.
Rule of thumb: messages notify; ledger authorizes shared beliefs. Correlation IDs tell you which notification matches which request. Ledger sequence numbers tell you whether the notification is still fresh.
Exercise foreshadow: drop the RESPONSE entirely. Resolution should not “assume silence means yes.” Time out the request and read the ledger — Policy may have written the verdict even if the bus ate the reply.
Correlation ID discipline
Minimum fields on every bus message:
case_id— partition keycorrelation_id— request/response gluemessage_id— unique envelopecausation_id— optional parent
Handlers must be idempotent on (recipient, message_id) or (recipient, correlation_id, kind=RESPONSE). Without that, at-least-once delivery duplicates Policy verdicts into side effects. The ledger still appends once if handlers check “already wrote VERDICT for this proposal id.”
Chapter summary
- Typed messages assign work; ledgers hold shared beliefs.
- Correlation IDs are mandatory.
- Conflicts are ledger data.
- Drops/reorders must not invent agreement.
- Keep payloads as pointers.
- MCP/A2A are edges, not case truth.
- Verify hash chains in tests.
- Stale responses defer to newer ledger seq.
Exercises
- Reorder two policy responses with
reorder_two; show a naive “last message wins” outreach policy fails, while ledger-seq policy does not. - Append CONFLICT, then resolve via
ledger.resolvewithbasis="human_reviewer". - Implement message TTL: ignore responses older than N seconds unless ledger confirms.
- Map one A2A/MCP concept to either bus or ledger — not both blindly. [VERIFY SOURCE for the official doc section you cite.]
References
- Lamport, 1978. Time, Clocks, and the Ordering of Events in a Distributed System. CACM.
- Kleppmann, 2017. Designing Data-Intensive Applications. — logs, streams, derived state.
- Model Context Protocol docs. https://modelcontextprotocol.io [VERIFY]
- Agent2Agent (A2A) official specification / docs. [VERIFY URL]