cellstate_core/
handoff.rs

1//! Handoff typestate for compile-time safety of handoff lifecycle.
2//!
3//! Uses the typestate pattern to make invalid state transitions uncompilable.
4//!
5//! # State Transition Diagram
6//!
7//! ```text
8//! create() → Initiated ──┬── accept() ──→ Accepted ── complete() → Completed
9//!                        └── reject() ──→ Rejected (terminal)
10//! ```
11
12use crate::{AgentId, EnumParseError, HandoffId, ScopeId, TenantId, Timestamp, TrajectoryId};
13use serde::{Deserialize, Serialize};
14use std::fmt;
15use std::marker::PhantomData;
16use std::str::FromStr;
17
18// ============================================================================
19// HANDOFF STATUS ENUM (replaces String)
20// ============================================================================
21
22/// Status of a handoff operation.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
26pub enum HandoffStatus {
27    /// Handoff has been initiated, waiting for acceptance
28    Initiated,
29    /// Handoff was accepted by the receiving agent
30    Accepted,
31    /// Handoff was rejected by the receiving agent
32    Rejected,
33    /// Handoff has been completed successfully
34    Completed,
35}
36
37impl HandoffStatus {
38    /// Convert to database string representation.
39    pub fn as_db_str(&self) -> &'static str {
40        match self {
41            HandoffStatus::Initiated => "initiated",
42            HandoffStatus::Accepted => "accepted",
43            HandoffStatus::Rejected => "rejected",
44            HandoffStatus::Completed => "completed",
45        }
46    }
47
48    /// Parse from database string representation.
49    pub fn from_db_str(s: &str) -> Result<Self, EnumParseError> {
50        match s.to_lowercase().as_str() {
51            "initiated" | "pending" => Ok(HandoffStatus::Initiated),
52            "accepted" => Ok(HandoffStatus::Accepted),
53            "rejected" => Ok(HandoffStatus::Rejected),
54            "completed" | "complete" => Ok(HandoffStatus::Completed),
55            _ => Err(EnumParseError {
56                enum_name: "HandoffStatus",
57                input: s.to_string(),
58            }),
59        }
60    }
61
62    /// Check if this is a terminal state (no further transitions possible).
63    pub fn is_terminal(&self) -> bool {
64        matches!(self, HandoffStatus::Rejected | HandoffStatus::Completed)
65    }
66}
67
68impl fmt::Display for HandoffStatus {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        write!(f, "{}", self.as_db_str())
71    }
72}
73
74impl FromStr for HandoffStatus {
75    type Err = EnumParseError;
76
77    fn from_str(s: &str) -> Result<Self, Self::Err> {
78        Self::from_db_str(s)
79    }
80}
81
82// ============================================================================
83// HANDOFF DATA (internal storage, state-independent)
84// ============================================================================
85
86/// Internal data storage for a handoff, independent of typestate.
87/// This is what gets persisted to the database.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
90pub struct HandoffRecord {
91    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
92    pub handoff_id: HandoffId,
93    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
94    pub tenant_id: TenantId,
95    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
96    pub from_agent_id: AgentId,
97    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
98    pub to_agent_id: AgentId,
99    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
100    pub trajectory_id: TrajectoryId,
101    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
102    pub scope_id: ScopeId,
103    pub reason: String,
104    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "byte"))]
105    pub context_snapshot: Vec<u8>,
106    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
107    pub created_at: Timestamp,
108    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = "date-time"))]
109    pub accepted_at: Option<Timestamp>,
110    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = "date-time"))]
111    pub completed_at: Option<Timestamp>,
112    /// Rejection reason (only set if rejected)
113    pub rejection_reason: Option<String>,
114}
115
116// ============================================================================
117// TYPESTATE MARKERS
118// ============================================================================
119
120/// Marker trait for handoff states.
121pub trait HandoffState: private::Sealed + Send + Sync {}
122
123/// Handoff has been initiated, waiting for acceptance.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct Initiated;
126impl HandoffState for Initiated {}
127
128/// Handoff was accepted by the receiving agent.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct HandoffAccepted;
131impl HandoffState for HandoffAccepted {}
132
133/// Handoff was rejected by the receiving agent (terminal).
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct Rejected;
136impl HandoffState for Rejected {}
137
138/// Handoff has been completed (terminal).
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct HandoffCompleted;
141impl HandoffState for HandoffCompleted {}
142
143mod private {
144    pub trait Sealed {}
145    impl Sealed for super::Initiated {}
146    impl Sealed for super::HandoffAccepted {}
147    impl Sealed for super::Rejected {}
148    impl Sealed for super::HandoffCompleted {}
149}
150
151// ============================================================================
152// HANDOFF TYPESTATE WRAPPER
153// ============================================================================
154
155/// A handoff with compile-time state tracking.
156///
157/// The type parameter `S` indicates the current state of the handoff.
158/// Methods are only available in appropriate states:
159/// - `Handoff<Initiated>`: Can be accepted or rejected
160/// - `Handoff<HandoffAccepted>`: Can be completed
161/// - `Handoff<Rejected>`: Terminal, no further transitions
162/// - `Handoff<HandoffCompleted>`: Terminal, no further transitions
163#[derive(Debug, Clone)]
164pub struct Handoff<S: HandoffState> {
165    data: HandoffRecord,
166    _state: PhantomData<S>,
167}
168
169impl<S: HandoffState> Handoff<S> {
170    /// Access the underlying handoff data (read-only).
171    pub fn data(&self) -> &HandoffRecord {
172        &self.data
173    }
174
175    /// Get the handoff ID.
176    pub fn handoff_id(&self) -> HandoffId {
177        self.data.handoff_id
178    }
179
180    /// Get the tenant ID.
181    pub fn tenant_id(&self) -> TenantId {
182        self.data.tenant_id
183    }
184
185    /// Get the source agent ID.
186    pub fn from_agent_id(&self) -> AgentId {
187        self.data.from_agent_id
188    }
189
190    /// Get the target agent ID.
191    pub fn to_agent_id(&self) -> AgentId {
192        self.data.to_agent_id
193    }
194
195    /// Get the trajectory ID.
196    pub fn trajectory_id(&self) -> TrajectoryId {
197        self.data.trajectory_id
198    }
199
200    /// Get the scope ID.
201    pub fn scope_id(&self) -> ScopeId {
202        self.data.scope_id
203    }
204
205    /// Get the handoff reason.
206    pub fn reason(&self) -> &str {
207        &self.data.reason
208    }
209
210    /// Get the context snapshot.
211    pub fn context_snapshot(&self) -> &[u8] {
212        &self.data.context_snapshot
213    }
214
215    /// Get when the handoff was created.
216    pub fn created_at(&self) -> Timestamp {
217        self.data.created_at
218    }
219
220    /// Consume and return the underlying data (for serialization).
221    pub fn into_data(self) -> HandoffRecord {
222        self.data
223    }
224}
225
226impl Handoff<Initiated> {
227    /// Create a new initiated handoff.
228    pub fn new(data: HandoffRecord) -> Self {
229        Handoff {
230            data,
231            _state: PhantomData,
232        }
233    }
234
235    /// Accept the handoff.
236    ///
237    /// Transitions to `Handoff<HandoffAccepted>`.
238    /// Consumes the current handoff.
239    pub fn accept(mut self, accepted_at: Timestamp) -> Handoff<HandoffAccepted> {
240        self.data.accepted_at = Some(accepted_at);
241        Handoff {
242            data: self.data,
243            _state: PhantomData,
244        }
245    }
246
247    /// Reject the handoff.
248    ///
249    /// Transitions to `Handoff<Rejected>` (terminal state).
250    /// Consumes the current handoff.
251    pub fn reject(mut self, reason: String) -> Handoff<Rejected> {
252        self.data.rejection_reason = Some(reason);
253        Handoff {
254            data: self.data,
255            _state: PhantomData,
256        }
257    }
258}
259
260impl Handoff<HandoffAccepted> {
261    /// Get when the handoff was accepted.
262    pub fn accepted_at(&self) -> Timestamp {
263        self.data
264            .accepted_at
265            .expect("Accepted handoff must have accepted_at")
266    }
267
268    /// Complete the handoff.
269    ///
270    /// Transitions to `Handoff<HandoffCompleted>` (terminal state).
271    /// Consumes the current handoff.
272    pub fn complete(mut self, completed_at: Timestamp) -> Handoff<HandoffCompleted> {
273        self.data.completed_at = Some(completed_at);
274        Handoff {
275            data: self.data,
276            _state: PhantomData,
277        }
278    }
279}
280
281impl Handoff<Rejected> {
282    /// Get the rejection reason.
283    pub fn rejection_reason(&self) -> &str {
284        self.data
285            .rejection_reason
286            .as_deref()
287            .unwrap_or("No reason provided")
288    }
289}
290
291impl Handoff<HandoffCompleted> {
292    /// Get when the handoff was accepted.
293    pub fn accepted_at(&self) -> Timestamp {
294        self.data
295            .accepted_at
296            .expect("Completed handoff must have accepted_at")
297    }
298
299    /// Get when the handoff was completed.
300    pub fn completed_at(&self) -> Timestamp {
301        self.data
302            .completed_at
303            .expect("Completed handoff must have completed_at")
304    }
305}
306
307// ============================================================================
308// DATABASE BOUNDARY: STORED HANDOFF
309// ============================================================================
310
311/// A handoff as stored in the database (status-agnostic).
312///
313/// When loading from the database, we don't know the state at compile time.
314/// Use the `into_*` methods to validate and convert to a typed handoff.
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
316pub struct StoredHandoff {
317    pub data: HandoffRecord,
318    pub status: HandoffStatus,
319}
320
321/// Enum representing all possible runtime states of a handoff.
322/// Use this when you need to handle handoffs loaded from the database.
323#[derive(Debug, Clone)]
324pub enum LoadedHandoff {
325    Initiated(Handoff<Initiated>),
326    Accepted(Handoff<HandoffAccepted>),
327    Rejected(Handoff<Rejected>),
328    Completed(Handoff<HandoffCompleted>),
329}
330
331impl StoredHandoff {
332    /// Convert to a typed handoff based on the stored status.
333    pub fn into_typed(self) -> LoadedHandoff {
334        match self.status {
335            HandoffStatus::Initiated => LoadedHandoff::Initiated(Handoff {
336                data: self.data,
337                _state: PhantomData,
338            }),
339            HandoffStatus::Accepted => LoadedHandoff::Accepted(Handoff {
340                data: self.data,
341                _state: PhantomData,
342            }),
343            HandoffStatus::Rejected => LoadedHandoff::Rejected(Handoff {
344                data: self.data,
345                _state: PhantomData,
346            }),
347            HandoffStatus::Completed => LoadedHandoff::Completed(Handoff {
348                data: self.data,
349                _state: PhantomData,
350            }),
351        }
352    }
353
354    /// Try to convert to an initiated handoff.
355    pub fn into_initiated(self) -> Result<Handoff<Initiated>, HandoffStateError> {
356        if self.status != HandoffStatus::Initiated {
357            return Err(HandoffStateError::WrongState {
358                handoff_id: self.data.handoff_id,
359                expected: HandoffStatus::Initiated,
360                actual: self.status,
361            });
362        }
363        Ok(Handoff {
364            data: self.data,
365            _state: PhantomData,
366        })
367    }
368
369    /// Try to convert to an accepted handoff.
370    pub fn into_accepted(self) -> Result<Handoff<HandoffAccepted>, HandoffStateError> {
371        if self.status != HandoffStatus::Accepted {
372            return Err(HandoffStateError::WrongState {
373                handoff_id: self.data.handoff_id,
374                expected: HandoffStatus::Accepted,
375                actual: self.status,
376            });
377        }
378        Ok(Handoff {
379            data: self.data,
380            _state: PhantomData,
381        })
382    }
383
384    /// Get the underlying data without state validation.
385    pub fn data(&self) -> &HandoffRecord {
386        &self.data
387    }
388
389    /// Get the current status.
390    pub fn status(&self) -> HandoffStatus {
391        self.status
392    }
393}
394
395/// Errors when transitioning handoff states.
396#[derive(Debug, Clone, PartialEq, Eq)]
397pub enum HandoffStateError {
398    /// Handoff is not in the expected state.
399    WrongState {
400        handoff_id: HandoffId,
401        expected: HandoffStatus,
402        actual: HandoffStatus,
403    },
404}
405
406impl fmt::Display for HandoffStateError {
407    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
408        match self {
409            HandoffStateError::WrongState {
410                handoff_id,
411                expected,
412                actual,
413            } => {
414                write!(
415                    f,
416                    "Handoff {} is in state {} but expected {}",
417                    handoff_id, actual, expected
418                )
419            }
420        }
421    }
422}
423
424impl std::error::Error for HandoffStateError {}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::EntityIdType;
430    use chrono::Utc;
431
432    fn make_handoff_data() -> HandoffRecord {
433        let now = Utc::now();
434        HandoffRecord {
435            handoff_id: HandoffId::now_v7(),
436            tenant_id: TenantId::now_v7(),
437            from_agent_id: AgentId::now_v7(),
438            to_agent_id: AgentId::now_v7(),
439            trajectory_id: TrajectoryId::now_v7(),
440            scope_id: ScopeId::now_v7(),
441            reason: "Need specialist".to_string(),
442            context_snapshot: vec![1, 2, 3],
443            created_at: now,
444            accepted_at: None,
445            completed_at: None,
446            rejection_reason: None,
447        }
448    }
449
450    #[test]
451    fn test_handoff_status_roundtrip() {
452        for status in [
453            HandoffStatus::Initiated,
454            HandoffStatus::Accepted,
455            HandoffStatus::Rejected,
456            HandoffStatus::Completed,
457        ] {
458            let db_str = status.as_db_str();
459            let parsed =
460                HandoffStatus::from_db_str(db_str).expect("HandoffStatus roundtrip should succeed");
461            assert_eq!(status, parsed);
462        }
463    }
464
465    #[test]
466    fn test_handoff_accept_complete() {
467        let now = Utc::now();
468        let data = make_handoff_data();
469        let handoff = Handoff::<Initiated>::new(data);
470
471        let accepted = handoff.accept(now);
472        assert_eq!(accepted.accepted_at(), now);
473
474        let completed = accepted.complete(now);
475        assert_eq!(completed.completed_at(), now);
476    }
477
478    #[test]
479    fn test_handoff_reject() {
480        let data = make_handoff_data();
481        let handoff = Handoff::<Initiated>::new(data);
482
483        let rejected = handoff.reject("Not available".to_string());
484        assert_eq!(rejected.rejection_reason(), "Not available");
485    }
486
487    #[test]
488    fn test_stored_handoff_conversion() {
489        let data = make_handoff_data();
490        let stored = StoredHandoff {
491            data: data.clone(),
492            status: HandoffStatus::Initiated,
493        };
494
495        let initiated = stored
496            .into_initiated()
497            .expect("initiated handoff should convert successfully");
498        assert_eq!(initiated.handoff_id(), data.handoff_id);
499    }
500
501    #[test]
502    fn test_stored_handoff_wrong_state() {
503        let data = make_handoff_data();
504        let stored = StoredHandoff {
505            data,
506            status: HandoffStatus::Accepted,
507        };
508
509        assert!(matches!(
510            stored.into_initiated(),
511            Err(HandoffStateError::WrongState { .. })
512        ));
513    }
514}