BBest24
ENPL

APIs change, devices remain: compatibility over several years

How can an API add quality and absent measurements without silently changing meaning? JSON and proto3 contracts, a version matrix, migration, history and rollback.

Two spatial structures with different grid spacing are joined by an adapting bridge. A conceptual illustration of interface compatibility.

For two years, a sensor has sent temperature as a number. New firmware must distinguish a valid measurement from a stale value and report when there is no current result at all. The team adds a quality field, updates the server and calls the change backwards compatible. The old number is still in the message, after all.

The problem appears when the sensor stops responding. New code retains the last temperature and sets quality: "stale". An old consumer ignores the unknown field, sees a familiar number and continues presenting it as current. Parsing works, the connection is healthy and there is no API error alarm. Yet the system communicates something different from what the user expects.

Compatibility therefore needs to be assessed through the behaviour of the complete data path. This article develops one fictional measurement contract, a version compatibility matrix and a migration plan with a rollback path. The examples support design review; they are neither a production service nor a claim about a particular deployed fleet.

What the old number promised

Start by writing down the v1 contract. The device sends a message only when it has taken a measurement that its existing diagnostics considers available. Temperature is a finite number in degrees Celsius. measuredAt identifies the measurement time, not the time of transmission, storage or retrieval. A sample message is:

{
  "temperature": 21.5,
  "measuredAt": "2026-10-01T12:00:00Z"
}

The absence of another message does not mean zero degrees. The consumer needs a separate freshness rule. In our example, a new feature requiring a current measurement permits an age from zero to 60 seconds inclusive. This is an application assumption, not a universal property of temperature measurements. Another process might need milliseconds or tolerate hours.

The old format says nothing about quality under newly introduced criteria. We know that the old firmware permitted transmission according to its own behaviour. We do not know whether it performed a later diagnostic check or could detect a newly recognised fault. An adapter should not turn that lack of knowledge into a valid declaration. We use the internal value legacy_unknown for these records.

This has a product consequence. A new feature requiring confirmed quality may be unavailable for an old device even though its existing temperature display still works. That is an explicit capability limitation. Pretending the data has full quality would preserve apparent interface uniformity at the expense of trustworthy information.

Three different compatibility questions

Google AIP-180 distinguishes source, wire and semantic compatibility. A useful review asks whether the client compiles, understands the message and makes the same justified decision after reading it. Meeting the first two conditions does not guarantee the third.

Renaming a field can amount to removing the old one and adding another from the consumer’s perspective. Adding an enum value may be valid for the format but surprise code with a closed list of states. Switching from Celsius to kelvin leaves a valid number while changing its interpretation completely. A test that checks for status 200 and parseable JSON covers only a small part of the contract.

Nor should we say that JSON generally ignores extra fields. The JSON parser reads the structure. A subsequent validator may reject unknown keys, while application code may preserve, ignore or mishandle them. Our S1 demonstrator deliberately permits exactly the two v1 keys. Adding quality without agreeing a contract change causes rejection even though the message remains syntactically valid.

The new format must express an absent result

We choose a separate v2 contract directed to a consumer supporting the new schema. The quantity and unit are explicit, and quality applies to a particular sample. A valid result looks like this:

{
  "schema": 2,
  "temperature": {
    "value": 21.5,
    "unit": "Cel",
    "quality": "valid",
    "measuredAt": "2026-10-01T12:00:00Z"
  }
}

Here, Cel is the agreed unit value. The contract does not accept an arbitrary string or assume every consumer will perform a conversion. valid means the sample passed the diagnostics declared for that device release. It does not mean the measurement is error-free or replace an uncertainty budget.

An absent result has a different representation:

{
  "schema": 2,
  "temperature": {
    "value": null,
    "unit": "Cel",
    "quality": "missing",
    "measuredAt": null
  }
}

We establish a consistency rule: missing requires both values to be null. valid and stale require a number and a measurement time. stale may retain a historical value but is ineligible for an action requiring a current sample. A future, unrecognised quality value is treated conservatively: the consumer does not promote it to valid and signals unsupported meaning.

We do not use zero, minus 999 or an empty string to represent absence. Each can be mistaken for a measurement or hide a validation problem. NaN and infinity are not numbers permitted by the JSON grammar in RFC 8259. Writing "NaN" produces a string, not a special temperature. The meaning of absence must come from the contract.

A matrix describes behaviour, not just version numbers

We introduce firmware releases F1 and F2. F1 knows only v1; F2 supports v2 and a controlled v1 emission mode. Service S1 supports only the old contract. S2 can read both and preserve provenance. Updating the server does not automatically update devices or every user application.

