Lay two printed message slips side by side. Each records a payment, and each carries the same line: amount: 10000. Same field name, same numeric type, same value, and a schema checker would pass both without comment. On the first slip, the documented unit is cents, so the payment is one hundred dollars. On the second, the team that produces the messages has quietly started writing dollars, so the payment is ten thousand. Nothing on the face of either slip tells you which is which. The slips and the team are invented, and the failure is deliberately simple, but anyone who has connected two systems will recognize its shape.
The two slips share structural compatibility: a reader built for one can parse the other without error. They do not share meaning compatibility: a reader that acts on the second as it acted on the first will be wrong by a factor of one hundred. My argument is that a change to shared data is a change to a promise, and should be reviewed as one. A responsible data contract needs both a structural rule, which machines check well, and a few concrete examples of the business meaning that consumers rely on, which machines can check only if someone writes them down.
This is not a claim that schema tools are broken. Automated compatibility checks do exactly what they say, and they prevent real outages. Nor is it a claim that every meaning can be squeezed into a schema; some ambiguity belongs to the business itself, and piling formal constraints on every field can slow down useful change. The claim is narrower. A check can only settle the questions it was given, and the most consequential question about a field, what it means for a decision, is usually not one of them.
PAYMENT.RECORDED
message 1 · writer v1
fieldamount
typelong
value10000
statuscomplete
Reads OKas the producer means it
unitcents
worth$100.00
completesettled
as consumer v1 reads it
unitcents
worth$100.00
completesettled
PAYMENT.RECORDED
message 2 · writer v2
fieldamount
typelong
value10000
statuscomplete
Reads OKas the producer means it
unitdollars
worth$10,000.00
completeauthorized
as consumer v1 reads it
unitcents
worth$100.00
completesettled, ship it
The upper lines are what a schema check compares: field, type and value. Both slips pass.
The lower lines are what a decision uses: unit and event. No parser can find them in the bytes.
Both slips are shown as their producer means them: one hundred dollars, settled, and ten thousand dollars, authorized.
Reading is three different acts
It helps to separate three things that the word read runs together. The first is parsing: turning a sequence of bytes back into values, a string here, a 64-bit integer there. The second is schema resolution: reconciling the shape the writer used with the shape the reader expects, when the two have evolved separately. The third is interpretation: deciding what a value means for the decision in front of you, such as how much to refund, or whether to ship the goods.
Each act is a real guarantee, and each is narrower than the one after it. A successful parse says the bytes were well formed. A successful resolution says the reader's expectations about fields and types can be satisfied from what the writer sent. Neither says that the number in the amount field still counts the same thing. That third guarantee is the one the business is actually relying on, and it is the one nobody's tooling checks unless somebody has told the tooling what to look for.
What a careful format promises
Apache Avro is a good place to see this, because its specification is unusually precise about what it does. Pin the reference to version 1.11.3, released in September 2023, so a later edit cannot change the claim.1 Avro data is always written with a schema, and the specification distinguishes the writer's schema, used when the data was written, from the reader's schema, the one the reading application expects. When the two differ, a set of resolution rules decides what happens.1
The rules are sensible and strict about structure. Record fields are matched by name, so their order may change. A field the writer sent but the reader does not know is ignored. A field the reader expects but the writer did not send takes the default value declared in the reader's schema, and if there is no default, resolution fails with an error. Some primitive types may be promoted: an int can be read as a long, a float or a double, a long as a float or a double, a float as a double, and strings and bytes can be read as each other. An enum symbol the reader does not recognize falls back to the reader's declared default, if it has one, and otherwise fails.1
Now notice what the rules leave out. Avro lets each schema and each field carry a doc attribute, a string for human readers, and that is where a team might write "amount in cents." The specification is explicit: "A schema's doc fields are ignored for the purposes of schema resolution."1 So if the writer's schema says dollars in its doc string and the reader's says cents, the two schemas match, the data resolves, and the consumer computes one hundred dollars from a ten-thousand-dollar payment. Every rule was followed. The specification even defines a canonical form for comparing schemas that strips doc attributes away, because they are irrelevant to parsing.1
None of this is a defect in Avro. The specification says what it checks, and it checks it well. The failure on the second slip lives in a promise that was never expressed anywhere the resolver could see it. If a different format is in use, the same exercise applies, but its own rules should be tested rather than borrowed from Avro by analogy.
When the word stays and the event moves
Units are the easiest case to picture. A harder one is a word whose spelling never changes. Suppose the invented payment system has a status field, and for years the value complete has meant that the payment settled: the money arrived. A fulfillment service, the consumer, ships goods when it sees complete. Then the payment team, for good reasons of its own, starts marking a payment complete as soon as it is authorized, which in this invented system means the money has been approved but has not yet arrived and may still fail to.
The field is still a string, the value is still the same eight letters, and an enum check would pass. The fulfillment service is behaving correctly against the contract it was built for and incorrectly against the business event that now sits behind the word. It ships on approvals that sometimes never turn into money. Who is wrong? In the narrow sense, neither team. The payment team changed what its event means; the fulfillment team kept relying on what it used to mean. The gap between them is a promise that changed without anyone renegotiating it.
This is not only a hypothetical pattern. In September 1999, NASA lost the Mars Climate Orbiter as it arrived at Mars. The investigation board found that a ground software file called "Small Forces," whose output fed the trajectory models, reported thruster impulse in English units, pound-force seconds, where the project's Software Interface Specification required metric newton-seconds.3 One pound of force is about 4.45 newtons, so the navigation software underestimated the effect of those small thruster firings by that factor, and the spacecraft's trajectory ended up roughly 170 kilometers lower than planned.3 The interface specification defined both the format and the units of the file. The format was honored. The units were not, and the numbers were perfectly well formed.3
A small contract for meaning
The remedy is not a grand new architecture. It is a short written contract, kept for the fields where a misunderstanding would be expensive, with seven parts. A definition of what the field records. Its unit, or its currency and scale. The lifecycle event it marks, such as authorized or settled. What a missing or null value means. A handful of example inputs. The interpretation a consumer is expected to draw from each example. And an owner, a named person or team who decides when the meaning may change.
Two habits make such a contract much stronger than a page of prose. The first is to move meaning into structure wherever possible, so that the resolver can see it. A field named amount_cents cannot silently start holding dollars without someone renaming it, and renaming a field is exactly the kind of change a structural check notices. Separate fields for authorized_at and settled_at make it impossible to confuse the two events. Avro's logical types work the same way within their limits: two decimal schemas match only if their scales and precisions match, so a change of scale becomes visible to resolution.1 The limit is that implementations must ignore a logical type they do not recognize and fall back to the underlying type, so a logical type is a strong hint, not a guarantee everywhere.1
The second habit is to make the examples executable. "Given a payment of ten thousand in this field, the refund screen shows one hundred dollars" is a sentence a test can run, in the producer's build and in the consumer's. When the producer changes its convention, the test fails before the change ships, at the desk of the person who made it, instead of weeks later in someone else's ledger.
Protocols with long lives already work this way. The HTTP standard, published as RFC 9110 in June 2022, does not stop at describing the bytes of a request. It defines what a method promises. A method is safe when the client "does not request, and does not expect, any state change" on the server, and GET is defined to be safe.2 That definition of meaning, not the wire format, is what lets a search crawler follow links without fear of deleting anything.2
A migration is a period of coexistence
Suppose the payment team really does need to change the meaning of a field. The honest way to think about the change is not as a moment but as a period, during which old and new producers and old and new consumers will all be running at once. A useful migration plan states four things. Which producer versions can work with which consumer versions, and where they cannot. What the historical data means, since a warehouse full of cents does not convert itself because the live stream switched. How a shadow comparison will catch divergence, by running the new consumer beside the old one on the same messages and comparing the decisions they reach. And what event retires the old promise: a date, the last consumer confirmed, the comparison quiet for an agreed time.
The worksheet below lays out the core of such a plan for the invented payment system. Each cell pairs a writer version with a reader version and records two separate results: whether the reader can read the message, and whether it draws the meaning the writer intended. Drag across the sheet to pick a cell, or tab to it and use the arrow keys.
The circled cell is the whole essay in one square. The structural check passes, honestly, because nothing structural changed. The written example fails, because the reader built for cents shows one hundred dollars for a payment the writer meant as ten thousand. A team that runs only the first check will ship that cell to production and believe it was careful. A team that also runs the second will see the problem on its own screen, and can then choose: rename the field, add a new one beside the old, or run both versions side by side until every reader has moved.
Objections, considered
A schema registry already prevents breaking changes. It prevents the classes of breaking change it checks, and that is valuable. A check built on Avro's rules will catch a new reader field with no default, a type that cannot be promoted, and every other difference that would make resolution fail, before any of them reaches production. But their scope should be stated rather than enlarged by assumption. A registry that compares Avro schemas is applying rules in which documentation is ignored by design.1 Reporting its green light as "the change is safe" claims more than the check measured.
Consumers should not depend on undocumented behavior. In principle, no. In practice, the fulfillment service in the example depended on what complete had meant for years, and whether or not that was written down, it was true. Observed dependence is a migration fact. The producer's better response is to discover who relies on the old meaning, tell them, and agree on an end date, rather than to assume the dependence away and let the consumer find out from a customer.
Formal contracts create bureaucracy. They can, if every annotation needs a committee. The contract proposed here is meant for a few fields where a misunderstanding moves money, inventory or safety, with a handful of executable examples each. Adding a description to a log field does not need a review. Changing what amount counts does. The cost of the review should be proportionate to the harm a misunderstanding would cause, and for most fields that harm is small.
This is just documentation. Some of it is, and documentation alone has a poor record: the doc string in the example said cents right up to the day it was edited to say dollars. The stronger version connects each definition to a test that runs in both teams' builds and to a decision about when the old behavior is retired. Documentation tells a careful reader what was meant. A test tells a careless build that the meaning has changed.
A compatible message can still carry an incompatible promise.
Before the next schema review
- Which fields conceal a unit? Amounts, durations, rates and weights whose unit lives in a doc string or a wiki page instead of the field's name or type.
- Which status values authorize an action? Find the words that make another system ship, pay, approve or notify, and name the event each one marks.
- Which consumer supplies an executable example? For every consequential field, at least one reader should own a test that would fail if the meaning moved.
- Which versions coexist, and until when? A migration without a stated retirement event becomes a permanent second meaning.
- Who may change the meaning? A field with no owner of its definition has an owner by default: whoever edits it next.
Back to the two slips on the desk. Reprint them with the promise on their face. The first reads amount_cents: 10000 and settled_at with a date. The second reads amount_dollars: 10000 and authorized_at, with no settlement yet. They no longer look identical, and a reader built for the first will now stop at the second and ask what it means. That is the point. The slips were never the same message. They only looked the same to a check that was never asked the question.