Skip to main content

protocol_droid/domain/ir/
envelope.rs

1//! Wire JSON-RPC envelope to [`IrMessage`] and back, losslessly.
2//!
3//! The IR has the message types ([`IrRequest`], [`IrResult`], and the rest)
4//! but nothing built them. This module is that crossing, and it exists because
5//! a bridge cannot work without it: the bridge's whole job is to read a
6//! message in one revision's encoding, hand it to the other side in another
7//! revision's encoding, and lose nothing the peer sent.
8//!
9//! ## Why this is not [`Codec`](super::Codec)
10//!
11//! [`Codec`] handles `ToolResult`, and it is implemented per revision because
12//! the revisions genuinely disagree about `CallToolResult`'s shape. The
13//! *envelope* is a different animal: `jsonrpc`, `id`, `method`, `params`,
14//! `result` and `error` are the same six names in every revision of JSON-RPC
15//! the specification has published. Per-revision envelope codecs would be six
16//! identical implementations, and the first one to drift would be the bug.
17//!
18//! ## What "lossless" means here
19//!
20//! Every member that is not one of those six goes into
21//! [`Extensions`], in order, and comes back out. A `2026-07-28` client sending
22//! a `_meta` key this build has never heard of, or a top-level field the
23//! message model does not name, crosses the bridge intact in both directions.
24//!
25//! That is what makes the round-trip claim checkable:
26//! `lower(lift(v))` re-emits every member of `v`. A test that only inspects
27//! the parsed [`IrMessage`] would pass even when the wire form were mangled, so
28//! the tests here assert on the re-emitted JSON.
29//!
30//! ## Number fidelity
31//!
32//! JSON numbers go through `serde_json::Number`, which keeps an integer from
33//! `1.0` distinct from one and does not reformat either. A bridge that turned
34//! every number into a float would silently corrupt any peer sending a
35//! 64-bit id.
36
37use serde_json::{Map, Value, json};
38
39use super::{
40    ExtensionValue, Extensions, IrError, IrErrorCode, IrMessage, IrNotification, IrParams,
41    IrRequest, IrResult, JsonObject, MethodName,
42};
43use crate::domain::RequestId;
44
45/// The envelope members this module models. Anything else is an extension.
46const KNOWN: [&str; 6] = ["jsonrpc", "id", "method", "params", "result", "error"];
47
48/// An envelope this build could not lift.
49#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
50pub enum EnvelopeError {
51    /// The object is not a JSON-RPC message of any recognised shape.
52    ///
53    /// That is: no `method` (so not a request or notification) and no
54    /// `result`/`error` (so not a reply). JSON-RPC 2.0 calls this
55    /// `Invalid Request`, and `-32600` is the right answer for it.
56    #[error("the object is not a JSON-RPC request, notification, result or error")]
57    NotAMessage,
58
59    /// A `method` that is not a string.
60    #[error("the method member is {found}, not a string")]
61    MethodNotAString {
62        /// The JSON type that was actually there.
63        found: &'static str,
64    },
65
66    /// An `id` that is neither a string nor a number.
67    ///
68    /// `null` is *not* an error: it is how a peer reports a request it could
69    /// not parse enough to recover an id for, and dropping it would turn a
70    /// recoverable client error into an unanswerable one.
71    #[error("the id member is {found}, which JSON-RPC does not allow")]
72    IdNotAllowed {
73        /// The JSON type that was actually there.
74        found: &'static str,
75    },
76
77    /// An `error` member whose `code` is not an integer.
78    #[error("the error code is {found}, not an integer")]
79    CodeNotAnInteger {
80        /// The JSON type that was actually there.
81        found: &'static str,
82    },
83}
84
85/// Lifts one wire envelope into the IR.
86///
87/// # Errors
88///
89/// [`EnvelopeError`] when the object is not a JSON-RPC message, or when a
90/// member this module models has the wrong JSON type.
91pub fn lift(object: &Map<String, Value>) -> Result<IrMessage, EnvelopeError> {
92    let mut extensions = Extensions::empty();
93    for (key, value) in object {
94        if !KNOWN.contains(&key.as_str()) {
95            extensions.insert(key.clone(), ExtensionValue::from(value.clone()));
96        }
97    }
98
99    let id = match object.get("id") {
100        None | Some(Value::Null) => None,
101        Some(Value::String(text)) => Some(RequestId::text(text)),
102        // A numeric id stays numeric. Routing it through `RequestId::text`
103        // would make a peer that matches replies by id type see a string, and
104        // the reply would arrive under an id nothing is waiting for.
105        Some(Value::Number(number)) => Some(
106            number
107                .as_i64()
108                .map_or_else(|| RequestId::text(number.to_string()), RequestId::number),
109        ),
110        Some(other) => {
111            return Err(EnvelopeError::IdNotAllowed {
112                found: type_name(other),
113            });
114        }
115    };
116
117    if let Some(method) = object.get("method") {
118        let name = match method {
119            Value::String(text) => text.clone(),
120            other => {
121                return Err(EnvelopeError::MethodNotAString {
122                    found: type_name(other),
123                });
124            }
125        };
126        let method = MethodName::new(&name).map_err(|_| EnvelopeError::NotAMessage)?;
127        let params = lift_params(object.get("params"));
128        // A notification is a request with no id. The distinction is
129        // structural in JSON-RPC and the IR keeps it.
130        return Ok(match id {
131            Some(id) => IrMessage::Request(IrRequest {
132                method,
133                id: Some(id),
134                params,
135                extensions,
136            }),
137            None => IrMessage::Notification(IrNotification {
138                method,
139                params,
140                extensions,
141            }),
142        });
143    }
144
145    if let Some(result) = object.get("result") {
146        return Ok(IrMessage::Result(IrResult {
147            id: id.ok_or(EnvelopeError::NotAMessage)?,
148            params: lift_params(Some(result)),
149            extensions,
150        }));
151    }
152
153    if let Some(error) = object.get("error") {
154        return Ok(IrMessage::Error(lift_error(error, id, extensions)?));
155    }
156
157    Err(EnvelopeError::NotAMessage)
158}
159
160/// Lifts a `params` member, or `result`, into the parameter bag.
161///
162/// `null` and a missing member are both "no parameters", because JSON-RPC 2.0
163/// permits both spellings and the difference is not observable downstream.
164fn lift_params(value: Option<&Value>) -> IrParams {
165    match value {
166        Some(Value::Object(object)) => {
167            let mut extensions = Extensions::empty();
168            extensions.absorb_json(object);
169            IrParams::from_extensions(extensions)
170        }
171        // An array or a scalar `params` is not something this build models,
172        // but it is not the caller's business at this layer either — the
173        // handler owns that decision. It arrives as an empty bag rather than
174        // being dropped, so a caller can see the shape was not understood.
175        _ => IrParams::empty(),
176    }
177}
178
179fn lift_error(
180    value: &Value,
181    id: Option<RequestId>,
182    extensions: Extensions,
183) -> Result<IrError, EnvelopeError> {
184    let Value::Object(object) = value else {
185        return Err(EnvelopeError::NotAMessage);
186    };
187    let mut extensions = extensions;
188    for (key, member) in object {
189        if !matches!(key.as_str(), "code" | "message" | "data") {
190            extensions.insert(key.clone(), ExtensionValue::from(member.clone()));
191        }
192    }
193
194    let code = match object.get("code") {
195        Some(Value::Number(number)) => IrErrorCode::from_code(
196            number
197                .as_i64()
198                .ok_or(EnvelopeError::CodeNotAnInteger { found: "a float" })?,
199        ),
200        Some(other) => {
201            return Err(EnvelopeError::CodeNotAnInteger {
202                found: type_name(other),
203            });
204        }
205        None => IrErrorCode::InternalError,
206    };
207
208    let message = object
209        .get("message")
210        .and_then(Value::as_str)
211        .unwrap_or_default()
212        .to_owned();
213    // `IrError::data` is a validated *text* wrapper rather than a parsed map,
214    // so the member is re-validated through it rather than trusted: by the
215    // time it is here it has already parsed once, so this cannot fail.
216    let data = object
217        .get("data")
218        .map(|value| JsonObject::new(value.to_string().as_str()))
219        .transpose()
220        .map_err(|_| EnvelopeError::NotAMessage)?;
221
222    Ok(IrError {
223        id,
224        code,
225        message,
226        data,
227        extensions,
228    })
229}
230
231/// Re-emits an IR message as a wire envelope.
232///
233/// The inverse of [`lift`] for everything [`lift`] models: each extension
234/// goes back where it came from, so a member the build does not understand
235/// still reaches the peer on the other side byte-for-byte as text.
236#[must_use]
237pub fn lower(message: &IrMessage) -> Map<String, Value> {
238    let mut object = Map::new();
239    object.insert("jsonrpc".to_owned(), json!("2.0"));
240
241    match message {
242        IrMessage::Request(request) => {
243            object.insert("method".to_owned(), json!(request.method.as_str()));
244            set_id(&mut object, request.id.as_ref());
245            insert_params(&mut object, &request.params);
246            absorb(&mut object, &request.extensions);
247        }
248        IrMessage::Notification(notification) => {
249            object.insert("method".to_owned(), json!(notification.method.as_str()));
250            insert_params(&mut object, &notification.params);
251            absorb(&mut object, &notification.extensions);
252        }
253        IrMessage::Result(result) => {
254            set_id(&mut object, Some(&result.id));
255            object.insert("result".to_owned(), params_to_value(&result.params));
256            absorb(&mut object, &result.extensions);
257        }
258        IrMessage::Error(error) => {
259            if let Some(id) = &error.id {
260                set_id(&mut object, Some(id));
261            } else {
262                object.insert("id".to_owned(), Value::Null);
263            }
264            let mut inner = Map::new();
265            inner.insert("code".to_owned(), json!(error.code.code()));
266            inner.insert("message".to_owned(), json!(error.message));
267            if let Some(data) = &error.data {
268                // Parsed back rather than kept as a string: `data` is a JSON
269                // *object* on the wire, and re-emitting the validated text as a
270                // quoted string would change the message the peer receives.
271                inner.insert(
272                    "data".to_owned(),
273                    serde_json::from_str(data.as_str()).unwrap_or(Value::Null),
274                );
275            }
276            absorb(&mut inner, &error.extensions);
277            object.insert("error".to_owned(), Value::Object(inner));
278        }
279    }
280    object
281}
282
283fn set_id(object: &mut Map<String, Value>, id: Option<&RequestId>) {
284    if let Some(id) = id {
285        // `RequestId::to_json` returns the id's *JSON text*, so it is parsed
286        // back rather than wrapped. Wrapping would emit `"\"7\""` — a string
287        // containing the two characters `"7"` — and a numeric id would arrive
288        // at the peer as a string, which is a different id.
289        object.insert(
290            "id".to_owned(),
291            serde_json::from_str(&id.to_json()).unwrap_or(Value::Null),
292        );
293    }
294}
295
296fn insert_params(object: &mut Map<String, Value>, params: &IrParams) {
297    if !params.as_extensions().is_empty() {
298        object.insert("params".to_owned(), params_to_value(params));
299    }
300}
301
302fn params_to_value(params: &IrParams) -> Value {
303    Value::Object(params.as_extensions().to_json_object())
304}
305
306fn absorb(object: &mut Map<String, Value>, extensions: &Extensions) {
307    // Goes through `to_json_object` rather than serialising each value: that
308    // is the one place that knows how an `ExtensionValue` becomes JSON, so
309    // doing it here too would be the second place to learn it.
310    for (key, value) in extensions.to_json_object() {
311        // An extension never overwrites a modelled member. If a peer sent a
312        // member this build models *and* claims the same name in the
313        // passthrough bag, the modelled one wins — it is the one we can
314        // actually read, and silently replacing it would make the message
315        // mean something different than the one we parsed.
316        object.entry(key).or_insert(value);
317    }
318}
319
320const fn type_name(value: &Value) -> &'static str {
321    match value {
322        Value::Null => "null",
323        Value::Bool(_) => "a boolean",
324        Value::Number(_) => "a number",
325        Value::String(_) => "a string",
326        Value::Array(_) => "an array",
327        Value::Object(_) => "an object",
328    }
329}
330#[cfg(test)]
331mod tests {
332    use serde_json::{Value, json};
333
334    use super::{EnvelopeError, lift, lower};
335
336    type TestError = Box<dyn std::error::Error>;
337
338    fn round_trip(value: Value) -> Result<Value, TestError> {
339        let Value::Object(object) = value else {
340            return Err("a fixture that is not an object".into());
341        };
342        let message = lift(&object)?;
343        Ok(Value::Object(lower(&message)))
344    }
345
346    /// The round trip must reproduce every member of the original.
347    ///
348    /// This is the test that says the bridge is lossless, and it asserts on
349    /// the *re-emitted JSON* rather than the parsed IR on purpose. A test that
350    /// only inspected the IR would pass even if lowering mangled the wire form
351    /// — which is the failure mode the task calls out by name.
352    #[test]
353    fn an_unmodelled_directive_survives_the_round_trip() -> Result<(), TestError> {
354        let sent = json!({
355            "jsonrpc": "2.0",
356            "id": 7,
357            "method": "tools/call",
358            "params": {
359                "name": "echo",
360                "arguments": {"text": "hi"},
361                "_meta": {"com.example/trace": {"span": 12}}
362            },
363            "x-vendor-directive": {"anything": [1, 2, 3]},
364            "future-top-level": "surprise"
365        });
366
367        let back = round_trip(sent.clone())?;
368
369        assert_eq!(back, sent, "the bridge altered the wire form");
370        Ok(())
371    }
372
373    /// The same claim for a reply, which travels the other direction and has
374    /// `result` rather than `params` to carry the extra.
375    #[test]
376    fn an_unmodelled_result_member_survives_the_round_trip() -> Result<(), TestError> {
377        let sent = json!({
378            "jsonrpc": "2.0",
379            "id": "abc",
380            "result": {"content": [], "vendorExtra": {"a": 1}},
381            "x-trace": "t-1"
382        });
383
384        assert_eq!(round_trip(sent.clone())?, sent);
385        Ok(())
386    }
387
388    /// Order survives *through the bag*, which is what `Extensions` promises.
389    ///
390    /// It does not survive into the emitted `serde_json::Map`, and cannot:
391    /// this crate builds `serde_json` without `preserve_order`, so a `Map` is
392    /// a `BTreeMap` and re-sorts on insertion. JSON object member order is not
393    /// semantically significant (RFC 8259 §4), so the wire message is
394    /// equivalent — but it is not byte-identical, and a test that claimed
395    /// otherwise would be asserting something the build cannot deliver.
396    ///
397    /// Flipping `preserve_order` on would make the whole claim true and
398    /// change `Value` for every consumer, including the JSON-RPC hot path.
399    /// That is a decision about the crate, not about the bridge, so it is
400    /// raised rather than taken here.
401    /// Lowering emits every member it was handed, and drops none.
402    ///
403    /// Note what this does *not* claim: member order. This crate builds
404    /// `serde_json` without `preserve_order`, so a `Map` is a `BTreeMap` and
405    /// re-sorts on insertion — the wire order is already gone by the time
406    /// `lift` first sees the object, and `lower` cannot put it back.
407    ///
408    /// `Extensions` still preserves insertion order internally, which is what
409    /// its own contract promises; what it cannot promise is that order
410    /// survives into a sorted `Map`. Flipping `preserve_order` on would make
411    /// order survive end to end, and would change `Value` for every consumer
412    /// including the JSON-RPC hot path — a decision about the crate rather
413    /// than about the bridge, so it is raised here rather than taken here.
414    #[test]
415    fn lowering_keeps_every_member() -> Result<(), TestError> {
416        let sent = json!({
417            "jsonrpc": "2.0",
418            "id": 1,
419            "method": "ping",
420            "zeta": 1,
421            "alpha": 2,
422            "nested": {"b": [1, 2], "a": null}
423        });
424
425        let back = round_trip(sent.clone())?;
426        let (Value::Object(forwarded), Value::Object(original)) = (&back, &sent) else {
427            return Err("a fixture that is not an object".into());
428        };
429
430        assert_eq!(
431            forwarded.len(),
432            original.len(),
433            "lowering dropped or invented a member: {forwarded:?}"
434        );
435        for (key, value) in original {
436            assert_eq!(
437                forwarded.get(key),
438                Some(value),
439                "member {key:?} did not survive"
440            );
441        }
442        Ok(())
443    }
444
445    /// A large integer id must not come back as a float. A bridge that routed
446    /// every number through `f64` would silently rewrite a peer's id and the
447    /// reply would arrive under an id nobody is waiting for.
448    #[test]
449    fn a_large_integer_id_survives_exactly() -> Result<(), TestError> {
450        // 2^53 + 1: the first integer an f64 cannot represent, which is
451        // exactly what a float-routed bridge would round away.
452        let beyond_float_precision = 9_007_199_254_740_993_i64;
453        let sent = json!({
454            "jsonrpc": "2.0",
455            "id": beyond_float_precision,
456            "method": "ping"
457        });
458
459        assert_eq!(round_trip(sent.clone())?, sent);
460        Ok(())
461    }
462
463    /// A notification has no id and must not grow one.
464    #[test]
465    fn a_notification_stays_a_notification() -> Result<(), TestError> {
466        let sent = json!({"jsonrpc": "2.0", "method": "notifications/initialized", "note": 1});
467
468        let back = round_trip(sent.clone())?;
469        assert_eq!(back, sent);
470        assert!(
471            back.get("id").is_none(),
472            "a notification gained an id it never had"
473        );
474        Ok(())
475    }
476
477    /// `null` id is how a peer reports a request it could not parse enough to
478    /// recover an id for. Dropping it would turn a recoverable client error
479    /// into an unanswerable one.
480    #[test]
481    fn a_null_id_is_kept() -> Result<(), TestError> {
482        let sent =
483            json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "parse"}});
484
485        assert_eq!(round_trip(sent.clone())?, sent);
486        Ok(())
487    }
488
489    /// An error's `data` is free-form and must survive whole.
490    #[test]
491    fn error_data_survives() -> Result<(), TestError> {
492        let sent = json!({
493            "jsonrpc": "2.0",
494            "id": 3,
495            "error": {"code": -32602, "message": "bad", "data": {"detail": [1, {"x": null}]}}
496        });
497
498        assert_eq!(round_trip(sent.clone())?, sent);
499        Ok(())
500    }
501
502    /// The failure cases, each naming its own variant rather than letting a
503    /// test helper collapse them into one "did not work".
504    #[test]
505    fn malformed_envelopes_are_refused_by_kind() -> Result<(), TestError> {
506        let cases: [(&str, Value); 3] = [
507            ("nothing", json!({"jsonrpc": "2.0"})),
508            (
509                "method type",
510                json!({"jsonrpc": "2.0", "id": 1, "method": 5}),
511            ),
512            (
513                "id type",
514                json!({"jsonrpc": "2.0", "id": [], "method": "ping"}),
515            ),
516        ];
517
518        for (label, value) in cases {
519            let Value::Object(object) = &value else {
520                return Err("a fixture that is not an object".into());
521            };
522            let Err(refusal) = lift(object) else {
523                return Err(format!("{label} should have been refused").into());
524            };
525            match (label, refusal) {
526                ("nothing", EnvelopeError::NotAMessage) => {}
527                ("method type", EnvelopeError::MethodNotAString { found }) => {
528                    assert_eq!(found, "a number");
529                }
530                ("id type", EnvelopeError::IdNotAllowed { found }) => {
531                    assert_eq!(found, "an array");
532                }
533                _ => return Err(format!("{label} reported the wrong refusal").into()),
534            }
535        }
536        Ok(())
537    }
538}