Protocol Evolution and Compatibility

On this page

After a protocol is deployed, clients, servers, proxies, caches, historical data, and offline tasks do not upgrade simultaneously. Compatibility design ensures that these different versions coexist within clear boundaries, rather than simply adding a version number to headers or requiring parsers to "accept as much as possible."

DimensionRelationship to MaintainFailure Example
Byte FormatAbility to locate fields and parse correctlyNew field length breaks old parser
Data SemanticsConsistency in units, defaults, and meaningThe same number changes from seconds to milliseconds
BehaviorConsistency in state machines, errors, and retry semanticsNew service allows intermediate states that old clients cannot handle
SecurityIdentity, permissions, and integrity requirements are not weakenedAuthorization constraints are silently omitted during fallback
OperationsAvailability of mixed versions, storage, and rollbackNew data is persisted, but old programs cannot re-read it

Clarify Who Generates Data and Who Reads It First

Compatibility: Define read/write directions first, then check if semantics are preserved Old Writer → New Reader New implementation can read historical data: often called backward compatible New Writer → Old Reader Old implementation can handle new data: often called forward compatible New Writer → Old Middle Layer → New Reader Being able to read is not enough: storing, transforming, or forwarding must not lose necessary information Request and response write directions are opposite; simply writing "new client / old server" cannot describe the complete compatibility relationship.

Using data producers/consumers as a reference, a new reader being able to read old writer data is typically called backward compatibility; an old reader being able to handle new writer data is typically called forward compatibility. Terminology references in different communities may vary, so design documents should explicitly state the version direction.

"New client with old server" involves two paths: the new client writes requests for the old server to read, and the old server writes responses for the new client to read. These two paths require different capabilities and cannot be replaced by a single client/server age table instead of a data direction matrix.

How Changing a Field's Unit Turns Wire Compatibility into a Business Error

Assume the original field timeout_seconds = 1 is a uint32, and the new requirement wants to support milliseconds. Renaming the field to timeout_ms might still result in successful byte parsing, but the original value 5 would change from five seconds to five milliseconds, causing a semantic break. Switching to uint64 would not fix the unit meaning.

A more controllable evolution is to keep the old field, introduce a new, explicit timeout_ms = 2, define the priority when both appear, and maintain a writable method that is usable before the old reader exits. If 0 means "no timeout set," you must distinguish between "not provided" and "explicitly provided 0"; you cannot rely solely on truthiness checks.

# Teaching model: field names represent parsed fields; explicit presence is expressed by key existence.
def read_timeout_ms(message):
    if 'timeout_ms' in message:
        value = message['timeout_ms']
    elif 'timeout_seconds' in message:
        value = message['timeout_seconds'] * 1000
    else:
        value = 30_000
    if type(value) is not int or not 0 <= value <= 3_600_000:
        raise ValueError('invalid timeout')
    return value

assert read_timeout_ms({'timeout_seconds': 5}) == 5000
assert read_timeout_ms({'timeout_ms': 0}) == 0
assert read_timeout_ms({}) == 30000
assert read_timeout_ms({'timeout_seconds': 5, 'timeout_ms': 1200}) == 1200
# If the old reader only supports whole seconds, 1.2s cannot be losslessly written back to the old field.
assert 1200 % 1000 != 0
print('Units, defaults, explicit 0, and dual-field priority passed')

You cannot claim that any value for the new field can "automatically degrade." 1.2 seconds can only be rejected, have new features limited, or use an explicit lossy conversion strategy in the old format; the choice belongs to the business contract. In Protobuf, you can use the corresponding presence mechanism to express absence and explicit defaults; language-generated code and field versions must also be checked together. Protobuf Field Presence

ChangeChecks Required
Add fieldWhether old readers allow unknown values; whether new reader defaults are correct
Delete fieldKeep field number/name; check historical data and old writers
Modify numeric typeWire compatibility, range overflow, and language mapping changes
Add enum valueDefault branch in old code, unknown value preservation, and business handling
Add oneof / change field ownershipPresence, mutual exclusion, and old value clearing semantics
Change mandatory constraintsWhether old writers can still generate accepted requests

After deleting a field in Protobuf, you should reserve the corresponding identifier to prevent old bytes from being interpreted as another meaning in the future. Having the same field number and readable wire type does not equal type, numeric, or business compatibility. Proto3 Updating Messages

