A mixed-version Protobuf path can look healthy while violating the contract that matters. The producer writes a message. The older relay parses it successfully. The relay emits another valid message. The newer consumer receives it without transport errors. Yet a field introduced by the producer may have disappeared somewhere between those apparently successful steps.
For API engineers and platform owners, the acceptance question is therefore narrower than “is the schema backward compatible?” and stronger than “does the message arrive?” It is: does a field unknown to the deployed relay survive the relay’s actual transformation path and reach a newer consumer with its intended meaning intact?
This playbook tests that question with proto3, generated Python messages, two versions of one Shipment schema, and local standard input/output as the transport. It deliberately compares a binary-preserving relay with older-schema ProtoJSON conversion, field-by-field reconstruction, strict JSON parsing, and ignore-unknown parsing. Protocol Buffers documents that proto3 binary messages retain unknown fields during parse/serialization, while converting through JSON or rebuilding a message from known fields can discard them.
The research cutoff is September 22, 2026. The procedure below distinguishes documented behavior, expected acceptance assertions, and observations that qualify as release evidence. Where the exact locked compiler environment cannot be executed, the correct release state is HOLD, not an invented successful run.
Define the field-survival contract before changing the schema
The test topology is intentionally small:
producer_v2 -> relay_v1 -> consumer_v2
Version 1 knows only Shipment.id. Version 2 adds Shipment.delivery_instruction. The producer sets that new field to the synthetic marker HOLD_AT_DOCK_7. The relay is deliberately unaware of field 2. The v2 consumer is the semantic oracle: acceptance requires it to recover the exact shipment ID and the exact instruction marker after the relay path.
Adding a new field is a binary wire-safe schema change: current Protobuf guidance says old code can parse messages produced by newer code, treating the new data as unknown fields. The same documentation separately warns that application transformations can destroy those unknown fields. In other words, wire compatibility is necessary evidence, not end-to-end preservation evidence. Protocol Buffers’ proto3 language guide documents both properties.
That distinction matters operationally. Arrival proves that bytes reached a destination. Parse success proves that the decoder accepted a representation. Field survival proves that information required by the new contract is still present. Only the third answers this rollout question.
This is also deliberately separate from payload efficiency and endpoint performance. That guidance discusses payload size and formats such as Protocol Buffers in a performance context; this acceptance test does not benchmark encodings. A compact message that silently loses delivery_instruction is still a failed integration.
For this fixture, the producer owns the meaning of delivery_instruction, the consumer is responsible for asserting that meaning, and the relay is responsible for proving that its transformation does not destroy information merely because its schema predates the field.
Freeze schema, compiler, runtime, and relay boundaries
Do not test “some Python Protobuf installation.” Record a reproducible environment. Compiler version and Python package version are separate identifiers: for the proposed lock, the compiler release is protoc 33.6, while the Python runtime package is protobuf 6.33.6. The official v33.6 release is dated March 18, 2026, and PyPI records protobuf 6.33.6 on the same date; the Python package supports Python 3.13.
Use a manifest similar to:
fixture_id=relaylab-shipment-r2
python_version=3.13.5
protoc_version=33.6
protobuf_python_runtime=6.33.6
schema_v1=shipment-v1-r1
schema_v2=shipment-v2-r2
relay_build=relay-v1-lab-r1
generation_v1=protoc -I schemas/v1 --python_out=gen/v1 schemas/v1/shipment.proto
generation_v2=protoc -I schemas/v2 --python_out=gen/v2 schemas/v2/shipment.proto
producer_import_root=gen/v2
relay_import_root=gen/v1
consumer_import_root=gen/v2
transport=stdin/stdout binary files
Also retain the .proto files, generated files, pip freeze or equivalent environment lock, compiler artifact checksum, relay source revision, and hashes of every test input/output artifact.
Execution qualification. The exact protoc 33.6 binary was not available in the execution environment used to prepare this validation. A smoke run was possible with Python 3.13.5, runtime 6.33.6, and an available protoc 3.13.0; it reproduced the expected preservation/loss distinctions, but that compiler/runtime pairing is outside the current documented Python acceptance window discussed below. It is therefore useful corroboration, not release evidence. The locked acceptance run remains pending, so an actual production approval based solely on this document is HOLD.
Separate schema compatibility from runtime compatibility
Generated-code compatibility and wire-schema compatibility are different gates. Protobuf describes generated code and the runtime library as separate components and explicitly warns that unsupported version skew can appear to work while remaining unsupported. For Python, generated code since 3.20.0 has unusually long compatibility support; as documented in the policy current at the cutoff, gencode from 3.20.0 onward is supported through the stated Python compatibility window. The cross-version runtime guarantee should therefore be checked independently of the schema change itself.
Before interpreting field-loss results, require each generated module to import in a clean process and print its runtime version without warning or startup failure. A broken or unsupported runtime pairing produces HOLD, because a field-preservation conclusion drawn from an invalid test environment is not acceptance evidence.
Isolate old and new descriptors in separate processes
Both fixture files define the same fully qualified message name, relaylab.Shipment, but with different descriptors. Do not import the independently generated v1 and v2 modules into the same default Python descriptor environment merely to make the harness convenient.
Instead, make process boundaries part of the fixture:
PYTHONPATH=gen/v2 python producer_v2.py
PYTHONPATH=gen/v1 python relay_v1.py
PYTHONPATH=gen/v2 python consumer_v2.py
This also resembles the mixed-version deployment question more faithfully: producer, relay, and consumer do not share one generated module. Python generated code is descriptor-driven, and the official Python generated-code guide describes generated modules and message operations in that model.
Create two schemas and one independent semantic oracle
Keep the fixture smaller than the production contract. There should be exactly enough structure to identify a known field and detect loss of the unknown one.
Version 1:
syntax = "proto3";
package relaylab;
message Shipment {
string id = 1;
}
Version 2:
syntax = "proto3";
package relaylab;
message Shipment {
string id = 1;
string delivery_instruction = 2;
}
Generate the modules from the locked compiler:
mkdir -p gen/v1 gen/v2
./toolchain/protoc-33.6/bin/protoc \
-I schemas/v1 \
--python_out=gen/v1 \
schemas/v1/shipment.proto
./toolchain/protoc-33.6/bin/protoc \
-I schemas/v2 \
--python_out=gen/v2 \
schemas/v2/shipment.proto
The fixture relies on the documented binary-safe operation of adding a field; it does not attempt to teach field-number reuse, oneof migration, or general schema design. Protobuf’s proto3 guide identifies adding fields as binary wire-safe and explains that an older parser treats the newly encoded field as unknown.
The v2 producer should write one known-answer payload:
# producer_v2.py
import sys
import shipment_pb2
msg = shipment_pb2.Shipment(
id="shipment-2026-09-22-001",
delivery_instruction="HOLD_AT_DOCK_7",
)
try:
sys.stdout.buffer.write(msg.SerializeToString())
except BrokenPipeError:
sys.exit(1)
Use an invented ID and an inert, non-sensitive instruction marker. The marker is intentionally nonempty, because the implicit default for a proto3 string is "". That gives the oracle a sharp distinction between “the expected instruction survived” and “the field disappeared and now reads as its default.” Proto3’s default-value rules document the empty-string behavior.
The v2 consumer must not ask the relay whether it preserved the field. It independently parses the emitted bytes using the newer descriptor:
# consumer_v2.py
import argparse
import sys
import shipment_pb2
parser = argparse.ArgumentParser()
parser.add_argument(
"--expect-id",
default="shipment-2026-09-22-001",
)
args = parser.parse_args()
raw = sys.stdin.buffer.read()
msg = shipment_pb2.Shipment()
try:
msg.ParseFromString(raw)
except Exception as exc:
print(f"decode failed: {exc}", file=sys.stderr)
sys.exit(10)
if msg.id != args.expect_id:
print(f"id mismatch: {msg.id!r}", file=sys.stderr)
sys.exit(11)
if msg.delivery_instruction != "HOLD_AT_DOCK_7":
print(
"delivery_instruction missing or changed: "
f"{msg.delivery_instruction!r}",
file=sys.stderr,
)
sys.exit(12)
print("semantic assertion: PASS")
That final comparison is the unit of acceptance. A relay log saying “parsed Shipment” does not substitute for it.
Pass the binary control through the older relay
First prove the expected positive case. The v1 relay parses the v2 bytes and serializes that same v1 message object without translating it into another representation:
# relay_v1.py : binary-preserving mode
import sys
import shipment_pb2
source = sys.stdin.buffer.read()
msg = shipment_pb2.Shipment()
try:
msg.ParseFromString(source)
sys.stdout.buffer.write(msg.SerializeToString())
except Exception as exc:
print(f"relay failed: {exc}", file=sys.stderr)
sys.exit(20)
This is precisely the behavior for which Protobuf’s unknown-field mechanism is relevant. The proto3 guide says unknown fields are retained in a message and included again when it is serialized. It separately recommends binary exchange and message-oriented operations where retaining unknown information matters. The unknown-fields section of the proto3 guide is the vendor basis for expecting field 2 to survive this path.
The ordered acceptance run is:
set -euo pipefail
mkdir -p artifacts
PYTHONPATH=gen/v2 python producer_v2.py \
> artifacts/source-v2.bin
PYTHONPATH=gen/v1 python relay_v1.py \
< artifacts/source-v2.bin \
> artifacts/binary-v1-forward.bin
PYTHONPATH=gen/v2 python consumer_v2.py \
< artifacts/binary-v1-forward.bin
sha256sum \
artifacts/source-v2.bin \
artifacts/binary-v1-forward.bin \
> artifacts/binary-control.sha256
Record two things separately:
Evidence | Required acceptance result |
Relay parse/serialize | exits successfully |
v2 semantic oracle | id exact; delivery_instruction == "HOLD_AT_DOCK_7" |
Input/output artifact hashes | recorded for provenance |
Byte-for-byte equality | not required |
The non-acceptance smoke run described earlier did recover both fields after this binary path. It also happened to emit identical bytes for the tiny fixture. Neither observation can approve the release: the former used an unsupported compiler/runtime combination, and the latter is not the right semantic criterion.
That qualification matters because Protobuf serialization is explicitly not defined as a canonical representation. Semantic equality should come from decoding and asserting the contract, not from assuming every correct implementation must reproduce identical bytes.
Expose the two transformations that discard the new field
Now keep the original v2 payload and the v2 oracle unchanged. Replace only the v1 relay transformation. This isolates the causal boundary: if the control preserves field 2 but the altered relay loses it, the schema addition itself is not the differentiator.
The two destructive paths correspond directly to cases called out by the Protobuf documentation: serializing a message to JSON and populating a fresh message by iterating or copying only recognized fields can lose unknown fields.
A compact relay can support all test modes without changing its schema:
# relevant relay_v1 modes
from google.protobuf import json_format
import shipment_pb2
old = shipment_pb2.Shipment()
old.ParseFromString(source)
if mode == "binary":
output = old
elif mode == "json-roundtrip":
text = json_format.MessageToJson(old)
output = shipment_pb2.Shipment()
json_format.Parse(text, output)
elif mode == "rebuild":
output = shipment_pb2.Shipment(id=old.id)
elif mode == "copy":
output = shipment_pb2.Shipment()
output.CopyFrom(old)
The expected distinction is not subtle: binary and same-type copy preserve the marker; json-roundtrip and rebuild do not.
Round-trip through the old schema’s JSON representation
Take the v2 binary payload and parse it with v1. At that point field 2 is unknown, but it is still resident in the binary message’s unknown-field state. Then run:
json_text = json_format.MessageToJson(old)
round_tripped = shipment_pb2.Shipment()
json_format.Parse(json_text, round_tripped)
result = round_tripped.SerializeToString()
The critical loss boundary is the first line. The v1 descriptor has no JSON field corresponding to delivery_instruction, and ProtoJSON does not provide the binary format’s general unknown-field propagation. Protobuf’s ProtoJSON format documentation says the format does not support unknown fields in the same way as binary and generally does not propagate them through schema evolution.
Therefore, the old-schema JSON produced from the v1 object represents id but not the unknown instruction. Parsing that JSON back into v1 cannot resurrect information that is no longer represented. When the resulting binary reaches v2, delivery_instruction reads as "", and the oracle exits with failure.
The smoke run produced exactly that failure pattern: id remained shipment-2026-09-22-001; the new string was empty. Treat that observation only as corroboration until repeated under the locked environment.
Rebuild only fields the old relay recognizes
The second destructive pattern appears in adapters and “clean” domain-mapping code:
rebuilt = shipment_pb2.Shipment()
rebuilt.id = old.id
sys.stdout.buffer.write(rebuilt.SerializeToString())
The fresh message has no relationship to the unknown bytes stored in old. Its only populated field is the one the v1 application understands. The v2 consumer consequently receives the ID and defaults field 2 to the empty string.
Do not describe that as equivalent to CopyFrom(). Protobuf’s Python generated-code documentation defines CopyFrom() as copying values from another message of the same type, and the unknown-fields guidance recommends message-oriented copies rather than field-by-field reconstruction where preservation is required. The Python generated-code guide is also why this control must be v1-to-v1; there is no fictitious cross-schema CopyFrom(v2_message) in this fixture.
A valid separate control is:
copied = shipment_pb2.Shipment()
copied.CopyFrom(old)
The smoke run preserved the unknown instruction through this same-type copy. Again, rerun under the lock before approval.
Test rejection and silent ignoring in the JSON direction
The previous JSON test starts with binary v2 and asks what happens when an old relay converts its old-schema view to JSON. Test the opposite direction separately: create ProtoJSON using v2, where the new field is explicitly represented, then ask an old v1 JSON parser to consume it.
Generate JSON:
# producer_json_v2.py
import sys
from google.protobuf import json_format
import shipment_pb2
msg = shipment_pb2.Shipment(
id="shipment-2026-09-22-001",
delivery_instruction="HOLD_AT_DOCK_7",
)
sys.stdout.write(json_format.MessageToJson(msg))
ProtoJSON normally maps delivery_instruction to deliveryInstruction, so the resulting document contains an explicit key representing the marker. The ProtoJSON specification describes this lower-camel-case mapping.
Now parse it under v1. Python’s selected json_format.Parse API uses the keyword ignore_unknown_fields; in the runtime inspected for this fixture, its signature is:
Parse(
text,
message,
ignore_unknown_fields=False,
descriptor_pool=None,
max_recursion_depth=100,
)
Strict path:
msg = shipment_pb2.Shipment()
json_format.Parse(text, msg)
Ignore path:
msg = shipment_pb2.Shipment()
json_format.Parse(
text,
msg,
ignore_unknown_fields=True,
)
These are different outcomes, but neither proves preservation. The ProtoJSON specification states that unknown JSON fields should be rejected by default and that an implementation may offer an ignore-unknown option. It also warns that adding a field can produce old-client parse failures unless rollout is coordinated or unknown fields are ignored.
In the smoke run, strict v1 parsing failed with a ParseError identifying deliveryInstruction as an unknown field. With ignore_unknown_fields=True, parsing succeeded, but only id remained. Serializing that old message and decoding it with v2 yielded delivery_instruction == "".
That comparison is operationally important:
Old JSON policy | Parser status | New field after reserialization |
Default strict | failure | no relay output to accept |
Ignore unknown | success | discarded |
Required preservation | n/a | neither path satisfies it |
“Ignore unknown fields” can be useful when a reader intentionally does not need the added data. It is not an opaque forwarding mechanism.
Compare decoded meaning rather than convenient byte properties
The acceptance record should make semantic survival visible at a glance. Before the exact locked run, distinguish documented expectation, smoke observation, and release evidence rather than quietly promoting a smoke result to acceptance.
Path | Parse outcome | id at v2 | instruction marker at v2 | Release decision |
Direct v2 → v2 | expected pass | exact | exact | baseline |
v1 binary parse/serialize | expected pass | exact | exact | ALLOW only after locked observation |
v1 same-type CopyFrom | expected pass | exact | exact | control; verify locally |
v1 JSON round-trip | expected pass | exact | "" | REPAIR / HOLD |
v1 known-field rebuild | expected pass | exact | "" | REPAIR / HOLD |
v1 known-field mutation | expected pass | intentionally changed | exact | acceptable only if mutation is contractual |
strict v1 parse of v2 JSON | expected reject | n/a | n/a | HOLD for that JSON path |
v1 JSON parse with ignore | parse passes | exact | discarded | HOLD if preservation required |
The available smoke execution matched every expectation shown above, including preservation through binary forwarding and same-type copying and loss through JSON/reconstruction. Because the compiler in that smoke environment was 3.13.0, however, those observations are deliberately not labeled release-approved. The official Python compatibility policy at the research cutoff explicitly discusses the long-lived generated-code model beginning with 3.20.0.
For this contract, a missing HOLD_AT_DOCK_7 is decisive. There is no percentage threshold and no “mostly compatible” interpretation: either the v2 oracle recovers that exact business value or the path does not preserve the new field.
Make defaults and field presence explicit
The canonical fixture deliberately uses a nonempty implicit-presence string. When the encoded field is lost, v2 returns the string default "", which is distinguishable from the intended marker. Proto3 documentation notes that implicit scalar fields at their default value do not let the application distinguish “explicitly set to default” from “not provided” after parsing.
Do not generalize this one fixture into a universal presence test. A production contract that uses optional, message fields, default-valued payloads, or other presence-sensitive semantics needs assertions appropriate to those fields. The principle remains the same: the oracle must test the semantic distinction the application actually depends upon.
For this laboratory, using a nonempty string keeps the unknown-field preservation question unambiguous without expanding into a broader field-presence tutorial.
Use hashes for provenance, not semantic equivalence
Calculate SHA-256 hashes for source-v2.bin, every relay output, generated-code archives, and JSON fixtures. Those hashes answer questions such as “which artifact did this test consume?” and “are these two evidence packages the same files?”
They do not define semantic equality. Protocol Buffers’ serialization-is-not-canonical guidance explicitly warns that serialized representations are not a universal canonical identity and discusses the hazards of generic fingerprinting, particularly around unknown fields.
Accordingly:
sha256sum artifacts/* > artifacts/SHA256SUMS
is evidence bookkeeping, while:
decoded.delivery_instruction == "HOLD_AT_DOCK_7"
is the contract test.
The smoke run happened to produce the same SHA-256 for its source and simple binary-forward output and a different hash for the lossy output. That is an artifact-specific observation, not a rule. Another valid implementation or build could serialize semantically equivalent data differently.
Inspect the real transformation path around the serializer
A laboratory binary relay is useful only if it models what production actually does. Trace one deployed relay from input bytes to output bytes and write down every representation boundary. Do not stop at the line that calls ParseFromString().
Typical boundaries worth inspecting are an adapter that creates a domain object, a message-to-dictionary conversion, JSON written to a durable replay queue, JSON persisted and later reconstructed, a fresh protobuf object populated from selected attributes, and an explicit call that discards unknown fields. Protobuf’s documented failure modes make the key question straightforward: where does the original message stop being the thing that is forwarded?
This is a much narrower exercise than a general guide to reliable external API integration. That material discusses adapters, testing, API changes, error handling, and integration maintenance broadly; here, inspect only the transformation chain that can alter this Shipment.
An inventory entry should look like this:
Stage | Input representation | Action | Output representation | Preservation risk |
ingest | Protobuf bytes | v1 parse | v1 message | low; unknown field retained |
enrichment | v1 message | mutate known id | v1 message | test explicitly |
audit log | v1 message | JSON print | JSON | lossy if reused as payload |
replay | JSON | v1 parse | v1 message | unknown marker already absent |
egress | v1 message | binary serialize | bytes | cannot restore discarded marker |
Do not confuse diagnostic logging with the forwarding path. It is harmless for a log formatter to omit an unknown field if the relay still forwards the original binary-bearing message. It becomes a preservation defect when that logged or mapped representation later becomes the authoritative payload.
Create a preservation matrix for mixed-version operation
The acceptance matrix is the handoff between API owners and platform owners. Each row represents an actual transformation mode, not merely a schema pair.
Because the exact locked run remains outstanding, the “observed” column below distinguishes the executed non-acceptance smoke observation from the evidence still required for approval.
Path | Source schema | Relay awareness | Representation transitions | Consumer | Expected fields | Smoke-observed fields | Operational result |
Direct delivery | v2 | none | binary → v2 | v2 | id, instruction | both | baseline; repeat under lock |
Binary v1 forwarding | v2 | v1 | binary → v1 message → binary | v2 | both | both | candidate ALLOW |
v1 same-type copy | v2 | v1 | binary → v1 → v1 CopyFrom → binary | v2 | both | both | preservation control |
v1 known-field mutation | v2 | v1 | binary → v1 mutate id → binary | v2 | mutated id, instruction | both; ID mutated | ALLOW only if mutation is intended |
v1 JSON round-trip | v2 | v1 | binary → v1 → JSON → v1 → binary | v2 | both | ID only | REPAIR |
known-field reconstruction | v2 | v1 | binary → v1 → fresh v1 → binary | v2 | both | ID only | REPAIR |
strict old JSON parser | v2 JSON | v1 | JSON → v1 | n/a | parse or controlled rejection | rejection | HOLD path |
ignore-unknown JSON parser | v2 JSON | v1 | JSON → v1 → binary | v2 | both | ID only | HOLD / REPAIR |
The matrix prevents three common category errors.
First, a schema combination is not itself a transformation path. Binary v1 forwarding and v1 JSON conversion use the same schemas yet have opposite field-survival outcomes. Second, parser success is not equivalent to compatible relaying: the ignore-unknown JSON row succeeds syntactically while losing the contract field. Third, deliberate known-field mutation must be visible as its own case. Do not conceal an id rewrite inside the preservation control and then wonder whether changed bytes indicate unknown-field loss.
Under the locked toolchain, replace “smoke-observed” with an immutable evidence reference: artifact hashes, process exit codes, stderr capture, schema revisions, relay build, and consumer assertion results. Until that replacement exists, the matrix supports engineering expectations but not a release ALLOW.
Turn the counterexamples into regression tests
Once a relay has been shown to preserve or destroy the field, encode those cases into a process-level test. The harness should invoke producer, relay, and consumer as separate commands so the old and new generated modules never share one Python import root.
A minimal shell harness is enough:
#!/usr/bin/env bash
set -euo pipefail
rm -rf artifacts
mkdir -p artifacts
V2="env PYTHONPATH=gen/v2 python"
V1="env PYTHONPATH=gen/v1 python"
$V2 producer_v2.py > artifacts/source.bin
run_preserving() {
local mode="$1"
$V1 relay_v1.py "$mode" \
< artifacts/source.bin \
> "artifacts/${mode}.bin"
$V2 consumer_v2.py \
< "artifacts/${mode}.bin"
}
run_loss_expected() {
local mode="$1"
$V1 relay_v1.py "$mode" \
< artifacts/source.bin \
> "artifacts/${mode}.bin"
set +e
$V2 consumer_v2.py \
< "artifacts/${mode}.bin" \
> "artifacts/${mode}.stdout" \
2> "artifacts/${mode}.stderr"
rc=$?
set -e
if [ "$rc" -eq 0 ]; then
echo "unexpected preservation result for ${mode}" >&2
exit 40
fi
}
run_preserving binary
run_preserving copy
run_loss_expected json-roundtrip
run_loss_expected rebuild
sha256sum artifacts/* > artifacts/SHA256SUMS
Then add the JSON-direction tests separately. The strict parser must exit nonzero and retain its error text. The ignore-unknown parser must exit successfully, but the subsequent v2 oracle must fail because delivery_instruction is empty. Do not make the test pass merely because the old parser returned zero.
Also capture the environment:
python --version > artifacts/python.version
python -c \
'import google.protobuf; print(google.protobuf.__version__)' \
> artifacts/protobuf-runtime.version
./toolchain/protoc-33.6/bin/protoc --version \
> artifacts/protoc.version
This executable evidence complements request and event contract documentation; documentation can state the field and producer/consumer contract, but it does not execute an older relay transformation to prove that unknown data survives it.
One more regression deserves a permanent place: mutate a known v1 field while retaining the old message object.
old.id = old.id + "-relay"
sys.stdout.buffer.write(old.SerializeToString())
The v2 consumer should be invoked with --expect-id shipment-2026-09-22-001-relay and must still recover HOLD_AT_DOCK_7. This separates “the relay changed something it understands” from “the relay reconstructed the entire message and destroyed what it did not understand.”
The suite should fail closed when the compiler, runtime, generated directory, relay revision, or required artifact is missing. A missing environment record is not a warning to clean up later; it is a HOLD because the result cannot be tied to the build being approved.
Choose a safe rollout order and a narrow rollback
The rollout question is not simply whether v2 may be deployed. It is when the producer may start emitting field 2 while older relays still exist.
Begin with consumers that understand v2, then qualify every mixed-version relay path that may encounter v2 messages. Where the relay is a tested binary-preserving parse/serialize path and business policy permits opaque forwarding, the locked field-survival test can support an ALLOW even though that relay does not understand delivery_instruction. That conclusion is grounded in observed preservation, not merely in the schema checker reporting an additive field. The documented unknown-field behavior provides the mechanism; your regression evidence proves your implementation follows the safe path.
If any active path converts through old-schema JSON or reconstructs only recognized fields, do not begin emitting the new instruction through it. Choose one of three actions: hold producer exposure, repair the relay to preserve the binary/message representation, or deliberately redesign that route’s contract so preservation is no longer required.
ProtoJSON’s own schema-evolution guidance is consistent with that caution: added JSON fields can fail against old readers, and “ignore unknown” allows deployment flexibility by discarding data rather than preserving it.
Rollback should be equally narrow. If the new field encounters a destructive relay, disable emission of that field or roll back the defective relay transformation. Do not roll back unrelated queueing, networking, databases, or consumer infrastructure merely because they are adjacent to the failing path.
Recover only from retained authoritative input
Repairing the relay prevents future loss. It does not recover an instruction already removed from the only surviving artifact.
Suppose the authoritative v2 source bytes were retained before the old relay converted them to JSON. After repairing the relay, those bytes can be replayed through the corrected build because the original unknown field is still encoded in the authoritative input. The replay manifest should be bounded: record source artifact IDs or hashes, original processing interval, repaired relay build, target consumer revision, and reconciliation status.
For example:
replay_id=shipment-field2-repair-r1
source_window_start=2026-09-22T13:00:00Z
source_window_end=2026-09-22T13:15:00Z
source_kind=retained-v2-binary
relay_build=relay-v1-fixed-r2
consumer_schema=shipment-v2-r2
required_assertion=delivery_instruction:HOLD_AT_DOCK_7
Reprocessing is valid only from an authorized source that still contains the value. Protobuf’s documentation tells us why the distinction exists: binary unknown fields can remain in a parsed message, while a JSON conversion or known-field rebuild can discard them. Once that information has disappeared from the surviving representation, decoding it again cannot reconstruct the original value.
So if the only retained artifact is:
{
"id": "shipment-2026-09-22-001"
}
there is no technical basis for inferring whether the original instruction was HOLD_AT_DOCK_7, some other string, or absent. REPROCESS is unavailable from that artifact alone. The correct state is data loss for that field unless another authoritative source exists.
Keep downstream duplicate-action handling separate. Replaying a repaired input may require existing deduplication or reconciliation controls, but that is not evidence about unknown-field preservation and should not expand this test into a delivery/idempotency tutorial.
Assign acceptance authority at the schema boundary
Approval should have named evidence owners rather than a single generic “platform signed off” checkbox.
The producer owner defines the new field’s meaning, supplies the known-answer fixture, confirms when producers will begin emitting it, and identifies whether loss is acceptable anywhere.
The relay owner documents every representation transition in the deployed build, provides its exact build revision, runs the mixed-version regression under the locked toolchain, and repairs any JSON/reconstruction path that violates the preservation contract.
The consumer owner owns the independent v2 oracle. That team verifies the recovered id and exact delivery_instruction; it must not accept a relay-generated report as a substitute.
The security or data-governance reviewer decides whether the relay is permitted to forward information it does not understand. Unknown-field preservation is a data-retention property, not authorization. An older relay that successfully carries opaque bytes has not validated the business semantics of those bytes. This is where API security and evidence boundaries remain relevant: logging, trust, visibility, and data handling controls are different concerns from serializer preservation.
Use four release states:
Decision | Evidence required | Stop condition |
ALLOW | locked compiler/runtime recorded; exact relay build tested; v2 oracle recovers required fields on every approved path | any active required path lacks evidence |
HOLD | evidence incomplete, runtime/compiler mismatch, strict JSON incompatibility unresolved, or required path not tested | do not enable field emission |
REPAIR | demonstrated path loses field through JSON, reconstruction, or another transform | fix transform, then rerun entire fixture |
REPROCESS | repaired relay plus retained authoritative input containing original field and bounded replay plan | source artifact already lost the value |
For the environment described in this playbook, the immediate formal status is HOLD: the behavior was smoke-tested, but the exact compatible protoc 33.6 lock was not executed. That is deliberately stricter than pretending the successful smoke behavior establishes release acceptance.
After the locked run, a binary relay may move to ALLOW if the semantic oracle passes. The JSON round-trip and field-by-field reconstruction cases remain REPAIR for a contract requiring field survival. Strict old JSON parsing is HOLD until coordinated, while ignore-unknown parsing is still HOLD as a preservation mechanism because it succeeds by throwing the field away.
Use only synthetic, non-sensitive markers in the laboratory, and retain no more payload evidence than policy permits.
Build API skills that connect contracts to operational proof
A mature compatibility review stops treating “the message arrived” as the finish line. The stronger practice is to define the semantic unit that must survive, isolate the exact mixed-version transformation, run an independent newer decoder, preserve the evidence, and attach ALLOW, HOLD, REPAIR, or REPROCESS to observable results.
That mindset complements broader REST and fullstack API foundations, but the critical lesson here is representation-specific: binary Protobuf can retain fields an old schema does not understand, while an intermediate representation can erase them before a newer consumer ever gets a chance to interpret them.
For engineers developing the broader testing and contract-management skills around that work, Refonte Learning’s APIs Developer Fundamentals page lists API documentation and testing plus versioning and deprecation among its competencies, with a published duration of three months at 10–12 hours per week. Those are provider-described curriculum facts; the page does not establish that this specific Protobuf/ProtoJSON laboratory is taught.
The release boundary remains concrete: a delivered message is evidence of delivery. A preserved contract is evidence that the field carrying the required meaning survived the path.
