Skip to main content

axon/session_runtime/
wire.rs

1//! Wire format for the §Fase 41.d session-typed WebSocket dialogue.
2//!
3//! Every frame is one **text** WebSocket message carrying a JSON envelope
4//! whose `kind` discriminator names one of the five operational actions of
5//! the §41.a algebra: `send`, `recv` (reserved — see note), `select`,
6//! `branch`, `end`, plus an out-of-band `error` carrier for protocol-error
7//! close-frame reasons. The format is **closed** — anything else is a
8//! `MalformedFrame` (no silent toleration; the type checker has already
9//! ruled out the schema, so any new shape on the wire is by definition
10//! out-of-spec).
11//!
12//! ### Why a single direction word
13//!
14//! `send` and `recv` are *peer-relative*: the **sender** always tags its
15//! frame `kind: "send"`. From the **receiver's** perspective the frame is
16//! a `recv` step in *its* type, but the wire tag is symmetric — both peers
17//! agree on the **sender's** view. This matches RFC 6455 message direction
18//! and removes ambiguity when both peers share log infrastructure.
19//!
20//! `select` is *senderly* (the chooser); `branch` is *receiverly* (the
21//! offerer). At the wire level the chooser emits `kind: "select"`; the
22//! offerer never emits a label-bearing frame (its arms are silent).
23//!
24//! All frames are validated by the receiver against the session-type
25//! cursor; the wire format is intentionally minimal — it carries just
26//! enough to advance the state machine, not the full schema (which lives
27//! statically in `axon-frontend::session`).
28
29use serde::{Deserialize, Serialize};
30use serde_json::Value as JsonValue;
31
32use super::error::ProtocolError;
33
34/// Stable protocol-version tag — bumped if and only if the envelope shape
35/// changes incompatibly. Senders MUST emit, receivers MUST validate.
36pub const AXON_WIRE_VERSION: u8 = 1;
37
38/// One frame on the wire — exactly one operational step.
39///
40/// `#[serde(tag = "kind", rename_all = "lowercase")]` is intentional: the
41/// `kind` discriminator is the closed catalog `send | select | end |
42/// error`. Anything else is malformed.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44#[serde(tag = "kind", rename_all = "lowercase")]
45pub enum Frame {
46    /// `!A.S` advance: the sender produces a value of type `A` (named by
47    /// the canonical payload string; the JSON `data` carries the value).
48    /// On the receiver side this triggers a `try_recv(A)` on its cursor.
49    Send {
50        /// Canonical payload type name — must equal the cursor's
51        /// `Payload` for the step. Validated at the receiver.
52        #[serde(rename = "payload_type")]
53        payload_type: String,
54        /// The value carried by the step. Opaque at this layer (no
55        /// schema enforcement beyond presence + JSON well-formedness);
56        /// payload-shape validation is a future fase (e.g. typed-data
57        /// integration with `axonstore`).
58        data: JsonValue,
59    },
60    /// `⊕{ℓᵢ:Sᵢ}` advance: the chooser names a labelled branch. On the
61    /// receiver side this triggers a `try_offer(ℓ)` on its cursor (whose
62    /// type at this point is `&{ℓᵢ:Sᵢ}`).
63    Select {
64        /// The chosen label — must be a key in the cursor's arms.
65        label: String,
66    },
67    /// `end` — the dialogue terminates. Both halves transition to `End`.
68    /// No further frames are accepted from either side; the carrier
69    /// (WebSocket) closes cleanly with code `1000 normal closure`.
70    End,
71    /// Out-of-band protocol error — emitted by either side just before
72    /// closing the carrier with code `1002 protocol error`. Carries a
73    /// short machine-readable code + a human detail message so the peer
74    /// can diagnose the divergence without re-running its analysis.
75    Error {
76        /// Short stable identifier — see [`ProtocolError::code`].
77        code: String,
78        /// Free-form human-readable detail.
79        detail: String,
80    },
81}
82
83impl Frame {
84    /// The fixed runtime tag of this frame's kind, used in
85    /// [`ProtocolError::UnexpectedFrame`] for cursor / frame mismatch
86    /// diagnostics. Stable in both the wire (tag value) and the runtime
87    /// (matching the closed catalog).
88    pub fn kind_tag(&self) -> &'static str {
89        match self {
90            Frame::Send { .. } => "send",
91            Frame::Select { .. } => "select",
92            Frame::End => "end",
93            Frame::Error { .. } => "error",
94        }
95    }
96
97    /// Serialise to a JSON string, prefixed with the wire-version tag in
98    /// the outer envelope: every frame on the wire is one line of
99    /// `{"v":1,"kind":"…",…}` — the version is the **first** key so a
100    /// linewise log scan can reject pre-handshake junk without parsing.
101    ///
102    /// We splice the version into the head of the serialised inner object
103    /// rather than building a `serde_json::Map`: the default `Map` sorts
104    /// keys alphabetically (BTreeMap-backed without `preserve_order`),
105    /// which would land `"kind"` before `"v"`. The splice is total — the
106    /// inner serialisation is always a JSON object for this enum (every
107    /// variant has a `kind` tag), so the leading `{` is guaranteed.
108    pub fn to_wire(&self) -> String {
109        let inner = serde_json::to_string(self).expect("Frame ⇒ JSON is total");
110        debug_assert!(inner.starts_with('{') && inner.ends_with('}'));
111        if inner == "{}" {
112            // Defensive — no Frame variant produces this, but be total.
113            return format!("{{\"v\":{AXON_WIRE_VERSION}}}");
114        }
115        // `{"kind":"…",…}` → `{"v":1,"kind":"…",…}`
116        format!("{{\"v\":{AXON_WIRE_VERSION},{}", &inner[1..])
117    }
118
119    /// Parse a wire string into a [`Frame`]. Validates the version tag and
120    /// the closed `kind` catalog; returns [`ProtocolError::MalformedFrame`]
121    /// on any divergence (including pre-1.0 shapes, unknown `kind`, missing
122    /// required fields).
123    pub fn from_wire(s: &str) -> Result<Frame, ProtocolError> {
124        let raw: JsonValue = serde_json::from_str(s)
125            .map_err(|e| ProtocolError::MalformedFrame(format!("invalid JSON: {e}")))?;
126        let obj = raw
127            .as_object()
128            .ok_or_else(|| ProtocolError::MalformedFrame("envelope is not a JSON object".into()))?;
129        let v = obj
130            .get("v")
131            .and_then(JsonValue::as_u64)
132            .ok_or_else(|| ProtocolError::MalformedFrame("missing wire-version `v`".into()))?;
133        if v != AXON_WIRE_VERSION as u64 {
134            return Err(ProtocolError::MalformedFrame(format!(
135                "unsupported wire version {v} (this runtime speaks v{AXON_WIRE_VERSION})"
136            )));
137        }
138        // `serde(tag = "kind")` does the rest — we strip the version so the
139        // deserialiser sees the inner shape exactly.
140        let mut inner = obj.clone();
141        inner.remove("v");
142        serde_json::from_value::<Frame>(JsonValue::Object(inner)).map_err(|e| {
143            ProtocolError::MalformedFrame(format!(
144                "unknown frame kind or missing field: {e}"
145            ))
146        })
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use serde_json::json;
154
155    #[test]
156    fn send_frame_round_trips_through_wire() {
157        let f = Frame::Send {
158            payload_type: "Msg".into(),
159            data: json!({"text": "hello"}),
160        };
161        let wire = f.to_wire();
162        // Version is the first key (deterministic order).
163        assert!(wire.starts_with("{\"v\":1,"));
164        let parsed = Frame::from_wire(&wire).expect("parse");
165        assert_eq!(parsed, f);
166    }
167
168    #[test]
169    fn select_frame_round_trips() {
170        let f = Frame::Select { label: "ask".into() };
171        assert_eq!(Frame::from_wire(&f.to_wire()).unwrap(), f);
172    }
173
174    #[test]
175    fn end_frame_is_a_bare_kind_marker() {
176        let wire = Frame::End.to_wire();
177        assert_eq!(wire, "{\"v\":1,\"kind\":\"end\"}");
178        assert_eq!(Frame::from_wire(&wire).unwrap(), Frame::End);
179    }
180
181    #[test]
182    fn error_frame_round_trips_with_code_and_detail() {
183        let f = Frame::Error {
184            code: "credit_exhausted".into(),
185            detail: "n=0 on send Msg".into(),
186        };
187        assert_eq!(Frame::from_wire(&f.to_wire()).unwrap(), f);
188    }
189
190    #[test]
191    fn missing_version_is_malformed() {
192        let s = r#"{"kind":"end"}"#;
193        match Frame::from_wire(s) {
194            Err(ProtocolError::MalformedFrame(m)) => assert!(m.contains("wire-version")),
195            other => panic!("expected MalformedFrame, got {other:?}"),
196        }
197    }
198
199    #[test]
200    fn unsupported_version_is_malformed() {
201        let s = r#"{"v":99,"kind":"end"}"#;
202        match Frame::from_wire(s) {
203            Err(ProtocolError::MalformedFrame(m)) => assert!(m.contains("unsupported wire version")),
204            other => panic!("expected version-mismatch malformed, got {other:?}"),
205        }
206    }
207
208    #[test]
209    fn unknown_kind_is_malformed() {
210        let s = r#"{"v":1,"kind":"yeet","payload_type":"X"}"#;
211        assert!(matches!(Frame::from_wire(s), Err(ProtocolError::MalformedFrame(_))));
212    }
213
214    #[test]
215    fn invalid_json_is_malformed() {
216        let s = r#"{"v":1,"kind":"send""#; // truncated
217        assert!(matches!(Frame::from_wire(s), Err(ProtocolError::MalformedFrame(_))));
218    }
219
220    #[test]
221    fn non_object_envelope_is_malformed() {
222        let s = "[1,2,3]";
223        assert!(matches!(Frame::from_wire(s), Err(ProtocolError::MalformedFrame(_))));
224    }
225
226    #[test]
227    fn kind_tag_matches_wire_tag() {
228        let cases = [
229            (Frame::Send { payload_type: "T".into(), data: json!(null) }, "send"),
230            (Frame::Select { label: "a".into() }, "select"),
231            (Frame::End, "end"),
232            (Frame::Error { code: "c".into(), detail: "d".into() }, "error"),
233        ];
234        for (f, tag) in cases {
235            assert_eq!(f.kind_tag(), tag);
236        }
237    }
238}