Unknown Data: Skip, Preserve, or Reject Are Different Strategies

ScenarioBasis for Reasonable Behavior
Optional attributes affecting only displayCan be ignored per contract, preserving existing behavior
Transparent forwarding or persistence by middle layersMay need to preserve unknown fields to avoid backend losing new information
New permission constraints or commands that must be understoodReject or disable features if not understood
Unknown frame without a universal lengthCannot blindly skip if boundaries cannot be located
Known message appearing in an error stateShould error according to the state machine, not pretend to be an optional extension

HTTP/2 defines rules for ignoring unknown frame types and specifies handling for unknown SETTINGS parameters, but still requires adherence to connection prefaces, CONTINUATION sequences, etc. QUIC v1 requires FRAME_ENCODING_ERROR for unknown transport frame types; it is not TLV with a uniform length per frame, so HTTP/2 rules cannot be simply copied over. HTTP/2, QUIC §12.4

HTTP/3 has its own frame and stream extension rules, requiring a distinction between "unknown" and "known but appearing in a prohibited location." Extension tolerance should be determined jointly by the syntax and semantics of each layer. HTTP/3 Extensions

Even if an old middle layer successfully parses and re-writes, it may lose information: converting Protobuf to JSON, copying fields one by one into DTOs, or storing in a database with only old columns all alter the unknown field path. You should test the round-trip result of new messages passing through the actual middle layer, not just test directly between two latest version libraries.

Why does a successful old relay lose a new field?

Send ms1200: preserving yields1.2s, rebuilding yields old5s. With ms0, preservation means unlimited; removing the field triggers5s fallback. A truthiness check cannot implement this precedence.

Preparing the visual
Why does a successful old relay lose a new field?

Skippable does not mean lossless forwarding. Explicit0 differs from absence; rebuilding known fields loses semantics even when parsing succeeds.

Negotiation, Discovery, and Fallback Should Not Be Mixed into One Step

MechanismTimingDecision Content
HTTP/1.1 UpgradeProtocol switch request/response within an existing connectionSwitch on that connection if conditions are met
TLS ALPNWithin TLS handshakeSelect the application protocol for this connection from the client-provided list
HTTP Alt-SvcAfter receiving alternative service advertisementChoose another connection or an existing alternative connection for subsequent requests
QUIC Version MechanismConnection establishment and applicable version negotiation processTransport version and corresponding compatibility rules
Application CapabilityTiming defined by the application protocolWhether an optional feature is enabled by both parties

A typical modern use of HTTP Upgrade can be referenced in WebSocket; the old h2c Upgrade mechanism for HTTP/2 has been deprecated in RFC 9113 and should not continue to be used as a general tutorial for current HTTP/2 connection establishment. ALPN server selection in TLS 1.2 occurs in ServerHello, while in TLS 1.3 it occurs in EncryptedExtensions; you cannot draw a single ServerHello answer for all TLS versions. HTTP Upgrade, ALPN, TLS 1.3 Extension Locations

Alt-Svc does not convert an existing TCP connection into QUIC in place, nor does it force the client to migrate; the client still needs to verify the authority of the alternative service for the original origin. Advertisement validity, failure caching, and existing connections all affect actual selection. Alt-Svc

Version numbers and capabilities are not the same thing: some implementations of version 2 may lack a certain optional compression feature. The existence of a feature may have directionality, parameter ranges, and dependencies; a bitmap can only express a simple set of applicable capabilities, and the TLS cipher suites list itself is not a capability bitmap.

Find the Available Intersection First, Then Execute Local Policies

def choose(local_preference, peer_offered, forbidden=()):
    offered = set(peer_offered)
    for name in local_preference:
        if name in offered and name not in forbidden:
            return name
    raise ValueError('no acceptable common mode')

assert choose(['v2', 'v1'], ['v1']) == 'v1'
assert choose(['v2', 'v1'], ['v1', 'v2']) == 'v2'
try:
    choose(['v2', 'v1'], ['v1'], forbidden={'v1'})
except ValueError:
    pass
else:
    raise AssertionError('local minimum policy bypassed')
print('Intersection, preference, and local minimum requirements take effect separately')

