Versioning
The protocol carries a version from v1 because adding one later would be a breaking migration across every node and every backend simultaneously — and nodes are phones in other people’s buildings that update on their own schedule.
Negotiation
v is sent on connect. The backend picks the highest mutually supported
version and the session proceeds on that.
{ "kind": "control", "type": "version_negotiate", "payload": { "supported": [1] } }This means a fleet can be upgraded gradually. A backend supporting v1 and v2 speaks v2 to updated nodes and v1 to the rest, with no flag, no rollout coordination, and no window where both must be restarted together.
The rules
| Change | Bumps v? |
|---|---|
| Adding a new optional field | No |
| Adding a new command or event type | No |
| Adding a new value to an existing enum | No |
| Removing a field | Yes |
| Making an optional field required | Yes |
| Changing the meaning of an existing field | Yes |
| Changing a field’s type | Yes |
The short version: additive changes are free, removals and semantic changes are not.
The two rules that make it work
Unknown fields are ignored, never rejected
An older node receiving a frame with fields it has never heard of processes what it understands and drops the rest. Without this rule, every additive change would break every deployed node, and the version number would have to bump for everything.
Unknown types are quarantined, not fatal
Decoding yields Ok, Unsupported or Malformed. An unrecognised (kind, type) pair produces Unsupported: the frame is shelved, the connection
continues, and the node keeps doing its job.
This is what lets a backend start emitting a new event type, or accepting a new command, before every node in the fleet has been updated. The old nodes degrade rather than disconnecting.
The same principle applies at the application layer in pairing: a rejection code a build has never seen reaches the UI intact with its message, rather than being flattened into “invalid code”.
Keeping two implementations honest
The protocol has two implementations — Kotlin in the node, TypeScript in
@luno-oss/protocol — and they are cross-checked against a
shared fixture corpus. The same fixtures/frames.json is read by the node’s
ProtocolFixturesTest and by the TypeScript suite, both asserting byte-identical
round-trips.
When you change the protocol, change the fixtures. A protocol change that updates only one implementation fails the other’s tests in the same commit, which is the entire point — silent codec drift between a phone and a server is extremely expensive to diagnose in the field.
Practical guidance
- Design new capability as additive first. If a feature can be expressed as a new optional field or a new event type, it costs nothing. If it requires changing what an existing field means, reconsider the design before reconsidering the version.
- Reserve fields early.
publicKey,sessionId,installId,statusand the payloadvfield were all reserved before they were needed, precisely so that adding request signing or bulk provisioning later would not be a version bump. - Never assume the node is current. A phone that has been running untouched for eight months is the normal case, not the edge case.