use crate::admission::{AdmissionGate, GateContext, GateOutcome};
use car_ir::precondition::{check_precondition, StateView};
use car_ir::{ActionProposal, ActionType, ToolSchema};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::RwLock as TokioRwLock;
const DEFAULT_MAX_ACTIONS: usize = 100;
const ADVISORY_ISSUE_FRAGMENTS: &[&str] = &[
"repeated identical tool call",
"precondition will fail",
"not available at this point",
];
pub fn is_blocking_issue(issue: &car_verify::VerifyIssue) -> bool {
issue.severity == "error"
&& !ADVISORY_ISSUE_FRAGMENTS
.iter()
.any(|frag| issue.message.contains(frag))
}
pub fn blocking_errors(
proposal: &ActionProposal,
state: Option<&HashMap<String, serde_json::Value>>,
tenant: Option<&str>,
tools: &HashMap<String, ToolSchema>,
max_actions: usize,
) -> Vec<car_verify::VerifyIssue> {
let result = if tools.is_empty() {
car_verify::verify(proposal, state, None, max_actions)
} else {
car_verify::verify_with_schemas(proposal, state, Some(tools), max_actions)
};
let mut errors: Vec<car_verify::VerifyIssue> = if result.valid {
Vec::new()
} else {
result
.issues
.into_iter()
.filter(is_blocking_issue)
.collect()
};
if let Some(state) = state {
errors.extend(unmeetable_dependencies(proposal, state, tenant));
}
errors
}
struct NoKeys;
impl StateView for NoKeys {
fn get_value(&self, _key: &str) -> Option<serde_json::Value> {
None
}
fn key_exists(&self, _key: &str) -> bool {
false
}
}
fn unmeetable_dependencies(
proposal: &ActionProposal,
state: &HashMap<String, serde_json::Value>,
tenant: Option<&str>,
) -> Vec<car_verify::VerifyIssue> {
let actions = &proposal.actions;
let n = actions.len();
let mut rank: Vec<(usize, usize)> = vec![(0, 0); n];
for (level_no, level) in car_ir::build_dag(actions).iter().enumerate() {
let sequential = crate::executor::level_runs_sequentially(level, actions);
for (pos, &i) in level.iter().enumerate() {
rank[i] = (level_no, if sequential { pos } else { 0 });
}
}
let by_rank = |mut v: Vec<usize>| {
v.sort_by_key(|&i| rank[i]);
v
};
let store_key = |i: usize, key: &str| {
car_state::tenant_key(
crate::executor::action_state_tenant(&actions[i], tenant),
key,
)
};
let writes: Vec<Vec<String>> = actions
.iter()
.map(|action| crate::executor::action_write_store_keys(action, tenant))
.collect();
let mut writers: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, keys) in writes.iter().enumerate() {
for key in keys {
writers.entry(key.as_str()).or_default().push(i);
}
}
let needs: Vec<Vec<(String, &str)>> = actions
.iter()
.enumerate()
.map(|(i, action)| {
let mut keys: Vec<(String, &str)> = action
.state_dependencies
.iter()
.map(String::as_str)
.chain(
action
.preconditions
.iter()
.filter(|pre| check_precondition(pre, &NoKeys).is_some())
.map(|pre| pre.key.as_str()),
)
.map(|key| (store_key(i, key), key))
.filter(|(stored, _)| !state.contains_key(stored))
.collect();
keys.sort_unstable();
keys.dedup();
keys
})
.collect();
let mut needers: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, keys) in needs.iter().enumerate() {
for (key, _) in keys {
needers.entry(key.as_str()).or_default().push(i);
}
}
let mut needers: HashMap<&str, (Vec<usize>, usize)> = needers
.into_iter()
.map(|(key, v)| {
let v = by_rank(v);
let cut = v.len();
(key, (v, cut))
})
.collect();
let all_by_rank = by_rank((0..n).collect());
let mut tool_cut = n;
let can_write_undeclared = |j: usize| {
actions[j].action_type == ActionType::ToolCall && !actions[j].invocation_mode.is_detached()
};
let mut stuck = vec![true; n];
let mut unmet: Vec<usize> = needs.iter().map(Vec::len).collect();
let mut worklist: Vec<usize> = (0..n).filter(|&i| needs[i].is_empty()).collect();
for &i in &worklist {
stuck[i] = false;
}
while let Some(i) = worklist.pop() {
for key in &writes[i] {
let Some((order, cut)) = needers.get_mut(key.as_str()) else {
continue;
};
while *cut > 0 && rank[order[*cut - 1]] > rank[i] {
*cut -= 1;
let m = order[*cut];
unmet[m] -= 1;
if stuck[m] && unmet[m] == 0 {
stuck[m] = false;
worklist.push(m);
}
}
}
if can_write_undeclared(i) {
while tool_cut > 0 && rank[all_by_rank[tool_cut - 1]] > rank[i] {
tool_cut -= 1;
let m = all_by_rank[tool_cut];
if stuck[m] {
stuck[m] = false;
worklist.push(m);
}
}
}
}
(0..n)
.filter(|&i| stuck[i])
.filter_map(|i| {
let (stored, key) = needs[i].iter().find(|(stored, _)| {
needers
.get(stored.as_str())
.is_some_and(|(order, cut)| !order[*cut..].contains(&i))
})?;
let writers_note = writers
.get(stored.as_str())
.map(|ws| {
ws.iter()
.map(|&j| format!("'{}'", actions[j].id))
.collect::<Vec<_>>()
.join(", ")
})
.map(|ids| format!("its declared writers ({ids}) are themselves refused or run after it"))
.unwrap_or_else(|| "no action declares writing it".to_string());
Some(car_verify::VerifyIssue::new(
actions[i].id.clone(),
"error",
format!(
"unmeetable dependency: action '{}' needs '{key}', which is not in state; \
{writers_note}, and no tool call finishes before it to set '{key}', so the proposal \
cannot complete through its declared effects and ordering. Declare the \
write on an action that runs earlier, or set '{key}' before submitting",
actions[i].id
),
car_verify::EvidenceTier::DecisionProcedure,
))
})
.collect()
}
pub struct StaticVerificationGate {
tools: Arc<TokioRwLock<HashMap<String, ToolSchema>>>,
max_actions: usize,
}
impl StaticVerificationGate {
pub fn new(tools: Arc<TokioRwLock<HashMap<String, ToolSchema>>>) -> Self {
Self {
tools,
max_actions: DEFAULT_MAX_ACTIONS,
}
}
pub fn with_max_actions(mut self, max_actions: usize) -> Self {
self.max_actions = max_actions;
self
}
}
#[async_trait::async_trait]
impl AdmissionGate for StaticVerificationGate {
fn name(&self) -> &str {
"static_verification"
}
async fn check(&self, proposal: &ActionProposal, ctx: &GateContext<'_>) -> GateOutcome {
let errors = {
let tools = self.tools.read().await;
let tenant = ctx.scope.and_then(|s| s.tenant_id.as_deref());
blocking_errors(proposal, Some(ctx.state), tenant, &tools, self.max_actions)
};
if errors.is_empty() {
return GateOutcome::Allow;
}
let real_ids: HashSet<&str> = proposal.actions.iter().map(|a| a.id.as_str()).collect();
let blocked: HashSet<String> = errors
.iter()
.map(|i| i.action_id.clone())
.filter(|id| real_ids.contains(id.as_str()))
.collect();
let reason = format!(
"static verification failed: {}",
errors
.iter()
.map(|i| i.message.as_str())
.collect::<Vec<_>>()
.join("; ")
);
GateOutcome::Reject { blocked, reason }
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn schema(name: &str) -> ToolSchema {
serde_json::from_value(json!({ "name": name })).expect("tool schema fixture")
}
fn registry(names: &[&str]) -> Arc<TokioRwLock<HashMap<String, ToolSchema>>> {
let map = names
.iter()
.map(|n| (n.to_string(), schema(n)))
.collect::<HashMap<_, _>>();
Arc::new(TokioRwLock::new(map))
}
fn proposal_of(actions: serde_json::Value) -> ActionProposal {
serde_json::from_value(json!({
"id": "p1", "source": "test", "actions": actions,
}))
.expect("proposal fixture")
}
fn one(action_id: &str, tool: &str) -> ActionProposal {
proposal_of(json!([{
"id": action_id, "type": "tool_call", "tool": tool, "parameters": {},
}]))
}
async fn check(
gate: &StaticVerificationGate,
proposal: &ActionProposal,
state: &HashMap<String, serde_json::Value>,
) -> GateOutcome {
let versions = HashMap::new();
let ctx = GateContext {
session_id: None,
scope: None,
state,
versions: &versions,
};
gate.check(proposal, &ctx).await
}
#[tokio::test]
async fn rejects_before_any_action_when_a_later_tool_is_unregistered() {
let gate = StaticVerificationGate::new(registry(&["echo"]));
let proposal = proposal_of(json!([
{ "id": "a1", "type": "tool_call", "tool": "echo", "parameters": {} },
{ "id": "a2", "type": "tool_call", "tool": "ghost", "parameters": {} },
]));
match check(&gate, &proposal, &HashMap::new()).await {
GateOutcome::Reject { blocked, reason } => {
assert!(blocked.contains("a2"), "the offending action must be named");
assert!(
!blocked.contains("a1"),
"the valid action is collateral, not the cause"
);
assert!(
reason.contains("static verification failed"),
"got: {reason}"
);
}
other => panic!("expected Reject, got {other:?}"),
}
}
#[tokio::test]
async fn rejects_parameter_schema_violation() {
let strict: ToolSchema = serde_json::from_value(json!({
"name": "write",
"parameters": {
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"],
},
}))
.expect("schema");
let reg = registry(&[]);
reg.write().await.insert("write".to_string(), strict);
let gate = StaticVerificationGate::new(reg);
match check(&gate, &one("a1", "write"), &HashMap::new()).await {
GateOutcome::Reject { reason, .. } => {
assert!(
reason.contains("path"),
"reason should name the field: {reason}"
)
}
other => panic!("expected Reject for a missing required param, got {other:?}"),
}
}
#[tokio::test]
async fn allows_a_valid_proposal() {
let gate = StaticVerificationGate::new(registry(&["echo"]));
assert!(
matches!(
check(&gate, &one("a1", "echo"), &HashMap::new()).await,
GateOutcome::Allow
),
"a valid proposal must not be blocked"
);
}
#[tokio::test]
async fn empty_registry_does_not_block_every_proposal() {
let gate = StaticVerificationGate::new(registry(&[]));
assert!(
matches!(
check(&gate, &one("a1", "anything_at_all"), &HashMap::new()).await,
GateOutcome::Allow
),
"an empty tool registry means 'undeclared', not 'nothing exists'"
);
}
#[tokio::test]
async fn sees_tools_registered_after_construction() {
let reg = registry(&[]);
let gate = StaticVerificationGate::new(reg.clone());
reg.write().await.insert("echo".to_string(), schema("echo"));
assert!(
matches!(
check(&gate, &one("a1", "ghost"), &HashMap::new()).await,
GateOutcome::Reject { .. }
),
"the registry is now non-empty, so an unknown tool must be caught"
);
}
#[tokio::test]
async fn advisory_findings_do_not_block() {
let gate = StaticVerificationGate::new(registry(&["poll"]));
let proposal = proposal_of(json!([
{ "id": "a1", "type": "tool_call", "tool": "poll", "parameters": {} },
{ "id": "a2", "type": "tool_call", "tool": "poll", "parameters": {} },
{ "id": "a3", "type": "tool_call", "tool": "poll", "parameters": {},
"state_dependencies": ["written_but_undeclared"] },
{ "id": "a4", "type": "tool_call", "tool": "poll", "parameters": {},
"preconditions": [{ "key": "missing", "op": "exists" }] },
]));
let schemas: HashMap<String, ToolSchema> =
[("poll".to_string(), schema("poll"))].into_iter().collect();
let raw = car_verify::verify_with_schemas(&proposal, None, Some(&schemas), 100);
for frag in ADVISORY_ISSUE_FRAGMENTS {
assert!(
raw.errors().iter().any(|i| i.message.contains(frag)),
"car-verify no longer reports an error containing {frag:?} — update \
ADVISORY_ISSUE_FRAGMENTS, or the gate will start blocking on it"
);
}
assert!(
matches!(
check(&gate, &proposal, &HashMap::new()).await,
GateOutcome::Allow
),
"state-dependent and heuristic findings must not reject a proposal"
);
}
#[tokio::test]
async fn rejects_a_pure_declared_effect_cycle() {
let gate = StaticVerificationGate::new(registry(&["step"]));
let proposal = proposal_of(json!([
{ "id": "a0", "type": "state_write", "parameters": { "key": "k0", "value": 0 } },
{ "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
"state_dependencies": ["k2"], "expected_effects": { "k1": 1 } },
{ "id": "a2", "type": "tool_call", "tool": "step", "parameters": {},
"state_dependencies": ["k1"], "expected_effects": { "k2": 2 } },
]));
match check(&gate, &proposal, &HashMap::new()).await {
GateOutcome::Reject { blocked, reason } => {
let expected: HashSet<String> = ["a1", "a2"].map(String::from).into();
assert_eq!(blocked, expected);
assert!(
reason.contains("cannot complete") && reason.contains("'k2'"),
"the refusal names the key it waits on: {reason}"
);
}
other => panic!("expected Reject, got {other:?}"),
}
}
#[tokio::test]
async fn rejects_an_action_waiting_on_its_own_declared_write() {
let gate = StaticVerificationGate::new(registry(&["step"]));
let proposal = proposal_of(json!([
{ "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
"preconditions": [{ "key": "k", "operator": "exists" }],
"expected_effects": { "k": 1 } },
]));
assert!(
matches!(
check(&gate, &proposal, &HashMap::new()).await,
GateOutcome::Reject { .. }
),
"a self-dependency on an absent key is a cycle of one"
);
}
#[tokio::test]
async fn allows_cycles_the_plan_can_break() {
let gate = StaticVerificationGate::new(registry(&["step"]));
let cycle = |a0: serde_json::Value| {
proposal_of(json!([
a0,
{ "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
"state_dependencies": ["k2"], "expected_effects": { "k1": 1 } },
{ "id": "a2", "type": "tool_call", "tool": "step", "parameters": {},
"state_dependencies": ["k1"], "expected_effects": { "k2": 2 } },
]))
};
let plain = json!({ "id": "a0", "type": "state_write",
"parameters": { "key": "k0", "value": 0 } });
let live: HashMap<String, serde_json::Value> = [("k2".to_string(), json!(2))].into();
assert!(
matches!(check(&gate, &cycle(plain), &live).await, GateOutcome::Allow),
"k2 is already in live state, so a1 can run"
);
let state_write = json!({ "id": "a0", "type": "state_write",
"parameters": { "key": "k2", "value": 0 } });
assert!(
matches!(
check(&gate, &cycle(state_write), &HashMap::new()).await,
GateOutcome::Allow
),
"a state_write of k2 is a declared writer outside the cycle"
);
}
#[tokio::test]
async fn a_tool_call_breaks_a_cycle_only_if_it_finishes_first() {
let gate = StaticVerificationGate::new(registry(&["step"]));
let shape = |failure: &str, mode: &str| {
proposal_of(json!([
{ "id": "a0", "type": "tool_call", "tool": "step", "parameters": {},
"failure_behavior": failure, "invocation_mode": mode },
{ "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
"failure_behavior": failure,
"state_dependencies": ["k2"], "expected_effects": { "k1": 1 } },
{ "id": "a2", "type": "tool_call", "tool": "step", "parameters": {},
"failure_behavior": failure,
"state_dependencies": ["k1"], "expected_effects": { "k2": 2 } },
]))
};
assert!(
matches!(
check(&gate, &shape("abort", "one_shot"), &HashMap::new()).await,
GateOutcome::Allow
),
"a sequential tool call before the cycle may break it"
);
match check(&gate, &shape("skip", "one_shot"), &HashMap::new()).await {
GateOutcome::Reject { blocked, reason } => {
assert_eq!(blocked, ["a1".to_string()].into());
assert!(reason.contains("'k2'"), "{reason}");
}
other => panic!("expected Reject, got {other:?}"),
}
assert!(
matches!(
check(&gate, &shape("abort", "long_running"), &HashMap::new()).await,
GateOutcome::Reject { .. }
),
"a detached tool call does not finish before the next action"
);
}
#[tokio::test]
async fn not_exists_precondition_does_not_wait_on_its_key() {
let gate = StaticVerificationGate::new(registry(&["step"]));
let proposal = proposal_of(json!([
{ "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
"preconditions": [{ "key": "k2", "operator": "not_exists" }],
"expected_effects": { "k1": 1 } },
{ "id": "a2", "type": "tool_call", "tool": "step", "parameters": {},
"state_dependencies": ["k1"], "expected_effects": { "k2": 2 } },
]));
assert!(
matches!(
check(&gate, &proposal, &HashMap::new()).await,
GateOutcome::Allow
),
"a1 can run first, so this is a chain, not a cycle"
);
}
#[tokio::test]
async fn undeclared_key_is_refused_unless_a_tool_call_finishes_first() {
let gate = StaticVerificationGate::new(registry(&["step"]));
let waiting = json!({ "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
"state_dependencies": ["written_but_undeclared"] });
match check(
&gate,
&proposal_of(json!([waiting.clone()])),
&HashMap::new(),
)
.await
{
GateOutcome::Reject { blocked, reason } => {
assert_eq!(blocked, ["a1".to_string()].into());
assert!(reason.contains("no action declares writing it"), "{reason}");
}
other => panic!("expected Reject, got {other:?}"),
}
let tool_first = proposal_of(json!([
{ "id": "a0", "type": "tool_call", "tool": "step", "parameters": {} },
waiting,
]));
assert!(matches!(
check(&gate, &tool_first, &HashMap::new()).await,
GateOutcome::Allow
));
}
#[test]
fn blocking_errors_reports_the_cycle_for_the_replan_path() {
let proposal = proposal_of(json!([
{ "id": "a1", "type": "tool_call", "tool": "step", "parameters": {},
"state_dependencies": ["k2"], "expected_effects": { "k1": 1 } },
{ "id": "a2", "type": "tool_call", "tool": "step", "parameters": {},
"state_dependencies": ["k1"], "expected_effects": { "k2": 2 } },
]));
let errors = blocking_errors(&proposal, Some(&HashMap::new()), None, &HashMap::new(), 100);
let ids: HashSet<&str> = errors.iter().map(|i| i.action_id.as_str()).collect();
assert_eq!(ids, ["a1", "a2"].into());
}
#[test]
fn tenant_keys_resolve_per_action_namespace() {
let tenant_errors = |actions: serde_json::Value| {
blocking_errors(
&proposal_of(actions),
Some(&HashMap::new()),
Some("acme"),
&HashMap::new(),
100,
)
.into_iter()
.map(|i| i.action_id)
.collect::<HashSet<String>>()
};
let detached_writer = json!({ "id": "t0", "type": "tool_call", "tool": "step",
"parameters": {}, "invocation_mode": "long_running", "expected_effects": { "k": 1 } });
let tenant_read = json!({ "id": "r1", "type": "state_read",
"parameters": { "key": "k" }, "state_dependencies": ["k"] });
let tool_need = json!({ "id": "t1", "type": "tool_call", "tool": "step",
"parameters": {}, "state_dependencies": ["k"] });
let tenant_write = json!({ "id": "w0", "type": "state_write",
"parameters": { "key": "k", "value": 1 } });
assert!(tenant_errors(json!([detached_writer, tenant_read])).is_empty());
assert!(tenant_errors(json!([detached_writer, tool_need])).is_empty());
assert!(tenant_errors(json!([tenant_write, tenant_read])).is_empty());
assert_eq!(
tenant_errors(json!([tenant_write, tool_need])),
["t1".to_string()].into()
);
}
#[tokio::test]
async fn warnings_do_not_block() {
let gate = StaticVerificationGate::new(registry(&["w"]));
let proposal = proposal_of(json!([
{ "id": "a1", "type": "tool_call", "tool": "w", "parameters": {},
"expected_effects": { "k": 1 } },
{ "id": "a2", "type": "tool_call", "tool": "w", "parameters": {},
"expected_effects": { "k": 2 } },
]));
assert!(
matches!(
check(&gate, &proposal, &HashMap::new()).await,
GateOutcome::Allow
),
"a write conflict is a warning, not a refusal"
);
}
}