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 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: &StateStore,
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.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 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            description: String::new(),
231            parameters: Value::Object(Default::default()),
232            returns: None,
233            idempotent: false,
234            cache_ttl_secs: None,
235            rate_limit: None,
236        }
237    }
238
239    fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
240        names
241            .iter()
242            .map(|n| (n.to_string(), simple_schema(n)))
243            .collect()
244    }
245
246    #[test]
247    fn compensation_tool_is_resolved_against_the_registry() {
248        // A declared undo naming a tool that does not exist is a rollback plan
249        // that cannot run — and the action itself is perfectly valid, so
250        // nothing else in the pipeline would have caught it.
251        let state = StateStore::new();
252        let tools = tools_map(&["echo"]);
253        let mut action = make_tool_call("echo");
254        action.reversibility = car_ir::Reversibility::Compensable;
255        action.compensation = Some(car_ir::Compensation::Tool {
256            tool: "unecho".to_string(),
257            parameters: HashMap::new(),
258        });
259        let result = validate_action(&action, &state, &tools);
260        assert!(!result.valid());
261        assert!(result.errors.iter().any(|e| e
262            .reason
263            .contains("compensation tool 'unecho' is not registered")));
264
265        // Registered compensation passes.
266        let tools = tools_map(&["echo", "unecho"]);
267        assert!(validate_action(&action, &state, &tools).valid());
268
269        // An `ActionRef` compensation is not this function's to resolve — it
270        // needs the sibling actions, which `car_verify` has and this does not.
271        let mut action = make_tool_call("echo");
272        action.compensation = Some(car_ir::Compensation::ActionRef {
273            action_id: "whatever".to_string(),
274        });
275        assert!(validate_action(&action, &state, &tools_map(&["echo"])).valid());
276    }
277
278    #[test]
279    fn unknown_tool_rejected() {
280        let state = StateStore::new();
281        let tools = tools_map(&["echo"]);
282        let action = make_tool_call("nonexistent");
283        let result = validate_action(&action, &state, &tools);
284        assert!(!result.valid());
285        assert!(result.errors[0].reason.contains("not registered"));
286    }
287
288    #[test]
289    fn known_tool_passes() {
290        let state = StateStore::new();
291        let tools = tools_map(&["echo"]);
292        let action = make_tool_call("echo");
293        let result = validate_action(&action, &state, &tools);
294        assert!(result.valid());
295    }
296
297    #[test]
298    fn precondition_eq_fails() {
299        let state = StateStore::new();
300        let pre = Precondition {
301            key: "auth".to_string(),
302            operator: "eq".to_string(),
303            value: Value::Bool(true),
304            description: String::new(),
305        };
306        assert!(check_precondition(&pre, &state).is_some());
307    }
308
309    #[test]
310    fn precondition_eq_passes() {
311        let state = StateStore::new();
312        state.set("auth", Value::Bool(true), "setup");
313        let pre = Precondition {
314            key: "auth".to_string(),
315            operator: "eq".to_string(),
316            value: Value::Bool(true),
317            description: String::new(),
318        };
319        assert!(check_precondition(&pre, &state).is_none());
320    }
321
322    #[test]
323    fn precondition_exists() {
324        let state = StateStore::new();
325        let pre = Precondition {
326            key: "x".to_string(),
327            operator: "exists".to_string(),
328            value: Value::Null,
329            description: String::new(),
330        };
331        assert!(check_precondition(&pre, &state).is_some());
332
333        state.set("x", Value::from(1), "setup");
334        assert!(check_precondition(&pre, &state).is_none());
335    }
336
337    #[test]
338    fn state_dependency_missing() {
339        let state = StateStore::new();
340        let tools: HashMap<String, ToolSchema> = HashMap::new();
341        let mut action = make_tool_call("echo");
342        action.tool = None;
343        action.action_type = ActionType::StateRead;
344        action.state_dependencies = vec!["missing".to_string()];
345        let result = validate_action(&action, &state, &tools);
346        assert!(!result.valid());
347        assert!(result.errors[0].reason.contains("not found"));
348    }
349
350    #[test]
351    fn precondition_gt() {
352        let state = StateStore::new();
353        state.set("count", Value::from(10), "setup");
354        let pre = Precondition {
355            key: "count".to_string(),
356            operator: "gt".to_string(),
357            value: Value::from(5),
358            description: String::new(),
359        };
360        assert!(check_precondition(&pre, &state).is_none());
361
362        let pre_fail = Precondition {
363            key: "count".to_string(),
364            operator: "gt".to_string(),
365            value: Value::from(20),
366            description: String::new(),
367        };
368        assert!(check_precondition(&pre_fail, &state).is_some());
369    }
370
371    #[test]
372    fn missing_required_parameter_rejected() {
373        let state = StateStore::new();
374        let mut schema = simple_schema("add");
375        schema.parameters = serde_json::json!({
376            "type": "object",
377            "properties": {
378                "a": {"type": "number"},
379                "b": {"type": "number"}
380            },
381            "required": ["a", "b"]
382        });
383        let tools: HashMap<String, ToolSchema> =
384            [("add".to_string(), schema)].into_iter().collect();
385
386        let action = make_tool_call("add"); // no parameters
387        let result = validate_action(&action, &state, &tools);
388        assert!(!result.valid());
389        assert!(result
390            .errors
391            .iter()
392            .any(|e| e.reason.contains("missing required parameter 'a'")));
393        assert!(result
394            .errors
395            .iter()
396            .any(|e| e.reason.contains("missing required parameter 'b'")));
397    }
398
399    #[test]
400    fn required_parameters_provided_passes() {
401        let state = StateStore::new();
402        let mut schema = simple_schema("add");
403        schema.parameters = serde_json::json!({
404            "type": "object",
405            "properties": {
406                "a": {"type": "number"},
407                "b": {"type": "number"}
408            },
409            "required": ["a", "b"]
410        });
411        let tools: HashMap<String, ToolSchema> =
412            [("add".to_string(), schema)].into_iter().collect();
413
414        let mut action = make_tool_call("add");
415        action.parameters = [
416            ("a".to_string(), Value::from(1)),
417            ("b".to_string(), Value::from(2)),
418        ]
419        .into();
420        let result = validate_action(&action, &state, &tools);
421        assert!(result.valid());
422    }
423
424    #[test]
425    fn type_mismatch_rejected_when_schema_registered() {
426        let state = StateStore::new();
427        let mut schema = simple_schema("read");
428        schema.parameters = serde_json::json!({
429            "type": "object",
430            "properties": {
431                "path": {"type": "string"}
432            },
433            "required": ["path"]
434        });
435        let tools: HashMap<String, ToolSchema> =
436            [("read".to_string(), schema)].into_iter().collect();
437
438        let mut action = make_tool_call("read");
439        action.parameters = [("path".to_string(), Value::from(42))].into();
440        let result = validate_action(&action, &state, &tools);
441        assert!(!result.valid(), "type mismatch should be rejected");
442        assert!(
443            result
444                .errors
445                .iter()
446                .any(|e| e.reason.contains("parameter validation")),
447            "expected jsonschema parameter validation failure, got: {:?}",
448            result.errors
449        );
450    }
451
452    #[test]
453    fn empty_object_schema_is_treated_as_legacy() {
454        // Defense-in-depth: a future refactor that "improves"
455        // schema_is_empty_object must not silently turn the legacy
456        // schemaless registration into a hard rejection.
457        assert!(schema_is_empty_object(&Value::Object(Default::default())));
458    }
459
460    #[test]
461    fn tool_output_uses_declared_return_schema() {
462        let schema = serde_json::json!({
463            "type":"object",
464            "properties":{"report_id":{"type":"string"}},
465            "required":["report_id"],
466            "additionalProperties":false
467        });
468        assert!(validate_tool_output(
469            "report_source",
470            &schema,
471            &serde_json::json!({"report_id":"r-1"})
472        )
473        .is_ok());
474        let error = validate_tool_output(
475            "report_source",
476            &schema,
477            &serde_json::json!({"report_id":42}),
478        )
479        .expect_err("wrong return type must fail");
480        assert!(error.contains("output validation"), "{error}");
481    }
482
483    #[test]
484    fn empty_return_schema_preserves_legacy_output() {
485        assert!(validate_tool_output(
486            "legacy",
487            &serde_json::json!({}),
488            &serde_json::json!({"anything":true})
489        )
490        .is_ok());
491    }
492
493    #[test]
494    fn legacy_schemaless_tool_accepts_any_parameters() {
495        let state = StateStore::new();
496        let tools = tools_map(&["echo"]); // simple_schema → empty object
497        let mut action = make_tool_call("echo");
498        action.parameters = [
499            ("anything".to_string(), Value::from(42)),
500            ("else".to_string(), Value::from("string")),
501        ]
502        .into();
503        let result = validate_action(&action, &state, &tools);
504        assert!(
505            result.valid(),
506            "schemaless registration must accept anything"
507        );
508    }
509
510    #[test]
511    fn extra_parameters_allowed() {
512        let state = StateStore::new();
513        let mut schema = simple_schema("echo");
514        schema.parameters = serde_json::json!({
515            "type": "object",
516            "properties": {
517                "message": {"type": "string"}
518            },
519            "required": ["message"]
520        });
521        let tools: HashMap<String, ToolSchema> =
522            [("echo".to_string(), schema)].into_iter().collect();
523
524        let mut action = make_tool_call("echo");
525        action.parameters = [
526            ("message".to_string(), Value::from("hi")),
527            ("unexpected_extra".to_string(), Value::from(true)),
528        ]
529        .into();
530        let result = validate_action(&action, &state, &tools);
531        assert!(result.valid()); // extra params should NOT cause rejection
532    }
533}