Skip to main content

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    /// Further subjects this worker declares it serves, each decided as `serve`
431    /// at registration and checked against a site's declared subjects in its trial.
432    /// These declarations confer no prefix grant over `task_queue`.
433    pub namespaces: Vec<String>,
434    /// The exact subject routed to this worker, decided as `serve` before the
435    /// namespaces at registration and bound unchanged for each routed reply.
436    pub task_queue: String,
437    /// Optional node locality; `None` when the worker declares no node affinity.
438    pub node: Option<String>,
439    /// Activity types this worker can execute.
440    pub activity_types: Vec<String>,
441    /// Stable worker identity.
442    pub identity: String,
443    /// Typed action descriptors; empty identifies a pre-contract worker.
444    pub activities: Vec<WorkerActivityDescriptor>,
445}
446
447/// Outcome of a worker registration, carried by [`Frame::WorkerRegisterAck`].
448#[derive(Clone, Debug, PartialEq, Eq)]
449pub enum WorkerRegisterOutcome {
450    /// The server accepted the registration.
451    Accepted,
452    /// The server rejected the registration, with a human-readable reason.
453    Rejected {
454        /// Human-readable rejection reason surfaced to the worker.
455        reason: String,
456    },
457}
458
459/// The end of a link the sender of a [`Frame::LinkOpen`] takes (ACCESS-002
460/// R1): the exporting machine sends messages across, the importing one
461/// receives them. The server takes the other end.
462#[derive(Clone, Copy, Debug, PartialEq, Eq)]
463pub enum LinkEnd {
464    /// The sender exports: its messages cross to the server.
465    Export,
466    /// The sender imports: the server's messages cross to it.
467    Import,
468}
469
470/// The link a machine offers to open, carried by [`Frame::LinkOpen`].
471///
472/// `machine` is the sender's Ed25519 public key as Lys recorded it; the
473/// server refuses an offer whose key is not the one recorded for the
474/// connection's verified holder. `handshake` is the first Noise `KK` message,
475/// keyed from that identity's X25519 derivation.
476#[derive(Clone, PartialEq, Eq)]
477pub struct LinkOffer {
478    /// The link's id under the network.
479    pub link_id: String,
480    /// The end the sender takes.
481    pub end: LinkEnd,
482    /// The sender's Ed25519 public key as Lys recorded it.
483    pub machine: [u8; LINK_MACHINE_LEN],
484    /// The sender's first handshake message.
485    pub handshake: Vec<u8>,
486}
487
488impl core::fmt::Debug for LinkOffer {
489    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
490        formatter
491            .debug_struct("LinkOffer")
492            .field("link_id", &self.link_id)
493            .field("end", &self.end)
494            .field("machine", &"32 bytes")
495            .field("handshake", &self.handshake.len())
496            .finish()
497    }
498}
499
500/// Outcome of a link open, carried by [`Frame::LinkOpenAck`].
501#[derive(Clone, Debug, PartialEq, Eq)]
502pub enum LinkOpenOutcome {
503    /// Lys allowed the open outright and the handshake completed; the
504    /// responder's handshake message follows, after which the stream is a
505    /// keyed link.
506    Opened {
507        /// The server's second Noise `KK` message.
508        handshake: Vec<u8>,
509    },
510    /// Lys holds the open under a draft; nothing is keyed until it is
511    /// approved and offered again.
512    Held {
513        /// The grant requiring approval.
514        grant: String,
515        /// The draft Lys opened.
516        draft: String,
517    },
518    /// The open was refused, by name.
519    Refused {
520        /// The refusal: the four-field access refusal as JSON, or the
521        /// handshake's or binding's own words.
522        reason: String,
523    },
524}
525
526/// A typed protocol frame body plus the header metadata required to encode it.
527#[derive(Clone, PartialEq, Eq)]
528pub enum Frame {
529    /// Connection request carrying a supported version range and opaque auth token.
530    Connect {
531        flags: u8,
532        min_version: ProtocolVersion,
533        max_version: ProtocolVersion,
534        auth_token: Vec<u8>,
535    },
536    /// Connection success carrying the negotiated protocol version and server capabilities.
537    ConnectAck {
538        flags: u8,
539        selected_version: ProtocolVersion,
540        capabilities: u32,
541    },
542    /// Connection failure carrying a numeric reason and optional message.
543    ConnectError {
544        flags: u8,
545        reason_code: u16,
546        message: Option<String>,
547    },
548    /// Connection close notification with no payload.
549    Disconnect { flags: u8 },
550    /// Server close carrying a stable reason (ACCESS-005 R5): the body is a
551    /// version byte ([`DISCONNECT_REASON_BODY_VERSION`]), the reason's code
552    /// and its name. Written only on a connection at
553    /// [`REVOCATION_CLOSE_VERSION`] or later; the payload-free
554    /// [`Frame::Disconnect`] is unchanged.
555    DisconnectReason { flags: u8, reason: DisconnectReason },
556    /// Channel subscription request carrying a channel and accepted schema hashes.
557    Subscribe {
558        flags: u8,
559        stream_id: u32,
560        channel: String,
561        accepted_schemas: Vec<SchemaId>,
562        max_in_flight: u32,
563    },
564    /// Channel subscription success carrying a subscription id and selected schema.
565    SubscribeAck {
566        flags: u8,
567        stream_id: u32,
568        subscription_id: u64,
569        selected_schema: SchemaId,
570    },
571    /// Channel subscription failure carrying a numeric reason and optional message.
572    SubscribeError {
573        flags: u8,
574        stream_id: u32,
575        reason_code: u16,
576        message: Option<String>,
577    },
578    /// Channel unsubscription request carrying the subscription id.
579    Unsubscribe {
580        flags: u8,
581        stream_id: u32,
582        subscription_id: u64,
583    },
584    /// Publish request carrying a channel and typed message envelope.
585    ///
586    /// `idempotency_key` is `Some` only when the [`PUBLISH_IDEMPOTENCY_KEY_FLAG`]
587    /// flag bit is set; it is the dedup-on-delivery key the server feeds to its
588    /// dedup cache. When `None` (and the flag clear) the frame is byte-identical
589    /// on the wire to a pre-13-L1 publish.
590    Publish {
591        flags: u8,
592        stream_id: u32,
593        channel: String,
594        envelope: MessageEnvelope,
595        idempotency_key: Option<String>,
596    },
597    /// Publish success carrying the accepted message id.
598    ///
599    /// `durable_position` is `Some` only when the ack answers for a stored row
600    /// and the connection negotiated [`PUBLISH_ACK_POSITION_VERSION`]; the codec
601    /// then sets [`PUBLISH_DURABLE_POSITION_FLAG`] and writes the position after
602    /// `message_id`. When `None` the frame is byte-identical to the older ack.
603    PublishAck {
604        flags: u8,
605        stream_id: u32,
606        message_id: u64,
607        durable_position: Option<u64>,
608    },
609    /// Publish failure carrying a numeric reason and optional message.
610    PublishError {
611        flags: u8,
612        stream_id: u32,
613        reason_code: u16,
614        message: Option<String>,
615    },
616    /// Conversation open carrying a conversation id and subject.
617    ConversationOpen {
618        flags: u8,
619        stream_id: u32,
620        conversation_id: u64,
621        subject: String,
622    },
623    /// Conversation message carrying a conversation id and typed message envelope.
624    ConversationMessage {
625        flags: u8,
626        stream_id: u32,
627        conversation_id: u64,
628        envelope: MessageEnvelope,
629    },
630    /// Conversation close carrying a conversation id and optional reason.
631    ConversationClose {
632        flags: u8,
633        stream_id: u32,
634        conversation_id: u64,
635        reason_code: Option<u16>,
636        message: Option<String>,
637    },
638    /// Conversation failure carrying a conversation id, numeric reason, and optional message.
639    ConversationError {
640        flags: u8,
641        stream_id: u32,
642        conversation_id: u64,
643        reason_code: u16,
644        message: Option<String>,
645    },
646    /// Backpressure acceptance for a delivered message.
647    Accept {
648        flags: u8,
649        stream_id: u32,
650        referenced_message_id: MessageId,
651    },
652    /// Backpressure deferral for a buffered message.
653    Defer {
654        flags: u8,
655        stream_id: u32,
656        referenced_message_id: MessageId,
657        reason: Option<String>,
658    },
659    /// Backpressure rejection for a shed message.
660    Reject {
661        flags: u8,
662        stream_id: u32,
663        referenced_message_id: MessageId,
664        reason: Option<String>,
665    },
666    /// Connection keepalive ping.
667    Ping { flags: u8 },
668    /// Connection keepalive pong.
669    Pong { flags: u8 },
670    /// Server-initiated push carrying a correlation id and an opaque payload.
671    ///
672    /// A server writes this frame to a connected client over the client's existing
673    /// connection (server-to-client, the inverse of every other request frame). The
674    /// `correlation_id` is the key the server uses to match the client's later
675    /// [`Frame::PushReply`] back to this push; the `payload` is opaque application
676    /// bytes the server hands the client. This is an application-stream frame, so
677    /// `stream_id` is non-zero like a publish or conversation message.
678    Push {
679        flags: u8,
680        stream_id: u32,
681        correlation_id: u64,
682        payload: Vec<u8>,
683    },
684    /// Client-initiated correlated reply to a [`Frame::Push`].
685    ///
686    /// After handling a pushed frame the client writes this back on the same
687    /// connection, echoing the push's `correlation_id` so the server can match the
688    /// reply to the originating push. The `payload` is the client's opaque answer.
689    /// With [`super::PUSH_REPLY_REFUSED_FLAG`] it is a canonical
690    /// [`super::WorkerRefusal`]; all remaining flag bits are reserved.
691    PushReply {
692        flags: u8,
693        stream_id: u32,
694        correlation_id: u64,
695        payload: Vec<u8>,
696    },
697    /// Worker self-registration over an established connection.
698    ///
699    /// A worker sends this control frame (stream 0) after the connection
700    /// handshake to announce its identity and routing dimensions. The server
701    /// associates the registration with the connection's process id and surfaces
702    /// it to the application via the connection-notifier hook, then answers with a
703    /// [`Frame::WorkerRegisterAck`]. `node` is optional locality and is encoded
704    /// with a presence byte, never flattened to an empty string.
705    WorkerRegister {
706        flags: u8,
707        registration: WorkerRegistration,
708    },
709    /// Server acknowledgement of a [`Frame::WorkerRegister`].
710    ///
711    /// Carries the registration outcome: [`WorkerRegisterOutcome::Accepted`] when
712    /// the server (and any configured notifier) accepted the worker, or
713    /// [`WorkerRegisterOutcome::Rejected`] carrying a human-readable reason when it
714    /// did not. The acknowledgement is synchronous so a worker never believes it is
715    /// registered when the application rejected it.
716    WorkerRegisterAck {
717        flags: u8,
718        outcome: WorkerRegisterOutcome,
719    },
720    /// Server-to-client delivery of a message on a subscription the client opened.
721    ///
722    /// The server writes this frame on the subscription's own `stream_id` (the
723    /// non-zero application stream negotiated by the client's `Subscribe`). It is
724    /// the only server-originated delivery frame, and it is only ever sent on a
725    /// subscription the client itself opened, so the forward-compatibility rules
726    /// for unknown frames never surprise an old client with one. `delivery_seq` is
727    /// a per-subscription monotonic counter starting at 1 — the anchor the future
728    /// ack/resume (A1 v2 credit) protocol builds on. The `envelope` reuses the same
729    /// [`MessageEnvelope`] serialization as publish and conversation frames.
730    ///
731    /// `message` is `Some` only when the server knows the message's id and boot
732    /// and the connection negotiated [`DELIVER_MESSAGE_ID_VERSION`]; the codec
733    /// then sets [`DELIVER_MESSAGE_ID_FLAG`] and writes the pair after the
734    /// envelope. When `None` the frame is byte-identical to the older Deliver.
735    Deliver {
736        flags: u8,
737        stream_id: u32,
738        delivery_seq: u64,
739        envelope: MessageEnvelope,
740        message: Option<DeliveredId>,
741    },
742    /// A machine opening one end of a link on this application stream
743    /// (ACCESS-002 R1). The server decides the open with Lys, live, for the
744    /// grant the offered end names on the link, proves the offered machine
745    /// key is the verified holder's recorded key, and answers the handshake.
746    LinkOpen {
747        flags: u8,
748        stream_id: u32,
749        offer: LinkOffer,
750    },
751    /// The server's answer to a [`Frame::LinkOpen`] on the same stream.
752    LinkOpenAck {
753        flags: u8,
754        stream_id: u32,
755        outcome: LinkOpenOutcome,
756    },
757    /// One message crossing an open link on its stream: the subject it is
758    /// published on, and the message envelope sealed by the link's transport.
759    /// The receiving end decides the crossing from both machines' passes
760    /// before any effect.
761    LinkMessage {
762        flags: u8,
763        stream_id: u32,
764        subject: String,
765        sealed: Vec<u8>,
766    },
767    /// Forward-compatible frame preserved after length-delimited skipping.
768    Unknown {
769        type_id: u8,
770        flags: u8,
771        stream_id: u32,
772        payload: Vec<u8>,
773    },
774}
775
776impl Frame {
777    /// Construct a ping frame, enforcing the stream-0 control-frame invariant.
778    ///
779    /// # Errors
780    ///
781    /// Returns [`ProtocolError::InvalidStream`] when `stream_id` is not zero.
782    pub fn new_ping(stream_id: u32) -> Result<Self, ProtocolError> {
783        validate_stream(FrameType::Ping, stream_id)?;
784        Ok(Self::Ping { flags: 0 })
785    }
786
787    /// Construct a publish frame, enforcing the non-zero application-stream invariant.
788    ///
789    /// # Errors
790    ///
791    /// Returns [`ProtocolError::InvalidStream`] when `stream_id` is zero.
792    pub fn new_publish(
793        stream_id: u32,
794        channel: impl Into<String>,
795        envelope: MessageEnvelope,
796    ) -> Result<Self, ProtocolError> {
797        validate_stream(FrameType::Publish, stream_id)?;
798        Ok(Self::Publish {
799            flags: 0,
800            stream_id,
801            channel: channel.into(),
802            envelope,
803            idempotency_key: None,
804        })
805    }
806
807    /// Construct a publish frame carrying an idempotency key for dedup-on-delivery.
808    ///
809    /// The returned frame has [`PUBLISH_IDEMPOTENCY_KEY_FLAG`] set and serializes
810    /// the trailing key field, so the server consults its dedup cache for this key.
811    ///
812    /// # Errors
813    ///
814    /// Returns [`ProtocolError::InvalidStream`] when `stream_id` is zero.
815    pub fn new_publish_with_idempotency_key(
816        stream_id: u32,
817        channel: impl Into<String>,
818        envelope: MessageEnvelope,
819        idempotency_key: impl Into<String>,
820    ) -> Result<Self, ProtocolError> {
821        validate_stream(FrameType::Publish, stream_id)?;
822        Ok(Self::Publish {
823            flags: PUBLISH_IDEMPOTENCY_KEY_FLAG,
824            stream_id,
825            channel: channel.into(),
826            envelope,
827            idempotency_key: Some(idempotency_key.into()),
828        })
829    }
830
831    /// Construct a server-to-client push frame, enforcing the non-zero
832    /// application-stream invariant.
833    ///
834    /// # Errors
835    ///
836    /// Returns [`ProtocolError::InvalidStream`] when `stream_id` is zero.
837    pub fn new_push(
838        stream_id: u32,
839        correlation_id: u64,
840        payload: Vec<u8>,
841    ) -> Result<Self, ProtocolError> {
842        validate_stream(FrameType::Push, stream_id)?;
843        Ok(Self::Push {
844            flags: 0,
845            stream_id,
846            correlation_id,
847            payload,
848        })
849    }
850
851    /// Construct a server-to-client delivery frame on a subscription's stream,
852    /// enforcing the non-zero application-stream invariant.
853    ///
854    /// # Errors
855    ///
856    /// Returns [`ProtocolError::InvalidStream`] when `stream_id` is zero.
857    pub fn new_deliver(
858        stream_id: u32,
859        delivery_seq: u64,
860        envelope: MessageEnvelope,
861    ) -> Result<Self, ProtocolError> {
862        validate_stream(FrameType::Deliver, stream_id)?;
863        Ok(Self::Deliver {
864            flags: 0,
865            stream_id,
866            delivery_seq,
867            envelope,
868            message: None,
869        })
870    }
871
872    /// Construct a client-to-server push reply frame, echoing the correlation id of
873    /// the originating push, and enforcing the non-zero application-stream invariant.
874    ///
875    /// # Errors
876    ///
877    /// Returns [`ProtocolError::InvalidStream`] when `stream_id` is zero.
878    pub fn new_push_reply(
879        stream_id: u32,
880        correlation_id: u64,
881        payload: Vec<u8>,
882    ) -> Result<Self, ProtocolError> {
883        validate_stream(FrameType::PushReply, stream_id)?;
884        Ok(Self::PushReply {
885            flags: 0,
886            stream_id,
887            correlation_id,
888            payload,
889        })
890    }
891
892    /// Constructs a named refusal on the existing correlated reply frame.
893    ///
894    /// # Errors
895    /// An invalid application stream or canonical-record encoding failure.
896    pub fn new_push_refusal(
897        stream_id: u32,
898        correlation_id: u64,
899        refusal: &super::WorkerRefusal,
900    ) -> Result<Self, ProtocolError> {
901        validate_stream(FrameType::PushReply, stream_id)?;
902        Ok(Self::PushReply {
903            flags: super::PUSH_REPLY_REFUSED_FLAG,
904            stream_id,
905            correlation_id,
906            payload: refusal.canonical_bytes()?,
907        })
908    }
909
910    /// Return the frame type represented by this frame body.
911    #[must_use]
912    pub const fn frame_type(&self) -> FrameType {
913        match self {
914            Self::Connect { .. } => FrameType::Connect,
915            Self::ConnectAck { .. } => FrameType::ConnectAck,
916            Self::ConnectError { .. } => FrameType::ConnectError,
917            Self::Disconnect { .. } => FrameType::Disconnect,
918            Self::DisconnectReason { .. } => FrameType::DisconnectReason,
919            Self::Subscribe { .. } => FrameType::Subscribe,
920            Self::SubscribeAck { .. } => FrameType::SubscribeAck,
921            Self::SubscribeError { .. } => FrameType::SubscribeError,
922            Self::Unsubscribe { .. } => FrameType::Unsubscribe,
923            Self::Publish { .. } => FrameType::Publish,
924            Self::PublishAck { .. } => FrameType::PublishAck,
925            Self::PublishError { .. } => FrameType::PublishError,
926            Self::ConversationOpen { .. } => FrameType::ConversationOpen,
927            Self::ConversationMessage { .. } => FrameType::ConversationMessage,
928            Self::ConversationClose { .. } => FrameType::ConversationClose,
929            Self::ConversationError { .. } => FrameType::ConversationError,
930            Self::Accept { .. } => FrameType::Accept,
931            Self::Defer { .. } => FrameType::Defer,
932            Self::Reject { .. } => FrameType::Reject,
933            Self::Ping { .. } => FrameType::Ping,
934            Self::Pong { .. } => FrameType::Pong,
935            Self::Push { .. } => FrameType::Push,
936            Self::PushReply { .. } => FrameType::PushReply,
937            Self::WorkerRegister { .. } => FrameType::WorkerRegister,
938            Self::WorkerRegisterAck { .. } => FrameType::WorkerRegisterAck,
939            Self::Deliver { .. } => FrameType::Deliver,
940            Self::LinkOpen { .. } => FrameType::LinkOpen,
941            Self::LinkOpenAck { .. } => FrameType::LinkOpenAck,
942            Self::LinkMessage { .. } => FrameType::LinkMessage,
943            Self::Unknown { type_id, .. } => FrameType::Unknown(*type_id),
944        }
945    }
946
947    /// Return the frame flags stored in the fixed header.
948    #[must_use]
949    pub const fn flags(&self) -> u8 {
950        match self {
951            Self::Connect { flags, .. }
952            | Self::ConnectAck { flags, .. }
953            | Self::ConnectError { flags, .. }
954            | Self::Disconnect { flags, .. }
955            | Self::DisconnectReason { flags, .. }
956            | Self::Subscribe { flags, .. }
957            | Self::SubscribeAck { flags, .. }
958            | Self::SubscribeError { flags, .. }
959            | Self::Unsubscribe { flags, .. }
960            | Self::Publish { flags, .. }
961            | Self::PublishAck { flags, .. }
962            | Self::PublishError { flags, .. }
963            | Self::ConversationOpen { flags, .. }
964            | Self::ConversationMessage { flags, .. }
965            | Self::ConversationClose { flags, .. }
966            | Self::ConversationError { flags, .. }
967            | Self::Accept { flags, .. }
968            | Self::Defer { flags, .. }
969            | Self::Reject { flags, .. }
970            | Self::Ping { flags }
971            | Self::Pong { flags }
972            | Self::Push { flags, .. }
973            | Self::PushReply { flags, .. }
974            | Self::WorkerRegister { flags, .. }
975            | Self::WorkerRegisterAck { flags, .. }
976            | Self::Deliver { flags, .. }
977            | Self::LinkOpen { flags, .. }
978            | Self::LinkOpenAck { flags, .. }
979            | Self::LinkMessage { flags, .. }
980            | Self::Unknown { flags, .. } => *flags,
981        }
982    }
983
984    /// Return the flags byte the codec writes into the fixed header.
985    ///
986    /// This is [`Frame::flags`] except on a [`Frame::PublishAck`], whose
987    /// [`PUBLISH_DURABLE_POSITION_FLAG`] bit is set exactly when the ack carries
988    /// a durable position, and a [`Frame::Deliver`], whose
989    /// [`DELIVER_MESSAGE_ID_FLAG`] bit is set exactly when it carries a
990    /// [`DeliveredId`], so the bit and the trailing field never disagree.
991    #[must_use]
992    pub const fn wire_flags(&self) -> u8 {
993        match self {
994            Self::PublishAck {
995                flags,
996                durable_position: Some(_),
997                ..
998            } => *flags | PUBLISH_DURABLE_POSITION_FLAG,
999            Self::PublishAck {
1000                flags,
1001                durable_position: None,
1002                ..
1003            } => *flags & !PUBLISH_DURABLE_POSITION_FLAG,
1004            Self::Deliver {
1005                flags,
1006                message: Some(_),
1007                ..
1008            } => *flags | DELIVER_MESSAGE_ID_FLAG,
1009            Self::Deliver {
1010                flags,
1011                message: None,
1012                ..
1013            } => *flags & !DELIVER_MESSAGE_ID_FLAG,
1014            _ => self.flags(),
1015        }
1016    }
1017
1018    /// Return the stream id stored in the fixed header.
1019    #[must_use]
1020    pub const fn stream_id(&self) -> u32 {
1021        match self {
1022            Self::Connect { .. }
1023            | Self::ConnectAck { .. }
1024            | Self::ConnectError { .. }
1025            | Self::Disconnect { .. }
1026            | Self::DisconnectReason { .. }
1027            | Self::Ping { .. }
1028            | Self::Pong { .. }
1029            | Self::WorkerRegister { .. }
1030            | Self::WorkerRegisterAck { .. } => 0,
1031            Self::Subscribe { stream_id, .. }
1032            | Self::SubscribeAck { stream_id, .. }
1033            | Self::SubscribeError { stream_id, .. }
1034            | Self::Unsubscribe { stream_id, .. }
1035            | Self::Publish { stream_id, .. }
1036            | Self::PublishAck { stream_id, .. }
1037            | Self::PublishError { stream_id, .. }
1038            | Self::ConversationOpen { stream_id, .. }
1039            | Self::ConversationMessage { stream_id, .. }
1040            | Self::ConversationClose { stream_id, .. }
1041            | Self::ConversationError { stream_id, .. }
1042            | Self::Accept { stream_id, .. }
1043            | Self::Defer { stream_id, .. }
1044            | Self::Reject { stream_id, .. }
1045            | Self::Push { stream_id, .. }
1046            | Self::PushReply { stream_id, .. }
1047            | Self::Deliver { stream_id, .. }
1048            | Self::LinkOpen { stream_id, .. }
1049            | Self::LinkOpenAck { stream_id, .. }
1050            | Self::LinkMessage { stream_id, .. }
1051            | Self::Unknown { stream_id, .. } => *stream_id,
1052        }
1053    }
1054
1055    /// Validate the stream invariant for this frame.
1056    pub(crate) fn validate(&self) -> Result<(), ProtocolError> {
1057        validate_stream(self.frame_type(), self.stream_id())?;
1058
1059        if let Self::PushReply { flags, .. } = self {
1060            if flags & !super::PUSH_REPLY_REFUSED_FLAG != 0 {
1061                return Err(ProtocolError::codec("push_reply_reserved_flags"));
1062            }
1063        }
1064
1065        if let Self::Subscribe { max_in_flight, .. } = self {
1066            if *max_in_flight == 0 {
1067                return Err(ProtocolError::codec(
1068                    "max_in_flight must be greater than zero",
1069                ));
1070            }
1071        }
1072
1073        Ok(())
1074    }
1075}
1076
1077/// Validate stream placement for a known frame type.
1078///
1079/// # Errors
1080///
1081/// Returns [`ProtocolError::InvalidStream`] when `stream_id` is invalid for
1082/// `frame_type`.
1083pub fn validate_stream(frame_type: FrameType, stream_id: u32) -> Result<(), ProtocolError> {
1084    if matches!(frame_type, FrameType::Unknown(_)) {
1085        return Ok(());
1086    }
1087
1088    let valid = if frame_type.is_control() {
1089        stream_id == 0
1090    } else {
1091        stream_id >= 1
1092    };
1093
1094    if valid {
1095        Ok(())
1096    } else {
1097        Err(ProtocolError::invalid_stream(frame_type, stream_id))
1098    }
1099}
1100
1101#[cfg(test)]
1102mod tests;
1103
1104#[cfg(test)]
1105mod channel_fixture_tests;