API engineer validating Protobuf unknown-field preservation across binary and JSON relay paths

A Protobuf Message Survived the Relay. Did Its New Fields?

Thu, Sep 24, 2026

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.