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}