Skip to main content

khive_wire_protocol/
lib.rs

1//! # khive wire protocol
2//!
3//! This crate is the normative specification of khive's frame protocol
4//! (ADR-137: "Tailnet Wire Transport for the khive Frame Protocol"). It
5//! defines framing, the handshake sequence, every frame kind and its
6//! fields, the closed wire error taxonomy, and protocol versioning. A
7//! non-Rust client can implement this protocol from this documentation
8//! alone — that is the ADR's stated bar for this crate, and the sections
9//! below are written to meet it.
10//!
11//! This crate does no I/O and depends on no async runtime. It is types, a
12//! byte-buffer codec, and a handshake state machine; a transport crate
13//! (Unix-domain socket, tailnet TCP, or any other carrier of the same
14//! framing) drives it.
15//!
16//! ## Framing
17//!
18//! One wire frame is:
19//!
20//! ```text
21//! +----------------------------+----------------------------------+
22//! | length: u32, big-endian    | payload: `length` bytes of JSON  |
23//! | (4 bytes)                  |                                  |
24//! +----------------------------+----------------------------------+
25//! ```
26//!
27//! `length` is the byte length of the JSON payload only — it does not
28//! include itself. This is the same framing the existing Unix-domain-socket
29//! transport uses (`crates/khive-runtime/src/daemon.rs`, `read_frame` /
30//! `write_frame`): a 4-byte big-endian `u32` length prefix followed by JSON
31//! bytes. This crate defines what that JSON is; it does not change the
32//! outer framing.
33//!
34//! The default maximum frame size is **8 MiB**
35//! ([`codec::DEFAULT_MAX_FRAME_BYTES`]), matching the existing transport's
36//! configured limit. A deployment may configure a different maximum via
37//! [`codec::FrameCodec::new`]. A frame whose declared length exceeds the
38//! configured maximum is rejected at decode with
39//! [`codec::CodecError::FrameTooLarge`], which a server maps to the wire
40//! error [`error::WireErrorCode::FrameTooLarge`] and closes the connection
41//! — the frame is never partially buffered.
42//!
43//! [`codec::decode_frame`] and [`codec::encode_frame`] (or the
44//! [`codec::FrameCodec`] wrapper) operate on one complete frame's bytes at a
45//! time. They do not support partial/streaming reads: a transport reading
46//! from a socket is responsible for buffering until it has the 4-byte
47//! prefix and then the declared number of payload bytes before calling
48//! into this crate.
49//!
50//! **Decode errors are connection-terminal.** A decode error carries NO
51//! consumed byte count: once a frame fails to decode, the stream position
52//! is unrecoverable and the transport cannot find the next frame's start.
53//! A transport must map the error through [`codec::CodecError::wire_code`],
54//! send the corresponding wire error, and close the connection — never
55//! attempt to resynchronize and keep reading.
56//!
57//! ## The JSON payload: frame kind and fields
58//!
59//! Every payload is a JSON object with a `"kind"` string field naming one
60//! of the eleven closed frame kinds, plus that kind's own fields flattened
61//! into the same object (an internally tagged encoding). For example, a
62//! `cancel` frame referencing operation id `"op-42"`:
63//!
64//! ```json
65//! {"kind":"cancel","id":"op-42"}
66//! ```
67//!
68//! The eleven frame kinds ([`frame::FRAME_KINDS`]), grouped by role:
69//!
70//! | Kind               | Direction       | Fields                                                        | Role |
71//! | ------------------ | --------------- | -------------------------------------------------------------- | ---- |
72//! | `handshake`        | client → server | `version: u32`                                                 | First frame on every connection; names the client's protocol version. |
73//! | `handshake_ack`    | server → client | `version: u32`                                                 | Accepts a `handshake`; names the version the connection now speaks. |
74//! | `request`          | client → server | `id: string`, `ops: string`, `deadline_ms?: u64`, `namespace?: string`, `actor_id?: string`, `visible_namespaces?: string[]` | One DSL batch/chain (ADR-016) to execute. The three optional identity-override fields exist for transports that accept caller-supplied context; a mapped transport rejects a request carrying any of them with `context_rejected`. |
75//! | `response`         | server → client | `id: string`, `result: json`                                   | Successful terminal frame for a `request`; `result` is the verb-dispatch result, opaque to this crate. |
76//! | `error`            | server → client | `id?: string`, `code: string`, `message: string`               | Wire-level failure. `id` is present for a request-scoped error, absent for a connection-terminal error. |
77//! | `cancel`           | client → server | `id: string`                                                    | Asks the server to terminate the named `request`. No-op on an unknown/subscribe/unsubscribe/already-terminal id. |
78//! | `subscribe`        | client → server | `id: string`, `topic: string`, `resume_cursor?: u64`            | Opens delivery for one topic. |
79//! | `subscribe_ack`    | server → client | `id: string`, `topic: string`, `start_cursor: u64`              | Confirms a `subscribe`; names the cursor delivery begins after. |
80//! | `unsubscribe`      | client → server | `id: string`, `topic: string`                                   | Ends delivery for one topic. Idempotent no-op if not subscribed. |
81//! | `unsubscribe_ack`  | server → client | `id: string`, `topic: string`                                   | Confirms an `unsubscribe`. |
82//! | `event`            | server → client | `topic: string`, `cursor: u64`, `occurred_at: string`, `payload: json` | One state-change delivery. Carries no operation id — correlated by topic and ordered by `cursor` instead. `occurred_at` is RFC 3339. `payload`'s field-by-field shape is owned by the per-topic catalog (ADR-137, "Implementation-phase deliverables"), not by this crate. |
83//!
84//! The set is closed within one protocol version. A decoder that sees a
85//! `"kind"` value outside this table rejects the frame
86//! ([`codec::CodecError::UnknownFrameKind`]) rather than skipping it.
87//!
88//! ## Strict field rejection (closed grammar)
89//!
90//! The grammar is closed in both dimensions — kinds AND fields. Every
91//! payload is parsed by its kind's payload struct
92//! ([`frame::HandshakePayload`], [`frame::RequestPayload`], ...), each of
93//! which carries `#[serde(deny_unknown_fields)]`: a payload carrying any
94//! field its kind does not declare is rejected with
95//! [`codec::CodecError::InvalidFields`], never silently ignored. Unknown
96//! fields are rejected within a protocol version; forward compatibility is
97//! carried by the version handshake ([`version::ProtocolVersion`]), not by
98//! field tolerance — new fields arrive by bumping the protocol version and
99//! teaching the new version's grammar about them. This is the fail-closed
100//! posture ADR-137 requires: a decoder never guesses that an unrecognized
101//! field is ignorable.
102//!
103//! The strictness covers the fields each frame KIND declares. The two
104//! opaque JSON values — `response.result` and `event.payload` — are data,
105//! not grammar: keys inside them are preserved, and their field-by-field
106//! shape is owned by the verb result surface (ADR-016) and the per-topic
107//! event catalog respectively, not by this crate.
108//!
109//! Three deliberate boundaries of the strictness, stated so nobody
110//! re-derives them:
111//!
112//! - **Explicit `null` on an optional field is equivalent to absence.**
113//!   Optional payload fields are `Option<T>`; a member present with value
114//!   `null` decodes as absent and re-encodes with the member omitted. No
115//!   frame distinguishes present-null from absent.
116//! - **Duplicate members are last-wins, not rejected.** Payloads pass
117//!   through `serde_json`'s object model before field checking, so a
118//!   duplicated member name silently keeps the last occurrence —
119//!   `deny_unknown_fields` cannot see the earlier one. Rejecting
120//!   duplicates would require validating the raw document; the grammar
121//!   takes the documented last-wins stance instead.
122//! - **`topic` syntax is not validated here.** The codec accepts any JSON
123//!   string (including empty) for `subscribe`/`unsubscribe`/`event`
124//!   topics; the `<domain>.<event>` shape is enforced by the server
125//!   against its topic catalog, where the catalog lives.
126//!
127//! ## Opaque payload fidelity
128//!
129//! `response.result` and `event.payload` are preserved as JSON VALUES —
130//! semantic equality, not byte-for-byte. Decoding and re-encoding yields a
131//! payload semantically equal to the original under the JSON data model,
132//! but the wire bytes may differ in two documented ways, both consequences
133//! of this workspace's `serde_json` feature set (default features only —
134//! no `preserve_order`, no `arbitrary_precision`):
135//!
136//! - **Object key order is not preserved.** Decoded objects use
137//!   `serde_json`'s `BTreeMap`-backed map and re-encode with keys in
138//!   sorted order. Consumers must treat key order as insignificant.
139//! - **Integers outside the u64/i64 range lose precision.** Integers
140//!   within u64/i64 range — including values above 2^53 — parse and
141//!   re-encode exactly; anything outside that range parses as `f64` and
142//!   may lose precision.
143//!
144//! The codec's `opaque_payloads_are_preserved_semantically_not_byte_for_byte`
145//! test pins exactly this behavior.
146//!
147//! ## Server-produced fields
148//!
149//! `event.occurred_at` (RFC 3339) and `event.topic` are SERVER-PRODUCED
150//! fields: the server validates them when it produces an event, and the
151//! codec accepts them as plain strings without parsing. This crate
152//! deliberately takes no timestamp-parsing dependency for decode-side
153//! validation of server-produced data.
154//!
155//! ## Handshake sequence
156//!
157//! 1. The client opens the transport connection (Unix-domain socket or
158//!    tailnet TCP) and sends `handshake` as its first frame, naming the
159//!    highest protocol version it supports.
160//! 2. The server checks that version against its own supported range
161//!    ([`version::SupportedVersions`]):
162//!    - If supported, it replies `handshake_ack` naming the accepted
163//!      version. The connection now accepts `request`, `subscribe`,
164//!      `unsubscribe`, and `cancel` frames.
165//!    - If not supported, it replies `error` with code
166//!      `unsupported_version` (no `id` — connection-terminal) and closes
167//!      the connection. The client must surface this rejection; it must
168//!      not fall back to a different protocol or a local code path.
169//! 3. No `request`, `subscribe`, `unsubscribe`, or `cancel` frame is valid
170//!    before step 2 completes successfully.
171//!
172//! [`handshake::HandshakeGate`] implements the server side of this sequence
173//! as a type: it is fed every inbound frame and returns an admit/accept/
174//! reject decision, so "no request frame before handshake completes" is a
175//! property of the gate's API rather than a rule every call site has to
176//! remember to check.
177//!
178//! ## Wire error taxonomy
179//!
180//! [`error::WireErrorCode`] is the closed set of wire-level error codes for
181//! protocol version 1, matching ADR-137's "Wire error taxonomy" table
182//! exactly (serialized as the `snake_case` names below). A wire error is
183//! distinct from a DSL-level per-operation error (ADR-016's `{ok: false,
184//! error}` result carried inside a *successful* `response` frame) — a wire
185//! error is returned instead of, or before, DSL dispatch.
186//!
187//! | Code                        | Condition                                                                                      | Terminal |
188//! | ---------------------------- | ----------------------------------------------------------------------------------------------- | -------- |
189//! | `unsupported_version`        | Handshake named no mutually supported protocol version.                                        | Connection |
190//! | `identity_rejected`          | Whois lookup failed, no/invalid node mapping, or native-frame ingress by a class without access. | Connection |
191//! | `malformed_frame`            | A frame cannot be decoded or violates the frame grammar.                                        | Connection |
192//! | `frame_too_large`            | A frame exceeds the configured maximum size.                                                    | Connection |
193//! | `subscriber_overflow`        | The connection's bounded outbound event queue reached its limit.                                | Connection |
194//! | `subscription_revoked`       | A live mapping/class change short of deletion removed authorization for an active subscription. | Connection |
195//! | `context_rejected`           | A frame-level attempt to supply `namespace`/`actor_id`/`visible_namespaces` on a mapped transport. | Request |
196//! | `peer_class_denied`          | A parsed operation names a verb outside the mapped class allowlist.                             | Request |
197//! | `subscription_denied`        | A `subscribe` names a topic outside the mapped class's allowed-topic set.                       | Request |
198//! | `already_subscribed`         | A `subscribe` names a topic with an already-active subscription on this connection.             | Request |
199//! | `cursor_expired`             | A `subscribe` resume cursor is older than the topic's retention window.                         | Request |
200//! | `in_flight_limit_exceeded`   | A `request` arrives while the per-connection in-flight limit is reached.                        | Request |
201//! | `deadline_exceeded`          | The server abandoned the request at its deadline.                                               | Request |
202//! | `cancelled`                  | The request was terminated by a `cancel` frame before normal completion.                        | Request |
203//! | `shutting_down`              | The server is draining and refuses new work.                                                    | Request |
204//! | `internal`                   | An unclassified server-side transport failure — also the fallback for an unrecognized code.     | Request |
205//!
206//! A **connection-terminal** error is followed by connection close and
207//! carries no operation id. A **request-terminal** error terminates only
208//! the operation id it echoes; the connection stays usable. That pairing
209//! is enforced at encode AND decode time: an `error` frame whose id
210//! presence contradicts its code's terminal scope is rejected, never
211//! represented and never emitted. The check lives in
212//! [`frame::Frame`]'s serde visitor, so EVERY decode path agrees — the
213//! codec's `decode_payload` path (which re-classifies the
214//! rejection into the typed [`codec::CodecError::InconsistentErrorScope`])
215//! AND a direct `serde_json::from_str::<Frame>`. The check covers the
216//! same wire invariants in `Frame`'s serializer, so direct
217//! `serde_json::to_vec::<Frame>` cannot bypass the encode-side validation.
218//! On decode, the check covers the codes in the closed set
219//! ([`error::WIRE_ERROR_CODES`]); an unrecognized
220//! code falls back to `internal` and is processed per the fallback rule
221//! above (its true scope is unknown to this version, so the pairing is
222//! not enforced for it), but the raw code string is preserved in the
223//! decoded frame's `unrecognized_code` diagnostic field rather than
224//! discarded.
225//! If that fallback has no operation id, a consumer has nothing safe to
226//! correlate: it should surface the frame as a connection-level diagnostic
227//! and must not guess which request to fail.
228//!
229//! **Fallback frames are not re-encodable by this relay.** A decoded frame
230//! carrying `unrecognized_code` can be inspected locally, but the encode
231//! path rejects it with [`codec::CodecError::FallbackFrameNotEncodable`]:
232//! re-encoding would emit `internal` and silently discard the newer code
233//! the peer sent, so this crate never corrupts it; the connection remains
234//! healthy. A relay that must pass unknown codes through has to operate on
235//! the raw frame bytes, not on a decoded-and-re-encoded frame. The one
236//! decode-only guarantee the codec path holds over a direct serde decode is
237//! the finer-grained
238//! [`codec::CodecError::UnknownFrameKind`] classification for a `"kind"`
239//! outside the closed set; every other decode-time rule — strict field
240//! rejection, the id/scope pairing, and the unknown-code diagnostic — is
241//! enforced identically on both paths.
242//!
243//! The set is
244//! closed within a protocol version: adding a code requires a version bump,
245//! and a client that decodes a code it does not recognize treats it as
246//! `internal` (request-terminal) rather than inventing semantics for it —
247//! [`error::WireErrorCode`] implements this with `#[serde(other)]`.
248//!
249//! ## Versioning and compatibility
250//!
251//! Protocol version numbers ([`version::ProtocolVersion`]) are monotonic
252//! `u32`s. A server supports the current version and at least the
253//! immediately prior version ([`version::SupportedVersions::current`]). A
254//! breaking wire change — a new frame kind, a new wire error code, or a
255//! non-backward-compatible change to an existing frame's fields — is never
256//! introduced within a version number; it requires incrementing the
257//! version and updating this documentation as the normative source.
258//!
259//! ## What this crate does not define
260//!
261//! - Transport (socket binding, TLS/WireGuard, `tailscale whois`, peer
262//!   mapping) — ADR-137's "Transport identity and actor mapping".
263//! - The DSL carried in a `request` frame's `ops` string — ADR-016.
264//! - The per-topic `event` payload catalog — ADR-137's
265//!   "Implementation-phase deliverables".
266//! - Peer-class allowlists and dispatch enforcement — ADR-137's "Peer
267//!   classes and dispatch enforcement".
268
269pub mod codec;
270pub mod error;
271pub mod frame;
272pub mod handshake;
273pub mod version;
274
275pub use codec::{
276    decode_frame, decode_frame_with_consumed, encode_frame, encode_frame_with_max, CodecError,
277    FrameCodec, DEFAULT_MAX_FRAME_BYTES,
278};
279pub use error::{TerminalScope, WireErrorCode, WIRE_ERROR_CODES};
280pub use frame::{Cursor, Frame, OperationId, CLIENT_TO_SERVER_KINDS, FRAME_KINDS};
281pub use handshake::{HandshakeGate, HandshakeOutcome, HandshakeSequenceError};
282pub use version::{ProtocolVersion, SupportedVersions, SupportedVersionsError, CURRENT_VERSION};