Skip to main content

agent_effects/
policy.rs

1//! Policy: what the runtime may do on its own.
2//!
3//! Two parts:
4//!
5//! - [`Capabilities::unknown_plan`], the rule that keeps the runtime from
6//!   turning "I don't know" into a duplicate side effect;
7//! - [`RiskPolicy`], which adds requirements by risk level and effect kind:
8//!   approval, verification, no automatic retries.
9//!
10//! Both are pure, so they can be tested exhaustively.
11
12use std::fmt;
13
14use serde::{Deserialize, Serialize};
15
16use crate::kind::EffectKind;
17use crate::verification::VerificationMode;
18
19/// The properties of an effect that decide how its unknown outcomes are
20/// resolved.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22pub struct Capabilities {
23    /// The effect's kind.
24    pub kind: EffectKind,
25    /// Whether the remote system deduplicates on the effect's
26    /// [`IdempotencyKey`](crate::id::IdempotencyKey), so a repeated request
27    /// cannot apply twice.
28    pub remote_idempotency: bool,
29    /// Whether, and how reliably, the effect can be verified.
30    pub verification: VerificationMode,
31}
32
33/// What to do with an effect whose last attempt has an unknown outcome.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum UnknownPlan {
36    /// Ask the remote system what happened.
37    Verify,
38    /// Run the action again; repeating it cannot duplicate the effect.
39    Reexecute,
40    /// Nothing the runtime can do safely; an operator must resolve it.
41    Escalate,
42}
43
44impl Capabilities {
45    /// What to do when an attempt's outcome is unknown.
46    ///
47    /// Verification is preferred even when re-executing would be safe: it
48    /// usually costs less than the action and never repeats it.
49    pub const fn unknown_plan(&self) -> UnknownPlan {
50        if !matches!(self.verification, VerificationMode::None) {
51            UnknownPlan::Verify
52        } else if self.kind.is_naturally_idempotent() || self.remote_idempotency {
53            UnknownPlan::Reexecute
54        } else {
55            UnknownPlan::Escalate
56        }
57    }
58
59    /// Whether every unknown outcome of this effect will need an operator.
60    ///
61    /// True for writes that are neither idempotent nor verifiable. Such
62    /// effects are allowed, but the runtime warns when one is built.
63    pub const fn unknown_always_escalates(&self) -> bool {
64        matches!(self.unknown_plan(), UnknownPlan::Escalate)
65    }
66}
67
68/// How much damage an effect could do if it went wrong. Set with
69/// [`EffectBuilder::risk`](crate::EffectBuilder::risk) or
70/// [`EffectHandler::risk`](crate::EffectHandler::risk); defaults to `Low`.
71#[derive(
72    Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
73)]
74#[serde(rename_all = "snake_case")]
75pub enum RiskLevel {
76    /// Routine and cheap to get wrong.
77    #[default]
78    Low,
79    /// Visible to customers or costly to undo.
80    Medium,
81    /// Hard to undo, or affects money or data.
82    High,
83    /// Irreversible damage at scale, e.g. deleting production.
84    Critical,
85}
86
87impl fmt::Display for RiskLevel {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.write_str(match self {
90            Self::Low => "low",
91            Self::Medium => "medium",
92            Self::High => "high",
93            Self::Critical => "critical",
94        })
95    }
96}
97
98/// What a policy demands of an effect. Requirements only ever accumulate.
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100#[non_exhaustive]
101pub struct Requirements {
102    /// A human must approve the effect before its first attempt.
103    pub approval: bool,
104    /// The effect must be verifiable; one that is not is refused before
105    /// anything is recorded.
106    pub verification: bool,
107    /// The runtime must not retry or re-run the effect on its own.
108    pub no_automatic_retry: bool,
109}
110
111impl Requirements {
112    /// Both sets of requirements: the stricter of each.
113    #[must_use]
114    pub const fn and(self, other: Self) -> Self {
115        Self {
116            approval: self.approval || other.approval,
117            verification: self.verification || other.verification,
118            no_automatic_retry: self.no_automatic_retry || other.no_automatic_retry,
119        }
120    }
121}
122
123/// Which effects a rule applies to.
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125struct Selector {
126    risk: Option<RiskLevel>,
127    kind: Option<EffectKind>,
128}
129
130impl Selector {
131    fn matches(self, risk: RiskLevel, kind: EffectKind) -> bool {
132        self.risk.is_none_or(|r| r == risk) && self.kind.is_none_or(|k| k == kind)
133    }
134}
135
136/// Requirements by risk level and effect kind, applied to every effect the
137/// runtime runs. Built with [`PolicyBuilder`]; set with
138/// [`RuntimeBuilder::risk_policy`](crate::RuntimeBuilder::risk_policy).
139///
140/// **Precedence:** requirements only accumulate. An effect gets its own
141/// settings plus the requirements of *every* rule that matches it, so the
142/// strictest one always wins, and no rule, nor its order, can loosen another.
143///
144/// ```
145/// use agent_effects::{EffectKind, PolicyBuilder, RiskLevel};
146///
147/// let policy = PolicyBuilder::new()
148///     .for_risk(RiskLevel::Low).auto_execute()
149///     .for_risk(RiskLevel::Medium).require_verification()
150///     .for_risk(RiskLevel::High).require_approval()
151///     .for_risk(RiskLevel::Critical).require_approval().disable_automatic_retry()
152///     .for_kind(EffectKind::IrreversibleWrite).require_verification()
153///     .build();
154///
155/// let critical = policy.requirements(RiskLevel::Critical, EffectKind::ReversibleWrite);
156/// assert!(critical.approval && critical.no_automatic_retry && !critical.verification);
157/// ```
158#[derive(Clone, Debug, Default, PartialEq, Eq)]
159pub struct RiskPolicy {
160    rules: Vec<(Selector, Requirements)>,
161}
162
163impl RiskPolicy {
164    /// What the policy demands of an effect of `risk` and `kind`: the union
165    /// of every matching rule.
166    pub fn requirements(&self, risk: RiskLevel, kind: EffectKind) -> Requirements {
167        self.rules
168            .iter()
169            .filter(|(selector, _)| selector.matches(risk, kind))
170            .fold(Requirements::default(), |all, (_, rule)| all.and(*rule))
171    }
172}
173
174/// Builds a [`RiskPolicy`]: start a rule with `for_risk`, `for_kind` or
175/// `for_risk_and_kind`, then add requirements to it.
176#[derive(Clone, Debug, Default)]
177#[must_use]
178pub struct PolicyBuilder {
179    rules: Vec<(Selector, Requirements)>,
180}
181
182impl PolicyBuilder {
183    /// An empty policy.
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    /// Starts a rule for every effect of `risk`.
189    pub fn for_risk(self, risk: RiskLevel) -> Self {
190        self.rule(Some(risk), None)
191    }
192
193    /// Starts a rule for every effect of `kind`.
194    pub fn for_kind(self, kind: EffectKind) -> Self {
195        self.rule(None, Some(kind))
196    }
197
198    /// Starts a rule for effects of both `risk` and `kind`.
199    pub fn for_risk_and_kind(self, risk: RiskLevel, kind: EffectKind) -> Self {
200        self.rule(Some(risk), Some(kind))
201    }
202
203    /// Adds no requirement: the rule's effects run without extra checks.
204    /// Documents intent; it cannot lift another rule's requirements.
205    pub fn auto_execute(self) -> Self {
206        self
207    }
208
209    /// The rule's effects need approval before their first attempt.
210    pub fn require_approval(self) -> Self {
211        self.require(|r| r.approval = true)
212    }
213
214    /// The rule's effects must be verifiable.
215    pub fn require_verification(self) -> Self {
216        self.require(|r| r.verification = true)
217    }
218
219    /// The runtime must never retry or re-run the rule's effects on its
220    /// own. An operator's explicit retry is still allowed.
221    pub fn disable_automatic_retry(self) -> Self {
222        self.require(|r| r.no_automatic_retry = true)
223    }
224
225    /// The policy.
226    pub fn build(self) -> RiskPolicy {
227        RiskPolicy { rules: self.rules }
228    }
229
230    fn rule(mut self, risk: Option<RiskLevel>, kind: Option<EffectKind>) -> Self {
231        self.rules
232            .push((Selector { risk, kind }, Requirements::default()));
233        self
234    }
235
236    /// Adds to the latest rule, or to a rule for every effect if none was
237    /// started.
238    fn require(mut self, add: impl FnOnce(&mut Requirements)) -> Self {
239        if self.rules.is_empty() {
240            self = self.rule(None, None);
241        }
242        if let Some((_, requirements)) = self.rules.last_mut() {
243            add(requirements);
244        }
245        self
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use std::time::Duration;
252
253    use super::*;
254
255    const KINDS: [EffectKind; 4] = [
256        EffectKind::Read,
257        EffectKind::IdempotentWrite,
258        EffectKind::ReversibleWrite,
259        EffectKind::IrreversibleWrite,
260    ];
261
262    const MODES: [VerificationMode; 3] = [
263        VerificationMode::None,
264        VerificationMode::Authoritative,
265        VerificationMode::EventuallyConsistent {
266            settle: Duration::from_secs(5),
267        },
268    ];
269
270    fn all_capabilities() -> impl Iterator<Item = Capabilities> {
271        KINDS.into_iter().flat_map(|kind| {
272            [false, true]
273                .into_iter()
274                .flat_map(move |remote_idempotency| {
275                    MODES.into_iter().map(move |verification| Capabilities {
276                        kind,
277                        remote_idempotency,
278                        verification,
279                    })
280                })
281        })
282    }
283
284    #[test]
285    fn non_idempotent_writes_never_blindly_reexecute() {
286        for caps in all_capabilities() {
287            if caps.unknown_plan() == UnknownPlan::Reexecute {
288                assert!(
289                    caps.kind.is_naturally_idempotent() || caps.remote_idempotency,
290                    "{caps:?} re-executes blindly"
291                );
292            }
293        }
294    }
295
296    #[test]
297    fn escalation_happens_only_without_any_safe_option() {
298        for caps in all_capabilities() {
299            let no_safe_option = caps.verification == VerificationMode::None
300                && !caps.kind.is_naturally_idempotent()
301                && !caps.remote_idempotency;
302            assert_eq!(caps.unknown_always_escalates(), no_safe_option, "{caps:?}");
303        }
304    }
305
306    const RISKS: [RiskLevel; 4] = [
307        RiskLevel::Low,
308        RiskLevel::Medium,
309        RiskLevel::High,
310        RiskLevel::Critical,
311    ];
312
313    fn sample_policy() -> RiskPolicy {
314        PolicyBuilder::new()
315            .for_risk(RiskLevel::Low)
316            .auto_execute()
317            .for_risk(RiskLevel::Medium)
318            .require_verification()
319            .for_risk(RiskLevel::High)
320            .require_approval()
321            .for_risk(RiskLevel::Critical)
322            .require_approval()
323            .disable_automatic_retry()
324            .for_kind(EffectKind::IrreversibleWrite)
325            .require_verification()
326            .build()
327    }
328
329    #[test]
330    fn requirements_are_the_union_of_matching_rules() {
331        let policy = sample_policy();
332        let low_read = policy.requirements(RiskLevel::Low, EffectKind::Read);
333        assert_eq!(low_read, Requirements::default());
334        let high_irreversible = policy.requirements(RiskLevel::High, EffectKind::IrreversibleWrite);
335        assert!(high_irreversible.approval && high_irreversible.verification);
336        assert!(!high_irreversible.no_automatic_retry);
337    }
338
339    #[test]
340    fn rule_order_never_matters() {
341        let forward = sample_policy();
342        let mut reversed = forward.clone();
343        reversed.rules.reverse();
344        for risk in RISKS {
345            for kind in KINDS {
346                assert_eq!(
347                    forward.requirements(risk, kind),
348                    reversed.requirements(risk, kind)
349                );
350            }
351        }
352    }
353
354    #[test]
355    fn adding_a_rule_never_loosens() {
356        let base = sample_policy();
357        let mut extended = base.clone();
358        extended.rules.push((
359            Selector {
360                risk: None,
361                kind: None,
362            },
363            Requirements::default(),
364        ));
365        for risk in RISKS {
366            for kind in KINDS {
367                let (before, after) = (
368                    base.requirements(risk, kind),
369                    extended.requirements(risk, kind),
370                );
371                assert_eq!(before.and(after), after, "{risk} {kind:?}");
372            }
373        }
374    }
375
376    #[test]
377    fn requirements_without_a_rule_apply_to_everything() {
378        let policy = PolicyBuilder::new().disable_automatic_retry().build();
379        for risk in RISKS {
380            assert!(
381                policy
382                    .requirements(risk, EffectKind::Read)
383                    .no_automatic_retry
384            );
385        }
386    }
387}