Skip to main content

pmcp_server_toolkit/workbook/
schema.rs

1//! Tool schema builders (WBSV-07) — the mandatory non-empty `outputSchema` + the
2//! per-tool input schema, projected ENTIRELY from the embedded
3//! [`Manifest`](pmcp_workbook_runtime::Manifest) +
4//! [`CellMap`](pmcp_workbook_runtime::CellMap).
5//!
6//! There is NO privileged headline field (S-1): the output schema projects ALL
7//! named outputs uniformly from `cell_map.outputs`, each as a `{ value, unit }`
8//! pair carrying its declared `unit`/`meaning`. The input-schema envelope is
9//! strict (`additionalProperties: false`) and mirrors the runtime DTO gate so a
10//! client trusting the schema never sends a key the runtime then rejects.
11
12// Compiler/clippy-enforced panic-freedom on the value path (mirrors the runtime).
13#![cfg_attr(
14    not(test),
15    deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
16)]
17
18use std::collections::HashSet;
19
20use serde_json::{json, Map, Value};
21
22use pmcp_workbook_runtime::{CellEntry, CellMap, CellRole, Dtype, Manifest, Tool};
23
24/// Map a manifest [`Dtype`] to its JSON Schema primitive type string. `pub(crate)`
25/// so input.rs's type-check reuses the SAME `Dtype`→string mapping (one place).
26pub(crate) fn dtype_json_type(dtype: Dtype) -> &'static str {
27    match dtype {
28        Dtype::Number => "number",
29        Dtype::Text => "string",
30        Dtype::Bool => "boolean",
31    }
32}
33
34/// Find the manifest [`CellRole`] for a `cell_map` entry's seed coordinate —
35/// the runtime's shared exact-cell-key lookup.
36fn role_for_seed<'a>(manifest: &'a Manifest, seed_coord: &str) -> Option<&'a CellRole> {
37    pmcp_workbook_runtime::role_for_cell(manifest, seed_coord)
38}
39
40/// Build the per-output-column schema for the `calculate` result `outputs` map.
41///
42/// `project_outputs` (handler.rs) emits each column as a `{ value, unit }` pair,
43/// NOT a bare typed scalar — so the advertised schema MUST describe that nested
44/// shape or a client validating the result rejects every column.
45fn output_column_schema(unit: Option<&str>, role: Option<&CellRole>) -> Value {
46    let dtype = role.map_or(Dtype::Number, |r| r.dtype);
47    let mut value_prop = Map::new();
48    value_prop.insert("type".to_string(), json!(dtype_json_type(dtype)));
49    if let Some(u) = unit {
50        value_prop.insert("unit".to_string(), json!(u));
51    }
52
53    let mut props = Map::new();
54    props.insert("value".to_string(), Value::Object(value_prop));
55    props.insert("unit".to_string(), json!({ "type": ["string", "null"] }));
56
57    let mut col = Map::new();
58    col.insert("type".to_string(), json!("object"));
59    col.insert("additionalProperties".to_string(), json!(false));
60    col.insert("properties".to_string(), Value::Object(props));
61    col.insert("required".to_string(), json!(["value"]));
62    if let Some(meaning) = role.and_then(|r| r.meaning.as_deref()) {
63        col.insert("description".to_string(), json!(meaning));
64    }
65    Value::Object(col)
66}
67
68/// Build the mandatory non-empty `outputSchema` (WBSV-07) from the embedded
69/// [`Manifest`] + [`CellMap`].
70///
71/// S-1: ALL named outputs are projected uniformly from `cell_map.outputs` (keyed
72/// by their neutral `json_key`) — there is NO privileged top-level headline
73/// field. Each column projects to a `{ value, unit }` pair carrying its declared
74/// `unit`/`meaning`. The error-envelope fields ride in the SAME `structuredContent`
75/// slot, so [`result_envelope_schema`] folds them in (the result root is
76/// `additionalProperties:true` and `required:["provenance"]`).
77#[must_use]
78pub fn output_schema_for_manifest(manifest: &Manifest, cell_map: &CellMap) -> Value {
79    // The union of every tool's outputs (the workbook-WIDE output surface), kept for
80    // the meta/generalization consumers (the WBEX-01 reemit proofs). The per-TOOL
81    // served schema is `output_schema_for_tool`.
82    let all_outputs: Vec<CellEntry> = cell_map
83        .tools
84        .iter()
85        .flat_map(|t| t.outputs.iter().cloned())
86        .collect();
87    let output_props = output_props_for_entries(manifest, &all_outputs);
88
89    let mut success = Map::new();
90    success.insert(
91        "outputs".to_string(),
92        json!({
93            "type": "object",
94            "additionalProperties": false,
95            "properties": Value::Object(output_props),
96        }),
97    );
98    success.insert(
99        "accepted_overrides".to_string(),
100        json!({ "type": "array", "items": { "type": "string" } }),
101    );
102    result_envelope_schema(success)
103}
104
105/// Build the per-output schema map for a set of [`CellEntry`] output columns —
106/// the shared projection [`output_schema_for_manifest`] and
107/// [`output_schema_for_tool`] both use.
108fn output_props_for_entries(manifest: &Manifest, outputs: &[CellEntry]) -> Map<String, Value> {
109    let mut output_props = Map::new();
110    for entry in outputs {
111        let role = role_for_seed(manifest, &entry.seed_coord);
112        output_props.insert(
113            entry.json_key.clone(),
114            output_column_schema(entry.unit.as_deref(), role),
115        );
116    }
117    output_props
118}
119
120/// Build ONE tool's non-empty `outputSchema` (WBSV-07 / WBV2-04) over the tool's
121/// OWN `outputs` only (NOT the union across tools). Each output Table becomes its
122/// own MCP tool, so its schema enumerates exactly that Table's output columns —
123/// the TypedToolWithOutput dual-surface invariant (every tool emits a non-empty
124/// outputSchema → `structuredContent`).
125#[must_use]
126pub fn output_schema_for_tool(manifest: &Manifest, tool: &Tool) -> Value {
127    let output_props = output_props_for_entries(manifest, &tool.outputs);
128
129    let mut success = Map::new();
130    success.insert(
131        "outputs".to_string(),
132        json!({
133            "type": "object",
134            "additionalProperties": false,
135            "properties": Value::Object(output_props),
136        }),
137    );
138    success.insert(
139        "accepted_overrides".to_string(),
140        json!({ "type": "array", "items": { "type": "string" } }),
141    );
142    result_envelope_schema(success)
143}
144
145/// Build a tool result `outputSchema` that accepts BOTH the tool's success shape
146/// AND the shared `isError` envelope, generalizing the contract to every tool.
147///
148/// Each tool contributes only its SUCCESS-specific properties in `success_props`;
149/// this builder folds in the shared parts every tool's result carries:
150/// - the error-envelope fields (`isError`/`code`/`reason`/`field`/`allowed`/
151///   `range`/`required`) — the error rides in the SAME `structuredContent` slot,
152///   so a strict client validating an ERROR result must accept it;
153/// - the `provenance` stamp — present on success AND error;
154/// - `additionalProperties:true` (the success and error key sets are disjoint, so
155///   the root cannot be closed) and `required:["provenance"]` (the ONLY field on
156///   both paths).
157#[must_use]
158pub fn result_envelope_schema(success_props: Map<String, Value>) -> Value {
159    let mut props = success_props;
160    // ---- shared isError envelope fields ----
161    props.insert("isError".to_string(), json!({ "type": "boolean" }));
162    props.insert("code".to_string(), json!({ "type": "string" }));
163    props.insert("reason".to_string(), json!({ "type": "string" }));
164    props.insert("field".to_string(), json!({ "type": "string" }));
165    props.insert(
166        "allowed".to_string(),
167        json!({ "type": "array", "items": {} }),
168    );
169    props.insert("range".to_string(), json!({ "type": "array" }));
170    props.insert(
171        "required".to_string(),
172        json!({ "type": "array", "items": { "type": "string" } }),
173    );
174    // ---- always present (success AND error) ----
175    props.insert("provenance".to_string(), provenance_schema());
176
177    json!({
178        "type": "object",
179        "additionalProperties": true,
180        "properties": Value::Object(props),
181        "required": ["provenance"],
182    })
183}
184
185/// The `explain` result `outputSchema` (WBSV-07), composed over the shared result
186/// envelope. The success-specific fields are the ordered `steps` trace + the
187/// generic manifest-declared `annotations` object (S-2 — any domain-specific
188/// keystone step is generalized into manifest-declared annotations).
189#[must_use]
190pub fn explain_output_schema() -> Value {
191    let mut success = Map::new();
192    success.insert(
193        "steps".to_string(),
194        json!({
195            "type": "array",
196            "description": "Ordered business-language derivation steps.",
197            "items": {
198                "type": "object",
199                "additionalProperties": true,
200                "properties": {
201                    "step": { "type": "string" },
202                    "cell": { "type": "string" },
203                },
204            },
205        }),
206    );
207    success.insert(
208        "annotations".to_string(),
209        json!({
210            "type": "object",
211            "description": "Manifest-declared annotations (keyed by AnnotationDecl name).",
212            "additionalProperties": { "type": "object" },
213        }),
214    );
215    result_envelope_schema(success)
216}
217
218/// The `get_manifest` result `outputSchema` (WBSV-07), composed over the shared
219/// result envelope. `get_manifest` has no domain-error trigger today, but
220/// composing the SAME envelope keeps every tool's schema uniform.
221#[must_use]
222pub fn get_manifest_output_schema() -> Value {
223    let mut success = Map::new();
224    success.insert("bundle_id".to_string(), json!({ "type": "string" }));
225    success.insert("version".to_string(), json!({ "type": "string" }));
226    success.insert("combined_hash".to_string(), json!({ "type": "string" }));
227    for field in ["inputs", "outputs", "governed_data", "changelog"] {
228        success.insert(
229            field.to_string(),
230            json!({ "type": "array", "items": { "type": "object" } }),
231        );
232    }
233    result_envelope_schema(success)
234}
235
236/// The `diff_version` result `outputSchema` (WBSV-07), composed over the shared
237/// result envelope. The success-specific fields describe the served recorded
238/// changelog: `from_version`/`to_version`, `deltas` (per-output machine records),
239/// and a human-readable `summary`.
240#[must_use]
241pub fn diff_version_output_schema() -> Value {
242    let mut success = Map::new();
243    success.insert("from_version".to_string(), json!({ "type": "string" }));
244    success.insert("to_version".to_string(), json!({ "type": "string" }));
245    success.insert(
246        "deltas".to_string(),
247        json!({
248            "type": "array",
249            "description": "Per-output change records (region, change class, old/new \
250                            meaning+unit+provenance, drift/redefinition severity).",
251            "items": {
252                "type": "object",
253                "additionalProperties": true,
254                "properties": {
255                    "region": { "type": "string" },
256                    "change_class": { "type": "string" },
257                    "severity": { "type": "string" },
258                },
259            },
260        }),
261    );
262    success.insert(
263        "summary".to_string(),
264        json!({ "type": "string", "description": "Human-readable transition summary." }),
265    );
266    result_envelope_schema(success)
267}
268
269/// The `render_workbook` output schema (WBSV-05, WBSV-07): a `workbook://`
270/// resource-URI POINTER + its MIME type — NOT the `.xlsx` bytes (those arrive on
271/// `resources/read`). Non-empty so the `outputSchema`-advertise contract holds.
272#[must_use]
273pub fn render_workbook_output_schema() -> Value {
274    let mut success = Map::new();
275    success.insert(
276        "resource_uri".to_string(),
277        json!({
278            "type": "string",
279            "description": "A provenance-bound workbook:// resource URI. Read it via \
280                            resources/read to obtain the base64-encoded .xlsx, which is \
281                            regenerated statelessly from the URI on each read. The URI \
282                            encodes the inputs — treat it as sensitive.",
283        }),
284    );
285    success.insert(
286        "mime_type".to_string(),
287        json!({
288            "type": "string",
289            "description": "The MIME type of the resource the URI resolves to (the OOXML \
290                            spreadsheet type).",
291        }),
292    );
293    result_envelope_schema(success)
294}
295
296/// The `verify_accuracy` INPUT schema (WBVER-03): an OPTIONAL `tool`-name filter
297/// (advertise == accept). Absent → reconcile every tool; a registered tool name →
298/// scope the report to that tool (an unknown name is rejected by the handler,
299/// D-03). `additionalProperties:false` keeps the surface closed.
300#[must_use]
301pub fn verify_accuracy_input_schema() -> Value {
302    json!({
303        "type": "object",
304        "additionalProperties": false,
305        "properties": {
306            "tool": {
307                "type": "string",
308                "description": "Optional. Scope the report to one registered tool by name. \
309                                Omit to reconcile every tool. An unknown name returns an \
310                                error listing the available tools.",
311            },
312        },
313    })
314}
315
316/// The `verify_accuracy` result `outputSchema` (WBVER-03, WBSV-07): the
317/// [`pmcp_workbook_runtime::ReconcileReport`] shape composed over the shared
318/// result envelope — `tolerance`, the `all_within_tol` + `cells_checked` rollups,
319/// and the per-tool `tools[]` (each with its `outputs[]` rows carrying the D-01
320/// `cell` A1 address, server/oracle values, `abs_delta`, `within_tol`).
321#[must_use]
322pub fn verify_accuracy_output_schema() -> Value {
323    let mut success = Map::new();
324    success.insert(
325        "tolerance".to_string(),
326        json!({ "type": "number", "description": "The tolerance the report was graded at (±)." }),
327    );
328    success.insert(
329        "all_within_tol".to_string(),
330        json!({
331            "type": "boolean",
332            "description": "True iff every compared output across the reported tools is \
333                            within tolerance.",
334        }),
335    );
336    success.insert(
337        "cells_checked".to_string(),
338        json!({
339            "type": "integer",
340            "description": "The number of output rows actually compared (an empty-oracle \
341                            tool contributes 0).",
342        }),
343    );
344    success.insert(
345        "tools".to_string(),
346        json!({
347            "type": "array",
348            "description": "One report per reconciled tool.",
349            "items": {
350                "type": "object",
351                "additionalProperties": true,
352                "properties": {
353                    "tool": { "type": "string" },
354                    "all_within_tol": { "type": "boolean" },
355                    "outputs": {
356                        "type": "array",
357                        "items": {
358                            "type": "object",
359                            "additionalProperties": true,
360                            "properties": {
361                                "key": { "type": "string" },
362                                "cell": { "type": ["string", "null"] },
363                                "abs_delta": { "type": "number" },
364                                "within_tol": { "type": "boolean" },
365                            },
366                        },
367                    },
368                },
369            },
370        }),
371    );
372    result_envelope_schema(success)
373}
374
375/// The provenance stamp sub-schema — present on every result. Carries
376/// `bundle_id`/`version`/`combined_hash` (NEVER `workbook_hash` — Codex HIGH #3).
377#[must_use]
378pub fn provenance_schema() -> Value {
379    json!({
380        "type": "object",
381        "additionalProperties": false,
382        "properties": {
383            "bundle_id": { "type": "string" },
384            "version": { "type": "string" },
385            "combined_hash": { "type": "string" },
386        },
387        "required": ["bundle_id", "version", "combined_hash"],
388    })
389}
390
391/// The strict input schema for `calculate`/`explain`: an `object` with
392/// `additionalProperties:false` accepting only the manifest `Role::Input`
393/// columns (by their neutral `json_key`) plus an optional `overrides` map for
394/// variable-tier params. The DTO's `deny_unknown_fields` is the runtime gate;
395/// this schema mirrors it for discovery.
396#[must_use]
397pub fn input_schema_for_manifest(manifest: &Manifest, cell_map: &CellMap) -> Value {
398    let mut input_props = Map::new();
399    for entry in &cell_map.inputs {
400        input_props.insert(
401            entry.json_key.clone(),
402            input_prop_for_entry(manifest, entry),
403        );
404    }
405    assemble_input_schema(manifest, input_props)
406}
407
408/// Build the strict per-tool input schema (WBV2-04): an `object` with
409/// `additionalProperties:false` carrying ONLY this tool's DAG-derived
410/// `input_keys` (the subset of the shared `cell_map.inputs` pool transitively
411/// reachable upstream of this tool's outputs), plus the F2 `overrides` block.
412///
413/// The strict envelope (`additionalProperties:false`, V5) MUST survive: a client
414/// trusting the advertised schema must never be able to send a key the runtime
415/// then rejects.
416#[must_use]
417pub fn input_schema_for_tool(manifest: &Manifest, cell_map: &CellMap, tool: &Tool) -> Value {
418    // O(1) membership for the per-tool key projection (was a linear scan of
419    // `input_keys` per input — O(inputs × keys)).
420    let reached: HashSet<&str> = tool.input_keys.iter().map(String::as_str).collect();
421    // CR-02 defense-in-depth: a tool with an EMPTY input_keys advertised an empty
422    // inputs.properties while `validate_input` accepts the full shared pool — the
423    // served schema was STRICTER than the runtime (the V5 invariant inverted). When
424    // no DAG derivation populated input_keys (a hand-built / single-tool fallback
425    // bundle), project the FULL shared-input pool ("no derivation" => "all shared
426    // inputs") so the advertised schema is never stricter than what the runtime
427    // accepts. The production multi-tool path always populates input_keys, so this
428    // fires only for the fallback shape.
429    let project_all = tool.input_keys.is_empty();
430    let mut input_props = Map::new();
431    for entry in &cell_map.inputs {
432        // Project this tool's DAG-derived keys (or the full pool when empty — CR-02).
433        if project_all || reached.contains(entry.json_key.as_str()) {
434            input_props.insert(
435                entry.json_key.clone(),
436                input_prop_for_entry(manifest, entry),
437            );
438        }
439    }
440    assemble_input_schema(manifest, input_props)
441}
442
443/// Build the typed JSON-Schema property for one input [`CellEntry`] — its dtype,
444/// unit, meaning, and (for a frozen input) closed-enum domain. Shared by the
445/// manifest-level and per-tool input-schema builders so the per-input shape
446/// cannot drift.
447fn input_prop_for_entry(manifest: &Manifest, entry: &CellEntry) -> Value {
448    let role = role_for_seed(manifest, &entry.seed_coord);
449    let dtype = role.map_or(Dtype::Number, |r| r.dtype);
450    let mut prop = Map::new();
451    prop.insert("type".to_string(), json!(dtype_json_type(dtype)));
452    if let Some(unit) = entry.unit.as_deref() {
453        prop.insert("unit".to_string(), json!(unit));
454    }
455    if let Some(meaning) = role.and_then(|r| r.meaning.as_deref()) {
456        prop.insert("description".to_string(), json!(meaning));
457    }
458    // A frozen input (allowed_values from the workbook) advertises its closed
459    // domain as a JSON-Schema enum, verbatim workbook order. The input stays
460    // OPTIONAL — this fn builds no `required` array.
461    if let Some(allowed) = role.and_then(|r| r.allowed_values.as_ref()) {
462        prop.insert("enum".to_string(), json!(allowed));
463    }
464    Value::Object(prop)
465}
466
467/// Assemble the strict input-schema envelope from the already-built per-input
468/// `properties` map: the `inputs` object (strict) + the F2 `overrides` block
469/// (advertising the variable-tier override keys). Shared by the manifest-level
470/// and per-tool builders.
471fn assemble_input_schema(manifest: &Manifest, input_props: Map<String, Value>) -> Value {
472    // F2: ADVERTISE the legal override keys so an LLM/caller can DISCOVER them,
473    // rather than leaving `overrides` an opaque open map described in prose only.
474    // The keys are the SAME variable-tier list `validate_input` accepts (one source
475    // of truth — `crate::workbook::input::variable_tier_keys`), so advertisement and
476    // acceptance cannot drift. `additionalProperties` stays permissive (the open
477    // value-typed map) so this is a DISCOVERABILITY change only — `validate_input`'s
478    // accept/reject behavior is unchanged.
479    let mut override_props = Map::new();
480    for key in crate::workbook::input::variable_tier_keys(manifest) {
481        override_props.insert(
482            key,
483            json!({ "type": ["number", "string", "boolean", "null"] }),
484        );
485    }
486
487    json!({
488        "type": "object",
489        "additionalProperties": false,
490        "properties": {
491            "inputs": {
492                "type": "object",
493                "additionalProperties": false,
494                "properties": Value::Object(input_props),
495            },
496            "overrides": {
497                "type": "object",
498                "additionalProperties": { "type": ["number", "string", "boolean", "null"] },
499                "properties": Value::Object(override_props),
500                "description": "Variable-tier parameter overrides, keyed by parameter \
501                                name or cell key. Strict (BA-governed) constants are rejected.",
502            },
503        },
504    })
505}
506
507/// The `render_workbook` input schema (WBVER-02): the manifest-level input
508/// envelope PLUS an optional, render-ONLY top-level `mode` enum
509/// `["filled","inputs_only"]`.
510///
511/// This is a thin wrapper over [`input_schema_for_manifest`] that injects the
512/// `mode` property so the advertised RENDER schema matches what the handler
513/// accepts (advertise == accept, the same precedent the `overrides` block uses).
514/// `mode` is added ONLY here — NEVER on the shared `calculate`/`explain` schemas
515/// (which call [`input_schema_for_manifest`] directly) — and `CalculateInput`
516/// carries no `mode` field, so `mode` stays render-only. The envelope keeps
517/// `additionalProperties:false` (mode is now a KNOWN top-level property).
518#[must_use]
519pub fn render_input_schema_for_manifest(manifest: &Manifest, cell_map: &CellMap) -> Value {
520    let mut schema = input_schema_for_manifest(manifest, cell_map);
521    if let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) {
522        props.insert(
523            "mode".to_string(),
524            json!({
525                "type": "string",
526                "enum": ["filled", "inputs_only"],
527                "description": "Render mode (WBVER-02). 'filled' (default) writes \
528                                formula cells with their cached result; 'inputs_only' \
529                                writes bare formulas so Excel recomputes every output \
530                                (double-entry verification copy).",
531            }),
532        );
533    }
534    schema
535}
536
537/// `get_manifest`/`diff_version` have no input — an empty strict object schema.
538#[must_use]
539pub fn empty_input_schema() -> Value {
540    json!({ "type": "object", "additionalProperties": false })
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use pmcp_workbook_runtime::CellValue;
547    use pmcp_workbook_runtime::{CellEntry, CellMap, InputTier, Role, Tool};
548
549    fn input_role(
550        cell: &str,
551        dtype: Dtype,
552        meaning: &str,
553        allowed: Option<Vec<String>>,
554    ) -> CellRole {
555        CellRole {
556            cell: cell.to_string(),
557            role: Role::Input,
558            name: None,
559            unit: Some("USD".to_string()),
560            meaning: Some(meaning.to_string()),
561            dtype,
562            colour_evidence: None,
563            source: "test".to_string(),
564            notes: None,
565            tier: Some(InputTier::Variable {
566                default: CellValue::Number(0.0),
567            }),
568            allowed_values: allowed,
569        }
570    }
571
572    fn output_role(cell: &str, meaning: &str) -> CellRole {
573        CellRole {
574            cell: cell.to_string(),
575            role: Role::Output,
576            name: None,
577            unit: Some("USD".to_string()),
578            meaning: Some(meaning.to_string()),
579            dtype: Dtype::Number,
580            colour_evidence: None,
581            source: "test".to_string(),
582            notes: None,
583            tier: None,
584            allowed_values: None,
585        }
586    }
587
588    fn manifest_with(cells: Vec<CellRole>) -> Manifest {
589        Manifest {
590            schema_version: 1,
591            workflow: "tax-calc".to_string(),
592            workbook_hash: None,
593            ratified: true,
594            ratified_by: None,
595            ratified_at: None,
596            cells,
597            loop_block: None,
598            governed_data: vec![],
599            changelog: vec![],
600            capability_calls: vec![],
601            annotations: vec![],
602        }
603    }
604
605    fn three_input_manifest_and_map() -> (Manifest, CellMap) {
606        let manifest = manifest_with(vec![
607            input_role("1_Inputs!B2", Dtype::Number, "Gross income", None),
608            input_role(
609                "1_Inputs!B3",
610                Dtype::Text,
611                "Filing status",
612                Some(vec!["single".to_string(), "married_joint".to_string()]),
613            ),
614            input_role("1_Inputs!B4", Dtype::Number, "Deductions", None),
615            output_role("3_Outputs!B2", "Taxable income"),
616            output_role("3_Outputs!B3", "Tax owed"),
617        ]);
618        let cell_map = CellMap {
619            inputs: vec![
620                CellEntry {
621                    json_key: "gross_income".to_string(),
622                    seed_coord: "1_Inputs!B2".to_string(),
623                    unit: Some("USD".to_string()),
624                },
625                CellEntry {
626                    json_key: "filing_status".to_string(),
627                    seed_coord: "1_Inputs!B3".to_string(),
628                    unit: None,
629                },
630                CellEntry {
631                    json_key: "deductions".to_string(),
632                    seed_coord: "1_Inputs!B4".to_string(),
633                    unit: Some("USD".to_string()),
634                },
635            ],
636            tools: vec![Tool {
637                name: "calculate".to_string(),
638                description: None,
639                input_keys: Vec::new(),
640                outputs: vec![
641                    CellEntry {
642                        json_key: "taxable_income".to_string(),
643                        seed_coord: "3_Outputs!B2".to_string(),
644                        unit: Some("USD".to_string()),
645                    },
646                    CellEntry {
647                        json_key: "tax_owed".to_string(),
648                        seed_coord: "3_Outputs!B3".to_string(),
649                        unit: Some("USD".to_string()),
650                    },
651                ],
652                oracle: std::collections::BTreeMap::new(),
653            }],
654        };
655        (manifest, cell_map)
656    }
657
658    #[test]
659    fn input_schema_is_strict_and_projects_all_inputs() {
660        let (m, cm) = three_input_manifest_and_map();
661        let schema = input_schema_for_manifest(&m, &cm);
662        // additionalProperties:false at the root (strict envelope).
663        assert_eq!(schema["additionalProperties"], false);
664        let props = &schema["properties"]["inputs"]["properties"];
665        // One typed property per input, dtype/unit/meaning carried.
666        assert_eq!(props["gross_income"]["type"], json!("number"));
667        assert_eq!(props["gross_income"]["unit"], json!("USD"));
668        assert_eq!(props["gross_income"]["description"], json!("Gross income"));
669        assert_eq!(props["filing_status"]["type"], json!("string"));
670        assert_eq!(props["deductions"]["type"], json!("number"));
671        // The inputs object is also strict.
672        assert_eq!(
673            schema["properties"]["inputs"]["additionalProperties"],
674            false
675        );
676    }
677
678    #[test]
679    fn input_schema_emits_enum_for_allowed_values_and_keeps_it_optional() {
680        let (m, cm) = three_input_manifest_and_map();
681        let schema = input_schema_for_manifest(&m, &cm);
682        let props = &schema["properties"]["inputs"]["properties"];
683        assert_eq!(
684            props["filing_status"]["enum"],
685            json!(["single", "married_joint"]),
686            "allowed_values surfaces as a JSON-Schema enum (verbatim order)"
687        );
688        // A non-enum input grows no enum key.
689        assert!(props["gross_income"].get("enum").is_none());
690        // The enum input is NOT required.
691        assert!(schema["properties"]["inputs"].get("required").is_none());
692    }
693
694    #[test]
695    fn output_schema_is_non_empty_and_carries_every_named_output() {
696        let (m, cm) = three_input_manifest_and_map();
697        let schema = output_schema_for_manifest(&m, &cm);
698        let outputs = &schema["properties"]["outputs"]["properties"];
699        // Every named output is present as a { value, unit } column.
700        assert!(outputs["taxable_income"].is_object());
701        assert!(outputs["tax_owed"].is_object());
702        assert_eq!(
703            outputs["taxable_income"]["properties"]["value"]["unit"],
704            "USD"
705        );
706        assert_eq!(
707            outputs["taxable_income"]["properties"]["value"]["type"],
708            "number"
709        );
710        // S-1: the success root enumerates exactly outputs/accepted_overrides
711        // (+ the shared envelope), with NO privileged headline scalar elevated
712        // above the uniform all-outputs projection. The forbidden headline key
713        // name is built dynamically so the literal does not appear in this file.
714        let headline_key = ["supply", "_", "total"].concat();
715        assert!(
716            schema["properties"].get(&headline_key).is_none(),
717            "no privileged headline field at the root (S-1)"
718        );
719        // The outputSchema is non-empty (WBSV-07): it has properties + provenance.
720        assert!(schema["properties"]["provenance"].is_object());
721        assert!(
722            !outputs
723                .as_object()
724                .expect("outputs is an object")
725                .is_empty(),
726            "outputSchema must enumerate at least one output"
727        );
728    }
729
730    #[test]
731    fn result_envelope_accepts_both_success_and_iserror_shapes() {
732        let (m, cm) = three_input_manifest_and_map();
733        let schema = output_schema_for_manifest(&m, &cm);
734        // The error-envelope fields are declared so an error result validates.
735        assert_eq!(schema["properties"]["isError"]["type"], "boolean");
736        assert_eq!(schema["properties"]["code"]["type"], "string");
737        assert_eq!(schema["properties"]["reason"]["type"], "string");
738        // The root cannot be closed (success/error key sets are disjoint).
739        assert_eq!(schema["additionalProperties"], true);
740        // provenance is the only required field (present on both paths).
741        assert_eq!(schema["required"], json!(["provenance"]));
742    }
743
744    #[test]
745    fn overrides_advertise_variable_tier_keys() {
746        // F2: the overrides block carries a `properties` map keyed by the legal
747        // variable-tier override keys (the SAME list validate_input accepts).
748        let (m, cm) = three_input_manifest_and_map();
749        let schema = input_schema_for_manifest(&m, &cm);
750        let override_props = &schema["properties"]["overrides"]["properties"];
751        let props = override_props
752            .as_object()
753            .expect("overrides.properties is an object");
754        // The three variable-tier inputs (Some(Variable) tier, not computed) are
755        // advertised by their `variable_tier_keys` identity (name.or(cell) — here
756        // the cell key, since these fixtures carry no `name`).
757        for key in ["1_Inputs!B2", "1_Inputs!B3", "1_Inputs!B4"] {
758            assert!(
759                props.contains_key(key),
760                "overrides advertises the variable-tier key `{key}` (got {props:?})"
761            );
762            assert_eq!(
763                override_props[key]["type"],
764                json!(["number", "string", "boolean", "null"]),
765                "each advertised override carries the permissive value-type union"
766            );
767        }
768        // Computed outputs are NEVER advertised as overridable (WR-02).
769        assert!(
770            !props.contains_key("3_Outputs!B2") && !props.contains_key("taxable_income"),
771            "a computed output is never an advertised override key"
772        );
773    }
774
775    #[test]
776    fn overrides_advertise_named_param_keys() {
777        // With a NAMED variable-tier input, the advertised override key is the
778        // human param name (variable_tier_keys uses name.or(cell)).
779        let mut named = input_role("1_Inputs!B2", Dtype::Number, "Gross income", None);
780        named.name = Some("in_gross_income".to_string());
781        let m = manifest_with(vec![named, output_role("3_Outputs!B2", "Taxable income")]);
782        let cm = CellMap {
783            inputs: vec![CellEntry {
784                json_key: "gross_income".to_string(),
785                seed_coord: "1_Inputs!B2".to_string(),
786                unit: Some("USD".to_string()),
787            }],
788            tools: vec![Tool {
789                name: "calculate".to_string(),
790                description: None,
791                input_keys: Vec::new(),
792                outputs: vec![CellEntry {
793                    json_key: "taxable_income".to_string(),
794                    seed_coord: "3_Outputs!B2".to_string(),
795                    unit: Some("USD".to_string()),
796                }],
797                oracle: std::collections::BTreeMap::new(),
798            }],
799        };
800        let schema = input_schema_for_manifest(&m, &cm);
801        let override_props = &schema["properties"]["overrides"]["properties"];
802        assert!(
803            override_props["in_gross_income"].is_object(),
804            "the named variable-tier param is advertised under its name"
805        );
806    }
807
808    #[test]
809    fn overrides_keep_open_additional_properties_for_discoverability_only() {
810        // F2 is advertisement-only: the open value-typed additionalProperties map
811        // is PRESERVED (validate_input's accept/reject behavior is unchanged) and
812        // the prose description stays.
813        let (m, cm) = three_input_manifest_and_map();
814        let schema = input_schema_for_manifest(&m, &cm);
815        let overrides = &schema["properties"]["overrides"];
816        assert_eq!(
817            overrides["additionalProperties"],
818            json!({ "type": ["number", "string", "boolean", "null"] }),
819            "the open value-typed additionalProperties map is preserved"
820        );
821        assert!(
822            overrides["description"].as_str().is_some(),
823            "the prose override description is retained"
824        );
825    }
826
827    #[test]
828    fn provenance_schema_uses_combined_hash_never_workbook_hash() {
829        let schema = provenance_schema();
830        let props = &schema["properties"];
831        assert!(props["combined_hash"].is_object());
832        assert!(
833            props.get("workbook_hash").is_none(),
834            "the provenance schema must never carry workbook_hash (Codex HIGH #3)"
835        );
836        assert_eq!(
837            schema["required"],
838            json!(["bundle_id", "version", "combined_hash"])
839        );
840    }
841
842    #[test]
843    fn empty_input_keys_projects_full_pool() {
844        // CR-02 defense-in-depth: a Tool with EMPTY input_keys must advertise the
845        // FULL shared-input pool (never an empty inputs.properties while
846        // validate_input accepts the pool — the served schema can never be stricter
847        // than the runtime). three_input_manifest_and_map's single tool has empty
848        // input_keys.
849        let (m, cm) = three_input_manifest_and_map();
850        let tool = &cm.tools[0];
851        assert!(
852            tool.input_keys.is_empty(),
853            "the fixture tool has no derived keys"
854        );
855        let schema = input_schema_for_tool(&m, &cm, tool);
856        let props = schema["properties"]["inputs"]["properties"]
857            .as_object()
858            .expect("inputs.properties object");
859        assert!(
860            !props.is_empty(),
861            "an empty-input_keys tool advertises the full pool, not an empty schema"
862        );
863        // Every shared input key is advertised (the full pool projection).
864        for key in ["gross_income", "filing_status", "deductions"] {
865            assert!(
866                props.contains_key(key),
867                "the full shared-input pool is advertised: missing {key}"
868            );
869        }
870        // The strict envelope survives (V5).
871        assert_eq!(
872            schema["properties"]["inputs"]["additionalProperties"],
873            json!(false),
874            "the strict per-tool envelope is preserved"
875        );
876    }
877
878    #[test]
879    fn populated_input_keys_projects_only_reached() {
880        // The complement: a populated input_keys projects EXACTLY those keys (the
881        // production multi-tool shape — unchanged by the CR-02 fallback).
882        let (m, mut cm) = three_input_manifest_and_map();
883        cm.tools[0].input_keys = vec!["gross_income".to_string()];
884        let schema = input_schema_for_tool(&m, &cm, &cm.tools[0]);
885        let props = schema["properties"]["inputs"]["properties"]
886            .as_object()
887            .expect("inputs.properties object");
888        assert_eq!(props.len(), 1, "only the reached key is projected");
889        assert!(props.contains_key("gross_income"));
890        assert!(!props.contains_key("deductions"));
891    }
892}