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