Chapter 2 of 7 · 8 min read
A tolerant reader outlives the schema its sender is on
On this page
At 09:12 the intake team deployed a sender that added one field. budget_authority recorded whether the person on the call could actually sign, which was information the drafting agent had been asking for in review comments for a month. The field was optional. Nothing else changed. By 09:14 every handoff into the drafting agent was failing, and it stayed that way until 09:51, when someone found the line in the receiver's model config that had been there since the schema was written:
class Handoff(BaseModel):
model_config = ConfigDict(extra="forbid") # added in the first PR, never questionedThirty-nine minutes, 1,140 dropped handoffs, and a rollback of a change that added information. The teammate who predicted in design review that a schema change would break the boundary got to say so, and was not wrong about the outcome, only about the cause. The schema did not break the boundary. extra="forbid" did, and extra="forbid" was a choice made in an afternoon by someone reaching for the strictest setting because strict felt safer.
Strictness at the wrong end
There are two places a boundary can be strict, and they behave nothing alike. A sender that validates its own output before emitting it catches its own bugs and cannot break anyone else. A receiver that rejects anything it does not recognise makes every future sender change into a coordinated deploy. The instinct that produces extra="forbid" treats these as the same discipline, because both are "validating the payload", and the asymmetry is what actually determines whether the two agents can ship independently.
The rule that survives this is old enough to have several names. HTTP clients have ignored headers they do not understand since the early nineties. Protobuf, Google's binary format for messages passed between services, assigns numbers to fields precisely so a reader on an older schema can skip a field it has never heard of and keep parsing. The formulation usually credited to Jon Postel, who edited many of the early internet protocol specifications — be conservative in what you send, liberal in what you accept — is more often useful in the narrower form that Martin Fowler called the tolerant reader: take the fields you need, ignore the rest, and do not fail on structure you were not expecting. Applied here it means the receiver validates the fields it consumes and lets everything else through untouched.
import { z } from "zod";
const Constraint = z.object({
rule: z.string(),
kind: z.enum(["hard", "preference"]),
source: z.string(),
}).passthrough(); // unknown keys survive
const Handoff = z.object({
schema_version: z.string(),
correlation_id: z.string(),
goal: z.string().min(1),
constraints: z.array(Constraint),
constraints_elicited: z.boolean(),
owner: z.string(),
}).passthrough();
export function readHandoff(raw: unknown) {
const parsed = Handoff.parse(raw); // throws only on fields we actually use
const known = new Set([
"schema_version", "correlation_id", "goal",
"constraints", "constraints_elicited", "owner",
"evidence", "open_questions", "returns_to",
]);
const unknown = Object.keys(parsed).filter((k) => !known.has(k));
if (unknown.length) {
metrics.increment("handoff.unknown_fields", {
from: parsed.owner,
version: parsed.schema_version,
fields: unknown.join(","),
});
}
return parsed;
}The counter increment is the part worth arguing for. Ignoring an unknown field silently is correct behaviour and terrible operations, because the first sign that a sender has started emitting something new should not be a support ticket six weeks later. With the metric in place, budget_authority appearing at 09:12 shows up as a new label value on a dashboard, the drafting team notices that information is arriving that nobody consumes, and the conversation happens on their schedule instead of during an incident. The receiver keeps working the whole time.
Which changes are actually breaking
Most schema evolution is not the dramatic case. Sorting changes by whether they can be deployed one side at a time makes the dramatic case rare enough to plan for properly.
| change | safe to deploy sender-first? | what breaks if you get it wrong | procedure |
|---|---|---|---|
| add an optional field | yes, with a tolerant reader | nothing; old receivers ignore it | ship it, watch the unknown-field metric |
| add a required field | no | receivers reject every payload | ship optional, backfill, promote to required later |
| add a value to an enum | no, if the receiver switches on it | receiver hits an unmatched branch and guesses | add a default branch to the receiver first, then emit the value |
widen a type (str to str | list[str]) | no | receiver's parse fails on the new shape | accept both at the receiver first, then emit |
| rename a field | no | receiver sees the old name absent | emit both names for one window, then drop the old |
| remove a field | no | receiver reading it gets nothing | stop reading it first, then stop sending it |
tighten a constraint (min_length) | yes at the sender | senders that emitted the loose value now fail their own validation | roll out at the sender, alert on its own validation failures |
Two patterns run through that table. Every safe change is one where the receiver's behaviour does not depend on the thing that changed, and every unsafe change becomes safe by making the receiver tolerant first and only then changing the sender. Rolling out receiver-first is not a rule of thumb about caution, it is the direct consequence of the receiver being the party that can fail.
The enum row is the one that catches people, because it looks additive and behaves like a rename. When the intake agent gained a third constraint kind — hard, preference, and now conditional, meaning a limit that applies only above a certain contract value — the drafting agent had a two-branch switch with preference as its else. Every conditional constraint was quietly demoted to a preference, which is the exact failure the schema was adopted to prevent, arriving through the schema itself. A receiver that treats an unrecognised enum value as an error rather than as the default branch converts that back into a loud failure, and is the version worth writing even though it means one more deploy in the sequence.
When you genuinely have to break it
Some changes cannot be decomposed into safe steps. Splitting goal into objective and success_criteria — because senders had been writing both into one string and receivers had been guessing at the boundary — is a real restructure, and the payload has to say which shape it is in.
{
"schema_version": "2.0",
"correlation_id": "nmc-118-a41c",
"objective": "produce a technical response for NMC-2024-118",
"success_criteria": [
"every named delivery engineer is an employee",
"accessibility section cites the Harlow work",
"submitted before 14 March 17:00 GMT"
],
"constraints": [ "..." ],
"owner": "drafting"
}The version lives on the payload rather than in a deployment config, because the whole difficulty is that both versions are in flight at once and the receiver has to decide per message. A receiver that accepts a range and states what it does outside that range is about fifteen lines:
ACCEPTED = {"1.0", "1.1", "2.0"}
MAX_MAJOR = 2
def dispatch(payload: dict) -> Handoff:
version = payload.get("schema_version")
if version in ACCEPTED:
return ADAPTERS[version](payload) # normalise to the internal model
major = int(version.split(".")[0])
if major > MAX_MAJOR:
raise UnsupportedVersion( # loud, attributable, at the boundary
f"receiver accepts up to {MAX_MAJOR}.x, sender emitted {version}; "
f"correlation_id={payload.get('correlation_id')}"
)
raise UnsupportedVersion(f"version {version} retired on 2026-03-01")Meeting a version from the future is the case that has to be decided deliberately, because the two reasonable behaviours point in opposite directions. Rejecting loudly is right when acting on a partially-understood payload could cause harm, which covers anything with side effects — a payment, an email to a customer, a filing. Handling at the older version, by reading only the fields the receiver recognises and proceeding, is right when the run is read-only or cheaply reversible and an outage is worse than a degraded result. The one option that is not available is deciding by accident, which is what a receiver with no version check does: it reads whatever fields happen to match, ignores the rest, and produces a confident answer built from half a message.
The dual-accept window is what turns this from theory into a deploy plan. Senders emit 1.1 while receivers accept 1.1 and 2.0; senders switch to 2.0 one at a time; when the metric for 1.1 traffic reaches zero and stays there for a full business cycle — a week is usually enough, longer if any runs are queued or scheduled — 1.1 comes out of ACCEPTED. Retiring a version before its traffic has genuinely stopped is how a "completed" migration produces failures a month later from a cron job nobody remembered.
There is an alternative to running that plan by hand, which is to make the compatibility rule something a machine enforces at publish time. Message-bus ecosystems have done this for years with schema registries that refuse to accept a new schema version unless it is compatible with the previous one in a declared direction, so a sender attempting a rename fails in CI rather than in production. Agent frameworks are converging on something similar from a different angle: the Agent2Agent (A2A) Protocol has agents publish a card describing the task shapes they accept, which lets a caller discover the receiver's supported versions before sending anything instead of discovering them from an exception. Both approaches move the failure earlier, and both are worth adopting when you have more than a handful of boundaries. Neither removes the need to decide what the receiver does with a version it has never seen, because a registry governs what you publish and a card governs what you advertise, and a live payload can still arrive from a sender running a build that predates both.
What the schema really costs, and where prose still wins
The coupling objection is right about the mechanism and wrong about the conclusion. Two agents joined by a schema do have to coordinate on some changes, and the table above says exactly which ones. That coordination is paid at deploy time, by engineers, with a rollback available. The alternative is not the absence of coupling. Two agents joined by prose are coupled through a set of conventions that live nowhere — that the sender always mentions deadlines, that "prefer" means preference and "require" means requirement, that constraints come before recommendations. Rewrite the sender's prompt and those conventions change without anyone deciding to change them, and the cost is paid at runtime, by the receiver, on a customer's request, with no rollback because nothing looks broken.
Prose does keep one advantage that a schema cannot take from it, and it is worth naming precisely rather than dismissing. A paragraph can carry information for which no field exists, invented on the spot, without either side deploying anything. When the intake agent notices that the council's finance director has vetoed two previous digital contracts, there is no stakeholder_risk field, and there will not be one for six weeks. In prose that observation crosses the boundary today. In a strict schema it does not cross at all, and the sender either drops it or smuggles it into whatever text field is nearest.
That is the trade in its honest form: the schema buys a validation failure where prose gave you a behavioural difference, and it pays for that with a slower path for anything the schema did not anticipate. Most designs should hold both, with a narrative field that is explicitly the place for the unanticipated, and with the discipline that anything appearing in narrative three times in a month is a field the schema is missing. The narrative field is a queue of future schema changes, and reading it that way is what stops it from becoming the notes field that swallows the constraints.
None of the machinery in this chapter tells you what belongs in the fields. A payload can be perfectly versioned, tolerantly read and fully validated, and still hand the receiver a number with nothing behind it.
Inter-agent communication — read the current version at https://daviddominguez.dev/learn/en/inter-agent-communication/a-tolerant-reader-outlives-the-schema-its-sender-is-on