Skip to main content

subc_protocol/
tool_call.rs

1//! The body of a tool-call `REQUEST` frame on a bound route.
2//!
3//! The daemon splices route frames without reading their bodies, so this
4//! shape is a contract between consumers (the MCP gateway, model runners)
5//! and provider modules, not something the daemon enforces. Before this
6//! type existed every consumer carried its own struct and every provider its
7//! own reader, and the fields drifted: the gateway sent `progress_token`,
8//! a model runner sent only `name` and `arguments`, and a provider that
9//! needed the caller's tool-call id had no field to read it from.
10//!
11//! Decoding is deliberately tolerant of unknown members: a provider must
12//! never refuse a call because a newer consumer added a key it does not
13//! know. Omitted optionals decode as `None`; `None` optionals are omitted on
14//! the wire, so a body carrying no optionals serializes exactly as the
15//! two-field shape older consumers already send — this type is drop-in for
16//! them without a wire change.
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::Principal;
22
23/// Who really asked for a call that another module relays.
24///
25/// When a module forwards a tool call on behalf of a different caller, the
26/// provider sees the forwarding module as the route's principal and the
27/// forwarder's own `call_key`. This records the caller behind it, so the
28/// provider can attribute the call in its logs and ledgers.
29///
30/// It is a claim made by the forwarding module, not something the daemon
31/// checks: a provider may record it but must never grant or refuse anything
32/// because of it. Authority stays with the route's own principal.
33///
34/// `#[non_exhaustive]` so a later member is an additive change; build it with
35/// [`CallOrigin::new`].
36#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
37#[non_exhaustive]
38pub struct CallOrigin {
39    /// The original caller, in the same form the daemon stamps on a route.
40    pub carrier: Principal,
41    /// The original caller's key for the call, with the same bounds as
42    /// [`ToolCallRequest::call_key`]; check it with [`validate_call_origin`].
43    pub call_key: String,
44}
45
46impl CallOrigin {
47    /// An origin naming `carrier` as the caller and `call_key` as its key.
48    pub fn new(carrier: Principal, call_key: impl Into<String>) -> Self {
49        Self {
50            carrier,
51            call_key: call_key.into(),
52        }
53    }
54}
55
56/// A tool invocation as carried on a route `REQUEST` frame.
57#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
58pub struct ToolCallRequest {
59    /// The provider's bare manifest tool name (no gateway prefix).
60    pub name: String,
61    /// The arguments exactly as the caller supplied them; consumers never
62    /// translate them, and the provider's manifest schema is what accepts
63    /// or rejects their shape.
64    pub arguments: Value,
65    /// The consumer's own identifier for this call, minted by whatever
66    /// dispatched it (a model runner's WAL intent id, a gateway request id).
67    /// Opaque to the daemon and to subc; unique per call on the consumer's
68    /// side, so a provider's at-most-once fence can key on it directly
69    /// instead of synthesizing an id from the call's contents.
70    ///
71    /// `None` is a statement about the PRODUCER, not the call: it means this
72    /// consumer did not supply an id, never that the call has no identity.
73    /// A reader must not collapse the two — the moment a legacy producer is
74    /// on the other end, treating `None` as "no id exists" and synthesizing
75    /// one silently reproduces exactly the failure this field exists to end.
76    /// A reader that synthesizes a fallback id when this is `None` must
77    /// record that the fallback fired (a fallback that never reports firing
78    /// is indistinguishable from a working component that is quietly wrong).
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub tool_call_id: Option<String>,
81    /// An MCP progress token the consumer wants progress notifications
82    /// correlated to, when the caller requested progress. Opaque here.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub progress_token: Option<Value>,
85    /// A key the consumer chose for this call, which a provider may use to
86    /// recognise the same call arriving twice. Opaque to the daemon; a
87    /// provider checks only its shape, with [`validate_call_key`], and answers
88    /// a malformed one with `invalid_request` naming the field
89    /// [`CALL_KEY_FIELD`].
90    ///
91    /// This struct is deliberately not `#[non_exhaustive]`: a consumer that
92    /// builds it field by field must decide what key, if any, to send, so a
93    /// new field here is meant to stop its struct literal compiling.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub call_key: Option<String>,
96    /// Which version of the tool's schema the consumer built its arguments
97    /// against, so a provider can tell a call made against a schema it no
98    /// longer serves. Opaque here: the tool-provider role defines what it
99    /// holds. A provider checks only its shape, with [`validate_schema_pin`],
100    /// and answers a malformed one with `invalid_request` naming the field
101    /// [`SCHEMA_PIN_FIELD`].
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub schema_pin: Option<String>,
104    /// The session's tool preset, assigned by the module that owns the session
105    /// and forwarded by its carrier on every call, outside the model's
106    /// arguments. Opaque to the
107    /// daemon. A provider checks its shape with [`validate_preset`] and answers
108    /// a malformed value with `invalid_request` naming [`PRESET_FIELD`].
109    ///
110    /// Absent means the caller did not say: a provider must decide explicitly
111    /// what an absent preset gets, and must not default it to the most capable
112    /// preset. A provider refuses a preset it does not serve, by name, rather
113    /// than falling back to a default.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub preset: Option<String>,
116    /// The caller behind this call when the consumer is relaying it for
117    /// someone else; `None` when the consumer is the caller. For attribution
118    /// only, never authority: see [`CallOrigin`]. A provider checks it with
119    /// [`validate_call_origin`].
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub origin: Option<CallOrigin>,
122}
123
124impl ToolCallRequest {
125    /// A call with no consumer id, no progress token, no call key, no schema
126    /// pin, no preset and no origin — the shape older two-field consumers send.
127    pub fn new(name: impl Into<String>, arguments: Value) -> Self {
128        Self {
129            name: name.into(),
130            arguments,
131            tool_call_id: None,
132            progress_token: None,
133            call_key: None,
134            schema_pin: None,
135            preset: None,
136            origin: None,
137        }
138    }
139}
140
141/// The wire name of [`ToolCallRequest::call_key`], for the `field` of the
142/// `invalid_request` error a provider returns when the key is malformed.
143pub const CALL_KEY_FIELD: &str = "call_key";
144
145/// The wire name of [`ToolCallRequest::schema_pin`], for the `field` of the
146/// `invalid_request` error a provider returns when the pin is malformed.
147pub const SCHEMA_PIN_FIELD: &str = "schema_pin";
148
149/// The wire name of [`ToolCallRequest::preset`], for a provider's
150/// `invalid_request` error's `field`.
151pub const PRESET_FIELD: &str = "preset";
152
153/// The longest preset accepted, in ASCII characters.
154pub const PRESET_MAX_LEN: usize = 64;
155
156/// Why a tool preset was refused. Each error names [`PRESET_FIELD`], so a
157/// provider can use the same `invalid_request` path as for a schema pin.
158#[derive(Clone, Debug, PartialEq, Eq)]
159#[non_exhaustive]
160pub enum PresetError {
161    Empty,
162    TooLong { length: usize },
163    InvalidCharacter { index: usize },
164}
165
166impl PresetError {
167    pub fn field(&self) -> &'static str {
168        PRESET_FIELD
169    }
170}
171
172impl std::fmt::Display for PresetError {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        match self {
175            Self::Empty => write!(f, "preset must not be empty"),
176            Self::TooLong { length } => write!(
177                f,
178                "preset is {length} bytes; at most {PRESET_MAX_LEN} are allowed"
179            ),
180            Self::InvalidCharacter { index } => {
181                write!(
182                    f,
183                    "preset has a character at byte {index} outside [a-z0-9_-]"
184                )
185            }
186        }
187    }
188}
189
190impl std::error::Error for PresetError {}
191
192/// The wire path of [`CallOrigin::call_key`] inside a request, for the `field`
193/// of the `invalid_request` error a provider returns when it is malformed.
194pub const ORIGIN_CALL_KEY_FIELD: &str = "origin.call_key";
195
196/// The longest opaque token field accepted, in bytes (every accepted byte is
197/// one ASCII character). Shared by `call_key`, `schema_pin` and scope `flow_id`.
198pub const OPAQUE_FIELD_MAX_LEN: usize = 256;
199
200/// The longest `call_key` accepted.
201pub const CALL_KEY_MAX_LEN: usize = OPAQUE_FIELD_MAX_LEN;
202
203/// The longest `schema_pin` accepted.
204pub const SCHEMA_PIN_MAX_LEN: usize = OPAQUE_FIELD_MAX_LEN;
205
206/// Why an opaque token field (`call_key`, `schema_pin`, `flow_id`) was refused. Every
207/// variant names the request field it is about, so a provider can put it in
208/// its `invalid_request` error without tracking which check ran.
209#[derive(Clone, Debug, PartialEq, Eq)]
210pub enum OpaqueFieldError {
211    /// The value was the empty string. An absent value is `None`, never `""`.
212    Empty { field: &'static str },
213    /// The value was longer than [`OPAQUE_FIELD_MAX_LEN`] bytes.
214    TooLong { field: &'static str, length: usize },
215    /// The byte at `index` is not printable, non-space ASCII.
216    InvalidCharacter { field: &'static str, index: usize },
217}
218
219/// The error [`validate_call_key`] returns. Kept as a name so code that only
220/// formats the error or reads [`OpaqueFieldError::field`] keeps compiling.
221pub type CallKeyError = OpaqueFieldError;
222
223impl OpaqueFieldError {
224    /// The request field the error is about.
225    pub fn field(&self) -> &'static str {
226        match self {
227            Self::Empty { field }
228            | Self::TooLong { field, .. }
229            | Self::InvalidCharacter { field, .. } => field,
230        }
231    }
232}
233
234impl std::fmt::Display for OpaqueFieldError {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        match self {
237            Self::Empty { field } => write!(f, "{field} must not be empty"),
238            Self::TooLong { field, length } => write!(
239                f,
240                "{field} is {length} bytes; at most {OPAQUE_FIELD_MAX_LEN} are allowed"
241            ),
242            Self::InvalidCharacter { field, index } => write!(
243                f,
244                "{field} has a character at byte {index} outside printable ASCII \
245                 (0x21 to 0x7E; space is not allowed)"
246            ),
247        }
248    }
249}
250
251impl std::error::Error for OpaqueFieldError {}
252
253/// Check an opaque token field: 1 to [`OPAQUE_FIELD_MAX_LEN`] characters, each
254/// printable ASCII from 0x21 to 0x7E. `field` is the wire name the error
255/// reports.
256///
257/// Space (0x20) is refused. Providers compare these values byte for byte and
258/// write them into logs and ledgers, where a leading or trailing space is
259/// invisible: two values that differ only by one would read as the same value
260/// and act as different ones. Every other printable character is allowed, so
261/// a consumer can use its existing ids (UUIDs, `prefix:id` forms, base64,
262/// digests) unchanged.
263pub(crate) fn validate_opaque_field(
264    field: &'static str,
265    value: &str,
266) -> Result<(), OpaqueFieldError> {
267    if value.is_empty() {
268        return Err(OpaqueFieldError::Empty { field });
269    }
270    if value.len() > OPAQUE_FIELD_MAX_LEN {
271        return Err(OpaqueFieldError::TooLong {
272            field,
273            length: value.len(),
274        });
275    }
276    if let Some(index) = value
277        .bytes()
278        .position(|byte| !(0x21..=0x7e).contains(&byte))
279    {
280        return Err(OpaqueFieldError::InvalidCharacter { field, index });
281    }
282    Ok(())
283}
284
285/// Check a `call_key` with the shared opaque-field rule; errors name
286/// [`CALL_KEY_FIELD`].
287pub fn validate_call_key(key: &str) -> Result<(), CallKeyError> {
288    validate_opaque_field(CALL_KEY_FIELD, key)
289}
290
291/// Check a `schema_pin` with the shared opaque-field rule; errors name
292/// [`SCHEMA_PIN_FIELD`].
293pub fn validate_schema_pin(pin: &str) -> Result<(), OpaqueFieldError> {
294    validate_opaque_field(SCHEMA_PIN_FIELD, pin)
295}
296
297/// Check a preset: 1 to [`PRESET_MAX_LEN`] ASCII characters in `[a-z0-9_-]`.
298/// Errors name [`PRESET_FIELD`]. An absent preset is `None`, not an empty string.
299pub fn validate_preset(preset: &str) -> Result<(), PresetError> {
300    if preset.is_empty() {
301        return Err(PresetError::Empty);
302    }
303    if preset.len() > PRESET_MAX_LEN {
304        return Err(PresetError::TooLong {
305            length: preset.len(),
306        });
307    }
308    if let Some(index) = preset.bytes().position(|byte| {
309        !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_-".contains(&byte))
310    }) {
311        return Err(PresetError::InvalidCharacter { index });
312    }
313    Ok(())
314}
315
316/// Check a [`CallOrigin`]: its `call_key` must pass the shared opaque-field
317/// rule, and errors name [`ORIGIN_CALL_KEY_FIELD`]. Every [`Principal`] is
318/// accepted as the carrier.
319pub fn validate_call_origin(origin: &CallOrigin) -> Result<(), OpaqueFieldError> {
320    validate_opaque_field(ORIGIN_CALL_KEY_FIELD, &origin.call_key)
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use serde_json::json;
327
328    #[test]
329    fn omitted_optionals_decode_as_none() {
330        let request: ToolCallRequest =
331            serde_json::from_value(json!({ "name": "grep", "arguments": { "q": "x" } }))
332                .expect("two-field body decodes");
333        assert_eq!(request.tool_call_id, None);
334        assert_eq!(request.progress_token, None);
335        assert_eq!(request.call_key, None);
336        assert_eq!(request.schema_pin, None);
337        assert_eq!(request.preset, None);
338        assert_eq!(request.origin, None);
339    }
340
341    #[test]
342    fn call_key_round_trips_as_a_top_level_member() {
343        let request = ToolCallRequest {
344            name: "grep".to_string(),
345            arguments: json!({ "q": "x" }),
346            tool_call_id: None,
347            progress_token: None,
348            call_key: Some("run-7:call-3".to_string()),
349            schema_pin: None,
350            preset: None,
351            origin: None,
352        };
353        let encoded = serde_json::to_value(&request).expect("encode");
354        assert_eq!(
355            encoded,
356            json!({ "name": "grep", "arguments": { "q": "x" }, "call_key": "run-7:call-3" })
357        );
358        let decoded: ToolCallRequest = serde_json::from_value(encoded).expect("decode");
359        assert_eq!(decoded, request);
360    }
361
362    #[test]
363    fn a_request_without_a_call_key_omits_the_member_and_round_trips() {
364        let request = ToolCallRequest::new("grep", json!({}));
365        let encoded = serde_json::to_value(&request).expect("encode");
366        assert!(encoded.get("call_key").is_none(), "{encoded}");
367        let decoded: ToolCallRequest = serde_json::from_value(encoded).expect("decode");
368        assert_eq!(decoded.call_key, None);
369        assert_eq!(decoded, request);
370    }
371
372    /// The same bounds for both fields, with each refusal naming its own
373    /// field: one validator, two names.
374    #[test]
375    fn opaque_field_bounds_are_one_to_256_printable_non_space_ascii() {
376        type Validate = fn(&str) -> Result<(), OpaqueFieldError>;
377        let validators: [(&str, Validate); 2] = [
378            (CALL_KEY_FIELD, validate_call_key),
379            (SCHEMA_PIN_FIELD, validate_schema_pin),
380        ];
381        for (field, validate) in validators {
382            assert_eq!(validate(""), Err(OpaqueFieldError::Empty { field }));
383            assert_eq!(validate("k"), Ok(()));
384            assert_eq!(validate(&"k".repeat(256)), Ok(()));
385            assert_eq!(
386                validate(&"k".repeat(257)),
387                Err(OpaqueFieldError::TooLong { field, length: 257 })
388            );
389            assert_eq!(validate("!~"), Ok(()), "both ends of 0x21..=0x7E");
390            for bad in ["ké", "a\tb", "a\u{7f}", "a b"] {
391                assert_eq!(
392                    validate(bad),
393                    Err(OpaqueFieldError::InvalidCharacter { field, index: 1 }),
394                    "{field}: {bad:?}"
395                );
396            }
397            let error = validate("").unwrap_err();
398            assert_eq!(error.field(), field);
399            assert!(error.to_string().starts_with(field), "{error}");
400        }
401    }
402
403    #[test]
404    fn schema_pin_round_trips_as_a_top_level_member() {
405        let request = ToolCallRequest {
406            schema_pin: Some("sha256:0f1e2d".to_string()),
407            ..ToolCallRequest::new("grep", json!({ "q": "x" }))
408        };
409        let encoded = serde_json::to_value(&request).expect("encode");
410        assert_eq!(
411            encoded,
412            json!({ "name": "grep", "arguments": { "q": "x" }, "schema_pin": "sha256:0f1e2d" })
413        );
414        let decoded: ToolCallRequest = serde_json::from_value(encoded).expect("decode");
415        assert_eq!(decoded, request);
416    }
417
418    #[test]
419    fn preset_round_trips_outside_arguments_and_absence_keeps_the_bytes() {
420        let mut request = ToolCallRequest::new("grep", json!({ "q": "x" }));
421        let absent = serde_json::to_string(&request).unwrap();
422        assert_eq!(absent, r#"{"name":"grep","arguments":{"q":"x"}}"#);
423        let decoded: ToolCallRequest = serde_json::from_str(&absent).unwrap();
424        assert_eq!(decoded, request);
425        assert_eq!(decoded.preset, None);
426
427        request.preset = Some("read_only-2".to_string());
428        let encoded = serde_json::to_value(&request).unwrap();
429        assert_eq!(
430            encoded,
431            json!({
432                "name": "grep", "arguments": { "q": "x" }, "preset": "read_only-2"
433            })
434        );
435        assert_eq!(
436            serde_json::from_value::<ToolCallRequest>(encoded).unwrap(),
437            request
438        );
439    }
440
441    #[test]
442    fn preset_bounds_are_one_to_64_lowercase_digits_underscore_or_hyphen() {
443        assert_eq!(validate_preset(""), Err(PresetError::Empty));
444        assert_eq!(validate_preset("a"), Ok(()));
445        assert_eq!(validate_preset(&"a".repeat(64)), Ok(()));
446        assert_eq!(
447            validate_preset(&"a".repeat(65)),
448            Err(PresetError::TooLong { length: 65 })
449        );
450        assert_eq!(validate_preset("a0_-z9"), Ok(()));
451        for bad in ["A", ".", "é", " "] {
452            let error = validate_preset(bad).unwrap_err();
453            assert_eq!(error, PresetError::InvalidCharacter { index: 0 });
454            assert_eq!(error.field(), "preset");
455            assert!(error.to_string().starts_with("preset"));
456            let refusal =
457                crate::ErrorBody::new(crate::error_codes::INVALID_REQUEST, error.to_string())
458                    .with_detail(json!({ "field": error.field() }));
459            assert_eq!(refusal.code, "invalid_request");
460            assert_eq!(refusal.detail.unwrap()["field"], "preset");
461        }
462    }
463
464    #[test]
465    fn a_request_without_a_schema_pin_omits_the_member_and_round_trips() {
466        let request = ToolCallRequest::new("grep", json!({}));
467        let encoded = serde_json::to_value(&request).expect("encode");
468        assert!(encoded.get("schema_pin").is_none(), "{encoded}");
469        let decoded: ToolCallRequest = serde_json::from_value(encoded).expect("decode");
470        assert_eq!(decoded.schema_pin, None);
471        assert_eq!(decoded, request);
472    }
473
474    #[test]
475    fn none_optionals_are_omitted_so_the_wire_matches_the_two_field_shape() {
476        let request = ToolCallRequest::new("grep", json!({ "q": "x" }));
477        let encoded = serde_json::to_value(&request).expect("encode");
478        assert_eq!(
479            encoded,
480            json!({ "name": "grep", "arguments": { "q": "x" } })
481        );
482    }
483
484    #[test]
485    fn tool_call_id_round_trips() {
486        let request = ToolCallRequest {
487            name: "grep".to_string(),
488            arguments: json!({ "q": "x" }),
489            tool_call_id: Some("wal-intent-42".to_string()),
490            progress_token: None,
491            call_key: None,
492            schema_pin: None,
493            preset: None,
494            origin: None,
495        };
496        let encoded = serde_json::to_value(&request).expect("encode");
497        assert_eq!(encoded["tool_call_id"], json!("wal-intent-42"));
498        let decoded: ToolCallRequest = serde_json::from_value(encoded).expect("decode");
499        assert_eq!(decoded, request);
500    }
501
502    #[test]
503    fn unknown_members_do_not_fail_a_provider_decode() {
504        // A newer consumer added a key this provider has never heard of; the
505        // call must still decode rather than refuse.
506        let request: ToolCallRequest = serde_json::from_value(json!({
507            "name": "grep",
508            "arguments": {},
509            "some_future_key": { "nested": true }
510        }))
511        .expect("unknown members are tolerated");
512        assert_eq!(request.name, "grep");
513    }
514
515    fn relayed_origin() -> CallOrigin {
516        CallOrigin::new(
517            Principal::Reserved {
518                module_id: "broca".to_string(),
519            },
520            "broca:run-7/call-3",
521        )
522    }
523
524    /// The carrier travels as the tagged `Principal` object the daemon stamps
525    /// on a route, never as a bare string.
526    #[test]
527    fn origin_round_trips_as_a_top_level_member_with_a_tagged_carrier() {
528        let request = ToolCallRequest {
529            call_key: Some("pf:relay/991".to_string()),
530            origin: Some(relayed_origin()),
531            ..ToolCallRequest::new("grep", json!({ "q": "x" }))
532        };
533        let encoded = serde_json::to_value(&request).expect("encode");
534        assert_eq!(
535            encoded,
536            json!({
537                "name": "grep",
538                "arguments": { "q": "x" },
539                "call_key": "pf:relay/991",
540                "origin": {
541                    "carrier": { "kind": "reserved", "module_id": "broca" },
542                    "call_key": "broca:run-7/call-3"
543                }
544            })
545        );
546        let decoded: ToolCallRequest = serde_json::from_value(encoded).expect("decode");
547        assert_eq!(decoded, request);
548    }
549
550    #[test]
551    fn a_request_without_an_origin_omits_the_member_and_decodes_as_none() {
552        let request = ToolCallRequest::new("grep", json!({}));
553        let encoded = serde_json::to_value(&request).expect("encode");
554        assert!(encoded.get("origin").is_none(), "{encoded}");
555        let decoded: ToolCallRequest = serde_json::from_value(encoded).expect("decode");
556        assert_eq!(decoded.origin, None);
557        assert_eq!(decoded, request);
558    }
559
560    #[test]
561    fn a_request_with_an_origin_and_an_unknown_member_still_decodes() {
562        let request: ToolCallRequest = serde_json::from_value(json!({
563            "name": "grep",
564            "arguments": {},
565            "origin": {
566                "carrier": { "kind": "direct" },
567                "call_key": "k",
568                "some_future_origin_key": 1
569            },
570            "some_future_key": { "nested": true }
571        }))
572        .expect("unknown members are tolerated");
573        assert_eq!(
574            request.origin,
575            Some(CallOrigin::new(Principal::Direct, "k"))
576        );
577    }
578
579    #[test]
580    fn call_origin_key_refusals_name_the_origin_call_key_field() {
581        let carrier = Principal::Direct;
582        let field = ORIGIN_CALL_KEY_FIELD;
583        assert_eq!(field, "origin.call_key");
584        let cases = [
585            (String::new(), OpaqueFieldError::Empty { field }),
586            (
587                "k".repeat(257),
588                OpaqueFieldError::TooLong { field, length: 257 },
589            ),
590            (
591                "pf:relay 991".to_string(),
592                OpaqueFieldError::InvalidCharacter { field, index: 8 },
593            ),
594        ];
595        for (key, expected) in cases {
596            let error = validate_call_origin(&CallOrigin::new(carrier.clone(), key.clone()))
597                .expect_err("malformed origin key is refused");
598            assert_eq!(error, expected, "{key:?}");
599            assert_eq!(error.field(), "origin.call_key", "{key:?}");
600        }
601    }
602
603    #[test]
604    fn call_origin_accepts_every_carrier_kind() {
605        let carriers = [
606            Principal::Reserved {
607                module_id: "prefrontal-core".to_string(),
608            },
609            Principal::Direct,
610            Principal::Unverified,
611        ];
612        for carrier in carriers {
613            assert_eq!(
614                validate_call_origin(&CallOrigin::new(carrier.clone(), "pf:relay/991")),
615                Ok(()),
616                "{carrier:?}"
617            );
618        }
619    }
620}