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 — plus one state-dependent
34//! finding narrow enough to share their footing: an **unmeetable dependency**
35//! (below), which is decided against the executor's own ordering. The state-independent three are exact — they cannot be wrong
36//! about a plan that would in fact have run.
37//!
38//! Every other state-dependent finding is advisory, because the forward model is
39//! incomplete: it applies only the effects an action *declares* in
40//! `expected_effects`. An action that really writes a key without declaring it is
41//! invisible, so a downstream precondition or `state_dependency` reading that key
42//! is reported as failing even though execution would have succeeded. Blocking a
43//! whole proposal on that would be a false rejection, and `StaticState::is_unknown`
44//! does not save us — nothing in the workspace ever populates `unknown_keys`, so it
45//! is always false. The loop heuristic (`count >= 3`) is advisory for the same
46//! reason: three legitimate polls are indistinguishable from a runaway loop.
47//!
48//! Preconditions still get enforced — by `validate_action`, at execution time,
49//! against live state, where the answer is accurate. Write conflicts are warnings
50//! upstream and never blocked.
51//!
52//! **Unmeetable dependencies.** `car-ir`'s DAG edges only point to lower
53//! indices, so a dependency cycle cannot be drawn there; it surfaces as unmet
54//! dependencies, and left advisory that meant partial execution (car#3060): the
55//! validator refused the cycle's actions at run time after the rest of the
56//! proposal had already run. The gate now refuses the whole proposal when an
57//! action is certain to be refused that way, which takes all of:
58//!
59//! - it needs (through `state_dependencies`, or a precondition that fails when
60//! its key is absent) a key that is **absent from live state at admission**
61//! — read in the namespace the action executes in (car#3121): under a
62//! tenant-scoped proposal, a `state_write` / `state_read` / `assertion`
63//! reads its tenant's keys, so a bare shared key does not count for it,
64//! while a `tool_call` runs on the unscoped path and reads shared keys;
65//! - no declared writer of that key (in `expected_effects`, or as a
66//! `state_write`'s key) that is not itself certain to be refused **finishes
67//! before it** — "before" meaning the executor's own order: an
68//! earlier `build_dag` level, or earlier in a level that runs sequentially
69//! (one action, or any action with `FailureBehavior::Abort`, the default);
70//! - no such **tool call** finishes before it either, because a tool's handler
71//! can write state it never declares: a WebSocket client's `tools.execute`
72//! handler sending `state.set` on its connection, a NAPI / PyO3 / UniFFI
73//! callback calling `state_set`, a car-multi delegate or spawned subtask
74//! writing the parent's shared store. A streaming or long-running tool call
75//! returns a handle before its handler is done, so it does not count.
76//!
77//! A cycle whose members wait only on each other is the main case; a key
78//! whose only declared writer runs later, or that nothing in the proposal
79//! declares writing, is the same defect without one. Such a proposal cannot
80//! complete through its declared effects and ordering.
81//!
82//! What stays advisory, still refused per action at run time while the other
83//! actions run:
84//!
85//! - **A tool call that finishes first.** The issue's own shape, `[a0, a1, a2]`
86//! with a0 a `tool_call` and the default failure behavior, is one: the level
87//! holding a0 and a1 runs sequentially, so a0's handler may set a1's key, and
88//! the plan may complete.
89//! - **A needed key already in live state.**
90//!
91//! The residual is a race. Two actions in a level that runs concurrently are
92//! ordered neither way, so a tool call there could still set a waiting
93//! action's key before it is validated; so could a streaming or long-running
94//! tool call ordered earlier whose handler happens to finish in time; and a
95//! write from outside the proposal (another client sending `state.set` on the
96//! same session) can land at any time — for a key nothing declares writing,
97//! that is the only way it could be set. The gate refuses those plans, which
98//! complete only by timing. The
99//! refusal names the key, so the fix is to declare the write on an action that
100//! runs earlier, or to set the key before submitting. Without live state
101//! (`blocking_errors` given `None`) the check does not run.
102
103use crate::admission::{AdmissionGate, GateContext, GateOutcome};
104use car_ir::precondition::{check_precondition, StateView};
105use car_ir::{ActionProposal, ActionType, ToolSchema};
106use std::collections::{HashMap, HashSet};
107use std::sync::Arc;
108use tokio::sync::RwLock as TokioRwLock;
109
110/// Default ceiling on how many actions the gate will walk. Matches the
111/// executor's replan-path budget so both verification points agree.
112const DEFAULT_MAX_ACTIONS: usize = 100;
113
114/// Message fragments identifying the findings that are *state-dependent* and
115/// therefore advisory (see the module docs). `advisory_findings_do_not_block`
116/// pins the coupling: change any of these messages upstream and the test fails,
117/// rather than the gate silently starting to reject legitimate proposals.
118///
119/// **`VerifyIssue::tier` is not the structured kind this wanted.** The tier says
120/// how a finding was *derived* — decision procedure, heuristic, or sampling —
121/// and only the loop finding here is a heuristic. `precondition will fail` and
122/// `not available at this point` are `EvidenceTier::DecisionProcedure`: exactly
123/// decided, over a forward model that sees only *declared* effects. That gap,
124/// not the derivation, is why they can't block. Keying this list on
125/// `tier != Heuristic` would start rejecting proposals on the two findings the
126/// module docs above spend a paragraph explaining must stay advisory.
127const ADVISORY_ISSUE_FRAGMENTS: &[&str] = &[
128 // `count >= 3` heuristic — three legitimate polls look identical to a loop.
129 "repeated identical tool call",
130 // Both depend on the forward model, which only sees *declared* effects.
131 "precondition will fail",
132 "not available at this point",
133];
134
135/// Whether a car-verify issue is exact enough to refuse a whole proposal on.
136///
137/// Blocking car-verify findings are state-independent: they are true regardless of
138/// what the tools actually do, so they cannot false-reject a plan that would have
139/// run. The one state-dependent refusal, an unmeetable dependency, is not a
140/// car-verify finding; [`blocking_errors`] adds it.
141pub fn is_blocking_issue(issue: &car_verify::VerifyIssue) -> bool {
142 issue.severity == "error"
143 && !ADVISORY_ISSUE_FRAGMENTS
144 .iter()
145 .any(|frag| issue.message.contains(frag))
146}
147
148/// Run static verification and return the blocking errors, if any.
149///
150/// Shared by [`StaticVerificationGate`] and the executor's replan quality gate so
151/// the two verification points cannot disagree about what counts as fatal — they
152/// previously did, with the replan path blocking on the loop heuristic this gate
153/// deliberately treats as advisory.
154///
155/// An **empty** `tools` map means "this runtime doesn't declare its tools", not
156/// "no tool exists". Passing it through would flag every `tool_call` as
157/// unregistered and reject every proposal, turning a safety check into a denial of
158/// service for any embedder that executes via callback without registering
159/// schemas. In that case tool-existence and parameter-schema checking are skipped.
160///
161/// Note `Runtime::restore_checkpoint` rebuilds the registry with name-only schemas
162/// (`parameters: {}`), so the map stays non-empty and existence still checks, but
163/// parameter validation silently becomes a no-op after a restore.
164///
165/// `state` is the keys visible to `tenant`
166/// (`StateStore::versioned_snapshot_visible_to`), or the whole store when
167/// unscoped, and `tenant` the proposal's tenant, if any: each action's keys
168/// are resolved in the namespace it executes in (car#3121, see the module
169/// docs).
170pub fn blocking_errors(
171 proposal: &ActionProposal,
172 state: Option<&HashMap<String, serde_json::Value>>,
173 tenant: Option<&str>,
174 tools: &HashMap<String, ToolSchema>,
175 max_actions: usize,
176) -> Vec<car_verify::VerifyIssue> {
177 let result = if tools.is_empty() {
178 car_verify::verify(proposal, state, None, max_actions)
179 } else {
180 car_verify::verify_with_schemas(proposal, state, Some(tools), max_actions)
181 };
182 let mut errors: Vec<car_verify::VerifyIssue> = if result.valid {
183 Vec::new()
184 } else {
185 result
186 .issues
187 .into_iter()
188 .filter(is_blocking_issue)
189 .collect()
190 };
191 if let Some(state) = state {
192 errors.extend(unmeetable_dependencies(proposal, state, tenant));
193 }
194 errors
195}
196
197/// A state view in which no key exists: asks whether a precondition fails when
198/// its key is absent. A precondition reads only its own key, so the answer is
199/// exact for that key.
200struct NoKeys;
201
202impl StateView for NoKeys {
203 fn get_value(&self, _key: &str) -> Option<serde_json::Value> {
204 None
205 }
206
207 fn key_exists(&self, _key: &str) -> bool {
208 false
209 }
210}
211
212/// The actions of `proposal` that the validator is certain to refuse, because a
213/// key each one needs cannot be written before it is validated — one error
214/// finding each (see the module docs for the rule and its residual).
215///
216/// An action *needs* a key when it lists it in `state_dependencies`, or has a
217/// precondition on it that fails when the key is absent; only keys absent from
218/// `state` count, whether or not any action declares writing them. "Before" is the
219/// executor's order: an earlier `build_dag` level, or earlier in a level that
220/// runs sequentially ([`crate::executor::level_runs_sequentially`]); two
221/// actions in a concurrent level are ordered neither way.
222///
223/// Every action starts stuck. An action is freed when every key it needs has a
224/// free declared writer ordered before it (trivially, when it needs nothing),
225/// or when a free, non-detached tool call is ordered before it: that call's
226/// handler may set the key without declaring it. What is still stuck when
227/// nothing more can be freed is reported. Each free action is processed once:
228/// per key, the needers sorted by order are freed from the latest down as the
229/// key's earliest free writer moves earlier, and the tool rule does the same
230/// over all actions.
231///
232/// Keys are compared as the store holds them. Under a tenant, a state action's
233/// keys resolve to `tenant:<id>:<key>` and a tool call's stay shared
234/// ([`crate::executor::action_state_tenant`]), so a tenant `state_write` does
235/// not free a tool call's shared need. A tool call's declared effect frees the
236/// key in both namespaces, because its handler can write either: callback
237/// `state_changes` land in the tenant's namespace, a `state.set` in the shared
238/// one.
239fn unmeetable_dependencies(
240 proposal: &ActionProposal,
241 state: &HashMap<String, serde_json::Value>,
242 tenant: Option<&str>,
243) -> Vec<car_verify::VerifyIssue> {
244 let actions = &proposal.actions;
245 let n = actions.len();
246
247 // Execution order as the executor runs it: `rank[j] < rank[i]` exactly when
248 // j finishes before i is validated. Within a concurrent level every action
249 // shares one rank, because none is guaranteed to finish first.
250 let mut rank: Vec<(usize, usize)> = vec![(0, 0); n];
251 for (level_no, level) in car_ir::build_dag(actions).iter().enumerate() {
252 let sequential = crate::executor::level_runs_sequentially(level, actions);
253 for (pos, &i) in level.iter().enumerate() {
254 rank[i] = (level_no, if sequential { pos } else { 0 });
255 }
256 }
257 let by_rank = |mut v: Vec<usize>| {
258 v.sort_by_key(|&i| rank[i]);
259 v
260 };
261
262 // An action's keys as the store holds them (car#3121).
263 let store_key = |i: usize, key: &str| {
264 car_state::tenant_key(
265 crate::executor::action_state_tenant(&actions[i], tenant),
266 key,
267 )
268 };
269
270 // The keys each action declares writing, then declared writers per key.
271 let writes: Vec<Vec<String>> = actions
272 .iter()
273 .map(|action| crate::executor::action_write_store_keys(action, tenant))
274 .collect();
275 let mut writers: HashMap<&str, Vec<usize>> = HashMap::new();
276 for (i, keys) in writes.iter().enumerate() {
277 for key in keys {
278 writers.entry(key.as_str()).or_default().push(i);
279 }
280 }
281
282 // Each needed key as (store key, the name the action uses).
283 let needs: Vec<Vec<(String, &str)>> = actions
284 .iter()
285 .enumerate()
286 .map(|(i, action)| {
287 let mut keys: Vec<(String, &str)> = action
288 .state_dependencies
289 .iter()
290 .map(String::as_str)
291 .chain(
292 action
293 .preconditions
294 .iter()
295 .filter(|pre| check_precondition(pre, &NoKeys).is_some())
296 .map(|pre| pre.key.as_str()),
297 )
298 .map(|key| (store_key(i, key), key))
299 .filter(|(stored, _)| !state.contains_key(stored))
300 .collect();
301 keys.sort_unstable();
302 keys.dedup();
303 keys
304 })
305 .collect();
306
307 // Per key: its needers in execution order, and how many of them (a suffix)
308 // already have a free writer ordered before them.
309 let mut needers: HashMap<&str, Vec<usize>> = HashMap::new();
310 for (i, keys) in needs.iter().enumerate() {
311 for (key, _) in keys {
312 needers.entry(key.as_str()).or_default().push(i);
313 }
314 }
315 let mut needers: HashMap<&str, (Vec<usize>, usize)> = needers
316 .into_iter()
317 .map(|(key, v)| {
318 let v = by_rank(v);
319 let cut = v.len();
320 (key, (v, cut))
321 })
322 .collect();
323 let all_by_rank = by_rank((0..n).collect());
324 let mut tool_cut = n;
325 let can_write_undeclared = |j: usize| {
326 actions[j].action_type == ActionType::ToolCall && !actions[j].invocation_mode.is_detached()
327 };
328
329 let mut stuck = vec![true; n];
330 let mut unmet: Vec<usize> = needs.iter().map(Vec::len).collect();
331 let mut worklist: Vec<usize> = (0..n).filter(|&i| needs[i].is_empty()).collect();
332 for &i in &worklist {
333 stuck[i] = false;
334 }
335 while let Some(i) = worklist.pop() {
336 for key in &writes[i] {
337 let Some((order, cut)) = needers.get_mut(key.as_str()) else {
338 continue;
339 };
340 while *cut > 0 && rank[order[*cut - 1]] > rank[i] {
341 *cut -= 1;
342 let m = order[*cut];
343 unmet[m] -= 1;
344 if stuck[m] && unmet[m] == 0 {
345 stuck[m] = false;
346 worklist.push(m);
347 }
348 }
349 }
350 if can_write_undeclared(i) {
351 while tool_cut > 0 && rank[all_by_rank[tool_cut - 1]] > rank[i] {
352 tool_cut -= 1;
353 let m = all_by_rank[tool_cut];
354 if stuck[m] {
355 stuck[m] = false;
356 worklist.push(m);
357 }
358 }
359 }
360 }
361
362 (0..n)
363 .filter(|&i| stuck[i])
364 .filter_map(|i| {
365 // A key this action still waits on: no free writer is ordered
366 // before it, so it is not in this key's freed suffix.
367 let (stored, key) = needs[i].iter().find(|(stored, _)| {
368 needers
369 .get(stored.as_str())
370 .is_some_and(|(order, cut)| !order[*cut..].contains(&i))
371 })?;
372 let writers_note = writers
373 .get(stored.as_str())
374 .map(|ws| {
375 ws.iter()
376 .map(|&j| format!("'{}'", actions[j].id))
377 .collect::<Vec<_>>()
378 .join(", ")
379 })
380 .map(|ids| format!("its declared writers ({ids}) are themselves refused or run after it"))
381 .unwrap_or_else(|| "no action declares writing it".to_string());
382 Some(car_verify::VerifyIssue::new(
383 actions[i].id.clone(),
384 "error",
385 format!(
386 "unmeetable dependency: action '{}' needs '{key}', which is not in state; \
387 {writers_note}, and no tool call finishes before it to set '{key}', so the proposal \
388 cannot complete through its declared effects and ordering. Declare the \
389 write on an action that runs earlier, or set '{key}' before submitting",
390 actions[i].id
391 ),
392 // Decided over declared effects, the executor's ordering, and
393 // live state at admission; the residual (a race inside a
394 // concurrent level) is in the module docs.
395 car_verify::EvidenceTier::DecisionProcedure,
396 ))
397 })
398 .collect()
399}
400
401/// An admission gate that statically verifies a proposal before any action runs.
402///
403/// Holds a handle to the runtime's tool registry rather than a back-reference to
404/// the `Runtime` itself — gates are stored *on* the runtime, so an `Arc<Runtime>`
405/// here would be a cycle. The registry is already `Arc`-shared, so cloning it is
406/// both cheap and always current: tools registered after this gate is built are
407/// visible to it.
408pub struct StaticVerificationGate {
409 tools: Arc<TokioRwLock<HashMap<String, ToolSchema>>>,
410 max_actions: usize,
411}
412
413impl StaticVerificationGate {
414 /// Build a gate reading tool schemas from the runtime's live registry.
415 pub fn new(tools: Arc<TokioRwLock<HashMap<String, ToolSchema>>>) -> Self {
416 Self {
417 tools,
418 max_actions: DEFAULT_MAX_ACTIONS,
419 }
420 }
421
422 /// Override the action ceiling (proposals longer than this are not walked).
423 pub fn with_max_actions(mut self, max_actions: usize) -> Self {
424 self.max_actions = max_actions;
425 self
426 }
427}
428
429#[async_trait::async_trait]
430impl AdmissionGate for StaticVerificationGate {
431 fn name(&self) -> &str {
432 "static_verification"
433 }
434
435 async fn check(&self, proposal: &ActionProposal, ctx: &GateContext<'_>) -> GateOutcome {
436 let errors = {
437 // The read guard is held across the synchronous verify call and
438 // dropped before returning; car-verify does no .await, so nothing
439 // can deadlock behind it.
440 let tools = self.tools.read().await;
441 let tenant = ctx.scope.and_then(|s| s.tenant_id.as_deref());
442 blocking_errors(proposal, Some(ctx.state), tenant, &tools, self.max_actions)
443 };
444 if errors.is_empty() {
445 return GateOutcome::Allow;
446 }
447
448 // Only real action ids go in `blocked`. car-verify attributes some
449 // findings to synthetic ids (loop detection uses "proposal"), and an id
450 // matching no action would make the executor fall back to the generic
451 // "blocked by admission gate" message for every action, hiding the
452 // actual reason.
453 let real_ids: HashSet<&str> = proposal.actions.iter().map(|a| a.id.as_str()).collect();
454 let blocked: HashSet<String> = errors
455 .iter()
456 .map(|i| i.action_id.clone())
457 .filter(|id| real_ids.contains(id.as_str()))
458 .collect();
459 let reason = format!(
460 "static verification failed: {}",
461 errors
462 .iter()
463 .map(|i| i.message.as_str())
464 .collect::<Vec<_>>()
465 .join("; ")
466 );
467 GateOutcome::Reject { blocked, reason }
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use serde_json::json;
475
476 /// Build via serde rather than a struct literal: `Action` has 16 fields and
477 /// no `Default`, so a literal here would need editing every time an
478 /// unrelated field lands. The `#[serde(default)]` attributes are the
479 /// contract we actually care about.
480 fn schema(name: &str) -> ToolSchema {
481 serde_json::from_value(json!({ "name": name })).expect("tool schema fixture")
482 }
483
484 fn registry(names: &[&str]) -> Arc<TokioRwLock<HashMap<String, ToolSchema>>> {
485 let map = names
486 .iter()
487 .map(|n| (n.to_string(), schema(n)))
488 .collect::<HashMap<_, _>>();
489 Arc::new(TokioRwLock::new(map))
490 }
491
492 fn proposal_of(actions: serde_json::Value) -> ActionProposal {
493 serde_json::from_value(json!({
494 "id": "p1", "source": "test", "actions": actions,
495 }))
496 .expect("proposal fixture")
497 }
498
499 fn one(action_id: &str, tool: &str) -> ActionProposal {
500 proposal_of(json!([{
501 "id": action_id, "type": "tool_call", "tool": tool, "parameters": {},
502 }]))
503 }
504
505 async fn check(
506 gate: &StaticVerificationGate,
507 proposal: &ActionProposal,
508 state: &HashMap<String, serde_json::Value>,
509 ) -> GateOutcome {
510 let versions = HashMap::new();
511 let ctx = GateContext {
512 session_id: None,
513 scope: None,
514 state,
515 versions: &versions,
516 };
517 gate.check(proposal, &ctx).await
518 }
519
520 /// The regression this gate exists to prevent: on a multi-action proposal a
521 /// bad tool in a *later* action must stop the whole thing before the first
522 /// action dispatches, so no partial side effects happen.
523 #[tokio::test]
524 async fn rejects_before_any_action_when_a_later_tool_is_unregistered() {
525 let gate = StaticVerificationGate::new(registry(&["echo"]));
526 let proposal = proposal_of(json!([
527 { "id": "a1", "type": "tool_call", "tool": "echo", "parameters": {} },
528 { "id": "a2", "type": "tool_call", "tool": "ghost", "parameters": {} },
529 ]));
530
531 match check(&gate, &proposal, &HashMap::new()).await {
532 GateOutcome::Reject { blocked, reason } => {
533 assert!(blocked.contains("a2"), "the offending action must be named");
534 assert!(
535 !blocked.contains("a1"),
536 "the valid action is collateral, not the cause"
537 );
538 assert!(
539 reason.contains("static verification failed"),
540 "got: {reason}"
541 );
542 }
543 other => panic!("expected Reject, got {other:?}"),
544 }
545 }
546
547 /// Parameter-schema violations block: a missing required field is exact and
548 /// state-independent, so refusing early cannot be a false rejection.
549 #[tokio::test]
550 async fn rejects_parameter_schema_violation() {
551 let strict: ToolSchema = serde_json::from_value(json!({
552 "name": "write",
553 "parameters": {
554 "type": "object",
555 "properties": { "path": { "type": "string" } },
556 "required": ["path"],
557 },
558 }))
559 .expect("schema");
560 let reg = registry(&[]);
561 reg.write().await.insert("write".to_string(), strict);
562 let gate = StaticVerificationGate::new(reg);
563
564 match check(&gate, &one("a1", "write"), &HashMap::new()).await {
565 GateOutcome::Reject { reason, .. } => {
566 assert!(
567 reason.contains("path"),
568 "reason should name the field: {reason}"
569 )
570 }
571 other => panic!("expected Reject for a missing required param, got {other:?}"),
572 }
573 }
574
575 #[tokio::test]
576 async fn allows_a_valid_proposal() {
577 let gate = StaticVerificationGate::new(registry(&["echo"]));
578 assert!(
579 matches!(
580 check(&gate, &one("a1", "echo"), &HashMap::new()).await,
581 GateOutcome::Allow
582 ),
583 "a valid proposal must not be blocked"
584 );
585 }
586
587 /// An embedder that registers no schemas executes through its own callback.
588 /// Blocking every such proposal would be a denial of service.
589 #[tokio::test]
590 async fn empty_registry_does_not_block_every_proposal() {
591 let gate = StaticVerificationGate::new(registry(&[]));
592 assert!(
593 matches!(
594 check(&gate, &one("a1", "anything_at_all"), &HashMap::new()).await,
595 GateOutcome::Allow
596 ),
597 "an empty tool registry means 'undeclared', not 'nothing exists'"
598 );
599 }
600
601 /// The gate holds the live registry, not a snapshot.
602 #[tokio::test]
603 async fn sees_tools_registered_after_construction() {
604 let reg = registry(&[]);
605 let gate = StaticVerificationGate::new(reg.clone());
606 reg.write().await.insert("echo".to_string(), schema("echo"));
607 assert!(
608 matches!(
609 check(&gate, &one("a1", "ghost"), &HashMap::new()).await,
610 GateOutcome::Reject { .. }
611 ),
612 "the registry is now non-empty, so an unknown tool must be caught"
613 );
614 }
615
616 /// State-dependent findings are advisory, and this pins the message
617 /// coupling in `ADVISORY_ISSUE_FRAGMENTS`: it asserts car-verify still
618 /// *reports* each one at error severity (so the fragments are still live)
619 /// while the gate still allows. Change a message upstream and this fails,
620 /// rather than the gate silently starting to reject valid proposals.
621 #[tokio::test]
622 async fn advisory_findings_do_not_block() {
623 let gate = StaticVerificationGate::new(registry(&["poll"]));
624
625 // 3 identical calls -> loop heuristic; unmet state_dependency -> the
626 // "not available at this point" finding; failing precondition -> the
627 // "precondition will fail" finding.
628 let proposal = proposal_of(json!([
629 { "id": "a1", "type": "tool_call", "tool": "poll", "parameters": {} },
630 { "id": "a2", "type": "tool_call", "tool": "poll", "parameters": {} },
631 { "id": "a3", "type": "tool_call", "tool": "poll", "parameters": {},
632 "state_dependencies": ["written_but_undeclared"] },
633 { "id": "a4", "type": "tool_call", "tool": "poll", "parameters": {},
634 "preconditions": [{ "key": "missing", "op": "exists" }] },
635 ]));
636
637 let schemas: HashMap<String, ToolSchema> =
638 [("poll".to_string(), schema("poll"))].into_iter().collect();
639 let raw = car_verify::verify_with_schemas(&proposal, None, Some(&schemas), 100);
640 for frag in ADVISORY_ISSUE_FRAGMENTS {
641 assert!(
642 raw.errors().iter().any(|i| i.message.contains(frag)),
643 "car-verify no longer reports an error containing {frag:?} — update \
644 ADVISORY_ISSUE_FRAGMENTS, or the gate will start blocking on it"
645 );
646 }
647
648 assert!(
649 matches!(
650 check(&gate, &proposal, &HashMap::new()).await,
651 GateOutcome::Allow
652 ),
653 "state-dependent and heuristic findings must not reject a proposal"
654 );
655 }
656
657 /// a1 and a2 each need a key only the other declares writing, neither key
658 /// is in live state, and the only other action is a state_write of an
659 /// unrelated key. The whole proposal is refused, and `blocked` names the
660 /// cycle, not a0.
661 #[tokio::test]
662 async fn rejects_a_pure_declared_effect_cycle() {
663 let gate = StaticVerificationGate::new(registry(&["step"]));
664 let proposal = proposal_of(json!([
665 { "id": "a0", "type": "state_write", "parameters": { "key": "k0", "value": 0 } },
666 { "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
667 "state_dependencies": ["k2"], "expected_effects": { "k1": 1 } },
668 { "id": "a2", "type": "tool_call", "tool": "step", "parameters": {},
669 "state_dependencies": ["k1"], "expected_effects": { "k2": 2 } },
670 ]));
671
672 match check(&gate, &proposal, &HashMap::new()).await {
673 GateOutcome::Reject { blocked, reason } => {
674 let expected: HashSet<String> = ["a1", "a2"].map(String::from).into();
675 assert_eq!(blocked, expected);
676 assert!(
677 reason.contains("cannot complete") && reason.contains("'k2'"),
678 "the refusal names the key it waits on: {reason}"
679 );
680 }
681 other => panic!("expected Reject, got {other:?}"),
682 }
683 }
684
685 /// An action that needs a key only it declares writing cannot complete
686 /// through its declared effects and ordering either.
687 #[tokio::test]
688 async fn rejects_an_action_waiting_on_its_own_declared_write() {
689 let gate = StaticVerificationGate::new(registry(&["step"]));
690 let proposal = proposal_of(json!([
691 { "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
692 "preconditions": [{ "key": "k", "operator": "exists" }],
693 "expected_effects": { "k": 1 } },
694 ]));
695 assert!(
696 matches!(
697 check(&gate, &proposal, &HashMap::new()).await,
698 GateOutcome::Reject { .. }
699 ),
700 "a self-dependency on an absent key is a cycle of one"
701 );
702 }
703
704 /// Each of these is the cycle above with one thing changed that lets the
705 /// plan start, so the gate must allow it and leave the rest to the
706 /// per-action validator.
707 #[tokio::test]
708 async fn allows_cycles_the_plan_can_break() {
709 let gate = StaticVerificationGate::new(registry(&["step"]));
710 let cycle = |a0: serde_json::Value| {
711 proposal_of(json!([
712 a0,
713 { "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
714 "state_dependencies": ["k2"], "expected_effects": { "k1": 1 } },
715 { "id": "a2", "type": "tool_call", "tool": "step", "parameters": {},
716 "state_dependencies": ["k1"], "expected_effects": { "k2": 2 } },
717 ]))
718 };
719 let plain = json!({ "id": "a0", "type": "state_write",
720 "parameters": { "key": "k0", "value": 0 } });
721
722 let live: HashMap<String, serde_json::Value> = [("k2".to_string(), json!(2))].into();
723 assert!(
724 matches!(check(&gate, &cycle(plain), &live).await, GateOutcome::Allow),
725 "k2 is already in live state, so a1 can run"
726 );
727
728 let state_write = json!({ "id": "a0", "type": "state_write",
729 "parameters": { "key": "k2", "value": 0 } });
730 assert!(
731 matches!(
732 check(&gate, &cycle(state_write), &HashMap::new()).await,
733 GateOutcome::Allow
734 ),
735 "a state_write of k2 is a declared writer outside the cycle"
736 );
737 }
738
739 /// The issue's `[a0 tool_call, a1, a2]` three ways. Which one the gate
740 /// refuses turns on whether a tool call finishes before a1 is validated.
741 #[tokio::test]
742 async fn a_tool_call_breaks_a_cycle_only_if_it_finishes_first() {
743 let gate = StaticVerificationGate::new(registry(&["step"]));
744 let shape = |failure: &str, mode: &str| {
745 proposal_of(json!([
746 { "id": "a0", "type": "tool_call", "tool": "step", "parameters": {},
747 "failure_behavior": failure, "invocation_mode": mode },
748 { "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
749 "failure_behavior": failure,
750 "state_dependencies": ["k2"], "expected_effects": { "k1": 1 } },
751 { "id": "a2", "type": "tool_call", "tool": "step", "parameters": {},
752 "failure_behavior": failure,
753 "state_dependencies": ["k1"], "expected_effects": { "k2": 2 } },
754 ]))
755 };
756
757 // Default `abort`: the level runs sequentially, a0's handler finishes
758 // before a1 is validated and may set k2 (`state.set`, FFI
759 // `state_set`), so the plan may complete.
760 assert!(
761 matches!(
762 check(&gate, &shape("abort", "one_shot"), &HashMap::new()).await,
763 GateOutcome::Allow
764 ),
765 "a sequential tool call before the cycle may break it"
766 );
767
768 // No `abort` in the level: a0 and a1 run concurrently, so a0 could set
769 // k2 in time only by winning a race. a2 comes after a0 and is freed;
770 // a1 is certain to be refused.
771 match check(&gate, &shape("skip", "one_shot"), &HashMap::new()).await {
772 GateOutcome::Reject { blocked, reason } => {
773 assert_eq!(blocked, ["a1".to_string()].into());
774 assert!(reason.contains("'k2'"), "{reason}");
775 }
776 other => panic!("expected Reject, got {other:?}"),
777 }
778
779 // A detached a0 returns a handle before its handler is done, so it is
780 // not guaranteed to have written anything when a1 is validated.
781 assert!(
782 matches!(
783 check(&gate, &shape("abort", "long_running"), &HashMap::new()).await,
784 GateOutcome::Reject { .. }
785 ),
786 "a detached tool call does not finish before the next action"
787 );
788 }
789
790 /// Only a precondition that fails when its key is absent makes an action
791 /// wait on that key: `not_exists` passes on an absent key.
792 #[tokio::test]
793 async fn not_exists_precondition_does_not_wait_on_its_key() {
794 let gate = StaticVerificationGate::new(registry(&["step"]));
795 let proposal = proposal_of(json!([
796 { "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
797 "preconditions": [{ "key": "k2", "operator": "not_exists" }],
798 "expected_effects": { "k1": 1 } },
799 { "id": "a2", "type": "tool_call", "tool": "step", "parameters": {},
800 "state_dependencies": ["k1"], "expected_effects": { "k2": 2 } },
801 ]));
802 assert!(
803 matches!(
804 check(&gate, &proposal, &HashMap::new()).await,
805 GateOutcome::Allow
806 ),
807 "a1 can run first, so this is a chain, not a cycle"
808 );
809 }
810
811 /// A key nothing declares writing, with no tool call before the action that
812 /// needs it, could be set only from outside the proposal: refused. With a
813 /// tool call finishing first, whose handler may set it, it stays advisory.
814 #[tokio::test]
815 async fn undeclared_key_is_refused_unless_a_tool_call_finishes_first() {
816 let gate = StaticVerificationGate::new(registry(&["step"]));
817 let waiting = json!({ "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
818 "state_dependencies": ["written_but_undeclared"] });
819
820 match check(
821 &gate,
822 &proposal_of(json!([waiting.clone()])),
823 &HashMap::new(),
824 )
825 .await
826 {
827 GateOutcome::Reject { blocked, reason } => {
828 assert_eq!(blocked, ["a1".to_string()].into());
829 assert!(reason.contains("no action declares writing it"), "{reason}");
830 }
831 other => panic!("expected Reject, got {other:?}"),
832 }
833
834 let tool_first = proposal_of(json!([
835 { "id": "a0", "type": "tool_call", "tool": "step", "parameters": {} },
836 waiting,
837 ]));
838 assert!(matches!(
839 check(&gate, &tool_first, &HashMap::new()).await,
840 GateOutcome::Allow
841 ));
842 }
843
844 /// The replan quality gate calls `blocking_errors` too, so the cycle refusal
845 /// reaches both verification points.
846 #[test]
847 fn blocking_errors_reports_the_cycle_for_the_replan_path() {
848 let proposal = proposal_of(json!([
849 { "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
850 "state_dependencies": ["k2"], "expected_effects": { "k1": 1 } },
851 { "id": "a2", "type": "tool_call", "tool": "step", "parameters": {},
852 "state_dependencies": ["k1"], "expected_effects": { "k2": 2 } },
853 ]));
854 let errors = blocking_errors(&proposal, Some(&HashMap::new()), None, &HashMap::new(), 100);
855 let ids: HashSet<&str> = errors.iter().map(|i| i.action_id.as_str()).collect();
856 assert_eq!(ids, ["a1", "a2"].into());
857 }
858
859 /// Under a tenant, a tool call's declared effect frees the key in both
860 /// namespaces, because its handler can write either (callback
861 /// `state_changes` land in the tenant's, a `state.set` in the shared one):
862 /// a detached tool call declaring `k` frees a later tenant `state_read` of
863 /// `k` and a later tool call needing the shared `k`. A tenant
864 /// `state_write` of `k` frees only the tenant's `k` (car#3121).
865 #[test]
866 fn tenant_keys_resolve_per_action_namespace() {
867 let tenant_errors = |actions: serde_json::Value| {
868 blocking_errors(
869 &proposal_of(actions),
870 Some(&HashMap::new()),
871 Some("acme"),
872 &HashMap::new(),
873 100,
874 )
875 .into_iter()
876 .map(|i| i.action_id)
877 .collect::<HashSet<String>>()
878 };
879 let detached_writer = json!({ "id": "t0", "type": "tool_call", "tool": "step",
880 "parameters": {}, "invocation_mode": "long_running", "expected_effects": { "k": 1 } });
881 let tenant_read = json!({ "id": "r1", "type": "state_read",
882 "parameters": { "key": "k" }, "state_dependencies": ["k"] });
883 let tool_need = json!({ "id": "t1", "type": "tool_call", "tool": "step",
884 "parameters": {}, "state_dependencies": ["k"] });
885 let tenant_write = json!({ "id": "w0", "type": "state_write",
886 "parameters": { "key": "k", "value": 1 } });
887
888 assert!(tenant_errors(json!([detached_writer, tenant_read])).is_empty());
889 assert!(tenant_errors(json!([detached_writer, tool_need])).is_empty());
890 assert!(tenant_errors(json!([tenant_write, tenant_read])).is_empty());
891 assert_eq!(
892 tenant_errors(json!([tenant_write, tool_need])),
893 ["t1".to_string()].into()
894 );
895 }
896
897 /// Warnings never block — a write conflict is reported at `warning`
898 /// severity and `valid` stays true, so the plan still runs.
899 #[tokio::test]
900 async fn warnings_do_not_block() {
901 let gate = StaticVerificationGate::new(registry(&["w"]));
902 let proposal = proposal_of(json!([
903 { "id": "a1", "type": "tool_call", "tool": "w", "parameters": {},
904 "expected_effects": { "k": 1 } },
905 { "id": "a2", "type": "tool_call", "tool": "w", "parameters": {},
906 "expected_effects": { "k": 2 } },
907 ]));
908 assert!(
909 matches!(
910 check(&gate, &proposal, &HashMap::new()).await,
911 GateOutcome::Allow
912 ),
913 "a write conflict is a warning, not a refusal"
914 );
915 }
916}