Skip to main content

car_engine/
verify_gate.rs

1//! Static plan verification as an admission gate.
2//!
3//! `car-verify` could already reject a bad plan before any tool ran — but the
4//! only call site that did so lived inside the executor's *replan* loop
5//! (`ReplanConfig::verify_before_execute`). That loop needs a registered
6//! [`crate::ReplanCallback`], and it is bounded by `max_replans`, which defaults
7//! to `0`. No production path registered a callback, so the check never fired on
8//! real work: the README promised verification before execution and the runtime
9//! delivered it only for a replan that never happened.
10//!
11//! [`StaticVerificationGate`] moves the same check to the seam that *does* run on
12//! every proposal — [`crate::admission::AdmissionGate`], the same place the
13//! information-flow gate already sits.
14//!
15//! **What this is worth, precisely.** `validate_action` already checks tool
16//! existence, parameter schemas, preconditions, and state dependencies — per
17//! action, and with a *stronger* schema validator than car-verify's (the full
18//! `jsonschema` crate versus a hand-rolled `required`+`type` subset). So on a
19//! **single-action** proposal this gate adds no coverage at all: rejecting "the
20//! whole proposal" and rejecting "the one action" are the same thing, and the
21//! validator gets there anyway.
22//!
23//! The value is entirely on **multi-action** proposals, and it is about *timing*,
24//! not coverage. `validate_action` runs as execution reaches each action, so a bad
25//! tool name in action 5 is discovered after actions 1–4 have already had their
26//! side effects. This gate sees the whole proposal up front and refuses it before
27//! the first dispatch, so nothing partial happens. Register it on any runtime that
28//! accepts externally-authored multi-action proposals; on a runtime that only ever
29//! submits one action at a time it is close to inert.
30//!
31//! **What blocks, and what is only advisory.** Blocking is limited to
32//! *state-independent* findings: an unregistered tool, parameters that violate the
33//! registered schema, and a `tool_call` with no tool. Those are exact — they
34//! cannot be wrong about a plan that would in fact have run.
35//!
36//! Everything state-dependent is advisory, because the forward model is
37//! incomplete: it applies only the effects an action *declares* in
38//! `expected_effects`. An action that really writes a key without declaring it is
39//! invisible, so a downstream precondition or `state_dependency` reading that key
40//! is reported as failing even though execution would have succeeded. Blocking a
41//! whole proposal on that would be a false rejection, and `StaticState::is_unknown`
42//! does not save us — nothing in the workspace ever populates `unknown_keys`, so it
43//! is always false. The loop heuristic (`count >= 3`) is advisory for the same
44//! reason: three legitimate polls are indistinguishable from a runaway loop.
45//!
46//! Preconditions still get enforced — by `validate_action`, at execution time,
47//! against live state, where the answer is accurate. Write conflicts are warnings
48//! upstream and never blocked. Dependency cycles cannot occur at all (`car-ir`'s
49//! DAG edges only point to lower indices).
50
51use crate::admission::{AdmissionGate, GateContext, GateOutcome};
52use car_ir::{ActionProposal, ToolSchema};
53use std::collections::{HashMap, HashSet};
54use std::sync::Arc;
55use tokio::sync::RwLock as TokioRwLock;
56
57/// Default ceiling on how many actions the gate will walk. Matches the
58/// executor's replan-path budget so both verification points agree.
59const DEFAULT_MAX_ACTIONS: usize = 100;
60
61/// Message fragments identifying the findings that are *state-dependent* and
62/// therefore advisory (see the module docs). `advisory_findings_do_not_block`
63/// pins the coupling: change any of these messages upstream and the test fails,
64/// rather than the gate silently starting to reject legitimate proposals.
65///
66/// **`VerifyIssue::tier` is not the structured kind this wanted.** The tier says
67/// how a finding was *derived* — decision procedure, heuristic, or sampling —
68/// and only the loop finding here is a heuristic. `precondition will fail` and
69/// `not available at this point` are `EvidenceTier::DecisionProcedure`: exactly
70/// decided, over a forward model that sees only *declared* effects. That gap,
71/// not the derivation, is why they can't block. Keying this list on
72/// `tier != Heuristic` would start rejecting proposals on the two findings the
73/// module docs above spend a paragraph explaining must stay advisory.
74const ADVISORY_ISSUE_FRAGMENTS: &[&str] = &[
75    // `count >= 3` heuristic — three legitimate polls look identical to a loop.
76    "repeated identical tool call",
77    // Both depend on the forward model, which only sees *declared* effects.
78    "precondition will fail",
79    "not available at this point",
80];
81
82/// Whether a verification issue is exact enough to refuse a whole proposal on.
83///
84/// Blocking findings are state-independent: they are true regardless of what the
85/// tools actually do, so they cannot false-reject a plan that would have run.
86pub fn is_blocking_issue(issue: &car_verify::VerifyIssue) -> bool {
87    issue.severity == "error"
88        && !ADVISORY_ISSUE_FRAGMENTS
89            .iter()
90            .any(|frag| issue.message.contains(frag))
91}
92
93/// Run static verification and return the blocking errors, if any.
94///
95/// Shared by [`StaticVerificationGate`] and the executor's replan quality gate so
96/// the two verification points cannot disagree about what counts as fatal — they
97/// previously did, with the replan path blocking on the loop heuristic this gate
98/// deliberately treats as advisory.
99///
100/// An **empty** `tools` map means "this runtime doesn't declare its tools", not
101/// "no tool exists". Passing it through would flag every `tool_call` as
102/// unregistered and reject every proposal, turning a safety check into a denial of
103/// service for any embedder that executes via callback without registering
104/// schemas. In that case tool-existence and parameter-schema checking are skipped.
105///
106/// Note `Runtime::restore_checkpoint` rebuilds the registry with name-only schemas
107/// (`parameters: {}`), so the map stays non-empty and existence still checks, but
108/// parameter validation silently becomes a no-op after a restore.
109pub fn blocking_errors(
110    proposal: &ActionProposal,
111    state: Option<&HashMap<String, serde_json::Value>>,
112    tools: &HashMap<String, ToolSchema>,
113    max_actions: usize,
114) -> Vec<car_verify::VerifyIssue> {
115    let result = if tools.is_empty() {
116        car_verify::verify(proposal, state, None, max_actions)
117    } else {
118        car_verify::verify_with_schemas(proposal, state, Some(tools), max_actions)
119    };
120    if result.valid {
121        return Vec::new();
122    }
123    result
124        .issues
125        .into_iter()
126        .filter(is_blocking_issue)
127        .collect()
128}
129
130/// An admission gate that statically verifies a proposal before any action runs.
131///
132/// Holds a handle to the runtime's tool registry rather than a back-reference to
133/// the `Runtime` itself — gates are stored *on* the runtime, so an `Arc<Runtime>`
134/// here would be a cycle. The registry is already `Arc`-shared, so cloning it is
135/// both cheap and always current: tools registered after this gate is built are
136/// visible to it.
137pub struct StaticVerificationGate {
138    tools: Arc<TokioRwLock<HashMap<String, ToolSchema>>>,
139    max_actions: usize,
140}
141
142impl StaticVerificationGate {
143    /// Build a gate reading tool schemas from the runtime's live registry.
144    pub fn new(tools: Arc<TokioRwLock<HashMap<String, ToolSchema>>>) -> Self {
145        Self {
146            tools,
147            max_actions: DEFAULT_MAX_ACTIONS,
148        }
149    }
150
151    /// Override the action ceiling (proposals longer than this are not walked).
152    pub fn with_max_actions(mut self, max_actions: usize) -> Self {
153        self.max_actions = max_actions;
154        self
155    }
156}
157
158#[async_trait::async_trait]
159impl AdmissionGate for StaticVerificationGate {
160    fn name(&self) -> &str {
161        "static_verification"
162    }
163
164    async fn check(&self, proposal: &ActionProposal, ctx: &GateContext<'_>) -> GateOutcome {
165        let errors = {
166            // The read guard is held across the synchronous verify call and
167            // dropped before returning; car-verify does no .await, so nothing
168            // can deadlock behind it.
169            let tools = self.tools.read().await;
170            blocking_errors(proposal, Some(ctx.state), &tools, self.max_actions)
171        };
172        if errors.is_empty() {
173            return GateOutcome::Allow;
174        }
175
176        // Only real action ids go in `blocked`. car-verify attributes some
177        // findings to synthetic ids (loop detection uses "proposal"), and an id
178        // matching no action would make the executor fall back to the generic
179        // "blocked by admission gate" message for every action, hiding the
180        // actual reason.
181        let real_ids: HashSet<&str> = proposal.actions.iter().map(|a| a.id.as_str()).collect();
182        let blocked: HashSet<String> = errors
183            .iter()
184            .map(|i| i.action_id.clone())
185            .filter(|id| real_ids.contains(id.as_str()))
186            .collect();
187        let reason = format!(
188            "static verification failed: {}",
189            errors
190                .iter()
191                .map(|i| i.message.as_str())
192                .collect::<Vec<_>>()
193                .join("; ")
194        );
195        GateOutcome::Reject { blocked, reason }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use serde_json::json;
203
204    /// Build via serde rather than a struct literal: `Action` has 16 fields and
205    /// no `Default`, so a literal here would need editing every time an
206    /// unrelated field lands. The `#[serde(default)]` attributes are the
207    /// contract we actually care about.
208    fn schema(name: &str) -> ToolSchema {
209        serde_json::from_value(json!({ "name": name })).expect("tool schema fixture")
210    }
211
212    fn registry(names: &[&str]) -> Arc<TokioRwLock<HashMap<String, ToolSchema>>> {
213        let map = names
214            .iter()
215            .map(|n| (n.to_string(), schema(n)))
216            .collect::<HashMap<_, _>>();
217        Arc::new(TokioRwLock::new(map))
218    }
219
220    fn proposal_of(actions: serde_json::Value) -> ActionProposal {
221        serde_json::from_value(json!({
222            "id": "p1", "source": "test", "actions": actions,
223        }))
224        .expect("proposal fixture")
225    }
226
227    fn one(action_id: &str, tool: &str) -> ActionProposal {
228        proposal_of(json!([{
229            "id": action_id, "type": "tool_call", "tool": tool, "parameters": {},
230        }]))
231    }
232
233    async fn check(
234        gate: &StaticVerificationGate,
235        proposal: &ActionProposal,
236        state: &HashMap<String, serde_json::Value>,
237    ) -> GateOutcome {
238        let versions = HashMap::new();
239        let ctx = GateContext {
240            session_id: None,
241            scope: None,
242            state,
243            versions: &versions,
244        };
245        gate.check(proposal, &ctx).await
246    }
247
248    /// The regression this gate exists to prevent: on a multi-action proposal a
249    /// bad tool in a *later* action must stop the whole thing before the first
250    /// action dispatches, so no partial side effects happen.
251    #[tokio::test]
252    async fn rejects_before_any_action_when_a_later_tool_is_unregistered() {
253        let gate = StaticVerificationGate::new(registry(&["echo"]));
254        let proposal = proposal_of(json!([
255            { "id": "a1", "type": "tool_call", "tool": "echo", "parameters": {} },
256            { "id": "a2", "type": "tool_call", "tool": "ghost", "parameters": {} },
257        ]));
258
259        match check(&gate, &proposal, &HashMap::new()).await {
260            GateOutcome::Reject { blocked, reason } => {
261                assert!(blocked.contains("a2"), "the offending action must be named");
262                assert!(
263                    !blocked.contains("a1"),
264                    "the valid action is collateral, not the cause"
265                );
266                assert!(
267                    reason.contains("static verification failed"),
268                    "got: {reason}"
269                );
270            }
271            other => panic!("expected Reject, got {other:?}"),
272        }
273    }
274
275    /// Parameter-schema violations block: a missing required field is exact and
276    /// state-independent, so refusing early cannot be a false rejection.
277    #[tokio::test]
278    async fn rejects_parameter_schema_violation() {
279        let strict: ToolSchema = serde_json::from_value(json!({
280            "name": "write",
281            "parameters": {
282                "type": "object",
283                "properties": { "path": { "type": "string" } },
284                "required": ["path"],
285            },
286        }))
287        .expect("schema");
288        let reg = registry(&[]);
289        reg.write().await.insert("write".to_string(), strict);
290        let gate = StaticVerificationGate::new(reg);
291
292        match check(&gate, &one("a1", "write"), &HashMap::new()).await {
293            GateOutcome::Reject { reason, .. } => {
294                assert!(
295                    reason.contains("path"),
296                    "reason should name the field: {reason}"
297                )
298            }
299            other => panic!("expected Reject for a missing required param, got {other:?}"),
300        }
301    }
302
303    #[tokio::test]
304    async fn allows_a_valid_proposal() {
305        let gate = StaticVerificationGate::new(registry(&["echo"]));
306        assert!(
307            matches!(
308                check(&gate, &one("a1", "echo"), &HashMap::new()).await,
309                GateOutcome::Allow
310            ),
311            "a valid proposal must not be blocked"
312        );
313    }
314
315    /// An embedder that registers no schemas executes through its own callback.
316    /// Blocking every such proposal would be a denial of service.
317    #[tokio::test]
318    async fn empty_registry_does_not_block_every_proposal() {
319        let gate = StaticVerificationGate::new(registry(&[]));
320        assert!(
321            matches!(
322                check(&gate, &one("a1", "anything_at_all"), &HashMap::new()).await,
323                GateOutcome::Allow
324            ),
325            "an empty tool registry means 'undeclared', not 'nothing exists'"
326        );
327    }
328
329    /// The gate holds the live registry, not a snapshot.
330    #[tokio::test]
331    async fn sees_tools_registered_after_construction() {
332        let reg = registry(&[]);
333        let gate = StaticVerificationGate::new(reg.clone());
334        reg.write().await.insert("echo".to_string(), schema("echo"));
335        assert!(
336            matches!(
337                check(&gate, &one("a1", "ghost"), &HashMap::new()).await,
338                GateOutcome::Reject { .. }
339            ),
340            "the registry is now non-empty, so an unknown tool must be caught"
341        );
342    }
343
344    /// State-dependent findings are advisory, and this pins the message
345    /// coupling in `ADVISORY_ISSUE_FRAGMENTS`: it asserts car-verify still
346    /// *reports* each one at error severity (so the fragments are still live)
347    /// while the gate still allows. Change a message upstream and this fails,
348    /// rather than the gate silently starting to reject valid proposals.
349    #[tokio::test]
350    async fn advisory_findings_do_not_block() {
351        let gate = StaticVerificationGate::new(registry(&["poll"]));
352
353        // 3 identical calls -> loop heuristic; unmet state_dependency -> the
354        // "not available at this point" finding; failing precondition -> the
355        // "precondition will fail" finding.
356        let proposal = proposal_of(json!([
357            { "id": "a1", "type": "tool_call", "tool": "poll", "parameters": {} },
358            { "id": "a2", "type": "tool_call", "tool": "poll", "parameters": {} },
359            { "id": "a3", "type": "tool_call", "tool": "poll", "parameters": {},
360              "state_dependencies": ["written_but_undeclared"] },
361            { "id": "a4", "type": "tool_call", "tool": "poll", "parameters": {},
362              "preconditions": [{ "key": "missing", "op": "exists" }] },
363        ]));
364
365        let schemas: HashMap<String, ToolSchema> =
366            [("poll".to_string(), schema("poll"))].into_iter().collect();
367        let raw = car_verify::verify_with_schemas(&proposal, None, Some(&schemas), 100);
368        for frag in ADVISORY_ISSUE_FRAGMENTS {
369            assert!(
370                raw.errors().iter().any(|i| i.message.contains(frag)),
371                "car-verify no longer reports an error containing {frag:?} — update \
372                 ADVISORY_ISSUE_FRAGMENTS, or the gate will start blocking on it"
373            );
374        }
375
376        assert!(
377            matches!(
378                check(&gate, &proposal, &HashMap::new()).await,
379                GateOutcome::Allow
380            ),
381            "state-dependent and heuristic findings must not reject a proposal"
382        );
383    }
384
385    /// Warnings never block — a write conflict is reported at `warning`
386    /// severity and `valid` stays true, so the plan still runs.
387    #[tokio::test]
388    async fn warnings_do_not_block() {
389        let gate = StaticVerificationGate::new(registry(&["w"]));
390        let proposal = proposal_of(json!([
391            { "id": "a1", "type": "tool_call", "tool": "w", "parameters": {},
392              "expected_effects": { "k": 1 } },
393            { "id": "a2", "type": "tool_call", "tool": "w", "parameters": {},
394              "expected_effects": { "k": 2 } },
395        ]));
396        assert!(
397            matches!(
398                check(&gate, &proposal, &HashMap::new()).await,
399                GateOutcome::Allow
400            ),
401            "a write conflict is a warning, not a refusal"
402        );
403    }
404}