Compatibility matrix for old and new firmware, services and archived messages, preserving unknown quality.
Example compatibility: S1 understands only v1; S2 reads both formats. The v1 adapter preserves temperature and time without inventing valid quality. F2 in v1 mode emits only samples that the old contract can safely represent. Reading an archive does not make a historical sample current.

F1 with S2 still delivers temperature and time. S2 marks quality as unknown under the new model. F2 can work with S1 only in a v1 mode that preserves the old promises. If F2 has missing, stale or an unknown state, it does not manufacture a number. In this mode it emits no new measurement, and the old consumer reaches a stale-data condition through its existing time mechanism.

That option is acceptable only if the mechanism actually exists and works. If an old client displays the last value as current indefinitely, an adapter cannot repair its behaviour. The client must be updated, the feature limited or the unsafe combination disabled. There is no lossless way to represent every v2 state in a number that never carried that meaning.

Sending v2 directly to S1 produces an explicit rejection. Cleverly guessing that the temperature object probably contains a usable number somewhere would weaken contract control. The new service should not infer the version from an incidental key either, when a channel or message version is explicitly available.

Samples that expose real defects

A contract test set should specify expected decisions. Set evaluation time to 12:00:30Z and sample time to 12:00:00Z on the same day. Age is 30 seconds. A v2 sample with valid quality and a value of 21.5 can feed the new feature. The same number marked stale cannot. A v1 number remains available under its original meaning but does not automatically acquire the new quality assurance.

Test the exact boundary too: 60 seconds is permitted; 60.001 is not. A future-dated sample is not exceptionally fresh. In this simplified example, it is blocked from the new feature. A production system must define clock synchronisation tolerance and what happens when it is exceeded. Clamping negative age to zero would conceal the problem.

Further cases include zero degrees as a real measurement, null as absence, an unknown warming_up state, the wrong unit and a contradictory missing state containing a number. In JavaScript, Number(null) returns zero. Validation must therefore not begin with general type coercion. First establish whether the value is a number with an allowed meaning; only then calculate with it.

These rules were checked with a small contract model using a deliberately restricted UTC timestamp format. This is not a complete date-validation library or networking implementation. The tests check expected behaviours and rejections rather than reducing compatibility to matching types.

Protobuf does not remove the meaning problem

Binary Protocol Buffers has different evolution rules from our hand-defined JSON contract. The proto3 guide describes adding fields as binary wire-safe, with appropriate handling of default values. Existing field numbers must not be changed, and a removed number must not be reassigned a new meaning. Safe encoding does not establish correct application logic.

A new, separately identified message can express the intended semantics as follows:

syntax = "proto3";

message TemperatureV2 {
  optional double value_celsius = 1;
  Quality quality = 2;
  string measured_at = 3;

  enum Quality {
    QUALITY_UNSPECIFIED = 0;
    QUALITY_VALID = 1;
    QUALITY_STALE = 2;
    QUALITY_MISSING = 3;
  }
}

This is a new type, not an instruction to replace field 1 with a different type inside an existing message. optional distinguishes a present zero from an absent value. The default quality is unspecified. Validation must still check consistency: QUALITY_VALID requires a present number and a timestamp; QUALITY_MISSING requires an absent number and an empty timestamp under this illustrative contract.

The field presence documentation explains the difference between a scalar default and explicit presence. Adding optional does not fix every old intermediary: parsing and reserialising through code that does not track presence can lose the fact that zero was explicitly set. Test the entire route, including gateways and exports.

ProtoJSON is a separate trap. Its rules must not be equated with binary Protobuf or arbitrary JSON objects. Unknown fields are rejected by default, and their information is not propagated as in the binary format. A converter can therefore change compatibility. The illustrative null in our custom v2 does not claim to be the identical ProtoJSON representation of the type above.

A firmware number is not capability negotiation

Device software version and contract version answer different questions. Firmware can receive a driver fix without changing its messages. Several hardware revisions may also emit the same format. If a server recognises capabilities solely by checking whether a version exceeds some number, it can misclassify a special release, a rolled-back image or a product from another branch.

In our example, F2 declares supported formats and configuration selects one for a particular channel. This does not mean negotiating every field independently. The number of combinations must remain bounded and covered by the test matrix. A capability declaration does not replace authentication: an unrelated device gains no access merely by sending a correctly formed schema: 2.

Telemetry should retain firmware release identity, contract version and active emission mode separately. If rejections rise after an update, the team can then determine whether the issue concerns parsing, configuration or a particular release. A label such as “new client” cannot reconstruct the combination that actually occurred.

Changing a number can change a decision without changing its type

Consider a seemingly cosmetic change: the server starts rounding temperature to one decimal place instead of retaining the transmitted value. For a sample of 21.46 and a rule of temperature > 21.48, the result is false before rounding and true after rounding to 21.5. The message still contains a number in degrees Celsius. Its operational outcome has nevertheless changed.

