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}