car-ir 0.53.0

Agent IR types for Common Agent Runtime
Documentation
//! Reversibility — a typed answer to *can this be undone?*
//!
//! CAR already types **who may authorize an action**:
//! `car_policy::PermissionTier`, ordered `ReadOnly < SandboxEdit <
//! FullAccess`. It has never typed the orthogonal question of what happens
//! *after* the action runs, and the two got conflated. `PermissionTier`'s own
//! doc comments give it away — `SandboxEdit` is described as "reversible local
//! mutation" and `FullAccess` as "externally-consequential **or**
//! irreversible". That `or` is the problem: it collapses
//!
//! | Action | Authority required | Rollback contract |
//! |---|---|---|
//! | write a scratch file in the sandbox | `SandboxEdit` | reversible |
//! | `INSERT` into a production table | `FullAccess` | compensable (delete the row) |
//! | `git push` | `FullAccess` | compensable (force-push the prior ref) |
//! | send an email / charge a card | `FullAccess` | **irreversible** |
//! | `rm -rf` outside a snapshotted tree | `FullAccess` | **irreversible** |
//!
//! into a single ladder rung. With one lever for both questions the runtime
//! has two available failure modes and no third option: gate every
//! `FullAccess` action identically (approval fatigue, and the predictable
//! response is that someone turns the gate off), or relax the tier and lose
//! the genuinely permanent cases along with the recoverable ones. Splitting
//! the axis is what lets a later gate treat "needs approval and can be undone"
//! differently from "needs approval and is permanent".
//!
//! Background: `docs/proposals/shepherd-substrate-adoption.md`, section "The
//! finding worth acting on first: two axes, one enum", derived from *Shepherd:
//! A Runtime Substrate Empowering Meta-Agents with a Formalized Execution
//! Trace* (arXiv 2605.10913) Appendix A.2, which argues the deployment case for
//! a reversibility tier on every effect.
//!
//! # What this module deliberately does not do
//!
//! It does not enforce anything. Deferring the materialization of an
//! irreversible effect until a gate releases it needs scope machinery CAR does
//! not have yet (the proposal's item 3/4: a branchable oplog and a checkpoint
//! coupled to the filesystem — today `car_engine::Checkpoint` restores the KV
//! store and leaves whatever a tool wrote to disk exactly where it is). This
//! slice makes the property *typed, classified, and audited*, which is the
//! prerequisite for the rest. Nothing in the runtime reads
//! [`Action::reversibility`](crate::Action::reversibility) to decide whether to
//! run an action, and no claim here should be read as saying otherwise.
//!
//! It is not inert, though, and the distinction matters when reasoning about
//! what a change here affects: `car-server-core`'s supervision filter routes on
//! it (`SupervisionFilter::min_reversibility`), so the classification decides
//! which intents a supervisor is shown. That is *visibility*, not admission —
//! an irreversible action nobody is watching still runs.

use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;