This is a teaching selection function and does not represent that all protocols sort by the same party's preference. In practice, you must also verify that the peer's selection comes from a legitimate offer and bind the negotiation content that needs protection to the authentication handshake, preventing middle devices from deleting strong capabilities and forcing fallback.

Fallback is a strategy after a trial fails, not negotiation itself. Temporary network failures, version unsupported, certificate verification failure, and insufficient authorization should not all trigger the same fallback; fallback cannot silently cancel authentication or change business permissions. When there are minimum requirements, having no acceptable intersection is a failure.

GREASE: Continuously Use "Should Be Ignorable" Extension Locations

Protocol specifications say to ignore unknown values, but if the ecosystem only sends a few fixed values for a long time, buggy implementations might hardcode these values. GREASE maintains the usability of extension points by continuously sending reserved values in allowed locations, exposing such bugs early.

TLS GREASE uses values reserved by the specification, not arbitrary random bytes injected into arbitrary fields. The server should treat them as unknown candidates and select other legal capabilities; if the server returns GREASE values as real negotiation results, the client must reject them. It tests correct unknown value behavior, not requiring all locations to accept loosely. RFC 8701

HTTP/3 reserves values in the form of 0x1f * N + 0x21 for some settings and other spaces to exercise the corresponding ignore rules; different registries have their own regulations, and this formula should not be treated as a unified rule for TLS or all QUIC layers. GREASE also does not guarantee that every implementation sends all variants. HTTP/3 Reserved Settings

If an implementation only whitelists GREASE values for special handling and still errors on truly unknown values, it has not achieved the goal. Tests should cover different values, locations, lengths, and mixed known fields, while strictly rejecting inputs prohibited by the specification.

QUIC's Evolvability Has Clear Boundaries

QUIC encrypts and protects many transmission details, reducing the chance of middle devices making incorrect inferences about them; but it is not "all flags are encrypted, and middle boxes only see the CID." Long headers still have visible fields like version and connection ID, and some leading bit positions also have public meanings; header protection is also different from hiding the entire header content. QUIC Invariants

The initial protection key for QUIC Initial can be derived from public information and cannot serve as a universal guarantee to hide all handshake content from passive observers. Connection migration relies on CID, path validation, and state, not on completely removing IP/port from the network path. Evolvable internal formats and stable external invariants need to be designed together.

The base Version Negotiation packet itself lacks the authentication guarantees of a normal handshake; you cannot blindly trust the list within it to achieve secure fallback. RFC 9368 defines compatible version negotiation and related verification, and upgrade windows and service cluster capabilities also affect fallback prevention boundaries. QUIC Compatible Version Negotiation

Release Order Must Include Storage and Rollback

Rolling Evolution: Extend read capability first, then enable new write semantics ① Extend reader New version reads both old and new fields, still writes in old format ② Confirm full-path capabilities Services, proxies, consumers, offline tasks, and rollback versions are all included in checks ③ Enable new writer Release by capability or scope, observing old readers and persistent data behavior Only then consider shrinking old paths; binary rollback will not automatically revert newly written new-format data back to the old format.

Assume the service first upgrades to dual-read but old-write, and old consumers still work normally; then confirm that all necessary paths can read the new fields, and gradually enable new writing. To support rollback to the original old binary, you cannot first write data that it cannot read or correctly interpret. A more common rollback target is a version that "supports dual-read but has new write features disabled."

Test CombinationWhat to Verify
Old writer → New readerHistorical data and defaults
New writer → Old readerUnknown fields, ranges, and explicit failures for unsupported features
New → Old middle layer → NewWhether unknown information is preserved
New data → Rollback versionBoundaries between data and code rollback
Partial nodes have new capabilitiesWhether routing, retries, and capability caching are correct
Truncation, duplication, unknown critical valuesWhether failure behavior is consistent across different versions

Also account for the longest survival time of long connections, offline clients, retry queues, backups, and batch processing. "Production traffic no longer has old versions" does not mean old format data has completely disappeared. There should be usage evidence and a clear retention window before deleting old paths.

Lenient parsing without unified rules causes implementations to produce different results for the same input, creating interoperability and security issues. Clearly writing acceptable inputs, extension locations, and failure behaviors, and continuously verifying through mixed versions and actual data paths, allows the protocol to evolve stably. Maintaining Robust Protocols