The wire protocol is 1.0. Any 1.x client works with any 1.x server, older or newer. The case to design for is a new client against a server that is far older: servers run for weeks and are upgraded on their own schedule, clients update whenever their app does. A new major is a deliberate, announced break; 0.x cannot talk to 1.x. Protocol has the framing and handshake this page builds on.
Versions
HELLO and HELLO_OK each carry major.minor.patch. The server, every
client, and the hub’s satellite link compare majors only and refuse another
major with VERSION_INCOMPATIBLE. Minor and patch are informational:
phux status shows them to explain a peer’s behavior, and nothing gates on
them. What a peer can do is its feature bits. (Handing an older server over
to a newer binary compares build versions, not the protocol.)
A newer client, an older server
The older server answers what it does not know and keeps the connection:
| The client sends | The server answers |
|---|---|
| an unknown frame type | ERROR UNSUPPORTED, uncorrelated |
| an unknown command tag, or an unknown value in a command body | COMMAND_RESULT error UNSUPPORTED with the request id |
an unknown value in a request frame (SPAWN, MOVE, metadata, LIST_DIRECTORY, PATH_QUERY) | ERROR UNSUPPORTED with the request id |
an unknown value in any other frame (INPUT_*, ATTACH, resize, …) | ERROR UNSUPPORTED, uncorrelated; nothing is applied |
an unknown op or field in a JSON request (CHECKOUT, NATIVE_AGENT) | the same as an unknown value in a command body |
| unknown tagged fields, or bytes past a record’s known tail | skipped |
An “unknown value” is a type byte, tag, enum value, flag bit, or presence
or boolean byte this build did not allocate. Presence and boolean bytes are
strict: 0 and 1 are known and anything else is unknown. Unknown
modifier and role-policy bits are refused, never masked off, so a newer
client’s input or restriction is never applied half-understood. A spawn
whose fields break this build’s per-kind rules is a correlated spawn
refusal, so a later rule relaxation does not close older servers. Bytes that cannot be read at all (a truncated
field, a bad length, invalid UTF-8 or JSON where it is required) are still
malformed and close the connection with MALFORMED_MESSAGE; so does a
frame sent before HELLO.
HELLO itself always decodes. An unknown color support reads as
truecolor and an unknown output mode as raw; a flag is set when its byte is
nonzero; a limit above this server’s cap is clamped to the cap and a zero
limit takes the default. An unknown compression algorithm reads as none.
A client checks a feature bit before it sends the request the bit gates,
and degrades that one feature when the bit is missing. It does not refuse
the connection and does not rely on the UNSUPPORTED reply for ordinary
flow; the reply is what keeps a mistake from hanging a request or dropping
the session.
A newer server, an older client
A newer server sends a new frame kind, event, enum value, or record shape
only to clients whose HELLO version or capabilities show they know it.
Clients are still tolerant, because that rule can be gotten wrong.
phux_protocol::wire::decode::newer_server_fallback is the shared rule,
and the runtime, the C ABI, the TUI, and the web client all apply it:
- unknown tagged fields and record tails are skipped;
- a reply this build cannot read (
COMMAND_RESULT,METADATA_VALUE,METADATA_KEYS,DIRECTORY_LISTING,PATH_RESULTS) settles its request as an error with code0,UNREADABLE_REPLY; an unreadableRESOURCE_SPAWNEDorRESOURCE_MOVEDsettles as anUnknownspawn or move result. Neither isUNSUPPORTED: the server answered, so the request may have taken effect. Acknowledged input, a spawn, or an agent prompt answered this way (or with an error code or spawn error the client does not know) settles as outcome unknown, never as refused, so nothing is retyped or re-prompted; - an unreadable
ATTACHEDorATTACH_READYends the connection with a protocol error instead of leaving the attach waiting; - any other frame with an unknown value is skipped with a warning;
- unknown spawn and move errors and tombstone, history, and rejection
reasons decode as an
Unknownvariant (a reason as wire value255, which a client never encodes); a tombstoned stream reads as released; - an unknown path status reads as truncated, and a path row of an unknown kind is dropped and marks the answer truncated;
- unknown close reasons, detach reasons, error codes, and agent events were already surfaced as unknown.
Only malformed bytes close the connection. A HELLO_OK or
BOOTSTRAP_BEGIN naming an output mode, profile, or engine codec the
client did not offer is malformed too, since the server must pick from the
offer. A server never compresses a frame that settles a request (replies,
ERROR, ATTACHED, ATTACH_READY), because a client could not find the
type byte of one it cannot read; only bulk frames such as
BOOTSTRAP_CHUNK and HISTORY_PAGE are compressed.
Federation
A hub’s link to a satellite is a 1.x client of that satellite. A frame the
hub cannot read for an unknown value is logged and skipped; when it
answered a request the hub relayed, the request settles as an unknown
outcome, never as refused or UNSUPPORTED, since the satellite may have
acted: a spawn or move result carries the reserved result tag 255
(outcome not stated), anything else an UNREADABLE_REPLY error. The hub
never relays a reply it cannot read, since a satellite’s ids inside it
would not be translated to the hub’s. An
uncorrelated ERROR from a satellite is logged and does not end the link.
Malformed bytes still end the link. A hub relays a command to a satellite
only when that satellite advertises the feature, and refuses
UNSUPPORTED_SATELLITE_ROUTE otherwise.
Making a change compatible
- Ship new behavior behind a feature bit in
caps/server_features.rs. Do not bump the major for an additive change; a shipped bit is never withdrawn or repointed. - A new request is a
COMMANDsub-tag, so an older server answers it with a correlatedUNSUPPORTEDinstead of an uncorrelated one. - Grow a positional record only at its tail. A record that is not last in
its field carries a
u32length. An optional value in the middle of a record carries a presence byte (0absent,1present), as the keyed kill and signal commands,ATTACH_RESOURCE, andNATIVE_AGENT_RESULTdetail do; never infer presence from how many bytes remain, since that leaves no room for a field after it. - Ids and other leaves are frozen. A new shape is a new tag, a new enum
value is a new number, and a retired field id is never reused. Value
255is reserved in reason enums and spawn and move result tags: it is how a peer holds an unknown one. A fixed-size field such asPATH_QUERY’s grows through a new field id, not a longer value. The fieldwire_typebyte is always4. - Syntax an older peer validates is part of 1.0 as well: a path in
PATH_RESULTSis an absolute POSIX path, and an arrangement names a session or a workspace. Changing either needs a feature bit. - A count or size limit an older peer enforces (path results, directory
entries, batch and arrangement caps,
APPLY_INPUT) is part of 1.0. Raising one needs a feature bit; the newer peer sends more only when the other side advertises it. - JSON requests are decoded strictly by the server, so an older server
refuses an unknown op or field as
UNSUPPORTEDrather than ignoring it. JSON a client reads (answers, agent stream updates, stored recipes) skips unknown fields; native agent states and dispositions fold an unknown value tounknown. - Say in the PR what an older client sees, and what an older server does
with the new request. The probes live in the server’s protocol
tests (
newer_client*.rs) and the codec’s wire contract (tail_extension.rs,wire_roundtrip/hello_growth.rs).