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}