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 — 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}