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        _ => true,
110    }
111}
112
113fn parameters_to_value(params: &HashMap<String, serde_json::Value>) -> serde_json::Value {
114    let map: serde_json::Map<String, serde_json::Value> =
115        params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
116    serde_json::Value::Object(map)
117}
118
119/// Validate a tool result against its declared return schema.
120///
121/// Return schemas are optional, so callers invoke this only when a schema is
122/// present. Keeping compilation and error formatting beside parameter-schema
123/// validation prevents callback transports from growing subtly different JSON
124/// Schema implementations.
125pub fn validate_tool_output(
126    tool: &str,
127    schema: &serde_json::Value,
128    output: &serde_json::Value,
129) -> Result<(), String> {
130    if schema_is_empty_object(schema) {
131        return Ok(());
132    }
133    let validator = jsonschema::validator_for(schema)
134        .map_err(|error| format!("tool '{tool}' has an invalid return JSON Schema: {error}"))?;
135    let errors: Vec<String> = validator
136        .iter_errors(output)
137        .map(|error| format!("{error} (at {})", error.instance_path))
138        .collect();
139    if errors.is_empty() {
140        Ok(())
141    } else {
142        Err(format!(
143            "tool '{tool}' output validation: {}",
144            errors.join("; ")
145        ))
146    }
147}
148
149/// Validate a single action against state and registered tools.
150pub fn validate_action(
151    action: &Action,
152    state: &dyn StateView,
153    registered_tools: &HashMap<String, ToolSchema>,
154) -> ValidationResult {
155    let mut result = ValidationResult {
156        action_id: action.id.clone(),
157        errors: Vec::new(),
158    };
159
160    // Check tool exists and validate parameters against schema
161    if let Some(ref tool) = action.tool {
162        if let Some(schema) = registered_tools.get(tool) {
163            validate_parameters(action, tool, schema, &mut result);
164        } else {
165            result.errors.push(ValidationError {
166                action_id: action.id.clone(),
167                reason: format!("tool '{}' is not registered", tool),
168            });
169        }
170    }
171
172    // A declared compensation is the whole basis for calling an effect
173    // recoverable, so an undo naming a tool that does not exist is a rollback
174    // plan that cannot run. Resolved here against the same registry as
175    // `action.tool` — `Compensation::ActionRef` needs the sibling actions and
176    // is resolved at proposal level by `car_verify`'s
177    // `compensation_resolution` check instead.
178    if let Some(car_ir::Compensation::Tool { tool, .. }) = &action.compensation {
179        if !registered_tools.contains_key(tool) {
180            result.errors.push(ValidationError {
181                action_id: action.id.clone(),
182                reason: format!("compensation tool '{}' is not registered", tool),
183            });
184        }
185    }
186
187    // Check preconditions
188    for pre in &action.preconditions {
189        if let Some(error) = check_precondition(pre, state) {
190            result.errors.push(ValidationError {
191                action_id: action.id.clone(),
192                reason: error,
193            });
194        }
195    }
196
197    // Check state dependencies
198    for dep in &action.state_dependencies {
199        if !state.key_exists(dep) {
200            result.errors.push(ValidationError {
201                action_id: action.id.clone(),
202                reason: format!("state dependency '{}' not found", dep),
203            });
204        }
205    }
206
207    result
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use car_ir::{ActionType, Precondition, ToolSchema};
214    use car_state::StateStore;
215    use serde_json::Value;
216    use std::collections::HashMap;
217
218    fn make_tool_call(tool: &str) -> Action {
219        {
220            let mut a = Action::new(ActionType::ToolCall);
221            a.id = "test".to_string();
222            a.tool = Some(tool.to_string());
223            a
224        }
225    }
226
227    fn simple_schema(name: &str) -> ToolSchema {
228        ToolSchema {
229            name: name.to_string(),
230            source: car_ir::ToolSourceKind::UserDefined,
231            description: String::new(),
232            parameters: Value::Object(Default::default()),
233            returns: None,
234            idempotent: false,
235            cache_ttl_secs: None,
236            rate_limit: None,
237        }
238    }
239
240    fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
241        names
242            .iter()
243            .map(|n| (n.to_string(), simple_schema(n)))
244            .collect()
245    }
246
247    #[test]
248    fn compensation_tool_is_resolved_against_the_registry() {
249        // A declared undo naming a tool that does not exist is a rollback plan
250        // that cannot run — and the action itself is perfectly valid, so
251        // nothing else in the pipeline would have caught it.
252        let state = StateStore::new();
253        let tools = tools_map(&["echo"]);
254        let mut action = make_tool_call("echo");
255        action.reversibility = car_ir::Reversibility::Compensable;
256        action.compensation = Some(car_ir::Compensation::Tool {
257            tool: "unecho".to_string(),
258            parameters: HashMap::new(),
259        });
260        let result = validate_action(&action, &state, &tools);
261        assert!(!result.valid());
262        assert!(result.errors.iter().any(|e| e
263            .reason
264            .contains("compensation tool 'unecho' is not registered")));
265
266        // Registered compensation passes.
267        let tools = tools_map(&["echo", "unecho"]);
268        assert!(validate_action(&action, &state, &tools).valid());
269
270        // An `ActionRef` compensation is not this function's to resolve — it
271        // needs the sibling actions, which `car_verify` has and this does not.
272        let mut action = make_tool_call("echo");
273        action.compensation = Some(car_ir::Compensation::ActionRef {
274            action_id: "whatever".to_string(),
275        });
276        assert!(validate_action(&action, &state, &tools_map(&["echo"])).valid());
277    }
278
279    #[test]
280    fn unknown_tool_rejected() {
281        let state = StateStore::new();
282        let tools = tools_map(&["echo"]);
283        let action = make_tool_call("nonexistent");
284        let result = validate_action(&action, &state, &tools);
285        assert!(!result.valid());
286        assert!(result.errors[0].reason.contains("not registered"));
287    }
288
289    #[test]
290    fn known_tool_passes() {
291        let state = StateStore::new();
292        let tools = tools_map(&["echo"]);
293        let action = make_tool_call("echo");
294        let result = validate_action(&action, &state, &tools);
295        assert!(result.valid());
296    }
297
298    #[test]
299    fn precondition_eq_fails() {
300        let state = StateStore::new();
301        let pre = Precondition {
302            key: "auth".to_string(),
303            operator: "eq".to_string(),
304            value: Value::Bool(true),
305            description: String::new(),
306        };
307        assert!(check_precondition(&pre, &state).is_some());
308    }
309
310    #[test]
311    fn precondition_eq_passes() {
312        let state = StateStore::new();
313        state.set("auth", Value::Bool(true), "setup");
314        let pre = Precondition {
315            key: "auth".to_string(),
316            operator: "eq".to_string(),
317            value: Value::Bool(true),
318            description: String::new(),
319        };
320        assert!(check_precondition(&pre, &state).is_none());
321    }
322
323    #[test]
324    fn precondition_exists() {
325        let state = StateStore::new();
326        let pre = Precondition {
327            key: "x".to_string(),
328            operator: "exists".to_string(),
329            value: Value::Null,
330            description: String::new(),
331        };
332        assert!(check_precondition(&pre, &state).is_some());
333
334        state.set("x", Value::from(1), "setup");
335        assert!(check_precondition(&pre, &state).is_none());
336    }
337
338    #[test]
339    fn state_dependency_missing() {
340        let state = StateStore::new();
341        let tools: HashMap<String, ToolSchema> = HashMap::new();
342        let mut action = make_tool_call("echo");
343        action.tool = None;
344        action.action_type = ActionType::StateRead;
345        action.state_dependencies = vec!["missing".to_string()];
346        let result = validate_action(&action, &state, &tools);
347        assert!(!result.valid());
348        assert!(result.errors[0].reason.contains("not found"));
349    }
350
351    #[test]
352    fn precondition_gt() {
353        let state = StateStore::new();
354        state.set("count", Value::from(10), "setup");
355        let pre = Precondition {
356            key: "count".to_string(),
357            operator: "gt".to_string(),
358            value: Value::from(5),
359            description: String::new(),
360        };
361        assert!(check_precondition(&pre, &state).is_none());
362
363        let pre_fail = Precondition {
364            key: "count".to_string(),
365            operator: "gt".to_string(),
366            value: Value::from(20),
367            description: String::new(),
368        };
369        assert!(check_precondition(&pre_fail, &state).is_some());
370    }
371
372    #[test]
373    fn missing_required_parameter_rejected() {
374        let state = StateStore::new();
375        let mut schema = simple_schema("add");
376        schema.parameters = serde_json::json!({
377            "type": "object",
378            "properties": {
379                "a": {"type": "number"},
380                "b": {"type": "number"}
381            },
382            "required": ["a", "b"]
383        });
384        let tools: HashMap<String, ToolSchema> =
385            [("add".to_string(), schema)].into_iter().collect();
386
387        let action = make_tool_call("add"); // no parameters
388        let result = validate_action(&action, &state, &tools);
389        assert!(!result.valid());
390        assert!(result
391            .errors
392            .iter()
393            .any(|e| e.reason.contains("missing required parameter 'a'")));
394        assert!(result
395            .errors
396            .iter()
397            .any(|e| e.reason.contains("missing required parameter 'b'")));
398    }
399
400    #[test]
401    fn required_parameters_provided_passes() {
402        let state = StateStore::new();
403        let mut schema = simple_schema("add");
404        schema.parameters = serde_json::json!({
405            "type": "object",
406            "properties": {
407                "a": {"type": "number"},
408                "b": {"type": "number"}
409            },
410            "required": ["a", "b"]
411        });
412        let tools: HashMap<String, ToolSchema> =
413            [("add".to_string(), schema)].into_iter().collect();
414
415        let mut action = make_tool_call("add");
416        action.parameters = [
417            ("a".to_string(), Value::from(1)),
418            ("b".to_string(), Value::from(2)),
419        ]
420        .into();
421        let result = validate_action(&action, &state, &tools);
422        assert!(result.valid());
423    }
424
425    #[test]
426    fn type_mismatch_rejected_when_schema_registered() {
427        let state = StateStore::new();
428        let mut schema = simple_schema("read");
429        schema.parameters = serde_json::json!({
430            "type": "object",
431            "properties": {
432                "path": {"type": "string"}
433            },
434            "required": ["path"]
435        });
436        let tools: HashMap<String, ToolSchema> =
437            [("read".to_string(), schema)].into_iter().collect();
438
439        let mut action = make_tool_call("read");
440        action.parameters = [("path".to_string(), Value::from(42))].into();
441        let result = validate_action(&action, &state, &tools);
442        assert!(!result.valid(), "type mismatch should be rejected");
443        assert!(
444            result
445                .errors
446                .iter()
447                .any(|e| e.reason.contains("parameter validation")),
448            "expected jsonschema parameter validation failure, got: {:?}",
449            result.errors
450        );
451    }
452
453    #[test]
454    fn empty_object_schema_is_treated_as_legacy() {
455        // Defense-in-depth: a future refactor that "improves"
456        // schema_is_empty_object must not silently turn the legacy
457        // schemaless registration into a hard rejection.
458        assert!(schema_is_empty_object(&Value::Object(Default::default())));
459    }
460
461    #[test]
462    fn tool_output_uses_declared_return_schema() {
463        let schema = serde_json::json!({
464            "type":"object",
465            "properties":{"report_id":{"type":"string"}},
466            "required":["report_id"],
467            "additionalProperties":false
468        });
469        assert!(validate_tool_output(
470            "report_source",
471            &schema,
472            &serde_json::json!({"report_id":"r-1"})
473        )
474        .is_ok());
475        let error = validate_tool_output(
476            "report_source",
477            &schema,
478            &serde_json::json!({"report_id":42}),
479        )
480        .expect_err("wrong return type must fail");
481        assert!(error.contains("output validation"), "{error}");
482    }
483
484    #[test]
485    fn empty_return_schema_preserves_legacy_output() {
486        assert!(validate_tool_output(
487            "legacy",
488            &serde_json::json!({}),
489            &serde_json::json!({"anything":true})
490        )
491        .is_ok());
492    }
493
494    #[test]
495    fn legacy_schemaless_tool_accepts_any_parameters() {
496        let state = StateStore::new();
497        let tools = tools_map(&["echo"]); // simple_schema → empty object
498        let mut action = make_tool_call("echo");
499        action.parameters = [
500            ("anything".to_string(), Value::from(42)),
501            ("else".to_string(), Value::from("string")),
502        ]
503        .into();
504        let result = validate_action(&action, &state, &tools);
505        assert!(
506            result.valid(),
507            "schemaless registration must accept anything"
508        );
509    }
510
511    #[test]
512    fn extra_parameters_allowed() {
513        let state = StateStore::new();
514        let mut schema = simple_schema("echo");
515        schema.parameters = serde_json::json!({
516            "type": "object",
517            "properties": {
518                "message": {"type": "string"}
519            },
520            "required": ["message"]
521        });
522        let tools: HashMap<String, ToolSchema> =
523            [("echo".to_string(), schema)].into_iter().collect();
524
525        let mut action = make_tool_call("echo");
526        action.parameters = [
527            ("message".to_string(), Value::from("hi")),
528            ("unexpected_extra".to_string(), Value::from(true)),
529        ]
530        .into();
531        let result = validate_action(&action, &state, &tools);
532        assert!(result.valid()); // extra params should NOT cause rejection
533    }
534}