Skip to main content

pmcp_server_toolkit/workbook/
handler.rs

1//! The curated workbook tool handlers (WBSV-01/02/03/04): the per-output-Table
2//! compute handler (WBV2-04 registers one NAMED tool per Table — the generic
3//! single `calculate` is retired), plus `explain`, `get_manifest`,
4//! `diff_version`.
5//!
6//! All are native [`pmcp::ToolHandler`] impls registered via `tool_arc` and
7//! [`pmcp::types::ToolInfo::with_ui`] (so the returned `Value` lands in
8//! `structuredContent`). Each attaches the provenance stamp and advertises a
9//! non-empty `outputSchema` (WBSV-07). Domain failures return the `isError:true`
10//! envelope via [`to_iserror_result`] — NEVER a protocol-level error (T-92-10).
11//!
12//! The per-Table [`WorkbookToolHandler`]s (WBV2-04) + `explain` re-run the
13//! SERVE-time [`pmcp_workbook_runtime::run_executor`] over the pre-built
14//! `bundle.dag` (no compiler, no second evaluator), seeding the `CellEnv` via the
15//! embedded `cell_map`. Each per-Table handler projects ONLY its own Table's
16//! outputs via [`project_tool_outputs`] — one named MCP tool per output Table.
17
18// Compiler/clippy-enforced panic-freedom on the value path (mirrors the runtime).
19#![cfg_attr(
20    not(test),
21    deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
22)]
23
24use std::collections::BTreeMap;
25use std::sync::Arc;
26
27use async_trait::async_trait;
28use pmcp::types::ToolInfo;
29use pmcp::{RequestHandlerExtra, ToolHandler};
30use serde_json::{json, Value};
31
32use pmcp_workbook_runtime::{run_executor, CellEnv, CellValue, RenderMode, RunResult, Tool};
33
34use super::error::{to_iserror_result, WorkbookToolError};
35use super::input::validate_input;
36use super::render_uri;
37use super::schema::{
38    diff_version_output_schema, empty_input_schema, explain_output_schema,
39    get_manifest_output_schema, input_schema_for_manifest, input_schema_for_tool,
40    output_schema_for_tool, render_input_schema_for_manifest, render_workbook_output_schema,
41    verify_accuracy_input_schema, verify_accuracy_output_schema,
42};
43use super::{ProvStamp, WorkbookBundle, WORKBOOK_TOOL_UI};
44
45// ---- Shared handler helpers (kept decomposed so each handler fn stays under
46//      cognitive complexity 25) -------------------------------------------------
47
48/// Re-run the embedded IR over the validated seeds and return the [`RunResult`].
49/// The per-cell DAG is the one built ONCE at bundle load (`bundle.dag`). A DAG
50/// cycle (impossible for a conforming bundle) surfaces as an `invalid_input`
51/// error rather than a panic.
52#[allow(clippy::result_large_err)]
53pub(crate) fn run_bundle(
54    bundle: &WorkbookBundle,
55    seeds: BTreeMap<String, Value>,
56) -> Result<RunResult, WorkbookToolError> {
57    let mut env = CellEnv::new();
58    for (key, value) in seeds {
59        env = env.with_value(key, value);
60    }
61    run_executor(&bundle.ir, &bundle.dag, &env).map_err(|f| {
62        WorkbookToolError::invalid_input(format!("executor failed: {} ({})", f.message, f.rule))
63    })
64}
65
66/// Project ONLY one tool's outputs into the typed `{ <json_key>: { value, unit } }`
67/// map (WBV2-04). Each output Table is its own MCP tool, so its handler projects
68/// exactly that Table's output cells — never the union across tools.
69///
70/// WR-04: fail closed on a declared-but-uncomputed output (a cell_map/IR skew).
71/// WR-06: every numeric output is finiteness-checked.
72#[allow(clippy::result_large_err)]
73pub(crate) fn project_tool_outputs(
74    tool: &Tool,
75    run: &RunResult,
76) -> Result<Value, WorkbookToolError> {
77    let mut outputs = serde_json::Map::new();
78    for entry in &tool.outputs {
79        let Some(value) = run.computed.get(&entry.seed_coord) else {
80            return Err(WorkbookToolError::invalid_input(format!(
81                "internal: declared output '{}' ({}) was not computed by the bundle IR",
82                entry.json_key, entry.seed_coord
83            )));
84        };
85        let projected = finite_output_value(value, &entry.seed_coord, &entry.json_key)?;
86        outputs.insert(
87            entry.json_key.clone(),
88            json!({ "value": projected, "unit": entry.unit }),
89        );
90    }
91    Ok(Value::Object(outputs))
92}
93
94/// Project one computed [`CellValue`] into its JSON `value`, finiteness-checking
95/// numbers (WR-06). A non-finite number is an error, NOT a JSON `null`.
96#[allow(clippy::result_large_err)]
97fn finite_output_value(
98    value: &CellValue,
99    seed_coord: &str,
100    json_key: &str,
101) -> Result<Value, WorkbookToolError> {
102    match value {
103        CellValue::Number(n) if n.is_finite() => Ok(json!(n)),
104        CellValue::Number(_) => Err(WorkbookToolError::invalid_input(format!(
105            "output cell {seed_coord} ({json_key}) did not compute to a finite number"
106        ))),
107        CellValue::Text(s) => Ok(json!(s)),
108        CellValue::Bool(b) => Ok(json!(b)),
109        CellValue::Empty => Ok(Value::Null),
110        CellValue::Error(e) => Err(WorkbookToolError::invalid_input(format!(
111            "output cell {seed_coord} ({json_key}) computed to an error: {e:?}"
112        ))),
113    }
114}
115
116/// Append the provenance stamp to a success payload object.
117pub(crate) fn with_provenance(mut payload: Value, stamp: &ProvStamp) -> Value {
118    if let Some(obj) = payload.as_object_mut() {
119        obj.insert("provenance".to_string(), stamp.to_json());
120    }
121    payload
122}
123
124/// Render a fallible compute pipeline once at the boundary: a domain failure
125/// becomes the `isError:true` envelope (in `structuredContent`), never a
126/// protocol-level error.
127#[allow(clippy::result_large_err)]
128pub(crate) fn render_at_boundary(
129    result: Result<Value, WorkbookToolError>,
130    stamp: &ProvStamp,
131) -> Value {
132    result.unwrap_or_else(|e| to_iserror_result(&e, stamp))
133}
134
135// ---- per-tool handler (WBV2-04) ----------------------------------------------
136
137/// Sanitize a raw output-Table name into an MCP tool name matching
138/// `^[a-zA-Z0-9_-]{1,64}$` (T-100-10), wrapping the SINGLE shared runtime
139/// sanitizer ([`pmcp_workbook_runtime::sanitize_tool_name`]) so the served
140/// registration and the offline compiler's collision lint cannot drift on the
141/// locked five-rule semantics (lowercase, illegal-run → single `_`, trim edges,
142/// truncate 64, reject empty/all-illegal). A reject becomes the fail-closed
143/// `invalid_tool_name` domain error.
144///
145/// # Errors
146/// Returns `Err(WorkbookToolError::unmappable_tool_name)` when the input has no
147/// character mappable to the charset (empty or all-illegal).
148#[allow(clippy::result_large_err)]
149pub fn sanitize_tool_name(raw: &str) -> Result<String, WorkbookToolError> {
150    pmcp_workbook_runtime::sanitize_tool_name(raw).map_err(WorkbookToolError::unmappable_tool_name)
151}
152
153/// One served MCP tool per output Table (WBV2-04): validate → seed via cell_map →
154/// re-run the embedded IR → project ONLY this tool's outputs (finite) → stamp.
155///
156/// Each handler advertises a per-tool I/O schema: an inputSchema carrying ONLY
157/// this tool's DAG-derived `input_keys`, and a non-empty outputSchema over this
158/// tool's own outputs (TypedToolWithOutput). The generic single `calculate` is
159/// retired (§4 — an LLM selects a NAMED tool per output Table).
160pub struct WorkbookToolHandler {
161    bundle: Arc<WorkbookBundle>,
162    tool: Tool,
163    stamp: ProvStamp,
164}
165
166impl WorkbookToolHandler {
167    /// Build over the shared verified bundle + this tool's projection.
168    #[must_use]
169    pub fn new(bundle: Arc<WorkbookBundle>, tool: Tool) -> Self {
170        let stamp = ProvStamp::from_bundle(&bundle);
171        Self {
172            bundle,
173            tool,
174            stamp,
175        }
176    }
177
178    /// The sanitized MCP tool name (the registered name + the metadata name —
179    /// ONE source so they cannot drift).
180    ///
181    /// # Errors
182    /// Returns `Err` if this tool's raw name is unmappable to the MCP charset.
183    #[allow(clippy::result_large_err)]
184    pub fn registered_name(&self) -> Result<String, WorkbookToolError> {
185        sanitize_tool_name(&self.tool.name)
186    }
187
188    /// The per-tool description (the output Table's caption), falling back to a
189    /// generic one when the Table carried no caption.
190    fn description(&self) -> String {
191        self.tool.description.clone().unwrap_or_else(|| {
192            format!(
193                "Compute the '{}' workbook outputs from the declared inputs by re-running \
194                 the compiled workbook IR. Returns each output as a units-bearing \
195                 {{ value, unit }} projection plus a provenance stamp. Strict \
196                 (BA-governed) constants cannot be overridden.",
197                self.tool.name
198            )
199        })
200    }
201
202    /// The linear `?`-chained per-tool pipeline: validate → re-run → project ONLY
203    /// this tool's outputs → stamp.
204    #[allow(clippy::result_large_err)]
205    fn compute(&self, args: Value) -> Result<Value, WorkbookToolError> {
206        let validated = validate_input(args, &self.bundle.manifest, &self.bundle.cell_map)?;
207        let run = run_bundle(&self.bundle, validated.seeds)?;
208        let outputs = project_tool_outputs(&self.tool, &run)?;
209        let payload = json!({
210            "outputs": outputs,
211            "accepted_overrides": validated.accepted_overrides,
212        });
213        Ok(with_provenance(payload, &self.stamp))
214    }
215}
216
217#[async_trait]
218impl ToolHandler for WorkbookToolHandler {
219    async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
220        Ok(render_at_boundary(self.compute(args), &self.stamp))
221    }
222
223    fn metadata(&self) -> Option<ToolInfo> {
224        // The sanitized name is the metadata name. If it is somehow unmappable
225        // (registration would have already rejected it), fall back to the raw
226        // name so metadata() stays infallible — registration is the fail-closed gate.
227        let name = self
228            .registered_name()
229            .unwrap_or_else(|_| self.tool.name.clone());
230        Some(
231            ToolInfo::with_ui(
232                name,
233                Some(self.description()),
234                input_schema_for_tool(&self.bundle.manifest, &self.bundle.cell_map, &self.tool),
235                WORKBOOK_TOOL_UI,
236            )
237            .with_output_schema(output_schema_for_tool(&self.bundle.manifest, &self.tool)),
238        )
239    }
240}
241
242// ---- explain -----------------------------------------------------------------
243
244/// A display projection of a [`CellValue`] for the explain trace.
245fn cell_value_display(v: &CellValue) -> Value {
246    match v {
247        CellValue::Number(n) => json!(n),
248        CellValue::Text(s) => json!(s),
249        CellValue::Bool(b) => json!(b),
250        CellValue::Empty => Value::Null,
251        CellValue::Error(e) => json!(format!("{e:?}")),
252    }
253}
254
255/// The `explain` handler (WBSV-02): a stateless re-run that renders the
256/// derivation trace as ordered business-language steps, plus a GENERIC
257/// manifest-declared `annotations` object (S-2 — any domain-specific keystone is
258/// generalized into manifest-declared annotations; the engine reads only
259/// `manifest.annotations` names, nothing domain-specific).
260pub struct ExplainHandler {
261    bundle: Arc<WorkbookBundle>,
262    stamp: ProvStamp,
263}
264
265impl ExplainHandler {
266    /// The registered tool name — the single source for registration + metadata.
267    pub const NAME: &str = "explain";
268
269    /// Build over the shared verified bundle.
270    #[must_use]
271    pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
272        let stamp = ProvStamp::from_bundle(&bundle);
273        Self { bundle, stamp }
274    }
275
276    /// The linear `?`-chained `explain` pipeline: validate → re-run → ordered
277    /// derivation steps + manifest annotations → stamp.
278    #[allow(clippy::result_large_err)]
279    fn compute(&self, args: Value) -> Result<Value, WorkbookToolError> {
280        let validated = validate_input(args, &self.bundle.manifest, &self.bundle.cell_map)?;
281        let run = run_bundle(&self.bundle, validated.seeds)?;
282        let steps = self.render_steps(&run);
283        let payload = json!({
284            "steps": steps,
285            "annotations": self.manifest_annotations(),
286        });
287        Ok(with_provenance(payload, &self.stamp))
288    }
289
290    /// Render the [`RunResult`] traces into ORDERED business-language steps
291    /// (sorted by cell key for determinism), each carrying the formula + operand
292    /// values + the manifest meaning.
293    fn render_steps(&self, run: &RunResult) -> Vec<Value> {
294        let mut entries: Vec<_> = run.traces.iter().collect();
295        entries.sort_by(|a, b| a.0.cmp(b.0));
296        let mut steps = Vec::with_capacity(entries.len());
297        for (key, trace) in entries {
298            steps.push(json!({
299                "step": "derivation",
300                "cell": key,
301                "meaning": self.meaning_for(key),
302                "formula": trace.formula,
303                "dispatched_fn": trace.dispatched_fn,
304                "resolved_refs": trace.resolved_refs.iter().map(|(k, v)| json!({
305                    "cell": k,
306                    "value": cell_value_display(v),
307                })).collect::<Vec<_>>(),
308                "result": run.computed.get(key).map(cell_value_display),
309            }));
310        }
311        steps
312    }
313
314    /// The GENERIC manifest-declared annotations object (S-2): keyed by each
315    /// [`pmcp_workbook_runtime::AnnotationDecl`] `name`, carrying its `target` +
316    /// `meaning`. The engine reads ONLY manifest-declared names — nothing
317    /// domain-specific.
318    fn manifest_annotations(&self) -> Value {
319        let mut obj = serde_json::Map::new();
320        for ann in &self.bundle.manifest.annotations {
321            obj.insert(
322                ann.name.clone(),
323                json!({ "target": ann.target, "meaning": ann.meaning }),
324            );
325        }
326        Value::Object(obj)
327    }
328
329    /// The manifest meaning for a cell key (for the business-language prose).
330    fn meaning_for(&self, key: &str) -> Option<String> {
331        pmcp_workbook_runtime::role_for_cell(&self.bundle.manifest, key)
332            .and_then(|c| c.meaning.clone().or_else(|| c.name.clone()))
333    }
334}
335
336#[async_trait]
337impl ToolHandler for ExplainHandler {
338    async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
339        Ok(render_at_boundary(self.compute(args), &self.stamp))
340    }
341
342    fn metadata(&self) -> Option<ToolInfo> {
343        Some(
344            ToolInfo::with_ui(
345                Self::NAME,
346                Some(
347                    "Explain the computed workbook outputs: an ordered business-language \
348                     derivation trace (formula + operands + meaning per step) plus a \
349                     manifest-declared annotations object. Stamped + stateless (re-run \
350                     from the same inputs)."
351                        .into(),
352                ),
353                input_schema_for_manifest(&self.bundle.manifest, &self.bundle.cell_map),
354                WORKBOOK_TOOL_UI,
355            )
356            .with_output_schema(explain_output_schema()),
357        )
358    }
359}
360
361// ---- get_manifest ------------------------------------------------------------
362
363/// The `get_manifest` handler (WBSV-03): a CURATED agent-facing projection —
364/// inputs (tier+default+unit), outputs (unit/meaning), governed-data summary,
365/// versions/hashes, changelog — NOT the raw internal manifest.
366pub struct GetManifestHandler {
367    bundle: Arc<WorkbookBundle>,
368    stamp: ProvStamp,
369}
370
371impl GetManifestHandler {
372    /// The registered tool name — the single source for registration + metadata.
373    pub const NAME: &str = "get_manifest";
374
375    /// Build over the shared verified bundle.
376    #[must_use]
377    pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
378        let stamp = ProvStamp::from_bundle(&bundle);
379        Self { bundle, stamp }
380    }
381}
382
383/// Project one manifest input cell into its curated agent-facing record (M5).
384///
385/// The advertised `name` is the STRIPPED served key
386/// ([`json_key_for_role`](pmcp_workbook_runtime::json_key_for_role)) — the SAME key
387/// the served tool schema (`input_schema_for_tool`) advertises and `validate_input`
388/// accepts — so an agent that reads `get_manifest` then calls the tool with the
389/// discovered name is NOT rejected. The raw prefixed `role.name` (`in_income`) is kept
390/// only as internal `governance_name` for the named-range/governance audit trail.
391fn input_projection(role: &pmcp_workbook_runtime::CellRole) -> Value {
392    use pmcp_workbook_runtime::{json_key_for_role, InputTier};
393    let (tier_kind, default) = match &role.tier {
394        Some(InputTier::Variable { default }) => ("variable", cell_value_display(default)),
395        Some(InputTier::BoundedVariable { default, .. }) => {
396            ("bounded_variable", cell_value_display(default))
397        },
398        None => ("variable", Value::Null),
399    };
400    json!({
401        "name": json_key_for_role(role),
402        "governance_name": role.name,
403        "unit": role.unit,
404        "meaning": role.meaning,
405        "tier": tier_kind,
406        "default": default,
407    })
408}
409
410/// Build the curated agent-facing manifest projection (WBSV-03) + stamp.
411///
412/// M5: BOTH the input and output projections advertise the STRIPPED served key (the
413/// `json_key`) as `name`, so the discovery surface == the call surface — never the
414/// raw `in_`/`out_` prefixed name (which is kept only as `governance_name`).
415fn curated_manifest(bundle: &WorkbookBundle, stamp: &ProvStamp) -> Value {
416    use pmcp_workbook_runtime::{json_key_for_role, Role};
417
418    let mut inputs = Vec::new();
419    let mut outputs = Vec::new();
420    for role in &bundle.manifest.cells {
421        match role.role {
422            Role::Input => inputs.push(input_projection(role)),
423            Role::Output => outputs.push(json!({
424                "name": json_key_for_role(role),
425                "governance_name": role.name,
426                "unit": role.unit,
427                "meaning": role.meaning,
428            })),
429            Role::Constant | Role::Formula => {},
430        }
431    }
432
433    let governed: Vec<Value> = bundle
434        .manifest
435        .governed_data
436        .iter()
437        .map(|g| {
438            json!({
439                "key": g.key,
440                "value": cell_value_display(&g.value),
441                "approved_by": g.approved_by,
442                "provenance": g.provenance,
443            })
444        })
445        .collect();
446
447    let changelog: Vec<Value> = bundle
448        .manifest
449        .changelog
450        .iter()
451        .map(|c| json!({ "version": c.version, "note": c.note }))
452        .collect();
453
454    json!({
455        "bundle_id": stamp.bundle_id,
456        "version": stamp.version,
457        "combined_hash": stamp.combined_hash,
458        "inputs": inputs,
459        "outputs": outputs,
460        "governed_data": governed,
461        "changelog": changelog,
462        "provenance": stamp.to_json(),
463    })
464}
465
466#[async_trait]
467impl ToolHandler for GetManifestHandler {
468    async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
469        Ok(curated_manifest(&self.bundle, &self.stamp))
470    }
471
472    fn metadata(&self) -> Option<ToolInfo> {
473        Some(
474            ToolInfo::with_ui(
475                Self::NAME,
476                Some(
477                    "Describe the compiled workbook workflow: a curated agent-facing \
478                     manifest projection (inputs with tier/default/unit, outputs with \
479                     unit/meaning, governed-data summary, version/hashes, changelog) + \
480                     provenance stamp."
481                        .into(),
482                ),
483                empty_input_schema(),
484                WORKBOOK_TOOL_UI,
485            )
486            .with_output_schema(get_manifest_output_schema()),
487        )
488    }
489}
490
491// ---- diff_version ------------------------------------------------------------
492
493/// The `diff_version` handler (WBSV-04): serve the RECORDED prev→current
494/// [`pmcp_workbook_runtime::VersionChangelog`] the offline promote step folded
495/// into the bundle (hash-verified at boot — NOT a runtime computation), stamped.
496pub struct DiffVersionHandler {
497    bundle: Arc<WorkbookBundle>,
498    stamp: ProvStamp,
499}
500
501impl DiffVersionHandler {
502    /// The registered tool name — the single source for registration + metadata.
503    pub const NAME: &str = "diff_version";
504
505    /// Build over the shared verified bundle.
506    #[must_use]
507    pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
508        let stamp = ProvStamp::from_bundle(&bundle);
509        Self { bundle, stamp }
510    }
511}
512
513/// Serialize the recorded [`pmcp_workbook_runtime::VersionChangelog`] into the
514/// served structured payload. Infallible — the changelog was hash-verified and
515/// parsed at boot, so serving it cannot fail.
516fn serve_changelog(bundle: &WorkbookBundle, stamp: &ProvStamp) -> Value {
517    let cl = &bundle.changelog;
518    let deltas: Vec<Value> = cl.deltas.iter().map(delta_to_json).collect();
519    let payload = json!({
520        "from_version": cl.from_version,
521        "to_version": cl.to_version,
522        "deltas": deltas,
523        "summary": cl.summary,
524    });
525    with_provenance(payload, stamp)
526}
527
528/// Project one [`pmcp_workbook_runtime::OutputDelta`] into its served JSON shape.
529fn delta_to_json(delta: &pmcp_workbook_runtime::OutputDelta) -> Value {
530    json!({
531        "region": delta.region,
532        "change_class": delta.change_class,
533        "old": meta_to_json(&delta.old),
534        "new": meta_to_json(&delta.new),
535        "severity": delta.severity,
536    })
537}
538
539/// Project one [`pmcp_workbook_runtime::OutputMeta`] into its served JSON.
540fn meta_to_json(meta: &pmcp_workbook_runtime::OutputMeta) -> Value {
541    json!({
542        "meaning": meta.meaning,
543        "unit": meta.unit,
544        "provenance": meta.provenance,
545    })
546}
547
548#[async_trait]
549impl ToolHandler for DiffVersionHandler {
550    async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
551        Ok(serve_changelog(&self.bundle, &self.stamp))
552    }
553
554    fn metadata(&self) -> Option<ToolInfo> {
555        Some(
556            ToolInfo::with_ui(
557                Self::NAME,
558                Some(
559                    "Describe what changed between two promoted workflow versions: the \
560                     RECORDED, hash-verified prev→current changelog (per-output deltas \
561                     with change class + drift/redefinition severity + a human-readable \
562                     summary) + a provenance stamp. Served from the bundle's recorded \
563                     evidence, not a runtime computation."
564                        .into(),
565                ),
566                empty_input_schema(),
567                WORKBOOK_TOOL_UI,
568            )
569            .with_output_schema(diff_version_output_schema()),
570        )
571    }
572}
573
574// ---- render_workbook ---------------------------------------------------------
575
576/// The `render_workbook` handler (WBSV-05): validate the inputs, then return a
577/// provenance-bound `workbook://` URI POINTER — NOT the `.xlsx` bytes. The bytes
578/// are recomputed per `resources/read` by [`super::render_resource`] from the
579/// decoded URI (stateless regen-on-read, Lambda-safe, V3).
580///
581/// The URI encodes the canonical inputs + the bundle [`ProvStamp`]
582/// (`combined_hash`, Codex HIGH #3) via [`render_uri::encode`]. A domain failure
583/// (invalid input, an un-encodable payload) routes through [`to_iserror_result`]
584/// into `structuredContent` — never a protocol-level error (T-92-10).
585pub struct RenderWorkbookHandler {
586    bundle: Arc<WorkbookBundle>,
587    stamp: ProvStamp,
588}
589
590impl RenderWorkbookHandler {
591    /// The registered tool name — the single source for registration + metadata.
592    pub const NAME: &str = "render_workbook";
593
594    /// Build over the shared verified bundle.
595    #[must_use]
596    pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
597        let stamp = ProvStamp::from_bundle(&bundle);
598        Self { bundle, stamp }
599    }
600
601    /// The linear `?`-chained `render_workbook` pipeline: parse+strip the
602    /// render-only `mode` arg → validate the REMAINING inputs → encode the
603    /// canonical DTO + provenance + mode into a `workbook://` URI → return the
604    /// POINTER (plus the stamp), NOT the bytes.
605    #[allow(clippy::result_large_err)]
606    fn compute(&self, mut args: Value) -> Result<Value, WorkbookToolError> {
607        // WBVER-02: lift `mode` out FIRST (CalculateInput is deny_unknown_fields,
608        // so a `mode` key would otherwise be rejected). An unknown value is an Err
609        // here, NOT a validate_input rejection of the remaining inputs.
610        let mode = parse_render_mode(&mut args)?;
611        let validated = validate_input(args, &self.bundle.manifest, &self.bundle.cell_map)?;
612        let uri = render_uri::encode(&validated.canonical_dto, &self.stamp, mode)?;
613        let payload = json!({
614            "resource_uri": uri,
615            "mime_type": render_uri::WORKBOOK_XLSX_MIME,
616        });
617        Ok(with_provenance(payload, &self.stamp))
618    }
619}
620
621/// Lift the render-only `mode` arg out of the raw `render_workbook` args and map
622/// it to a [`RenderMode`], REMOVING the key so the remaining `{inputs, overrides}`
623/// passes `validate_input`'s `deny_unknown_fields` (WBVER-02).
624///
625/// Mapping: absent / `null` → [`RenderMode::Filled`]; `"filled"` → `Filled`;
626/// `"inputs_only"` → `InputsOnly`; ANY other value → `Err` (the locked
627/// "unknown mode → Err" decision). Total + panic-free (no unwrap/expect).
628#[allow(clippy::result_large_err)]
629fn parse_render_mode(args: &mut Value) -> Result<RenderMode, WorkbookToolError> {
630    let Some(obj) = args.as_object_mut() else {
631        // Non-object args carry no `mode`; let validate_input report the shape.
632        return Ok(RenderMode::Filled);
633    };
634    let Some(raw) = obj.remove("mode") else {
635        return Ok(RenderMode::Filled); // absent → Filled
636    };
637    match raw {
638        Value::Null => Ok(RenderMode::Filled),
639        Value::String(s) if s == "filled" => Ok(RenderMode::Filled),
640        Value::String(s) if s == "inputs_only" => Ok(RenderMode::InputsOnly),
641        other => Err(WorkbookToolError::invalid_input(format!(
642            "unknown render mode {other}; expected \"filled\" or \"inputs_only\""
643        ))),
644    }
645}
646
647#[async_trait]
648impl ToolHandler for RenderWorkbookHandler {
649    async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
650        Ok(render_at_boundary(self.compute(args), &self.stamp))
651    }
652
653    fn metadata(&self) -> Option<ToolInfo> {
654        Some(
655            ToolInfo::with_ui(
656                Self::NAME,
657                Some(
658                    "Render the computed workbook to a downloadable .xlsx. Returns a \
659                     provenance-bound workbook:// resource URI (NOT the bytes) — read \
660                     that URI via resources/read to obtain the base64-encoded .xlsx, \
661                     which is regenerated statelessly from the URI on each read. The URI \
662                     encodes the inputs; treat it as sensitive."
663                        .into(),
664                ),
665                render_input_schema_for_manifest(&self.bundle.manifest, &self.bundle.cell_map),
666                WORKBOOK_TOOL_UI,
667            )
668            .with_output_schema(render_workbook_output_schema()),
669        )
670    }
671}
672
673// ---- verify_accuracy (WBVER-03) ----------------------------------------------
674
675/// The `verify_accuracy` meta-tool (the 6th served tool): re-run the executor at
676/// the workbook's REFERENCE inputs (the manifest tier defaults — verified the
677/// oracle was computed there) and return a per-output [`ReconcileReport`] vs each
678/// `Tool.oracle` within `TOL`.
679///
680/// This makes the compile-time penny-reconcile RUNTIME-inspectable: a queryable,
681/// HONESTLY-framed attestation that the served engine reproduces Excel's authored
682/// values at the reference inputs. It does NOT attest arbitrary inputs — for those
683/// the BA downloads the formula workbook via `render_workbook` (`filled` /
684/// `inputs_only`), where Excel is the oracle.
685///
686/// Reader-free + stateless: calls the pure
687/// [`pmcp_workbook_runtime::reconcile_reference`] over the in-memory bundle (no
688/// reader, no toolkit-side seeding, no caller-supplied seeds). An optional
689/// `tool`-name filter scopes the report; an unknown filter is an `Err` listing the
690/// available tool names (D-03) — never a silent empty pass.
691pub struct VerifyAccuracyHandler {
692    bundle: Arc<WorkbookBundle>,
693    stamp: ProvStamp,
694}
695
696impl VerifyAccuracyHandler {
697    /// The registered tool name — the single source for registration + the H3
698    /// binding test + metadata.
699    pub const NAME: &str = "verify_accuracy";
700
701    /// Build over the shared verified bundle.
702    #[must_use]
703    pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
704        let stamp = ProvStamp::from_bundle(&bundle);
705        Self { bundle, stamp }
706    }
707
708    /// The `verify_accuracy` pipeline: parse the optional tool filter (D-03) →
709    /// reconcile at the reference inputs → optionally scope to the filtered tool
710    /// (recomputing the top-level aggregates over the FILTERED set) → stamp.
711    #[allow(clippy::result_large_err)]
712    fn compute(&self, args: Value) -> Result<Value, WorkbookToolError> {
713        let filter = parse_tool_filter(&args)?;
714        if let Some(name) = filter.as_deref() {
715            self.ensure_known_tool(name)?;
716        }
717
718        let report = pmcp_workbook_runtime::reconcile_reference(
719            &self.bundle.cell_map,
720            &self.bundle.manifest,
721            &self.bundle.ir,
722            &self.bundle.dag,
723            pmcp_workbook_runtime::reconcile::TOL,
724        )
725        .map_err(|f| {
726            WorkbookToolError::invalid_input(format!(
727                "reconcile failed: {} ({})",
728                f.message, f.rule
729            ))
730        })?;
731
732        let scoped = scope_report(report, filter.as_deref());
733        let payload = serde_json::to_value(&scoped).map_err(|e| {
734            WorkbookToolError::invalid_input(format!("internal: report not serializable: {e}"))
735        })?;
736        Ok(with_provenance(payload, &self.stamp))
737    }
738
739    /// D-03: a `tool`-name filter that names no registered tool is an `Err`
740    /// listing the available tool names (never a silent empty report). The
741    /// available names are the bundle's per-Table tool names.
742    #[allow(clippy::result_large_err)]
743    fn ensure_known_tool(&self, name: &str) -> Result<(), WorkbookToolError> {
744        if self.bundle.cell_map.tools.iter().any(|t| t.name == name) {
745            return Ok(());
746        }
747        let available: Vec<String> = self
748            .bundle
749            .cell_map
750            .tools
751            .iter()
752            .map(|t| t.name.clone())
753            .collect();
754        Err(WorkbookToolError::invalid_enum(
755            "tool",
756            available,
757            format!("unknown tool '{name}'; verify_accuracy accepts only a registered tool name"),
758        ))
759    }
760}
761
762/// Lift the OPTIONAL `tool`-name filter from the raw `verify_accuracy` args.
763///
764/// Absent / `null` → no filter (all tools). A `tool` string → that filter. A
765/// non-string `tool` value → `Err` (panic-free; the locked `deny(panic)`
766/// discipline). Any other top-level key is ignored — `verify_accuracy` has no
767/// other inputs.
768#[allow(clippy::result_large_err)]
769fn parse_tool_filter(args: &Value) -> Result<Option<String>, WorkbookToolError> {
770    let Some(obj) = args.as_object() else {
771        return Ok(None); // non-object args carry no filter
772    };
773    match obj.get("tool") {
774        None | Some(Value::Null) => Ok(None),
775        Some(Value::String(s)) => Ok(Some(s.clone())),
776        Some(other) => Err(WorkbookToolError::invalid_input(format!(
777            "the 'tool' filter must be a string tool name, got {other}"
778        ))),
779    }
780}
781
782/// Scope a full [`pmcp_workbook_runtime::ReconcileReport`] to a single named tool
783/// (D-03 caller guarantees the name exists), RECOMPUTING the top-level
784/// `cells_checked` + `all_within_tol` over the FILTERED set so a partial filter
785/// never leaves stale full-bundle aggregates (MEDIUM #4 / T-100-08). With no
786/// filter the report is returned unchanged.
787fn scope_report(
788    report: pmcp_workbook_runtime::ReconcileReport,
789    filter: Option<&str>,
790) -> pmcp_workbook_runtime::ReconcileReport {
791    let Some(name) = filter else {
792        return report;
793    };
794    let tools: Vec<_> = report
795        .tools
796        .into_iter()
797        .filter(|t| t.tool == name)
798        .collect();
799    let cells_checked = tools
800        .iter()
801        .map(|t| u32::try_from(t.outputs.len()).unwrap_or(u32::MAX))
802        .fold(0u32, u32::saturating_add);
803    let all_within_tol = tools.iter().all(|t| t.all_within_tol);
804    pmcp_workbook_runtime::ReconcileReport {
805        tolerance: report.tolerance,
806        all_within_tol,
807        cells_checked,
808        tools,
809    }
810}
811
812#[async_trait]
813impl ToolHandler for VerifyAccuracyHandler {
814    async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
815        Ok(render_at_boundary(self.compute(args), &self.stamp))
816    }
817
818    fn metadata(&self) -> Option<ToolInfo> {
819        Some(
820            ToolInfo::with_ui(
821                Self::NAME,
822                Some(
823                    "Verify the served engine reproduces the workbook's authored \
824                     (Excel-cached) output values AT THE REFERENCE INPUTS — the \
825                     compile-time penny-reconcile, made runtime-inspectable. Returns a \
826                     per-output report (server value vs authored oracle, abs delta, \
827                     within-tolerance) plus rollup flags, stamped + stateless. This \
828                     attests ONLY the reference point; for arbitrary inputs download the \
829                     formula workbook via render_workbook (filled / inputs_only) where \
830                     Excel is the oracle. Optional 'tool' filter scopes the report to one \
831                     tool (an unknown name returns an error listing the available tools)."
832                        .into(),
833                ),
834                verify_accuracy_input_schema(),
835                WORKBOOK_TOOL_UI,
836            )
837            .with_output_schema(verify_accuracy_output_schema()),
838        )
839    }
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845    use std::path::{Path, PathBuf};
846
847    use pmcp_workbook_runtime::{load_bundle, LocalDirSource};
848
849    fn golden_dir() -> PathBuf {
850        Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tax-calc@1.1.0")
851    }
852
853    fn golden_bundle() -> Arc<WorkbookBundle> {
854        let source = LocalDirSource::new(golden_dir());
855        Arc::new(load_bundle(&source).expect("golden bundle boots"))
856    }
857
858    /// A per-tool handler over the golden's FIRST output Table (the multi-tool
859    /// model lift — Plan 04). The served compute path is shared across tools, so a
860    /// handler over the first tool exercises the same validate→run→project pipeline
861    /// the single `calculate` handler used to.
862    fn calc_handler() -> WorkbookToolHandler {
863        let bundle = golden_bundle();
864        let tool = bundle.cell_map.tools[0].clone();
865        WorkbookToolHandler::new(bundle, tool)
866    }
867
868    /// H3 BINDING: the reserved-tool-name set the offline compiler rejects against
869    /// (`RESERVED_TOOL_NAMES`, in the runtime leaf) is EXACTLY the meta tools this
870    /// toolkit registers — derived from their `NAME` constants. If a handler's `NAME`
871    /// ever changes (or a meta tool is added) WITHOUT updating the shared const, this
872    /// binding test fails, so the compiler gate cannot silently drift from what is
873    /// registered.
874    #[test]
875    fn reserved_tool_names_match_the_registered_meta_tool_names() {
876        let registered = [
877            ExplainHandler::NAME,
878            GetManifestHandler::NAME,
879            DiffVersionHandler::NAME,
880            RenderWorkbookHandler::NAME,
881            // Plan 04 (this plan) landed the handler: the Plan-01 placeholder string
882            // literal is now the real `VerifyAccuracyHandler::NAME` constant, so this
883            // binding test derives from the registered handler (H3 — never hand-copy)
884            // and the drift Plan 01 deliberately left is closed.
885            VerifyAccuracyHandler::NAME,
886        ];
887        assert_eq!(
888            pmcp_workbook_runtime::RESERVED_TOOL_NAMES,
889            registered,
890            "the shared RESERVED_TOOL_NAMES const must equal the registered meta tool \
891             NAME constants (H3 — derive, never hand-copy)"
892        );
893    }
894
895    #[test]
896    fn calculate_returns_tool_outputs_with_provenance_no_headline() {
897        let handler = calc_handler();
898        let v = handler
899            .compute(json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }))
900            .expect("calculate succeeds");
901
902        // Each of this tool's named outputs is present as a { value, unit } pair.
903        // The Calculate_Tax tool now projects numeric outputs PLUS the WBVER-01/D-07
904        // text (bracket_label) and bool (is_taxable) formula outputs, so a value may
905        // be a number, string, bool, or null (an Empty cell).
906        let outputs = v["outputs"].as_object().expect("outputs is an object");
907        assert!(!outputs.is_empty(), "the tool projects its outputs");
908        for (_key, col) in outputs {
909            let val = &col["value"];
910            assert!(
911                val.is_number() || val.is_string() || val.is_boolean() || val.is_null(),
912                "each output carries a value (number/text/bool/null)"
913            );
914        }
915        // The text + bool formula outputs project their authored cached values at
916        // the reference inputs (WBVER-01 / D-07).
917        assert_eq!(
918            outputs["bracket_label"]["value"],
919            json!("bracket_2"),
920            "the text formula output computes its authored oracle"
921        );
922        assert_eq!(
923            outputs["is_taxable"]["value"],
924            json!(true),
925            "the bool formula output computes its authored oracle"
926        );
927        // S-1: the success payload has EXACTLY outputs/accepted_overrides/
928        // provenance — no privileged headline scalar elevated above the
929        // uniform all-outputs projection.
930        let root = v.as_object().expect("payload is an object");
931        let mut top_keys: Vec<&str> = root.keys().map(String::as_str).collect();
932        top_keys.sort_unstable();
933        assert_eq!(
934            top_keys,
935            ["accepted_overrides", "outputs", "provenance"],
936            "no headline field elevated above the all-outputs projection (S-1)"
937        );
938        // Provenance stamp on every result.
939        assert!(v["provenance"]["combined_hash"].is_string());
940        assert!(v.get("isError").is_none(), "a success is not an error");
941    }
942
943    #[test]
944    fn calculate_honors_non_default_input() {
945        // CR-01: a caller-supplied input MUST drive the computation, not be
946        // silently discarded in favour of the bundle's baked-in default
947        // (gross_income=60000).
948        let handler = calc_handler();
949
950        // gross_income 100000, default deduction 12000 => taxable_income 88000.
951        let v = handler
952            .compute(json!({ "inputs": { "gross_income": 100000.0 } }))
953            .expect("calculate honors a non-default gross_income");
954        assert_eq!(
955            v["outputs"]["taxable_income"]["value"],
956            json!(88000.0),
957            "taxable_income reflects the caller's gross_income (100000 - 12000), not the default"
958        );
959
960        // A DIFFERENT non-default input also flows (guards against a single-value
961        // coincidence): gross_income 80000 - 12000 => 68000.
962        let v = handler
963            .compute(json!({ "inputs": { "gross_income": 80000.0 } }))
964            .expect("calculate honors a second non-default gross_income");
965        assert_eq!(
966            v["outputs"]["taxable_income"]["value"],
967            json!(68000.0),
968            "a second caller input flows through (80000 - 12000)"
969        );
970    }
971
972    #[test]
973    fn calculate_invalid_input_returns_iserror_in_structured_content() {
974        let bundle = golden_bundle();
975        let tool = bundle.cell_map.tools[0].clone();
976        let handler = WorkbookToolHandler::new(bundle.clone(), tool);
977        // An out-of-enum filing_status is a domain failure.
978        let v = render_at_boundary(
979            handler.compute(json!({ "inputs": { "filing_status": "alien" } })),
980            &ProvStamp::from_bundle(&bundle),
981        );
982        assert_eq!(
983            v["isError"],
984            json!(true),
985            "isError rides in structuredContent"
986        );
987        assert_eq!(v["code"], json!("invalid_input"));
988        assert!(v["provenance"]["combined_hash"].is_string());
989    }
990
991    #[test]
992    fn non_finite_output_surfaces_as_error_not_null() {
993        // WR-06: a non-finite f64 must surface as an error, never JSON null.
994        let err = finite_output_value(&CellValue::Number(f64::NAN), "3_Outputs!B3", "tax_owed")
995            .expect_err("NaN is rejected (WR-06)");
996        assert_eq!(err.code, "invalid_input");
997        let err = finite_output_value(&CellValue::Number(f64::INFINITY), "c", "k")
998            .expect_err("Infinity is rejected (WR-06)");
999        assert_eq!(err.code, "invalid_input");
1000        // A finite number projects fine.
1001        let ok = finite_output_value(&CellValue::Number(42.0), "c", "k").expect("finite ok");
1002        assert_eq!(ok, json!(42.0));
1003    }
1004
1005    #[test]
1006    fn project_tool_outputs_fails_closed_on_missing_declared_output() {
1007        // WR-04: a declared output (verified in cell_map at boot) absent from the run
1008        // result is a cell_map/IR skew, NOT a success. project_tool_outputs must fail
1009        // closed with invalid_input so the served payload can never silently diverge
1010        // from the advertised outputSchema (WBSV-07) — never an `else { continue }`.
1011        let bundle = golden_bundle();
1012        let tool = &bundle.cell_map.tools[0];
1013        // A crafted RunResult whose `computed` map is EMPTY — every declared output's
1014        // seed_coord is therefore absent.
1015        let run = RunResult::default();
1016        let err = project_tool_outputs(tool, &run)
1017            .expect_err("a missing declared output fails closed (WR-04)");
1018        assert_eq!(err.code, "invalid_input");
1019        assert!(
1020            err.reason.contains("was not computed by the bundle IR"),
1021            "the error names the cell_map/IR skew: {}",
1022            err.reason
1023        );
1024        // The named, missing output is identified in the message.
1025        assert!(
1026            tool.outputs
1027                .iter()
1028                .any(|e| err.reason.contains(&e.json_key) || err.reason.contains(&e.seed_coord)),
1029            "the error identifies the uncomputed output: {}",
1030            err.reason
1031        );
1032    }
1033
1034    #[test]
1035    fn project_tool_outputs_succeeds_when_all_declared_outputs_present() {
1036        // Companion to the fail-closed test: when every declared output IS computed,
1037        // project_tool_outputs returns the full { value, unit } map (no false positive).
1038        let bundle = golden_bundle();
1039        let tool = &bundle.cell_map.tools[0];
1040        let mut run = RunResult::default();
1041        for entry in &tool.outputs {
1042            run.computed
1043                .insert(entry.seed_coord.clone(), CellValue::Number(1.0));
1044        }
1045        let projected = project_tool_outputs(tool, &run).expect("all-present projects");
1046        let obj = projected.as_object().expect("outputs is an object");
1047        assert_eq!(
1048            obj.len(),
1049            tool.outputs.len(),
1050            "every declared output is projected"
1051        );
1052    }
1053
1054    #[test]
1055    fn tool_advertises_non_empty_output_schema() {
1056        let handler = calc_handler();
1057        let meta = handler.metadata().expect("metadata present");
1058        let schema = meta
1059            .output_schema
1060            .expect("outputSchema advertised (WBSV-07)");
1061        let outputs = &schema["properties"]["outputs"]["properties"];
1062        assert!(
1063            outputs.as_object().is_some_and(|o| !o.is_empty()),
1064            "outputSchema enumerates the named outputs"
1065        );
1066    }
1067
1068    // ---- sanitize_tool_name (WBV2-04, T-100-10 locked semantics) ----------
1069
1070    #[test]
1071    fn sanitize_lowercases_and_maps_space_to_underscore() {
1072        assert_eq!(
1073            sanitize_tool_name("Calculate Tax").unwrap(),
1074            "calculate_tax"
1075        );
1076    }
1077
1078    #[test]
1079    fn sanitize_lowercases_existing_underscore_name() {
1080        assert_eq!(
1081            sanitize_tool_name("Calculate_Tax").unwrap(),
1082            "calculate_tax"
1083        );
1084    }
1085
1086    #[test]
1087    fn sanitize_collapses_illegal_runs_to_single_underscore() {
1088        assert_eq!(sanitize_tool_name("a  b").unwrap(), "a_b");
1089        assert_eq!(sanitize_tool_name("a@@b").unwrap(), "a_b");
1090        assert_eq!(sanitize_tool_name("a@ @b").unwrap(), "a_b");
1091    }
1092
1093    #[test]
1094    fn sanitize_trims_leading_and_trailing_edges() {
1095        assert_eq!(sanitize_tool_name("  hello  ").unwrap(), "hello");
1096        assert_eq!(sanitize_tool_name("__hi__").unwrap(), "hi");
1097        assert_eq!(sanitize_tool_name("-hi-").unwrap(), "hi");
1098    }
1099
1100    #[test]
1101    fn sanitize_truncates_to_64() {
1102        let long = "a".repeat(200);
1103        let out = sanitize_tool_name(&long).unwrap();
1104        assert_eq!(out.len(), 64);
1105        assert!(out.chars().all(|c| c == 'a'));
1106    }
1107
1108    #[test]
1109    fn sanitize_rejects_empty_and_all_illegal() {
1110        assert!(sanitize_tool_name("").is_err());
1111        assert!(sanitize_tool_name("   ").is_err());
1112        assert!(sanitize_tool_name("@@@").is_err());
1113        assert!(sanitize_tool_name("日本語").is_err());
1114    }
1115
1116    #[test]
1117    fn workbook_tool_handler_metadata_carries_both_schemas() {
1118        let handler = calc_handler();
1119        let meta = handler.metadata().expect("metadata present");
1120        // Name is the sanitized tool name.
1121        assert_eq!(
1122            meta.name,
1123            sanitize_tool_name(&handler.tool.name).unwrap(),
1124            "metadata name is the sanitized tool name"
1125        );
1126        assert!(meta.input_schema.is_object(), "carries an input schema");
1127        assert!(meta.output_schema.is_some(), "carries an output schema");
1128    }
1129
1130    // ---- explain (WBSV-02, S-2) ------------------------------------------
1131
1132    #[test]
1133    fn explain_emits_ordered_trace_and_generic_manifest_annotations() {
1134        let handler = ExplainHandler::new(golden_bundle());
1135        let v = handler
1136            .compute(json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }))
1137            .expect("explain succeeds");
1138
1139        // An ordered per-cell derivation trace.
1140        let steps = v["steps"].as_array().expect("steps is an array");
1141        assert!(!steps.is_empty(), "explain emits derivation steps");
1142        for step in steps {
1143            assert_eq!(step["step"], json!("derivation"));
1144            assert!(step["cell"].is_string());
1145        }
1146
1147        // S-2: a GENERIC annotations object keyed by the manifest AnnotationDecl
1148        // names (the tax golden declares bracket_boundary_1/2) — nothing
1149        // domain-specific is hardcoded.
1150        let annotations = v["annotations"].as_object().expect("annotations object");
1151        assert!(annotations.contains_key("bracket_boundary_1"));
1152        assert!(annotations.contains_key("bracket_boundary_2"));
1153        assert_eq!(
1154            annotations["bracket_boundary_1"]["target"],
1155            json!("2_Brackets!A2")
1156        );
1157        assert!(annotations["bracket_boundary_1"]["meaning"].is_string());
1158
1159        assert!(v["provenance"]["combined_hash"].is_string());
1160    }
1161
1162    #[test]
1163    fn explain_invalid_input_returns_iserror() {
1164        let bundle = golden_bundle();
1165        let handler = ExplainHandler::new(bundle.clone());
1166        let v = render_at_boundary(
1167            handler.compute(json!({ "inputs": { "filing_status": "alien" } })),
1168            &ProvStamp::from_bundle(&bundle),
1169        );
1170        assert_eq!(v["isError"], json!(true));
1171        assert_eq!(v["code"], json!("invalid_input"));
1172    }
1173
1174    // ---- get_manifest (WBSV-03) ------------------------------------------
1175
1176    #[test]
1177    fn get_manifest_returns_curated_projection_with_no_input() {
1178        let bundle = golden_bundle();
1179        let v = curated_manifest(&bundle, &ProvStamp::from_bundle(&bundle));
1180        assert_eq!(v["bundle_id"], json!("tax-calc"));
1181        assert_eq!(v["version"], json!("1.1.0"));
1182        assert!(v["combined_hash"].is_string());
1183        // Curated inputs/outputs/governed_data/changelog projections.
1184        let inputs = v["inputs"].as_array().expect("inputs array");
1185        assert_eq!(
1186            inputs.len(),
1187            4,
1188            "four inputs projected (income, filing, deductions, withheld)"
1189        );
1190        assert!(inputs.iter().all(|i| i["tier"].is_string()));
1191        let outputs = v["outputs"].as_array().expect("outputs array");
1192        assert_eq!(
1193            outputs.len(),
1194            7,
1195            "seven outputs projected (4 numeric tax + the WBVER-01/D-07 text+bool \
1196             formula outputs + 1 refund) across the two tools"
1197        );
1198        assert!(v["governed_data"].is_array());
1199        assert!(v["changelog"].is_array());
1200        assert!(v["provenance"]["combined_hash"].is_string());
1201    }
1202
1203    /// M5: `get_manifest` advertises the STRIPPED served key (the `json_key`) as the
1204    /// input/output `name` — EXACTLY the keys the served tool schemas advertise — never
1205    /// the raw `in_`/`out_` prefixed name. An agent that reads `get_manifest` then calls
1206    /// the tool with the discovered name is therefore NOT rejected.
1207    #[test]
1208    fn get_manifest_advertises_the_stripped_served_keys() {
1209        use super::super::schema::output_schema_for_manifest;
1210        use std::collections::BTreeSet;
1211        let bundle = golden_bundle();
1212        let v = curated_manifest(&bundle, &ProvStamp::from_bundle(&bundle));
1213
1214        // The advertised input/output names from get_manifest.
1215        let manifest_inputs: BTreeSet<String> = v["inputs"]
1216            .as_array()
1217            .expect("inputs array")
1218            .iter()
1219            .map(|i| i["name"].as_str().expect("input name string").to_string())
1220            .collect();
1221        let manifest_outputs: BTreeSet<String> = v["outputs"]
1222            .as_array()
1223            .expect("outputs array")
1224            .iter()
1225            .map(|o| o["name"].as_str().expect("output name string").to_string())
1226            .collect();
1227
1228        // NO advertised name carries an in_/out_ governance prefix (stripped).
1229        for name in manifest_inputs.iter().chain(manifest_outputs.iter()) {
1230            assert!(
1231                !name.starts_with("in_") && !name.starts_with("out_"),
1232                "advertised get_manifest name `{name}` is stripped (no governance prefix)"
1233            );
1234        }
1235
1236        // The WORKBOOK-WIDE served schema keys (get_manifest is a workbook-wide
1237        // projection — every manifest input/output, NOT a single tool's DAG-scoped
1238        // subset). M5 asserts get_manifest's advertised names EQUAL these served keys.
1239        let wide_in = input_schema_for_manifest(&bundle.manifest, &bundle.cell_map);
1240        let served_inputs: BTreeSet<String> = wide_in["properties"]["inputs"]["properties"]
1241            .as_object()
1242            .map(|m| m.keys().cloned().collect())
1243            .unwrap_or_default();
1244        let wide_out = output_schema_for_manifest(&bundle.manifest, &bundle.cell_map);
1245        let served_outputs: BTreeSet<String> = wide_out["properties"]["outputs"]["properties"]
1246            .as_object()
1247            .map(|m| m.keys().cloned().collect())
1248            .unwrap_or_default();
1249
1250        assert_eq!(
1251            manifest_inputs, served_inputs,
1252            "get_manifest input names == the workbook-wide served input keys (stripped)"
1253        );
1254        assert_eq!(
1255            manifest_outputs, served_outputs,
1256            "get_manifest output names == the workbook-wide served output keys (stripped)"
1257        );
1258
1259        // And every PER-TOOL served key is discoverable in get_manifest (a tool's
1260        // DAG-scoped subset is always covered by the workbook-wide advertised names),
1261        // so a discovered name is always callable.
1262        for tool in &bundle.cell_map.tools {
1263            let in_schema = input_schema_for_tool(&bundle.manifest, &bundle.cell_map, tool);
1264            if let Some(props) = in_schema["properties"]["inputs"]["properties"].as_object() {
1265                for key in props.keys() {
1266                    assert!(
1267                        manifest_inputs.contains(key),
1268                        "served per-tool input key `{key}` is discoverable in get_manifest"
1269                    );
1270                }
1271            }
1272        }
1273    }
1274
1275    // ---- diff_version (WBSV-04) ------------------------------------------
1276
1277    #[test]
1278    fn diff_version_serves_recorded_changelog() {
1279        let bundle = golden_bundle();
1280        let v = serve_changelog(&bundle, &ProvStamp::from_bundle(&bundle));
1281
1282        // The served changelog matches the recorded one (not recomputed).
1283        assert_eq!(v["from_version"], json!(bundle.changelog.from_version));
1284        assert_eq!(v["to_version"], json!(bundle.changelog.to_version));
1285        assert_eq!(v["summary"], json!(bundle.changelog.summary));
1286        let deltas = v["deltas"].as_array().expect("deltas array");
1287        assert_eq!(deltas.len(), bundle.changelog.deltas.len());
1288        if let Some(first) = deltas.first() {
1289            assert!(first["region"].is_string());
1290            assert!(first["change_class"].is_string());
1291            assert!(first["severity"].is_string());
1292        }
1293        assert!(v["provenance"]["combined_hash"].is_string());
1294        assert!(
1295            v.get("isError").is_none(),
1296            "a served changelog is not an error"
1297        );
1298    }
1299
1300    #[test]
1301    fn diff_version_advertises_output_schema() {
1302        let handler = DiffVersionHandler::new(golden_bundle());
1303        let meta = handler.metadata().expect("metadata present");
1304        let schema = meta.output_schema.expect("output schema advertised");
1305        assert_eq!(
1306            schema["properties"]["from_version"]["type"],
1307            json!("string")
1308        );
1309        assert_eq!(schema["properties"]["deltas"]["type"], json!("array"));
1310    }
1311
1312    // ---- render_workbook (WBSV-05) ---------------------------------------
1313
1314    #[test]
1315    fn render_workbook_returns_uri_pointer_not_bytes() {
1316        let bundle = golden_bundle();
1317        let handler = RenderWorkbookHandler::new(bundle.clone());
1318        let v = handler
1319            .compute(json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }))
1320            .expect("render_workbook succeeds");
1321
1322        // The response carries a workbook:// pointer, NOT the bytes.
1323        let uri = v["resource_uri"]
1324            .as_str()
1325            .expect("resource_uri is a string");
1326        assert!(
1327            uri.starts_with(render_uri::RENDER_URI_PREFIX),
1328            "returns a workbook:// pointer"
1329        );
1330        assert!(
1331            v.get("bytes").is_none() && v.get("data").is_none(),
1332            "the bytes are NOT in the tool response"
1333        );
1334        // The pointer decodes back to the bound provenance (Codex HIGH #3).
1335        let decoded = render_uri::decode(uri).expect("pointer decodes");
1336        assert_eq!(decoded.provenance, ProvStamp::from_bundle(&bundle));
1337        assert_eq!(decoded.provenance.combined_hash, bundle.stamp.combined);
1338        // The success payload carries the provenance stamp.
1339        assert!(v["provenance"]["combined_hash"].is_string());
1340        assert!(v.get("isError").is_none(), "a success is not an error");
1341    }
1342
1343    #[test]
1344    fn render_workbook_invalid_input_returns_iserror() {
1345        let bundle = golden_bundle();
1346        let handler = RenderWorkbookHandler::new(bundle.clone());
1347        let v = render_at_boundary(
1348            handler.compute(json!({ "inputs": { "filing_status": "alien" } })),
1349            &ProvStamp::from_bundle(&bundle),
1350        );
1351        assert_eq!(v["isError"], json!(true), "isError rides in the payload");
1352        assert_eq!(v["code"], json!("invalid_input"));
1353        assert!(v["provenance"]["combined_hash"].is_string());
1354    }
1355
1356    #[test]
1357    fn render_workbook_advertises_non_empty_output_schema() {
1358        let handler = RenderWorkbookHandler::new(golden_bundle());
1359        let meta = handler.metadata().expect("metadata present");
1360        let schema = meta
1361            .output_schema
1362            .expect("outputSchema advertised (WBSV-07)");
1363        assert_eq!(
1364            schema["properties"]["resource_uri"]["type"],
1365            json!("string")
1366        );
1367    }
1368
1369    #[test]
1370    fn render_workbook_inputs_only_mode_encodes_into_uri() {
1371        // WBVER-02 happy path: render_workbook with mode:"inputs_only" produces a
1372        // URI whose decoded payload carries InputsOnly; with no mode it carries
1373        // Filled (default). Proven by decoding the returned URI.
1374        let bundle = golden_bundle();
1375        let handler = RenderWorkbookHandler::new(bundle.clone());
1376
1377        let io = handler
1378            .compute(json!({
1379                "inputs": { "gross_income": 60000.0, "filing_status": "single" },
1380                "mode": "inputs_only",
1381            }))
1382            .expect("inputs_only render_workbook succeeds");
1383        let io_uri = io["resource_uri"].as_str().expect("resource_uri string");
1384        let io_decoded = render_uri::decode(io_uri).expect("pointer decodes");
1385        assert_eq!(
1386            io_decoded.mode,
1387            RenderMode::InputsOnly,
1388            "mode:inputs_only rides into the URI payload"
1389        );
1390
1391        let default = handler
1392            .compute(json!({
1393                "inputs": { "gross_income": 60000.0, "filing_status": "single" }
1394            }))
1395            .expect("no-mode render_workbook succeeds");
1396        let d_uri = default["resource_uri"]
1397            .as_str()
1398            .expect("resource_uri string");
1399        let d_decoded = render_uri::decode(d_uri).expect("pointer decodes");
1400        assert_eq!(
1401            d_decoded.mode,
1402            RenderMode::Filled,
1403            "no mode arg defaults to Filled (no regression)"
1404        );
1405    }
1406
1407    #[test]
1408    fn render_workbook_unknown_mode_is_iserror_not_panic() {
1409        // WBVER-02 (MEDIUM #3 / T-100-06): an unknown `mode` value is an Err /
1410        // isError envelope at the boundary — NOT a panic, and NOT a deny_unknown_fields
1411        // rejection of the remaining inputs.
1412        let bundle = golden_bundle();
1413        let handler = RenderWorkbookHandler::new(bundle.clone());
1414        let v = render_at_boundary(
1415            handler.compute(json!({
1416                "inputs": { "gross_income": 60000.0, "filing_status": "single" },
1417                "mode": "bogus",
1418            })),
1419            &ProvStamp::from_bundle(&bundle),
1420        );
1421        assert_eq!(
1422            v["isError"],
1423            json!(true),
1424            "unknown mode is an isError envelope"
1425        );
1426        assert_eq!(v["code"], json!("invalid_input"));
1427        // The compute path returns Err directly too (not a silent Filled).
1428        assert!(
1429            handler
1430                .compute(json!({
1431                    "inputs": { "gross_income": 60000.0, "filing_status": "single" },
1432                    "mode": "bogus",
1433                }))
1434                .is_err(),
1435            "unknown mode → Err, never a silent Filled"
1436        );
1437    }
1438
1439    #[test]
1440    fn mode_is_render_only_and_never_leaks_into_calculate_or_explain() {
1441        // WBVER-02 (T-100-07): the calculate (per-tool) and explain (manifest-level)
1442        // input schemas do NOT advertise `mode`, while the render schema DOES; and
1443        // calculate/explain REJECT a `{"mode":...}` key via deny_unknown_fields
1444        // (CalculateInput carries no mode field). mode is render-only.
1445        let bundle = golden_bundle();
1446
1447        // The render schema advertises mode; calculate + explain do NOT.
1448        let render_schema = render_input_schema_for_manifest(&bundle.manifest, &bundle.cell_map);
1449        assert!(
1450            render_schema["properties"]["mode"].is_object(),
1451            "render schema advertises mode"
1452        );
1453        assert_eq!(
1454            render_schema["properties"]["mode"]["enum"],
1455            json!(["filled", "inputs_only"]),
1456            "advertise == accept: the render mode enum matches the handler's accepted values"
1457        );
1458
1459        let calc = calc_handler();
1460        let calc_schema = calc.metadata().expect("calc meta").input_schema;
1461        assert!(
1462            calc_schema["properties"].get("mode").is_none(),
1463            "calculate schema does NOT advertise mode"
1464        );
1465        let explain = ExplainHandler::new(bundle.clone());
1466        let explain_schema = explain.metadata().expect("explain meta").input_schema;
1467        assert!(
1468            explain_schema["properties"].get("mode").is_none(),
1469            "explain schema does NOT advertise mode"
1470        );
1471
1472        // calculate/explain REJECT a `mode` key (deny_unknown_fields on CalculateInput).
1473        let with_mode = json!({ "inputs": { "gross_income": 60000.0 }, "mode": "inputs_only" });
1474        assert!(
1475            validate_input(with_mode.clone(), &bundle.manifest, &bundle.cell_map).is_err(),
1476            "a mode key is rejected by validate_input (deny_unknown_fields)"
1477        );
1478    }
1479
1480    // ---- verify_accuracy (WBVER-03) ------------------------------------------
1481
1482    /// WBVER-03 golden handler: no filter reconciles every tool of the Plan-01
1483    /// fixture green, INCLUDING the text + bool formula outputs.
1484    #[test]
1485    fn verify_accuracy_no_filter_reconciles_golden_green() {
1486        let handler = VerifyAccuracyHandler::new(golden_bundle());
1487        let v = handler
1488            .compute(json!({}))
1489            .expect("verify_accuracy succeeds");
1490
1491        assert_eq!(
1492            v["all_within_tol"],
1493            json!(true),
1494            "the golden reconciles green"
1495        );
1496        // The fixture authors two tools (Calculate_Tax with 6 outputs, Estimate_Refund
1497        // with 1) — every one of the 7 oracle rows is compared.
1498        assert_eq!(
1499            v["cells_checked"],
1500            json!(7),
1501            "all authored oracle rows are checked"
1502        );
1503
1504        let tools = v["tools"].as_array().expect("tools array");
1505        let calc = tools
1506            .iter()
1507            .find(|t| t["tool"] == json!("Calculate_Tax"))
1508            .expect("Calculate_Tax present");
1509        let keys: Vec<&str> = calc["outputs"]
1510            .as_array()
1511            .unwrap()
1512            .iter()
1513            .map(|r| r["key"].as_str().unwrap())
1514            .collect();
1515        assert!(
1516            keys.contains(&"bracket_label"),
1517            "the text output is reconciled"
1518        );
1519        assert!(
1520            keys.contains(&"is_taxable"),
1521            "the bool output is reconciled"
1522        );
1523        // Every row carries its D-01 sheet-qualified A1 cell address.
1524        for row in calc["outputs"].as_array().unwrap() {
1525            assert!(
1526                row["cell"].as_str().is_some_and(|c| c.contains('!')),
1527                "each output row carries its A1 cell address"
1528            );
1529            assert_eq!(row["within_tol"], json!(true));
1530        }
1531    }
1532
1533    /// MEDIUM #4 filtered rollup: a filter naming ONE tool scopes the report AND
1534    /// recomputes the top-level aggregates over the filtered set (never stale
1535    /// full-bundle counts).
1536    #[test]
1537    fn verify_accuracy_filter_scopes_and_recomputes_aggregates() {
1538        let handler = VerifyAccuracyHandler::new(golden_bundle());
1539        let v = handler
1540            .compute(json!({ "tool": "Estimate_Refund" }))
1541            .expect("filtered verify_accuracy succeeds");
1542
1543        let tools = v["tools"].as_array().expect("tools array");
1544        assert_eq!(tools.len(), 1, "only the filtered tool is reported");
1545        assert_eq!(tools[0]["tool"], json!("Estimate_Refund"));
1546        // Estimate_Refund has ONE output — cells_checked reflects ONLY that tool,
1547        // NOT the full-bundle 7.
1548        assert_eq!(
1549            v["cells_checked"],
1550            json!(1),
1551            "cells_checked is the filtered tool's compared-row count, not the full rollup"
1552        );
1553        assert_eq!(v["all_within_tol"], json!(true));
1554    }
1555
1556    /// D-03: an unknown `tool` filter returns an isError envelope listing the
1557    /// available tools — never a silent empty pass, never a panic.
1558    #[test]
1559    fn verify_accuracy_unknown_filter_errors_listing_tools() {
1560        let handler = VerifyAccuracyHandler::new(golden_bundle());
1561        let err = handler
1562            .compute(json!({ "tool": "nonexistent" }))
1563            .expect_err("an unknown tool filter is an Err (D-03)");
1564        assert_eq!(err.code, "invalid_input");
1565        // The error carries the available tool names so the caller can repair.
1566        let names = err.allowed.clone().unwrap_or_default();
1567        assert!(
1568            names.contains(&"Calculate_Tax".to_string())
1569                && names.contains(&"Estimate_Refund".to_string()),
1570            "the D-03 error lists the available tool names, got {names:?}"
1571        );
1572    }
1573
1574    /// Via the async boundary an unknown filter renders as an isError envelope,
1575    /// never a protocol-level error (T-92-10).
1576    #[tokio::test]
1577    async fn verify_accuracy_unknown_filter_renders_iserror_envelope() {
1578        let handler = VerifyAccuracyHandler::new(golden_bundle());
1579        let rendered = handler
1580            .handle(json!({ "tool": "nope" }), RequestHandlerExtra::default())
1581            .await
1582            .expect("handle never returns a protocol error");
1583        assert_eq!(
1584            rendered["isError"],
1585            json!(true),
1586            "renders as an isError envelope"
1587        );
1588    }
1589
1590    /// A non-string `tool` filter is rejected panic-free (deny(panic)).
1591    #[test]
1592    fn verify_accuracy_non_string_filter_errors() {
1593        let handler = VerifyAccuracyHandler::new(golden_bundle());
1594        assert!(
1595            handler.compute(json!({ "tool": 42 })).is_err(),
1596            "a non-string tool filter is an Err, not a panic"
1597        );
1598    }
1599
1600    /// The verify_accuracy output schema advertises the ReconcileReport rollups +
1601    /// per-tool rows, and its input schema advertises the optional `tool` filter.
1602    #[test]
1603    fn verify_accuracy_schemas_advertise_report_and_filter() {
1604        let handler = VerifyAccuracyHandler::new(golden_bundle());
1605        let meta = handler.metadata().expect("verify_accuracy metadata");
1606        assert_eq!(meta.name, "verify_accuracy");
1607        assert!(
1608            meta.input_schema["properties"].get("tool").is_some(),
1609            "the input schema advertises the optional tool filter (advertise == accept)"
1610        );
1611        let out = meta.output_schema.expect("output schema present");
1612        for field in ["tolerance", "all_within_tol", "cells_checked", "tools"] {
1613            assert!(
1614                out["properties"].get(field).is_some(),
1615                "output schema advertises {field}"
1616            );
1617        }
1618    }
1619}