Skip to main content

car_verify/
lib.rs

1//! Static plan verification for Agent IR.
2//!
3//! Deterministic graph and dataflow algorithms — no solver, no search, no proof
4//! term. The checks differ in strength, and conflating them is how a green
5//! verdict gets over-trusted:
6//!
7//! - **Decision procedures** over their fragment: STRIPS applicability
8//!   ([`plan_check`]), workflow precedence ([`workflow_graph`]), and
9//!   Denning-style lattice information flow ([`infoflow`]).
10//! - **Deliberate heuristics**: loop detection fires on three identical calls,
11//!   so it has both false positives (a legitimate 3× poll) and false negatives
12//!   (semantically redundant calls with differing arguments).
13//! - **Sampling**: [`equivalent`] probes the supplied test states — two trivial
14//!   defaults if you pass none — and [`montecarlo::simulate_monte_carlo`]
15//!   samples rollouts. Neither decides anything.
16//!
17//! Not all of it is static, either: [`trace_policy`] is runtime verification
18//! over an execution trace (bounded LTL), and [`cwm`] scores recorded
19//! trajectories with a model in the repair loop.
20//!
21//! **Known blind spots.** The forward walk applies only what each action
22//! *declares* in `expected_effects`. So it is optimistic in one direction — a
23//! declared effect is assumed to land, though the tool may fail at runtime — and
24//! pessimistic in the other: a precondition or `state_dependency` reading a key
25//! that an upstream tool really writes but never declared is reported as
26//! unavailable, at `error` severity. [`StaticState::unknown_keys`] exists to
27//! model "a tool wrote this, value unknown", but **nothing in the workspace ever
28//! populates it**, so `is_unknown()` is always false and provides no relief.
29//! Undeclared effects are the practical false-rejection source here.
30//!
31//! Write conflicts are reported as warnings, not errors: `valid` stays `true`.
32//! And `dependency_edges` tracks writers last-writer-wins, so with two writers of
33//! one key an intervening reader can be scheduled into the same execution level
34//! as its writer — which the executor runs concurrently — while this crate
35//! reports only a warning.
36//!
37//! [`VerificationEvidence`] on every result names what each check did and did
38//! not establish. Read it rather than trusting `valid` alone. The taxonomy
39//! above is also carried *in the data*: every [`VerifyIssue`] and
40//! [`CheckRecord`] tags itself with an [`EvidenceTier`], so a caller can tell a
41//! decision procedure's finding from a heuristic's without knowing which
42//! function produced it. Single-tier modules expose the same thing as an
43//! `evidence_tier()` on their report type.
44//!
45//! Given a state S and proposal P, you can:
46//! 1. **verify**: Check P is satisfiable in S without executing
47//! 2. **simulate**: Compute expected final state S' without tools
48//! 3. **simulate_monte_carlo**: Sample N rollouts with tools allowed to fail,
49//!    giving P(goal), a distribution over S', and per-action blast radius —
50//!    see [`montecarlo`]
51//! 4. **equivalent**: Sample whether two proposals produce identical state
52//! 5. **optimize**: Reorder actions safely, per the checks above
53
54use car_ir::precondition::{self, StateView};
55use car_ir::{build_dag, Action, ActionProposal, ActionType, ToolSchema};
56use serde_json::Value;
57use std::collections::{HashMap, HashSet};
58
59pub mod admission;
60pub mod attempt;
61pub mod concurrency;
62pub mod cwm;
63pub mod dag;
64pub mod eval_boundary;
65pub mod goal;
66pub mod infoflow;
67pub mod intent;
68pub mod montecarlo;
69pub mod plan_check;
70pub mod trace_policy;
71pub mod transaction;
72pub mod verifier;
73pub use admission::{
74    admit_state, AdmissionRefusal, CommitAuthority, OwnershipTable, SelfCommit, StateAdmission,
75    StateCandidate, StateSurface, SurfaceRule,
76};
77pub use attempt::{Attempt, AttemptAdvice, AttemptLedger, AttemptOutcome, Exclusion, FailureClass};
78pub use goal::{
79    anchor_directive, evaluate_goal, governor_check, run_goal_loop, GoalCondition, GoalGovernor,
80    GoalHalt, GoalInputs, GoalRun, GoalRunState, GoalSpec, GoalStatus, GoalVerdict,
81    IterationOutcome,
82};
83pub use intent::{
84    check_intent, gate_intent, intent_actions_from, IntentAction, IntentDisposition,
85    IntentGateDecision, IntentGatePolicy, IntentReport, IntentSpec, IntentViolation,
86    IntentViolationKind,
87};
88pub use montecarlo::{
89    simulate_monte_carlo, ActionOutcome, Distribution, KeyOutcome, MonteCarloConfig,
90    MonteCarloResult, ValueFrequency,
91};
92pub use plan_check::{
93    check_plan, PlanCheckReport, PlanCheckRequest, PlanDefect, PlanDefectKind, PlanStep,
94};
95pub use verifier::{
96    admit, required_classes, AdmissionDecision, AdmissionOutcome, EvidenceRequirement, UnmetReason,
97    UnmetRequirement, VerifierAuthority, VerifierCost, VerifierDescriptor, VerifierOutcome,
98    VerifierVerdict,
99};
100pub mod workflow_graph;
101pub use concurrency::{
102    analyze as analyze_concurrency, gate_concurrency, AgentOp, AnomalyFinding, ConcurrencyAnomaly,
103    ConcurrencyGate, ConcurrencyGatePolicy, ConcurrencyReport, ConsistencyLevel, Disposition,
104    GatedRemediation, Remediation,
105};
106pub use cwm::{
107    score, score_predictions, simulate_with_model, synthesize_cwm, CwmRequest, CwmResult,
108    EffectModel, Failure, GatedEffectModel, GatedPrediction, ScoreReport, Transition,
109};
110pub use eval_boundary::{
111    check_eval_optimize_boundary, BoundaryReport, BoundaryRole, BoundaryRoles, CAP_EVALUATOR,
112    CAP_OPTIMIZER, CAP_REDACTOR,
113};
114pub use infoflow::{
115    check_information_flow, gate_flow, Confidentiality, FlowAction, FlowGateDecision,
116    FlowGatePolicy, FlowPolicy, FlowReport, FlowViolation, FlowViolationKind, ToolLabels,
117    TrustLevel,
118};
119pub use transaction::{
120    check_transaction, check_transaction_ordered_by, check_transaction_with_predictions,
121    ConflictKind, TransactionConflict, TransactionReport,
122};
123pub use workflow_graph::{
124    check_temporal_policies, verify_workflow_graph, PolicyReport, PolicyViolation, TemporalPolicy,
125    WorkflowDefect, WorkflowDefectKind, WorkflowEdge, WorkflowGraph, WorkflowVerifyReport,
126};
127
128/// Symbolic state for static analysis.
129#[derive(Debug, Clone)]
130pub struct StaticState {
131    pub known: HashMap<String, Value>,
132    pub unknown_keys: HashSet<String>,
133}
134
135impl StaticState {
136    pub fn new() -> Self {
137        Self {
138            known: HashMap::new(),
139            unknown_keys: HashSet::new(),
140        }
141    }
142
143    pub fn from_map(map: HashMap<String, Value>) -> Self {
144        Self {
145            known: map,
146            unknown_keys: HashSet::new(),
147        }
148    }
149
150    pub fn get(&self, key: &str) -> Option<&Value> {
151        self.known.get(key)
152    }
153
154    pub fn exists(&self, key: &str) -> bool {
155        self.known.contains_key(key)
156    }
157
158    pub fn is_unknown(&self, key: &str) -> bool {
159        self.unknown_keys.contains(key)
160    }
161
162    pub fn set(&mut self, key: &str, value: Value) {
163        self.known.insert(key.to_string(), value);
164        self.unknown_keys.remove(key);
165    }
166}
167
168impl Default for StaticState {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl StateView for StaticState {
175    fn get_value(&self, key: &str) -> Option<Value> {
176        self.known.get(key).cloned()
177    }
178    fn key_exists(&self, key: &str) -> bool {
179        self.known.contains_key(key)
180    }
181    fn is_unknown(&self, key: &str) -> bool {
182        self.unknown_keys.contains(key)
183    }
184}
185
186/// What *kind* of check produced a finding — the crate's existing taxonomy
187/// (decision procedures / heuristics / sampling), carried in the data instead
188/// of only in the module docs.
189///
190/// The gap this closes is narrow and worth stating exactly: the taxonomy at the
191/// top of this file has always been accurate, but a caller holding a
192/// [`VerifyIssue`] could not tell which branch of it produced that issue
193/// without recognising the message string. Two findings that read identically
194/// in a log — one from an exact set-membership test, one from a `count >= 3`
195/// rule of thumb — now differ in the data.
196///
197/// # What this axis is not
198///
199/// This tier classifies a check's relationship to **the property it reports**,
200/// over **the inputs it was handed**. It is deliberately orthogonal to a second
201/// question: whether those inputs describe what will actually happen at
202/// runtime. That question is answered elsewhere — [`CheckRecord::cannot_verify`],
203/// [`VerificationEvidence::assumptions`], [`VerificationEvidence::untested_regions`],
204/// and the "Known blind spots" section of the module docs. Folding the two into
205/// one ordering would be the same mistake as grading "who may authorize this"
206/// on the same ladder as "can this be undone": correlated, distinct, and
207/// misleading once collapsed.
208///
209/// So [`EvidenceTier::DecisionProcedure`] is **not** a proof, a soundness
210/// claim, or a prediction that the plan will work. This crate depends on
211/// `car-ir` and serde; there is no solver in it and nothing here proves
212/// anything. The forward model applies only what an action *declares*, so an
213/// exactly-decided finding can still be about a world the tools then
214/// contradict. The tier's entire job is to stop three unlike kinds of check
215/// from reading alike.
216///
217/// # No ordering, on purpose
218///
219/// This enum deliberately derives neither `PartialOrd` nor `Ord`. The three
220/// variants are *kinds*, not grades: `Heuristic` and `Sampled` have no
221/// defensible strength ranking against each other — a proxy signal over
222/// complete inputs and an exact measurement over incomplete inputs fail in
223/// different directions, and which is worse depends entirely on the question
224/// being asked. An `Ord` derive would encode declaration order as if it meant
225/// something, and would invite exactly the filter [`VerifyResult::issues_with_tier`]
226/// warns against (`tier >= EvidenceTier::Heuristic`, i.e. "discard the
227/// findings I trust least"), which discards the crate's only signal for the
228/// things no decision procedure here covers. Compare by equality; if you need
229/// per-tier handling, match exhaustively.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum EvidenceTier {
233    /// The check decides the property it reports, exactly, within a declared
234    /// fragment: total, deterministic, and free of false positives and false
235    /// negatives *with respect to its inputs*. Set membership, graph
236    /// reachability, and the STRIPS-style forward walk are all of this kind.
237    ///
238    /// The fragment is the point. "This precondition is unsatisfied in the
239    /// forward model" is decided; "this precondition will fail at runtime" is
240    /// not, and the accompanying `cannot_verify` says which one you are
241    /// holding.
242    DecisionProcedure,
243    /// The check reports a property it does **not** decide, using a proxy
244    /// signal chosen because it is useful in practice. False positives and
245    /// false negatives are expected *on the check's own inputs*, not merely on
246    /// the gap between the model and runtime.
247    ///
248    /// Loop detection is the crate's example: the repeat count is exact, but
249    /// the step from "three identical calls" to "this is a runaway loop" is
250    /// the proxy — a legitimate 3× poll trips it, and three semantically
251    /// redundant calls with differing arguments slip past.
252    Heuristic,
253    /// The check examines a subset of a space and reports what it found there.
254    /// A finding is a witness from the sample; the *absence* of a finding is
255    /// evidence about the sample only, and generalises no further.
256    ///
257    /// [`equivalent`] probes the supplied test states (two trivial defaults if
258    /// you pass none), [`montecarlo::simulate_monte_carlo`] samples rollouts,
259    /// and [`cwm::score`] measures accuracy over the transitions it was given.
260    Sampled,
261}
262
263impl EvidenceTier {
264    /// Stable lowercase label, matching the serde representation. Handy for
265    /// log lines and for the FFI/JSON-RPC surfaces, which carry the tier as a
266    /// string so they need no dependency on this crate.
267    ///
268    /// Matched exhaustively on purpose (project convention #2): a new tier
269    /// must fail to compile here rather than silently acquire a label.
270    pub const fn as_str(&self) -> &'static str {
271        match self {
272            EvidenceTier::DecisionProcedure => "decision_procedure",
273            EvidenceTier::Heuristic => "heuristic",
274            EvidenceTier::Sampled => "sampled",
275        }
276    }
277}
278
279/// A single verification finding.
280#[derive(Debug, Clone, serde::Serialize)]
281#[non_exhaustive]
282pub struct VerifyIssue {
283    pub action_id: String,
284    pub severity: String, // "error", "warning", "info"
285    pub message: String,
286    /// Which kind of check produced this finding — see [`EvidenceTier`].
287    ///
288    /// Orthogonal to `severity`: severity says how bad the situation would be
289    /// if the finding is right, the tier says how the finding was arrived at.
290    /// An `error` from a heuristic and an `error` from a decision procedure are
291    /// equally loud and not equally trustworthy.
292    ///
293    /// It is also *not* the same axis as "does this block execution".
294    /// `car_engine::is_blocking_issue` blocks on state-independence, and two of
295    /// the three findings it treats as advisory (`precondition will fail`,
296    /// `not available at this point`) are `DecisionProcedure` findings —
297    /// exactly decided over a forward model that only sees declared effects.
298    /// Do not rewire that gate onto this field.
299    pub tier: EvidenceTier,
300}
301
302impl VerifyIssue {
303    /// Build a finding outside this crate. The struct is `#[non_exhaustive]`, so
304    /// a caller that derives its own finding from a proposal (the engine's
305    /// admission gate does, for dependency cycles) needs a constructor.
306    pub fn new(
307        action_id: impl Into<String>,
308        severity: impl Into<String>,
309        message: impl Into<String>,
310        tier: EvidenceTier,
311    ) -> Self {
312        Self {
313            action_id: action_id.into(),
314            severity: severity.into(),
315            message: message.into(),
316            tier,
317        }
318    }
319}
320
321/// Scope record for one verification check.
322///
323/// Survey "Code as Agent Harness" §5.2.2 argues a green check creates a
324/// false sense of correctness unless the verifier declares *what it
325/// verifies, what it cannot verify, and what confidence it provides*. A
326/// `CheckRecord` makes that scope explicit per check so downstream
327/// consumers (self-repair, harness evolution, human review) can reason
328/// about *why* a proposal is `valid`, not merely that it is.
329#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
330#[non_exhaustive]
331pub struct CheckRecord {
332    /// Stable identifier, e.g. `"preconditions"`, `"tool_existence"`.
333    pub name: String,
334    /// Whether this check actually ran. Some checks are conditional —
335    /// parameter-schema validation only runs when tool schemas are
336    /// supplied; when skipped, `ran=false` and `cannot_verify` names the
337    /// resulting blind spot.
338    pub ran: bool,
339    /// What a pass of this check establishes.
340    pub verifies: String,
341    /// The scope boundary: what a pass does *not* establish. The core
342    /// anti-overconfidence signal.
343    pub cannot_verify: String,
344    /// Number of issues this check contributed to `issues`.
345    pub findings: usize,
346    /// What kind of check this is — see [`EvidenceTier`]. Every
347    /// [`VerifyIssue`] this check contributed carries the same tier, so a
348    /// consumer can read the strength of a whole check without walking its
349    /// findings.
350    pub tier: EvidenceTier,
351}
352
353/// Evidence bundle accompanying a verification result.
354///
355/// Makes the verifier's scope inspectable so a `valid` verdict is not
356/// mistaken for a full-specification guarantee (survey §5.2.2: "every
357/// accepted action \[should\] carry an evidence bundle containing the
358/// checks run, the assumptions preserved, the untested regions, and the
359/// remaining risks"). Static verification is sound only within its
360/// declared scope; this bundle is that declaration.
361#[derive(Debug, Clone, serde::Serialize)]
362pub struct VerificationEvidence {
363    /// Per-check scope records.
364    pub checks: Vec<CheckRecord>,
365    /// Assumptions the verdict relies on (e.g. registered tools behave
366    /// per their schema; supplied state values are accurate).
367    pub assumptions: Vec<String>,
368    /// State keys / aspects static verification could not evaluate —
369    /// unknown or dynamic keys, and runtime-only tool outputs.
370    pub untested_regions: Vec<String>,
371    /// Risks that persist even when `valid` is true — downgraded
372    /// warnings, undeclared write conflicts, dynamically-resolved
373    /// preconditions.
374    pub residual_risks: Vec<String>,
375    /// Heuristic 0.0–1.0 coverage confidence: how completely the
376    /// applicable checks covered this proposal. 1.0 means every
377    /// applicable check ran against fully-known state with no warnings;
378    /// reduced by skipped checks, unknown/dynamic state, and warnings.
379    /// This is a coverage signal, not a probability of success.
380    pub confidence: f64,
381}
382
383/// Complete verification result.
384#[derive(Debug, serde::Serialize)]
385pub struct VerifyResult {
386    pub valid: bool,
387    pub issues: Vec<VerifyIssue>,
388    pub simulated_state: HashMap<String, Value>,
389    pub execution_levels: Vec<Vec<String>>,
390    pub conflicts: Vec<(String, String, String)>, // (action1, action2, key)
391    /// Inspectable scope of this verdict (survey §5.2.2). See
392    /// [`VerificationEvidence`].
393    pub evidence: VerificationEvidence,
394}
395
396impl VerifyResult {
397    pub fn errors(&self) -> Vec<&VerifyIssue> {
398        self.issues
399            .iter()
400            .filter(|i| i.severity == "error")
401            .collect()
402    }
403
404    pub fn warnings(&self) -> Vec<&VerifyIssue> {
405        self.issues
406            .iter()
407            .filter(|i| i.severity == "warning")
408            .collect()
409    }
410
411    /// Findings produced by one kind of check — see [`EvidenceTier`].
412    ///
413    /// The intended use is triage, not filtering for correctness: "show me the
414    /// heuristic findings separately so a reviewer can eyeball them" is a good
415    /// reason to call this; "drop everything that isn't a decision procedure"
416    /// is not, since a heuristic finding is the crate's only signal for the
417    /// things no decision procedure here covers.
418    pub fn issues_with_tier(&self, tier: EvidenceTier) -> Vec<&VerifyIssue> {
419        self.issues.iter().filter(|i| i.tier == tier).collect()
420    }
421}
422
423// --- Action effects (symbolic) ---
424
425pub(crate) fn apply_action_effects(action: &Action, state: &mut StaticState) {
426    if action.action_type == ActionType::StateWrite {
427        if let Some(key) = action.parameters.get("key").and_then(|v| v.as_str()) {
428            let value = action
429                .parameters
430                .get("value")
431                .cloned()
432                .unwrap_or(Value::Null);
433            state.set(key, value);
434        }
435    }
436    for (key, value) in &action.expected_effects {
437        state.set(key, value.clone());
438    }
439}
440
441// --- Conflict detection ---
442
443fn detect_conflicts(actions: &[Action]) -> Vec<(String, String, String)> {
444    let mut writers: HashMap<String, Vec<String>> = HashMap::new();
445
446    for action in actions {
447        let mut keys_written = HashSet::new();
448        if action.action_type == ActionType::StateWrite {
449            if let Some(k) = action.parameters.get("key").and_then(|v| v.as_str()) {
450                keys_written.insert(k.to_string());
451            }
452        }
453        for key in action.expected_effects.keys() {
454            keys_written.insert(key.clone());
455        }
456        for key in keys_written {
457            writers.entry(key).or_default().push(action.id.clone());
458        }
459    }
460
461    let dep_map: HashMap<String, HashSet<String>> = actions
462        .iter()
463        .map(|a| (a.id.clone(), a.state_dependencies.iter().cloned().collect()))
464        .collect();
465
466    let mut conflicts = Vec::new();
467    for (key, action_ids) in &writers {
468        if action_ids.len() < 2 {
469            continue;
470        }
471        for i in 0..action_ids.len() {
472            for j in (i + 1)..action_ids.len() {
473                let a1 = &action_ids[i];
474                let a2 = &action_ids[j];
475                let deps_a2 = dep_map.get(a2).cloned().unwrap_or_default();
476                let deps_a1 = dep_map.get(a1).cloned().unwrap_or_default();
477                if !deps_a2.contains(key) && !deps_a1.contains(key) {
478                    conflicts.push((a1.clone(), a2.clone(), key.clone()));
479                }
480            }
481        }
482    }
483    conflicts
484}
485
486// --- Tool-parameter schema validation ---
487
488/// Friendly JSON type name for error messages.
489fn json_type_name(v: &Value) -> &'static str {
490    match v {
491        Value::Null => "null",
492        Value::Bool(_) => "boolean",
493        Value::Number(_) => "number",
494        Value::String(_) => "string",
495        Value::Array(_) => "array",
496        Value::Object(_) => "object",
497    }
498}
499
500/// Does `v` satisfy a single JSON Schema `type` keyword?
501fn value_matches_type(v: &Value, expected: &str) -> bool {
502    match expected {
503        "string" => v.is_string(),
504        "number" => v.is_number(),
505        // JSON Schema "integer": an integral number. Accept i64/u64,
506        // plus a float with no fractional part (e.g. `5.0`).
507        "integer" => {
508            v.is_i64() || v.is_u64() || v.as_f64().map(|f| f.fract() == 0.0).unwrap_or(false)
509        }
510        "boolean" => v.is_boolean(),
511        "array" => v.is_array(),
512        "object" => v.is_object(),
513        "null" => v.is_null(),
514        // Unknown/unsupported type keyword: don't flag — we only
515        // enforce the keywords we understand.
516        _ => true,
517    }
518}
519
520/// Validate a tool_call's `parameters` against the tool's JSON-Schema
521/// `parameters` object. Intentionally a focused subset of JSON Schema
522/// — the two checks that catch the overwhelming majority of malformed
523/// model output: declared property `type`s and `required` presence.
524/// Returns human-readable violation messages; empty when the schema
525/// imposes no constraints (e.g. the default empty object `{}`).
526fn validate_tool_params(params: &HashMap<String, Value>, schema: &Value) -> Vec<String> {
527    let mut out = Vec::new();
528    let Some(schema_obj) = schema.as_object() else {
529        // Non-object schema: nothing we can enforce.
530        return out;
531    };
532
533    // required: every named key must be present in params.
534    if let Some(Value::Array(required)) = schema_obj.get("required") {
535        for req in required {
536            if let Some(name) = req.as_str() {
537                if !params.contains_key(name) {
538                    out.push(format!("missing required parameter '{name}'"));
539                }
540            }
541        }
542    }
543
544    // property types: each supplied param whose key has a declared
545    // `type` must match it. `type` may be a string or an array of
546    // strings (JSON Schema union).
547    if let Some(Value::Object(properties)) = schema_obj.get("properties") {
548        for (key, val) in params {
549            let Some(prop_schema) = properties.get(key).and_then(|s| s.as_object()) else {
550                continue;
551            };
552            let ok = match prop_schema.get("type") {
553                Some(Value::String(t)) => value_matches_type(val, t),
554                Some(Value::Array(types)) => types
555                    .iter()
556                    .filter_map(|t| t.as_str())
557                    .any(|t| value_matches_type(val, t)),
558                // No declared type (or non-string/array): accept.
559                _ => true,
560            };
561            if !ok {
562                let expected = match prop_schema.get("type") {
563                    Some(Value::String(t)) => t.clone(),
564                    Some(Value::Array(types)) => types
565                        .iter()
566                        .filter_map(|t| t.as_str())
567                        .collect::<Vec<_>>()
568                        .join("|"),
569                    _ => String::new(),
570                };
571                out.push(format!(
572                    "parameter '{key}' has wrong type: expected {expected}, got {}",
573                    json_type_name(val)
574                ));
575            }
576        }
577    }
578
579    out
580}
581
582// --- Core verification ---
583
584/// Statically verify a proposal against an initial state.
585///
586/// `registered_tools` carries tool *names* only, so tool-existence is
587/// checked but `parameters` are not. To additionally validate each
588/// `tool_call`'s parameters against the tool's registered JSON Schema
589/// (type mismatches, missing required fields), use
590/// [`verify_with_schemas`].
591pub fn verify(
592    proposal: &ActionProposal,
593    initial_state: Option<&HashMap<String, Value>>,
594    registered_tools: Option<&HashSet<String>>,
595    max_actions: usize,
596) -> VerifyResult {
597    verify_inner(proposal, initial_state, registered_tools, None, max_actions)
598}
599
600/// Like [`verify`], but validates each `tool_call`'s `parameters`
601/// against the registered [`ToolSchema`]'s `parameters` JSON Schema —
602/// catching type mismatches (`{"path": 42}` for a `string` param) and
603/// missing `required` fields before dispatch. Tool existence is
604/// checked against the schema map's keys. This is the path the runtime
605/// (`verify_proposal`) and daemon (`verify` JSON-RPC) use, where the
606/// full schemas registered via `register_tool_schema` are available.
607pub fn verify_with_schemas(
608    proposal: &ActionProposal,
609    initial_state: Option<&HashMap<String, Value>>,
610    tool_schemas: Option<&HashMap<String, ToolSchema>>,
611    max_actions: usize,
612) -> VerifyResult {
613    verify_inner(proposal, initial_state, None, tool_schemas, max_actions)
614}
615
616/// How the topological walk treats the effects of an action it has just found
617/// a problem with.
618///
619/// The two callers want opposite things, and conflating them was
620/// Parslee-ai/car#622.
621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
622enum EffectMode {
623    /// Apply `expected_effects` even when the action's preconditions fail or
624    /// its state dependencies are missing.
625    ///
626    /// This is what [`verify`] wants. Its job is to report **every** problem in
627    /// one pass, so it keeps walking as though each action had run. Withholding
628    /// effects here would bury the real findings under a cascade of
629    /// "dependency not available" issues that are artifacts of the first
630    /// failure rather than independent defects.
631    Optimistic,
632    /// Skip the effects of an action that could not run.
633    ///
634    /// This is what [`simulate`] wants, because the executor rejects such an
635    /// action *before* dispatch (`ActionStatus::Rejected`) and its effects
636    /// never land. Downstream actions then find their dependencies missing and
637    /// are skipped in turn, so the cascade emerges from the data dependencies —
638    /// the same way the executor produces it — without modelling
639    /// `failure_behavior` here.
640    ExecutionFaithful,
641}
642
643fn verify_inner(
644    proposal: &ActionProposal,
645    initial_state: Option<&HashMap<String, Value>>,
646    registered_tools: Option<&HashSet<String>>,
647    tool_schemas: Option<&HashMap<String, ToolSchema>>,
648    max_actions: usize,
649) -> VerifyResult {
650    verify_inner_with_effects(
651        proposal,
652        initial_state,
653        registered_tools,
654        tool_schemas,
655        max_actions,
656        EffectMode::Optimistic,
657    )
658}
659
660fn verify_inner_with_effects(
661    proposal: &ActionProposal,
662    initial_state: Option<&HashMap<String, Value>>,
663    registered_tools: Option<&HashSet<String>>,
664    tool_schemas: Option<&HashMap<String, ToolSchema>>,
665    max_actions: usize,
666    effect_mode: EffectMode,
667) -> VerifyResult {
668    let mut state = match initial_state {
669        Some(s) => StaticState::from_map(s.clone()),
670        None => StaticState::new(),
671    };
672    let mut issues = Vec::new();
673
674    // Per-check finding counters for the evidence bundle (§5.2.2). The
675    // topo-walk checks below are interleaved per action, so they are
676    // tallied inline rather than by issue-vector deltas.
677    let mut precondition_findings = 0usize;
678    let mut state_dependency_findings = 0usize;
679    let mut tool_existence_findings = 0usize;
680    let mut param_schema_findings = 0usize;
681    let mut has_tool_calls = false;
682    // A malformed `tool_call` with no tool named is a structural finding
683    // the existence pass produces even without a registry — track it so
684    // the check's `ran` flag and `findings` count can't contradict
685    // (neo review m1).
686    let mut saw_missing_tool = false;
687    // Compensation resolution: a declared undo that names a missing tool or a
688    // sibling action that isn't in the batch. Counted separately from
689    // `tool_existence_findings` so the evidence bundle says which check fired.
690    let mut compensation_findings = 0usize;
691    // An `ActionRef` compensation is resolvable with no registry at all — the
692    // referent is in the proposal — so the check can run even when existence
693    // could not. Tracked so `ran` and `findings` cannot contradict.
694    let mut saw_compensation_ref = false;
695    // Which conditional checks actually ran, given the inputs we were
696    // handed. Existence needs *some* tool registry; parameter-schema
697    // validation needs the full schemas.
698    let has_tool_registry = tool_schemas.is_some() || registered_tools.is_some();
699    let param_schema_ran = tool_schemas.is_some();
700
701    // Resource bounds
702    let issues_before_bounds = issues.len();
703    if proposal.actions.len() > max_actions {
704        issues.push(VerifyIssue {
705            action_id: proposal
706                .actions
707                .first()
708                .map(|a| a.id.clone())
709                .unwrap_or_default(),
710            severity: "warning".to_string(),
711            message: format!(
712                "excessive actions: {} (limit {})",
713                proposal.actions.len(),
714                max_actions
715            ),
716            // `len > max_actions` is decided, not estimated. The *limit* is a
717            // policy input supplied by the caller — choosing it well is a
718            // judgement call, but the tier grades the check against its own
719            // claim ("this plan exceeds the limit you gave me"), and that claim
720            // is exact.
721            tier: EvidenceTier::DecisionProcedure,
722        });
723    }
724
725    let resource_bound_findings = issues.len() - issues_before_bounds;
726
727    // Loop detection
728    let issues_before_loop = issues.len();
729    let mut seen_calls: HashMap<String, u32> = HashMap::new();
730    for action in &proposal.actions {
731        if action.action_type == ActionType::ToolCall {
732            if let Some(ref tool) = action.tool {
733                let params = serde_json::to_string(&action.parameters).unwrap_or_default();
734                let key = format!("{}:{}", tool, params);
735                *seen_calls.entry(key).or_insert(0) += 1;
736            }
737        }
738    }
739    for (call_key, count) in &seen_calls {
740        let tool_name = call_key.split(':').next().unwrap_or("?");
741        if *count >= 3 {
742            issues.push(VerifyIssue {
743                action_id: "proposal".to_string(),
744                severity: "error".to_string(),
745                message: format!(
746                    "repeated identical tool call: {} ({}x) — likely loop",
747                    tool_name, count
748                ),
749                // The count is exact; "likely loop" is not. Three legitimate
750                // polls of the same endpoint produce this finding, and three
751                // semantically redundant calls with differing arguments do not.
752                tier: EvidenceTier::Heuristic,
753            });
754        } else if *count == 2 {
755            issues.push(VerifyIssue {
756                action_id: "proposal".to_string(),
757                severity: "warning".to_string(),
758                message: format!("duplicate tool call: {} ({}x)", tool_name, count),
759                // Same proxy, one threshold lower: a duplicate call is
760                // reported as suspicious, but a retry is a duplicate call.
761                tier: EvidenceTier::Heuristic,
762            });
763        }
764    }
765
766    let loop_detection_findings = issues.len() - issues_before_loop;
767
768    // Build DAG
769    let levels = build_dag(&proposal.actions);
770    let execution_levels: Vec<Vec<String>> = levels
771        .iter()
772        .map(|level| {
773            level
774                .iter()
775                .map(|&i| proposal.actions[i].id.clone())
776                .collect()
777        })
778        .collect();
779
780    // Walk in topological order
781    for level in &levels {
782        for &idx in level {
783            let action = &proposal.actions[idx];
784
785            // Would the executor refuse to dispatch this action? A failing
786            // precondition or a missing state dependency both produce
787            // `ActionStatus::Rejected` *before* the tool runs, so under
788            // `ExecutionFaithful` its effects must not land (car#622).
789            let mut blocked = false;
790
791            // Check preconditions
792            for pre in &action.preconditions {
793                if let Some(error) = precondition::check_precondition(pre, &state) {
794                    precondition_findings += 1;
795                    blocked = true;
796                    issues.push(VerifyIssue {
797                        action_id: action.id.clone(),
798                        severity: "error".to_string(),
799                        message: format!("precondition will fail: {}", error),
800                        // `check_precondition` decides the predicate against
801                        // the forward-simulated state — no guessing. That the
802                        // forward state is built from *declared* effects, and
803                        // so can disagree with runtime, is the separate
804                        // fidelity axis: see `cannot_verify` on the
805                        // "preconditions" CheckRecord and the crate's known
806                        // blind spots.
807                        tier: EvidenceTier::DecisionProcedure,
808                    });
809                }
810            }
811
812            // State dependencies
813            for dep in &action.state_dependencies {
814                if !state.exists(dep) && !state.is_unknown(dep) {
815                    state_dependency_findings += 1;
816                    blocked = true;
817                    issues.push(VerifyIssue {
818                        action_id: action.id.clone(),
819                        severity: "error".to_string(),
820                        message: format!("state dependency '{}' not available at this point", dep),
821                        // Membership in the forward model's key set — decided.
822                        // Undeclared writes are why the *model* can be wrong
823                        // here, which is the fidelity axis, not this one.
824                        tier: EvidenceTier::DecisionProcedure,
825                    });
826                }
827            }
828
829            // Tool existence + parameter-schema validation
830            if action.action_type == ActionType::ToolCall {
831                has_tool_calls = true;
832                if let Some(ref tool) = action.tool {
833                    // Existence: prefer the schema map's keys, fall
834                    // back to the name set. When neither is provided
835                    // (both None) existence isn't checked.
836                    let registered = match (tool_schemas, registered_tools) {
837                        (Some(schemas), _) => Some(schemas.contains_key(tool.as_str())),
838                        (None, Some(names)) => Some(names.contains(tool.as_str())),
839                        (None, None) => None,
840                    };
841                    if registered == Some(false) {
842                        tool_existence_findings += 1;
843                        issues.push(VerifyIssue {
844                            action_id: action.id.clone(),
845                            severity: "error".to_string(),
846                            message: format!("tool '{}' is not registered", tool),
847                            // Set membership in the supplied registry.
848                            tier: EvidenceTier::DecisionProcedure,
849                        });
850                    }
851                    // Parameters: validate against the registered
852                    // schema when we have one. This is the check the
853                    // `register_tool_schema` contract promises —
854                    // type mismatches and missing required fields.
855                    if let Some(schema) = tool_schemas.and_then(|s| s.get(tool.as_str())) {
856                        for msg in validate_tool_params(&action.parameters, &schema.parameters) {
857                            param_schema_findings += 1;
858                            issues.push(VerifyIssue {
859                                action_id: action.id.clone(),
860                                severity: "error".to_string(),
861                                message: format!("tool '{tool}': {msg}"),
862                                // `validate_tool_params` implements a strict
863                                // subset of JSON Schema (`required` +
864                                // `type`) and decides that subset exactly —
865                                // incomplete, but never approximate. What
866                                // falls outside the subset is recorded in the
867                                // check's `cannot_verify`, not hidden behind a
868                                // weaker tier.
869                                tier: EvidenceTier::DecisionProcedure,
870                            });
871                        }
872                    }
873                } else {
874                    saw_missing_tool = true;
875                    tool_existence_findings += 1;
876                    issues.push(VerifyIssue {
877                        action_id: action.id.clone(),
878                        severity: "error".to_string(),
879                        message: "tool_call action has no tool specified".to_string(),
880                        // Structural: the field is absent or it isn't.
881                        tier: EvidenceTier::DecisionProcedure,
882                    });
883                }
884            }
885
886            // Compensation resolution. A `Compensable` action's declared undo
887            // is the whole basis for calling the effect recoverable, so a
888            // compensation naming a tool that does not exist or an action that
889            // is not in the batch is a rollback plan that cannot run — and the
890            // moment anyone discovers it is the moment it is worth least.
891            match &action.compensation {
892                Some(car_ir::Compensation::Tool { tool, .. }) => {
893                    let registered = match (tool_schemas, registered_tools) {
894                        (Some(schemas), _) => Some(schemas.contains_key(tool.as_str())),
895                        (None, Some(names)) => Some(names.contains(tool.as_str())),
896                        (None, None) => None,
897                    };
898                    if registered == Some(false) {
899                        compensation_findings += 1;
900                        issues.push(VerifyIssue {
901                            action_id: action.id.clone(),
902                            severity: "error".to_string(),
903                            message: format!(
904                                "compensation names tool '{tool}', which is not registered"
905                            ),
906                            tier: EvidenceTier::DecisionProcedure,
907                        });
908                    }
909                }
910                Some(car_ir::Compensation::ActionRef { action_id }) => {
911                    saw_compensation_ref = true;
912                    if !proposal.actions.iter().any(|a| &a.id == action_id) {
913                        compensation_findings += 1;
914                        issues.push(VerifyIssue {
915                            action_id: action.id.clone(),
916                            severity: "error".to_string(),
917                            message: format!(
918                                "compensation references action '{action_id}', which is not in this proposal"
919                            ),
920                            tier: EvidenceTier::DecisionProcedure,
921                        });
922                    }
923                }
924                None => {}
925            }
926
927            // A `Compensable` contract with nothing declared to compensate
928            // with. `Action::missing_required_compensation` owns the rule; this
929            // is the surface that reports it.
930            if action.missing_required_compensation() {
931                compensation_findings += 1;
932                issues.push(VerifyIssue {
933                    action_id: action.id.clone(),
934                    severity: "error".to_string(),
935                    message: "action declares reversibility 'compensable' but no compensation"
936                        .to_string(),
937                    tier: EvidenceTier::DecisionProcedure,
938                });
939            }
940
941            // `verify` applies effects regardless, so one early failure doesn't
942            // bury the rest of the plan's real findings under a cascade of
943            // knock-on "dependency not available" issues. `simulate` must not:
944            // the executor rejects a blocked action before dispatch, so its
945            // effects never land, and predicting otherwise is what made
946            // `simulate` disagree with execution (car#622).
947            if effect_mode == EffectMode::Optimistic || !blocked {
948                apply_action_effects(action, &mut state);
949            }
950        }
951    }
952
953    // Conflicts
954    let conflicts = detect_conflicts(&proposal.actions);
955    for (a1, a2, key) in &conflicts {
956        issues.push(VerifyIssue {
957            action_id: a1.clone(),
958            severity: "warning".to_string(),
959            message: format!(
960                "write conflict on '{}' with action {} (no dependency declared)",
961                key, a2
962            ),
963            // `detect_conflicts` is exact over what the actions declare: two
964            // writers of one key with no `state_dependencies` edge between
965            // them. Whether the runtime interleaving actually hurts is a
966            // different question, and the residual risk says so.
967            tier: EvidenceTier::DecisionProcedure,
968        });
969    }
970
971    let conflict_findings = conflicts.len();
972
973    let has_errors = issues.iter().any(|i| i.severity == "error");
974    let warning_count = issues.iter().filter(|i| i.severity == "warning").count();
975
976    // --- Assemble the evidence bundle (§5.2.2) ---
977    let checks = vec![
978        CheckRecord {
979            name: "resource_bounds".into(),
980            ran: true,
981            verifies: format!("action count is within the limit ({max_actions})"),
982            cannot_verify: "per-action cost, wall-clock time, or memory at runtime".into(),
983            findings: resource_bound_findings,
984            tier: EvidenceTier::DecisionProcedure,
985        },
986        CheckRecord {
987            name: "loop_detection".into(),
988            ran: true,
989            verifies: "no identical tool call is repeated enough to look like a loop".into(),
990            cannot_verify: "semantically redundant calls with differing arguments".into(),
991            findings: loop_detection_findings,
992            // The only heuristic among `verify`'s checks — see the
993            // `EvidenceTier::Heuristic` docs for why the repeat count doesn't
994            // decide the property it reports.
995            tier: EvidenceTier::Heuristic,
996        },
997        CheckRecord {
998            name: "preconditions".into(),
999            ran: true,
1000            verifies: "declared preconditions hold against the statically-known state".into(),
1001            cannot_verify: "preconditions over keys whose values are only known at runtime".into(),
1002            findings: precondition_findings,
1003            tier: EvidenceTier::DecisionProcedure,
1004        },
1005        CheckRecord {
1006            name: "state_dependencies".into(),
1007            ran: true,
1008            verifies: "each declared state dependency is produced before it is read".into(),
1009            cannot_verify: "undeclared reads — state a tool consumes without listing it".into(),
1010            findings: state_dependency_findings,
1011            tier: EvidenceTier::DecisionProcedure,
1012        },
1013        CheckRecord {
1014            // The existence pass "ran" if a registry let us check names,
1015            // or if it caught a structurally malformed tool_call (no tool
1016            // named) even without one — so `ran` and `findings` agree.
1017            name: "tool_existence".into(),
1018            ran: has_tool_registry || saw_missing_tool,
1019            verifies: if has_tool_registry {
1020                "every tool_call names a registered tool".into()
1021            } else if saw_missing_tool {
1022                "tool_call structural well-formedness (a tool is named); registry not supplied so existence unchecked".into()
1023            } else {
1024                "(skipped — no tool registry supplied)".into()
1025            },
1026            cannot_verify: "whether the registered tool behaves as its name/description implies"
1027                .into(),
1028            findings: tool_existence_findings,
1029            // Set membership plus a structural field test. The tier describes
1030            // the check, so it stays `DecisionProcedure` even when the check
1031            // was skipped for want of a registry — `ran: false` is how a skip
1032            // is reported, not a weaker tier.
1033            tier: EvidenceTier::DecisionProcedure,
1034        },
1035        CheckRecord {
1036            name: "param_schema".into(),
1037            ran: param_schema_ran,
1038            verifies: if param_schema_ran {
1039                "tool_call parameters match the registered JSON Schema (types + required)".into()
1040            } else {
1041                "(skipped — no tool schemas supplied; existence only)".into()
1042            },
1043            cannot_verify:
1044                "value-level constraints beyond type/required (ranges, formats, cross-field)".into(),
1045            findings: param_schema_findings,
1046            tier: EvidenceTier::DecisionProcedure,
1047        },
1048        CheckRecord {
1049            name: "compensation_resolution".into(),
1050            // Resolvable without a registry when the compensation is an
1051            // `ActionRef` (the referent is in the proposal), and the
1052            // declared-but-missing rule needs no inputs at all.
1053            ran: has_tool_registry || saw_compensation_ref || compensation_findings > 0,
1054            verifies: "a declared compensation names a registered tool or an action in this \
1055                       proposal, and a `compensable` action declares one at all"
1056                .into(),
1057            cannot_verify: "whether the named compensation actually undoes the effect — that it \
1058                            is the right inverse, and that it will still work later"
1059                .into(),
1060            findings: compensation_findings,
1061            // Set membership and an id lookup over the batch.
1062            tier: EvidenceTier::DecisionProcedure,
1063        },
1064        CheckRecord {
1065            name: "write_conflicts".into(),
1066            ran: true,
1067            verifies: "concurrent writers to the same key declare an ordering dependency".into(),
1068            cannot_verify:
1069                "semantic conflicts — two actions whose effects are logically incompatible".into(),
1070            findings: conflict_findings,
1071            tier: EvidenceTier::DecisionProcedure,
1072        },
1073    ];
1074
1075    // Untested regions: values the static pass cannot pin down because
1076    // they are only determined at runtime. A tool_call's return value is
1077    // opaque to static analysis, and any state key the tool is declared
1078    // to write holds a runtime-determined value (the declared effect is a
1079    // placeholder, not the real value). We source these from the IR
1080    // directly rather than from `StaticState`, which only tracks
1081    // statically-known values (neo review M1).
1082    let mut untested_regions: Vec<String> = Vec::new();
1083    for action in &proposal.actions {
1084        if action.action_type == ActionType::ToolCall {
1085            if let Some(ref tool) = action.tool {
1086                untested_regions.push(format!(
1087                    "runtime output of tool '{tool}' (action {})",
1088                    action.id
1089                ));
1090            }
1091            for key in action.expected_effects.keys() {
1092                untested_regions.push(format!(
1093                    "state key '{key}' (value set at runtime by action {})",
1094                    action.id
1095                ));
1096            }
1097        }
1098    }
1099    untested_regions.sort();
1100    untested_regions.dedup();
1101
1102    let mut assumptions = vec![
1103        "supplied initial-state values are accurate".to_string(),
1104        "tool implementations honor their declared effects and side effects".to_string(),
1105    ];
1106    if !param_schema_ran && has_tool_calls {
1107        assumptions.push(
1108            "tool_call parameters are well-formed (no schemas supplied to check them)".to_string(),
1109        );
1110    }
1111
1112    let mut residual_risks = Vec::new();
1113    if !conflicts.is_empty() {
1114        residual_risks.push(format!(
1115            "{} undeclared write conflict(s) — last-writer-wins at runtime",
1116            conflicts.len()
1117        ));
1118    }
1119    if warning_count > 0 {
1120        residual_risks.push(format!(
1121            "{warning_count} warning(s) not blocking the verdict"
1122        ));
1123    }
1124    if !untested_regions.is_empty() {
1125        residual_risks.push(
1126            "outcomes depending on runtime tool output or runtime-set state are unverified"
1127                .to_string(),
1128        );
1129    }
1130
1131    // Coverage confidence: start full, dock for skipped applicable
1132    // checks, unknown/dynamic state, and warnings. A coverage signal,
1133    // not a probability — documented on the field.
1134    let mut confidence: f64 = 1.0;
1135    if has_tool_calls && !has_tool_registry {
1136        confidence -= 0.15;
1137    }
1138    if has_tool_calls && !param_schema_ran {
1139        confidence -= 0.20;
1140    }
1141    confidence -= (untested_regions.len() as f64 * 0.02).min(0.25);
1142    confidence -= (warning_count as f64 * 0.05).min(0.20);
1143    let confidence = confidence.clamp(0.0, 1.0);
1144
1145    let evidence = VerificationEvidence {
1146        checks,
1147        assumptions,
1148        untested_regions,
1149        residual_risks,
1150        confidence,
1151    };
1152
1153    VerifyResult {
1154        valid: !has_errors,
1155        issues,
1156        simulated_state: state.known,
1157        execution_levels,
1158        conflicts,
1159        evidence,
1160    }
1161}
1162
1163/// Simulate a proposal's state effects without executing tools.
1164///
1165/// Predicts the state the **executor** would leave behind: an action whose
1166/// preconditions fail, or whose state dependencies aren't available, is
1167/// rejected before dispatch and contributes no effects. Downstream actions then
1168/// find their own dependencies missing and drop out in turn, so the cascade
1169/// follows the data dependencies exactly as it does at runtime.
1170///
1171/// This deliberately differs from [`verify`], which keeps applying effects past
1172/// a failure so it can report every problem in one pass. Sharing that
1173/// optimism made `simulate` claim `deployed: true` for a deploy whose
1174/// `tests_passed` precondition provably could not hold (Parslee-ai/car#622).
1175///
1176/// Scope: models per-action gating, not `failure_behavior`. An *independent*
1177/// action alongside a blocked one still contributes its effects here, whereas
1178/// the executor's default `FailureBehavior::Abort` may stop the run before
1179/// reaching it. So this is the state assuming execution proceeds as far as the
1180/// dependency graph allows — never a claim that a provably-blocked action ran.
1181pub fn simulate(
1182    proposal: &ActionProposal,
1183    initial_state: Option<&HashMap<String, Value>>,
1184) -> HashMap<String, Value> {
1185    verify_inner_with_effects(
1186        proposal,
1187        initial_state,
1188        None,
1189        None,
1190        usize::MAX,
1191        EffectMode::ExecutionFaithful,
1192    )
1193    .simulated_state
1194}
1195
1196/// Test if two proposals produce identical state transitions.
1197///
1198/// [`EvidenceTier::Sampled`]: this probes the states in `test_states` and
1199/// nothing else — two trivial defaults (empty, and `{x:1, y:2}`) when you pass
1200/// none. `false` is a witness: some supplied state separates the two proposals.
1201/// `true` means only that none of the sampled states did, which is why the
1202/// return type is a bare `bool` with no result object to hang a tier on — read
1203/// this doc comment as the tier.
1204pub fn equivalent(
1205    p1: &ActionProposal,
1206    p2: &ActionProposal,
1207    test_states: Option<&[HashMap<String, Value>]>,
1208) -> bool {
1209    let defaults = vec![
1210        HashMap::new(),
1211        [
1212            ("x".to_string(), Value::from(1)),
1213            ("y".to_string(), Value::from(2)),
1214        ]
1215        .into(),
1216    ];
1217    let states = test_states.unwrap_or(&defaults);
1218
1219    for state in states {
1220        let s1 = simulate(p1, Some(state));
1221        let s2 = simulate(p2, Some(state));
1222        if s1 != s2 {
1223            return false;
1224        }
1225    }
1226    true
1227}
1228
1229/// Optimize a proposal: remove phantom dependencies to enable more parallelism.
1230pub fn optimize(proposal: &ActionProposal) -> ActionProposal {
1231    // Find which keys are actually written
1232    let mut written_keys = HashSet::new();
1233    for action in &proposal.actions {
1234        if action.action_type == ActionType::StateWrite {
1235            if let Some(k) = action.parameters.get("key").and_then(|v| v.as_str()) {
1236                written_keys.insert(k.to_string());
1237            }
1238        }
1239        for key in action.expected_effects.keys() {
1240            written_keys.insert(key.clone());
1241        }
1242    }
1243
1244    let optimized_actions: Vec<Action> = proposal
1245        .actions
1246        .iter()
1247        .map(|action| {
1248            let pruned: Vec<String> = action
1249                .state_dependencies
1250                .iter()
1251                .filter(|d| written_keys.contains(d.as_str()))
1252                .cloned()
1253                .collect();
1254
1255            if pruned.len() != action.state_dependencies.len() {
1256                let mut new_action = action.clone();
1257                new_action.state_dependencies = pruned;
1258                new_action
1259            } else {
1260                action.clone()
1261            }
1262        })
1263        .collect();
1264
1265    ActionProposal {
1266        id: proposal.id.clone(),
1267        source: proposal.source.clone(),
1268        actions: optimized_actions,
1269        timestamp: proposal.timestamp,
1270        context: proposal.context.clone(),
1271    }
1272}
1273
1274#[cfg(test)]
1275mod tests {
1276    use super::*;
1277    use car_ir::Precondition;
1278
1279    fn tool_call(id: &str, tool: &str) -> Action {
1280        {
1281            let mut a = Action::new(ActionType::ToolCall);
1282            a.id = id.to_string();
1283            a.tool = Some(tool.to_string());
1284            a
1285        }
1286    }
1287
1288    fn state_write(id: &str, key: &str, value: Value) -> Action {
1289        {
1290            let mut a = Action::new(ActionType::StateWrite);
1291            a.id = id.to_string();
1292            a.parameters = [
1293                ("key".to_string(), Value::from(key)),
1294                ("value".to_string(), value),
1295            ]
1296            .into();
1297            a
1298        }
1299    }
1300
1301    fn prop(actions: Vec<Action>) -> ActionProposal {
1302        ActionProposal {
1303            id: "test".to_string(),
1304            source: "test".to_string(),
1305            actions,
1306            timestamp: chrono::Utc::now(),
1307            context: HashMap::new(),
1308        }
1309    }
1310
1311    #[test]
1312    fn verify_valid_proposal() {
1313        let p = prop(vec![state_write("a1", "x", Value::from(1)), {
1314            let mut a = tool_call("a2", "search");
1315            a.state_dependencies = vec!["x".to_string()];
1316            a
1317        }]);
1318        let r = verify(&p, None, Some(&["search".to_string()].into()), 30);
1319        assert!(r.valid);
1320    }
1321
1322    // --- tool-parameter schema validation (car-releases#56) ---
1323
1324    fn echo_schema_parameters() -> Value {
1325        serde_json::json!({
1326            "type": "object",
1327            "properties": { "msg": { "type": "string" } },
1328            "required": ["msg"],
1329        })
1330    }
1331
1332    fn schema_map(parameters: Value) -> HashMap<String, ToolSchema> {
1333        [(
1334            "echo".to_string(),
1335            ToolSchema {
1336                name: "echo".to_string(),
1337                source: car_ir::ToolSourceKind::UserDefined,
1338                description: String::new(),
1339                parameters,
1340                returns: None,
1341                idempotent: true,
1342                cache_ttl_secs: None,
1343                rate_limit: None,
1344            },
1345        )]
1346        .into()
1347    }
1348
1349    fn echo_call(params: HashMap<String, Value>) -> ActionProposal {
1350        let mut a = tool_call("a1", "echo");
1351        a.parameters = params;
1352        prop(vec![a])
1353    }
1354
1355    #[test]
1356    fn schema_verify_accepts_well_typed_params() {
1357        let p = echo_call([("msg".to_string(), Value::from("hi"))].into());
1358        let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
1359        assert!(r.valid, "{:?}", r.issues);
1360    }
1361
1362    #[test]
1363    fn schema_verify_rejects_type_mismatch() {
1364        let p = echo_call([("msg".to_string(), Value::from(42))].into());
1365        let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
1366        assert!(!r.valid);
1367        assert!(r
1368            .issues
1369            .iter()
1370            .any(|i| i.message.contains("wrong type") && i.message.contains("msg")));
1371    }
1372
1373    #[test]
1374    fn schema_verify_rejects_missing_required() {
1375        let p = echo_call(HashMap::new());
1376        let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
1377        assert!(!r.valid);
1378        assert!(
1379            r.issues
1380                .iter()
1381                .any(|i| i.message.contains("missing required parameter")
1382                    && i.message.contains("msg"))
1383        );
1384    }
1385
1386    #[test]
1387    fn schema_verify_rejects_unknown_tool() {
1388        let mut a = tool_call("a1", "nope");
1389        a.parameters = [("msg".to_string(), Value::from("hi"))].into();
1390        let r = verify_with_schemas(
1391            &prop(vec![a]),
1392            None,
1393            Some(&schema_map(echo_schema_parameters())),
1394            30,
1395        );
1396        assert!(!r.valid);
1397        assert!(r
1398            .issues
1399            .iter()
1400            .any(|i| i.message.contains("not registered")));
1401    }
1402
1403    #[test]
1404    fn name_only_verify_still_skips_param_validation() {
1405        // Back-compat: verify() with names checks existence only. A
1406        // bad parameter type must NOT be flagged when no schema is
1407        // supplied — that path has no schema to validate against.
1408        let p = echo_call([("msg".to_string(), Value::from(42))].into());
1409        let r = verify(&p, None, Some(&["echo".to_string()].into()), 30);
1410        assert!(
1411            r.valid,
1412            "name-only verify must not validate params: {:?}",
1413            r.issues
1414        );
1415    }
1416
1417    #[test]
1418    fn schema_verify_accepts_integer_and_union_types() {
1419        let parameters = serde_json::json!({
1420            "type": "object",
1421            "properties": {
1422                "n": { "type": "integer" },
1423                "maybe": { "type": ["string", "null"] },
1424            },
1425            "required": ["n"],
1426        });
1427        let p = echo_call(
1428            [
1429                ("n".to_string(), Value::from(7)),
1430                ("maybe".to_string(), Value::Null),
1431            ]
1432            .into(),
1433        );
1434        let r = verify_with_schemas(&p, None, Some(&schema_map(parameters)), 30);
1435        assert!(r.valid, "{:?}", r.issues);
1436    }
1437
1438    #[test]
1439    fn schema_verify_empty_schema_imposes_no_constraints() {
1440        // Default `{}` parameters schema -> existence only, no param
1441        // checks (preserves behavior for tools registered without a
1442        // detailed schema).
1443        let p = echo_call([("anything".to_string(), Value::from(42))].into());
1444        let r = verify_with_schemas(&p, None, Some(&schema_map(serde_json::json!({}))), 30);
1445        assert!(r.valid, "{:?}", r.issues);
1446    }
1447
1448    #[test]
1449    fn verify_catches_unsatisfied_precondition() {
1450        let mut a = tool_call("a1", "deploy");
1451        a.preconditions = vec![Precondition {
1452            key: "tests_passed".to_string(),
1453            operator: "eq".to_string(),
1454            value: Value::Bool(true),
1455            description: String::new(),
1456        }];
1457        let r = verify(&prop(vec![a]), None, None, 30);
1458        assert!(!r.valid);
1459    }
1460
1461    #[test]
1462    fn verify_precondition_satisfied_by_earlier_action() {
1463        let mut a2 = tool_call("a2", "deploy");
1464        a2.preconditions = vec![Precondition {
1465            key: "ready".to_string(),
1466            operator: "eq".to_string(),
1467            value: Value::Bool(true),
1468            description: String::new(),
1469        }];
1470        a2.state_dependencies = vec!["ready".to_string()];
1471
1472        let p = prop(vec![state_write("a1", "ready", Value::Bool(true)), a2]);
1473        let r = verify(&p, None, None, 30);
1474        assert!(r.valid);
1475    }
1476
1477    #[test]
1478    fn verify_missing_state_dependency() {
1479        let mut a = tool_call("a1", "x");
1480        a.state_dependencies = vec!["nonexistent".to_string()];
1481        let r = verify(&prop(vec![a]), None, None, 30);
1482        assert!(!r.valid);
1483    }
1484
1485    /// A cycle (a1 needs what only a2 produces, a2 needs what only a1
1486    /// produces) cannot be drawn in the DAG, so verify reports it as an unmet
1487    /// state dependency rather than as a cycle.
1488    #[test]
1489    fn verify_mutual_state_dependency_is_invalid() {
1490        let mut a1 = tool_call("a1", "step");
1491        a1.state_dependencies = vec!["k2".to_string()];
1492        a1.expected_effects = [("k1".to_string(), Value::from(1))].into();
1493        let mut a2 = tool_call("a2", "step");
1494        a2.state_dependencies = vec!["k1".to_string()];
1495        a2.expected_effects = [("k2".to_string(), Value::from(2))].into();
1496
1497        let r = verify(&prop(vec![a1, a2]), None, None, 30);
1498
1499        assert!(!r.valid);
1500        assert!(
1501            r.issues.iter().any(|i| i.severity == "error"
1502                && i.message == "state dependency 'k2' not available at this point"),
1503            "issues: {:?}",
1504            r.issues
1505        );
1506    }
1507
1508    #[test]
1509    fn verify_tool_not_registered() {
1510        let a = tool_call("a1", "quantum");
1511        let r = verify(&prop(vec![a]), None, Some(&HashSet::new()), 30);
1512        assert!(!r.valid);
1513    }
1514
1515    #[test]
1516    fn compensation_naming_an_unregistered_tool_is_a_finding() {
1517        // A `compensable` contract is only worth what its declared undo is
1518        // worth. A compensation naming a tool that does not exist is a
1519        // rollback plan that cannot run, and the moment anyone finds out is
1520        // the moment it is worth least.
1521        let mut a = tool_call("a1", "poll");
1522        a.reversibility = car_ir::Reversibility::Compensable;
1523        a.compensation = Some(car_ir::Compensation::Tool {
1524            tool: "db.delet".into(), // typo
1525            parameters: Default::default(),
1526        });
1527        let r = verify(&prop(vec![a]), None, Some(&["poll".to_string()].into()), 30);
1528        assert!(!r.valid);
1529        assert!(r
1530            .issues
1531            .iter()
1532            .any(|i| i.message.contains("compensation names tool 'db.delet'")));
1533
1534        // The same declaration against a registry that has the tool is fine.
1535        let mut a = tool_call("a1", "poll");
1536        a.reversibility = car_ir::Reversibility::Compensable;
1537        a.compensation = Some(car_ir::Compensation::Tool {
1538            tool: "undo".into(),
1539            parameters: Default::default(),
1540        });
1541        let reg = ["poll".to_string(), "undo".to_string()].into();
1542        assert!(verify(&prop(vec![a]), None, Some(&reg), 30).valid);
1543    }
1544
1545    #[test]
1546    fn compensation_action_ref_must_resolve_within_the_proposal() {
1547        // Resolvable with no registry at all — the referent is in the batch.
1548        let mut a = tool_call("a1", "deploy");
1549        a.reversibility = car_ir::Reversibility::Compensable;
1550        a.compensation = Some(car_ir::Compensation::ActionRef {
1551            action_id: "rollback-1".into(),
1552        });
1553        let r = verify(&prop(vec![a.clone()]), None, None, 30);
1554        assert!(!r.valid);
1555        assert!(r
1556            .issues
1557            .iter()
1558            .any(|i| i.message.contains("references action 'rollback-1'")));
1559
1560        // With the referenced action actually present, it resolves.
1561        let mut undo = tool_call("rollback-1", "rollback");
1562        undo.id = "rollback-1".into();
1563        let r = verify(&prop(vec![a, undo]), None, None, 30);
1564        assert!(
1565            !r.issues
1566                .iter()
1567                .any(|i| i.message.contains("references action")),
1568            "{:?}",
1569            r.issues
1570        );
1571    }
1572
1573    #[test]
1574    fn compensable_with_no_compensation_declared_is_a_finding() {
1575        let mut a = tool_call("a1", "poll");
1576        a.reversibility = car_ir::Reversibility::Compensable;
1577        a.compensation = None;
1578        let r = verify(&prop(vec![a]), None, None, 30);
1579        assert!(!r.valid);
1580        assert!(r.issues.iter().any(|i| i
1581            .message
1582            .contains("declares reversibility 'compensable' but no compensation")));
1583        // Every compensation finding is an exact lookup, never a guess.
1584        assert!(r
1585            .issues_with_tier(EvidenceTier::DecisionProcedure)
1586            .iter()
1587            .any(|i| i.message.contains("no compensation")));
1588        // ...and the check reports itself as having run.
1589        let rec = r
1590            .evidence
1591            .checks
1592            .iter()
1593            .find(|c| c.name == "compensation_resolution")
1594            .expect("compensation_resolution check is recorded");
1595        assert!(rec.ran);
1596        assert_eq!(rec.findings, 1);
1597    }
1598
1599    #[test]
1600    fn verify_no_tool_specified() {
1601        let mut a = tool_call("a1", "x");
1602        a.tool = None;
1603        let r = verify(&prop(vec![a]), None, None, 30);
1604        assert!(!r.valid);
1605    }
1606
1607    #[test]
1608    fn detect_write_conflict() {
1609        let p = prop(vec![
1610            state_write("a1", "x", Value::from(1)),
1611            state_write("a2", "x", Value::from(2)),
1612        ]);
1613        let r = verify(&p, None, None, 30);
1614        assert!(!r.conflicts.is_empty());
1615    }
1616
1617    #[test]
1618    fn simulate_state_writes() {
1619        let p = prop(vec![
1620            state_write("a1", "x", Value::from(10)),
1621            state_write("a2", "y", Value::from(20)),
1622        ]);
1623        let s = simulate(&p, None);
1624        assert_eq!(s.get("x"), Some(&Value::from(10)));
1625        assert_eq!(s.get("y"), Some(&Value::from(20)));
1626    }
1627
1628    /// Parslee-ai/car#622 — the reported case. A deploy gated on
1629    /// `tests_passed == true`, simulated from a state where it is `false`, used
1630    /// to come back `deployed: true`: `simulate` shared `verify`'s optimistic
1631    /// effect application, so it predicted the effects of an action the
1632    /// executor would reject before dispatch.
1633    #[test]
1634    fn simulate_skips_effects_of_a_provably_blocked_action() {
1635        let mut deploy = tool_call("deploy", "deploy");
1636        deploy.preconditions = vec![Precondition {
1637            key: "tests_passed".to_string(),
1638            operator: "eq".to_string(),
1639            value: Value::Bool(true),
1640            description: String::new(),
1641        }];
1642        deploy
1643            .expected_effects
1644            .insert("deployed".to_string(), Value::Bool(true));
1645        let p = prop(vec![deploy]);
1646
1647        let failing: HashMap<String, Value> =
1648            [("tests_passed".to_string(), Value::Bool(false))].into();
1649        let s = simulate(&p, Some(&failing));
1650        assert_eq!(
1651            s.get("deployed"),
1652            None,
1653            "a deploy whose precondition provably fails must not appear deployed: {s:?}"
1654        );
1655
1656        // And it still predicts the effects when the precondition holds.
1657        let passing: HashMap<String, Value> =
1658            [("tests_passed".to_string(), Value::Bool(true))].into();
1659        let s = simulate(&p, Some(&passing));
1660        assert_eq!(s.get("deployed"), Some(&Value::Bool(true)));
1661    }
1662
1663    /// `verify` keeps applying effects past a failure on purpose: it reports
1664    /// every problem in one pass, and withholding effects would bury the real
1665    /// findings under knock-on "dependency not available" issues. Its behaviour
1666    /// must not change with the simulate fix.
1667    #[test]
1668    fn verify_stays_optimistic_so_it_reports_every_finding() {
1669        let mut deploy = tool_call("deploy", "deploy");
1670        deploy.preconditions = vec![Precondition {
1671            key: "tests_passed".to_string(),
1672            operator: "eq".to_string(),
1673            value: Value::Bool(true),
1674            description: String::new(),
1675        }];
1676        deploy
1677            .expected_effects
1678            .insert("deployed".to_string(), Value::Bool(true));
1679        let mut notify = tool_call("notify", "notify");
1680        notify.state_dependencies = vec!["deployed".to_string()];
1681        let p = prop(vec![deploy, notify]);
1682
1683        let failing: HashMap<String, Value> =
1684            [("tests_passed".to_string(), Value::Bool(false))].into();
1685        let r = verify(&p, Some(&failing), None, 30);
1686
1687        assert!(!r.valid);
1688        // Exactly one finding: the precondition. `notify` must NOT also be
1689        // flagged for a missing `deployed`, which is the cascade the optimism
1690        // exists to suppress.
1691        assert_eq!(
1692            r.errors().len(),
1693            1,
1694            "expected only the precondition finding, got {:?}",
1695            r.issues
1696        );
1697        assert!(r.issues[0].message.contains("precondition will fail"));
1698    }
1699
1700    /// The block propagates along data dependencies, the way the executor
1701    /// produces it — no `failure_behavior` modelling needed.
1702    #[test]
1703    fn simulate_cascade_follows_data_dependencies() {
1704        let mut build = tool_call("build", "build");
1705        build.preconditions = vec![Precondition {
1706            key: "ready".to_string(),
1707            operator: "eq".to_string(),
1708            value: Value::Bool(true),
1709            description: String::new(),
1710        }];
1711        build
1712            .expected_effects
1713            .insert("artifact".to_string(), Value::from("app.tar.gz"));
1714        let mut deploy = tool_call("deploy", "deploy");
1715        deploy.state_dependencies = vec!["artifact".to_string()];
1716        deploy
1717            .expected_effects
1718            .insert("deployed".to_string(), Value::Bool(true));
1719
1720        let s = simulate(&prop(vec![build, deploy]), None);
1721        assert_eq!(
1722            s.get("artifact"),
1723            None,
1724            "blocked build produced no artifact"
1725        );
1726        assert_eq!(
1727            s.get("deployed"),
1728            None,
1729            "deploy depends on the artifact that never appeared: {s:?}"
1730        );
1731    }
1732
1733    /// `equivalent` compares `simulate` output, so it inherited the bug: two
1734    /// proposals differing only in a precondition that gates one of them read
1735    /// as equivalent.
1736    #[test]
1737    fn equivalent_distinguishes_a_gated_proposal_from_an_ungated_one() {
1738        let mut gated = tool_call("a", "deploy");
1739        gated.preconditions = vec![Precondition {
1740            key: "tests_passed".to_string(),
1741            operator: "eq".to_string(),
1742            value: Value::Bool(true),
1743            description: String::new(),
1744        }];
1745        gated
1746            .expected_effects
1747            .insert("deployed".to_string(), Value::Bool(true));
1748
1749        let mut ungated = tool_call("b", "deploy");
1750        ungated
1751            .expected_effects
1752            .insert("deployed".to_string(), Value::Bool(true));
1753
1754        let failing: Vec<HashMap<String, Value>> =
1755            vec![[("tests_passed".to_string(), Value::Bool(false))].into()];
1756        assert!(
1757            !equivalent(&prop(vec![gated]), &prop(vec![ungated]), Some(&failing)),
1758            "a gate that blocks one proposal and not the other is a real difference"
1759        );
1760    }
1761
1762    #[test]
1763    fn equivalent_proposals() {
1764        let p1 = prop(vec![
1765            state_write("a1", "x", Value::from(1)),
1766            state_write("a2", "y", Value::from(2)),
1767        ]);
1768        let p2 = prop(vec![
1769            state_write("b1", "y", Value::from(2)),
1770            state_write("b2", "x", Value::from(1)),
1771        ]);
1772        assert!(equivalent(&p1, &p2, None));
1773    }
1774
1775    #[test]
1776    fn non_equivalent_proposals() {
1777        let p1 = prop(vec![state_write("a1", "x", Value::from(1))]);
1778        let p2 = prop(vec![state_write("b1", "x", Value::from(99))]);
1779        assert!(!equivalent(&p1, &p2, None));
1780    }
1781
1782    #[test]
1783    fn optimize_removes_phantom_deps() {
1784        let mut a = tool_call("a1", "search");
1785        a.state_dependencies = vec!["phantom".to_string()];
1786        let p = prop(vec![a]);
1787        let optimized = optimize(&p);
1788        assert!(optimized.actions[0].state_dependencies.is_empty());
1789    }
1790
1791    #[test]
1792    fn optimize_preserves_real_deps() {
1793        let mut a2 = tool_call("a2", "x");
1794        a2.state_dependencies = vec!["x".to_string()];
1795        let p = prop(vec![state_write("a1", "x", Value::from(1)), a2]);
1796        let optimized = optimize(&p);
1797        assert_eq!(optimized.actions[1].state_dependencies, vec!["x"]);
1798    }
1799
1800    #[test]
1801    fn loop_detection_duplicates() {
1802        let p = prop(vec![tool_call("a1", "search"), tool_call("a2", "search")]);
1803        let r = verify(&p, None, None, 30);
1804        assert!(r.issues.iter().any(|i| i.message.contains("duplicate")));
1805    }
1806
1807    #[test]
1808    fn loop_detection_triple() {
1809        let p = prop(vec![
1810            tool_call("a1", "search"),
1811            tool_call("a2", "search"),
1812            tool_call("a3", "search"),
1813        ]);
1814        let r = verify(&p, None, None, 30);
1815        assert!(!r.valid);
1816        assert!(r.issues.iter().any(|i| i.message.contains("likely loop")));
1817    }
1818
1819    #[test]
1820    fn resource_bounds() {
1821        let actions: Vec<Action> = (0..35)
1822            .map(|i| tool_call(&format!("a{}", i), &format!("t{}", i)))
1823            .collect();
1824        let r = verify(&prop(actions), None, None, 30);
1825        assert!(r.issues.iter().any(|i| i.message.contains("excessive")));
1826    }
1827
1828    // --- mutation-testing gaps (cargo-mutants, 2026-09-10) ---
1829    //
1830    // A mutation run survived 20 changes inside `verify_inner_with_effects`,
1831    // all in the arithmetic the evidence bundle is built from. The suite
1832    // asserted the VERDICT (`valid`, `issues`) and barely touched the
1833    // bookkeeping the verdict is graded by, so a `>` could widen to `>=`, a
1834    // `-=` flip to `+=` and a `+= 1` counter become `*= 1` with all 254 tests
1835    // still green. These close that gap.
1836    // Background: docs/solutions/mutation-testing-first-run.md
1837
1838    #[test]
1839    fn resource_bound_is_exclusive_at_the_limit() {
1840        // `resource_bounds` above is a negative test with no positive control:
1841        // it builds 35 against a limit of 30, so `>` and `>=` behave alike and
1842        // the off-by-one is invisible. Pin BOTH sides of the boundary.
1843        let at_limit: Vec<Action> = (0..30)
1844            .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
1845            .collect();
1846        let r = verify(&prop(at_limit), None, None, 30);
1847        assert!(
1848            !r.issues.iter().any(|i| i.message.contains("excessive")),
1849            "exactly max_actions is within the bound"
1850        );
1851
1852        let over: Vec<Action> = (0..31)
1853            .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
1854            .collect();
1855        let r = verify(&prop(over), None, None, 30);
1856        assert!(
1857            r.issues.iter().any(|i| i.message.contains("excessive")),
1858            "one past max_actions is over it"
1859        );
1860    }
1861
1862    #[test]
1863    fn resource_bound_finding_count_is_recorded() {
1864        // The `resource_bounds` CheckRecord is computed as a delta between two
1865        // `issues.len()` snapshots; a `-` flipped to `+` leaves the verdict
1866        // untouched and silently inflates the count.
1867        let over: Vec<Action> = (0..31)
1868            .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
1869            .collect();
1870        let r = verify(&prop(over), None, None, 30);
1871        let rec = r
1872            .evidence
1873            .checks
1874            .iter()
1875            .find(|c| c.name == "resource_bounds")
1876            .expect("resource_bounds is always recorded");
1877        assert_eq!(rec.findings, 1, "exactly one bound was exceeded");
1878
1879        let under = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
1880        let rec = under
1881            .evidence
1882            .checks
1883            .iter()
1884            .find(|c| c.name == "resource_bounds")
1885            .unwrap();
1886        assert_eq!(
1887            rec.findings, 0,
1888            "a proposal inside the bound has no finding"
1889        );
1890    }
1891
1892    #[test]
1893    fn precondition_findings_are_counted_not_just_reported() {
1894        // `precondition_findings += 1` survived becoming `*= 1`, which pins the
1895        // counter at 0 while the issues still appear.
1896        let mut a = tool_call("a1", "t1");
1897        a.preconditions = vec![Precondition {
1898            key: "missing_key".to_string(),
1899            operator: "exists".to_string(),
1900            value: Value::Null,
1901            description: String::new(),
1902        }];
1903        let r = verify(&prop(vec![a]), Some(&HashMap::new()), None, 30);
1904        let rec = r
1905            .evidence
1906            .checks
1907            .iter()
1908            .find(|c| c.name == "preconditions")
1909            .expect("preconditions is recorded whenever an action declares one");
1910        assert_eq!(
1911            rec.findings,
1912            r.issues
1913                .iter()
1914                .filter(|i| i.message.contains("precondition"))
1915                .count(),
1916            "the recorded count must match the issues actually raised"
1917        );
1918        assert!(rec.findings > 0, "an unmet precondition is a finding");
1919    }
1920
1921    #[test]
1922    fn warning_count_drives_the_residual_risk_line() {
1923        // Two mutants met here: the `severity == "warning"` filter flipping to
1924        // `!=`, and the `warning_count > 0` guard widening to `>=` (which would
1925        // emit a "0 warning(s)" line on a clean proposal).
1926        let clean = verify(
1927            &prop(vec![state_write("a1", "x", Value::from(1))]),
1928            None,
1929            None,
1930            30,
1931        );
1932        assert!(
1933            !clean
1934                .evidence
1935                .residual_risks
1936                .iter()
1937                .any(|s| s.contains("warning(s)")),
1938            "no warnings means no warning risk line at all"
1939        );
1940
1941        // Two undeclared writers to one key is a warning, not an error.
1942        let warned = verify(
1943            &prop(vec![
1944                state_write("a1", "k", Value::from(1)),
1945                state_write("a2", "k", Value::from(2)),
1946            ]),
1947            None,
1948            None,
1949            30,
1950        );
1951        let warnings = warned
1952            .issues
1953            .iter()
1954            .filter(|i| i.severity == "warning")
1955            .count();
1956        assert!(warnings > 0, "the fixture must actually produce a warning");
1957        assert!(
1958            warned
1959                .evidence
1960                .residual_risks
1961                .iter()
1962                .any(|s| s.contains(&format!("{warnings} warning(s)"))),
1963            "the risk line must carry the real warning count"
1964        );
1965    }
1966
1967    #[test]
1968    fn confidence_docks_exactly_once_per_skipped_check() {
1969        // The confidence arithmetic carried six survivors: two `!` deletions,
1970        // three `-=` flipped to `+=`, and two `*` to `/`. Only exact values
1971        // pin them, so these assert the number rather than a direction.
1972        //
1973        // The untested-region dock is separate and applies to both cases
1974        // below, so derive it from the observed regions rather than baking a
1975        // number in — the point is to pin the OPERATORS, and the expectation
1976        // is computed independently of the code under test.
1977        let untested_dock =
1978            |r: &VerifyResult| (r.evidence.untested_regions.len() as f64 * 0.02).min(0.25);
1979
1980        // Tool calls with no registry and no schemas: -0.15 and -0.20.
1981        let r = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
1982        let expected = 1.0 - 0.15 - 0.20 - untested_dock(&r);
1983        assert!(
1984            (r.evidence.confidence - expected).abs() < 1e-9,
1985            "1.0 - 0.15 (no registry) - 0.20 (no schemas) - untested, want {expected}, got {}",
1986            r.evidence.confidence
1987        );
1988
1989        // Same proposal with the tool registered: the registry dock lifts, and
1990        // nothing else may move with it.
1991        let r = verify(
1992            &prop(vec![tool_call("a1", "t1")]),
1993            None,
1994            Some(&["t1".to_string()].into()),
1995            30,
1996        );
1997        let expected = 1.0 - 0.20 - untested_dock(&r);
1998        assert!(
1999            (r.evidence.confidence - expected).abs() < 1e-9,
2000            "1.0 - 0.20 (no schemas) - untested, want {expected}, got {}",
2001            r.evidence.confidence
2002        );
2003
2004        // No tool calls at all: neither dock applies, so neither `!` may be
2005        // deleted without this moving.
2006        let r = verify(
2007            &prop(vec![state_write("a1", "x", Value::from(1))]),
2008            None,
2009            None,
2010            30,
2011        );
2012        assert!(
2013            (r.evidence.confidence - 1.0).abs() < 1e-9,
2014            "pure state writes dock nothing, got {}",
2015            r.evidence.confidence
2016        );
2017    }
2018
2019    #[test]
2020    fn confidence_docks_scale_with_warnings_and_stay_clamped() {
2021        // `warning_count as f64 * 0.05` becoming `/ 0.05` would explode the
2022        // dock; the `.min(0.20)` clamp and the 0.0 floor keep it in range.
2023        let warned = verify(
2024            &prop(vec![
2025                state_write("a1", "k", Value::from(1)),
2026                state_write("a2", "k", Value::from(2)),
2027            ]),
2028            None,
2029            None,
2030            30,
2031        );
2032        let warnings = warned
2033            .issues
2034            .iter()
2035            .filter(|i| i.severity == "warning")
2036            .count();
2037        let expected = 1.0 - (warnings as f64 * 0.05).min(0.20);
2038        assert!(
2039            (warned.evidence.confidence - expected).abs() < 1e-9,
2040            "{warnings} warning(s) dock 0.05 each, capped at 0.20; got {}",
2041            warned.evidence.confidence
2042        );
2043        assert!(
2044            (0.0..=1.0).contains(&warned.evidence.confidence),
2045            "confidence must stay inside its documented range"
2046        );
2047    }
2048
2049    #[test]
2050    fn the_schema_assumption_needs_both_conditions() {
2051        // `!param_schema_ran && has_tool_calls` survived becoming `||`, which
2052        // would add the "no schemas supplied" assumption to a proposal that
2053        // makes no tool calls at all.
2054        let r = verify(
2055            &prop(vec![state_write("a1", "x", Value::from(1))]),
2056            None,
2057            None,
2058            30,
2059        );
2060        assert!(
2061            !r.evidence
2062                .assumptions
2063                .iter()
2064                .any(|s| s.contains("tool_call parameters")),
2065            "a proposal with no tool calls assumes nothing about tool_call params"
2066        );
2067
2068        let r = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
2069        assert!(
2070            r.evidence
2071                .assumptions
2072                .iter()
2073                .any(|s| s.contains("tool_call parameters")),
2074            "unchecked tool_call params must be declared as an assumption"
2075        );
2076    }
2077
2078    #[test]
2079    fn loop_detection_finding_count_is_a_delta_not_a_total() {
2080        // `issues.len() - issues_before_loop` survived becoming `+`. It is only
2081        // observable when issues already exist when the loop check starts, so
2082        // this proposal breaks the action bound AND repeats a tool: the loop
2083        // count must report ITS OWN findings, not the running total.
2084        let mut actions: Vec<Action> = (0..31)
2085            .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
2086            .collect();
2087        actions.push(tool_call("dup", "t0")); // now a duplicate of a0
2088        let r = verify(&prop(actions), None, None, 30);
2089        let bounds = r
2090            .evidence
2091            .checks
2092            .iter()
2093            .find(|c| c.name == "resource_bounds")
2094            .unwrap();
2095        let loops = r
2096            .evidence
2097            .checks
2098            .iter()
2099            .find(|c| c.name == "loop_detection")
2100            .expect("loop_detection is recorded");
2101        assert_eq!(bounds.findings, 1, "one bound exceeded");
2102        assert!(loops.findings > 0, "the duplicate must be found");
2103        assert!(
2104            loops.findings < r.issues.len(),
2105            "loop_detection reports its own findings ({}), not every issue raised ({})",
2106            loops.findings,
2107            r.issues.len()
2108        );
2109    }
2110
2111    #[test]
2112    fn param_schema_finding_count_is_recorded() {
2113        // `param_schema_findings += 1` survived becoming `*= 1`, pinning the
2114        // counter at 0 while the issues still surface.
2115        let p = echo_call([("msg".to_string(), Value::from(42))].into());
2116        let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
2117        assert!(!r.valid);
2118        let rec = r
2119            .evidence
2120            .checks
2121            .iter()
2122            .find(|c| c.name == "param_schema")
2123            .expect("param_schema is recorded when schemas are supplied");
2124        assert_eq!(
2125            rec.findings,
2126            r.issues
2127                .iter()
2128                .filter(|i| i.message.contains("wrong type"))
2129                .count(),
2130            "the recorded count must match the type errors raised"
2131        );
2132        assert!(rec.findings > 0);
2133    }
2134
2135    #[test]
2136    fn compensation_finding_count_is_recorded() {
2137        // Two `compensation_findings += 1` sites survived becoming `*= 1`.
2138        let mut a = tool_call("a1", "poll");
2139        a.reversibility = car_ir::Reversibility::Compensable;
2140        a.compensation = Some(car_ir::Compensation::Tool {
2141            tool: "db.delet".into(), // a tool that is not registered
2142            parameters: Default::default(),
2143        });
2144        let r = verify(&prop(vec![a]), None, Some(&["poll".to_string()].into()), 30);
2145        let rec = r
2146            .evidence
2147            .checks
2148            .iter()
2149            .find(|c| c.name == "compensation_resolution")
2150            .expect("compensation is recorded when an action declares one");
2151        assert!(
2152            rec.findings > 0,
2153            "an unrunnable rollback plan is a compensation finding"
2154        );
2155
2156        // And a well-formed compensation records zero, so the counter is not
2157        // simply always non-zero.
2158        let mut ok = tool_call("a1", "poll");
2159        ok.reversibility = car_ir::Reversibility::Compensable;
2160        ok.compensation = Some(car_ir::Compensation::Tool {
2161            tool: "undo".into(),
2162            parameters: Default::default(),
2163        });
2164        let r = verify(
2165            &prop(vec![ok]),
2166            None,
2167            Some(&["poll".to_string(), "undo".to_string()].into()),
2168            30,
2169        );
2170        let rec = r
2171            .evidence
2172            .checks
2173            .iter()
2174            .find(|c| c.name == "compensation_resolution")
2175            .unwrap();
2176        assert_eq!(rec.findings, 0, "a registered undo is not a finding");
2177    }
2178
2179    #[test]
2180    fn action_ref_compensation_finding_count_is_recorded() {
2181        // The ActionRef arm has its OWN `compensation_findings += 1`, and the
2182        // tool-arm test above does not reach it: killing one `*= 1` says
2183        // nothing about the other.
2184        let mut a = tool_call("a1", "poll");
2185        a.reversibility = car_ir::Reversibility::Compensable;
2186        a.compensation = Some(car_ir::Compensation::ActionRef {
2187            action_id: "nonexistent".into(),
2188        });
2189        let r = verify(&prop(vec![a]), None, Some(&["poll".to_string()].into()), 30);
2190        let rec = r
2191            .evidence
2192            .checks
2193            .iter()
2194            .find(|c| c.name == "compensation_resolution")
2195            .expect("compensation_resolution is recorded");
2196        assert_eq!(
2197            rec.findings,
2198            r.issues
2199                .iter()
2200                .filter(|i| i.message.contains("not in this proposal"))
2201                .count(),
2202            "the count must match the dangling references raised"
2203        );
2204        assert!(rec.findings > 0, "a dangling action ref is a finding");
2205    }
2206
2207    #[test]
2208    fn untested_regions_drive_their_own_residual_risk() {
2209        // `!untested_regions.is_empty()` survived having its `!` deleted,
2210        // which inverts which proposals get the runtime-output risk line.
2211        let clean = verify(
2212            &prop(vec![state_write("a1", "x", Value::from(1))]),
2213            None,
2214            None,
2215            30,
2216        );
2217        assert!(clean.evidence.untested_regions.is_empty());
2218        assert!(
2219            !clean
2220                .evidence
2221                .residual_risks
2222                .iter()
2223                .any(|s| s.contains("runtime tool output")),
2224            "nothing untested means no runtime-output risk line"
2225        );
2226
2227        let dynamic = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
2228        assert!(
2229            !dynamic.evidence.untested_regions.is_empty(),
2230            "a tool call leaves runtime-decided regions"
2231        );
2232        assert!(
2233            dynamic
2234                .evidence
2235                .residual_risks
2236                .iter()
2237                .any(|s| s.contains("runtime tool output")),
2238            "untested regions must surface as a residual risk"
2239        );
2240    }
2241
2242    // NOTE on `issues.len() - issues_before_bounds` (the resource_bounds
2243    // delta): cargo-mutants flags `-` -> `+` there and it is an EQUIVALENT
2244    // MUTANT, not a test gap. Nothing pushes an issue before that snapshot, so
2245    // `issues_before_bounds` is always 0 and both programs are identical. It
2246    // becomes killable only if a check is ever added ahead of the bound.
2247
2248    // --- evidence bundle (§5.2.2) ---
2249
2250    #[test]
2251    fn evidence_declares_all_check_scopes() {
2252        let p = echo_call([("msg".to_string(), Value::from("hi"))].into());
2253        let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
2254        // Every check category is present with a non-empty scope.
2255        for want in [
2256            "resource_bounds",
2257            "loop_detection",
2258            "preconditions",
2259            "state_dependencies",
2260            "tool_existence",
2261            "param_schema",
2262            "write_conflicts",
2263        ] {
2264            let rec = r
2265                .evidence
2266                .checks
2267                .iter()
2268                .find(|c| c.name == want)
2269                .unwrap_or_else(|| panic!("missing check record {want}"));
2270            assert!(!rec.verifies.is_empty());
2271            assert!(!rec.cannot_verify.is_empty());
2272        }
2273        // With schemas supplied, both conditional checks ran.
2274        let by = |n: &str| r.evidence.checks.iter().find(|c| c.name == n).unwrap();
2275        assert!(by("param_schema").ran);
2276        assert!(by("tool_existence").ran);
2277    }
2278
2279    #[test]
2280    fn evidence_marks_param_schema_skipped_without_schemas() {
2281        // Tool call but no schemas: param_schema can't run; confidence
2282        // is docked and the blind spot is recorded as an assumption.
2283        let p = prop(vec![tool_call("a1", "search")]);
2284        let r = verify(&p, None, None, 30);
2285        let param = r
2286            .evidence
2287            .checks
2288            .iter()
2289            .find(|c| c.name == "param_schema")
2290            .unwrap();
2291        assert!(!param.ran);
2292        assert!(
2293            r.evidence.confidence < 1.0,
2294            "skipped check should dock coverage"
2295        );
2296        assert!(r
2297            .evidence
2298            .assumptions
2299            .iter()
2300            .any(|a| a.contains("well-formed")));
2301    }
2302
2303    #[test]
2304    fn evidence_full_confidence_for_pure_state_writes() {
2305        // No tool calls, fully-known state, no warnings: coverage is 1.0.
2306        let p = prop(vec![state_write("a1", "x", Value::from(1))]);
2307        let r = verify(&p, None, None, 30);
2308        assert!(r.valid);
2309        assert_eq!(r.evidence.confidence, 1.0);
2310        assert!(r.evidence.untested_regions.is_empty());
2311    }
2312
2313    #[test]
2314    fn evidence_conflicts_become_residual_risk() {
2315        // Two undeclared writers to the same key: warning, not error, so
2316        // it must surface as a residual risk rather than vanish.
2317        let p = prop(vec![
2318            state_write("a1", "k", Value::from(1)),
2319            state_write("a2", "k", Value::from(2)),
2320        ]);
2321        let r = verify(&p, None, None, 30);
2322        assert!(r.valid, "conflicts are warnings, not errors");
2323        assert!(!r.conflicts.is_empty());
2324        assert!(r
2325            .evidence
2326            .residual_risks
2327            .iter()
2328            .any(|s| s.contains("write conflict")));
2329        let wc = r
2330            .evidence
2331            .checks
2332            .iter()
2333            .find(|c| c.name == "write_conflicts")
2334            .unwrap();
2335        assert_eq!(wc.findings, r.conflicts.len());
2336    }
2337
2338    #[test]
2339    fn evidence_untested_includes_runtime_set_effect_keys() {
2340        // A tool whose declared effect writes `out`: the *key* exists
2341        // statically but its *value* is runtime-determined, so it is an
2342        // untested region — not just the tool's opaque return (neo M1).
2343        let mut a = tool_call("a1", "fetch");
2344        a.expected_effects = [("out".to_string(), Value::from("placeholder"))].into();
2345        let r = verify(
2346            &prop(vec![a]),
2347            None,
2348            Some(&["fetch".to_string()].into()),
2349            30,
2350        );
2351        assert!(r
2352            .evidence
2353            .untested_regions
2354            .iter()
2355            .any(|s| s.contains("state key 'out'")));
2356        assert!(r
2357            .evidence
2358            .untested_regions
2359            .iter()
2360            .any(|s| s.contains("runtime output of tool 'fetch'")));
2361    }
2362
2363    #[test]
2364    fn evidence_tool_existence_ran_consistent_with_findings() {
2365        // Malformed tool_call (no tool named) with no registry supplied:
2366        // the existence record must not claim ran:false while reporting a
2367        // finding (neo m1).
2368        let mut a = tool_call("a1", "x");
2369        a.tool = None;
2370        let r = verify(&prop(vec![a]), None, None, 30);
2371        assert!(!r.valid);
2372        let te = r
2373            .evidence
2374            .checks
2375            .iter()
2376            .find(|c| c.name == "tool_existence")
2377            .unwrap();
2378        assert!(te.findings >= 1);
2379        assert!(
2380            te.ran,
2381            "ran must be true whenever the check produced a finding"
2382        );
2383    }
2384
2385    // --- evidence tiers ---
2386
2387    /// The loop rule is the one heuristic in `verify`, and the tier is how a
2388    /// caller learns that without recognising the message. Pinning it here
2389    /// means a later refactor that mislabels it fails a test rather than
2390    /// quietly presenting a rule of thumb as an exact result.
2391    #[test]
2392    fn loop_detection_findings_are_heuristic_and_the_rest_are_not() {
2393        let p = prop(vec![
2394            tool_call("a1", "poll"),
2395            tool_call("a2", "poll"),
2396            tool_call("a3", "poll"),
2397            tool_call("a4", "ghost"),
2398        ]);
2399        let r = verify(&p, None, Some(&["poll".to_string()].into()), 30);
2400
2401        let heuristic = r.issues_with_tier(EvidenceTier::Heuristic);
2402        assert_eq!(
2403            heuristic.len(),
2404            1,
2405            "only the repeated-call finding is heuristic: {:?}",
2406            r.issues
2407        );
2408        assert!(heuristic[0]
2409            .message
2410            .contains("repeated identical tool call"));
2411
2412        // The unregistered tool is set membership — exactly decided.
2413        let decided = r.issues_with_tier(EvidenceTier::DecisionProcedure);
2414        assert!(decided
2415            .iter()
2416            .any(|i| i.message.contains("'ghost' is not registered")));
2417
2418        // Nothing in `verify` samples anything.
2419        assert!(r.issues_with_tier(EvidenceTier::Sampled).is_empty());
2420    }
2421
2422    /// Every issue's tier must agree with the tier on the check that reported
2423    /// it — the two are the same claim at different granularity, and a caller
2424    /// reading either must get the same answer.
2425    ///
2426    /// The correlation is done by counting, not by name: for each tier, the
2427    /// `findings` declared by the checks carrying that tier must equal the
2428    /// number of issues actually carrying it, and the totals must account for
2429    /// every issue. That catches the failure a per-name assertion misses — a
2430    /// future finding site emitting, say, a `Heuristic` issue from under the
2431    /// `write_conflicts` record, which would leave the two views of the same
2432    /// verdict disagreeing.
2433    #[test]
2434    fn check_records_and_issues_agree_on_tier() {
2435        let p = prop(vec![
2436            // Two identical calls to a registered tool: loop_detection
2437            // (Heuristic) reports one duplicate.
2438            tool_call("a1", "poll"),
2439            tool_call("a2", "poll"),
2440            // Unregistered tool reading a key nobody writes: tool_existence
2441            // and state_dependencies, one finding each (DecisionProcedure).
2442            {
2443                let mut a = tool_call("a3", "ghost");
2444                a.state_dependencies = vec!["missing".to_string()];
2445                a
2446            },
2447            // Two undeclared-order writers of one key: write_conflicts
2448            // (DecisionProcedure).
2449            state_write("a4", "x", Value::from(1)),
2450            state_write("a5", "x", Value::from(2)),
2451        ]);
2452        let r = verify(&p, None, Some(&["poll".to_string()].into()), 30);
2453
2454        // Listed explicitly rather than iterated: `EvidenceTier` has no `Ord`
2455        // and no variant count, so a new tier has to be added here by hand —
2456        // which is the intended nudge to decide what it means for this
2457        // invariant.
2458        for tier in [
2459            EvidenceTier::DecisionProcedure,
2460            EvidenceTier::Heuristic,
2461            EvidenceTier::Sampled,
2462        ] {
2463            let declared: usize = r
2464                .evidence
2465                .checks
2466                .iter()
2467                .filter(|c| c.tier == tier)
2468                .map(|c| c.findings)
2469                .sum();
2470            let actual = r.issues_with_tier(tier).len();
2471            assert_eq!(
2472                declared,
2473                actual,
2474                "checks at tier {} declare {declared} findings but {actual} issues carry it: {:?}",
2475                tier.as_str(),
2476                r.issues
2477            );
2478        }
2479
2480        // …and between them the checks account for every issue, so a mismatch
2481        // can't hide as an issue no check claims.
2482        let total: usize = r.evidence.checks.iter().map(|c| c.findings).sum();
2483        assert_eq!(total, r.issues.len(), "unaccounted issues: {:?}", r.issues);
2484
2485        // Non-vacuity: the fixture really does exercise both tiers that
2486        // `verify` can produce.
2487        assert_eq!(
2488            r.issues_with_tier(EvidenceTier::Heuristic).len(),
2489            1,
2490            "expected exactly the duplicate-call finding: {:?}",
2491            r.issues
2492        );
2493        assert!(
2494            r.issues_with_tier(EvidenceTier::DecisionProcedure).len() >= 3,
2495            "expected the unregistered tool, the missing dependency, and the \
2496             write conflict: {:?}",
2497            r.issues
2498        );
2499    }
2500
2501    /// The tier travels over the wire as a stable snake_case string; the FFI
2502    /// and JSON-RPC surfaces depend on these exact labels.
2503    #[test]
2504    fn tier_serializes_as_stable_snake_case() {
2505        let p = prop(vec![tool_call("a1", "ghost")]);
2506        let r = verify(&p, None, Some(&HashSet::new()), 30);
2507        let json = serde_json::to_value(&r.issues[0]).expect("issue serializes");
2508        assert_eq!(json["tier"], Value::from("decision_procedure"));
2509        assert_eq!(
2510            json["tier"],
2511            Value::from(r.issues[0].tier.as_str()),
2512            "as_str and the serde representation must not drift"
2513        );
2514    }
2515}