# JetStream Version Negotiation Specification
This document specifies how JetStream services negotiate protocol versions. Version negotiation follows the 9P model: transport-level identifiers (ALPN, URI path, iroh protocol ID) are used to **route** a connection to the correct protocol handler, then a `Tversion`/`Rversion` exchange on the stream **negotiates** the protocol version and maximum message size.
## Overview
r[jetstream.version.overview]
Version negotiation is a two-phase process:
1. **Routing**: The transport layer uses a protocol identifier to look up a handler. This identifier is the protocol name (not the full semver version) — it answers "which service?" not "which version?".
2. **Negotiation**: Once routed, the first message on the stream is a `Tversion`/`Rversion` exchange. The client proposes its version and maximum message size. The server checks compatibility and responds with the agreed version and message size — or `"unknown"` if the version is not acceptable.
This mirrors the Plan 9 `version(5)` protocol: connect first, negotiate after.
## Protocol Trait
r[jetstream.version.protocol]
The `Protocol` trait requires two string constants:
- `const NAME: &'static str` — the protocol name (e.g., `"echohttp"`). Used by the transport layer for routing — matching connections to the correct handler by name alone.
- `const VERSION: &'static str` — the full protocol version string (e.g., `"rs.jetstream.proto/echohttp/15.0.0+bfd7d20e"`). Used during the `Tversion`/`Rversion` negotiation exchange on the stream.
The macro generates both: `NAME` is the lowercased trait name, and `VERSION` is `PROTOCOL_VERSION`.
## Protocol Version String
r[jetstream.version.string]
The `PROTOCOL_VERSION` constant is generated by the `#[service]` macro inside the protocol module. Its format is `rs.jetstream.proto/{trait_name_lower}/{cargo_version}+{digest_prefix}`, where `{cargo_version}` is `CARGO_PKG_VERSION_MAJOR.CARGO_PKG_VERSION_MINOR.CARGO_PKG_VERSION_PATCH` and `{digest_prefix}` is the first 8 characters of the SHA-256 of the trait source.
r[jetstream.version.string.build-metadata]
The digest is appended as semver **build metadata** using the `+` separator, not as a pre-release using `-`. This is semantically correct: the schema digest is build-time metadata that does not affect version precedence. A version string like `15.0.0+bfd7d20e` means "version 15.0.0, built from schema digest bfd7d20e". The `semver` crate ignores build metadata when evaluating version comparisons. This applies to all code paths that produce or consume the version string: the Rust `#[service]` macro, the TypeScript codegen backend, and `Version::from_str` parsing.
r[jetstream.version.string.parsing]
The `Version::from_str` implementation parses strings beginning with `rs.jetstream.proto` by splitting on `/` into three parts: prefix, name, and semver version string. The semver portion (including build metadata after `+`) is parsed via `semver::Version::parse`. The resulting `Version::JetStream { name, version }` carries both the protocol name and the parsed semver version.
## Routing
r[jetstream.version.routing]
The transport layer routes incoming connections to a protocol handler using `Protocol::NAME`. This is used solely for handler lookup — no version checking happens at this layer. Version compatibility is determined later during the `Tversion`/`Rversion` exchange.
r[jetstream.version.routing.identifiers]
Each transport uses a different mechanism to carry the protocol name:
- **WebTransport (H3Service)**: The CONNECT request URI path (e.g., `/echohttp`). `H3Service` looks up the handler by matching the path against registered `Protocol::NAME` values.
- **QUIC (jetstream_quic::Router)**: The ALPN string. Handlers are registered with `Protocol::NAME` as the ALPN, and the router maps it to the `ProtocolHandler` implementation.
- **iroh**: iroh's `Router::builder().accept(alpn, handler)` registers handlers by `Protocol::NAME` as ALPN bytes.
r[jetstream.version.routing.protocol-router]
`ProtocolRouter` is a transport-agnostic registry that maps protocol names to service handlers. It replaces the ad-hoc `HashMap<ProtocolRequirements, Box<dyn WebTransportHandler>>` inside `H3Service` and provides a unified lookup mechanism usable by any transport. Registration takes a protocol name and a handler. Multiple services with distinct protocol names can be registered on the same router.
## Tversion/Rversion Negotiation
r[jetstream.version.negotiation]
After routing establishes which handler serves the connection, the first message exchange on the stream is `Tversion`/`Rversion`. This follows the Plan 9 `version(5)` protocol.
r[jetstream.version.negotiation.tversion]
The client (downstream) sends a `Tversion` frame as the first message:
```text
size[4] Tversion tag[2] msize[4] version[s]
```
- `msize`: The maximum message size the client can handle, inclusive of the size field.
- `version`: The client's protocol version string (e.g., `"rs.jetstream.proto/echohttp/15.1.0+a1b2c3d4"`).
r[jetstream.version.negotiation.rversion]
The server (upstream) responds with an `Rversion` frame:
```text
size[4] Rversion tag[2] msize[4] version[s]
```
- `msize`: The negotiated maximum message size — the minimum of the client's and server's `msize`.
- `version`: The server's accepted version string if compatible, or `"unknown"` if the version is not acceptable.
r[jetstream.version.negotiation.reset]
Following 9P semantics, a `Tversion` request clunks all open fids and terminates any pending I/O. It resets the connection to a clean state. This allows re-negotiation if needed.
## Version and Error Framers
r[jetstream.version.framer]
`Tversion`/`Rversion` and `Error` are special message types that all services must handle, regardless of the service's own RPC methods. The `#[service]` macro injects these into the generated `Tmessage` and `Rmessage` enums alongside the service-specific variants.
r[jetstream.version.framer.tmessage]
The macro injects a `Version(Tversion)` variant into `Tmessage` at message type `TVERSION` (100). This is generated the same way the error variant is injected into `Rmessage` — as a fixed variant that every service's framer can decode and encode:
```rust
pub enum Tmessage {
// ... generated RPC request variants ...
Version(Tversion) = TVERSION,
}
```
When the server-side framer decodes a frame with type `TVERSION`, it produces `Tmessage::Version(tversion)`.
r[jetstream.version.framer.rmessage]
The macro injects a `Version(Rversion)` variant into `Rmessage` at message type `RVERSION` (101), alongside the existing `Error` variant:
```rust
pub enum Rmessage {
// ... generated RPC response variants ...
Error(Error) = RERROR,
Version(Rversion) = RVERSION,
}
```
When the client-side framer decodes a frame with type `RVERSION`, it produces `Rmessage::Version(rversion)`.
r[jetstream.version.framer.server-dispatch]
The macro-generated `Server::rpc` implementation handles the `Tversion` variant before dispatching to service methods. When it receives `Tmessage::Version(tversion)`, it:
1. Parses the client's version string via `Version::from_str`.
2. Calls `Self::accepts(client_version)` to check compatibility.
3. If accepted: responds with `Rmessage::Version(Rversion { msize: min(client_msize, server_msize), version: Self::VERSION.to_string() })`.
4. If rejected: responds with `Rmessage::Version(Rversion { msize: 0, version: "unknown".to_string() })`.
No user code is involved — version negotiation is fully handled by the generated server dispatch.
r[jetstream.version.framer.client-handshake]
The macro-generated client sends a `Tversion` frame as its first message when establishing a connection. It sends `Tmessage::Version(Tversion { msize, version: PROTOCOL_VERSION.to_string() })` and waits for `Rmessage::Version(rversion)`. If the response version is `"unknown"`, the client returns an error and does not proceed with RPC calls.
## Server Version Acceptance
r[jetstream.version.accepts]
The `Server` trait provides a default `fn accepts(client_version: Version) -> bool` method that determines whether a `Tversion` proposal is acceptable. This is called by the generated server dispatch during the `Tversion`/`Rversion` exchange.
r[jetstream.version.accepts.default]
The default implementation parses `Self::VERSION` into a `Version` and compares it against the client's proposed version:
1. **9P2000 / 9P2000.L**: Exact protocol match — `V9P2000L` accepts only `V9P2000L`, `V9P2000` accepts only `V9P2000`.
2. **JetStream**: Protocol name must match. Then semver compatibility is checked: same major version, and the server's `(minor, patch)` must be >= the client's `(minor, patch)` using tuple ordering. This ensures:
- A major version change is always a breaking change and is rejected.
- The server can have newer minor/patch versions than the client (the server may have added new RPC methods, which is non-breaking).
- A client with a higher minor/patch than the server is rejected, because the client might call methods the server doesn't have.
3. **Mismatched variants**: Any other combination returns `false`.
r[jetstream.version.accepts.override]
Service implementations can override `accepts` to enforce custom version policies — for example, pinning to an exact version, accepting a wider range during migration, or rejecting based on the schema digest in build metadata.
## Macro Code Generation
r[jetstream.version.macro.version-string]
The macro generates `PROTOCOL_VERSION` using `+` as the digest separator:
```rust
pub const PROTOCOL_VERSION: &str = concat!(
"rs.jetstream.proto/",
trait_name_lower,
"/",
env!("CARGO_PKG_VERSION_MAJOR"),
".",
env!("CARGO_PKG_VERSION_MINOR"),
".",
env!("CARGO_PKG_VERSION_PATCH"),
"+",
digest_prefix
);
```
The TypeScript and Swift codegen backends must produce the same format with `+` as the build metadata separator.
r[jetstream.version.macro.server-impl]
The macro-generated `impl Server for ServiceName<T>` inherits the default `accepts` from `Server`. The generated `rpc` method includes the `Tmessage::Version` match arm that performs the version handshake. Users who need custom acceptance logic override `accepts` on their type.
r[jetstream.version.macro.message-id-start]
`MESSAGE_ID_START` must be bumped from 101 to 102 so that service method IDs do not collide with `RVERSION` (101). `TVERSION` is 100 and `RVERSION` is 101 — these are reserved, matching the 9P convention. The first service method's request type becomes 102, response type 103, and so on. This is a wire-format breaking change and requires a major version bump.
r[jetstream.version.macro.backwards-compat]
The `Tversion`/`Rversion` exchange is a new addition to the JetStream RPC stream lifecycle. Previously, RPC frames were sent immediately after connection establishment with no version handshake. Adding `Version` variants to `Tmessage`/`Rmessage` reserves type IDs 100 and 101 for the version handshake. Since `MESSAGE_ID_START` is bumped to 102, this changes the wire-format encoding of all service method frames — existing clients and servers using the old IDs are incompatible. This is intentionally a major version bump.