Skip to main content

car_validator/
lib.rs

1//! Action validation — precondition checking and invariant enforcement.
2
3use car_ir::precondition;
4use car_ir::{Action, Precondition, ToolSchema};
5use car_state::StateStore;
6use std::collections::HashMap;
7
8/// A single validation failure.
9#[derive(Debug, Clone)]
10pub struct ValidationError {
11    pub action_id: String,
12    pub reason: String,
13}
14
15/// Aggregate result of validating an action.
16#[derive(Debug)]
17pub struct ValidationResult {
18    pub action_id: String,
19    pub errors: Vec<ValidationError>,
20}
21
22impl ValidationResult {
23    pub fn valid(&self) -> bool {
24        self.errors.is_empty()
25    }
26}
27
28/// Check a single precondition against state.
29pub fn check_precondition(pre: &Precondition, state: &StateStore) -> Option<String> {
30    precondition::check_precondition(pre, state)
31}
32
33/// Validate `action.parameters` against a tool's registered schema.
34///
35/// Two paths run side-by-side for backwards compatibility with the
36/// schemaless `register_tool(name)` registration path:
37///
38/// 1. **Required-key check** — kept for the legacy case where the
39///    schema is an empty object (no constraints) but a sibling
40///    `required` array is somehow present. Cheap, no-op when no
41///    `required` field exists.
42/// 2. **Full JSON Schema validation** via `jsonschema` — runs only
43///    when the schema is a non-trivial object (has `type` /
44///    `properties` / `required` / `enum` / etc.). Catches type
45///    mismatches like `{path: 42}` for a tool wanting
46///    `{path: string}` — which the legacy required-only check let
47///    through.
48///
49/// Tools registered via the schemaless API have an empty `{}` schema
50/// → both checks are no-ops → existing callers see no behavior change.
51fn validate_parameters(
52    action: &car_ir::Action,
53    tool: &str,
54    schema: &ToolSchema,
55    result: &mut ValidationResult,
56) {
57    // 1. Cheap required-key check (LLM may send unexpected extras —
58    // we intentionally do NOT reject unknown properties).
59    if let Some(required) = schema.parameters.get("required") {
60        if let Some(required_arr) = required.as_array() {
61            for req in required_arr {
62                if let Some(param_name) = req.as_str() {
63                    if !action.parameters.contains_key(param_name) {
64                        result.errors.push(ValidationError {
65                            action_id: action.id.clone(),
66                            reason: format!(
67                                "missing required parameter '{}' for tool '{}'",
68                                param_name, tool
69                            ),
70                        });
71                    }
72                }
73            }
74        }
75    }
76
77    // 2. Full JSON Schema validation when the schema actually carries
78    // constraints. An empty object schema (the schemaless registration
79    // case) trivially validates everything — skip the work.
80    if !schema_is_empty_object(&schema.parameters) {
81        let params_value = parameters_to_value(&action.parameters);
82        match jsonschema::validator_for(&schema.parameters) {
83            Ok(validator) => {
84                for err in validator.iter_errors(&params_value) {
85                    result.errors.push(ValidationError {
86                        action_id: action.id.clone(),
87                        reason: format!(
88                            "tool '{}' parameter validation: {} (at {})",
89                            tool, err, err.instance_path
90                        ),
91                    });
92                }
93            }
94            Err(e) => {
95                result.errors.push(ValidationError {
96                    action_id: action.id.clone(),
97                    reason: format!(
98                        "tool '{}' has an invalid registered JSON Schema: {}",
99                        tool, e
100                    ),
101                });
102            }
103        }
104    }
105}
106
107fn schema_is_empty_object(schema: &serde_json::Value) -> bool {
108    match schema {
109        serde_json::Value::Object(map) => map.is_empty(),
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 single action against state and registered tools.
121pub fn validate_action(
122    action: &Action,
123    state: &StateStore,
124    registered_tools: &HashMap<String, ToolSchema>,
125) -> ValidationResult {
126    let mut result = ValidationResult {
127        action_id: action.id.clone(),
128        errors: Vec::new(),
129    };
130
131    // Check tool exists and validate parameters against schema
132    if let Some(ref tool) = action.tool {
133        if let Some(schema) = registered_tools.get(tool) {
134            validate_parameters(action, tool, schema, &mut result);
135        } else {
136            result.errors.push(ValidationError {
137                action_id: action.id.clone(),
138                reason: format!("tool '{}' is not registered", tool),
139            });
140        }
141    }
142
143    // Check preconditions
144    for pre in &action.preconditions {
145        if let Some(error) = check_precondition(pre, state) {
146            result.errors.push(ValidationError {
147                action_id: action.id.clone(),
148                reason: error,
149            });
150        }
151    }
152
153    // Check state dependencies
154    for dep in &action.state_dependencies {
155        if !state.exists(dep) {
156            result.errors.push(ValidationError {
157                action_id: action.id.clone(),
158                reason: format!("state dependency '{}' not found", dep),
159            });
160        }
161    }
162
163    result
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use car_ir::{ActionType, FailureBehavior, Precondition, ToolSchema};
170    use serde_json::Value;
171    use std::collections::HashMap;
172
173    fn make_tool_call(tool: &str) -> Action {
174        Action {
175            id: "test".to_string(),
176            action_type: ActionType::ToolCall,
177            tool: Some(tool.to_string()),
178            parameters: HashMap::new(),
179            preconditions: vec![],
180            expected_effects: HashMap::new(),
181            state_dependencies: vec![],
182            read_set: vec![],
183            write_set: vec![],
184            assumptions: vec![],
185            invocation_mode: Default::default(),
186            idempotent: false,
187            max_retries: 3,
188            failure_behavior: FailureBehavior::Abort,
189            timeout_ms: None,
190            metadata: HashMap::new(),
191        }
192    }
193
194    fn simple_schema(name: &str) -> ToolSchema {
195        ToolSchema {
196            name: name.to_string(),
197            description: String::new(),
198            parameters: Value::Object(Default::default()),
199            returns: None,
200            idempotent: false,
201            cache_ttl_secs: None,
202            rate_limit: None,
203        }
204    }
205
206    fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
207        names
208            .iter()
209            .map(|n| (n.to_string(), simple_schema(n)))
210            .collect()
211    }
212
213    #[test]
214    fn unknown_tool_rejected() {
215        let state = StateStore::new();
216        let tools = tools_map(&["echo"]);
217        let action = make_tool_call("nonexistent");
218        let result = validate_action(&action, &state, &tools);
219        assert!(!result.valid());
220        assert!(result.errors[0].reason.contains("not registered"));
221    }
222
223    #[test]
224    fn known_tool_passes() {
225        let state = StateStore::new();
226        let tools = tools_map(&["echo"]);
227        let action = make_tool_call("echo");
228        let result = validate_action(&action, &state, &tools);
229        assert!(result.valid());
230    }
231
232    #[test]
233    fn precondition_eq_fails() {
234        let state = StateStore::new();
235        let pre = Precondition {
236            key: "auth".to_string(),
237            operator: "eq".to_string(),
238            value: Value::Bool(true),
239            description: String::new(),
240        };
241        assert!(check_precondition(&pre, &state).is_some());
242    }
243
244    #[test]
245    fn precondition_eq_passes() {
246        let state = StateStore::new();
247        state.set("auth", Value::Bool(true), "setup");
248        let pre = Precondition {
249            key: "auth".to_string(),
250            operator: "eq".to_string(),
251            value: Value::Bool(true),
252            description: String::new(),
253        };
254        assert!(check_precondition(&pre, &state).is_none());
255    }
256
257    #[test]
258    fn precondition_exists() {
259        let state = StateStore::new();
260        let pre = Precondition {
261            key: "x".to_string(),
262            operator: "exists".to_string(),
263            value: Value::Null,
264            description: String::new(),
265        };
266        assert!(check_precondition(&pre, &state).is_some());
267
268        state.set("x", Value::from(1), "setup");
269        assert!(check_precondition(&pre, &state).is_none());
270    }
271
272    #[test]
273    fn state_dependency_missing() {
274        let state = StateStore::new();
275        let tools: HashMap<String, ToolSchema> = HashMap::new();
276        let mut action = make_tool_call("echo");
277        action.tool = None;
278        action.action_type = ActionType::StateRead;
279        action.state_dependencies = vec!["missing".to_string()];
280        let result = validate_action(&action, &state, &tools);
281        assert!(!result.valid());
282        assert!(result.errors[0].reason.contains("not found"));
283    }
284
285    #[test]
286    fn precondition_gt() {
287        let state = StateStore::new();
288        state.set("count", Value::from(10), "setup");
289        let pre = Precondition {
290            key: "count".to_string(),
291            operator: "gt".to_string(),
292            value: Value::from(5),
293            description: String::new(),
294        };
295        assert!(check_precondition(&pre, &state).is_none());
296
297        let pre_fail = Precondition {
298            key: "count".to_string(),
299            operator: "gt".to_string(),
300            value: Value::from(20),
301            description: String::new(),
302        };
303        assert!(check_precondition(&pre_fail, &state).is_some());
304    }
305
306    #[test]
307    fn missing_required_parameter_rejected() {
308        let state = StateStore::new();
309        let mut schema = simple_schema("add");
310        schema.parameters = serde_json::json!({
311            "type": "object",
312            "properties": {
313                "a": {"type": "number"},
314                "b": {"type": "number"}
315            },
316            "required": ["a", "b"]
317        });
318        let tools: HashMap<String, ToolSchema> =
319            [("add".to_string(), schema)].into_iter().collect();
320
321        let action = make_tool_call("add"); // no parameters
322        let result = validate_action(&action, &state, &tools);
323        assert!(!result.valid());
324        assert!(result
325            .errors
326            .iter()
327            .any(|e| e.reason.contains("missing required parameter 'a'")));
328        assert!(result
329            .errors
330            .iter()
331            .any(|e| e.reason.contains("missing required parameter 'b'")));
332    }
333
334    #[test]
335    fn required_parameters_provided_passes() {
336        let state = StateStore::new();
337        let mut schema = simple_schema("add");
338        schema.parameters = serde_json::json!({
339            "type": "object",
340            "properties": {
341                "a": {"type": "number"},
342                "b": {"type": "number"}
343            },
344            "required": ["a", "b"]
345        });
346        let tools: HashMap<String, ToolSchema> =
347            [("add".to_string(), schema)].into_iter().collect();
348
349        let mut action = make_tool_call("add");
350        action.parameters = [
351            ("a".to_string(), Value::from(1)),
352            ("b".to_string(), Value::from(2)),
353        ]
354        .into();
355        let result = validate_action(&action, &state, &tools);
356        assert!(result.valid());
357    }
358
359    #[test]
360    fn type_mismatch_rejected_when_schema_registered() {
361        let state = StateStore::new();
362        let mut schema = simple_schema("read");
363        schema.parameters = serde_json::json!({
364            "type": "object",
365            "properties": {
366                "path": {"type": "string"}
367            },
368            "required": ["path"]
369        });
370        let tools: HashMap<String, ToolSchema> =
371            [("read".to_string(), schema)].into_iter().collect();
372
373        let mut action = make_tool_call("read");
374        action.parameters = [("path".to_string(), Value::from(42))].into();
375        let result = validate_action(&action, &state, &tools);
376        assert!(!result.valid(), "type mismatch should be rejected");
377        assert!(
378            result
379                .errors
380                .iter()
381                .any(|e| e.reason.contains("parameter validation")),
382            "expected jsonschema parameter validation failure, got: {:?}",
383            result.errors
384        );
385    }
386
387    #[test]
388    fn empty_object_schema_is_treated_as_legacy() {
389        // Defense-in-depth: a future refactor that "improves"
390        // schema_is_empty_object must not silently turn the legacy
391        // schemaless registration into a hard rejection.
392        assert!(schema_is_empty_object(&Value::Object(Default::default())));
393    }
394
395    #[test]
396    fn legacy_schemaless_tool_accepts_any_parameters() {
397        let state = StateStore::new();
398        let tools = tools_map(&["echo"]); // simple_schema → empty object
399        let mut action = make_tool_call("echo");
400        action.parameters = [
401            ("anything".to_string(), Value::from(42)),
402            ("else".to_string(), Value::from("string")),
403        ]
404        .into();
405        let result = validate_action(&action, &state, &tools);
406        assert!(
407            result.valid(),
408            "schemaless registration must accept anything"
409        );
410    }
411
412    #[test]
413    fn extra_parameters_allowed() {
414        let state = StateStore::new();
415        let mut schema = simple_schema("echo");
416        schema.parameters = serde_json::json!({
417            "type": "object",
418            "properties": {
419                "message": {"type": "string"}
420            },
421            "required": ["message"]
422        });
423        let tools: HashMap<String, ToolSchema> =
424            [("echo".to_string(), schema)].into_iter().collect();
425
426        let mut action = make_tool_call("echo");
427        action.parameters = [
428            ("message".to_string(), Value::from("hi")),
429            ("unexpected_extra".to_string(), Value::from(true)),
430        ]
431        .into();
432        let result = validate_action(&action, &state, &tools);
433        assert!(result.valid()); // extra params should NOT cause rejection
434    }
435}