Skip to main content

aivcs_core/hitl_controls/
checkpoint.rs

1//! Approval checkpoints — the core unit of HITL gating.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use super::risk::RiskTier;
8
9/// An approval checkpoint inserted into a run or pipeline.
10///
11/// When a checkpoint is reached during execution, the system pauses and
12/// waits for the required approvals before proceeding.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct ApprovalCheckpoint {
15    /// Unique identifier for this checkpoint.
16    pub checkpoint_id: String,
17    /// Human-readable label describing what is being gated.
18    pub label: String,
19    /// The run this checkpoint belongs to.
20    pub run_id: Uuid,
21    /// Risk tier determining the approval requirements.
22    pub risk_tier: RiskTier,
23    /// When the checkpoint was created.
24    pub created_at: DateTime<Utc>,
25    /// Deadline after which the checkpoint expires (auto-reject).
26    pub expires_at: Option<DateTime<Utc>>,
27    /// Current status of the checkpoint.
28    pub status: CheckpointStatus,
29    /// Explanation of what this action does and why it needs approval.
30    pub explanation: ExplainabilitySummary,
31}
32
33/// Status of an approval checkpoint.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum CheckpointStatus {
37    /// Waiting for required approvals.
38    Pending,
39    /// All required approvals received — execution may continue.
40    Approved,
41    /// Rejected by a reviewer.
42    Rejected { reason: String },
43    /// The checkpoint expired without sufficient approvals.
44    Expired,
45    /// Execution was paused by an operator intervention.
46    Paused,
47}
48
49impl CheckpointStatus {
50    /// Whether the checkpoint allows execution to proceed.
51    pub fn allows_proceed(&self) -> bool {
52        matches!(self, Self::Approved)
53    }
54
55    /// Whether the checkpoint is in a terminal state.
56    pub fn is_terminal(&self) -> bool {
57        matches!(self, Self::Approved | Self::Rejected { .. } | Self::Expired)
58    }
59}
60
61/// Explainability summary attached to every checkpoint.
62///
63/// Provides context for what the gated action does and why it was flagged.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ExplainabilitySummary {
66    /// Brief description of the action being gated.
67    pub action_description: String,
68    /// What changed compared to the previous state.
69    pub changes_summary: String,
70    /// Why this action was flagged for review.
71    pub flag_reason: String,
72}
73
74impl ApprovalCheckpoint {
75    /// Create a new pending checkpoint.
76    pub fn new(
77        label: impl Into<String>,
78        run_id: Uuid,
79        risk_tier: RiskTier,
80        explanation: ExplainabilitySummary,
81        timeout_secs: Option<u64>,
82        now: DateTime<Utc>,
83    ) -> Self {
84        let expires_at = timeout_secs.map(|s| now + chrono::Duration::seconds(s as i64));
85        Self {
86            checkpoint_id: Uuid::new_v4().to_string(),
87            label: label.into(),
88            run_id,
89            risk_tier,
90            created_at: now,
91            expires_at,
92            status: CheckpointStatus::Pending,
93            explanation,
94        }
95    }
96
97    /// Check whether this checkpoint has expired at the given time.
98    pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
99        self.expires_at.is_some_and(|exp| now >= exp)
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    fn sample_explanation() -> ExplainabilitySummary {
108        ExplainabilitySummary {
109            action_description: "deploy to production".into(),
110            changes_summary: "bumps API version from v2 to v3".into(),
111            flag_reason: "production deploy is high-risk".into(),
112        }
113    }
114
115    #[test]
116    fn test_new_checkpoint_is_pending() {
117        let cp = ApprovalCheckpoint::new(
118            "deploy-prod",
119            Uuid::new_v4(),
120            RiskTier::High,
121            sample_explanation(),
122            Some(300),
123            Utc::now(),
124        );
125        assert_eq!(cp.status, CheckpointStatus::Pending);
126        assert!(cp.expires_at.is_some());
127    }
128
129    #[test]
130    fn test_checkpoint_status_allows_proceed() {
131        assert!(CheckpointStatus::Approved.allows_proceed());
132        assert!(!CheckpointStatus::Pending.allows_proceed());
133        assert!(!CheckpointStatus::Expired.allows_proceed());
134        assert!(!CheckpointStatus::Rejected {
135            reason: "no".into()
136        }
137        .allows_proceed());
138    }
139
140    #[test]
141    fn test_checkpoint_status_is_terminal() {
142        assert!(!CheckpointStatus::Pending.is_terminal());
143        assert!(!CheckpointStatus::Paused.is_terminal());
144        assert!(CheckpointStatus::Approved.is_terminal());
145        assert!(CheckpointStatus::Expired.is_terminal());
146        assert!(CheckpointStatus::Rejected { reason: "x".into() }.is_terminal());
147    }
148
149    #[test]
150    fn test_is_expired_at() {
151        let now = Utc::now();
152        let cp = ApprovalCheckpoint::new(
153            "test",
154            Uuid::new_v4(),
155            RiskTier::High,
156            sample_explanation(),
157            Some(60),
158            now,
159        );
160        assert!(!cp.is_expired_at(now));
161        assert!(cp.is_expired_at(now + chrono::Duration::seconds(61)));
162    }
163
164    #[test]
165    fn test_no_expiry_never_expires() {
166        let cp = ApprovalCheckpoint::new(
167            "test",
168            Uuid::new_v4(),
169            RiskTier::Critical,
170            sample_explanation(),
171            None,
172            Utc::now(),
173        );
174        assert!(!cp.is_expired_at(Utc::now() + chrono::Duration::days(365)));
175    }
176
177    #[test]
178    fn test_serde_roundtrip() {
179        let cp = ApprovalCheckpoint::new(
180            "deploy-staging",
181            Uuid::new_v4(),
182            RiskTier::High,
183            sample_explanation(),
184            Some(120),
185            Utc::now(),
186        );
187        let json = serde_json::to_string(&cp).unwrap();
188        let back: ApprovalCheckpoint = serde_json::from_str(&json).unwrap();
189        assert_eq!(cp, back);
190    }
191}