/// The rollback contract for an action's effects — orthogonal to
/// `car_policy::PermissionTier`, which answers *who may authorize this*.
///
/// # Ordering
///
/// The `Ord` derive follows declaration order, so `Reversible < Compensable <
/// Irreversible` — ascending in **severity**, the same convention
/// `PermissionTier` uses. That makes "what is the rollback contract of this
/// whole batch?" a `max()` over its actions (see
/// [`ActionProposal::rollback_contract`](crate::ActionProposal::rollback_contract)):
/// a plan is only as recoverable as its least recoverable step.
///
/// # Why the default is `Irreversible`
///
/// `#[serde(default)]` on [`Action::reversibility`](crate::Action::reversibility)
/// fires whenever an author omitted the field — that is, for every proposal
/// written before this axis existed and every proposal from a model that has
/// not been taught about it. The default is therefore not a neutral technical
/// choice; it decides what the runtime believes about an *unclassified* action,
/// and the two directions fail very differently:
///
/// - Default `Reversible`, get it wrong: the runtime silently believes an
///   email that has already been sent can be unsent. A future gate waves it
///   through, an audit record says it is recoverable, and nothing anywhere
///   surfaces the mistake. This is a safety property, and it fails quietly.
/// - Default `Irreversible`, get it wrong: an action that was in fact
///   perfectly recoverable is treated as permanent. A future gate over-asks.
///   That is annoying, it is visible, and the fix is local — annotate the
///   action, or teach the classifier about the tool.
///
/// The proposal notes the honest cost of the conservative choice: against the
/// existing corpus, *everything* comes back `Irreversible`, which is noisy. Two
/// things make that acceptable rather than merely tolerable. First, the noise
/// is only paid once something enforces on the field, and this slice enforces
/// nothing — so the cost arrives on the same change that adds a gate, when
/// someone is looking at it, rather than now. Second, by that point
/// `car-policy`'s classifier is expected to supply a *classified* value for
/// every action whose tool it recognizes, so the default is what an
/// unrecognized tool falls back to — precisely the case where assuming the
/// worst is right.
///
/// The rule of thumb this encodes: when a default is a safety property, pick
/// the direction whose failure mode is loud.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum Reversibility {
    /// Undone by restoring the scope the action ran in — state writes,
    /// sandboxed filesystem writes, anything whose entire footprint is inside
    /// something the runtime can roll back. No compensating work is required;
    /// the rollback *is* the restore.
    Reversible,
    /// Undone only by running a compensating action: a database `INSERT` needs
    /// its `DELETE`, a `git push` needs a force-push of the prior ref, a deploy
    /// needs a rollback deploy. The effect really did reach the world and
    /// restoring a scope will not retract it — something has to actively
    /// reverse it. Should carry a [`Compensation`]; see
    /// [`Action::missing_required_compensation`](crate::Action::missing_required_compensation)
    /// for why the type system does not force that.
    Compensable,
    /// Cannot be undone once it reaches the world: a sent email, a charged
    /// card, a physical actuation, `rm -rf` outside a snapshotted tree. There
    /// is no compensating action, only a *mitigating* one, and the two should
    /// not be conflated — "send a retraction email" does not unsend the first
    /// email. The only lever is the gate before it runs; afterwards the record
    /// exists for audit.
    ///
    /// The default (see the type-level docs).
    #[default]
    Irreversible,
}

impl Reversibility {
    /// Whether an action carrying this contract needs a [`Compensation`]
    /// declared to be coherent. True only for [`Reversibility::Compensable`]:
    /// a reversible action is undone by restoring its scope and an
    /// irreversible one cannot be undone at all, so in both cases a declared
    /// compensation would be describing work that will never run.
    pub fn requires_compensation(self) -> bool {
        match self {
            Reversibility::Reversible => false,
            Reversibility::Compensable => true,
            Reversibility::Irreversible => false,
        }
    }

    /// The wire form, matching the serde representation. Kept as a borrowed
    /// `&'static str` so event payloads and log lines can name the tier
    /// without allocating — the same reason `PermissionTier::as_str` exists,
    /// and the two are reported side by side once `car-policy` classifies
    /// both axes.
    pub fn as_str(self) -> &'static str {
        match self {
            Reversibility::Reversible => "reversible",
            Reversibility::Compensable => "compensable",
            Reversibility::Irreversible => "irreversible",
        }
    }
}

