Skip to main content

atm_storage/
contract.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3use std::fmt;
4use std::ops::Deref;
5use std::str::FromStr;
6
7use crate::error::AtmError;
8use crate::schema::{AtmMessageId, InboxMessage, MessageEnvelope};
9use crate::types::{AgentName, IsoTimestamp, ModelName, PaneId, TaskId, TeamName};
10
11#[doc(hidden)]
12pub mod sealed {
13    pub trait Sealed {}
14}
15
16fn require_non_blank(
17    value: String,
18    subject: &str,
19    recovery: &'static str,
20) -> Result<String, AtmError> {
21    if value.trim().is_empty() {
22        return Err(
23            AtmError::validation(format!("{subject} must not be blank")).with_recovery(recovery)
24        );
25    }
26    Ok(value)
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
30#[serde(transparent)]
31pub struct MessageKey(String);
32
33impl MessageKey {
34    pub fn new(value: impl Into<String>) -> Result<Self, AtmError> {
35        require_non_blank(
36            value.into(),
37            "message key",
38            "Populate a stable ATM message key before calling the storage contract.",
39        )
40        .map(Self)
41    }
42
43    pub fn into_inner(self) -> String {
44        self.0
45    }
46
47    pub fn as_str(&self) -> &str {
48        &self.0
49    }
50
51    pub fn as_atm_message_id(&self) -> Result<AtmMessageId, AtmError> {
52        let raw = self.as_str().strip_prefix("atm:").unwrap_or(self.as_str());
53        raw.parse::<AtmMessageId>()
54            .map_err(|error| AtmError::validation(format!("message key parse failed: {error}")))
55    }
56
57    fn from_atm_message_id(value: AtmMessageId) -> Self {
58        let mut key = String::with_capacity(4 + value.to_string().len());
59        key.push_str("atm:");
60        key.push_str(&value.to_string());
61        Self(key)
62    }
63}
64
65impl From<AtmMessageId> for MessageKey {
66    fn from(value: AtmMessageId) -> Self {
67        Self::from_atm_message_id(value)
68    }
69}
70
71impl FromStr for MessageKey {
72    type Err = AtmError;
73
74    fn from_str(value: &str) -> Result<Self, Self::Err> {
75        Self::new(value)
76    }
77}
78
79impl fmt::Display for MessageKey {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.write_str(self.as_str())
82    }
83}
84
85impl AsRef<str> for MessageKey {
86    fn as_ref(&self) -> &str {
87        self.as_str()
88    }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
92#[serde(transparent)]
93pub struct TaskState(String);
94
95impl TaskState {
96    pub fn new(value: impl Into<String>) -> Result<Self, AtmError> {
97        require_non_blank(
98            value.into(),
99            "task state",
100            "Populate a non-empty task state before calling the storage contract.",
101        )
102        .map(Self)
103    }
104}
105
106impl AsRef<str> for TaskState {
107    fn as_ref(&self) -> &str {
108        &self.0
109    }
110}
111
112impl Deref for TaskState {
113    type Target = str;
114
115    fn deref(&self) -> &Self::Target {
116        &self.0
117    }
118}
119
120impl fmt::Display for TaskState {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        f.write_str(self.as_ref())
123    }
124}
125
126impl FromStr for TaskState {
127    type Err = AtmError;
128
129    fn from_str(value: &str) -> Result<Self, Self::Err> {
130        Self::new(value)
131    }
132}
133
134impl PartialEq<&str> for TaskState {
135    fn eq(&self, other: &&str) -> bool {
136        self.as_ref() == *other
137    }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
141#[serde(transparent)]
142pub struct AckTransition(String);
143
144impl AckTransition {
145    pub fn new(value: impl Into<String>) -> Result<Self, AtmError> {
146        require_non_blank(
147            value.into(),
148            "ack transition",
149            "Populate a non-empty ack transition before calling the storage contract.",
150        )
151        .map(Self)
152    }
153}
154
155impl AsRef<str> for AckTransition {
156    fn as_ref(&self) -> &str {
157        &self.0
158    }
159}
160
161impl Deref for AckTransition {
162    type Target = str;
163
164    fn deref(&self) -> &Self::Target {
165        &self.0
166    }
167}
168
169impl fmt::Display for AckTransition {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.write_str(self.as_ref())
172    }
173}
174
175impl FromStr for AckTransition {
176    type Err = AtmError;
177
178    fn from_str(value: &str) -> Result<Self, Self::Err> {
179        Self::new(value)
180    }
181}
182
183#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
184#[serde(rename_all = "snake_case")]
185pub enum BuiltInNudgeTemplateKind {
186    Delivery,
187    DeliveryAck,
188    DeliveryTask,
189    DeliveryTaskAck,
190    Acknowledge,
191    AcknowledgeTask,
192}
193
194impl BuiltInNudgeTemplateKind {
195    pub const fn as_str(self) -> &'static str {
196        match self {
197            Self::Delivery => "delivery",
198            Self::DeliveryAck => "delivery_ack",
199            Self::DeliveryTask => "delivery_task",
200            Self::DeliveryTaskAck => "delivery_task_ack",
201            Self::Acknowledge => "acknowledge",
202            Self::AcknowledgeTask => "acknowledge_task",
203        }
204    }
205}
206
207impl fmt::Display for BuiltInNudgeTemplateKind {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        f.write_str(self.as_str())
210    }
211}
212
213impl FromStr for BuiltInNudgeTemplateKind {
214    type Err = AtmError;
215
216    fn from_str(value: &str) -> Result<Self, Self::Err> {
217        match value {
218            "delivery" => Ok(Self::Delivery),
219            "delivery_ack" => Ok(Self::DeliveryAck),
220            "delivery_task" => Ok(Self::DeliveryTask),
221            "delivery_task_ack" => Ok(Self::DeliveryTaskAck),
222            "acknowledge" => Ok(Self::Acknowledge),
223            "acknowledge_task" => Ok(Self::AcknowledgeTask),
224            other => Err(AtmError::validation(format!(
225                "unsupported built-in nudge template kind `{other}`"
226            ))
227            .with_recovery(
228                "Use one of delivery, delivery_ack, delivery_task, delivery_task_ack, acknowledge, or acknowledge_task.",
229            )),
230        }
231    }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
235#[serde(rename_all = "kebab-case")]
236pub enum TeamNudgeTemplateOverrideMode {
237    Override { template_body: String },
238    Disabled,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
242pub struct TeamNudgeTemplateOverrideRow {
243    pub team_name: TeamName,
244    pub kind: BuiltInNudgeTemplateKind,
245    pub mode: TeamNudgeTemplateOverrideMode,
246    pub updated_at: IsoTimestamp,
247}
248
249impl TeamNudgeTemplateOverrideRow {
250    pub fn template_body(&self) -> Option<&str> {
251        match &self.mode {
252            TeamNudgeTemplateOverrideMode::Override { template_body } => Some(template_body),
253            TeamNudgeTemplateOverrideMode::Disabled => None,
254        }
255    }
256
257    pub fn is_disabled(&self) -> bool {
258        matches!(self.mode, TeamNudgeTemplateOverrideMode::Disabled)
259    }
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum AckRequirementState {
265    NotRequired,
266    RequiredPending,
267    RequiredAcknowledged,
268}
269
270pub fn derive_ack_requirement(message: &InboxMessage) -> AckRequirementState {
271    if !message.requires_ack {
272        AckRequirementState::NotRequired
273    } else if message.acknowledged_at.is_some() {
274        AckRequirementState::RequiredAcknowledged
275    } else {
276        AckRequirementState::RequiredPending
277    }
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
281pub struct Message {
282    pub team: TeamName,
283    pub agent: AgentName,
284    pub message_key: MessageKey,
285    pub envelope: MessageEnvelope,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
289pub struct MailMessageState {
290    pub team: TeamName,
291    pub agent: AgentName,
292    pub actor: AgentName,
293    pub message_key: MessageKey,
294    pub read: bool,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub pending_ack_at: Option<IsoTimestamp>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub acknowledged_at: Option<IsoTimestamp>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub expires_at: Option<IsoTimestamp>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub deleted_at: Option<IsoTimestamp>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub updated_at: Option<IsoTimestamp>,
305}
306
307/// Opaque hash or content-addressable identifier that marks the last
308/// successfully ingested message boundary for a replay source.
309#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
310#[serde(transparent)]
311pub struct MessageFingerprint(String);
312
313impl MessageFingerprint {
314    pub fn new(value: impl Into<String>) -> Self {
315        Self(value.into())
316    }
317
318    pub fn as_str(&self) -> &str {
319        &self.0
320    }
321}
322
323impl fmt::Display for MessageFingerprint {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        f.write_str(self.as_str())
326    }
327}
328
329impl From<String> for MessageFingerprint {
330    fn from(value: String) -> Self {
331        Self::new(value)
332    }
333}
334
335impl AsRef<str> for MessageFingerprint {
336    fn as_ref(&self) -> &str {
337        self.as_str()
338    }
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
342pub struct MessageQuery {
343    pub team: TeamName,
344    pub agent: AgentName,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub sender: Option<AgentName>,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub task_id: Option<TaskId>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub limit: Option<usize>,
351}
352
353#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
354#[serde(rename_all = "kebab-case")]
355pub enum RosterMemberKind {
356    Permanent,
357    Ephemeral,
358}
359
360#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
361#[serde(rename_all = "kebab-case")]
362pub enum RosterHarness {
363    ClaudeCode,
364    CodexCli,
365    GeminiCli,
366    Opencode,
367}
368
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub enum AgentType {
371    GeneralPurpose,
372    Plan,
373    Lead,
374    Qa,
375    Worker,
376    Unknown(String),
377}
378
379impl Default for AgentType {
380    fn default() -> Self {
381        Self::Unknown(String::new())
382    }
383}
384
385impl From<String> for AgentType {
386    fn from(value: String) -> Self {
387        match value.as_str() {
388            "general-purpose" => Self::GeneralPurpose,
389            "plan" => Self::Plan,
390            "lead" => Self::Lead,
391            "qa" => Self::Qa,
392            "worker" => Self::Worker,
393            _ => Self::Unknown(value),
394        }
395    }
396}
397
398impl AgentType {
399    pub fn as_str(&self) -> &str {
400        match self {
401            Self::GeneralPurpose => "general-purpose",
402            Self::Plan => "plan",
403            Self::Lead => "lead",
404            Self::Qa => "qa",
405            Self::Worker => "worker",
406            Self::Unknown(value) => value,
407        }
408    }
409}
410
411impl From<AgentType> for String {
412    fn from(value: AgentType) -> Self {
413        value.as_str().to_string()
414    }
415}
416
417impl Serialize for AgentType {
418    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
419    where
420        S: serde::Serializer,
421    {
422        serializer.serialize_str(self.as_str())
423    }
424}
425
426impl fmt::Display for AgentType {
427    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428        f.write_str(self.as_str())
429    }
430}
431
432impl<'de> Deserialize<'de> for AgentType {
433    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
434    where
435        D: serde::Deserializer<'de>,
436    {
437        Ok(Self::from(String::deserialize(deserializer)?))
438    }
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
442pub struct RosterMember {
443    pub team_name: TeamName,
444    pub agent_name: AgentName,
445    pub member_kind: RosterMemberKind,
446    pub harness: RosterHarness,
447    #[serde(default)]
448    pub agent_type: AgentType,
449    #[serde(default)]
450    pub model: ModelName,
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub recipient_pane_id: Option<PaneId>,
453    #[serde(default)]
454    pub metadata_json: Map<String, Value>,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
458pub struct RosterSnapshot {
459    pub team_name: TeamName,
460    pub members: Vec<RosterMember>,
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub refreshed_at: Option<IsoTimestamp>,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
466pub struct MessageReceivedEvent {
467    pub team: TeamName,
468    pub agent: AgentName,
469    pub message_key: MessageKey,
470    pub timestamp: IsoTimestamp,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
474pub struct RosterChangedEvent {
475    pub team: TeamName,
476    pub member_count: usize,
477    pub timestamp: IsoTimestamp,
478}
479
480pub trait MessageStore {
481    fn save_message(&self, message: &Message) -> Result<(), AtmError>;
482    fn load_message(&self, key: &MessageKey) -> Result<Option<Message>, AtmError>;
483    fn list_messages(&self, query: &MessageQuery) -> Result<Vec<Message>, AtmError>;
484    fn delete_message(&self, key: &MessageKey) -> Result<(), AtmError>;
485}
486
487pub trait RosterStore {
488    fn load_roster(&self, team: &TeamName) -> Result<RosterSnapshot, AtmError>;
489    fn save_roster(&self, roster: &RosterSnapshot) -> Result<(), AtmError>;
490    fn list_teams(&self) -> Result<Vec<TeamName>, AtmError>;
491}
492
493pub trait StorageNotifier {
494    fn message_received(&self, event: &MessageReceivedEvent) -> Result<(), AtmError>;
495    fn roster_changed(&self, event: &RosterChangedEvent) -> Result<(), AtmError>;
496}
497
498pub trait NudgeTemplateOverrideStore: sealed::Sealed + Send + Sync {
499    fn load_template_override(
500        &self,
501        team: &TeamName,
502        kind: BuiltInNudgeTemplateKind,
503    ) -> Result<Option<TeamNudgeTemplateOverrideRow>, AtmError>;
504
505    fn save_template_override(
506        &self,
507        team: &TeamName,
508        kind: BuiltInNudgeTemplateKind,
509        template_body: &str,
510    ) -> Result<TeamNudgeTemplateOverrideRow, AtmError>;
511
512    fn disable_template_override(
513        &self,
514        team: &TeamName,
515        kind: BuiltInNudgeTemplateKind,
516    ) -> Result<TeamNudgeTemplateOverrideRow, AtmError>;
517
518    fn clear_template_override(
519        &self,
520        team: &TeamName,
521        kind: BuiltInNudgeTemplateKind,
522    ) -> Result<bool, AtmError>;
523}
524
525#[cfg(test)]
526mod tests {
527    use super::{
528        AckRequirementState, BuiltInNudgeTemplateKind, Message, MessageKey, MessageQuery,
529        MessageReceivedEvent, MessageStore, NudgeTemplateOverrideStore, RosterChangedEvent,
530        RosterHarness, RosterMember, RosterMemberKind, RosterSnapshot, RosterStore,
531        StorageNotifier, TeamNudgeTemplateOverrideMode, TeamNudgeTemplateOverrideRow,
532        derive_ack_requirement, sealed,
533    };
534    use crate::ROLE_WORKER;
535    use crate::error::AtmError;
536    use crate::schema::MessageEnvelope;
537    use crate::types::{AgentName, IsoTimestamp, ModelName, TeamName};
538    use chrono::Utc;
539    use serde_json::Map;
540
541    #[derive(Default)]
542    struct DummyStore;
543
544    #[derive(Default)]
545    struct DummyNudgeTemplateOverrideStore;
546
547    impl MessageStore for DummyStore {
548        fn save_message(&self, _message: &Message) -> Result<(), AtmError> {
549            Ok(())
550        }
551
552        fn load_message(&self, _key: &MessageKey) -> Result<Option<Message>, AtmError> {
553            Ok(None)
554        }
555
556        fn list_messages(&self, _query: &MessageQuery) -> Result<Vec<Message>, AtmError> {
557            Ok(Vec::new())
558        }
559
560        fn delete_message(&self, _key: &MessageKey) -> Result<(), AtmError> {
561            Ok(())
562        }
563    }
564
565    impl RosterStore for DummyStore {
566        fn load_roster(&self, team: &TeamName) -> Result<RosterSnapshot, AtmError> {
567            Ok(RosterSnapshot {
568                team_name: team.clone(),
569                members: Vec::new(),
570                refreshed_at: None,
571            })
572        }
573
574        fn save_roster(&self, _roster: &RosterSnapshot) -> Result<(), AtmError> {
575            Ok(())
576        }
577
578        fn list_teams(&self) -> Result<Vec<TeamName>, AtmError> {
579            Ok(Vec::new())
580        }
581    }
582
583    impl StorageNotifier for DummyStore {
584        fn message_received(&self, _event: &MessageReceivedEvent) -> Result<(), AtmError> {
585            Ok(())
586        }
587
588        fn roster_changed(&self, _event: &RosterChangedEvent) -> Result<(), AtmError> {
589            Ok(())
590        }
591    }
592
593    impl sealed::Sealed for DummyNudgeTemplateOverrideStore {}
594
595    impl NudgeTemplateOverrideStore for DummyNudgeTemplateOverrideStore {
596        fn load_template_override(
597            &self,
598            _team: &TeamName,
599            _kind: BuiltInNudgeTemplateKind,
600        ) -> Result<Option<TeamNudgeTemplateOverrideRow>, AtmError> {
601            Ok(None)
602        }
603
604        fn save_template_override(
605            &self,
606            team: &TeamName,
607            kind: BuiltInNudgeTemplateKind,
608            template_body: &str,
609        ) -> Result<TeamNudgeTemplateOverrideRow, AtmError> {
610            Ok(TeamNudgeTemplateOverrideRow {
611                team_name: team.clone(),
612                kind,
613                mode: TeamNudgeTemplateOverrideMode::Override {
614                    template_body: template_body.to_string(),
615                },
616                updated_at: IsoTimestamp::from_datetime(Utc::now()),
617            })
618        }
619
620        fn disable_template_override(
621            &self,
622            team: &TeamName,
623            kind: BuiltInNudgeTemplateKind,
624        ) -> Result<TeamNudgeTemplateOverrideRow, AtmError> {
625            Ok(TeamNudgeTemplateOverrideRow {
626                team_name: team.clone(),
627                kind,
628                mode: TeamNudgeTemplateOverrideMode::Disabled,
629                updated_at: IsoTimestamp::from_datetime(Utc::now()),
630            })
631        }
632
633        fn clear_template_override(
634            &self,
635            _team: &TeamName,
636            _kind: BuiltInNudgeTemplateKind,
637        ) -> Result<bool, AtmError> {
638            Ok(true)
639        }
640    }
641
642    #[test]
643    fn storage_traits_are_object_safe() {
644        let store = DummyStore;
645        let message_store: &dyn MessageStore = &store;
646        let roster_store: &dyn RosterStore = &store;
647        let notifier: &dyn StorageNotifier = &store;
648        let override_store: &dyn NudgeTemplateOverrideStore = &DummyNudgeTemplateOverrideStore;
649
650        let team: TeamName = "test-team".parse().expect("team");
651        let agent: AgentName = ROLE_WORKER.parse().expect("agent");
652        let key = MessageKey::new("atm:test-1").expect("key");
653
654        let message = Message {
655            team: team.clone(),
656            agent: agent.clone(),
657            message_key: key.clone(),
658            envelope: MessageEnvelope {
659                from: agent.clone(),
660                text: "hello".to_string(),
661                timestamp: IsoTimestamp::from_datetime(Utc::now()),
662                read: false,
663                source_team: Some(team.clone()),
664                summary: None,
665                message_id: None,
666                requires_ack: false,
667                pending_ack_at: None,
668                acknowledged_at: None,
669                acknowledges_message_id: None,
670                parent_message_id: None,
671                thread_mode: None,
672                expires_at: None,
673                task_id: None,
674                extra: Map::new(),
675            },
676        };
677        let roster = RosterSnapshot {
678            team_name: team.clone(),
679            members: vec![RosterMember {
680                team_name: team.clone(),
681                agent_name: agent.clone(),
682                member_kind: RosterMemberKind::Permanent,
683                harness: RosterHarness::ClaudeCode,
684                agent_type: super::AgentType::Worker,
685                model: ModelName::default(),
686                recipient_pane_id: None,
687                metadata_json: Map::new(),
688            }],
689            refreshed_at: None,
690        };
691
692        message_store.save_message(&message).expect("save message");
693        assert!(message_store.load_message(&key).expect("load").is_none());
694        assert!(
695            message_store
696                .list_messages(&MessageQuery {
697                    team: team.clone(),
698                    agent: agent.clone(),
699                    sender: None,
700                    task_id: None,
701                    limit: Some(5),
702                })
703                .expect("list")
704                .is_empty()
705        );
706        message_store.delete_message(&key).expect("delete");
707
708        roster_store.save_roster(&roster).expect("save roster");
709        assert_eq!(
710            roster_store
711                .load_roster(&team)
712                .expect("load roster")
713                .team_name,
714            team
715        );
716        assert!(roster_store.list_teams().expect("list teams").is_empty());
717
718        notifier
719            .message_received(&MessageReceivedEvent {
720                team: team.clone(),
721                agent: agent.clone(),
722                message_key: key,
723                timestamp: IsoTimestamp::from_datetime(Utc::now()),
724            })
725            .expect("message notification");
726        notifier
727            .roster_changed(&RosterChangedEvent {
728                team,
729                member_count: 1,
730                timestamp: IsoTimestamp::from_datetime(Utc::now()),
731            })
732            .expect("roster notification");
733
734        let override_row = override_store
735            .save_template_override(
736                &"test-team".parse().expect("team"),
737                BuiltInNudgeTemplateKind::DeliveryAck,
738                "<atm/>",
739            )
740            .expect("save override");
741        assert_eq!(override_row.kind, BuiltInNudgeTemplateKind::DeliveryAck);
742    }
743
744    #[test]
745    fn derive_ack_requirement_ignores_task_id_and_uses_only_requires_ack_and_acknowledged_at() {
746        let base = MessageEnvelope {
747            from: "sender".parse().expect("agent"),
748            text: "hello".to_string(),
749            timestamp: IsoTimestamp::from_datetime(Utc::now()),
750            read: false,
751            source_team: Some("test-team".parse().expect("team")),
752            summary: None,
753            message_id: None,
754            requires_ack: false,
755            pending_ack_at: None,
756            acknowledged_at: None,
757            acknowledges_message_id: None,
758            parent_message_id: None,
759            thread_mode: None,
760            expires_at: None,
761            task_id: Some("AD.99".parse().expect("task")),
762            extra: Map::new(),
763        };
764
765        assert_eq!(
766            derive_ack_requirement(&base),
767            AckRequirementState::NotRequired
768        );
769
770        let mut pending = base.clone();
771        pending.requires_ack = true;
772        assert_eq!(
773            derive_ack_requirement(&pending),
774            AckRequirementState::RequiredPending
775        );
776
777        pending.acknowledged_at = Some(IsoTimestamp::from_datetime(Utc::now()));
778        assert_eq!(
779            derive_ack_requirement(&pending),
780            AckRequirementState::RequiredAcknowledged
781        );
782    }
783}