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 provenance stamp sub-schema — present on every result. Carries
297/// `bundle_id`/`version`/`combined_hash` (NEVER `workbook_hash` — Codex HIGH #3).
298#[must_use]
299pub fn provenance_schema() -> Value {
300    json!({
301        "type": "object",
302        "additionalProperties": false,
303        "properties": {
304            "bundle_id": { "type": "string" },
305            "version": { "type": "string" },
306            "combined_hash": { "type": "string" },
307        },
308        "required": ["bundle_id", "version", "combined_hash"],
309    })
310}
311
312/// The strict input schema for `calculate`/`explain`: an `object` with
313/// `additionalProperties:false` accepting only the manifest `Role::Input`
314/// columns (by their neutral `json_key`) plus an optional `overrides` map for
315/// variable-tier params. The DTO's `deny_unknown_fields` is the runtime gate;
316/// this schema mirrors it for discovery.
317#[must_use]
318pub fn input_schema_for_manifest(manifest: &Manifest, cell_map: &CellMap) -> Value {
319    let mut input_props = Map::new();
320    for entry in &cell_map.inputs {
321        input_props.insert(
322            entry.json_key.clone(),
323            input_prop_for_entry(manifest, entry),
324        );
325    }
326    assemble_input_schema(manifest, input_props)
327}
328
329/// Build the strict per-tool input schema (WBV2-04): an `object` with
330/// `additionalProperties:false` carrying ONLY this tool's DAG-derived
331/// `input_keys` (the subset of the shared `cell_map.inputs` pool transitively
332/// reachable upstream of this tool's outputs), plus the F2 `overrides` block.
333///
334/// The strict envelope (`additionalProperties:false`, V5) MUST survive: a client
335/// trusting the advertised schema must never be able to send a key the runtime
336/// then rejects.
337#[must_use]
338pub fn input_schema_for_tool(manifest: &Manifest, cell_map: &CellMap, tool: &Tool) -> Value {
339    // O(1) membership for the per-tool key projection (was a linear scan of
340    // `input_keys` per input — O(inputs × keys)).
341    let reached: HashSet<&str> = tool.input_keys.iter().map(String::as_str).collect();
342    // CR-02 defense-in-depth: a tool with an EMPTY input_keys advertised an empty
343    // inputs.properties while `validate_input` accepts the full shared pool — the
344    // served schema was STRICTER than the runtime (the V5 invariant inverted). When
345    // no DAG derivation populated input_keys (a hand-built / single-tool fallback
346    // bundle), project the FULL shared-input pool ("no derivation" => "all shared
347    // inputs") so the advertised schema is never stricter than what the runtime
348    // accepts. The production multi-tool path always populates input_keys, so this
349    // fires only for the fallback shape.
350    let project_all = tool.input_keys.is_empty();
351    let mut input_props = Map::new();
352    for entry in &cell_map.inputs {
353        // Project this tool's DAG-derived keys (or the full pool when empty — CR-02).
354        if project_all || reached.contains(entry.json_key.as_str()) {
355            input_props.insert(
356                entry.json_key.clone(),
357                input_prop_for_entry(manifest, entry),
358            );
359        }
360    }
361    assemble_input_schema(manifest, input_props)
362}
363
364/// Build the typed JSON-Schema property for one input [`CellEntry`] — its dtype,
365/// unit, meaning, and (for a frozen input) closed-enum domain. Shared by the
366/// manifest-level and per-tool input-schema builders so the per-input shape
367/// cannot drift.
368fn input_prop_for_entry(manifest: &Manifest, entry: &CellEntry) -> Value {
369    let role = role_for_seed(manifest, &entry.seed_coord);
370    let dtype = role.map_or(Dtype::Number, |r| r.dtype);
371    let mut prop = Map::new();
372    prop.insert("type".to_string(), json!(dtype_json_type(dtype)));
373    if let Some(unit) = entry.unit.as_deref() {
374        prop.insert("unit".to_string(), json!(unit));
375    }
376    if let Some(meaning) = role.and_then(|r| r.meaning.as_deref()) {
377        prop.insert("description".to_string(), json!(meaning));
378    }
379    // A frozen input (allowed_values from the workbook) advertises its closed
380    // domain as a JSON-Schema enum, verbatim workbook order. The input stays
381    // OPTIONAL — this fn builds no `required` array.
382    if let Some(allowed) = role.and_then(|r| r.allowed_values.as_ref()) {
383        prop.insert("enum".to_string(), json!(allowed));
384    }
385    Value::Object(prop)
386}
387
388/// Assemble the strict input-schema envelope from the already-built per-input
389/// `properties` map: the `inputs` object (strict) + the F2 `overrides` block
390/// (advertising the variable-tier override keys). Shared by the manifest-level
391/// and per-tool builders.
392fn assemble_input_schema(manifest: &Manifest, input_props: Map<String, Value>) -> Value {
393    // F2: ADVERTISE the legal override keys so an LLM/caller can DISCOVER them,
394    // rather than leaving `overrides` an opaque open map described in prose only.
395    // The keys are the SAME variable-tier list `validate_input` accepts (one source
396    // of truth — `crate::workbook::input::variable_tier_keys`), so advertisement and
397    // acceptance cannot drift. `additionalProperties` stays permissive (the open
398    // value-typed map) so this is a DISCOVERABILITY change only — `validate_input`'s
399    // accept/reject behavior is unchanged.
400    let mut override_props = Map::new();
401    for key in crate::workbook::input::variable_tier_keys(manifest) {
402        override_props.insert(
403            key,
404            json!({ "type": ["number", "string", "boolean", "null"] }),
405        );
406    }
407
408    json!({
409        "type": "object",
410        "additionalProperties": false,
411        "properties": {
412            "inputs": {
413                "type": "object",
414                "additionalProperties": false,
415                "properties": Value::Object(input_props),
416            },
417            "overrides": {
418                "type": "object",
419                "additionalProperties": { "type": ["number", "string", "boolean", "null"] },
420                "properties": Value::Object(override_props),
421                "description": "Variable-tier parameter overrides, keyed by parameter \
422                                name or cell key. Strict (BA-governed) constants are rejected.",
423            },
424        },
425    })
426}
427
428/// `get_manifest`/`diff_version` have no input — an empty strict object schema.
429#[must_use]
430pub fn empty_input_schema() -> Value {
431    json!({ "type": "object", "additionalProperties": false })
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use pmcp_workbook_runtime::CellValue;
438    use pmcp_workbook_runtime::{CellEntry, CellMap, InputTier, Role, Tool};
439
440    fn input_role(
441        cell: &str,
442        dtype: Dtype,
443        meaning: &str,
444        allowed: Option<Vec<String>>,
445    ) -> CellRole {
446        CellRole {
447            cell: cell.to_string(),
448            role: Role::Input,
449            name: None,
450            unit: Some("USD".to_string()),
451            meaning: Some(meaning.to_string()),
452            dtype,
453            colour_evidence: None,
454            source: "test".to_string(),
455            notes: None,
456            tier: Some(InputTier::Variable {
457                default: CellValue::Number(0.0),
458            }),
459            allowed_values: allowed,
460        }
461    }
462
463    fn output_role(cell: &str, meaning: &str) -> CellRole {
464        CellRole {
465            cell: cell.to_string(),
466            role: Role::Output,
467            name: None,
468            unit: Some("USD".to_string()),
469            meaning: Some(meaning.to_string()),
470            dtype: Dtype::Number,
471            colour_evidence: None,
472            source: "test".to_string(),
473            notes: None,
474            tier: None,
475            allowed_values: None,
476        }
477    }
478
479    fn manifest_with(cells: Vec<CellRole>) -> Manifest {
480        Manifest {
481            schema_version: 1,
482            workflow: "tax-calc".to_string(),
483            workbook_hash: None,
484            ratified: true,
485            ratified_by: None,
486            ratified_at: None,
487            cells,
488            loop_block: None,
489            governed_data: vec![],
490            changelog: vec![],
491            capability_calls: vec![],
492            annotations: vec![],
493        }
494    }
495
496    fn three_input_manifest_and_map() -> (Manifest, CellMap) {
497        let manifest = manifest_with(vec![
498            input_role("1_Inputs!B2", Dtype::Number, "Gross income", None),
499            input_role(
500                "1_Inputs!B3",
501                Dtype::Text,
502                "Filing status",
503                Some(vec!["single".to_string(), "married_joint".to_string()]),
504            ),
505            input_role("1_Inputs!B4", Dtype::Number, "Deductions", None),
506            output_role("3_Outputs!B2", "Taxable income"),
507            output_role("3_Outputs!B3", "Tax owed"),
508        ]);
509        let cell_map = CellMap {
510            inputs: vec![
511                CellEntry {
512                    json_key: "gross_income".to_string(),
513                    seed_coord: "1_Inputs!B2".to_string(),
514                    unit: Some("USD".to_string()),
515                },
516                CellEntry {
517                    json_key: "filing_status".to_string(),
518                    seed_coord: "1_Inputs!B3".to_string(),
519                    unit: None,
520                },
521                CellEntry {
522                    json_key: "deductions".to_string(),
523                    seed_coord: "1_Inputs!B4".to_string(),
524                    unit: Some("USD".to_string()),
525                },
526            ],
527            tools: vec![Tool {
528                name: "calculate".to_string(),
529                description: None,
530                input_keys: Vec::new(),
531                outputs: vec![
532                    CellEntry {
533                        json_key: "taxable_income".to_string(),
534                        seed_coord: "3_Outputs!B2".to_string(),
535                        unit: Some("USD".to_string()),
536                    },
537                    CellEntry {
538                        json_key: "tax_owed".to_string(),
539                        seed_coord: "3_Outputs!B3".to_string(),
540                        unit: Some("USD".to_string()),
541                    },
542                ],
543                oracle: std::collections::BTreeMap::new(),
544            }],
545        };
546        (manifest, cell_map)
547    }
548
549    #[test]
550    fn input_schema_is_strict_and_projects_all_inputs() {
551        let (m, cm) = three_input_manifest_and_map();
552        let schema = input_schema_for_manifest(&m, &cm);
553        // additionalProperties:false at the root (strict envelope).
554        assert_eq!(schema["additionalProperties"], false);
555        let props = &schema["properties"]["inputs"]["properties"];
556        // One typed property per input, dtype/unit/meaning carried.
557        assert_eq!(props["gross_income"]["type"], json!("number"));
558        assert_eq!(props["gross_income"]["unit"], json!("USD"));
559        assert_eq!(props["gross_income"]["description"], json!("Gross income"));
560        assert_eq!(props["filing_status"]["type"], json!("string"));
561        assert_eq!(props["deductions"]["type"], json!("number"));
562        // The inputs object is also strict.
563        assert_eq!(
564            schema["properties"]["inputs"]["additionalProperties"],
565            false
566        );
567    }
568
569    #[test]
570    fn input_schema_emits_enum_for_allowed_values_and_keeps_it_optional() {
571        let (m, cm) = three_input_manifest_and_map();
572        let schema = input_schema_for_manifest(&m, &cm);
573        let props = &schema["properties"]["inputs"]["properties"];
574        assert_eq!(
575            props["filing_status"]["enum"],
576            json!(["single", "married_joint"]),
577            "allowed_values surfaces as a JSON-Schema enum (verbatim order)"
578        );
579        // A non-enum input grows no enum key.
580        assert!(props["gross_income"].get("enum").is_none());
581        // The enum input is NOT required.
582        assert!(schema["properties"]["inputs"].get("required").is_none());
583    }
584
585    #[test]
586    fn output_schema_is_non_empty_and_carries_every_named_output() {
587        let (m, cm) = three_input_manifest_and_map();
588        let schema = output_schema_for_manifest(&m, &cm);
589        let outputs = &schema["properties"]["outputs"]["properties"];
590        // Every named output is present as a { value, unit } column.
591        assert!(outputs["taxable_income"].is_object());
592        assert!(outputs["tax_owed"].is_object());
593        assert_eq!(
594            outputs["taxable_income"]["properties"]["value"]["unit"],
595            "USD"
596        );
597        assert_eq!(
598            outputs["taxable_income"]["properties"]["value"]["type"],
599            "number"
600        );
601        // S-1: the success root enumerates exactly outputs/accepted_overrides
602        // (+ the shared envelope), with NO privileged headline scalar elevated
603        // above the uniform all-outputs projection. The forbidden headline key
604        // name is built dynamically so the literal does not appear in this file.
605        let headline_key = ["supply", "_", "total"].concat();
606        assert!(
607            schema["properties"].get(&headline_key).is_none(),
608            "no privileged headline field at the root (S-1)"
609        );
610        // The outputSchema is non-empty (WBSV-07): it has properties + provenance.
611        assert!(schema["properties"]["provenance"].is_object());
612        assert!(
613            !outputs
614                .as_object()
615                .expect("outputs is an object")
616                .is_empty(),
617            "outputSchema must enumerate at least one output"
618        );
619    }
620
621    #[test]
622    fn result_envelope_accepts_both_success_and_iserror_shapes() {
623        let (m, cm) = three_input_manifest_and_map();
624        let schema = output_schema_for_manifest(&m, &cm);
625        // The error-envelope fields are declared so an error result validates.
626        assert_eq!(schema["properties"]["isError"]["type"], "boolean");
627        assert_eq!(schema["properties"]["code"]["type"], "string");
628        assert_eq!(schema["properties"]["reason"]["type"], "string");
629        // The root cannot be closed (success/error key sets are disjoint).
630        assert_eq!(schema["additionalProperties"], true);
631        // provenance is the only required field (present on both paths).
632        assert_eq!(schema["required"], json!(["provenance"]));
633    }
634
635    #[test]
636    fn overrides_advertise_variable_tier_keys() {
637        // F2: the overrides block carries a `properties` map keyed by the legal
638        // variable-tier override keys (the SAME list validate_input accepts).
639        let (m, cm) = three_input_manifest_and_map();
640        let schema = input_schema_for_manifest(&m, &cm);
641        let override_props = &schema["properties"]["overrides"]["properties"];
642        let props = override_props
643            .as_object()
644            .expect("overrides.properties is an object");
645        // The three variable-tier inputs (Some(Variable) tier, not computed) are
646        // advertised by their `variable_tier_keys` identity (name.or(cell) — here
647        // the cell key, since these fixtures carry no `name`).
648        for key in ["1_Inputs!B2", "1_Inputs!B3", "1_Inputs!B4"] {
649            assert!(
650                props.contains_key(key),
651                "overrides advertises the variable-tier key `{key}` (got {props:?})"
652            );
653            assert_eq!(
654                override_props[key]["type"],
655                json!(["number", "string", "boolean", "null"]),
656                "each advertised override carries the permissive value-type union"
657            );
658        }
659        // Computed outputs are NEVER advertised as overridable (WR-02).
660        assert!(
661            !props.contains_key("3_Outputs!B2") && !props.contains_key("taxable_income"),
662            "a computed output is never an advertised override key"
663        );
664    }
665
666    #[test]
667    fn overrides_advertise_named_param_keys() {
668        // With a NAMED variable-tier input, the advertised override key is the
669        // human param name (variable_tier_keys uses name.or(cell)).
670        let mut named = input_role("1_Inputs!B2", Dtype::Number, "Gross income", None);
671        named.name = Some("in_gross_income".to_string());
672        let m = manifest_with(vec![named, output_role("3_Outputs!B2", "Taxable income")]);
673        let cm = CellMap {
674            inputs: vec![CellEntry {
675                json_key: "gross_income".to_string(),
676                seed_coord: "1_Inputs!B2".to_string(),
677                unit: Some("USD".to_string()),
678            }],
679            tools: vec![Tool {
680                name: "calculate".to_string(),
681                description: None,
682                input_keys: Vec::new(),
683                outputs: vec![CellEntry {
684                    json_key: "taxable_income".to_string(),
685                    seed_coord: "3_Outputs!B2".to_string(),
686                    unit: Some("USD".to_string()),
687                }],
688                oracle: std::collections::BTreeMap::new(),
689            }],
690        };
691        let schema = input_schema_for_manifest(&m, &cm);
692        let override_props = &schema["properties"]["overrides"]["properties"];
693        assert!(
694            override_props["in_gross_income"].is_object(),
695            "the named variable-tier param is advertised under its name"
696        );
697    }
698
699    #[test]
700    fn overrides_keep_open_additional_properties_for_discoverability_only() {
701        // F2 is advertisement-only: the open value-typed additionalProperties map
702        // is PRESERVED (validate_input's accept/reject behavior is unchanged) and
703        // the prose description stays.
704        let (m, cm) = three_input_manifest_and_map();
705        let schema = input_schema_for_manifest(&m, &cm);
706        let overrides = &schema["properties"]["overrides"];
707        assert_eq!(
708            overrides["additionalProperties"],
709            json!({ "type": ["number", "string", "boolean", "null"] }),
710            "the open value-typed additionalProperties map is preserved"
711        );
712        assert!(
713            overrides["description"].as_str().is_some(),
714            "the prose override description is retained"
715        );
716    }
717
718    #[test]
719    fn provenance_schema_uses_combined_hash_never_workbook_hash() {
720        let schema = provenance_schema();
721        let props = &schema["properties"];
722        assert!(props["combined_hash"].is_object());
723        assert!(
724            props.get("workbook_hash").is_none(),
725            "the provenance schema must never carry workbook_hash (Codex HIGH #3)"
726        );
727        assert_eq!(
728            schema["required"],
729            json!(["bundle_id", "version", "combined_hash"])
730        );
731    }
732
733    #[test]
734    fn empty_input_keys_projects_full_pool() {
735        // CR-02 defense-in-depth: a Tool with EMPTY input_keys must advertise the
736        // FULL shared-input pool (never an empty inputs.properties while
737        // validate_input accepts the pool — the served schema can never be stricter
738        // than the runtime). three_input_manifest_and_map's single tool has empty
739        // input_keys.
740        let (m, cm) = three_input_manifest_and_map();
741        let tool = &cm.tools[0];
742        assert!(
743            tool.input_keys.is_empty(),
744            "the fixture tool has no derived keys"
745        );
746        let schema = input_schema_for_tool(&m, &cm, tool);
747        let props = schema["properties"]["inputs"]["properties"]
748            .as_object()
749            .expect("inputs.properties object");
750        assert!(
751            !props.is_empty(),
752            "an empty-input_keys tool advertises the full pool, not an empty schema"
753        );
754        // Every shared input key is advertised (the full pool projection).
755        for key in ["gross_income", "filing_status", "deductions"] {
756            assert!(
757                props.contains_key(key),
758                "the full shared-input pool is advertised: missing {key}"
759            );
760        }
761        // The strict envelope survives (V5).
762        assert_eq!(
763            schema["properties"]["inputs"]["additionalProperties"],
764            json!(false),
765            "the strict per-tool envelope is preserved"
766        );
767    }
768
769    #[test]
770    fn populated_input_keys_projects_only_reached() {
771        // The complement: a populated input_keys projects EXACTLY those keys (the
772        // production multi-tool shape — unchanged by the CR-02 fallback).
773        let (m, mut cm) = three_input_manifest_and_map();
774        cm.tools[0].input_keys = vec!["gross_income".to_string()];
775        let schema = input_schema_for_tool(&m, &cm, &cm.tools[0]);
776        let props = schema["properties"]["inputs"]["properties"]
777            .as_object()
778            .expect("inputs.properties object");
779        assert_eq!(props.len(), 1, "only the reached key is projected");
780        assert!(props.contains_key("gross_income"));
781        assert!(!props.contains_key("deductions"));
782    }
783}