Skip to main content

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