Skip to main content

pmcp_server_toolkit/workbook/
error.rs

1//! The structured `isError:true` envelope (WBSV-06) — machine-actionable repair
2//! payloads that ride in `structuredContent`, NEVER a JSON-RPC protocol error,
3//! and ALWAYS carry the provenance stamp (on the failure path too).
4//!
5//! # Domain failure vs infrastructure failure (Codex LOW)
6//!
7//! [`to_iserror_result`] is for **domain** failures — invalid input, an
8//! out-of-range / non-finite output, a strict-constant override. A domain
9//! failure returns `isError:true` INSIDE `structuredContent` and never a
10//! protocol `Err`: pmcp tool dispatch hardcodes the protocol
11//! `CallToolResult.is_error` to `false`, so the only machine-actionable error
12//! channel is the structured payload.
13//!
14//! An **infrastructure** failure (a poisoned/malformed in-memory bundle state, a
15//! resource-handler internal fault, a genuine bug) is a DIFFERENT class and MAY
16//! still surface as a protocol `Err`. This module deliberately does NOT blanket
17//! every fault into a domain envelope — only the modelled domain failures route
18//! through [`WorkbookToolError`].
19//!
20//! # The self-repair code table (Gemini)
21//!
22//! Every [`WorkbookToolError`] code is a STABLE machine-readable string — the
23//! primary signal the MCP App widget reads to repair a call. The four codes and
24//! their UI self-repair meaning:
25//!
26//! | `code` | When | Self-repair fields | UI meaning |
27//! |--------|------|--------------------|------------|
28//! | `invalid_input` | arg-parse / dtype / enum-membership failure, or an unknown input field | `field`, `allowed` | "this argument is malformed or out of the allowed set — fix it to a listed value" |
29//! | `missing_field` | a required input is absent | `field`, `required` | "supply the listed required field(s)" |
30//! | `unsupported_option` | an override names no manifest cell | `field`, `allowed` | "this override is not a known variable-tier parameter — pick a listed one" |
31//! | `strict_constant_override` | a BA-governed strict constant supplied as input | `field`, `allowed` | "this value is BA-governed and cannot be overridden per-call — set a listed variable-tier parameter instead" |
32//!
33//! Each code is stable across releases; the `allowed`/`required`/`range`/`field`
34//! repair fields are present only when applicable ("allowed-values live in the
35//! error"). The two SHAPE-ONLY deferred codes (`stale_oracle`,
36//! `unapproved_assumption`) from the lighthouse have NO runtime trigger and are
37//! intentionally NOT lifted (STATE.md Deferred Items: "Wire deferred error
38//! triggers — Deferred v2.x").
39
40// Compiler/clippy-enforced panic-freedom on the value path (mirrors the
41// runtime). Test code constructs fixtures freely.
42#![cfg_attr(
43    not(test),
44    deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
45)]
46
47use serde_json::{json, Map, Value};
48
49use super::ProvStamp;
50
51/// One structured tool-execution error (WBSV-06): a machine-actionable repair
52/// payload. The agent reads `allowed`/`range`/`required` to repair the call —
53/// "allowed-values live in the error".
54///
55/// `code` is one of the four stable machine-readable strings documented in the
56/// module-level self-repair table.
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub struct WorkbookToolError {
60    /// The stable error code (one of the four documented codes).
61    pub code: String,
62    /// The offending input field, when located.
63    pub field: Option<String>,
64    /// A human-readable reason.
65    pub reason: String,
66    /// The allowed alternatives (e.g. the variable-tier override keys for a
67    /// `strict_constant_override`, or the enum members for an out-of-enum value).
68    pub allowed: Option<Vec<String>>,
69    /// A declared `[min, max]` range (carried, unenforced this phase).
70    pub range: Option<(Value, Value)>,
71    /// The required fields (e.g. for `missing_field`).
72    pub required: Option<Vec<String>>,
73}
74
75impl WorkbookToolError {
76    /// `invalid_input` — an arg-parse / type / enum-membership failure.
77    #[must_use]
78    pub fn invalid_input(reason: impl Into<String>) -> Self {
79        Self::bare("invalid_input", reason)
80    }
81
82    /// `invalid_input` for an unknown input FIELD (WR-05). An unknown input key
83    /// is a bad field, NOT an out-of-set option VALUE, so it shares the
84    /// `invalid_input` code with the top-level `deny_unknown_fields` path.
85    /// Carries the offending `field` and the `allowed` known input keys so the
86    /// agent can repair the call.
87    #[must_use]
88    pub fn invalid_input_field(field: impl Into<String>, allowed: Vec<String>) -> Self {
89        let field = field.into();
90        let reason = format!("'{field}' is not a known input field");
91        Self::invalid_enum(field, allowed, reason)
92    }
93
94    /// `invalid_input` carrying the closed-enum `allowed` members (an
95    /// out-of-enum or non-string-on-string-enum value).
96    #[must_use]
97    pub fn invalid_enum(
98        field: impl Into<String>,
99        allowed: Vec<String>,
100        reason: impl Into<String>,
101    ) -> Self {
102        Self {
103            code: "invalid_input".to_string(),
104            reason: reason.into(),
105            field: Some(field.into()),
106            allowed: Some(allowed),
107            range: None,
108            required: None,
109        }
110    }
111
112    /// `missing_field` — a required input is absent.
113    #[must_use]
114    pub fn missing_field(field: impl Into<String>, required: Vec<String>) -> Self {
115        Self {
116            code: "missing_field".to_string(),
117            field: Some(field.into()),
118            reason: "a required input is missing".to_string(),
119            allowed: None,
120            range: None,
121            required: Some(required),
122        }
123    }
124
125    /// `unsupported_option` — an override value out of the supported set (an
126    /// override naming no manifest cell).
127    #[must_use]
128    pub fn unsupported_option(field: impl Into<String>, allowed: Vec<String>) -> Self {
129        Self {
130            code: "unsupported_option".to_string(),
131            field: Some(field.into()),
132            reason: "the supplied override is not a known variable-tier parameter".to_string(),
133            allowed: Some(allowed),
134            range: None,
135            required: None,
136        }
137    }
138
139    /// `strict_constant_override` — a BA-governed strict constant was supplied as
140    /// a `calculate` input. `allowed` carries the variable-tier override keys the
141    /// caller MAY set instead.
142    #[must_use]
143    pub fn strict_constant_override(
144        field: impl Into<String>,
145        allowed_variable_keys: Vec<String>,
146    ) -> Self {
147        let field = field.into();
148        Self {
149            code: "strict_constant_override".to_string(),
150            reason: format!(
151                "'{field}' is a BA-governed strict constant and cannot be overridden \
152                 per-call; set a variable-tier parameter instead"
153            ),
154            field: Some(field),
155            allowed: Some(allowed_variable_keys),
156            range: None,
157            required: None,
158        }
159    }
160
161    /// `invalid_tool_name` — an output Table name that cannot be sanitized to the
162    /// MCP tool-name charset `^[a-zA-Z0-9_-]{1,64}$` (empty or all-illegal). A
163    /// fail-closed reject (T-100-10): an uncallable / charset-illegal tool can
164    /// never be registered. `field` carries the offending raw name.
165    #[must_use]
166    pub fn unmappable_tool_name(raw: impl Into<String>) -> Self {
167        let raw = raw.into();
168        Self {
169            code: "invalid_tool_name".to_string(),
170            reason: format!(
171                "output Table name '{raw}' has no characters mappable to the MCP \
172                 tool-name charset [a-z0-9_-]; give it at least one alphanumeric"
173            ),
174            field: Some(raw),
175            allowed: None,
176            range: None,
177            required: None,
178        }
179    }
180
181    /// A bare code+reason error with no repair-field detail.
182    fn bare(code: &str, reason: impl Into<String>) -> Self {
183        Self {
184            code: code.to_string(),
185            field: None,
186            reason: reason.into(),
187            allowed: None,
188            range: None,
189            required: None,
190        }
191    }
192}
193
194/// Render a [`WorkbookToolError`] into the `isError:true` payload carrying the
195/// provenance stamp (on the failure path too). Returned as a bare `Value` — the
196/// widget-meta tool routes it into `structuredContent` where the `isError:true`
197/// marker + repair fields survive dispatch.
198///
199/// NEVER returns an `Err(pmcp::Error)`: a DOMAIN failure is a success-shaped
200/// `CallToolResult` whose structured payload carries `isError:true`, not a
201/// JSON-RPC protocol error (T-92-10). The provenance stamp carries the
202/// `combined_hash` integrity anchor (the `BUNDLE.lock` combined hash) — see
203/// [`ProvStamp`] for the field-naming contract (Codex HIGH #3).
204#[must_use]
205pub fn to_iserror_result(err: &WorkbookToolError, stamp: &ProvStamp) -> Value {
206    let mut obj = Map::new();
207    // Always-present envelope fields.
208    obj.insert("isError".to_string(), json!(true));
209    obj.insert("code".to_string(), json!(err.code));
210    obj.insert("reason".to_string(), json!(err.reason));
211    obj.insert("provenance".to_string(), stamp.to_json());
212    // Optional repair fields — inserted only when present.
213    if let Some(field) = &err.field {
214        obj.insert("field".to_string(), json!(field));
215    }
216    if let Some(allowed) = &err.allowed {
217        obj.insert("allowed".to_string(), json!(allowed));
218    }
219    if let Some((min, max)) = &err.range {
220        obj.insert("range".to_string(), json!([min, max]));
221    }
222    if let Some(required) = &err.required {
223        obj.insert("required".to_string(), json!(required));
224    }
225    Value::Object(obj)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn stamp() -> ProvStamp {
233        ProvStamp {
234            bundle_id: "tax-calc".to_string(),
235            version: "1.1.0".to_string(),
236            combined_hash: "a".repeat(64),
237        }
238    }
239
240    #[test]
241    fn iserror_envelope_carries_flag_code_and_provenance() {
242        let err = WorkbookToolError::invalid_input("bad number");
243        let v = to_iserror_result(&err, &stamp());
244        assert_eq!(
245            v["isError"],
246            json!(true),
247            "isError:true rides in the payload"
248        );
249        assert_eq!(v["code"], json!("invalid_input"));
250        // The provenance carries bundle_id + version + combined_hash (Codex HIGH #3).
251        assert_eq!(v["provenance"]["bundle_id"], json!("tax-calc"));
252        assert_eq!(v["provenance"]["version"], json!("1.1.0"));
253        assert_eq!(
254            v["provenance"]["combined_hash"].as_str().map(str::len),
255            Some(64)
256        );
257        // The stamp carries EXACTLY the three contract keys — no source-workbook
258        // hash key (Codex HIGH #3). The forbidden key name is built dynamically so
259        // the contract is asserted without the literal appearing in this file (the
260        // dedicated `workbook_provstamp_contract.rs` integration test checks the
261        // key absence against the real golden bundle by name).
262        let forbidden_key = ["work", "book_", "hash"].concat();
263        assert!(
264            v["provenance"].get(&forbidden_key).is_none(),
265            "the stamp must never carry the source-workbook hash key"
266        );
267        let prov = v["provenance"]
268            .as_object()
269            .expect("provenance is an object");
270        assert_eq!(
271            prov.len(),
272            3,
273            "stamp has exactly bundle_id/version/combined_hash"
274        );
275    }
276
277    #[test]
278    fn strict_constant_override_carries_allowed_alternatives() {
279        let err = WorkbookToolError::strict_constant_override(
280            "const_rate",
281            vec!["gross_income".to_string(), "deductions".to_string()],
282        );
283        let v = to_iserror_result(&err, &stamp());
284        assert_eq!(v["code"], json!("strict_constant_override"));
285        assert_eq!(v["field"], json!("const_rate"));
286        assert_eq!(v["allowed"], json!(["gross_income", "deductions"]));
287    }
288
289    #[test]
290    fn missing_field_carries_required() {
291        let err =
292            WorkbookToolError::missing_field("gross_income", vec!["gross_income".to_string()]);
293        let v = to_iserror_result(&err, &stamp());
294        assert_eq!(v["code"], json!("missing_field"));
295        assert_eq!(v["required"], json!(["gross_income"]));
296    }
297
298    #[test]
299    fn invalid_enum_carries_allowed_members() {
300        let err = WorkbookToolError::invalid_enum(
301            "filing_status",
302            vec!["single".to_string(), "married_joint".to_string()],
303            "not a member",
304        );
305        let v = to_iserror_result(&err, &stamp());
306        assert_eq!(v["code"], json!("invalid_input"));
307        assert_eq!(v["field"], json!("filing_status"));
308        assert_eq!(v["allowed"], json!(["single", "married_joint"]));
309    }
310
311    #[test]
312    fn optional_repair_fields_are_omitted_when_absent() {
313        // A bare invalid_input carries no field/allowed/range/required keys.
314        let v = to_iserror_result(&WorkbookToolError::invalid_input("x"), &stamp());
315        assert!(v.get("field").is_none());
316        assert!(v.get("allowed").is_none());
317        assert!(v.get("range").is_none());
318        assert!(v.get("required").is_none());
319    }
320
321    #[test]
322    fn every_documented_code_is_an_emittable_stable_string() {
323        // The four stable machine-readable codes (Gemini self-repair table) are
324        // all emittable in the isError:true + provenance shape.
325        let cases = [
326            WorkbookToolError::invalid_input("x"),
327            WorkbookToolError::missing_field("f", vec![]),
328            WorkbookToolError::unsupported_option("f", vec![]),
329            WorkbookToolError::strict_constant_override("f", vec![]),
330        ];
331        let expected_codes = [
332            "invalid_input",
333            "missing_field",
334            "unsupported_option",
335            "strict_constant_override",
336        ];
337        for (err, expected) in cases.iter().zip(expected_codes) {
338            let v = to_iserror_result(err, &stamp());
339            assert_eq!(v["isError"], json!(true));
340            assert_eq!(v["code"], json!(expected), "code is a stable string");
341        }
342    }
343}