Skip to main content

car_ir/
actions.rs

1//! Core IR action types — the contract between models and the runtime.
2
3use crate::reversibility::{Compensation, Reversibility};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::HashMap;
8use uuid::Uuid;
9
10/// What kind of action this is.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ActionType {
14    ToolCall,
15    StateWrite,
16    StateRead,
17    Assertion,
18}
19
20/// What to do when an action fails.
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
22#[serde(rename_all = "snake_case")]
23pub enum FailureBehavior {
24    #[default]
25    Abort,
26    Retry,
27    Skip,
28}
29
30/// Classification reported by a tool when its dispatch fails.
31///
32/// This is execution evidence from the tool, not proposal policy. In
33/// particular, [`ToolFailureClassification::Terminal`] is distinct from
34/// [`FailureBehavior::Abort`]: it says retrying cannot recover the failure and
35/// the proposal must abort regardless of the behavior its author selected.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
37#[serde(rename_all = "snake_case")]
38pub enum ToolFailureClassification {
39    /// An ordinary failure governed by the action's [`FailureBehavior`].
40    #[default]
41    Ordinary,
42    /// A failure that must not be retried and must abort the proposal.
43    Terminal,
44}
45
46/// A typed tool-dispatch failure.
47///
48/// Legacy executors returning `String` errors convert to [`Self::ordinary`],
49/// so terminality is strictly opt-in and is never inferred from error text.
50///
51/// [`Self::is_unhandled`] is the one signal that lets the runtime hand a call
52/// to its built-in tools instead of returning this failure. It is a typed
53/// flag, not text: only [`Self::unhandled`] and the legacy `From<String>`
54/// conversion set it. The flag is never serialized or deserialized, so a
55/// failure read off a wire cannot claim it (Parslee-ai/car#3104).
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct ToolFailure {
58    pub message: String,
59    #[serde(default)]
60    pub classification: ToolFailureClassification,
61    #[serde(skip)]
62    unhandled: bool,
63}
64
65/// The message prefix a legacy `String`-returning executor uses to say "I do
66/// not handle this tool". Only the `From<String>` conversion reads it.
67const LEGACY_UNHANDLED_PREFIX: &str = "unknown tool";
68
69impl ToolFailure {
70    /// An ordinary failure. It is never [unhandled](Self::is_unhandled),
71    /// whatever the message says, so relaying text from outside the process
72    /// through this constructor cannot route the call anywhere else.
73    pub fn ordinary(message: impl Into<String>) -> Self {
74        Self {
75            message: message.into(),
76            classification: ToolFailureClassification::Ordinary,
77            unhandled: false,
78        }
79    }
80
81    pub fn terminal(message: impl Into<String>) -> Self {
82        Self {
83            message: message.into(),
84            classification: ToolFailureClassification::Terminal,
85            unhandled: false,
86        }
87    }
88
89    /// The executor does not handle this tool, so the runtime may run its
90    /// built-in implementation instead. Return it only from code the runtime
91    /// trusts to make that choice. Never build it from text another process
92    /// sent: that is how a client reached the daemon's host file tools.
93    pub fn unhandled(message: impl Into<String>) -> Self {
94        Self {
95            message: message.into(),
96            classification: ToolFailureClassification::Ordinary,
97            unhandled: true,
98        }
99    }
100
101    /// Whether the tool declared this failure terminal.
102    ///
103    /// Match this enum exhaustively: adding a classification must force every
104    /// execution and binding surface to choose its behavior explicitly.
105    pub const fn is_terminal(&self) -> bool {
106        match self.classification {
107            ToolFailureClassification::Ordinary => false,
108            ToolFailureClassification::Terminal => true,
109        }
110    }
111
112    /// Whether the executor said it does not handle the tool. See
113    /// [`Self::unhandled`]. A terminal failure is never unhandled.
114    pub const fn is_unhandled(&self) -> bool {
115        self.unhandled && !self.is_terminal()
116    }
117}
118
119/// The legacy in-process contract: a `String`-returning executor that answers
120/// `"unknown tool…"` is saying it does not handle the tool. This conversion is
121/// for executor code linked into the process. A transport that relays another
122/// process's error text must build the failure with [`ToolFailure::ordinary`]
123/// instead, and must implement `execute_classified` so its failures never pass
124/// through here.
125impl From<String> for ToolFailure {
126    fn from(message: String) -> Self {
127        if message.starts_with(LEGACY_UNHANDLED_PREFIX) {
128            Self::unhandled(message)
129        } else {
130            Self::ordinary(message)
131        }
132    }
133}
134
135impl From<&str> for ToolFailure {
136    fn from(message: &str) -> Self {
137        Self::from(message.to_string())
138    }
139}
140
141impl std::fmt::Display for ToolFailure {
142    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        formatter.write_str(&self.message)
144    }
145}
146
147impl std::error::Error for ToolFailure {}
148
149/// Lifecycle status of an action.
150#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
151#[serde(rename_all = "snake_case")]
152pub enum ActionStatus {
153    Proposed,
154    Validated,
155    Rejected,
156    Executing,
157    Succeeded,
158    Failed,
159    Skipped,
160}
161
162/// A condition that must hold before an action can execute.
163///
164/// Valid operators: `eq`, `neq`, `exists`, `not_exists`, `gt`, `lt`, `gte`, `lte`, `contains`.
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct Precondition {
167    pub key: String,
168    /// Comparison operator. One of: eq, neq, exists, not_exists, gt, lt, gte, lte, contains.
169    #[serde(default = "default_operator")]
170    pub operator: String,
171    #[serde(default)]
172    pub value: Value,
173    #[serde(default)]
174    pub description: String,
175}
176
177fn default_operator() -> String {
178    "eq".to_string()
179}
180
181/// Generate a short unique ID (12 hex chars from UUIDv4).
182fn short_id() -> String {
183    Uuid::new_v4().simple().to_string()[..12].to_string()
184}
185
186/// A single unit of agent intent compiled into IR.
187///
188/// This is the core primitive. Models produce these (directly or via compilation),
189/// and the runtime validates and executes them.
190///
191/// # Construction
192///
193/// `#[non_exhaustive]`, so from outside `car-ir` this must be built with
194/// [`Action::new`] (or [`Action::tool_call`] / [`Action::state_write`] /
195/// [`Action::state_read`]) and then mutated — struct-literal syntax, including
196/// functional update (`..other`), is rejected in other crates.
197///
198/// That is the point. `car-ir` is published on crates.io and re-exported by
199/// `car-runtime`, so while the *wire* format tolerates new fields (they carry
200/// `#[serde(default)]`, and a proposal authored before a field existed still
201/// deserializes), the *Rust API* previously did not: every added field was a
202/// source-breaking `error[E0063]` for every out-of-tree consumer building an
203/// `Action` literal, and cost ~20 files of churn in-tree the last time it
204/// happened. Adding a field is now backwards-compatible in both directions.
205/// Parslee-ai/car#855.
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207#[non_exhaustive]
208pub struct Action {
209    #[serde(default = "short_id")]
210    pub id: String,
211
212    #[serde(rename = "type")]
213    pub action_type: ActionType,
214
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub tool: Option<String>,
217
218    #[serde(default)]
219    pub parameters: HashMap<String, Value>,
220
221    #[serde(default)]
222    pub preconditions: Vec<Precondition>,
223
224    #[serde(default)]
225    pub expected_effects: HashMap<String, Value>,
226
227    #[serde(default)]
228    pub state_dependencies: Vec<String>,
229
230    /// Explicit transactional **read set** — the state keys this action
231    /// reads (survey §5.2.4: "each action should declare its read set,
232    /// write set, assumptions, version dependencies"). When empty, the
233    /// effective read set falls back to [`Action::effective_read_set`]
234    /// (derived from `state_dependencies` + assumption keys), so existing
235    /// proposals keep working. Used to detect read-write conflicts and
236    /// stale reads across concurrent actions/agents.
237    #[serde(default)]
238    pub read_set: Vec<String>,
239
240    /// Explicit transactional **write set** — the state keys this action
241    /// writes. When empty, falls back to [`Action::effective_write_set`]
242    /// (derived from `expected_effects` + a `StateWrite`'s `key` param).
243    #[serde(default)]
244    pub write_set: Vec<String>,
245
246    /// Assumptions this action makes about shared state it did not itself
247    /// produce — the basis for belief-divergence / stale-read detection in
248    /// a multi-agent transaction (§5.2.4). An assumption can pin an
249    /// expected value, a version the action read at, or both.
250    #[serde(default)]
251    pub assumptions: Vec<StateAssumption>,
252
253    /// How a `ToolCall` runs: `one_shot` (default — dispatch awaits the
254    /// result inline), or a detached mode (`streaming` / `long_running`)
255    /// where dispatch *starts* the tool, returns a [`crate::ToolHandle`]
256    /// as the action's output, and the DAG proceeds without blocking on
257    /// completion. Chunks/status are consumed via the handle (C2).
258    /// Ignored for non-ToolCall actions.
259    #[serde(default)]
260    pub invocation_mode: crate::tool_stream::ToolInvocationMode,
261
262    /// The rollback contract for this action's effects — **can this be
263    /// undone?** Orthogonal to `car_policy::PermissionTier`, which answers the
264    /// separate question of *who may authorize this*; the two were conflated
265    /// in a single ladder until this field existed. See [`Reversibility`] for
266    /// the axis, and for why the `#[serde(default)]` is the conservative
267    /// [`Reversibility::Irreversible`] rather than the quiet-failing
268    /// [`Reversibility::Reversible`].
269    ///
270    /// Nothing in the runtime gates on this yet — it is typed and audited, not
271    /// enforced (see the [`reversibility`](crate::reversibility) module docs).
272    #[serde(default)]
273    pub reversibility: Reversibility,
274
275    /// How to undo this action once it has already run. Meaningful only when
276    /// `reversibility` is [`Reversibility::Compensable`]: a reversible action
277    /// is undone by restoring its scope, and an irreversible one cannot be
278    /// undone at all.
279    ///
280    /// The pairing is not enforced by the type system — `Compensable` is a
281    /// bare variant, not `Compensable { compensation }`, so
282    /// `Compensable` + `None` is representable. That is a deliberate trade,
283    /// argued in [`Compensation`]'s docs: making the state unrepresentable
284    /// costs `Reversibility` its plain C-like shape, which every FFI surface
285    /// mirrors as a string enum. [`Action::missing_required_compensation`] is
286    /// the check that stands in for the type.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub compensation: Option<Compensation>,
289
290    #[serde(default)]
291    pub idempotent: bool,
292
293    #[serde(default = "default_max_retries")]
294    pub max_retries: u32,
295
296    #[serde(default)]
297    pub failure_behavior: FailureBehavior,
298
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub timeout_ms: Option<u64>,
301
302    #[serde(default)]
303    pub metadata: HashMap<String, Value>,
304}
305
306fn default_max_retries() -> u32 {
307    3
308}
309
310/// An assumption an action makes about shared state it did not produce —
311/// the unit of belief-divergence detection in a multi-agent transaction
312/// (survey "Code as Agent Harness" §5.2.4). Synchronizing artifacts is not
313/// enough; agents must also agree on *assumptions*. An action that planned
314/// against `config@v3` should be flagged when `config` has since advanced
315/// to `v4`, even if no file diff conflicts.
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317pub struct StateAssumption {
318    /// The state key the assumption is about.
319    pub key: String,
320    /// The value the action expects `key` to hold, if it pinned one.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub expected_value: Option<Value>,
323    /// The version of `key` the action read when it planned, if known.
324    /// Compared against the current version to detect stale reads.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub read_version: Option<u64>,
327}
328
329impl Action {
330    /// A new action of `action_type`, with every optional field at its
331    /// default and a freshly generated [`Action::id`].
332    ///
333    /// **This is the only way to build an `Action` from outside `car-ir`.**
334    /// The struct is `#[non_exhaustive]` (see its docs), so struct-literal
335    /// syntax — including functional update, `..other` — is rejected in other
336    /// crates. Set the fields you need afterwards; they are all public:
337    ///
338    /// ```
339    /// # use car_ir::{Action, ActionType};
340    /// let mut a = Action::new(ActionType::ToolCall);
341    /// a.tool = Some("deploy".into());
342    /// a.idempotent = true;
343    /// ```
344    ///
345    /// There is deliberately **no** `Default for Action`. An action's type
346    /// decides what the rest of it means, and defaulting it would let
347    /// `Action::default()` produce a `ToolCall` naming no tool — a value that
348    /// is invalid the moment it exists, and that the validator would have to
349    /// reject. Requiring the type at construction makes that unrepresentable.
350    pub fn new(action_type: ActionType) -> Self {
351        Self {
352            id: short_id(),
353            action_type,
354            tool: None,
355            parameters: HashMap::new(),
356            preconditions: Vec::new(),
357            expected_effects: HashMap::new(),
358            state_dependencies: Vec::new(),
359            read_set: Vec::new(),
360            write_set: Vec::new(),
361            assumptions: Vec::new(),
362            invocation_mode: crate::tool_stream::ToolInvocationMode::default(),
363            reversibility: Reversibility::default(),
364            compensation: None,
365            idempotent: false,
366            max_retries: default_max_retries(),
367            failure_behavior: FailureBehavior::default(),
368            timeout_ms: None,
369            metadata: HashMap::new(),
370        }
371    }
372
373    /// An [`ActionType::ToolCall`] naming `tool`.
374    pub fn tool_call(tool: impl Into<String>) -> Self {
375        let mut a = Self::new(ActionType::ToolCall);
376        a.tool = Some(tool.into());
377        a
378    }
379
380    /// An [`ActionType::StateWrite`] of `value` to `key`, carrying both the
381    /// `key`/`value` parameters the executor reads and the `expected_effects`
382    /// entry the static verifier reads.
383    pub fn state_write(key: impl Into<String>, value: Value) -> Self {
384        let key = key.into();
385        let mut a = Self::new(ActionType::StateWrite);
386        a.parameters
387            .insert("key".to_string(), Value::String(key.clone()));
388        a.parameters.insert("value".to_string(), value.clone());
389        a.expected_effects.insert(key, value);
390        a
391    }
392
393    /// An [`ActionType::StateRead`] of `key`.
394    pub fn state_read(key: impl Into<String>) -> Self {
395        let mut a = Self::new(ActionType::StateRead);
396        a.parameters
397            .insert("key".to_string(), Value::String(key.into()));
398        a
399    }
400
401    /// Replace the generated [`Action::id`]. Chainable, so a caller that cares
402    /// about the id can still write one expression.
403    pub fn with_id(mut self, id: impl Into<String>) -> Self {
404        self.id = id.into();
405        self
406    }
407
408    /// Set one parameter. Chainable.
409    pub fn with_param(mut self, key: impl Into<String>, value: Value) -> Self {
410        self.parameters.insert(key.into(), value);
411        self
412    }
413
414    /// The keys this action writes — the **union** of the explicit
415    /// `write_set` with the keys derived from `expected_effects` and a
416    /// `StateWrite` action's `key` parameter. Union, not replacement: a
417    /// partial `write_set` must never *narrow* the real write footprint, or
418    /// a conflict on an unlisted side-effect key would go undetected (a
419    /// false negative in the soundness-critical path). Proposals authored
420    /// before the transactional fields existed still participate via the
421    /// derived keys.
422    pub fn effective_write_set(&self) -> Vec<String> {
423        let mut keys: Vec<String> = self.write_set.clone();
424        let push = |k: String, keys: &mut Vec<String>| {
425            if !keys.contains(&k) {
426                keys.push(k);
427            }
428        };
429        for k in self.expected_effects.keys() {
430            push(k.clone(), &mut keys);
431        }
432        if self.action_type == ActionType::StateWrite {
433            if let Some(k) = self.parameters.get("key").and_then(|v| v.as_str()) {
434                push(k.to_string(), &mut keys);
435            }
436        }
437        keys
438    }
439
440    /// The keys this action reads — the **union** of the explicit `read_set`
441    /// with `state_dependencies`, assumption keys, and a `StateRead`
442    /// action's `key` parameter. Union for the same soundness reason as
443    /// [`Action::effective_write_set`].
444    pub fn effective_read_set(&self) -> Vec<String> {
445        let mut keys: Vec<String> = self.read_set.clone();
446        let push = |k: String, keys: &mut Vec<String>| {
447            if !keys.contains(&k) {
448                keys.push(k);
449            }
450        };
451        for k in &self.state_dependencies {
452            push(k.clone(), &mut keys);
453        }
454        for a in &self.assumptions {
455            push(a.key.clone(), &mut keys);
456        }
457        if self.action_type == ActionType::StateRead {
458            if let Some(k) = self.parameters.get("key").and_then(|v| v.as_str()) {
459                push(k.to_string(), &mut keys);
460            }
461        }
462        keys
463    }
464
465    /// Whether this action claims [`Reversibility::Compensable`] but declares
466    /// no [`Action::compensation`] — the one incoherent combination the enum
467    /// could not exclude by construction (see [`Compensation`] for why the
468    /// compensation is a sibling field rather than a variant payload).
469    ///
470    /// It is a *check*, not a guarantee: it catches the missing declaration,
471    /// not a declaration that names a tool which cannot actually reverse the
472    /// effect. Nothing here can establish that a compensating call is a true
473    /// inverse — that remains the author's claim, exactly as
474    /// `expected_effects` is.
475    pub fn missing_required_compensation(&self) -> bool {
476        self.reversibility.requires_compensation() && self.compensation.is_none()
477    }
478}
479
480/// A batch of actions proposed by a model for runtime validation and execution.
481#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
482pub struct ActionProposal {
483    #[serde(default = "short_id")]
484    pub id: String,
485
486    #[serde(default = "default_source")]
487    pub source: String,
488
489    pub actions: Vec<Action>,
490
491    #[serde(default = "Utc::now")]
492    pub timestamp: DateTime<Utc>,
493
494    #[serde(default)]
495    pub context: HashMap<String, Value>,
496}
497
498fn default_source() -> String {
499    "unknown".to_string()
500}
501
502impl ActionProposal {
503    /// The rollback contract of the batch as a whole: the **least** reversible
504    /// contract any of its actions carries, via the severity ordering on
505    /// [`Reversibility`]. A plan is only as recoverable as its least
506    /// recoverable step — one irreversible action in an otherwise reversible
507    /// batch makes the batch irreversible, because partial execution is a real
508    /// outcome (it is exactly the failure mode `StaticVerificationGate` exists
509    /// to prevent on multi-action proposals).
510    ///
511    /// An empty proposal is [`Reversibility::Reversible`], not the
512    /// `Irreversible` default: the default answers "the author did not say",
513    /// which warrants assuming the worst, whereas an empty batch has no
514    /// effects at all and there is nothing to be pessimistic about.
515    pub fn rollback_contract(&self) -> Reversibility {
516        self.actions
517            .iter()
518            .map(|a| a.reversibility)
519            .max()
520            .unwrap_or(Reversibility::Reversible)
521    }
522}
523
524/// A state mutation recorded in [`ActionResult::state_changes`].
525///
526/// The tagged envelope distinguishes deleting a key from setting it to JSON
527/// `null`. `ActionResult` keeps its existing `HashMap<String, Value>` field so
528/// the public Rust and serialized wire shapes remain backward compatible;
529/// callers use this type at the encode/decode boundary instead of rebuilding
530/// or hand-parsing the envelope.
531#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
532#[serde(tag = "op", rename_all = "snake_case")]
533pub enum StateMutation {
534    Set {
535        /// Missing values from older hand-written envelopes retain the runtime's
536        /// historical set-to-null interpretation.
537        #[serde(default)]
538        value: Value,
539    },
540    Delete,
541}
542
543impl StateMutation {
544    /// Convert an observed post-execution value into its typed mutation.
545    pub fn from_new_value(new_value: Option<Value>) -> Self {
546        match new_value {
547            Some(value) => Self::Set { value },
548            None => Self::Delete,
549        }
550    }
551
552    /// Encode this mutation into the stable `ActionResult.state_changes` value.
553    pub fn encode(self) -> Value {
554        serde_json::to_value(self)
555            .expect("StateMutation contains only infallibly serializable JSON values")
556    }
557
558    /// Decode one `ActionResult.state_changes` value as a tagged mutation.
559    pub fn decode(value: &Value) -> Result<Self, serde_json::Error> {
560        serde_json::from_value(value.clone())
561    }
562}
563
564/// The outcome of executing a single action.
565#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
566pub struct ActionResult {
567    pub action_id: String,
568    pub status: ActionStatus,
569
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub output: Option<Value>,
572
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub error: Option<String>,
575
576    /// True only when the executing tool explicitly returned a typed terminal
577    /// failure. Missing fields and legacy string errors remain non-terminal.
578    #[serde(default, skip_serializing_if = "is_false")]
579    pub terminal: bool,
580
581    /// Whether this action executed successfully but its enclosing proposal's
582    /// state transaction was subsequently rolled back. The action's
583    /// [`ActionResult::status`] remains truthful about execution; callers use
584    /// this independent marker to tell whether its state effects committed.
585    /// External effects may remain because CAR can restore only its own state.
586    ///
587    /// Defaults to `false` for results written by older CAR versions and is
588    /// omitted from the serialized form when false.
589    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
590    pub rolled_back: bool,
591
592    #[serde(default)]
593    pub state_changes: HashMap<String, Value>,
594
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub duration_ms: Option<f64>,
597
598    #[serde(default = "Utc::now")]
599    pub timestamp: DateTime<Utc>,
600}
601
602fn is_false(value: &bool) -> bool {
603    !value
604}
605
606/// Rate limit configuration for a tool.
607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
608pub struct ToolRateLimit {
609    pub max_calls: u32,
610    pub interval_secs: f64,
611}
612
613/// Stable, detail-free origin of a registered tool.
614///
615/// Runtime registries may retain richer source metadata (for example, the MCP
616/// server name), but this enum is the public IR/wire classification used by
617/// tool catalogs and execution events.
618#[derive(
619    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema,
620)]
621#[serde(rename_all = "snake_case")]
622pub enum ToolSourceKind {
623    Builtin,
624    #[default]
625    UserDefined,
626    Subprocess,
627    Mcp,
628}
629
630impl ToolSourceKind {
631    pub const fn as_str(self) -> &'static str {
632        match self {
633            Self::Builtin => "builtin",
634            Self::UserDefined => "user_defined",
635            Self::Subprocess => "subprocess",
636            Self::Mcp => "mcp",
637        }
638    }
639}
640
641/// Rich schema describing a tool's interface and runtime configuration.
642///
643/// Carries everything the runtime needs: parameter validation via JSON Schema,
644/// idempotency hints, caching policy, rate limiting, and origin attribution.
645#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
646pub struct ToolSchema {
647    pub name: String,
648    /// Origin classification surfaced by `tools.list` and execution events.
649    /// Older serialized schemas default to caller-defined tools.
650    #[serde(default)]
651    pub source: ToolSourceKind,
652    #[serde(default)]
653    pub description: String,
654    /// JSON Schema for parameters (e.g. `{"type": "object", "properties": {...}, "required": [...]}`)
655    #[serde(default = "default_parameters_schema")]
656    pub parameters: Value,
657    /// JSON Schema for return value (optional)
658    #[serde(default, skip_serializing_if = "Option::is_none")]
659    pub returns: Option<Value>,
660    /// Whether this tool is idempotent (safe to cache/retry)
661    #[serde(default)]
662    pub idempotent: bool,
663    /// If set, results are cached with this TTL in seconds
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub cache_ttl_secs: Option<u64>,
666    /// If set, rate limited to this many calls per interval
667    #[serde(default, skip_serializing_if = "Option::is_none")]
668    pub rate_limit: Option<ToolRateLimit>,
669}
670
671fn default_parameters_schema() -> Value {
672    Value::Object(Default::default())
673}
674
675/// Cost summary for a proposal execution.
676#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
677pub struct CostSummary {
678    pub tool_calls: u32,
679    /// Actions that actually ran — `Succeeded` plus `Failed`. A failed action
680    /// executed and consumed real work (the tool ran and errored), so it counts;
681    /// a *rejected* one never started and is counted in
682    /// [`Self::actions_rejected`] instead. Rejections used to land here, which
683    /// reported "2 actions executed" for a proposal where nothing ran at all
684    /// (Parslee-ai/car#624).
685    pub actions_executed: u32,
686    /// Actions blocked before execution — by the validator (unknown tool,
687    /// unsatisfied dependency) or by policy. Nothing ran, so these cost nothing
688    /// beyond the check itself.
689    ///
690    /// `#[serde(default)]` so a payload written by an older CAR still
691    /// deserializes; it simply reports 0.
692    #[serde(default)]
693    pub actions_rejected: u32,
694    pub actions_skipped: u32,
695    pub total_duration_ms: f64,
696    pub retries: u32,
697}
698
699/// Soft optimization targets for proposal cost.
700///
701/// Unlike `CostBudget` (hard limits that reject proposals), `CostTarget` is used
702/// by the planner to score proposals on a cost-vs-success curve. The `cost_weight`
703/// controls how aggressively the planner favors cheaper proposals.
704///
705/// score = success_likelihood * (1 - cost_weight) + cost_efficiency * cost_weight
706#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct CostTarget {
708    /// Target number of tool calls (proposals below this get full cost score).
709    pub target_tool_calls: u32,
710    /// Target total duration in milliseconds.
711    pub target_duration_ms: f64,
712    /// Target number of actions.
713    pub target_actions: u32,
714    /// Weight for cost in scoring (0.0–1.0). 0 = ignore cost, 1 = only cost.
715    pub cost_weight: f64,
716}
717
718impl Default for CostTarget {
719    fn default() -> Self {
720        Self {
721            target_tool_calls: 5,
722            target_duration_ms: 5000.0,
723            target_actions: 10,
724            cost_weight: 0.2,
725        }
726    }
727}
728
729/// Admission/execution disposition for one proposal generation.
730#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(rename_all = "snake_case")]
732pub enum ProposalLineageStatus {
733    Accepted,
734    Rejected,
735}
736
737/// Immutable ordered identity receipt for one original or replanned proposal.
738/// `proposal_digest` is absent only when the proposal itself could not be
739/// represented as repository JCS/I-JSON, so no truthful digest exists.
740#[derive(Debug, Clone, PartialEq, Eq)]
741pub struct ProposalLineageEntry {
742    pub generation: u32,
743    pub proposal_id: String,
744    pub proposal_digest: Option<String>,
745    pub status: ProposalLineageStatus,
746    pub rejection_reason: Option<String>,
747}
748
749impl ProposalLineageEntry {
750    fn validate(&self) -> Result<(), String> {
751        let digest_is_lowercase_sha256 = |digest: &str| {
752            digest.len() == 64
753                && digest
754                    .bytes()
755                    .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
756        };
757
758        match self.status {
759            ProposalLineageStatus::Accepted => {
760                let digest = self.proposal_digest.as_deref().ok_or_else(|| {
761                    "accepted proposal lineage requires a lowercase JCS SHA-256 digest".to_string()
762                })?;
763                if !digest_is_lowercase_sha256(digest) {
764                    return Err(
765                        "accepted proposal lineage requires a lowercase JCS SHA-256 digest"
766                            .to_string(),
767                    );
768                }
769                if self.rejection_reason.is_some() {
770                    return Err(
771                        "accepted proposal lineage cannot include a rejection reason".to_string(),
772                    );
773                }
774            }
775            ProposalLineageStatus::Rejected => {
776                if let Some(digest) = self.proposal_digest.as_deref() {
777                    if !digest_is_lowercase_sha256(digest) {
778                        return Err(
779                            "rejected proposal lineage digest must be lowercase JCS SHA-256"
780                                .to_string(),
781                        );
782                    }
783                }
784                let reason = self
785                    .rejection_reason
786                    .as_deref()
787                    .filter(|reason| !reason.trim().is_empty())
788                    .ok_or_else(|| {
789                        "rejected proposal lineage requires an exact rejection reason".to_string()
790                    })?;
791                if self.proposal_digest.is_none()
792                    && !reason.starts_with("RFC 8785 canonicalization failed")
793                    && !reason.starts_with("proposal serialization failed")
794                {
795                    return Err(
796                        "undigested rejected proposal lineage must identify a JCS/I-JSON failure"
797                            .to_string(),
798                    );
799                }
800            }
801        }
802        Ok(())
803    }
804}
805
806impl Serialize for ProposalLineageEntry {
807    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
808    where
809        S: serde::Serializer,
810    {
811        use serde::ser::{Error as _, SerializeStruct};
812        self.validate().map_err(S::Error::custom)?;
813        let mut state = serializer.serialize_struct("ProposalLineageEntry", 5)?;
814        state.serialize_field("generation", &self.generation)?;
815        state.serialize_field("proposal_id", &self.proposal_id)?;
816        if let Some(digest) = &self.proposal_digest {
817            state.serialize_field("proposal_digest", digest)?;
818        }
819        state.serialize_field("status", &self.status)?;
820        if let Some(reason) = &self.rejection_reason {
821            state.serialize_field("rejection_reason", reason)?;
822        }
823        state.end()
824    }
825}
826
827impl<'de> Deserialize<'de> for ProposalLineageEntry {
828    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
829    where
830        D: serde::Deserializer<'de>,
831    {
832        #[derive(Deserialize)]
833        struct Wire {
834            generation: u32,
835            proposal_id: String,
836            #[serde(default)]
837            proposal_digest: Option<String>,
838            status: ProposalLineageStatus,
839            #[serde(default)]
840            rejection_reason: Option<String>,
841        }
842
843        let wire = Wire::deserialize(deserializer)?;
844        let entry = Self {
845            generation: wire.generation,
846            proposal_id: wire.proposal_id,
847            proposal_digest: wire.proposal_digest,
848            status: wire.status,
849            rejection_reason: wire.rejection_reason,
850        };
851        entry.validate().map_err(serde::de::Error::custom)?;
852        Ok(entry)
853    }
854}
855
856/// Exact accepted proposal input retained independently of the mutable event
857/// log. Entries are returned in generation order so lifecycle persistence can
858/// authenticate every proposal the runtime admitted, including replans whose
859/// event rows have already been evicted by retention.
860#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
861pub struct AcceptedProposalPreimage {
862    pub generation: u32,
863    pub proposal_digest: String,
864    pub proposal: ActionProposal,
865}
866
867/// The complete result of processing a proposal through the runtime.
868#[derive(Debug, Clone, PartialEq, Serialize)]
869pub struct ProposalResult {
870    /// The final proposal generation that actually executed. When no replan
871    /// was accepted this remains the original proposal id.
872    pub proposal_id: String,
873
874    /// The caller-submitted proposal id, unchanged across every replan.
875    pub original_proposal_id: String,
876
877    /// Exact normal-serde preimage of the final proposal generation selected
878    /// for execution. Legacy payloads may omit this field; every active
879    /// runtime execution populates it so consumers can authenticate the final
880    /// id, actions, parameters, dependencies, and declared effects against the
881    /// accepted lineage digest.
882    #[serde(default, skip_serializing_if = "Option::is_none")]
883    pub final_proposal: Option<ActionProposal>,
884
885    /// Generation-ordered proposal identities and admission dispositions.
886    #[serde(default, skip_serializing_if = "Vec::is_empty")]
887    pub replan_lineage: Vec<ProposalLineageEntry>,
888
889    /// Generation-ordered exact preimages for accepted lineage entries. This
890    /// is result-owned evidence, not an EventLog projection, so configured log
891    /// retention cannot remove it before a caller persists the run.
892    #[serde(default, skip_serializing_if = "Vec::is_empty")]
893    pub accepted_proposal_preimages: Vec<AcceptedProposalPreimage>,
894
895    #[serde(default)]
896    pub results: Vec<ActionResult>,
897
898    #[serde(default)]
899    pub cost: CostSummary,
900}
901
902impl<'de> Deserialize<'de> for ProposalResult {
903    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
904    where
905        D: serde::Deserializer<'de>,
906    {
907        #[derive(Deserialize)]
908        struct Wire {
909            proposal_id: String,
910            #[serde(default)]
911            original_proposal_id: Option<String>,
912            #[serde(default)]
913            final_proposal: Option<ActionProposal>,
914            #[serde(default)]
915            replan_lineage: Vec<ProposalLineageEntry>,
916            #[serde(default)]
917            accepted_proposal_preimages: Vec<AcceptedProposalPreimage>,
918            #[serde(default)]
919            results: Vec<ActionResult>,
920            #[serde(default)]
921            cost: CostSummary,
922        }
923
924        let wire = Wire::deserialize(deserializer)?;
925        let original_proposal_id = wire
926            .original_proposal_id
927            .unwrap_or_else(|| wire.proposal_id.clone());
928        Ok(Self {
929            proposal_id: wire.proposal_id,
930            original_proposal_id,
931            final_proposal: wire.final_proposal,
932            replan_lineage: wire.replan_lineage,
933            accepted_proposal_preimages: wire.accepted_proposal_preimages,
934            results: wire.results,
935            cost: wire.cost,
936        })
937    }
938}
939
940impl ProposalResult {
941    pub fn new(
942        proposal_id: impl Into<String>,
943        results: Vec<ActionResult>,
944        cost: CostSummary,
945    ) -> Self {
946        let proposal_id = proposal_id.into();
947        Self {
948            original_proposal_id: proposal_id.clone(),
949            proposal_id,
950            final_proposal: None,
951            replan_lineage: Vec::new(),
952            accepted_proposal_preimages: Vec::new(),
953            results,
954            cost,
955        }
956    }
957
958    /// Construct an active runtime result bound to its exact final proposal
959    /// preimage. Use [`Self::new`] only for legacy or synthetic results that
960    /// did not pass through proposal execution.
961    pub fn for_proposal(
962        proposal: &ActionProposal,
963        results: Vec<ActionResult>,
964        cost: CostSummary,
965    ) -> Self {
966        Self {
967            proposal_id: proposal.id.clone(),
968            original_proposal_id: proposal.id.clone(),
969            final_proposal: Some(proposal.clone()),
970            replan_lineage: Vec::new(),
971            accepted_proposal_preimages: Vec::new(),
972            results,
973            cost,
974        }
975    }
976
977    pub fn all_succeeded(&self) -> bool {
978        self.results
979            .iter()
980            .all(|r| r.status == ActionStatus::Succeeded)
981    }
982
983    pub fn summary(&self) -> HashMap<ActionStatus, usize> {
984        let mut counts = HashMap::new();
985        for r in &self.results {
986            *counts.entry(r.status.clone()).or_insert(0) += 1;
987        }
988        counts
989    }
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995    use pretty_assertions::assert_eq;
996
997    /// The built-in fall-through signal comes from the constructor, never from
998    /// text a peer chose (Parslee-ai/car#3104).
999    #[test]
1000    fn unhandled_is_typed_and_never_crosses_a_wire() {
1001        let forged = "unknown tool: read_file";
1002        assert!(!ToolFailure::ordinary(forged).is_unhandled());
1003        assert!(!ToolFailure::terminal(forged).is_unhandled());
1004        assert!(ToolFailure::unhandled("x").is_unhandled());
1005        // Legacy in-process String executors keep their sentinel.
1006        assert!(ToolFailure::from(forged.to_string()).is_unhandled());
1007        assert!(ToolFailure::from(forged).is_unhandled());
1008        assert!(!ToolFailure::from("boom".to_string()).is_unhandled());
1009
1010        let wire = serde_json::to_value(ToolFailure::unhandled(forged)).unwrap();
1011        assert_eq!(
1012            wire,
1013            serde_json::json!({"message": forged, "classification": "ordinary"})
1014        );
1015        let read: ToolFailure = serde_json::from_value(serde_json::json!({
1016            "message": forged, "classification": "ordinary", "unhandled": true
1017        }))
1018        .unwrap();
1019        assert!(!read.is_unhandled());
1020    }
1021
1022    #[test]
1023    fn constructors_fill_defaults_and_leave_the_wire_unchanged() {
1024        // The migration's contract (Parslee-ai/car#855): `Action::new` must
1025        // produce exactly what a full struct literal produced, so converting
1026        // ~100 call sites off literals changed no behaviour. If a future field
1027        // is added to the struct but not to `new`, this stops compiling —
1028        // which is the point.
1029        let a = Action::new(ActionType::ToolCall);
1030        assert_eq!(a.action_type, ActionType::ToolCall);
1031        assert!(a.tool.is_none());
1032        assert!(a.parameters.is_empty());
1033        assert!(a.preconditions.is_empty());
1034        assert!(a.expected_effects.is_empty());
1035        assert!(a.state_dependencies.is_empty());
1036        assert!(a.read_set.is_empty());
1037        assert!(a.write_set.is_empty());
1038        assert!(a.assumptions.is_empty());
1039        assert_eq!(a.reversibility, Reversibility::default());
1040        assert!(a.compensation.is_none());
1041        assert!(!a.idempotent);
1042        assert_eq!(a.max_retries, default_max_retries());
1043        assert_eq!(a.failure_behavior, FailureBehavior::Abort);
1044        assert!(a.timeout_ms.is_none());
1045        assert!(a.metadata.is_empty());
1046        assert_eq!(a.id.len(), 12, "id is a generated short id");
1047
1048        // The typed shortcuts agree with the long form.
1049        assert_eq!(Action::tool_call("deploy").tool.as_deref(), Some("deploy"));
1050
1051        let sw = Action::state_write("k", Value::from(1));
1052        assert_eq!(sw.action_type, ActionType::StateWrite);
1053        assert_eq!(sw.parameters["key"], Value::from("k"));
1054        assert_eq!(sw.parameters["value"], Value::from(1));
1055        // The verifier reads the effect, the executor reads the parameters —
1056        // a `state_write` that set only one of them would silently defeat
1057        // write-conflict detection.
1058        assert_eq!(sw.expected_effects["k"], Value::from(1));
1059        assert_eq!(sw.effective_write_set(), vec!["k".to_string()]);
1060
1061        let sr = Action::state_read("k");
1062        assert_eq!(sr.action_type, ActionType::StateRead);
1063        assert_eq!(sr.parameters["key"], Value::from("k"));
1064
1065        // Chainable setters.
1066        let c = Action::tool_call("t")
1067            .with_id("fixed")
1068            .with_param("p", Value::from(2));
1069        assert_eq!(c.id, "fixed");
1070        assert_eq!(c.parameters["p"], Value::from(2));
1071    }
1072
1073    #[test]
1074    fn action_type_serializes_snake_case() {
1075        assert_eq!(
1076            serde_json::to_string(&ActionType::ToolCall).unwrap(),
1077            "\"tool_call\""
1078        );
1079        assert_eq!(
1080            serde_json::to_string(&ActionType::StateWrite).unwrap(),
1081            "\"state_write\""
1082        );
1083    }
1084
1085    #[test]
1086    fn failure_behavior_serializes_snake_case() {
1087        assert_eq!(
1088            serde_json::to_string(&FailureBehavior::Abort).unwrap(),
1089            "\"abort\""
1090        );
1091        assert_eq!(
1092            serde_json::to_string(&FailureBehavior::Retry).unwrap(),
1093            "\"retry\""
1094        );
1095    }
1096
1097    #[test]
1098    fn action_roundtrip_json() {
1099        let action = Action {
1100            id: "abc123".to_string(),
1101            action_type: ActionType::ToolCall,
1102            tool: Some("add".to_string()),
1103            parameters: [
1104                ("a".to_string(), Value::from(1)),
1105                ("b".to_string(), Value::from(2)),
1106            ]
1107            .into(),
1108            preconditions: vec![Precondition {
1109                key: "auth".to_string(),
1110                operator: "eq".to_string(),
1111                value: Value::Bool(true),
1112                description: String::new(),
1113            }],
1114            expected_effects: [("sum".to_string(), Value::from(3))].into(),
1115            state_dependencies: vec!["auth".to_string()],
1116            read_set: vec![],
1117            write_set: vec![],
1118            assumptions: vec![],
1119            invocation_mode: Default::default(),
1120            reversibility: Reversibility::Compensable,
1121            compensation: Some(Compensation::Tool {
1122                tool: "subtract".to_string(),
1123                parameters: [("sum".to_string(), Value::from(3))].into(),
1124            }),
1125            idempotent: true,
1126            max_retries: 3,
1127            failure_behavior: FailureBehavior::Retry,
1128            timeout_ms: Some(5000),
1129            metadata: HashMap::new(),
1130        };
1131
1132        let json = serde_json::to_string_pretty(&action).unwrap();
1133        let roundtripped: Action = serde_json::from_str(&json).unwrap();
1134
1135        assert_eq!(action.id, roundtripped.id);
1136        assert_eq!(action.action_type, roundtripped.action_type);
1137        assert_eq!(action.tool, roundtripped.tool);
1138        assert_eq!(action.idempotent, roundtripped.idempotent);
1139        assert_eq!(action.failure_behavior, roundtripped.failure_behavior);
1140        assert_eq!(action.timeout_ms, roundtripped.timeout_ms);
1141        assert_eq!(action.reversibility, roundtripped.reversibility);
1142        assert_eq!(action.compensation, roundtripped.compensation);
1143        assert_eq!(action, roundtripped);
1144    }
1145
1146    #[test]
1147    fn proposal_roundtrip_json() {
1148        let proposal = ActionProposal {
1149            id: "prop1".to_string(),
1150            source: "test".to_string(),
1151            actions: vec![Action {
1152                id: "a1".to_string(),
1153                action_type: ActionType::StateWrite,
1154                tool: None,
1155                parameters: [
1156                    ("key".to_string(), Value::from("x")),
1157                    ("value".to_string(), Value::from(42)),
1158                ]
1159                .into(),
1160                preconditions: vec![],
1161                expected_effects: HashMap::new(),
1162                state_dependencies: vec![],
1163                read_set: vec![],
1164                write_set: vec![],
1165                assumptions: vec![],
1166                invocation_mode: Default::default(),
1167                reversibility: Reversibility::Reversible,
1168                compensation: None,
1169                idempotent: false,
1170                max_retries: 3,
1171                failure_behavior: FailureBehavior::Abort,
1172                timeout_ms: None,
1173                metadata: HashMap::new(),
1174            }],
1175            timestamp: Utc::now(),
1176            context: HashMap::new(),
1177        };
1178
1179        let json = serde_json::to_string(&proposal).unwrap();
1180        let roundtripped: ActionProposal = serde_json::from_str(&json).unwrap();
1181
1182        assert_eq!(proposal.id, roundtripped.id);
1183        assert_eq!(proposal.source, roundtripped.source);
1184        assert_eq!(proposal.actions.len(), roundtripped.actions.len());
1185    }
1186
1187    #[test]
1188    fn state_mutation_round_trips_the_stable_wire_shape() {
1189        let set = StateMutation::Set {
1190            value: Value::from(42),
1191        };
1192        let set_wire = set.clone().encode();
1193        assert_eq!(set_wire, serde_json::json!({"op": "set", "value": 42}));
1194        assert_eq!(StateMutation::decode(&set_wire).unwrap(), set);
1195
1196        let delete = StateMutation::Delete;
1197        let delete_wire = delete.clone().encode();
1198        assert_eq!(delete_wire, serde_json::json!({"op": "delete"}));
1199        assert_eq!(StateMutation::decode(&delete_wire).unwrap(), delete);
1200
1201        assert_eq!(
1202            StateMutation::decode(&serde_json::json!({"op": "set"})).unwrap(),
1203            StateMutation::Set { value: Value::Null },
1204            "a missing set value retains the previous set-to-null interpretation"
1205        );
1206        assert!(StateMutation::decode(&serde_json::json!({"legacy": true})).is_err());
1207    }
1208
1209    #[test]
1210    fn action_result_serializes() {
1211        let result = ActionResult {
1212            action_id: "a1".to_string(),
1213            status: ActionStatus::Succeeded,
1214            output: Some(Value::from(42)),
1215            error: None,
1216            terminal: false,
1217            state_changes: HashMap::new(),
1218            rolled_back: false,
1219            duration_ms: Some(1.5),
1220            timestamp: Utc::now(),
1221        };
1222
1223        let json = serde_json::to_string(&result).unwrap();
1224        assert!(json.contains("\"succeeded\""));
1225        assert!(!json.contains("\"terminal\""));
1226        assert!(
1227            !json.contains("rolled_back"),
1228            "the additive false marker stays absent on the wire"
1229        );
1230
1231        let mut rolled_back = result;
1232        rolled_back.rolled_back = true;
1233        let value = serde_json::to_value(&rolled_back).unwrap();
1234        assert_eq!(value["rolled_back"], true);
1235
1236        let legacy: ActionResult = serde_json::from_value(serde_json::json!({
1237            "action_id": "legacy",
1238            "status": "succeeded",
1239            "state_changes": {}
1240        }))
1241        .unwrap();
1242        assert!(!legacy.rolled_back);
1243    }
1244
1245    #[test]
1246    fn terminal_tool_failure_is_explicit_and_roundtrips_on_action_results() {
1247        let ordinary = ToolFailure::from("ordinary failure");
1248        assert_eq!(ordinary.classification, ToolFailureClassification::Ordinary);
1249        assert!(!ordinary.is_terminal());
1250
1251        let failure = ToolFailure::terminal("stop now");
1252        assert_eq!(failure.classification, ToolFailureClassification::Terminal);
1253        assert!(failure.is_terminal());
1254        assert_eq!(
1255            serde_json::to_value(&failure).unwrap(),
1256            serde_json::json!({
1257                "message": "stop now",
1258                "classification": "terminal"
1259            })
1260        );
1261
1262        let result: ActionResult = serde_json::from_value(serde_json::json!({
1263            "action_id": "a1",
1264            "status": "failed",
1265            "error": "stop now",
1266            "terminal": true
1267        }))
1268        .unwrap();
1269        assert!(result.terminal);
1270        assert!(!result.rolled_back);
1271
1272        let legacy: ActionResult = serde_json::from_value(serde_json::json!({
1273            "action_id": "a2",
1274            "status": "failed",
1275            "error": "ordinary failure"
1276        }))
1277        .unwrap();
1278        assert!(!legacy.terminal);
1279        assert!(!legacy.rolled_back);
1280    }
1281
1282    #[test]
1283    fn proposal_result_all_succeeded() {
1284        let pr = ProposalResult {
1285            proposal_id: "p1".to_string(),
1286            original_proposal_id: "p1".to_string(),
1287            final_proposal: None,
1288            replan_lineage: vec![],
1289            accepted_proposal_preimages: vec![],
1290            results: vec![
1291                ActionResult {
1292                    action_id: "a1".to_string(),
1293                    status: ActionStatus::Succeeded,
1294                    output: None,
1295                    error: None,
1296                    terminal: false,
1297                    state_changes: HashMap::new(),
1298                    rolled_back: false,
1299                    duration_ms: None,
1300                    timestamp: Utc::now(),
1301                },
1302                ActionResult {
1303                    action_id: "a2".to_string(),
1304                    status: ActionStatus::Succeeded,
1305                    output: None,
1306                    error: None,
1307                    terminal: false,
1308                    state_changes: HashMap::new(),
1309                    rolled_back: false,
1310                    duration_ms: None,
1311                    timestamp: Utc::now(),
1312                },
1313            ],
1314            cost: CostSummary::default(),
1315        };
1316        assert!(pr.all_succeeded());
1317    }
1318
1319    #[test]
1320    fn proposal_result_not_all_succeeded() {
1321        let pr = ProposalResult {
1322            proposal_id: "p1".to_string(),
1323            original_proposal_id: "p1".to_string(),
1324            final_proposal: None,
1325            replan_lineage: vec![],
1326            accepted_proposal_preimages: vec![],
1327            results: vec![
1328                ActionResult {
1329                    action_id: "a1".to_string(),
1330                    status: ActionStatus::Succeeded,
1331                    output: None,
1332                    error: None,
1333                    terminal: false,
1334                    state_changes: HashMap::new(),
1335                    rolled_back: false,
1336                    duration_ms: None,
1337                    timestamp: Utc::now(),
1338                },
1339                ActionResult {
1340                    action_id: "a2".to_string(),
1341                    status: ActionStatus::Failed,
1342                    output: None,
1343                    error: Some("boom".to_string()),
1344                    terminal: false,
1345                    state_changes: HashMap::new(),
1346                    rolled_back: false,
1347                    duration_ms: None,
1348                    timestamp: Utc::now(),
1349                },
1350            ],
1351            cost: CostSummary::default(),
1352        };
1353        assert!(!pr.all_succeeded());
1354    }
1355
1356    #[test]
1357    fn cost_summary_default_is_zero() {
1358        let cost = CostSummary::default();
1359        assert_eq!(cost.tool_calls, 0);
1360        assert_eq!(cost.actions_executed, 0);
1361        assert_eq!(cost.actions_rejected, 0);
1362        assert_eq!(cost.actions_skipped, 0);
1363        assert_eq!(cost.total_duration_ms, 0.0);
1364        assert_eq!(cost.retries, 0);
1365    }
1366
1367    /// A summary written before `actions_rejected` existed must still load.
1368    #[test]
1369    fn cost_summary_deserializes_without_actions_rejected() {
1370        let legacy = r#"{"tool_calls":1,"actions_executed":2,"actions_skipped":0,
1371                         "total_duration_ms":5.0,"retries":0}"#;
1372        let cost: CostSummary = serde_json::from_str(legacy).unwrap();
1373        assert_eq!(cost.actions_executed, 2);
1374        assert_eq!(cost.actions_rejected, 0);
1375    }
1376
1377    #[test]
1378    fn proposal_result_deserializes_without_cost() {
1379        // Backward compatibility: old JSON without cost field should still deserialize
1380        let json = r#"{"proposal_id": "p1", "results": []}"#;
1381        let pr: ProposalResult = serde_json::from_str(json).unwrap();
1382        assert_eq!(pr.cost, CostSummary::default());
1383        assert_eq!(pr.original_proposal_id, "p1");
1384        assert_eq!(pr.final_proposal, None);
1385        assert!(pr.replan_lineage.is_empty());
1386        assert!(pr.accepted_proposal_preimages.is_empty());
1387    }
1388
1389    #[test]
1390    fn proposal_result_lineage_has_exact_tagged_wire_shape() {
1391        let original_digest = "a".repeat(64);
1392        let candidate_digest = "b".repeat(64);
1393        let accepted_digest = "c".repeat(64);
1394        let final_proposal = ActionProposal {
1395            id: "accepted-replan".to_string(),
1396            source: "replanner".to_string(),
1397            actions: vec![],
1398            timestamp: Utc::now(),
1399            context: HashMap::new(),
1400        };
1401        let pr = ProposalResult {
1402            proposal_id: "accepted-replan".to_string(),
1403            original_proposal_id: "original".to_string(),
1404            final_proposal: Some(final_proposal.clone()),
1405            replan_lineage: vec![
1406                ProposalLineageEntry {
1407                    generation: 0,
1408                    proposal_id: "original".to_string(),
1409                    proposal_digest: Some(original_digest.clone()),
1410                    status: ProposalLineageStatus::Accepted,
1411                    rejection_reason: None,
1412                },
1413                ProposalLineageEntry {
1414                    generation: 1,
1415                    proposal_id: "duplicate-candidate".to_string(),
1416                    proposal_digest: Some(candidate_digest.clone()),
1417                    status: ProposalLineageStatus::Rejected,
1418                    rejection_reason: Some("duplicate action id 'same'".to_string()),
1419                },
1420                ProposalLineageEntry {
1421                    generation: 2,
1422                    proposal_id: "accepted-replan".to_string(),
1423                    proposal_digest: Some(accepted_digest.clone()),
1424                    status: ProposalLineageStatus::Accepted,
1425                    rejection_reason: None,
1426                },
1427            ],
1428            accepted_proposal_preimages: vec![AcceptedProposalPreimage {
1429                generation: 2,
1430                proposal_digest: accepted_digest.clone(),
1431                proposal: final_proposal.clone(),
1432            }],
1433            results: vec![],
1434            cost: CostSummary::default(),
1435        };
1436        let value = serde_json::to_value(&pr).unwrap();
1437        assert_eq!(value["proposal_id"], "accepted-replan");
1438        assert_eq!(value["original_proposal_id"], "original");
1439        assert_eq!(value["final_proposal"], serde_json::json!(final_proposal));
1440        assert_eq!(
1441            value["replan_lineage"],
1442            serde_json::json!([
1443                {
1444                    "generation": 0,
1445                    "proposal_id": "original",
1446                    "proposal_digest": original_digest,
1447                    "status": "accepted"
1448                },
1449                {
1450                    "generation": 1,
1451                    "proposal_id": "duplicate-candidate",
1452                    "proposal_digest": candidate_digest,
1453                    "status": "rejected",
1454                    "rejection_reason": "duplicate action id 'same'"
1455                },
1456                {
1457                    "generation": 2,
1458                    "proposal_id": "accepted-replan",
1459                    "proposal_digest": accepted_digest,
1460                    "status": "accepted"
1461                }
1462            ])
1463        );
1464        assert_eq!(
1465            value["accepted_proposal_preimages"],
1466            serde_json::json!([{
1467                "generation": 2,
1468                "proposal_digest": accepted_digest,
1469                "proposal": final_proposal,
1470            }])
1471        );
1472    }
1473
1474    #[test]
1475    fn proposal_lineage_rejects_missing_or_noncanonical_accepted_digest() {
1476        for digest in [Value::Null, Value::from("A".repeat(64)), Value::from("abc")] {
1477            let value = serde_json::json!({
1478                "generation": 0,
1479                "proposal_id": "p",
1480                "proposal_digest": digest,
1481                "status": "accepted"
1482            });
1483            let error = serde_json::from_value::<ProposalLineageEntry>(value).unwrap_err();
1484            assert!(
1485                error
1486                    .to_string()
1487                    .contains("accepted proposal lineage requires a lowercase JCS SHA-256 digest"),
1488                "unexpected error: {error}"
1489            );
1490        }
1491    }
1492
1493    #[test]
1494    fn rejected_lineage_without_digest_requires_exact_reason() {
1495        let valid: ProposalLineageEntry = serde_json::from_value(serde_json::json!({
1496            "generation": 1,
1497            "proposal_id": "not-jcs",
1498            "status": "rejected",
1499            "rejection_reason": "RFC 8785 canonicalization failed: number is outside the I-JSON safe integer range"
1500        }))
1501        .unwrap();
1502        assert!(valid.proposal_digest.is_none());
1503
1504        let error = serde_json::from_value::<ProposalLineageEntry>(serde_json::json!({
1505            "generation": 1,
1506            "proposal_id": "not-jcs",
1507            "status": "rejected"
1508        }))
1509        .unwrap_err();
1510        assert!(error
1511            .to_string()
1512            .contains("rejected proposal lineage requires an exact rejection reason"));
1513
1514        let wrong_reason = serde_json::from_value::<ProposalLineageEntry>(serde_json::json!({
1515            "generation": 1,
1516            "proposal_id": "not-jcs",
1517            "status": "rejected",
1518            "rejection_reason": "duplicate action id"
1519        }))
1520        .unwrap_err();
1521        assert!(wrong_reason
1522            .to_string()
1523            .contains("undigested rejected proposal lineage must identify a JCS/I-JSON failure"));
1524    }
1525
1526    #[test]
1527    fn tool_source_kind_uses_the_stable_wire_vocabulary() {
1528        let cases = [
1529            (ToolSourceKind::Builtin, "builtin"),
1530            (ToolSourceKind::UserDefined, "user_defined"),
1531            (ToolSourceKind::Subprocess, "subprocess"),
1532            (ToolSourceKind::Mcp, "mcp"),
1533        ];
1534        for (source, expected) in cases {
1535            assert_eq!(source.as_str(), expected);
1536            assert_eq!(
1537                serde_json::to_value(source).unwrap(),
1538                serde_json::json!(expected)
1539            );
1540        }
1541    }
1542
1543    #[test]
1544    fn legacy_tool_schema_defaults_to_user_defined_source() {
1545        let schema: ToolSchema = serde_json::from_value(serde_json::json!({
1546            "name": "legacy",
1547            "parameters": {}
1548        }))
1549        .unwrap();
1550        assert_eq!(schema.source, ToolSourceKind::UserDefined);
1551        assert_eq!(
1552            serde_json::to_value(schema).unwrap()["source"],
1553            "user_defined"
1554        );
1555    }
1556
1557    #[test]
1558    fn deserialize_from_python_compatible_json() {
1559        // This JSON must match what Python's model_dump_json() produces
1560        let json = r#"{
1561            "id": "test123",
1562            "type": "tool_call",
1563            "tool": "add",
1564            "parameters": {"a": 1, "b": 2},
1565            "preconditions": [],
1566            "expected_effects": {"sum": 3},
1567            "state_dependencies": [],
1568            "idempotent": true,
1569            "max_retries": 3,
1570            "failure_behavior": "retry",
1571            "timeout_ms": 5000,
1572            "metadata": {}
1573        }"#;
1574
1575        let action: Action = serde_json::from_str(json).unwrap();
1576        assert_eq!(action.id, "test123");
1577        assert_eq!(action.action_type, ActionType::ToolCall);
1578        assert_eq!(action.tool, Some("add".to_string()));
1579        assert!(action.idempotent);
1580        assert_eq!(action.failure_behavior, FailureBehavior::Retry);
1581        assert_eq!(action.timeout_ms, Some(5000));
1582    }
1583
1584    /// The backward-compatibility contract the rest of the runtime depends on:
1585    /// a proposal authored before `reversibility` / `compensation` existed —
1586    /// including one that predates the transactional and streaming fields —
1587    /// must still deserialize, landing on the conservative default rather than
1588    /// failing.
1589    #[test]
1590    fn action_deserializes_without_reversibility_fields() {
1591        let legacy = r#"{
1592            "id": "legacy1",
1593            "type": "tool_call",
1594            "tool": "send_email",
1595            "parameters": {"to": "a@b.c"},
1596            "preconditions": [],
1597            "expected_effects": {},
1598            "state_dependencies": [],
1599            "idempotent": false,
1600            "max_retries": 3,
1601            "failure_behavior": "abort",
1602            "metadata": {}
1603        }"#;
1604
1605        let action: Action = serde_json::from_str(legacy).unwrap();
1606        assert_eq!(action.id, "legacy1");
1607        assert_eq!(action.reversibility, Reversibility::Irreversible);
1608        assert_eq!(action.compensation, None);
1609        // The default is not the incoherent state — `Irreversible` needs no
1610        // compensation, so an un-annotated legacy action never trips the check.
1611        assert!(!action.missing_required_compensation());
1612    }
1613
1614    /// The same contract one level up: a whole proposal written by an older
1615    /// CAR (or an older binding) still loads, and every action in it defaults.
1616    #[test]
1617    fn proposal_deserializes_without_reversibility_fields() {
1618        let legacy = r#"{
1619            "id": "prop-legacy",
1620            "source": "python",
1621            "actions": [
1622                {"id": "a1", "type": "state_write", "parameters": {"key": "x", "value": 1}},
1623                {"id": "a2", "type": "tool_call", "tool": "add", "parameters": {}}
1624            ],
1625            "context": {}
1626        }"#;
1627
1628        let proposal: ActionProposal = serde_json::from_str(legacy).unwrap();
1629        assert_eq!(proposal.actions.len(), 2);
1630        for action in &proposal.actions {
1631            assert_eq!(action.reversibility, Reversibility::Irreversible);
1632            assert!(action.compensation.is_none());
1633        }
1634        assert_eq!(proposal.rollback_contract(), Reversibility::Irreversible);
1635    }
1636
1637    /// `compensation` is `skip_serializing_if = "Option::is_none"`, so an
1638    /// action that declares no compensation emits no key at all — an older
1639    /// consumer sees exactly the payload shape it saw before.
1640    #[test]
1641    fn absent_compensation_is_omitted_from_the_wire_form() {
1642        let mut action: Action =
1643            serde_json::from_str(r#"{"id":"a1","type":"tool_call","tool":"noop"}"#).unwrap();
1644        action.reversibility = Reversibility::Reversible;
1645
1646        let json = serde_json::to_value(&action).unwrap();
1647        assert_eq!(json["reversibility"], "reversible");
1648        assert!(
1649            json.get("compensation").is_none(),
1650            "compensation should be skipped when None, got {json}"
1651        );
1652    }
1653
1654    #[test]
1655    fn compensable_without_compensation_is_flagged() {
1656        let mut action: Action =
1657            serde_json::from_str(r#"{"id":"a1","type":"tool_call","tool":"db.insert"}"#).unwrap();
1658
1659        action.reversibility = Reversibility::Compensable;
1660        assert!(action.missing_required_compensation());
1661
1662        action.compensation = Some(Compensation::ActionRef {
1663            action_id: "undo-a1".to_string(),
1664        });
1665        assert!(!action.missing_required_compensation());
1666
1667        // A compensation declared alongside a non-compensable contract is
1668        // pointless but not incoherent — the check stays quiet about it.
1669        action.reversibility = Reversibility::Reversible;
1670        assert!(!action.missing_required_compensation());
1671    }
1672
1673    #[test]
1674    fn rollback_contract_is_the_worst_action_in_the_batch() {
1675        let mut proposal: ActionProposal = serde_json::from_str(
1676            r#"{"id":"p1","source":"test","actions":[
1677                {"id":"a1","type":"state_write","parameters":{"key":"x"},"reversibility":"reversible"},
1678                {"id":"a2","type":"tool_call","tool":"db.insert","reversibility":"compensable",
1679                 "compensation":{"type":"tool","tool":"db.delete","parameters":{"id":1}}}
1680            ]}"#,
1681        )
1682        .unwrap();
1683        assert_eq!(proposal.rollback_contract(), Reversibility::Compensable);
1684
1685        proposal.actions[0].reversibility = Reversibility::Irreversible;
1686        assert_eq!(proposal.rollback_contract(), Reversibility::Irreversible);
1687
1688        // Nothing to undo is not the same as "unclassified", so an empty
1689        // batch does not inherit the pessimistic default.
1690        proposal.actions.clear();
1691        assert_eq!(proposal.rollback_contract(), Reversibility::Reversible);
1692    }
1693}