liminal/protocol/frame.rs
1use super::{
2 causal::MessageId,
3 envelope::{MessageEnvelope, SchemaId},
4 error::ProtocolError,
5 version::ProtocolVersion,
6};
7
8/// Number of bytes in every serialized frame header.
9pub const HEADER_LEN: usize = 10;
10
11/// Frame-flag bit set on a [`Frame::ConversationMessage`] to request a correlated
12/// reply.
13///
14/// A client sets this bit on the request frame of a request-reply round trip. The
15/// server, after delivering the message to the conversation participant, drains
16/// the participant's reply and sends it back as a `ConversationMessage` carrying
17/// the same `conversation_id` (the correlation key) and this same flag bit. A
18/// `ConversationMessage` WITHOUT this bit keeps the pre-existing fire-and-forget
19/// semantics: the server stays silent on success. The bit travels in the frame
20/// header's `flags` byte, which the codec already round-trips, so no wire-format
21/// change is required.
22pub const CONVERSATION_REPLY_REQUESTED_FLAG: u8 = 0x01;
23
24/// Frame-flag bit set on a [`Frame::Publish`] to declare that the frame body
25/// carries a trailing idempotency-key string field (the dedup-on-delivery key).
26///
27/// A publisher sets this bit when it wants the server to consult its dedup cache
28/// keyed by the trailing idempotency key, delivering the message to subscribers
29/// AT MOST ONCE across re-publishes of the same key. A `Publish` frame WITHOUT
30/// this bit keeps the pre-existing wire layout EXACTLY: no trailing field is
31/// written and none is read, so a no-key publish is byte-identical to before.
32/// The bit travels in the frame header's `flags` byte, which the codec already
33/// round-trips, so no header-format change is required (the 13-L0 precedent).
34pub const PUBLISH_IDEMPOTENCY_KEY_FLAG: u8 = 0x02;
35
36/// Frame-flag bit set on a [`Frame::PublishAck`] to report a GENUINE delivery
37/// ack: the published message was accepted by at least one live subscriber on
38/// this publish.
39///
40/// This is distinct from the backpressure `Accept`/`Defer`/`Reject` signal: a
41/// `PublishAck` always means the publish was processed without error, but only a
42/// `PublishAck` carrying this bit means a subscriber actually received the
43/// message. An ack WITHOUT this bit means the publish succeeded but reached no
44/// subscriber (an empty channel, or a duplicate suppressed by dedup-on-delivery),
45/// so a caller that needs a true delivery ack can observe the difference. The bit
46/// rides the existing `flags` byte, so no wire-format change is required.
47pub const PUBLISH_DELIVERED_FLAG: u8 = 0x01;
48
49/// Frame-flag bit set on a [`Frame::PublishAck`] to declare that the frame body
50/// carries a trailing durable-position field after `message_id`.
51///
52/// The durable position is the store-assigned position of the row the publish
53/// answers for, written as a `u64` big-endian (the width and byte order of
54/// `message_id`). An ack WITHOUT this bit keeps the older layout EXACTLY: no
55/// trailing field is written and none is read, so an ack that carries no
56/// position is byte-identical to before (the [`PUBLISH_IDEMPOTENCY_KEY_FLAG`]
57/// precedent). The codec sets or clears the bit from the frame's
58/// `durable_position`, so a caller never sets it by hand.
59pub const PUBLISH_DURABLE_POSITION_FLAG: u8 = 0x02;
60
61/// Protocol version that carries the [`Frame::PublishAck`] durable position.
62///
63/// A server writes an ack carrying a position only on a connection that
64/// negotiated this version or later; a connection at 1.0 always receives the
65/// older ack layout.
66pub const PUBLISH_ACK_POSITION_VERSION: ProtocolVersion = ProtocolVersion::new(1, 1);
67
68/// Frame-flag bit set on a [`Frame::Deliver`] to declare that the frame body
69/// carries a trailing [`DeliveredId`] after the envelope.
70///
71/// The pair is written as two `u64`s big-endian, the boot then the id. A
72/// Deliver WITHOUT this bit keeps the older layout EXACTLY: nothing trailing is
73/// written and nothing is read, so a 1.0 or 1.1 peer receives the bytes it
74/// always did. The codec sets or clears the bit from the frame's `message`, so a
75/// caller never sets it by hand (the [`PUBLISH_DURABLE_POSITION_FLAG`]
76/// precedent).
77pub const DELIVER_MESSAGE_ID_FLAG: u8 = 0x01;
78
79/// Protocol version that carries the [`Frame::Deliver`] message id.
80///
81/// A server writes a Deliver carrying a [`DeliveredId`] only on a connection
82/// that negotiated this version or later; a connection at 1.0 or 1.1 always
83/// receives the older Deliver layout.
84pub const DELIVER_MESSAGE_ID_VERSION: ProtocolVersion = ProtocolVersion::new(1, 2);
85
86/// The server's id for a delivered message, with the boot it was drawn in.
87///
88/// `id` is the number the publisher's `PublishAck` carried and the console's
89/// tap id names; it is drawn from a counter that restarts on every boot, so
90/// `boot` (the server incarnation's start, in wall-clock milliseconds since
91/// the epoch) says which run of the server the number belongs to. A reply that
92/// names a request does so by both, and a tap pairs it only within its own boot.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub struct DeliveredId {
95 /// The server incarnation's start, in wall-clock milliseconds since the epoch.
96 pub boot: u64,
97 /// The server's message id within that boot.
98 pub id: u64,
99}
100
101impl DeliveredId {
102 /// The causal parent a reply to this message names: `<boot>:<id>`, both in
103 /// decimal. It is the tap id's number with the boot in place of the device
104 /// (Hermes 7 Oct 08:12), so a tap pairs a reply only within its own boot.
105 #[must_use]
106 pub fn reply_parent(self) -> MessageId {
107 MessageId::new(format!("{}:{}", self.boot, self.id))
108 }
109
110 /// The message a reply's parent names, when the parent is exactly the form
111 /// [`DeliveredId::reply_parent`] writes; any other form names none. Reads
112 /// without allocating, because a tap reads it once per reply.
113 #[must_use]
114 pub fn from_parent(parent: &MessageId) -> Option<Self> {
115 let (boot, id) = parent.as_str().split_once(':')?;
116 Some(Self {
117 boot: canonical_decimal(boot)?,
118 id: canonical_decimal(id)?,
119 })
120 }
121}
122
123/// `digits` as a `u64` when it is the decimal [`u64`]'s `Display` writes: one
124/// or more ASCII digits, no sign, no leading zero, within range.
125fn canonical_decimal(digits: &str) -> Option<u64> {
126 let bytes = digits.as_bytes();
127 let canonical = !bytes.is_empty()
128 && bytes.iter().all(u8::is_ascii_digit)
129 && (bytes.len() == 1 || bytes.first() != Some(&b'0'));
130 if canonical { digits.parse().ok() } else { None }
131}
132
133/// Status byte prefixing a [`Frame::WorkerRegisterAck`] payload that signals the
134/// registration was accepted (no further payload follows).
135pub(crate) const WORKER_REGISTER_ACK_ACCEPTED: u8 = 0x00;
136
137/// Status byte prefixing a [`Frame::WorkerRegisterAck`] payload that signals the
138/// registration was rejected (a length-prefixed reason string follows).
139pub(crate) const WORKER_REGISTER_ACK_REJECTED: u8 = 0x01;
140
141/// Status byte prefixing a [`Frame::LinkOpenAck`] payload that signals the link
142/// opened: the responder's handshake message follows.
143pub(crate) const LINK_OPEN_ACK_OPENED: u8 = 0x00;
144
145/// Status byte prefixing a [`Frame::LinkOpenAck`] payload that signals the open
146/// is held under a Lys draft: the grant and the draft id follow.
147pub(crate) const LINK_OPEN_ACK_HELD: u8 = 0x01;
148
149/// Status byte prefixing a [`Frame::LinkOpenAck`] payload that signals the open
150/// was refused: the refusal follows as a string.
151pub(crate) const LINK_OPEN_ACK_REFUSED: u8 = 0x02;
152
153/// Wire byte of [`LinkEnd::Export`].
154pub(crate) const LINK_END_EXPORT: u8 = 0x00;
155
156/// Wire byte of [`LinkEnd::Import`].
157pub(crate) const LINK_END_IMPORT: u8 = 0x01;
158
159/// Length of the Ed25519 public key a [`LinkOffer`] carries.
160pub const LINK_MACHINE_LEN: usize = 32;
161
162/// Protocol version that carries [`Frame::DisconnectReason`] (ACCESS-005 R5,
163/// docs/design/protocol/REVOCATION-CLOSE.md).
164///
165/// A server writes the reason-bearing close only on a connection that
166/// negotiated this version or later; a server enforcing grant revocation
167/// refuses `Connect` from a peer that cannot negotiate it. A connection below
168/// it only ever sees the payload-free [`Frame::Disconnect`], byte for byte.
169pub const REVOCATION_CLOSE_VERSION: ProtocolVersion = ProtocolVersion::new(1, 3);
170
171/// The one body version of [`Frame::DisconnectReason`] this implementation
172/// writes and reads; any other is refused by name, never skipped.
173pub const DISCONNECT_REASON_BODY_VERSION: u8 = 1;
174
175/// The longest `reason_name` a [`Frame::DisconnectReason`] may carry, in
176/// bytes. Every enumerated name is shorter; a longer one is refused.
177pub const DISCONNECT_REASON_NAME_MAX: usize = 64;
178
179/// The exact length of a [`Frame::DisconnectReason`] body is
180/// `1 + 2 + 4 + name.len()`, so this is its upper bound.
181pub const DISCONNECT_REASON_BODY_MAX: usize = 1 + 2 + 4 + DISCONNECT_REASON_NAME_MAX;
182
183/// Why the server closed a connection, carried by [`Frame::DisconnectReason`]
184/// as a stable code and its stable name (ACCESS-005 R5).
185///
186/// The registry is closed: a code or name outside it, or a code paired with
187/// another code's name, refuses to decode.
188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189pub enum DisconnectReason {
190 /// The right that admitted the connection was withdrawn through a Lys
191 /// grant revocation.
192 GrantRevoked,
193 /// The grant log the connection's binding was decided from was replaced;
194 /// its rights cannot be compared against the new log.
195 RevocationLogReset,
196}
197
198impl DisconnectReason {
199 /// Every reason, in code order.
200 pub const ALL: [Self; 2] = [Self::GrantRevoked, Self::RevocationLogReset];
201
202 /// The reason's stable wire code.
203 #[must_use]
204 pub const fn code(self) -> u16 {
205 match self {
206 Self::GrantRevoked => 0x0001,
207 Self::RevocationLogReset => 0x0002,
208 }
209 }
210
211 /// The reason's stable name: canonical lower-case ASCII.
212 #[must_use]
213 pub const fn name(self) -> &'static str {
214 match self {
215 Self::GrantRevoked => "grant_revoked",
216 Self::RevocationLogReset => "revocation_log_reset",
217 }
218 }
219
220 /// The reason a code and name name together, when both are in the
221 /// registry and agree; `None` otherwise.
222 #[must_use]
223 pub fn from_wire(code: u16, name: &str) -> Option<Self> {
224 Self::ALL
225 .into_iter()
226 .find(|reason| reason.code() == code && reason.name() == name)
227 }
228}
229
230/// Protocol frame categories and their stable wire discriminants.
231#[derive(Clone, Copy, Debug, PartialEq, Eq)]
232pub enum FrameType {
233 /// Connection request.
234 Connect,
235 /// Successful connection response.
236 ConnectAck,
237 /// Failed connection response.
238 ConnectError,
239 /// Connection close notification.
240 Disconnect,
241 /// Channel subscription request.
242 Subscribe,
243 /// Successful subscription response.
244 SubscribeAck,
245 /// Failed subscription response.
246 SubscribeError,
247 /// Channel unsubscription request.
248 Unsubscribe,
249 /// Channel publish request.
250 Publish,
251 /// Successful publish response.
252 PublishAck,
253 /// Failed publish response.
254 PublishError,
255 /// Conversation lifecycle open.
256 ConversationOpen,
257 /// Conversation message delivery.
258 ConversationMessage,
259 /// Conversation lifecycle close.
260 ConversationClose,
261 /// Conversation processing error.
262 ConversationError,
263 /// In-band backpressure acceptance.
264 Accept,
265 /// In-band backpressure deferral.
266 Defer,
267 /// In-band backpressure rejection.
268 Reject,
269 /// Connection keepalive ping.
270 Ping,
271 /// Connection keepalive pong.
272 Pong,
273 /// Server-initiated push of an opaque payload to a connected client.
274 Push,
275 /// Client-initiated correlated reply to a server push.
276 PushReply,
277 /// Worker self-registration announcing identity and routing dimensions.
278 WorkerRegister,
279 /// Server acknowledgement of a worker registration (accepted or rejected).
280 WorkerRegisterAck,
281 /// Server-to-client delivery of a subscribed channel message.
282 Deliver,
283 /// A machine opening one end of a link on an application stream.
284 LinkOpen,
285 /// The server's answer to a [`FrameType::LinkOpen`].
286 LinkOpenAck,
287 /// One sealed message crossing an open link.
288 LinkMessage,
289 /// Server-to-client close carrying a stable reason (ACCESS-005 R5),
290 /// written only on a connection at [`REVOCATION_CLOSE_VERSION`] or later.
291 DisconnectReason,
292 /// Forward-compatible frame type not known to this implementation.
293 Unknown(u8),
294}
295
296impl FrameType {
297 /// Return true when this frame type must appear on stream 0.
298 #[must_use]
299 pub const fn is_control(self) -> bool {
300 matches!(
301 self,
302 Self::Connect
303 | Self::ConnectAck
304 | Self::ConnectError
305 | Self::Disconnect
306 | Self::DisconnectReason
307 | Self::Ping
308 | Self::Pong
309 | Self::WorkerRegister
310 | Self::WorkerRegisterAck
311 )
312 }
313}
314
315impl From<u8> for FrameType {
316 fn from(value: u8) -> Self {
317 match value {
318 0x01 => Self::Connect,
319 0x02 => Self::ConnectAck,
320 0x03 => Self::ConnectError,
321 0x04 => Self::Disconnect,
322 0x05 => Self::Subscribe,
323 0x06 => Self::SubscribeAck,
324 0x07 => Self::SubscribeError,
325 0x08 => Self::Unsubscribe,
326 0x09 => Self::Publish,
327 0x0A => Self::PublishAck,
328 0x0B => Self::PublishError,
329 0x0C => Self::ConversationOpen,
330 0x0D => Self::ConversationMessage,
331 0x0E => Self::ConversationClose,
332 0x0F => Self::ConversationError,
333 0x10 => Self::Accept,
334 0x11 => Self::Defer,
335 0x12 => Self::Reject,
336 0x13 => Self::Ping,
337 0x14 => Self::Pong,
338 0x15 => Self::Push,
339 0x16 => Self::PushReply,
340 0x17 => Self::WorkerRegister,
341 0x18 => Self::WorkerRegisterAck,
342 0x19 => Self::Deliver,
343 // 0x1A is the participant protocol's outer carrier
344 // (`liminal_protocol::wire::PARTICIPANT_FRAME_TYPE`) and must stay
345 // `Unknown` here, so the link frames sit after it.
346 0x1D => Self::LinkOpen,
347 0x1E => Self::LinkOpenAck,
348 0x1F => Self::LinkMessage,
349 0x20 => Self::DisconnectReason,
350 unknown => Self::Unknown(unknown),
351 }
352 }
353}
354
355impl From<FrameType> for u8 {
356 fn from(value: FrameType) -> Self {
357 match value {
358 FrameType::Connect => 0x01,
359 FrameType::ConnectAck => 0x02,
360 FrameType::ConnectError => 0x03,
361 FrameType::Disconnect => 0x04,
362 FrameType::Subscribe => 0x05,
363 FrameType::SubscribeAck => 0x06,
364 FrameType::SubscribeError => 0x07,
365 FrameType::Unsubscribe => 0x08,
366 FrameType::Publish => 0x09,
367 FrameType::PublishAck => 0x0A,
368 FrameType::PublishError => 0x0B,
369 FrameType::ConversationOpen => 0x0C,
370 FrameType::ConversationMessage => 0x0D,
371 FrameType::ConversationClose => 0x0E,
372 FrameType::ConversationError => 0x0F,
373 FrameType::Accept => 0x10,
374 FrameType::Defer => 0x11,
375 FrameType::Reject => 0x12,
376 FrameType::Ping => 0x13,
377 FrameType::Pong => 0x14,
378 FrameType::Push => 0x15,
379 FrameType::PushReply => 0x16,
380 FrameType::WorkerRegister => 0x17,
381 FrameType::WorkerRegisterAck => 0x18,
382 FrameType::Deliver => 0x19,
383 FrameType::LinkOpen => 0x1D,
384 FrameType::LinkOpenAck => 0x1E,
385 FrameType::LinkMessage => 0x1F,
386 FrameType::DisconnectReason => 0x20,
387 FrameType::Unknown(type_id) => type_id,
388 }
389 }
390}
391
392/// Fixed-size frame prefix: type, flags, stream identifier, and payload length.
393#[derive(Clone, Copy, Debug, PartialEq, Eq)]
394pub struct FrameHeader {
395 /// Frame type read from or written to byte 0.
396 pub frame_type: FrameType,
397 /// Frame flags read from or written to byte 1.
398 pub flags: u8,
399 /// Stream identifier read from or written to bytes 2..6.
400 pub stream_id: u32,
401 /// Payload length read from or written to bytes 6..10.
402 pub payload_length: u32,
403}
404
405impl FrameHeader {
406 /// Serialized header length in bytes.
407 pub const WIRE_LEN: usize = HEADER_LEN;
408}
409
410/// Typed activity surface committed by a worker build.
411#[derive(Clone, Debug, PartialEq, Eq)]
412pub struct WorkerActivityDescriptor {
413 /// Activity type.
414 pub name: String,
415 /// Canonical JSON Schema accepted by the worker.
416 pub input_schema_json: String,
417 /// Canonical JSON Schema produced by the worker.
418 pub output_schema_json: String,
419}
420
421/// Self-describing worker registration carried by [`Frame::WorkerRegister`].
422///
423/// A worker announces the routing dimensions it serves plus a stable identity so
424/// the server can associate the worker with its connection and the application can
425/// route work to it. `node` is optional locality (the routing model treats node as
426/// an optional dimension); the codec encodes it with a presence byte rather than
427/// flattening `None` to an empty string.
428#[derive(Clone, Debug, PartialEq, Eq)]
429pub struct WorkerRegistration {
430 /// Namespaces this worker serves.
431 pub namespaces: Vec<String>,
432 /// Task queue this worker pulls from.
433 pub task_queue: String,
434 /// Optional node locality; `None` when the worker declares no node affinity.
435 pub node: Option<String>,
436 /// Activity types this worker can execute.
437 pub activity_types: Vec<String>,
438 /// Stable worker identity.
439 pub identity: String,
440 /// Typed action descriptors; empty identifies a pre-contract worker.
441 pub activities: Vec<WorkerActivityDescriptor>,
442}
443
444/// Outcome of a worker registration, carried by [`Frame::WorkerRegisterAck`].
445#[derive(Clone, Debug, PartialEq, Eq)]
446pub enum WorkerRegisterOutcome {
447 /// The server accepted the registration.
448 Accepted,
449 /// The server rejected the registration, with a human-readable reason.
450 Rejected {
451 /// Human-readable rejection reason surfaced to the worker.
452 reason: String,
453 },
454}
455
456/// The end of a link the sender of a [`Frame::LinkOpen`] takes (ACCESS-002
457/// R1): the exporting machine sends messages across, the importing one
458/// receives them. The server takes the other end.
459#[derive(Clone, Copy, Debug, PartialEq, Eq)]
460pub enum LinkEnd {
461 /// The sender exports: its messages cross to the server.
462 Export,
463 /// The sender imports: the server's messages cross to it.
464 Import,
465}
466
467/// The link a machine offers to open, carried by [`Frame::LinkOpen`].
468///
469/// `machine` is the sender's Ed25519 public key as Lys recorded it; the
470/// server refuses an offer whose key is not the one recorded for the
471/// connection's verified holder. `handshake` is the first Noise `KK` message,
472/// keyed from that identity's X25519 derivation.
473#[derive(Clone, PartialEq, Eq)]
474pub struct LinkOffer {
475 /// The link's id under the network.
476 pub link_id: String,
477 /// The end the sender takes.
478 pub end: LinkEnd,
479 /// The sender's Ed25519 public key as Lys recorded it.
480 pub machine: [u8; LINK_MACHINE_LEN],
481 /// The sender's first handshake message.
482 pub handshake: Vec<u8>,
483}
484
485impl core::fmt::Debug for LinkOffer {
486 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
487 formatter
488 .debug_struct("LinkOffer")
489 .field("link_id", &self.link_id)
490 .field("end", &self.end)
491 .field("machine", &"32 bytes")
492 .field("handshake", &self.handshake.len())
493 .finish()
494 }
495}
496
497/// Outcome of a link open, carried by [`Frame::LinkOpenAck`].
498#[derive(Clone, Debug, PartialEq, Eq)]
499pub enum LinkOpenOutcome {
500 /// Lys allowed the open outright and the handshake completed; the
501 /// responder's handshake message follows, after which the stream is a
502 /// keyed link.
503 Opened {
504 /// The server's second Noise `KK` message.
505 handshake: Vec<u8>,
506 },
507 /// Lys holds the open under a draft; nothing is keyed until it is
508 /// approved and offered again.
509 Held {
510 /// The grant requiring approval.
511 grant: String,
512 /// The draft Lys opened.
513 draft: String,
514 },
515 /// The open was refused, by name.
516 Refused {
517 /// The refusal: the four-field access refusal as JSON, or the
518 /// handshake's or binding's own words.
519 reason: String,
520 },
521}
522
523/// A typed protocol frame body plus the header metadata required to encode it.
524#[derive(Clone, PartialEq, Eq)]
525pub enum Frame {
526 /// Connection request carrying a supported version range and opaque auth token.
527 Connect {
528 flags: u8,
529 min_version: ProtocolVersion,
530 max_version: ProtocolVersion,
531 auth_token: Vec<u8>,
532 },
533 /// Connection success carrying the negotiated protocol version and server capabilities.
534 ConnectAck {
535 flags: u8,
536 selected_version: ProtocolVersion,
537 capabilities: u32,
538 },
539 /// Connection failure carrying a numeric reason and optional message.
540 ConnectError {
541 flags: u8,
542 reason_code: u16,
543 message: Option<String>,
544 },
545 /// Connection close notification with no payload.
546 Disconnect { flags: u8 },
547 /// Server close carrying a stable reason (ACCESS-005 R5): the body is a
548 /// version byte ([`DISCONNECT_REASON_BODY_VERSION`]), the reason's code
549 /// and its name. Written only on a connection at
550 /// [`REVOCATION_CLOSE_VERSION`] or later; the payload-free
551 /// [`Frame::Disconnect`] is unchanged.
552 DisconnectReason { flags: u8, reason: DisconnectReason },
553 /// Channel subscription request carrying a channel and accepted schema hashes.
554 Subscribe {
555 flags: u8,
556 stream_id: u32,
557 channel: String,
558 accepted_schemas: Vec<SchemaId>,
559 max_in_flight: u32,
560 },
561 /// Channel subscription success carrying a subscription id and selected schema.
562 SubscribeAck {
563 flags: u8,
564 stream_id: u32,
565 subscription_id: u64,
566 selected_schema: SchemaId,
567 },
568 /// Channel subscription failure carrying a numeric reason and optional message.
569 SubscribeError {
570 flags: u8,
571 stream_id: u32,
572 reason_code: u16,
573 message: Option<String>,
574 },
575 /// Channel unsubscription request carrying the subscription id.
576 Unsubscribe {
577 flags: u8,
578 stream_id: u32,
579 subscription_id: u64,
580 },
581 /// Publish request carrying a channel and typed message envelope.
582 ///
583 /// `idempotency_key` is `Some` only when the [`PUBLISH_IDEMPOTENCY_KEY_FLAG`]
584 /// flag bit is set; it is the dedup-on-delivery key the server feeds to its
585 /// dedup cache. When `None` (and the flag clear) the frame is byte-identical
586 /// on the wire to a pre-13-L1 publish.
587 Publish {
588 flags: u8,
589 stream_id: u32,
590 channel: String,
591 envelope: MessageEnvelope,
592 idempotency_key: Option<String>,
593 },
594 /// Publish success carrying the accepted message id.
595 ///
596 /// `durable_position` is `Some` only when the ack answers for a stored row
597 /// and the connection negotiated [`PUBLISH_ACK_POSITION_VERSION`]; the codec
598 /// then sets [`PUBLISH_DURABLE_POSITION_FLAG`] and writes the position after
599 /// `message_id`. When `None` the frame is byte-identical to the older ack.
600 PublishAck {
601 flags: u8,
602 stream_id: u32,
603 message_id: u64,
604 durable_position: Option<u64>,
605 },
606 /// Publish failure carrying a numeric reason and optional message.
607 PublishError {
608 flags: u8,
609 stream_id: u32,
610 reason_code: u16,
611 message: Option<String>,
612 },
613 /// Conversation open carrying a conversation id and subject.
614 ConversationOpen {
615 flags: u8,
616 stream_id: u32,
617 conversation_id: u64,
618 subject: String,
619 },
620 /// Conversation message carrying a conversation id and typed message envelope.
621 ConversationMessage {
622 flags: u8,
623 stream_id: u32,
624 conversation_id: u64,
625 envelope: MessageEnvelope,
626 },
627 /// Conversation close carrying a conversation id and optional reason.
628 ConversationClose {
629 flags: u8,
630 stream_id: u32,
631 conversation_id: u64,
632 reason_code: Option<u16>,
633 message: Option<String>,
634 },
635 /// Conversation failure carrying a conversation id, numeric reason, and optional message.
636 ConversationError {
637 flags: u8,
638 stream_id: u32,
639 conversation_id: u64,
640 reason_code: u16,
641 message: Option<String>,
642 },
643 /// Backpressure acceptance for a delivered message.
644 Accept {
645 flags: u8,
646 stream_id: u32,
647 referenced_message_id: MessageId,
648 },
649 /// Backpressure deferral for a buffered message.
650 Defer {
651 flags: u8,
652 stream_id: u32,
653 referenced_message_id: MessageId,
654 reason: Option<String>,
655 },
656 /// Backpressure rejection for a shed message.
657 Reject {
658 flags: u8,
659 stream_id: u32,
660 referenced_message_id: MessageId,
661 reason: Option<String>,
662 },
663 /// Connection keepalive ping.
664 Ping { flags: u8 },
665 /// Connection keepalive pong.
666 Pong { flags: u8 },
667 /// Server-initiated push carrying a correlation id and an opaque payload.
668 ///
669 /// A server writes this frame to a connected client over the client's existing
670 /// connection (server-to-client, the inverse of every other request frame). The
671 /// `correlation_id` is the key the server uses to match the client's later
672 /// [`Frame::PushReply`] back to this push; the `payload` is opaque application
673 /// bytes the server hands the client. This is an application-stream frame, so
674 /// `stream_id` is non-zero like a publish or conversation message.
675 Push {
676 flags: u8,
677 stream_id: u32,
678 correlation_id: u64,
679 payload: Vec<u8>,
680 },
681 /// Client-initiated correlated reply to a [`Frame::Push`].
682 ///
683 /// After handling a pushed frame the client writes this back on the same
684 /// connection, echoing the push's `correlation_id` so the server can match the
685 /// reply to the originating push. The `payload` is the client's opaque answer.
686 PushReply {
687 flags: u8,
688 stream_id: u32,
689 correlation_id: u64,
690 payload: Vec<u8>,
691 },
692 /// Worker self-registration over an established connection.
693 ///
694 /// A worker sends this control frame (stream 0) after the connection
695 /// handshake to announce its identity and routing dimensions. The server
696 /// associates the registration with the connection's process id and surfaces
697 /// it to the application via the connection-notifier hook, then answers with a
698 /// [`Frame::WorkerRegisterAck`]. `node` is optional locality and is encoded
699 /// with a presence byte, never flattened to an empty string.
700 WorkerRegister {
701 flags: u8,
702 registration: WorkerRegistration,
703 },
704 /// Server acknowledgement of a [`Frame::WorkerRegister`].
705 ///
706 /// Carries the registration outcome: [`WorkerRegisterOutcome::Accepted`] when
707 /// the server (and any configured notifier) accepted the worker, or
708 /// [`WorkerRegisterOutcome::Rejected`] carrying a human-readable reason when it
709 /// did not. The acknowledgement is synchronous so a worker never believes it is
710 /// registered when the application rejected it.
711 WorkerRegisterAck {
712 flags: u8,
713 outcome: WorkerRegisterOutcome,
714 },
715 /// Server-to-client delivery of a message on a subscription the client opened.
716 ///
717 /// The server writes this frame on the subscription's own `stream_id` (the
718 /// non-zero application stream negotiated by the client's `Subscribe`). It is
719 /// the only server-originated delivery frame, and it is only ever sent on a
720 /// subscription the client itself opened, so the forward-compatibility rules
721 /// for unknown frames never surprise an old client with one. `delivery_seq` is
722 /// a per-subscription monotonic counter starting at 1 — the anchor the future
723 /// ack/resume (A1 v2 credit) protocol builds on. The `envelope` reuses the same
724 /// [`MessageEnvelope`] serialization as publish and conversation frames.
725 ///
726 /// `message` is `Some` only when the server knows the message's id and boot
727 /// and the connection negotiated [`DELIVER_MESSAGE_ID_VERSION`]; the codec
728 /// then sets [`DELIVER_MESSAGE_ID_FLAG`] and writes the pair after the
729 /// envelope. When `None` the frame is byte-identical to the older Deliver.
730 Deliver {
731 flags: u8,
732 stream_id: u32,
733 delivery_seq: u64,
734 envelope: MessageEnvelope,
735 message: Option<DeliveredId>,
736 },
737 /// A machine opening one end of a link on this application stream
738 /// (ACCESS-002 R1). The server decides the open with Lys, live, for the
739 /// grant the offered end names on the link, proves the offered machine
740 /// key is the verified holder's recorded key, and answers the handshake.
741 LinkOpen {
742 flags: u8,
743 stream_id: u32,
744 offer: LinkOffer,
745 },
746 /// The server's answer to a [`Frame::LinkOpen`] on the same stream.
747 LinkOpenAck {
748 flags: u8,
749 stream_id: u32,
750 outcome: LinkOpenOutcome,
751 },
752 /// One message crossing an open link on its stream: the subject it is
753 /// published on, and the message envelope sealed by the link's transport.
754 /// The receiving end decides the crossing from both machines' passes
755 /// before any effect.
756 LinkMessage {
757 flags: u8,
758 stream_id: u32,
759 subject: String,
760 sealed: Vec<u8>,
761 },
762 /// Forward-compatible frame preserved after length-delimited skipping.
763 Unknown {
764 type_id: u8,
765 flags: u8,
766 stream_id: u32,
767 payload: Vec<u8>,
768 },
769}
770
771impl Frame {
772 /// Construct a ping frame, enforcing the stream-0 control-frame invariant.
773 ///
774 /// # Errors
775 ///
776 /// Returns [`ProtocolError::InvalidStream`] when `stream_id` is not zero.
777 pub fn new_ping(stream_id: u32) -> Result<Self, ProtocolError> {
778 validate_stream(FrameType::Ping, stream_id)?;
779 Ok(Self::Ping { flags: 0 })
780 }
781
782 /// Construct a publish frame, enforcing the non-zero application-stream invariant.
783 ///
784 /// # Errors
785 ///
786 /// Returns [`ProtocolError::InvalidStream`] when `stream_id` is zero.
787 pub fn new_publish(
788 stream_id: u32,
789 channel: impl Into<String>,
790 envelope: MessageEnvelope,
791 ) -> Result<Self, ProtocolError> {
792 validate_stream(FrameType::Publish, stream_id)?;
793 Ok(Self::Publish {
794 flags: 0,
795 stream_id,
796 channel: channel.into(),
797 envelope,
798 idempotency_key: None,
799 })
800 }
801
802 /// Construct a publish frame carrying an idempotency key for dedup-on-delivery.
803 ///
804 /// The returned frame has [`PUBLISH_IDEMPOTENCY_KEY_FLAG`] set and serializes
805 /// the trailing key field, so the server consults its dedup cache for this key.
806 ///
807 /// # Errors
808 ///
809 /// Returns [`ProtocolError::InvalidStream`] when `stream_id` is zero.
810 pub fn new_publish_with_idempotency_key(
811 stream_id: u32,
812 channel: impl Into<String>,
813 envelope: MessageEnvelope,
814 idempotency_key: impl Into<String>,
815 ) -> Result<Self, ProtocolError> {
816 validate_stream(FrameType::Publish, stream_id)?;
817 Ok(Self::Publish {
818 flags: PUBLISH_IDEMPOTENCY_KEY_FLAG,
819 stream_id,
820 channel: channel.into(),
821 envelope,
822 idempotency_key: Some(idempotency_key.into()),
823 })
824 }
825
826 /// Construct a server-to-client push frame, enforcing the non-zero
827 /// application-stream invariant.
828 ///
829 /// # Errors
830 ///
831 /// Returns [`ProtocolError::InvalidStream`] when `stream_id` is zero.
832 pub fn new_push(
833 stream_id: u32,
834 correlation_id: u64,
835 payload: Vec<u8>,
836 ) -> Result<Self, ProtocolError> {
837 validate_stream(FrameType::Push, stream_id)?;
838 Ok(Self::Push {
839 flags: 0,
840 stream_id,
841 correlation_id,
842 payload,
843 })
844 }
845
846 /// Construct a server-to-client delivery frame on a subscription's stream,
847 /// enforcing the non-zero application-stream invariant.
848 ///
849 /// # Errors
850 ///
851 /// Returns [`ProtocolError::InvalidStream`] when `stream_id` is zero.
852 pub fn new_deliver(
853 stream_id: u32,
854 delivery_seq: u64,
855 envelope: MessageEnvelope,
856 ) -> Result<Self, ProtocolError> {
857 validate_stream(FrameType::Deliver, stream_id)?;
858 Ok(Self::Deliver {
859 flags: 0,
860 stream_id,
861 delivery_seq,
862 envelope,
863 message: None,
864 })
865 }
866
867 /// Construct a client-to-server push reply frame, echoing the correlation id of
868 /// the originating push, and enforcing the non-zero application-stream invariant.
869 ///
870 /// # Errors
871 ///
872 /// Returns [`ProtocolError::InvalidStream`] when `stream_id` is zero.
873 pub fn new_push_reply(
874 stream_id: u32,
875 correlation_id: u64,
876 payload: Vec<u8>,
877 ) -> Result<Self, ProtocolError> {
878 validate_stream(FrameType::PushReply, stream_id)?;
879 Ok(Self::PushReply {
880 flags: 0,
881 stream_id,
882 correlation_id,
883 payload,
884 })
885 }
886
887 /// Return the frame type represented by this frame body.
888 #[must_use]
889 pub const fn frame_type(&self) -> FrameType {
890 match self {
891 Self::Connect { .. } => FrameType::Connect,
892 Self::ConnectAck { .. } => FrameType::ConnectAck,
893 Self::ConnectError { .. } => FrameType::ConnectError,
894 Self::Disconnect { .. } => FrameType::Disconnect,
895 Self::DisconnectReason { .. } => FrameType::DisconnectReason,
896 Self::Subscribe { .. } => FrameType::Subscribe,
897 Self::SubscribeAck { .. } => FrameType::SubscribeAck,
898 Self::SubscribeError { .. } => FrameType::SubscribeError,
899 Self::Unsubscribe { .. } => FrameType::Unsubscribe,
900 Self::Publish { .. } => FrameType::Publish,
901 Self::PublishAck { .. } => FrameType::PublishAck,
902 Self::PublishError { .. } => FrameType::PublishError,
903 Self::ConversationOpen { .. } => FrameType::ConversationOpen,
904 Self::ConversationMessage { .. } => FrameType::ConversationMessage,
905 Self::ConversationClose { .. } => FrameType::ConversationClose,
906 Self::ConversationError { .. } => FrameType::ConversationError,
907 Self::Accept { .. } => FrameType::Accept,
908 Self::Defer { .. } => FrameType::Defer,
909 Self::Reject { .. } => FrameType::Reject,
910 Self::Ping { .. } => FrameType::Ping,
911 Self::Pong { .. } => FrameType::Pong,
912 Self::Push { .. } => FrameType::Push,
913 Self::PushReply { .. } => FrameType::PushReply,
914 Self::WorkerRegister { .. } => FrameType::WorkerRegister,
915 Self::WorkerRegisterAck { .. } => FrameType::WorkerRegisterAck,
916 Self::Deliver { .. } => FrameType::Deliver,
917 Self::LinkOpen { .. } => FrameType::LinkOpen,
918 Self::LinkOpenAck { .. } => FrameType::LinkOpenAck,
919 Self::LinkMessage { .. } => FrameType::LinkMessage,
920 Self::Unknown { type_id, .. } => FrameType::Unknown(*type_id),
921 }
922 }
923
924 /// Return the frame flags stored in the fixed header.
925 #[must_use]
926 pub const fn flags(&self) -> u8 {
927 match self {
928 Self::Connect { flags, .. }
929 | Self::ConnectAck { flags, .. }
930 | Self::ConnectError { flags, .. }
931 | Self::Disconnect { flags, .. }
932 | Self::DisconnectReason { flags, .. }
933 | Self::Subscribe { flags, .. }
934 | Self::SubscribeAck { flags, .. }
935 | Self::SubscribeError { flags, .. }
936 | Self::Unsubscribe { flags, .. }
937 | Self::Publish { flags, .. }
938 | Self::PublishAck { flags, .. }
939 | Self::PublishError { flags, .. }
940 | Self::ConversationOpen { flags, .. }
941 | Self::ConversationMessage { flags, .. }
942 | Self::ConversationClose { flags, .. }
943 | Self::ConversationError { flags, .. }
944 | Self::Accept { flags, .. }
945 | Self::Defer { flags, .. }
946 | Self::Reject { flags, .. }
947 | Self::Ping { flags }
948 | Self::Pong { flags }
949 | Self::Push { flags, .. }
950 | Self::PushReply { flags, .. }
951 | Self::WorkerRegister { flags, .. }
952 | Self::WorkerRegisterAck { flags, .. }
953 | Self::Deliver { flags, .. }
954 | Self::LinkOpen { flags, .. }
955 | Self::LinkOpenAck { flags, .. }
956 | Self::LinkMessage { flags, .. }
957 | Self::Unknown { flags, .. } => *flags,
958 }
959 }
960
961 /// Return the flags byte the codec writes into the fixed header.
962 ///
963 /// This is [`Frame::flags`] except on a [`Frame::PublishAck`], whose
964 /// [`PUBLISH_DURABLE_POSITION_FLAG`] bit is set exactly when the ack carries
965 /// a durable position, and a [`Frame::Deliver`], whose
966 /// [`DELIVER_MESSAGE_ID_FLAG`] bit is set exactly when it carries a
967 /// [`DeliveredId`], so the bit and the trailing field never disagree.
968 #[must_use]
969 pub const fn wire_flags(&self) -> u8 {
970 match self {
971 Self::PublishAck {
972 flags,
973 durable_position: Some(_),
974 ..
975 } => *flags | PUBLISH_DURABLE_POSITION_FLAG,
976 Self::PublishAck {
977 flags,
978 durable_position: None,
979 ..
980 } => *flags & !PUBLISH_DURABLE_POSITION_FLAG,
981 Self::Deliver {
982 flags,
983 message: Some(_),
984 ..
985 } => *flags | DELIVER_MESSAGE_ID_FLAG,
986 Self::Deliver {
987 flags,
988 message: None,
989 ..
990 } => *flags & !DELIVER_MESSAGE_ID_FLAG,
991 _ => self.flags(),
992 }
993 }
994
995 /// Return the stream id stored in the fixed header.
996 #[must_use]
997 pub const fn stream_id(&self) -> u32 {
998 match self {
999 Self::Connect { .. }
1000 | Self::ConnectAck { .. }
1001 | Self::ConnectError { .. }
1002 | Self::Disconnect { .. }
1003 | Self::DisconnectReason { .. }
1004 | Self::Ping { .. }
1005 | Self::Pong { .. }
1006 | Self::WorkerRegister { .. }
1007 | Self::WorkerRegisterAck { .. } => 0,
1008 Self::Subscribe { stream_id, .. }
1009 | Self::SubscribeAck { stream_id, .. }
1010 | Self::SubscribeError { stream_id, .. }
1011 | Self::Unsubscribe { stream_id, .. }
1012 | Self::Publish { stream_id, .. }
1013 | Self::PublishAck { stream_id, .. }
1014 | Self::PublishError { stream_id, .. }
1015 | Self::ConversationOpen { stream_id, .. }
1016 | Self::ConversationMessage { stream_id, .. }
1017 | Self::ConversationClose { stream_id, .. }
1018 | Self::ConversationError { stream_id, .. }
1019 | Self::Accept { stream_id, .. }
1020 | Self::Defer { stream_id, .. }
1021 | Self::Reject { stream_id, .. }
1022 | Self::Push { stream_id, .. }
1023 | Self::PushReply { stream_id, .. }
1024 | Self::Deliver { stream_id, .. }
1025 | Self::LinkOpen { stream_id, .. }
1026 | Self::LinkOpenAck { stream_id, .. }
1027 | Self::LinkMessage { stream_id, .. }
1028 | Self::Unknown { stream_id, .. } => *stream_id,
1029 }
1030 }
1031
1032 /// Validate the stream invariant for this frame.
1033 pub(crate) fn validate(&self) -> Result<(), ProtocolError> {
1034 validate_stream(self.frame_type(), self.stream_id())?;
1035
1036 if let Self::Subscribe { max_in_flight, .. } = self {
1037 if *max_in_flight == 0 {
1038 return Err(ProtocolError::codec(
1039 "max_in_flight must be greater than zero",
1040 ));
1041 }
1042 }
1043
1044 Ok(())
1045 }
1046}
1047
1048/// Validate stream placement for a known frame type.
1049///
1050/// # Errors
1051///
1052/// Returns [`ProtocolError::InvalidStream`] when `stream_id` is invalid for
1053/// `frame_type`.
1054pub fn validate_stream(frame_type: FrameType, stream_id: u32) -> Result<(), ProtocolError> {
1055 if matches!(frame_type, FrameType::Unknown(_)) {
1056 return Ok(());
1057 }
1058
1059 let valid = if frame_type.is_control() {
1060 stream_id == 0
1061 } else {
1062 stream_id >= 1
1063 };
1064
1065 if valid {
1066 Ok(())
1067 } else {
1068 Err(ProtocolError::invalid_stream(frame_type, stream_id))
1069 }
1070}
1071
1072mod debug;
1073
1074#[cfg(test)]
1075mod tests;