The contract should therefore separate the value used in calculations from its display formatting. Similar care applies to enabling a calibration correction, changing filtering or replacing an instantaneous sample with a one-minute average. Each may improve the product, but none is neutral for a consumer expecting the previous measured quantity.

Choose boundary tests near thresholds where clients make decisions, rather than only convenient mid-range values. Preserve processing information in the archive when old and new values cannot otherwise be compared honestly. Versioning that meaning can matter more than a cosmetic endpoint rename.

Migration starts with reading

The safe order follows from who will encounter the new format first. Prepare readers and storage for both versions while retaining existing emission. Then enable v2 for a limited sender cohort. Only after observing coexistence should retirement of the old path be considered.

Four API migration stages: readers support both versions, v2 is enabled selectively, coexistence is observed and v1 is retired.
A migration with explicit gates: readers and storage first support both formats; selected senders then emit v2; controlled coexistence follows; only then can v1 be retired. Rollback requires a reader that still understands stored v2.

The first acceptance gate covers the service, data store, export, application and background jobs. Each must either accept the new record or explicitly isolate it. A new endpoint is insufficient if an overnight report assumes temperature is always numeric. The test must send both a valid result and an absent result through the full path.

In the second stage, select pilot devices and record which capability was enabled. Confirmation of v2 support must concern the correct channel and current configuration. A server declaration remembered from a month earlier may be invalid after rollback. Negotiation failure needs a defined outcome, not a randomly selected message format.

During coexistence, monitor semantic rejections as well as parse errors, the number of legacy_unknown records, unsupported states and the proportion of stale data. Distinguish active devices from those that have not contacted the service for a long time. An offline device producing no errors has not demonstrated successful migration.

Rollback includes stored data

Suppose S2 operates for a week and stores v2 records. The team discovers an unrelated defect and wants to restore the previous service image. S1 cannot read those records. Rolling back code may fix one problem while breaking historical reads.

Prepare the retreat path before emitting the first v2 message. One option is the previous application behaviour running on a version that can still read both schemas. A separate adapter is another possibility, but it must preserve quality limitations. Converting missing to zero merely to keep an old report from crashing is not a correct data migration.

A rollback rehearsal should include records written before the change, during coexistence and after activation. It must also cover replaying a queued message and a device returning after a long disconnection. If storage has been changed irreversibly, restoring an old image is not an available recovery path. Document the procedure that can actually restore operation.

Also distinguish a stored measurement from current state. A historical record can preserve that the device considered the sample valid when it was created. A year later, it must not pass freshness checks for an action taken now. Reading an archive does not replace measurement time with retrieval time. That small implementation mistake can cause charts and automation to refer to different moments.

When v1 can be retired

A calendar date is insufficient. We need information about the active fleet, the maximum expected offline interval, customer integrations and stored data. If a device can return after six months, a month without v1 traffic does not prove that dependencies are gone. Unsupported units still require a deliberate decision about what happens when they reconnect.

Retiring a network contract does not automatically mean removing historical readers. Network ingress and archived data have different lifetimes. The system can stop accepting new v1 messages while retaining a stable adapter for old records. That responsibility is narrower and easier to test than maintaining the entire old service.

The team should also inspect exports outside its own system. A customer may have a script that reads only the number and a spreadsheet that treats an empty cell as zero. Without identifying those consumers, a claim of complete compatibility is too broad. Migration documentation should show specific response changes and state meanings rather than merely announce a new version number.

A review record for one contract change

Before accepting the change, complete a short table. The most important column is the last one: an observable outcome that can reject an incorrect implementation.

Scroll horizontally if needed.

Area Question Acceptance evidence
Meaning What did the old value mean, and what does the new one mean? A consumer decision before and after the change
Absence Is missing data distinct from zero and a stale result? Cases for 0, null, stale and no message
Extensions What happens with an unknown field or state? Explicit rejection or a documented limitation
Coexistence Which version combinations are supported? A matrix with a test for each supported cell
History Can old and new records be read again? Archive and queue replay
Rollback Which version reads data written after the change? A rollback rehearsal containing v2 records
Retirement What happens to a late-returning device? Reconnection after an offline period

A good contract does not promise that every future feature will work on every old device. It identifies where behaviour remains unchanged, where knowledge is limited and where migration is required. In this example, the crucial decision is that an absent current temperature must remain absent, even if substituting a familiar number would make an old client easier to maintain.

Sources

Documentation checked on 21 October 2026. The Protobuf example uses proto3 syntax, not Editions defaults; it is not a compiler or runtime test result. The checked scenarios concern the described JSON demonstrator and its semantic decisions.

All notes →