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, ¬ification.params);
251 absorb(&mut object, ¬ification.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}