/// How a [`Reversibility::Compensable`] action is undone — the action-level
/// analogue of the saga-pattern handler CAR already has one layer up.
///
/// `car_workflow::CompensationHandler` is `Proposal(ProposalStep) | StageRef {
/// stage_id }`: an inline handler, or a reference to something named elsewhere
/// in the same document. This enum keeps that split and restates it at action
/// granularity. It does **not** reuse the workflow type: `car-ir` sits at the
/// bottom of the stack (serde, serde_json, uuid, chrono, thiserror — nothing
/// else), and `car-workflow` already depends on `car-ir`, so importing it here
/// would invert the layering and cycle.
///
/// # Why the inline arm is a tool call and not a nested `Action`
///
/// A `Compensation::Action(Box<Action>)` arm is the obvious mirror of
/// `CompensationHandler::Proposal`, and it was rejected for two reasons.
///
/// 1. It makes `Action` recursive. That round-trips fine through serde, but
///    every FFI surface has to mirror the IR (project convention #2:
///    `index.d.ts`, `car_runtime.pyi`, both binding crates, the JSON-RPC
///    dispatcher), and a self-referential node is materially harder to state
///    honestly in a hand-maintained type stub than a flat tagged union.
/// 2. It is not needed. Undoing a *state* write is a scope restore, which is
///    [`Reversibility::Reversible`] by definition — so the inline arm only ever
///    has to express an external undo, and every external undo CAR can perform
///    is a tool call. Anything that genuinely needs several steps declares them
///    as actions and points at the entry point with
///    [`Compensation::ActionRef`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Compensation {
    /// Invoke a tool that reverses the effect — `db.delete` for an `INSERT`,
    /// `git.push --force` back to the prior ref, a rollback deploy.
    Tool {
        /// The tool to call. Not validated here — car-ir holds no registry.
        /// `car_validator::validate_action` resolves it against the registered
        /// tools the same way it resolves `Action::tool`, and `car_verify`'s
        /// `compensation_resolution` check reports it at proposal level.
        tool: String,
        /// Parameters for the compensating call.
        #[serde(default)]
        parameters: HashMap<String, Value>,
    },
    /// Run another action from the same proposal, identified by
    /// [`Action::id`](crate::Action::id). The referenced action is the entry
    /// point of the compensating work; use this when one tool call is not
    /// enough, or when the compensating step is itself something the
    /// validator and the policy layer should see as a first-class action.
    ActionRef {
        /// The `id` of the compensating action within the same proposal.
        /// `car_verify`'s `compensation_resolution` check reports a reference
        /// that names no action in the batch.
        action_id: String,
    },
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;

    #[test]
    fn default_is_the_conservative_tier() {
        assert_eq!(Reversibility::default(), Reversibility::Irreversible);
    }

    #[test]
    fn wire_forms_are_snake_case_and_roundtrip() {
        for (tier, wire) in [
            (Reversibility::Reversible, "\"reversible\""),
            (Reversibility::Compensable, "\"compensable\""),
            (Reversibility::Irreversible, "\"irreversible\""),
        ] {
            let json = serde_json::to_string(&tier).unwrap();
            assert_eq!(json, wire);
            let back: Reversibility = serde_json::from_str(&json).unwrap();
            assert_eq!(back, tier);
            // `as_str` must not drift from the serde representation — the two
            // are read as the same value by anything consuming an event.
            assert_eq!(json, format!("\"{}\"", tier.as_str()));
        }
    }

    #[test]
    fn ordering_is_ascending_severity() {
        assert!(Reversibility::Reversible < Reversibility::Compensable);
        assert!(Reversibility::Compensable < Reversibility::Irreversible);
        // The `max()` idiom the proposal-level contract relies on.
        let worst = [
            Reversibility::Reversible,
            Reversibility::Irreversible,
            Reversibility::Compensable,
        ]
        .into_iter()
        .max()
        .unwrap();
        assert_eq!(worst, Reversibility::Irreversible);
    }

    #[test]
    fn only_compensable_requires_compensation() {
        assert!(!Reversibility::Reversible.requires_compensation());
        assert!(Reversibility::Compensable.requires_compensation());
        assert!(!Reversibility::Irreversible.requires_compensation());
    }

    #[test]
    fn compensation_is_tagged_and_roundtrips() {
        let tool = Compensation::Tool {
            tool: "db.delete".into(),
            parameters: [("id".to_string(), Value::from(7))].into(),
        };
        let json = serde_json::to_value(&tool).unwrap();
        assert_eq!(json["type"], "tool");
        assert_eq!(json["tool"], "db.delete");
        let back: Compensation = serde_json::from_value(json).unwrap();
        assert_eq!(back, tool);

        let by_ref = Compensation::ActionRef {
            action_id: "undo-1".into(),
        };
        let json = serde_json::to_value(&by_ref).unwrap();
        assert_eq!(json["type"], "action_ref");
        assert_eq!(json["action_id"], "undo-1");
        let back: Compensation = serde_json::from_value(json).unwrap();
        assert_eq!(back, by_ref);
    }

    #[test]
    fn compensation_tool_parameters_default_to_empty() {
        let c: Compensation = serde_json::from_str(r#"{"type":"tool","tool":"rollback"}"#).unwrap();
        assert_eq!(
            c,
            Compensation::Tool {
                tool: "rollback".into(),
                parameters: HashMap::new(),
            }
        );
    }
}