Skip to main content

pmcp_server_toolkit/workbook/
input.rs

1//! The strict `calculate`/`explain` input DTO + fail-closed tier enforcement
2//! (WBSV-06, WR-02/WR-05/V4).
3//!
4//! The wire input is `{ "inputs": { <json_key>: <value>, … },
5//! "overrides": { <param_key>: <value>, … } }`. The DTO is
6//! `#[serde(deny_unknown_fields)]` (`additionalProperties:false`): an unknown
7//! TOP-LEVEL key is rejected as `invalid_input`.
8//!
9//! Every gate fails CLOSED — a `?`-or-reject arm, NEVER an `if let Some(...)`
10//! skip that fails open:
11//!
12//! - **WR-05** — a supplied input whose `cell_map` entry's `seed_coord` has no
13//!   matching manifest role is rejected (the manifest and cell_map are separate
14//!   embedded artifacts and can skew across a partial regeneration; a roleless
15//!   seed would bypass the dtype + enum gates).
16//! - **WR-02** — the enum-membership test is STRING-ONLY: a non-string value
17//!   tested against a string `allowed_values` set can never be a member and is
18//!   rejected (a SKEWED `Dtype::Number` + `allowed_values` manifest fails closed
19//!   here rather than silently seeding).
20//! - **V4** — a per-call override of a strict-constant (BA-governed) cell is
21//!   rejected via [`pmcp_workbook_runtime::is_strict_constant`].
22//!
23//! Every rejection populates a self-repair field (`allowed`/`range`/`required`)
24//! so the `isError` envelope carries it.
25
26// Compiler/clippy-enforced panic-freedom on the value path (mirrors the runtime).
27#![cfg_attr(
28    not(test),
29    deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
30)]
31
32use std::collections::BTreeMap;
33
34use serde::Deserialize;
35use serde_json::Value;
36
37use pmcp_workbook_runtime::{
38    is_computed, is_strict_constant, CellMap, CellRole, CellValue, Dtype, InputTier, Manifest, Role,
39};
40
41use super::error::WorkbookToolError;
42
43/// The strict wire input DTO: `inputs` (the per-call values keyed by their
44/// neutral `json_key`) and optional `overrides` (variable-tier param tweaks).
45/// `deny_unknown_fields` rejects any other top-level key.
46#[derive(Debug, Clone, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct CalculateInput {
49    /// The inputs keyed by their neutral `json_key` (the manifest `Role::Input`
50    /// columns). Defaults empty (the manifest tier defaults fill any omitted
51    /// input).
52    #[serde(default)]
53    pub inputs: BTreeMap<String, Value>,
54    /// Variable-tier parameter overrides. A strict constant key here is rejected
55    /// (V4).
56    #[serde(default)]
57    pub overrides: BTreeMap<String, Value>,
58}
59
60/// A validated, tier-checked input ready to seed the
61/// [`pmcp_workbook_runtime`] executor. `seeds` is `cell_key -> value` (the inputs
62/// mapped through the embedded `cell_map`, plus any accepted variable-tier
63/// overrides).
64#[derive(Debug, Clone)]
65pub struct ValidatedInput {
66    /// `cell_key -> seed value` for the executor `CellEnv`.
67    pub seeds: BTreeMap<String, Value>,
68    /// The accepted variable-tier override keys (for explain/audit).
69    pub accepted_overrides: Vec<String>,
70    /// The NORMALIZED canonical wire DTO (caller `inputs` + accepted `overrides`,
71    /// both `BTreeMap`-ordered → deterministic). This is the SAME shape
72    /// [`validate_input`] accepts, so it round-trips.
73    pub canonical_dto: Value,
74}
75
76/// Validate + tier-check the raw tool args against the manifest + cell_map,
77/// fail-closed.
78///
79/// # Errors
80///
81/// Returns a [`WorkbookToolError`] (NOT an `Err` the handler surfaces as a
82/// protocol error — the handler renders it as `isError:true`) when:
83/// - arg-parse / `deny_unknown_fields` fails (`invalid_input`);
84/// - an `inputs` key is not a known `cell_map` `json_key` (`invalid_input`, WR-05);
85/// - a supplied input's `seed_coord` has no manifest role (`invalid_input`, WR-05);
86/// - a value fails its declared dtype or closed-enum membership (`invalid_input`,
87///   WR-01/WR-02);
88/// - an `overrides` key resolves to a strict constant (`strict_constant_override`,
89///   V4) or to no manifest cell (`unsupported_option`).
90#[allow(clippy::result_large_err)]
91pub fn validate_input(
92    args: Value,
93    manifest: &Manifest,
94    cell_map: &CellMap,
95) -> Result<ValidatedInput, WorkbookToolError> {
96    let input: CalculateInput = serde_json::from_value(args)
97        .map_err(|e| WorkbookToolError::invalid_input(format!("invalid arguments: {e}")))?;
98
99    // 1. Seed every manifest input with its tier default (so an omitted input
100    //    still resolves), then overlay the caller-supplied + override values.
101    let mut seeds = seed_tier_defaults(manifest);
102
103    // 2. Map each supplied input key → seed_coord via the cell_map (WR-05).
104    seed_supplied_inputs(&input.inputs, manifest, cell_map, &mut seeds)?;
105
106    // 3. Tier-check each override; accept variable-tier, reject strict constants.
107    let accepted_overrides = seed_accepted_overrides(&input.overrides, manifest, &mut seeds)?;
108
109    let canonical_dto = serde_json::json!({
110        "inputs": &input.inputs,
111        "overrides": &input.overrides,
112    });
113
114    Ok(ValidatedInput {
115        seeds,
116        accepted_overrides,
117        canonical_dto,
118    })
119}
120
121/// Phase 1: seed each manifest `Role::Input` cell with its tier default, so an
122/// omitted input still resolves to a value before caller overlays.
123fn seed_tier_defaults(manifest: &Manifest) -> BTreeMap<String, Value> {
124    let mut seeds: BTreeMap<String, Value> = BTreeMap::new();
125    for role in &manifest.cells {
126        if matches!(role.role, Role::Input) {
127            if let Some(default) = tier_default(role) {
128                seeds.insert(role.cell.clone(), default);
129            }
130        }
131    }
132    seeds
133}
134
135/// Phase 2: map each supplied input key → its `seed_coord` via the cell_map and
136/// overlay its (dtype-checked) value onto `seeds`.
137///
138/// WR-05 (fail-closed): an unknown input key is `invalid_input` (a bad FIELD),
139/// and a supplied input's `seed_coord` MUST have a manifest role — `?`-or-reject,
140/// NEVER an if-let-Some skip. A roleless seed would bypass the dtype + enum gates.
141#[allow(clippy::result_large_err)]
142fn seed_supplied_inputs(
143    inputs: &BTreeMap<String, Value>,
144    manifest: &Manifest,
145    cell_map: &CellMap,
146    seeds: &mut BTreeMap<String, Value>,
147) -> Result<(), WorkbookToolError> {
148    for (key, value) in inputs {
149        let entry = cell_map
150            .inputs
151            .iter()
152            .find(|e| &e.json_key == key)
153            .ok_or_else(|| {
154                WorkbookToolError::invalid_input_field(key.clone(), known_input_keys(cell_map))
155            })?;
156        let role =
157            pmcp_workbook_runtime::role_for_cell(manifest, &entry.seed_coord).ok_or_else(|| {
158                WorkbookToolError::invalid_input(format!(
159                    "internal: input '{key}' maps to {} which has no manifest role",
160                    entry.seed_coord
161                ))
162            })?;
163        check_value_dtype(role, key, value)?;
164        seeds.insert(entry.seed_coord.clone(), value.clone());
165    }
166    Ok(())
167}
168
169/// Phase 3: tier-check each override, overlaying accepted variable-tier values
170/// onto `seeds` and returning the accepted keys (for explain/audit).
171///
172/// Each override is classified by [`classify_override`] into accept-or-reject;
173/// an accepted override is dtype-checked and seeded.
174#[allow(clippy::result_large_err)]
175fn seed_accepted_overrides(
176    overrides: &BTreeMap<String, Value>,
177    manifest: &Manifest,
178    seeds: &mut BTreeMap<String, Value>,
179) -> Result<Vec<String>, WorkbookToolError> {
180    let mut accepted_overrides = Vec::new();
181    for (key, value) in overrides {
182        let role = classify_override(manifest, key)?;
183        check_value_dtype(role, key, value)?;
184        seeds.insert(role.cell.clone(), value.clone());
185        accepted_overrides.push(key.clone());
186    }
187    Ok(accepted_overrides)
188}
189
190/// Classify an override key against the manifest, returning the target
191/// [`CellRole`] only when the override is acceptable (a variable-tier cell).
192///
193/// Rejections (all fail-closed):
194/// - a strict-constant key → `strict_constant_override` (V4);
195/// - a computed-cell key → `unsupported_option` (WR-02: blocks output forging);
196/// - an unknown key → `unsupported_option`.
197///
198/// The shared `is_computed` predicate is the same one `variable_tier_keys`
199/// filters on, so the reject gate and the advertised allow-list cannot drift.
200#[allow(clippy::result_large_err)]
201fn classify_override<'a>(
202    manifest: &'a Manifest,
203    key: &str,
204) -> Result<&'a CellRole, WorkbookToolError> {
205    match find_role_by_key(manifest, key) {
206        Some(r) if is_strict_constant(r) => Err(WorkbookToolError::strict_constant_override(
207            key.to_string(),
208            variable_tier_keys(manifest),
209        )),
210        Some(r) if is_computed(r) => Err(WorkbookToolError::unsupported_option(
211            key.to_string(),
212            variable_tier_keys(manifest),
213        )),
214        Some(r) => Ok(r),
215        None => Err(WorkbookToolError::unsupported_option(
216            key.to_string(),
217            variable_tier_keys(manifest),
218        )),
219    }
220}
221
222/// The tier default value for an input cell (`None` if the cell carries no tier).
223fn tier_default(role: &CellRole) -> Option<Value> {
224    match &role.tier {
225        Some(InputTier::Variable { default })
226        | Some(InputTier::BoundedVariable { default, .. }) => cell_value_to_json(default),
227        None => None,
228    }
229}
230
231/// Map a manifest [`CellValue`] default to a JSON value.
232fn cell_value_to_json(v: &CellValue) -> Option<Value> {
233    match v {
234        CellValue::Number(n) => serde_json::Number::from_f64(*n).map(Value::Number),
235        CellValue::Text(s) => Some(Value::String(s.clone())),
236        CellValue::Bool(b) => Some(Value::Bool(*b)),
237        CellValue::Empty => Some(Value::Null),
238        CellValue::Error(_) => None,
239    }
240}
241
242/// WR-01/WR-02: type-check a caller-supplied JSON value against the declared
243/// [`Dtype`] of the manifest [`CellRole`] it will seed, then (for a frozen input)
244/// enum-membership, BEFORE it reaches the evaluator. A `null` carries no type
245/// information — the evaluator's empty-cell semantics handle it. The enum gate is
246/// STRING-ONLY: a non-string value can never be a member of a string
247/// `allowed_values` set, so a SKEWED `Dtype::Number` + `allowed_values` manifest
248/// fails closed here (WR-02).
249#[allow(clippy::result_large_err)]
250fn check_value_dtype(role: &CellRole, field: &str, value: &Value) -> Result<(), WorkbookToolError> {
251    if value.is_null() {
252        return Ok(());
253    }
254    let ok = match role.dtype {
255        Dtype::Number => value.is_number(),
256        Dtype::Text => value.is_string(),
257        Dtype::Bool => value.is_boolean(),
258    };
259    if !ok {
260        let expected = super::schema::dtype_json_type(role.dtype);
261        return Err(WorkbookToolError::invalid_input(format!(
262            "input '{field}' must be a {expected} (cell {} is declared {expected})",
263            role.cell
264        )));
265    }
266    // Enum-membership gate (WR-02): STRING-ONLY membership runs AFTER the dtype
267    // gate. A non-string value can never be a member, so a skewed manifest fails
268    // closed here.
269    if let Some(allowed) = &role.allowed_values {
270        let is_member = value
271            .as_str()
272            .is_some_and(|s| allowed.iter().any(|a| a == s));
273        if !is_member {
274            return Err(WorkbookToolError::invalid_enum(
275                field,
276                allowed.clone(),
277                format!(
278                    "input '{field}' must be one of the allowed values \
279                     (cell {} is a closed enum)",
280                    role.cell
281                ),
282            ));
283        }
284    }
285    Ok(())
286}
287
288/// Find a manifest [`CellRole`] for an override key by its `name` or its
289/// fully-qualified `cell` key only (NOT by free-text `meaning` — ambiguous).
290fn find_role_by_key<'a>(manifest: &'a Manifest, key: &str) -> Option<&'a CellRole> {
291    manifest
292        .cells
293        .iter()
294        .find(|r| r.name.as_deref() == Some(key) || r.cell == key)
295}
296
297/// The variable-tier override keys a caller MAY set (the
298/// `strict_constant_override` allowed alternatives).
299///
300/// `pub(crate)` so the served `calculate` input schema builder
301/// ([`crate::workbook::schema::input_schema_for_manifest`]) can ADVERTISE the legal
302/// override keys as a `properties` map (F2) — the SAME list this validator and the
303/// reject path use, so "what we advertise" and "what we accept" cannot drift.
304pub(crate) fn variable_tier_keys(manifest: &Manifest) -> Vec<String> {
305    manifest
306        .cells
307        .iter()
308        .filter(|r| !is_strict_constant(r) && !is_computed(r))
309        .filter_map(|r| r.name.clone().or_else(|| Some(r.cell.clone())))
310        .collect()
311}
312
313/// The known input `json_key`s (for an unknown-field allowed-list).
314fn known_input_keys(cell_map: &CellMap) -> Vec<String> {
315    cell_map.inputs.iter().map(|e| e.json_key.clone()).collect()
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use pmcp_workbook_runtime::{CellEntry, CellMap, Tool};
322    use proptest::prelude::*;
323    use serde_json::json;
324
325    // ---- Tax-domain fixtures (S-4: gross_income / filing_status; zero customer
326    //      identifiers) ---------------------------------------------------------
327
328    fn input_role(
329        cell: &str,
330        dtype: Dtype,
331        name: &str,
332        tier: Option<InputTier>,
333        allowed: Option<Vec<String>>,
334    ) -> CellRole {
335        CellRole {
336            cell: cell.to_string(),
337            role: Role::Input,
338            name: Some(name.to_string()),
339            unit: None,
340            meaning: None,
341            dtype,
342            colour_evidence: None,
343            source: "test".to_string(),
344            notes: None,
345            tier,
346            allowed_values: allowed,
347        }
348    }
349
350    fn manifest() -> Manifest {
351        Manifest {
352            schema_version: 1,
353            workflow: "tax-calc".to_string(),
354            workbook_hash: None,
355            ratified: true,
356            ratified_by: None,
357            ratified_at: None,
358            cells: vec![
359                input_role(
360                    "1_Inputs!B2",
361                    Dtype::Number,
362                    "gross_income",
363                    Some(InputTier::Variable {
364                        default: CellValue::Number(0.0),
365                    }),
366                    None,
367                ),
368                input_role(
369                    "1_Inputs!B3",
370                    Dtype::Text,
371                    "filing_status",
372                    Some(InputTier::Variable {
373                        default: CellValue::Text("single".to_string()),
374                    }),
375                    Some(vec![
376                        "single".to_string(),
377                        "married_joint".to_string(),
378                        "head_of_household".to_string(),
379                    ]),
380                ),
381                // A strict (BA-governed) constant: Role::Constant + tier None.
382                CellRole {
383                    cell: "2_Rates!B2".to_string(),
384                    role: Role::Constant,
385                    name: Some("const_rate".to_string()),
386                    unit: None,
387                    meaning: None,
388                    dtype: Dtype::Number,
389                    colour_evidence: None,
390                    source: "test".to_string(),
391                    notes: None,
392                    tier: None,
393                    allowed_values: None,
394                },
395            ],
396            loop_block: None,
397            governed_data: vec![],
398            changelog: vec![],
399            capability_calls: vec![],
400            annotations: vec![],
401        }
402    }
403
404    fn cell_map() -> CellMap {
405        CellMap {
406            inputs: vec![
407                CellEntry {
408                    json_key: "gross_income".to_string(),
409                    seed_coord: "1_Inputs!B2".to_string(),
410                    unit: Some("USD".to_string()),
411                },
412                CellEntry {
413                    json_key: "filing_status".to_string(),
414                    seed_coord: "1_Inputs!B3".to_string(),
415                    unit: None,
416                },
417            ],
418            tools: vec![Tool {
419                name: "calculate".to_string(),
420                description: None,
421                input_keys: Vec::new(),
422                outputs: vec![CellEntry {
423                    json_key: "tax_owed".to_string(),
424                    seed_coord: "3_Outputs!B3".to_string(),
425                    unit: Some("USD".to_string()),
426                }],
427                oracle: std::collections::BTreeMap::new(),
428            }],
429        }
430    }
431
432    #[test]
433    fn valid_inputs_seed_their_cells() {
434        let args = json!({ "inputs": { "gross_income": 60000.0 } });
435        let v = validate_input(args, &manifest(), &cell_map()).expect("valid");
436        assert_eq!(v.seeds.get("1_Inputs!B2"), Some(&json!(60000.0)));
437    }
438
439    #[test]
440    fn unknown_top_level_field_is_rejected() {
441        let args = json!({ "bogus": 1 });
442        let err = validate_input(args, &manifest(), &cell_map())
443            .expect_err("unknown top-level field rejected (deny_unknown_fields)");
444        assert_eq!(err.code, "invalid_input");
445    }
446
447    #[test]
448    fn unknown_input_key_is_invalid_input_with_allowed() {
449        // WR-05: an unknown input KEY is a bad FIELD → invalid_input.
450        let args = json!({ "inputs": { "not_a_real_input": 1 } });
451        let err = validate_input(args, &manifest(), &cell_map())
452            .expect_err("an unknown input key is rejected (WR-05)");
453        assert_eq!(err.code, "invalid_input");
454        assert_eq!(err.field.as_deref(), Some("not_a_real_input"));
455        assert!(err.allowed.is_some(), "carries the known input keys");
456    }
457
458    #[test]
459    fn cell_map_entry_without_manifest_role_is_rejected_fail_closed() {
460        // WR-05: a cell_map input whose seed_coord has NO manifest role must be
461        // rejected (fail-closed, NOT an if-let-Some skip).
462        let mut cm = cell_map();
463        cm.inputs.push(CellEntry {
464            json_key: "orphan".to_string(),
465            seed_coord: "9_Nowhere!Z99".to_string(),
466            unit: None,
467        });
468        let args = json!({ "inputs": { "orphan": "oops" } });
469        let err = validate_input(args, &manifest(), &cm)
470            .expect_err("a cell_map entry with no manifest role is rejected (WR-05)");
471        assert_eq!(err.code, "invalid_input");
472        assert!(
473            err.reason.contains("no manifest role") && err.reason.contains("9_Nowhere!Z99"),
474            "the error names the internal-consistency failure: {}",
475            err.reason
476        );
477    }
478
479    #[test]
480    fn non_numeric_value_for_number_cell_is_rejected() {
481        let args = json!({ "inputs": { "gross_income": "oops" } });
482        let err = validate_input(args, &manifest(), &cell_map())
483            .expect_err("a non-numeric value for a numeric input is rejected (WR-01)");
484        assert_eq!(err.code, "invalid_input");
485        assert!(err.reason.contains("number"), "names the expected type");
486    }
487
488    #[test]
489    fn out_of_enum_value_is_rejected_with_allowed() {
490        let args = json!({ "inputs": { "filing_status": "alien" } });
491        let err = validate_input(args, &manifest(), &cell_map())
492            .expect_err("an out-of-enum value is rejected (WR-02)");
493        assert_eq!(err.code, "invalid_input");
494        assert_eq!(err.field.as_deref(), Some("filing_status"));
495        assert_eq!(
496            err.allowed,
497            Some(vec![
498                "single".to_string(),
499                "married_joint".to_string(),
500                "head_of_household".to_string(),
501            ]),
502            "the allowed enum members live in the error"
503        );
504    }
505
506    #[test]
507    fn in_enum_value_passes_the_gate() {
508        for legal in ["single", "married_joint", "head_of_household"] {
509            let args = json!({ "inputs": { "filing_status": legal } });
510            let v = validate_input(args, &manifest(), &cell_map())
511                .expect("an in-enum value passes the membership gate");
512            assert_eq!(v.seeds.get("1_Inputs!B3"), Some(&json!(legal)));
513        }
514    }
515
516    #[test]
517    fn non_string_value_on_string_enum_is_rejected_fail_closed() {
518        // WR-02: a SKEWED manifest (Dtype::Number + allowed_values) must fail
519        // CLOSED — a number can never be a member of a string enum.
520        let mut m = manifest();
521        // skew filing_status to Dtype::Number while keeping its string enum.
522        m.cells[1].dtype = Dtype::Number;
523        let args = json!({ "inputs": { "filing_status": 42 } });
524        let err = validate_input(args, &m, &cell_map())
525            .expect_err("a non-string value on a string-enum input is rejected (WR-02)");
526        assert_eq!(err.code, "invalid_input");
527        assert_eq!(err.field.as_deref(), Some("filing_status"));
528        assert!(
529            err.allowed.is_some(),
530            "still carries the allowed repair field"
531        );
532    }
533
534    #[test]
535    fn strict_constant_override_is_rejected() {
536        // V4: a per-call override of a strict-constant cell is rejected.
537        let args = json!({ "overrides": { "const_rate": 0.40 } });
538        let err = validate_input(args, &manifest(), &cell_map())
539            .expect_err("a strict-constant override is rejected (V4)");
540        assert_eq!(err.code, "strict_constant_override");
541        assert_eq!(err.field.as_deref(), Some("const_rate"));
542        assert!(err.allowed.is_some(), "carries variable-tier alternatives");
543    }
544
545    #[test]
546    fn override_naming_no_cell_is_unsupported_option() {
547        let args = json!({ "overrides": { "ghost_param": 1 } });
548        let err = validate_input(args, &manifest(), &cell_map())
549            .expect_err("an override naming no manifest cell is unsupported_option");
550        assert_eq!(err.code, "unsupported_option");
551    }
552
553    /// A manifest extending the tax fixture with a `Role::Output` cell (`tax_owed`)
554    /// and a `Role::Formula` cell (`taxable_income`) — the two computed roles an
555    /// override must never be allowed to seed (WR-02).
556    fn manifest_with_computed_cells() -> Manifest {
557        let mut m = manifest();
558        for (cell, role, name) in [
559            ("3_Outputs!B3", Role::Output, "tax_owed"),
560            ("3_Outputs!B2", Role::Formula, "taxable_income"),
561        ] {
562            m.cells.push(CellRole {
563                role,
564                unit: Some("USD".to_string()),
565                ..input_role(cell, Dtype::Number, name, None, None)
566            });
567        }
568        m
569    }
570
571    #[test]
572    fn override_on_computed_cell_is_rejected_unsupported_option() {
573        // WR-02: an override targeting a computed (Role::Output/Role::Formula) cell
574        // is the live output-forging vector after 92-06 (a seeded value now wins over
575        // the IR formula). It must be rejected with unsupported_option and NEVER
576        // appear in accepted_overrides. Target each by name and by cell key.
577        for key in ["tax_owed", "3_Outputs!B3", "taxable_income", "3_Outputs!B2"] {
578            let args = json!({ "overrides": { key: 999.0 } });
579            let err = validate_input(args, &manifest_with_computed_cells(), &cell_map())
580                .expect_err("a computed-cell override is rejected (WR-02)");
581            assert_eq!(err.code, "unsupported_option", "key {key} rejected");
582            // The allow-list it surfaces never offers a computed key.
583            let allowed = err
584                .allowed
585                .clone()
586                .expect("carries the variable-tier allowed-list");
587            assert!(
588                !allowed.iter().any(|k| {
589                    ["tax_owed", "3_Outputs!B3", "taxable_income", "3_Outputs!B2"]
590                        .contains(&k.as_str())
591                }),
592                "a computed key is never offered as an allowed override (key {key}): {allowed:?}"
593            );
594        }
595    }
596
597    // ---- Gemini Excel-edge seeds: empty-string vs null --------------------
598
599    #[test]
600    fn empty_string_for_enum_input_is_rejected() {
601        // An empty string is NOT a valid enum member — it must not be silently
602        // coerced to a legal member.
603        let args = json!({ "inputs": { "filing_status": "" } });
604        let err = validate_input(args, &manifest(), &cell_map())
605            .expect_err("an empty string for an enum input is rejected");
606        assert_eq!(err.code, "invalid_input");
607        assert_eq!(err.field.as_deref(), Some("filing_status"));
608    }
609
610    #[test]
611    fn null_for_required_input_is_handled_by_empty_cell_semantics() {
612        // A JSON null carries no type — it passes the dtype/enum gates (the
613        // evaluator's empty-cell semantics handle it). It does NOT spuriously
614        // reject, and it does NOT seed a bogus typed value.
615        let args = json!({ "inputs": { "gross_income": null } });
616        let v = validate_input(args, &manifest(), &cell_map())
617            .expect("null passes the gate (empty-cell semantics)");
618        assert_eq!(v.seeds.get("1_Inputs!B2"), Some(&Value::Null));
619    }
620
621    #[test]
622    fn null_for_enum_input_is_not_silently_coerced() {
623        // null on an enum input passes the present-only gate (no membership check
624        // runs on null), but it is seeded as null — never coerced to a member.
625        let args = json!({ "inputs": { "filing_status": null } });
626        let v = validate_input(args, &manifest(), &cell_map())
627            .expect("null on an enum input passes (empty-cell semantics)");
628        assert_eq!(v.seeds.get("1_Inputs!B3"), Some(&Value::Null));
629    }
630
631    // ---- proptest fuzz: validate_input is TOTAL over adversarial inputs ----
632
633    /// An arbitrary JSON scalar/array generator covering the adversarial shapes
634    /// (mixed types, out-of-range numbers, oversized strings) plus the seeded
635    /// Excel coercion edges (empty string, null).
636    fn arb_json_value() -> impl Strategy<Value = Value> {
637        prop_oneof![
638            Just(Value::Null),
639            Just(json!("")),
640            any::<bool>().prop_map(Value::Bool),
641            any::<f64>()
642                .prop_filter("finite", |n| n.is_finite())
643                .prop_map(|n| json!(n)),
644            ".*".prop_map(Value::String),
645            prop::collection::vec(any::<i64>(), 0..4).prop_map(|v| json!(v)),
646        ]
647    }
648
649    proptest! {
650        #![proptest_config(ProptestConfig::with_cases(512))]
651
652        /// T-92-09: validate_input is TOTAL over arbitrary/adversarial input maps
653        /// — it NEVER panics and ALWAYS returns either Ok or a WorkbookToolError.
654        /// Random keys (some valid json_keys, some unknown), mixed JSON value
655        /// types, and the empty-string/null edges are all covered.
656        #[test]
657        fn prop_validate_input_total(
658            keys in prop::collection::vec(
659                prop_oneof![
660                    Just("gross_income".to_string()),
661                    Just("filing_status".to_string()),
662                    Just("const_rate".to_string()),
663                    "[a-z_]{1,12}",
664                ],
665                0..5,
666            ),
667            vals in prop::collection::vec(arb_json_value(), 0..5),
668            use_overrides in any::<bool>(),
669        ) {
670            let mut map = serde_json::Map::new();
671            for (k, v) in keys.iter().zip(vals.iter()) {
672                map.insert(k.clone(), v.clone());
673            }
674            let bucket = if use_overrides { "overrides" } else { "inputs" };
675            let args = json!({ bucket: Value::Object(map) });
676
677            // The ONLY contract: total + fail-closed. No panic; Ok or Err.
678            match validate_input(args, &manifest(), &cell_map()) {
679                Ok(_) | Err(_) => {},
680            }
681        }
682
683        /// The empty-string and null edges are deterministically covered in the
684        /// proptest corpus by feeding them directly on both an enum and a numeric
685        /// input — validate_input stays total.
686        #[test]
687        fn prop_excel_edge_cases_are_total(
688            edge in prop_oneof![Just(json!("")), Just(Value::Null)],
689            on_enum in any::<bool>(),
690        ) {
691            let key = if on_enum { "filing_status" } else { "gross_income" };
692            let args = json!({ "inputs": { key: edge } });
693            match validate_input(args, &manifest(), &cell_map()) {
694                Ok(_) | Err(_) => {},
695            }
696        }
697    }
698}