Skip to main content

atd_protocol/
messages.rs

1use serde::{Deserialize, Serialize};
2
3/// Wire value of `code` on `Response::Error` when dispatch refuses a call
4/// whose `required_capabilities` are not a subset of the connection's
5/// granted capability set. SP-12 Task 2.
6pub const ERR_CAPABILITY_DENIED: u16 = 1001;
7
8/// Wire value of `code` on `Response::Error` when dispatch refuses
9/// a call because the tool's `max_concurrent` semaphore is saturated.
10/// SP-operability-v1 C2.
11pub const ERR_RATE_LIMITED: u16 = 1002;
12
13/// Wire value of `code` on `Response::Error` when a configured
14/// `TokenBroker` returns `Err(_)` while resolving secrets for the
15/// caller. Server-side only; SDKs may surface this code but won't
16/// generate it. `retryable: true` because broker failures may be
17/// transient (network blip, secret manager hiccup).
18/// SP-token-broker-phase1.
19pub const ERR_BROKER_FAILED: u16 = 1003;
20
21/// Wire value of `code` on `Response::Error` when a `Hello.ucan_tokens`
22/// entry fails structural / signature validation: malformed JWT,
23/// unsupported `alg`, unsupported DID method, bad signature, missing
24/// required field, or chain-attenuation widening.
25/// `retryable: false` — deterministic; retry without changing the token
26/// is pointless. SP-capability-v2.
27pub const ERR_UCAN_INVALID: u16 = 1010;
28
29/// Wire value of `code` on `Response::Error` when a UCAN chain link has
30/// `exp <= now()`. `retryable: false` (re-issue required).
31/// SP-capability-v2.
32pub const ERR_UCAN_EXPIRED: u16 = 1011;
33
34/// Wire value of `code` on `Response::Error` when a UCAN chain exceeds
35/// `ServerConfig.max_ucan_chain_depth` (default 5).
36/// `retryable: false`. SP-capability-v2.
37pub const ERR_DELEGATION_TOO_DEEP: u16 = 1012;
38
39/// Wire value of `code` on `Response::Error` when the deepest UCAN's
40/// `aud` does not match the connection's `client_id` (or the bearer's
41/// caller). Prevents intercepted-token replay by a third party.
42/// `retryable: false`. SP-capability-v2.
43pub const ERR_AUDIENCE_MISMATCH: u16 = 1013;
44
45/// Wire value of `code` on `Response::Error` when a `Request::RunToolContinue`
46/// presents a cursor whose `issued_at_unix` is older than the server's
47/// `cursor_ttl_seconds` (default 300s) or whose `server_session` does not
48/// match the current server-process random nonce (server-restart invalidation).
49/// `retryable: false` — the cursor is permanently dead; the client must
50/// re-issue the original `RunTool` to get a fresh cursor. SP-pagination-v1.
51pub const ERR_CURSOR_EXPIRED: u16 = 1020;
52
53/// Wire value of `code` on `Response::Error` when a cursor fails HMAC
54/// verification, has malformed framing, references a non-matching
55/// `tool_id`, or carries an `args_fingerprint` that doesn't match the
56/// continuation's intended args. Distinct from `ERR_CURSOR_EXPIRED`
57/// because an invalid cursor suggests a bug or attack (forge attempt)
58/// while expiry is a normal lifecycle event — ops alert differently.
59/// `retryable: false`. SP-pagination-v1.
60pub const ERR_CURSOR_INVALID: u16 = 1021;
61
62/// Request frames sent from client → server.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
65#[serde(tag = "type")]
66pub enum Request {
67    #[serde(rename = "ping")]
68    Ping,
69
70    /// SP-12 Hello handshake. Optional: pre-SP-12 servers do not recognize
71    /// it; `AtdClient::hello` tolerates that and returns an empty granted
72    /// set so callers can treat "no capabilities" and "server too old"
73    /// identically.
74    ///
75    /// SP-capability-v2 (2026-05-11) adds the optional `ucan_tokens` field.
76    /// When non-empty, each element is a UCAN-lite JWT compact form
77    /// (`alg=EdDSA`, `typ=ucan/1.0+jwt`); the server's verifier produces
78    /// `granted = granted_strings ∪ granted_ucan`. Pre-SP-capability-v2
79    /// servers omit the field via serde default; their behaviour is byte-
80    /// identical to SP-12. Spec §4.2 + §5.2.
81    #[serde(rename = "hello")]
82    Hello {
83        #[serde(default, skip_serializing_if = "Option::is_none")]
84        client_id: Option<String>,
85        #[serde(default)]
86        requested_capabilities: Vec<String>,
87        #[serde(default, skip_serializing_if = "Vec::is_empty")]
88        ucan_tokens: Vec<String>,
89    },
90
91    #[serde(rename = "tool_list")]
92    ToolList,
93
94    #[serde(rename = "tool_schema")]
95    ToolSchema { tool_id: String },
96
97    #[serde(rename = "run_tool")]
98    RunTool {
99        tool_id: String,
100        args: serde_json::Value,
101        dry_run: bool,
102    },
103
104    /// Fetch the next page of a paginated tool result. The cursor is the
105    /// server-issued opaque string from a prior `Response::ToolResultResponse.next_cursor`.
106    /// `tool_id` must match the cursor's embedded tool_id (server validates).
107    /// SP-pagination-v1 §4.1.
108    #[serde(rename = "run_tool_continue")]
109    RunToolContinue { tool_id: String, cursor: String },
110}
111
112/// Response frames sent from server → client.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
115#[serde(tag = "type")]
116pub enum Response {
117    #[serde(rename = "pong")]
118    Pong,
119
120    #[serde(rename = "hello_ack")]
121    HelloAck {
122        #[serde(default)]
123        granted_capabilities: Vec<String>,
124        #[serde(default)]
125        server_version: String,
126        #[serde(default)]
127        supported_tiers: Vec<String>,
128    },
129
130    #[serde(rename = "tool_list")]
131    ToolListResponse { tools: serde_json::Value },
132
133    #[serde(rename = "tool_schema")]
134    ToolSchemaResponse { schema: serde_json::Value },
135
136    #[serde(rename = "tool_result")]
137    ToolResultResponse {
138        tool_id: String,
139        result: serde_json::Value,
140        success: bool,
141        dry_run: bool,
142        /// SP-pagination-v1 §4.1 — when present, the tool has more pages.
143        /// The client passes this verbatim to `Request::RunToolContinue.cursor`
144        /// to fetch the next page. Server-opaque (HMAC-signed in the
145        /// reference impl); clients MUST NOT parse it. Absent on terminal
146        /// pages and on responses from non-paginating tools.
147        #[serde(default, skip_serializing_if = "Option::is_none")]
148        next_cursor: Option<String>,
149    },
150
151    #[serde(rename = "error")]
152    Error {
153        message: String,
154        #[serde(default, skip_serializing_if = "Option::is_none")]
155        code: Option<u16>,
156        #[serde(default, skip_serializing_if = "Option::is_none")]
157        retryable: Option<bool>,
158        #[serde(default, skip_serializing_if = "Option::is_none")]
159        details: Option<serde_json::Value>,
160    },
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn ping_serializes_with_type_tag() {
169        let j = serde_json::to_string(&Request::Ping).unwrap();
170        assert_eq!(j, r#"{"type":"ping"}"#);
171    }
172
173    #[test]
174    fn run_tool_roundtrip() {
175        let r = Request::RunTool {
176            tool_id: "anos:fs.read".into(),
177            args: serde_json::json!({"path": "/tmp/x"}),
178            dry_run: false,
179        };
180        let j = serde_json::to_string(&r).unwrap();
181        let back: Request = serde_json::from_str(&j).unwrap();
182        match back {
183            Request::RunTool {
184                tool_id, dry_run, ..
185            } => {
186                assert_eq!(tool_id, "anos:fs.read");
187                assert!(!dry_run);
188            }
189            _ => panic!("wrong variant"),
190        }
191    }
192
193    #[test]
194    fn tool_list_response_carries_array() {
195        let r = Response::ToolListResponse {
196            tools: serde_json::json!([{"id": "a"}, {"id": "b"}]),
197        };
198        let j = serde_json::to_string(&r).unwrap();
199        assert!(j.contains("\"type\":\"tool_list\""));
200        let back: Response = serde_json::from_str(&j).unwrap();
201        match back {
202            Response::ToolListResponse { tools } => {
203                assert_eq!(tools.as_array().unwrap().len(), 2);
204            }
205            _ => panic!("wrong variant"),
206        }
207    }
208
209    #[test]
210    fn error_deserializes_with_optional_fields_missing() {
211        let j = r#"{"type":"error","message":"boom"}"#;
212        let back: Response = serde_json::from_str(j).unwrap();
213        match back {
214            Response::Error {
215                message,
216                code,
217                retryable,
218                details,
219            } => {
220                assert_eq!(message, "boom");
221                assert!(code.is_none());
222                assert!(retryable.is_none());
223                assert!(details.is_none());
224            }
225            _ => panic!("wrong variant"),
226        }
227    }
228
229    // ---- SP-pagination-v1 Phase B: wire format round-trips ----
230
231    #[test]
232    fn run_tool_continue_round_trips() {
233        let r = Request::RunToolContinue {
234            tool_id: "celia:fhir.list_observations".into(),
235            cursor: "abc123".into(),
236        };
237        let j = serde_json::to_string(&r).unwrap();
238        assert!(j.contains(r#""type":"run_tool_continue""#));
239        let back: Request = serde_json::from_str(&j).unwrap();
240        match back {
241            Request::RunToolContinue { tool_id, cursor } => {
242                assert_eq!(tool_id, "celia:fhir.list_observations");
243                assert_eq!(cursor, "abc123");
244            }
245            _ => panic!("wrong variant: {j}"),
246        }
247    }
248
249    #[test]
250    fn tool_result_response_without_next_cursor_omits_field_on_wire() {
251        let r = Response::ToolResultResponse {
252            tool_id: "x".into(),
253            result: serde_json::json!({}),
254            success: true,
255            dry_run: false,
256            next_cursor: None,
257        };
258        let j = serde_json::to_string(&r).unwrap();
259        assert!(
260            !j.contains("next_cursor"),
261            "next_cursor: None must be omitted on the wire (back-compat), got: {j}"
262        );
263    }
264
265    #[test]
266    fn tool_result_response_with_next_cursor_includes_field_on_wire() {
267        let r = Response::ToolResultResponse {
268            tool_id: "x".into(),
269            result: serde_json::json!({}),
270            success: true,
271            dry_run: false,
272            next_cursor: Some("abc".into()),
273        };
274        let j = serde_json::to_string(&r).unwrap();
275        assert!(
276            j.contains(r#""next_cursor":"abc""#),
277            "next_cursor: Some(_) must serialize, got: {j}"
278        );
279    }
280
281    #[test]
282    fn tool_result_response_back_compat_default_when_field_missing() {
283        // Pre-pagination wire shape (no next_cursor field) must deserialize
284        // to next_cursor: None — adopters on old atd builds keep working.
285        let j =
286            r#"{"type":"tool_result","tool_id":"x","result":{},"success":true,"dry_run":false}"#;
287        let back: Response = serde_json::from_str(j).unwrap();
288        match back {
289            Response::ToolResultResponse { next_cursor, .. } => {
290                assert!(next_cursor.is_none(), "missing field must default to None");
291            }
292            _ => panic!("wrong variant"),
293        }
294    }
295
296    #[test]
297    fn err_cursor_codes_distinct_from_existing_families() {
298        // Sanity: don't collide with the existing 100x or 101x families.
299        assert_eq!(ERR_CURSOR_EXPIRED, 1020);
300        assert_eq!(ERR_CURSOR_INVALID, 1021);
301        assert_ne!(ERR_CURSOR_EXPIRED, ERR_CURSOR_INVALID);
302        assert_ne!(ERR_CURSOR_EXPIRED, ERR_AUDIENCE_MISMATCH);
303        assert_ne!(ERR_CURSOR_INVALID, ERR_RATE_LIMITED);
304    }
305}