Skip to main content

car_validator/
lib.rs

1//! Action validation — precondition checking and invariant enforcement.
2
3use car_ir::precondition::{self, StateView};
4use car_ir::{Action, Precondition, ToolSchema};
5use std::collections::HashMap;
6
7/// A single validation failure.
8#[derive(Debug, Clone)]
9pub struct ValidationError {
10    pub action_id: String,
11    pub reason: String,
12}
13
14/// Aggregate result of validating an action.
15#[derive(Debug)]
16pub struct ValidationResult {
17    pub action_id: String,
18    pub errors: Vec<ValidationError>,
19}
20
21impl ValidationResult {
22    pub fn valid(&self) -> bool {
23        self.errors.is_empty()
24    }
25}
26
27/// Check a single precondition against state.
28pub fn check_precondition(pre: &Precondition, state: &dyn StateView) -> Option<String> {
29    precondition::check_precondition(pre, state)
30}
31
32/// Validate `action.parameters` against a tool's registered schema.
33///
34/// Two paths run side-by-side for backwards compatibility with the
35/// schemaless `register_tool(name)` registration path:
36///
37/// 1. **Required-key check** — kept for the legacy case where the
38///    schema is an empty object (no constraints) but a sibling
39///    `required` array is somehow present. Cheap, no-op when no
40///    `required` field exists.
41/// 2. **Full JSON Schema validation** via `jsonschema` — runs only
42///    when the schema is a non-trivial object (has `type` /
43///    `properties` / `required` / `enum` / etc.). Catches type
44///    mismatches like `{path: 42}` for a tool wanting
45///    `{path: string}` — which the legacy required-only check let
46///    through.
47///
48/// Tools registered via the schemaless API have an empty `{}` schema
49/// → both checks are no-ops → existing callers see no behavior change.
50fn validate_parameters(
51    action: &car_ir::Action,
52    tool: &str,
53    schema: &ToolSchema,
54    result: &mut ValidationResult,
55) {
56    // 1. Cheap required-key check (LLM may send unexpected extras —
57    // we intentionally do NOT reject unknown properties).
58    if let Some(required) = schema.parameters.get("required") {
59        if let Some(required_arr) = required.as_array() {
60            for req in required_arr {
61                if let Some(param_name) = req.as_str() {
62                    if !action.parameters.contains_key(param_name) {
63                        result.errors.push(ValidationError {
64                            action_id: action.id.clone(),
65                            reason: format!(
66                                "missing required parameter '{}' for tool '{}'",
67                                param_name, tool
68                            ),
69                        });
70                    }
71                }
72            }
73        }
74    }
75
76    // 2. Full JSON Schema validation when the schema actually carries
77    // constraints. An empty object schema (the schemaless registration
78    // case) trivially validates everything — skip the work.
79    if !schema_is_empty_object(&schema.parameters) {
80        let params_value = parameters_to_value(&action.parameters);
81        match jsonschema::validator_for(&schema.parameters) {
82            Ok(validator) => {
83                for err in validator.iter_errors(&params_value) {
84                    result.errors.push(ValidationError {
85                        action_id: action.id.clone(),
86                        reason: format!(
87                            "tool '{}' parameter validation: {} (at {})",
88                            tool, err, err.instance_path
89                        ),
90                    });
91                }
92            }
93            Err(e) => {
94                result.errors.push(ValidationError {
95                    action_id: action.id.clone(),
96                    reason: format!(
97                        "tool '{}' has an invalid registered JSON Schema: {}",
98                        tool, e
99                    ),
100                });
101            }
102        }
103    }
104}
105
106fn schema_is_empty_object(schema: &serde_json::Value) -> bool {
107    match schema {
108        serde_json::Value::Object(map) => map.is_empty(),
109        serde_json::Value::Bool(_) => false,
110        _ => true,
111    }
112}
113
114fn parameters_to_value(params: &HashMap<String, serde_json::Value>) -> serde_json::Value {
115    let map: serde_json::Map<String, serde_json::Value> =
116        params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
117    serde_json::Value::Object(map)
118}
119
120/// Validate a tool result against its declared return schema.
121///
122/// Return schemas are optional, so callers invoke this only when a schema is
123/// present. Keeping compilation and error formatting beside parameter-schema
124/// validation prevents callback transports from growing subtly different JSON
125/// Schema implementations.
126pub fn validate_tool_output(
127    tool: &str,
128    schema: &serde_json::Value,
129    output: &serde_json::Value,
130) -> Result<(), String> {
131    if schema_is_empty_object(schema) {
132        return Ok(());
133    }
134    let validator = jsonschema::validator_for(schema)
135        .map_err(|error| format!("tool '{tool}' has an invalid return JSON Schema: {error}"))?;
136    let errors: Vec<String> = validator
137        .iter_errors(output)
138        .map(|error| format!("{error} (at {})", error.instance_path))
139        .collect();
140    if errors.is_empty() {
141        Ok(())
142    } else {
143        Err(format!(
144            "tool '{tool}' output validation: {}",
145            errors.join("; ")
146        ))
147    }
148}
149
150/// Validate a single action against state and registered tools.
151pub fn validate_action(
152    action: &Action,
153    state: &dyn StateView,
154    registered_tools: &HashMap<String, ToolSchema>,
155) -> ValidationResult {
156    let mut result = ValidationResult {
157        action_id: action.id.clone(),
158        errors: Vec::new(),
159    };
160
161    // Check tool exists and validate parameters against schema
162    if let Some(ref tool) = action.tool {
163        if let Some(schema) = registered_tools.get(tool) {
164            validate_parameters(action, tool, schema, &mut result);
165        } else {
166            result.errors.push(ValidationError {
167                action_id: action.id.clone(),
168                reason: format!("tool '{}' is not registered", tool),
169            });
170        }
171    }
172
173    // A declared compensation is the whole basis for calling an effect
174    // recoverable, so an undo naming a tool that does not exist is a rollback
175    // plan that cannot run. Resolved here against the same registry as
176    // `action.tool` — `Compensation::ActionRef` needs the sibling actions and
177    // is resolved at proposal level by `car_verify`'s
178    // `compensation_resolution` check instead.
179    if let Some(car_ir::Compensation::Tool { tool, .. }) = &action.compensation {
180        if !registered_tools.contains_key(tool) {
181            result.errors.push(ValidationError {
182                action_id: action.id.clone(),
183                reason: format!("compensation tool '{}' is not registered", tool),
184            });
185        }
186    }
187
188    // Check preconditions
189    for pre in &action.preconditions {
190        if let Some(error) = check_precondition(pre, state) {
191            result.errors.push(ValidationError {
192                action_id: action.id.clone(),
193                reason: error,
194            });
195        }
196    }
197
198    // Check state dependencies
199    for dep in &action.state_dependencies {
200        if !state.key_exists(dep) {
201            result.errors.push(ValidationError {
202                action_id: action.id.clone(),
203                reason: format!("state dependency '{}' not found", dep),
204            });
205        }
206    }
207
208    result
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use car_ir::{ActionType, Precondition, ToolSchema};
215    use car_state::StateStore;
216    use serde_json::Value;
217    use std::collections::HashMap;
218
219    fn make_tool_call(tool: &str) -> Action {
220        {
221            let mut a = Action::new(ActionType::ToolCall);
222            a.id = "test".to_string();
223            a.tool = Some(tool.to_string());
224            a
225        }
226    }
227
228    fn simple_schema(name: &str) -> ToolSchema {
229        ToolSchema {
230            name: name.to_string(),
231            source: car_ir::ToolSourceKind::UserDefined,
232            description: String::new(),
233            parameters: Value::Object(Default::default()),
234            returns: None,
235            idempotent: false,
236            cache_ttl_secs: None,
237            rate_limit: None,
238        }
239    }
240
241    fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
242        names
243            .iter()
244            .map(|n| (n.to_string(), simple_schema(n)))
245            .collect()
246    }
247
248    #[test]
249    fn compensation_tool_is_resolved_against_the_registry() {
250        // A declared undo naming a tool that does not exist is a rollback plan
251        // that cannot run — and the action itself is perfectly valid, so
252        // nothing else in the pipeline would have caught it.
253        let state = StateStore::new();
254        let tools = tools_map(&["echo"]);
255        let mut action = make_tool_call("echo");
256        action.reversibility = car_ir::Reversibility::Compensable;
257        action.compensation = Some(car_ir::Compensation::Tool {
258            tool: "unecho".to_string(),
259            parameters: HashMap::new(),
260        });
261        let result = validate_action(&action, &state, &tools);
262        assert!(!result.valid());
263        assert!(result.errors.iter().any(|e| e
264            .reason
265            .contains("compensation tool 'unecho' is not registered")));
266
267        // Registered compensation passes.
268        let tools = tools_map(&["echo", "unecho"]);
269        assert!(validate_action(&action, &state, &tools).valid());
270
271        // An `ActionRef` compensation is not this function's to resolve — it
272        // needs the sibling actions, which `car_verify` has and this does not.
273        let mut action = make_tool_call("echo");
274        action.compensation = Some(car_ir::Compensation::ActionRef {
275            action_id: "whatever".to_string(),
276        });
277        assert!(validate_action(&action, &state, &tools_map(&["echo"])).valid());
278    }
279
280    #[test]
281    fn unknown_tool_rejected() {
282        let state = StateStore::new();
283        let tools = tools_map(&["echo"]);
284        let action = make_tool_call("nonexistent");
285        let result = validate_action(&action, &state, &tools);
286        assert!(!result.valid());
287        assert!(result.errors[0].reason.contains("not registered"));
288    }
289
290    #[test]
291    fn known_tool_passes() {
292        let state = StateStore::new();
293        let tools = tools_map(&["echo"]);
294        let action = make_tool_call("echo");
295        let result = validate_action(&action, &state, &tools);
296        assert!(result.valid());
297    }
298
299    #[test]
300    fn precondition_eq_fails() {
301        let state = StateStore::new();
302        let pre = Precondition {
303            key: "auth".to_string(),
304            operator: "eq".to_string(),
305            value: Value::Bool(true),
306            description: String::new(),
307        };
308        assert!(check_precondition(&pre, &state).is_some());
309    }
310
311    #[test]
312    fn precondition_eq_passes() {
313        let state = StateStore::new();
314        state.set("auth", Value::Bool(true), "setup");
315        let pre = Precondition {
316            key: "auth".to_string(),
317            operator: "eq".to_string(),
318            value: Value::Bool(true),
319            description: String::new(),
320        };
321        assert!(check_precondition(&pre, &state).is_none());
322    }
323
324    #[test]
325    fn precondition_exists() {
326        let state = StateStore::new();
327        let pre = Precondition {
328            key: "x".to_string(),
329            operator: "exists".to_string(),
330            value: Value::Null,
331            description: String::new(),
332        };
333        assert!(check_precondition(&pre, &state).is_some());
334
335        state.set("x", Value::from(1), "setup");
336        assert!(check_precondition(&pre, &state).is_none());
337    }
338
339    #[test]
340    fn state_dependency_missing() {
341        let state = StateStore::new();
342        let tools: HashMap<String, ToolSchema> = HashMap::new();
343        let mut action = make_tool_call("echo");
344        action.tool = None;
345        action.action_type = ActionType::StateRead;
346        action.state_dependencies = vec!["missing".to_string()];
347        let result = validate_action(&action, &state, &tools);
348        assert!(!result.valid());
349        assert!(result.errors[0].reason.contains("not found"));
350    }
351
352    #[test]
353    fn precondition_gt() {
354        let state = StateStore::new();
355        state.set("count", Value::from(10), "setup");
356        let pre = Precondition {
357            key: "count".to_string(),
358            operator: "gt".to_string(),
359            value: Value::from(5),
360            description: String::new(),
361        };
362        assert!(check_precondition(&pre, &state).is_none());
363
364        let pre_fail = Precondition {
365            key: "count".to_string(),
366            operator: "gt".to_string(),
367            value: Value::from(20),
368            description: String::new(),
369        };
370        assert!(check_precondition(&pre_fail, &state).is_some());
371    }
372
373    #[test]
374    fn missing_required_parameter_rejected() {
375        let state = StateStore::new();
376        let mut schema = simple_schema("add");
377        schema.parameters = serde_json::json!({
378            "type": "object",
379            "properties": {
380                "a": {"type": "number"},
381                "b": {"type": "number"}
382            },
383            "required": ["a", "b"]
384        });
385        let tools: HashMap<String, ToolSchema> =
386            [("add".to_string(), schema)].into_iter().collect();
387
388        let action = make_tool_call("add"); // no parameters
389        let result = validate_action(&action, &state, &tools);
390        assert!(!result.valid());
391        assert!(result
392            .errors
393            .iter()
394            .any(|e| e.reason.contains("missing required parameter 'a'")));
395        assert!(result
396            .errors
397            .iter()
398            .any(|e| e.reason.contains("missing required parameter 'b'")));
399    }
400
401    #[test]
402    fn required_parameters_provided_passes() {
403        let state = StateStore::new();
404        let mut schema = simple_schema("add");
405        schema.parameters = serde_json::json!({
406            "type": "object",
407            "properties": {
408                "a": {"type": "number"},
409                "b": {"type": "number"}
410            },
411            "required": ["a", "b"]
412        });
413        let tools: HashMap<String, ToolSchema> =
414            [("add".to_string(), schema)].into_iter().collect();
415
416        let mut action = make_tool_call("add");
417        action.parameters = [
418            ("a".to_string(), Value::from(1)),
419            ("b".to_string(), Value::from(2)),
420        ]
421        .into();
422        let result = validate_action(&action, &state, &tools);
423        assert!(result.valid());
424    }
425
426    #[test]
427    fn type_mismatch_rejected_when_schema_registered() {
428        let state = StateStore::new();
429        let mut schema = simple_schema("read");
430        schema.parameters = serde_json::json!({
431            "type": "object",
432            "properties": {
433                "path": {"type": "string"}
434            },
435            "required": ["path"]
436        });
437        let tools: HashMap<String, ToolSchema> =
438            [("read".to_string(), schema)].into_iter().collect();
439
440        let mut action = make_tool_call("read");
441        action.parameters = [("path".to_string(), Value::from(42))].into();
442        let result = validate_action(&action, &state, &tools);
443        assert!(!result.valid(), "type mismatch should be rejected");
444        assert!(
445            result
446                .errors
447                .iter()
448                .any(|e| e.reason.contains("parameter validation")),
449            "expected jsonschema parameter validation failure, got: {:?}",
450            result.errors
451        );
452    }
453
454    #[test]
455    fn empty_object_schema_is_treated_as_legacy() {
456        // Defense-in-depth: a future refactor that "improves"
457        // schema_is_empty_object must not silently turn the legacy
458        // schemaless registration into a hard rejection.
459        assert!(schema_is_empty_object(&Value::Object(Default::default())));
460    }
461
462    #[test]
463    fn tool_output_uses_declared_return_schema() {
464        let schema = serde_json::json!({
465            "type":"object",
466            "properties":{"report_id":{"type":"string"}},
467            "required":["report_id"],
468            "additionalProperties":false
469        });
470        assert!(validate_tool_output(
471            "report_source",
472            &schema,
473            &serde_json::json!({"report_id":"r-1"})
474        )
475        .is_ok());
476        let error = validate_tool_output(
477            "report_source",
478            &schema,
479            &serde_json::json!({"report_id":42}),
480        )
481        .expect_err("wrong return type must fail");
482        assert!(error.contains("output validation"), "{error}");
483    }
484
485    #[test]
486    fn empty_return_schema_preserves_legacy_output() {
487        assert!(validate_tool_output(
488            "legacy",
489            &serde_json::json!({}),
490            &serde_json::json!({"anything":true})
491        )
492        .is_ok());
493    }
494
495    #[test]
496    fn legacy_schemaless_tool_accepts_any_parameters() {
497        let state = StateStore::new();
498        let tools = tools_map(&["echo"]); // simple_schema → empty object
499        let mut action = make_tool_call("echo");
500        action.parameters = [
501            ("anything".to_string(), Value::from(42)),
502            ("else".to_string(), Value::from("string")),
503        ]
504        .into();
505        let result = validate_action(&action, &state, &tools);
506        assert!(
507            result.valid(),
508            "schemaless registration must accept anything"
509        );
510    }
511
512    #[test]
513    fn extra_parameters_allowed() {
514        let state = StateStore::new();
515        let mut schema = simple_schema("echo");
516        schema.parameters = serde_json::json!({
517            "type": "object",
518            "properties": {
519                "message": {"type": "string"}
520            },
521            "required": ["message"]
522        });
523        let tools: HashMap<String, ToolSchema> =
524            [("echo".to_string(), schema)].into_iter().collect();
525
526        let mut action = make_tool_call("echo");
527        action.parameters = [
528            ("message".to_string(), Value::from("hi")),
529            ("unexpected_extra".to_string(), Value::from(true)),
530        ]
531        .into();
532        let result = validate_action(&action, &state, &tools);
533        assert!(result.valid()); // extra params should NOT cause rejection
534    }
535    #[test]
536    fn full_terminal_schemas_reject_constraints_before_dispatch() {
537        let state = StateStore::new();
538        for (schema, invalid, valid) in [
539            (
540                serde_json::json!({"properties":{"reason":{"enum":["yes"]}}}),
541                serde_json::json!({"reason":"no"}),
542                serde_json::json!({"reason":"yes"}),
543            ),
544            (
545                serde_json::json!({"properties":{"nested":{"type":"object","required":["reason"]}}}),
546                serde_json::json!({"nested":{}}),
547                serde_json::json!({"nested":{"reason":"yes"}}),
548            ),
549            (
550                serde_json::json!({"additionalProperties":false,"properties":{"reason":{}}}),
551                serde_json::json!({"extra":1}),
552                serde_json::json!({"reason":"yes"}),
553            ),
554            (
555                serde_json::json!({"properties":{"items":{"type":"array","items":{"type":"string"}}}}),
556                serde_json::json!({"items":[1]}),
557                serde_json::json!({"items":["yes"]}),
558            ),
559            (
560                serde_json::json!({"properties":{"reason":{"minLength":3}}}),
561                serde_json::json!({"reason":"x"}),
562                serde_json::json!({"reason":"yes"}),
563            ),
564        ] {
565            let mut tool = simple_schema("end");
566            tool.parameters = schema;
567            let tools = [("end".to_string(), tool)].into_iter().collect();
568            let mut action = make_tool_call("end");
569            action.parameters = serde_json::from_value(invalid).unwrap();
570            assert!(!validate_action(&action, &state, &tools).valid());
571            action.parameters = serde_json::from_value(valid).unwrap();
572            assert!(validate_action(&action, &state, &tools).valid());
573        }
574    }
575
576    #[test]
577    fn boolean_parameter_schemas_are_not_schemaless() {
578        let state = StateStore::new();
579        for allowed in [false, true] {
580            let mut tool = simple_schema("end");
581            tool.parameters = serde_json::Value::Bool(allowed);
582            let tools = [("end".to_string(), tool)].into_iter().collect();
583            assert_eq!(
584                validate_action(&make_tool_call("end"), &state, &tools).valid(),
585                allowed
586            );
587        }
588    }
589}