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