Skip to main content

mnm_core/
error.rs

1//! Typed error envelope used by every cloud HTTP response, MCP tool result,
2//! and CLI exit-with-error path.
3//!
4//! Spec: FR-030, Constitution V. The wire shape is intentionally additive — new
5//! `context` keys do NOT break old callers — and every variant ships a human-
6//! readable `remediation` field telling the caller exactly what to do next.
7
8use std::collections::BTreeMap;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13/// Canonical machine-readable error codes.
14///
15/// New variants are additive (MINOR bump per Constitution X). Removing or
16/// renaming a variant is a MAJOR contract break.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ErrorCode {
20    // -- 4xx client-side --
21    /// Generic 400 — payload failed validation. `context` should name the field.
22    InvalidRequest,
23    /// 400 — `queries.length > MIDNIGHT_MANUAL_MAX_QUERIES_PER_REQUEST` (D25, FR-088).
24    MultiQueryLimitExceeded,
25    /// 401 — JWT missing, signature invalid, or token expired. Remediation: `mnm login`.
26    Unauthorized,
27    /// 403 — caller authenticated but lacks the required role for this endpoint.
28    Forbidden,
29    /// 404 — resource not found (source slug, chunk id, etc.).
30    NotFound,
31    /// 409 — `client_embedding_model` disagrees with the corpus's active model (D12, FR-038).
32    EmbeddingModelMismatch,
33    /// 409 — write attempted against an `ingest_run` already in `aborted` state (FR-022).
34    RunAborted,
35    /// 429 — per-IP / SSO / CIDR rate-limit budget exhausted (D11, FR-031).
36    RateLimited,
37    // -- 5xx server-side --
38    /// 500 — internal invariant violated. Should be rare; logged with `request_id`.
39    Internal,
40    /// 503 — transient backend unavailability (DB down, model not yet loaded). Caller
41    /// should retry per the `Retry-After` header (Constitution VI, FR-035).
42    ServiceUnavailable,
43    /// 503 — MCP-tool-side: the local cloud client could not reach the server.
44    CloudUnreachable,
45    /// 503 — MCP-tool-side: local ML model file missing or corrupt; remediation: retry — the
46    /// reranker loads lazily on first use (or pre-fetch via `mnm models pull`).
47    ModelsMissing,
48    /// 500 — telemetry event failed schema validation; dropped server-side (FR-109).
49    TelemetrySchemaInvalid,
50}
51
52impl ErrorCode {
53    /// Returns the canonical `lower_snake_case` wire name.
54    #[must_use]
55    pub const fn as_str(self) -> &'static str {
56        match self {
57            Self::InvalidRequest => "invalid_request",
58            Self::MultiQueryLimitExceeded => "multi_query_limit_exceeded",
59            Self::Unauthorized => "unauthorized",
60            Self::Forbidden => "forbidden",
61            Self::NotFound => "not_found",
62            Self::EmbeddingModelMismatch => "embedding_model_mismatch",
63            Self::RunAborted => "run_aborted",
64            Self::RateLimited => "rate_limited",
65            Self::Internal => "internal",
66            Self::ServiceUnavailable => "service_unavailable",
67            Self::CloudUnreachable => "cloud_unreachable",
68            Self::ModelsMissing => "models_missing",
69            Self::TelemetrySchemaInvalid => "telemetry_schema_invalid",
70        }
71    }
72
73    /// HTTP status code mapping (server side only; CLI maps codes to exit codes).
74    #[must_use]
75    pub const fn http_status(self) -> u16 {
76        match self {
77            Self::InvalidRequest | Self::MultiQueryLimitExceeded => 400,
78            Self::Unauthorized => 401,
79            Self::Forbidden => 403,
80            Self::NotFound => 404,
81            Self::EmbeddingModelMismatch | Self::RunAborted => 409,
82            Self::RateLimited => 429,
83            Self::ServiceUnavailable | Self::CloudUnreachable | Self::ModelsMissing => 503,
84            Self::Internal | Self::TelemetrySchemaInvalid => 500,
85        }
86    }
87}
88
89impl fmt::Display for ErrorCode {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.write_str(self.as_str())
92    }
93}
94
95/// Per-error free-form context map.
96///
97/// Permissive on the wire (`serde_json::Value`) so error envelopes can carry
98/// arbitrary diagnostic context without a contract break. Builders should put
99/// the offending field name, the conflicting model identifier, the rate-limit
100/// reset time, etc. — never user input verbatim (Constitution VII).
101pub type ErrorContext = BTreeMap<String, serde_json::Value>;
102
103/// The wire-format error envelope: `{ error: { code, message, remediation, context } }`.
104///
105/// Always paired with an out-of-band `request_id` (returned in the `X-Request-Id`
106/// header or alongside the envelope at the response root). Constructed via
107/// [`Error::builder`].
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109pub struct Error {
110    /// Stable machine-readable code (see [`ErrorCode`]).
111    pub code: ErrorCode,
112    /// Operator-facing summary. One sentence, no trailing period.
113    pub message: String,
114    /// Concrete next step for the caller. Always populated (Constitution V).
115    pub remediation: String,
116    /// Optional structured diagnostic map; arbitrary additive keys allowed.
117    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
118    pub context: ErrorContext,
119}
120
121impl Error {
122    /// Start building an [`Error`] of the given [`ErrorCode`].
123    #[must_use]
124    pub const fn builder(code: ErrorCode) -> ErrorBuilder {
125        ErrorBuilder {
126            code,
127            message: None,
128            remediation: None,
129            context: ErrorContext::new(),
130        }
131    }
132
133    /// The HTTP status implied by this error's code.
134    #[must_use]
135    pub const fn http_status(&self) -> u16 {
136        self.code.http_status()
137    }
138}
139
140impl fmt::Display for Error {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        write!(f, "{}: {}", self.code, self.message)
143    }
144}
145
146impl std::error::Error for Error {}
147
148/// Builder for [`Error`]. Forces `message` + `remediation` to be set by panicking
149/// in `build()` if either is missing — every constructable error MUST be actionable.
150#[derive(Debug)]
151pub struct ErrorBuilder {
152    code: ErrorCode,
153    message: Option<String>,
154    remediation: Option<String>,
155    context: ErrorContext,
156}
157
158impl ErrorBuilder {
159    /// Set the operator-facing message.
160    #[must_use]
161    pub fn message(mut self, msg: impl Into<String>) -> Self {
162        self.message = Some(msg.into());
163        self
164    }
165
166    /// Set the caller-actionable remediation string.
167    #[must_use]
168    pub fn remediation(mut self, rem: impl Into<String>) -> Self {
169        self.remediation = Some(rem.into());
170        self
171    }
172
173    /// Attach an arbitrary diagnostic key/value to the error context.
174    #[must_use]
175    pub fn context(mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
176        self.context.insert(key.into(), value.into());
177        self
178    }
179
180    /// Finalize the builder.
181    ///
182    /// # Panics
183    ///
184    /// Panics if `message` or `remediation` was not set. This is a programmer
185    /// error (Constitution VI) — every error must be actionable.
186    #[must_use]
187    pub fn build(self) -> Error {
188        Error {
189            code: self.code,
190            message: self
191                .message
192                .expect("Error::builder requires .message() — every error must have a summary"),
193            remediation: self.remediation.expect(
194                "Error::builder requires .remediation() — every error must point at a next step",
195            ),
196            context: self.context,
197        }
198    }
199}
200
201/// Crate-local `Result` alias parameterized on the typed [`Error`].
202pub type Result<T> = std::result::Result<T, Error>;
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn code_round_trips_through_json() {
210        for code in [
211            ErrorCode::InvalidRequest,
212            ErrorCode::EmbeddingModelMismatch,
213            ErrorCode::RateLimited,
214            ErrorCode::ModelsMissing,
215        ] {
216            let s = serde_json::to_string(&code).unwrap();
217            let back: ErrorCode = serde_json::from_str(&s).unwrap();
218            assert_eq!(code, back);
219        }
220    }
221
222    #[test]
223    fn wire_shape_is_stable() {
224        let err = Error::builder(ErrorCode::EmbeddingModelMismatch)
225            .message("client model bge-small@1 does not match corpus model bge-base@1")
226            .remediation("run `mnm models pull` to fetch bge-base@1")
227            .context("corpus_model", "bge-base-en-v1.5@1")
228            .context("client_model", "bge-small-en-v1.5@1")
229            .build();
230
231        let v: serde_json::Value = serde_json::to_value(&err).unwrap();
232        assert_eq!(v["code"], "embedding_model_mismatch");
233        assert!(v["message"].is_string());
234        assert!(v["remediation"].is_string());
235        assert_eq!(v["context"]["corpus_model"], "bge-base-en-v1.5@1");
236    }
237
238    #[test]
239    fn http_status_mapping() {
240        assert_eq!(ErrorCode::InvalidRequest.http_status(), 400);
241        assert_eq!(ErrorCode::Unauthorized.http_status(), 401);
242        assert_eq!(ErrorCode::EmbeddingModelMismatch.http_status(), 409);
243        assert_eq!(ErrorCode::RateLimited.http_status(), 429);
244        assert_eq!(ErrorCode::ServiceUnavailable.http_status(), 503);
245        assert_eq!(ErrorCode::Internal.http_status(), 500);
246    }
247
248    #[test]
249    #[should_panic(expected = "requires .message()")]
250    fn builder_panics_without_message() {
251        let _ = Error::builder(ErrorCode::Internal)
252            .remediation("file an issue")
253            .build();
254    }
255
256    #[test]
257    #[should_panic(expected = "requires .remediation()")]
258    fn builder_panics_without_remediation() {
259        let _ = Error::builder(ErrorCode::Internal)
260            .message("something broke")
261            .build();
262    }
263
264    #[test]
265    fn empty_context_is_elided_from_wire() {
266        let err = Error::builder(ErrorCode::NotFound)
267            .message("source not found")
268            .remediation("check `mnm sources list`")
269            .build();
270        let v: serde_json::Value = serde_json::to_value(&err).unwrap();
271        assert!(v.get("context").is_none(), "empty context must be elided");
272    }
273}