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