Skip to main content

dig_node_control_interface/
error.rs

1//! The canonical control-plane error taxonomy.
2//!
3//! One definition point for every error code a dig-node control method emits, the [`ControlError`]
4//! envelope (`{code, message, data:{code, origin}}`), and the constructor both sides use so every
5//! error carries a machine-branchable `data.code` (UPPER_SNAKE) and `data.origin`.
6//!
7//! The numeric values are the SAME published wire contract as dig-node's `meta::ErrorCode` and the
8//! canonical dig-rpc `-320xx` control range — they never change once assigned. A client keys its UX
9//! off `data.code` (the stable symbol), never the human `message`.
10//!
11//! | Code | Variant | Origin | Meaning |
12//! |---|---|---|---|
13//! | `-32700` | [`ParseError`](ControlErrorCode::ParseError) | shell | request body was not valid JSON |
14//! | `-32600` | [`InvalidRequest`](ControlErrorCode::InvalidRequest) | shell | not a single JSON-RPC object |
15//! | `-32601` | [`MethodNotFound`](ControlErrorCode::MethodNotFound) | boundary | control method is not resolved |
16//! | `-32602` | [`InvalidParams`](ControlErrorCode::InvalidParams) | node | missing/malformed params |
17//! | `-32000` | [`DispatchFailed`](ControlErrorCode::DispatchFailed) | shell | the node failed to dispatch |
18//! | `-32030` | [`Unauthorized`](ControlErrorCode::Unauthorized) | shell | called without a valid control token |
19//! | `-32031` | [`NotSupported`](ControlErrorCode::NotSupported) | shell | control op unsupported on this build |
20//! | `-32032` | [`ControlError`](ControlErrorCode::ControlError) | shell | control op failed at runtime |
21
22use serde::{Deserialize, Serialize};
23
24/// A canonical control-plane error code.
25///
26/// `#[non_exhaustive]` so adding a code in a minor release is additive; downstream matches must use
27/// a `_ => …` arm.
28#[non_exhaustive]
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum ControlErrorCode {
31    /// `-32700` — request body was not valid JSON.
32    ParseError,
33    /// `-32600` — the request was not a single JSON-RPC object (batches are unsupported).
34    InvalidRequest,
35    /// `-32601` — the control method is not resolved by the node.
36    MethodNotFound,
37    /// `-32602` — invalid or missing method parameters.
38    InvalidParams,
39    /// `-32000` — the node failed to dispatch a well-formed control call.
40    DispatchFailed,
41    /// `-32030` — a `control.*` method was called without a valid local control token.
42    Unauthorized,
43    /// `-32031` — the control operation is not supported on this node build (e.g. §21 sync with no
44    /// identity loaded, or a capability the running engine does not expose).
45    NotSupported,
46    /// `-32032` — a control operation failed at runtime (e.g. a config write or cache op errored).
47    ControlError,
48}
49
50impl ControlErrorCode {
51    /// The numeric JSON-RPC error code.
52    pub const fn code(self) -> i64 {
53        match self {
54            ControlErrorCode::ParseError => -32700,
55            ControlErrorCode::InvalidRequest => -32600,
56            ControlErrorCode::MethodNotFound => -32601,
57            ControlErrorCode::InvalidParams => -32602,
58            ControlErrorCode::DispatchFailed => -32000,
59            ControlErrorCode::Unauthorized => -32030,
60            ControlErrorCode::NotSupported => -32031,
61            ControlErrorCode::ControlError => -32032,
62        }
63    }
64
65    /// The stable UPPER_SNAKE symbolic name a client branches on. Never derived from the message.
66    pub const fn name(self) -> &'static str {
67        match self {
68            ControlErrorCode::ParseError => "PARSE_ERROR",
69            ControlErrorCode::InvalidRequest => "INVALID_REQUEST",
70            ControlErrorCode::MethodNotFound => "METHOD_NOT_FOUND",
71            ControlErrorCode::InvalidParams => "INVALID_PARAMS",
72            ControlErrorCode::DispatchFailed => "DISPATCH_FAILED",
73            ControlErrorCode::Unauthorized => "UNAUTHORIZED",
74            ControlErrorCode::NotSupported => "NOT_SUPPORTED",
75            ControlErrorCode::ControlError => "CONTROL_ERROR",
76        }
77    }
78
79    /// Where the error originates, mirroring dig-node's classification.
80    pub const fn origin(self) -> &'static str {
81        match self {
82            ControlErrorCode::MethodNotFound => "boundary",
83            ControlErrorCode::InvalidParams => "node",
84            _ => "shell",
85        }
86    }
87
88    /// A one-line description for the error catalogue.
89    pub const fn description(self) -> &'static str {
90        match self {
91            ControlErrorCode::ParseError => "Request body was not valid JSON.",
92            ControlErrorCode::InvalidRequest => {
93                "Request was not a single JSON-RPC object (batch arrays are not supported)."
94            }
95            ControlErrorCode::MethodNotFound => "The control method is not resolved by the node.",
96            ControlErrorCode::InvalidParams => "Invalid or missing method parameters.",
97            ControlErrorCode::DispatchFailed => "The node failed to dispatch the control request.",
98            ControlErrorCode::Unauthorized => {
99                "A control.* method was called without a valid local control token."
100            }
101            ControlErrorCode::NotSupported => {
102                "The requested control operation is not supported on this node build."
103            }
104            ControlErrorCode::ControlError => "A control operation failed at runtime.",
105        }
106    }
107
108    /// Resolve a numeric wire code back to its variant, or `None` for an uncatalogued code.
109    pub fn from_code(code: i64) -> Option<ControlErrorCode> {
110        ControlErrorCode::ALL
111            .iter()
112            .copied()
113            .find(|c| c.code() == code)
114    }
115
116    /// Every catalogued code, for the error-catalogue document + drift tests.
117    pub const ALL: &'static [ControlErrorCode] = &[
118        ControlErrorCode::ParseError,
119        ControlErrorCode::InvalidRequest,
120        ControlErrorCode::MethodNotFound,
121        ControlErrorCode::InvalidParams,
122        ControlErrorCode::DispatchFailed,
123        ControlErrorCode::Unauthorized,
124        ControlErrorCode::NotSupported,
125        ControlErrorCode::ControlError,
126    ];
127}
128
129/// The machine-branchable inner data on a control error: the stable symbol + its origin.
130///
131/// Serializes to `{"code": "UNAUTHORIZED", "origin": "shell"}`, matching dig-node's `error.data`.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct ControlErrorData {
134    /// The stable UPPER_SNAKE symbol (e.g. `"UNAUTHORIZED"`) — see [`ControlErrorCode::name`].
135    pub code: String,
136    /// Where the error originated (`shell` / `node` / `boundary`) — see [`ControlErrorCode::origin`].
137    pub origin: String,
138}
139
140/// A control-plane error, serialized as the JSON-RPC `error` object
141/// (`{code, message, data:{code, origin}}`).
142///
143/// [`code`](ControlError::code) is the numeric wire value; [`data`](ControlError::data) carries the
144/// stable symbol + origin a client actually branches on.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct ControlError {
147    /// The numeric JSON-RPC error code.
148    pub code: i64,
149    /// A human-readable message (never contract-stable — for display/logs only).
150    pub message: String,
151    /// The machine-branchable symbol + origin.
152    pub data: ControlErrorData,
153}
154
155impl ControlError {
156    /// Build an error from a catalogued [`ControlErrorCode`] and a human message.
157    pub fn of(code: ControlErrorCode, message: impl Into<String>) -> ControlError {
158        ControlError {
159            code: code.code(),
160            message: message.into(),
161            data: ControlErrorData {
162                code: code.name().to_string(),
163                origin: code.origin().to_string(),
164            },
165        }
166    }
167
168    /// The catalogued code, resolved from the numeric wire value (`None` if uncatalogued).
169    pub fn code_enum(&self) -> Option<ControlErrorCode> {
170        ControlErrorCode::from_code(self.code)
171    }
172}
173
174impl std::fmt::Display for ControlError {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        write!(f, "{} ({}): {}", self.data.code, self.code, self.message)
177    }
178}
179
180impl std::error::Error for ControlError {}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn codes_and_symbols_and_origins_are_stable() {
188        assert_eq!(ControlErrorCode::Unauthorized.code(), -32030);
189        assert_eq!(ControlErrorCode::NotSupported.code(), -32031);
190        assert_eq!(ControlErrorCode::ControlError.code(), -32032);
191        assert_eq!(ControlErrorCode::InvalidParams.name(), "INVALID_PARAMS");
192        assert_eq!(ControlErrorCode::InvalidParams.origin(), "node");
193        assert_eq!(ControlErrorCode::MethodNotFound.origin(), "boundary");
194        assert_eq!(ControlErrorCode::Unauthorized.origin(), "shell");
195    }
196
197    #[test]
198    fn from_code_round_trips_every_code() {
199        for &c in ControlErrorCode::ALL {
200            assert_eq!(ControlErrorCode::from_code(c.code()), Some(c));
201        }
202        assert_eq!(ControlErrorCode::from_code(0), None);
203    }
204
205    #[test]
206    fn every_code_has_a_description() {
207        for &c in ControlErrorCode::ALL {
208            assert!(!c.description().is_empty());
209        }
210    }
211
212    #[test]
213    fn error_serializes_to_the_canonical_json_shape() {
214        let e = ControlError::of(ControlErrorCode::Unauthorized, "no token");
215        let v = serde_json::to_value(&e).unwrap();
216        assert_eq!(v["code"], -32030);
217        assert_eq!(v["message"], "no token");
218        assert_eq!(v["data"]["code"], "UNAUTHORIZED");
219        assert_eq!(v["data"]["origin"], "shell");
220        assert_eq!(e.code_enum(), Some(ControlErrorCode::Unauthorized));
221    }
222
223    #[test]
224    fn display_is_symbol_code_and_message() {
225        let e = ControlError::of(ControlErrorCode::ControlError, "boom");
226        assert_eq!(e.to_string(), "CONTROL_ERROR (-32032): boom");
227    }
228}