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    // A declared compensation is the whole basis for calling an effect
144    // recoverable, so an undo naming a tool that does not exist is a rollback
145    // plan that cannot run. Resolved here against the same registry as
146    // `action.tool` — `Compensation::ActionRef` needs the sibling actions and
147    // is resolved at proposal level by `car_verify`'s
148    // `compensation_resolution` check instead.
149    if let Some(car_ir::Compensation::Tool { tool, .. }) = &action.compensation {
150        if !registered_tools.contains_key(tool) {
151            result.errors.push(ValidationError {
152                action_id: action.id.clone(),
153                reason: format!("compensation tool '{}' is not registered", tool),
154            });
155        }
156    }
157
158    // Check preconditions
159    for pre in &action.preconditions {
160        if let Some(error) = check_precondition(pre, state) {
161            result.errors.push(ValidationError {
162                action_id: action.id.clone(),
163                reason: error,
164            });
165        }
166    }
167
168    // Check state dependencies
169    for dep in &action.state_dependencies {
170        if !state.exists(dep) {
171            result.errors.push(ValidationError {
172                action_id: action.id.clone(),
173                reason: format!("state dependency '{}' not found", dep),
174            });
175        }
176    }
177
178    result
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use car_ir::{ActionType, Precondition, ToolSchema};
185    use serde_json::Value;
186    use std::collections::HashMap;
187
188    fn make_tool_call(tool: &str) -> Action {
189        {
190            let mut a = Action::new(ActionType::ToolCall);
191            a.id = "test".to_string();
192            a.tool = Some(tool.to_string());
193            a
194        }
195    }
196
197    fn simple_schema(name: &str) -> ToolSchema {
198        ToolSchema {
199            name: name.to_string(),
200            description: String::new(),
201            parameters: Value::Object(Default::default()),
202            returns: None,
203            idempotent: false,
204            cache_ttl_secs: None,
205            rate_limit: None,
206        }
207    }
208
209    fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
210        names
211            .iter()
212            .map(|n| (n.to_string(), simple_schema(n)))
213            .collect()
214    }
215
216    #[test]
217    fn compensation_tool_is_resolved_against_the_registry() {
218        // A declared undo naming a tool that does not exist is a rollback plan
219        // that cannot run — and the action itself is perfectly valid, so
220        // nothing else in the pipeline would have caught it.
221        let state = StateStore::new();
222        let tools = tools_map(&["echo"]);
223        let mut action = make_tool_call("echo");
224        action.reversibility = car_ir::Reversibility::Compensable;
225        action.compensation = Some(car_ir::Compensation::Tool {
226            tool: "unecho".to_string(),
227            parameters: HashMap::new(),
228        });
229        let result = validate_action(&action, &state, &tools);
230        assert!(!result.valid());
231        assert!(result.errors.iter().any(|e| e
232            .reason
233            .contains("compensation tool 'unecho' is not registered")));
234
235        // Registered compensation passes.
236        let tools = tools_map(&["echo", "unecho"]);
237        assert!(validate_action(&action, &state, &tools).valid());
238
239        // An `ActionRef` compensation is not this function's to resolve — it
240        // needs the sibling actions, which `car_verify` has and this does not.
241        let mut action = make_tool_call("echo");
242        action.compensation = Some(car_ir::Compensation::ActionRef {
243            action_id: "whatever".to_string(),
244        });
245        assert!(validate_action(&action, &state, &tools_map(&["echo"])).valid());
246    }
247
248    #[test]
249    fn unknown_tool_rejected() {
250        let state = StateStore::new();
251        let tools = tools_map(&["echo"]);
252        let action = make_tool_call("nonexistent");
253        let result = validate_action(&action, &state, &tools);
254        assert!(!result.valid());
255        assert!(result.errors[0].reason.contains("not registered"));
256    }
257
258    #[test]
259    fn known_tool_passes() {
260        let state = StateStore::new();
261        let tools = tools_map(&["echo"]);
262        let action = make_tool_call("echo");
263        let result = validate_action(&action, &state, &tools);
264        assert!(result.valid());
265    }
266
267    #[test]
268    fn precondition_eq_fails() {
269        let state = StateStore::new();
270        let pre = Precondition {
271            key: "auth".to_string(),
272            operator: "eq".to_string(),
273            value: Value::Bool(true),
274            description: String::new(),
275        };
276        assert!(check_precondition(&pre, &state).is_some());
277    }
278
279    #[test]
280    fn precondition_eq_passes() {
281        let state = StateStore::new();
282        state.set("auth", Value::Bool(true), "setup");
283        let pre = Precondition {
284            key: "auth".to_string(),
285            operator: "eq".to_string(),
286            value: Value::Bool(true),
287            description: String::new(),
288        };
289        assert!(check_precondition(&pre, &state).is_none());
290    }
291
292    #[test]
293    fn precondition_exists() {
294        let state = StateStore::new();
295        let pre = Precondition {
296            key: "x".to_string(),
297            operator: "exists".to_string(),
298            value: Value::Null,
299            description: String::new(),
300        };
301        assert!(check_precondition(&pre, &state).is_some());
302
303        state.set("x", Value::from(1), "setup");
304        assert!(check_precondition(&pre, &state).is_none());
305    }
306
307    #[test]
308    fn state_dependency_missing() {
309        let state = StateStore::new();
310        let tools: HashMap<String, ToolSchema> = HashMap::new();
311        let mut action = make_tool_call("echo");
312        action.tool = None;
313        action.action_type = ActionType::StateRead;
314        action.state_dependencies = vec!["missing".to_string()];
315        let result = validate_action(&action, &state, &tools);
316        assert!(!result.valid());
317        assert!(result.errors[0].reason.contains("not found"));
318    }
319
320    #[test]
321    fn precondition_gt() {
322        let state = StateStore::new();
323        state.set("count", Value::from(10), "setup");
324        let pre = Precondition {
325            key: "count".to_string(),
326            operator: "gt".to_string(),
327            value: Value::from(5),
328            description: String::new(),
329        };
330        assert!(check_precondition(&pre, &state).is_none());
331
332        let pre_fail = Precondition {
333            key: "count".to_string(),
334            operator: "gt".to_string(),
335            value: Value::from(20),
336            description: String::new(),
337        };
338        assert!(check_precondition(&pre_fail, &state).is_some());
339    }
340
341    #[test]
342    fn missing_required_parameter_rejected() {
343        let state = StateStore::new();
344        let mut schema = simple_schema("add");
345        schema.parameters = serde_json::json!({
346            "type": "object",
347            "properties": {
348                "a": {"type": "number"},
349                "b": {"type": "number"}
350            },
351            "required": ["a", "b"]
352        });
353        let tools: HashMap<String, ToolSchema> =
354            [("add".to_string(), schema)].into_iter().collect();
355
356        let action = make_tool_call("add"); // no parameters
357        let result = validate_action(&action, &state, &tools);
358        assert!(!result.valid());
359        assert!(result
360            .errors
361            .iter()
362            .any(|e| e.reason.contains("missing required parameter 'a'")));
363        assert!(result
364            .errors
365            .iter()
366            .any(|e| e.reason.contains("missing required parameter 'b'")));
367    }
368
369    #[test]
370    fn required_parameters_provided_passes() {
371        let state = StateStore::new();
372        let mut schema = simple_schema("add");
373        schema.parameters = serde_json::json!({
374            "type": "object",
375            "properties": {
376                "a": {"type": "number"},
377                "b": {"type": "number"}
378            },
379            "required": ["a", "b"]
380        });
381        let tools: HashMap<String, ToolSchema> =
382            [("add".to_string(), schema)].into_iter().collect();
383
384        let mut action = make_tool_call("add");
385        action.parameters = [
386            ("a".to_string(), Value::from(1)),
387            ("b".to_string(), Value::from(2)),
388        ]
389        .into();
390        let result = validate_action(&action, &state, &tools);
391        assert!(result.valid());
392    }
393
394    #[test]
395    fn type_mismatch_rejected_when_schema_registered() {
396        let state = StateStore::new();
397        let mut schema = simple_schema("read");
398        schema.parameters = serde_json::json!({
399            "type": "object",
400            "properties": {
401                "path": {"type": "string"}
402            },
403            "required": ["path"]
404        });
405        let tools: HashMap<String, ToolSchema> =
406            [("read".to_string(), schema)].into_iter().collect();
407
408        let mut action = make_tool_call("read");
409        action.parameters = [("path".to_string(), Value::from(42))].into();
410        let result = validate_action(&action, &state, &tools);
411        assert!(!result.valid(), "type mismatch should be rejected");
412        assert!(
413            result
414                .errors
415                .iter()
416                .any(|e| e.reason.contains("parameter validation")),
417            "expected jsonschema parameter validation failure, got: {:?}",
418            result.errors
419        );
420    }
421
422    #[test]
423    fn empty_object_schema_is_treated_as_legacy() {
424        // Defense-in-depth: a future refactor that "improves"
425        // schema_is_empty_object must not silently turn the legacy
426        // schemaless registration into a hard rejection.
427        assert!(schema_is_empty_object(&Value::Object(Default::default())));
428    }
429
430    #[test]
431    fn legacy_schemaless_tool_accepts_any_parameters() {
432        let state = StateStore::new();
433        let tools = tools_map(&["echo"]); // simple_schema → empty object
434        let mut action = make_tool_call("echo");
435        action.parameters = [
436            ("anything".to_string(), Value::from(42)),
437            ("else".to_string(), Value::from("string")),
438        ]
439        .into();
440        let result = validate_action(&action, &state, &tools);
441        assert!(
442            result.valid(),
443            "schemaless registration must accept anything"
444        );
445    }
446
447    #[test]
448    fn extra_parameters_allowed() {
449        let state = StateStore::new();
450        let mut schema = simple_schema("echo");
451        schema.parameters = serde_json::json!({
452            "type": "object",
453            "properties": {
454                "message": {"type": "string"}
455            },
456            "required": ["message"]
457        });
458        let tools: HashMap<String, ToolSchema> =
459            [("echo".to_string(), schema)].into_iter().collect();
460
461        let mut action = make_tool_call("echo");
462        action.parameters = [
463            ("message".to_string(), Value::from("hi")),
464            ("unexpected_extra".to_string(), Value::from(true)),
465        ]
466        .into();
467        let result = validate_action(&action, &state, &tools);
468        assert!(result.valid()); // extra params should NOT cause rejection
469    }
470}