1use std::fmt;
13
14use serde::{Deserialize, Serialize};
15
16use crate::kind::EffectKind;
17use crate::verification::VerificationMode;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22pub struct Capabilities {
23 pub kind: EffectKind,
25 pub remote_idempotency: bool,
29 pub verification: VerificationMode,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum UnknownPlan {
36 Verify,
38 Reexecute,
40 Escalate,
42}
43
44impl Capabilities {
45 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 pub const fn unknown_always_escalates(&self) -> bool {
64 matches!(self.unknown_plan(), UnknownPlan::Escalate)
65 }
66}
67
68#[derive(
72 Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
73)]
74#[serde(rename_all = "snake_case")]
75pub enum RiskLevel {
76 #[default]
78 Low,
79 Medium,
81 High,
83 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100#[non_exhaustive]
101pub struct Requirements {
102 pub approval: bool,
104 pub verification: bool,
107 pub no_automatic_retry: bool,
109}
110
111impl Requirements {
112 #[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#[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#[derive(Clone, Debug, Default, PartialEq, Eq)]
159pub struct RiskPolicy {
160 rules: Vec<(Selector, Requirements)>,
161}
162
163impl RiskPolicy {
164 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#[derive(Clone, Debug, Default)]
177#[must_use]
178pub struct PolicyBuilder {
179 rules: Vec<(Selector, Requirements)>,
180}
181
182impl PolicyBuilder {
183 pub fn new() -> Self {
185 Self::default()
186 }
187
188 pub fn for_risk(self, risk: RiskLevel) -> Self {
190 self.rule(Some(risk), None)
191 }
192
193 pub fn for_kind(self, kind: EffectKind) -> Self {
195 self.rule(None, Some(kind))
196 }
197
198 pub fn for_risk_and_kind(self, risk: RiskLevel, kind: EffectKind) -> Self {
200 self.rule(Some(risk), Some(kind))
201 }
202
203 pub fn auto_execute(self) -> Self {
206 self
207 }
208
209 pub fn require_approval(self) -> Self {
211 self.require(|r| r.approval = true)
212 }
213
214 pub fn require_verification(self) -> Self {
216 self.require(|r| r.verification = true)
217 }
218
219 pub fn disable_automatic_retry(self) -> Self {
222 self.require(|r| r.no_automatic_retry = true)
223 }
224
225 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 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}