Skip to main content

fd_policy/
reversibility.rs

1//! Reversibility-aware graduated response — the DeepMind *AI Control Roadmap*
2//! R1→R3 ladder applied to tool calls.
3//!
4//! Risk and **reversibility** are orthogonal axes: a high-risk *read* is
5//! recoverable, a low-risk *delete* is not. This module adds the reversibility
6//! dimension and maps it onto a graduated response, so the control plane
7//! escalates friction only as far as the *recoverability* of the action
8//! demands:
9//!
10//! - **R1 — monitor** ([`ResponseLevel::AllowAndLog`]): a *reversible* action
11//!   proceeds; the transcript is reviewed asynchronously, no gate.
12//! - **R2 — conditional** ([`ResponseLevel::AllowUnderBudget`]): a *costly*
13//!   (recoverable, but expensive) action proceeds **only while the run's
14//!   budget gate has headroom**; once the budget is exhausted it escalates to
15//!   R3.
16//! - **R3 — gate** ([`ResponseLevel::RequireApproval`]): an *irreversible*
17//!   action requires the existing human-in-the-loop approval gate.
18//!
19//! Reference: DeepMind, *An Approach to Technical AGI Safety — AI Control
20//! Roadmap* (the R1–R3 control-level ladder).
21//!
22//! **Additive + deny-by-default**: this dimension is orthogonal to the
23//! existing risk tiers (it neither removes nor renames them), and an unknown /
24//! unclassified tool defaults to [`Reversibility::Irreversible`] — the most
25//! restrictive rung. The graduated response can only ever *add* friction to
26//! the allowlist decision, never loosen a `Deny` or an approval requirement
27//! (see [`combine`]).
28
29use serde::{Deserialize, Serialize};
30use std::str::FromStr;
31
32use crate::decision::{PolicyDecision, PolicyDecisionKind};
33
34/// Stable anchor recorded alongside the response so audit consumers can cite
35/// the design reference without re-reading docstrings.
36pub const RESPONSE_LADDER_ANCHOR: &str = "deepmind-ai-control-roadmap-r1-r3";
37
38/// How recoverable a tool's effect is. Orthogonal to risk tier.
39///
40/// Default is [`Reversibility::Irreversible`] — deny-by-default: an
41/// unclassified tool is treated as the most consequential until proven
42/// otherwise.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum Reversibility {
46    /// Effects can be undone freely (reads, idempotent no-ops).
47    Reversible,
48    /// Recoverable, but undoing costs real money / time (spend, large compute).
49    Costly,
50    /// Cannot be undone (deletes, deploys, payments, external sends).
51    #[default]
52    Irreversible,
53}
54
55impl Reversibility {
56    /// Stable snake_case wire label.
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Reversibility::Reversible => "reversible",
60            Reversibility::Costly => "costly",
61            Reversibility::Irreversible => "irreversible",
62        }
63    }
64
65    /// Parse from a stored string, defaulting **unknown → Irreversible**
66    /// (deny-by-default). Never fails — an unparseable value is the most
67    /// restrictive rung, which is the safe direction.
68    pub fn parse(value: &str) -> Self {
69        match value.trim().to_lowercase().as_str() {
70            "reversible" => Reversibility::Reversible,
71            "costly" => Reversibility::Costly,
72            _ => Reversibility::Irreversible,
73        }
74    }
75}
76
77impl FromStr for Reversibility {
78    type Err = std::convert::Infallible;
79    fn from_str(s: &str) -> Result<Self, Self::Err> {
80        Ok(Reversibility::parse(s))
81    }
82}
83
84/// The graduated response level chosen for a tool call — the R1–R3 rung.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum ResponseLevel {
88    /// R1: allow and log for async review — no block.
89    AllowAndLog,
90    /// R2: allow only while the budget gate has headroom.
91    AllowUnderBudget,
92    /// R3: require the human-in-the-loop approval gate.
93    RequireApproval,
94}
95
96impl ResponseLevel {
97    /// Stable snake_case wire label.
98    pub fn as_str(self) -> &'static str {
99        match self {
100            ResponseLevel::AllowAndLog => "allow_and_log",
101            ResponseLevel::AllowUnderBudget => "allow_under_budget",
102            ResponseLevel::RequireApproval => "require_approval",
103        }
104    }
105
106    /// The R-rung label (R1/R2/R3) for display + docs.
107    pub fn rung(self) -> &'static str {
108        match self {
109            ResponseLevel::AllowAndLog => "R1",
110            ResponseLevel::AllowUnderBudget => "R2",
111            ResponseLevel::RequireApproval => "R3",
112        }
113    }
114
115    /// The [`PolicyDecisionKind`] this rung corresponds to on its own.
116    /// R1/R2 allow; R3 requires approval. (R2's budget gating is applied
117    /// *before* this mapping — see [`graduated_response`].)
118    pub fn decision_kind(self) -> PolicyDecisionKind {
119        match self {
120            ResponseLevel::AllowAndLog | ResponseLevel::AllowUnderBudget => {
121                PolicyDecisionKind::Allow
122            }
123            ResponseLevel::RequireApproval => PolicyDecisionKind::RequiresApproval,
124        }
125    }
126
127    /// Build a standalone [`PolicyDecision`] for this rung with a reason.
128    pub fn to_policy_decision(self, reason: impl Into<String>) -> PolicyDecision {
129        match self {
130            ResponseLevel::AllowAndLog | ResponseLevel::AllowUnderBudget => {
131                PolicyDecision::allow(reason)
132            }
133            ResponseLevel::RequireApproval => PolicyDecision::requires_approval(reason),
134        }
135    }
136}
137
138/// Map a tool's reversibility (plus whether the run's budget gate currently has
139/// headroom) onto the graduated response rung.
140///
141/// - `Reversible` → R1 `AllowAndLog` (budget irrelevant).
142/// - `Costly` → R2 `AllowUnderBudget` **iff** `budget_has_headroom`, else it
143///   escalates to R3 `RequireApproval` (the budget is exhausted).
144/// - `Irreversible` → R3 `RequireApproval` (budget irrelevant).
145pub fn graduated_response(rev: Reversibility, budget_has_headroom: bool) -> ResponseLevel {
146    match rev {
147        Reversibility::Reversible => ResponseLevel::AllowAndLog,
148        Reversibility::Costly => {
149            if budget_has_headroom {
150                ResponseLevel::AllowUnderBudget
151            } else {
152                ResponseLevel::RequireApproval
153            }
154        }
155        Reversibility::Irreversible => ResponseLevel::RequireApproval,
156    }
157}
158
159/// Restrictiveness rank — lower is more restrictive. Mirrors the crate's
160/// `Deny > RequiresApproval > Allow` precedence so the reversibility ladder
161/// composes with the allowlist decision the same way every other gate does.
162fn restrictiveness(kind: PolicyDecisionKind) -> u8 {
163    match kind {
164        PolicyDecisionKind::Deny => 0,
165        PolicyDecisionKind::RequiresApproval => 1,
166        PolicyDecisionKind::AllowWithWarning => 2,
167        PolicyDecisionKind::Allow => 3,
168    }
169}
170
171/// Combine the allowlist's base decision with the reversibility rung,
172/// **more-restrictive-wins**. The reversibility ladder can only *add* friction
173/// — it can upgrade an `Allow` to `RequiresApproval`, but can never loosen a
174/// `Deny` or an existing approval requirement.
175pub fn combine(base: PolicyDecisionKind, level: ResponseLevel) -> PolicyDecisionKind {
176    let candidate = level.decision_kind();
177    if restrictiveness(candidate) < restrictiveness(base) {
178        candidate
179    } else {
180        base
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn default_is_irreversible() {
190        assert_eq!(Reversibility::default(), Reversibility::Irreversible);
191    }
192
193    #[test]
194    fn parse_unknown_defaults_to_irreversible() {
195        assert_eq!(
196            Reversibility::parse("reversible"),
197            Reversibility::Reversible
198        );
199        assert_eq!(Reversibility::parse("COSTLY"), Reversibility::Costly);
200        assert_eq!(
201            Reversibility::parse("irreversible"),
202            Reversibility::Irreversible
203        );
204        assert_eq!(
205            Reversibility::parse("nonsense"),
206            Reversibility::Irreversible
207        );
208        assert_eq!(Reversibility::parse(""), Reversibility::Irreversible);
209        assert_eq!(
210            "garbage".parse::<Reversibility>().unwrap(),
211            Reversibility::Irreversible
212        );
213    }
214
215    #[test]
216    fn ladder_reversible_is_r1_allow_and_log() {
217        // Budget state is irrelevant for a reversible action.
218        assert_eq!(
219            graduated_response(Reversibility::Reversible, true),
220            ResponseLevel::AllowAndLog
221        );
222        assert_eq!(
223            graduated_response(Reversibility::Reversible, false),
224            ResponseLevel::AllowAndLog
225        );
226    }
227
228    #[test]
229    fn ladder_irreversible_is_r3_require_approval() {
230        assert_eq!(
231            graduated_response(Reversibility::Irreversible, true),
232            ResponseLevel::RequireApproval
233        );
234    }
235
236    #[test]
237    fn ladder_costly_flips_to_approval_when_budget_exhausted() {
238        // R2 while there's headroom...
239        assert_eq!(
240            graduated_response(Reversibility::Costly, true),
241            ResponseLevel::AllowUnderBudget
242        );
243        // ...escalates to R3 once the budget is exhausted.
244        assert_eq!(
245            graduated_response(Reversibility::Costly, false),
246            ResponseLevel::RequireApproval
247        );
248    }
249
250    #[test]
251    fn response_level_wire_and_rung_labels() {
252        assert_eq!(ResponseLevel::AllowAndLog.as_str(), "allow_and_log");
253        assert_eq!(
254            ResponseLevel::AllowUnderBudget.as_str(),
255            "allow_under_budget"
256        );
257        assert_eq!(ResponseLevel::RequireApproval.as_str(), "require_approval");
258        assert_eq!(ResponseLevel::AllowAndLog.rung(), "R1");
259        assert_eq!(ResponseLevel::AllowUnderBudget.rung(), "R2");
260        assert_eq!(ResponseLevel::RequireApproval.rung(), "R3");
261    }
262
263    #[test]
264    fn to_policy_decision_maps_rungs() {
265        assert!(ResponseLevel::AllowAndLog
266            .to_policy_decision("r1")
267            .is_allowed());
268        assert!(ResponseLevel::AllowUnderBudget
269            .to_policy_decision("r2")
270            .is_allowed());
271        assert!(ResponseLevel::RequireApproval
272            .to_policy_decision("r3")
273            .needs_approval());
274    }
275
276    #[test]
277    fn combine_adds_friction_but_never_loosens() {
278        // Irreversible upgrades a plain Allow to RequiresApproval.
279        assert_eq!(
280            combine(PolicyDecisionKind::Allow, ResponseLevel::RequireApproval),
281            PolicyDecisionKind::RequiresApproval
282        );
283        // A reversible R1 cannot loosen an allowlist Deny.
284        assert_eq!(
285            combine(PolicyDecisionKind::Deny, ResponseLevel::AllowAndLog),
286            PolicyDecisionKind::Deny
287        );
288        // ...nor loosen an existing approval requirement.
289        assert_eq!(
290            combine(
291                PolicyDecisionKind::RequiresApproval,
292                ResponseLevel::AllowAndLog
293            ),
294            PolicyDecisionKind::RequiresApproval
295        );
296        // R2 (allow) leaves a base Allow as Allow.
297        assert_eq!(
298            combine(PolicyDecisionKind::Allow, ResponseLevel::AllowUnderBudget),
299            PolicyDecisionKind::Allow
300        );
301    }
302
303    #[test]
304    fn serde_snake_case_round_trip() {
305        for r in [
306            Reversibility::Reversible,
307            Reversibility::Costly,
308            Reversibility::Irreversible,
309        ] {
310            let json = serde_json::to_string(&r).unwrap();
311            assert_eq!(json, format!("\"{}\"", r.as_str()));
312            let back: Reversibility = serde_json::from_str(&json).unwrap();
313            assert_eq!(back, r);
314        }
315    }
316}