Skip to main content

atm_core/boundary/
mail.rs

1use crate::error::AtmError;
2use crate::schema::{AtmMessageId, ThreadMode};
3use crate::types::{AgentName, IsoTimestamp, TaskId, TeamName};
4use atm_storage::contract::{Message, MessageKey};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8use super::sealed;
9pub use atm_storage::contract::{MailMessageState, MessageFingerprint};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(try_from = "String", into = "String")]
13pub struct ReplaySource(String);
14
15impl ReplaySource {
16    pub fn new(value: impl Into<String>) -> Result<Self, AtmError> {
17        let value = value.into();
18        if value.trim().is_empty() {
19            return Err(AtmError::validation(
20                "replay source must not be empty or whitespace-only",
21            ));
22        }
23        Ok(Self(value))
24    }
25
26    pub fn as_str(&self) -> &str {
27        &self.0
28    }
29}
30
31impl fmt::Display for ReplaySource {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.write_str(&self.0)
34    }
35}
36
37impl TryFrom<String> for ReplaySource {
38    type Error = AtmError;
39
40    fn try_from(value: String) -> Result<Self, Self::Error> {
41        Self::new(value)
42    }
43}
44
45impl From<ReplaySource> for String {
46    fn from(value: ReplaySource) -> Self {
47        value.0
48    }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52pub struct MailStoreIngestReplayState {
53    pub team: TeamName,
54    pub agent: AgentName,
55    /// Invariant: source must name one concrete ingest origin chosen by the
56    /// caller (for example a file path or inbox export id) and must never be
57    /// synthesized from an empty or whitespace-only string.
58    pub source: ReplaySource,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub last_fingerprint: Option<MessageFingerprint>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub last_ingested_at: Option<IsoTimestamp>,
63    #[serde(default)]
64    pub ingested_rows: u64,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct MailStoreHealthSnapshot {
69    pub team: TeamName,
70    pub agent: AgentName,
71    pub total_messages: u64,
72    pub pending_ack_messages: u64,
73    pub read_message_count: u64,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub latest_message_timestamp: Option<IsoTimestamp>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct MailStoreMailboxMetadataRow {
80    pub message_key: MessageKey,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub message_id: Option<AtmMessageId>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub parent_message_id: Option<AtmMessageId>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub thread_mode: Option<ThreadMode>,
87    pub from_agent: AgentName,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub summary: Option<String>,
90    pub message_at: IsoTimestamp,
91    pub read: bool,
92    pub requires_ack: bool,
93    pub pending_ack: bool,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub acknowledged_at: Option<IsoTimestamp>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub expires_at: Option<IsoTimestamp>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub task_id: Option<TaskId>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103pub struct MailStoreMailboxMetadataCounts {
104    pub total_messages: u64,
105    pub unread_message_count: u64,
106    pub pending_ack_messages: u64,
107}
108
109/// Stub mail-store upsert-message-state request for the Phase R skeleton.
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct UpsertMailMessageStateRequest {
112    pub team: TeamName,
113    pub agent: AgentName,
114    pub actor: AgentName,
115    pub state: MailMessageState,
116}
117
118/// Stub mail-store upsert-message-state response for the Phase R skeleton.
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
120pub struct UpsertMailMessageStateResponse {
121    pub state: MailMessageState,
122}
123
124/// Stub mail-store load-message-state request for the Phase R skeleton.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126pub struct LoadMailMessageStateRequest {
127    pub team: TeamName,
128    pub agent: AgentName,
129    pub actor: AgentName,
130    pub message_key: MessageKey,
131}
132
133/// Stub mail-store load-message-state response for the Phase R skeleton.
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135pub struct LoadMailMessageStateResponse {
136    #[serde(default)]
137    pub state: Option<MailMessageState>,
138}
139
140pub type DoctorFinding = crate::doctor::DoctorFinding;
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
143pub struct MailStoreDoctorReport {
144    pub findings: Vec<DoctorFinding>,
145}
146
147/// BOUNDARY-MailStore — see docs/atm-core/boundaries.md.
148#[deprecated(note = "use canonical atm-storage types instead")]
149pub trait MailStore: sealed::Sealed {
150    /// # Errors
151    ///
152    /// Returns `AtmError` when the mailbox transaction cannot be started,
153    /// executed, or committed safely.
154    fn upsert_message(&self, record: Message) -> Result<(), AtmError>;
155
156    /// # Errors
157    ///
158    /// Returns `AtmError` when the requested message cannot be loaded.
159    fn load_message(
160        &self,
161        team: &TeamName,
162        agent: &AgentName,
163        message_key: &MessageKey,
164    ) -> Result<Option<Message>, AtmError>;
165
166    /// # Errors
167    ///
168    /// Returns `AtmError` when mailbox metadata rows cannot be queried.
169    fn query_mailbox_metadata(
170        &self,
171        team: &TeamName,
172        agent: &AgentName,
173        limit: Option<usize>,
174    ) -> Result<Vec<MailStoreMailboxMetadataRow>, AtmError>;
175
176    /// # Errors
177    ///
178    /// Returns `AtmError` when mailbox metadata counts cannot be queried.
179    fn query_mailbox_metadata_counts(
180        &self,
181        team: &TeamName,
182        agent: &AgentName,
183    ) -> Result<MailStoreMailboxMetadataCounts, AtmError>;
184
185    /// # Errors
186    ///
187    /// Returns `AtmError` when message-state persistence fails.
188    fn upsert_message_state(
189        &self,
190        request: UpsertMailMessageStateRequest,
191    ) -> Result<UpsertMailMessageStateResponse, AtmError>;
192
193    /// # Errors
194    ///
195    /// Returns `AtmError` when message state cannot be loaded.
196    fn load_message_state(
197        &self,
198        request: LoadMailMessageStateRequest,
199    ) -> Result<LoadMailMessageStateResponse, AtmError>;
200
201    /// # Errors
202    ///
203    /// Returns `AtmError` when ingest-replay state persistence fails.
204    fn record_ingest_replay_state(
205        &self,
206        team: &TeamName,
207        agent: &AgentName,
208        source: &ReplaySource,
209        state: &MailStoreIngestReplayState,
210    ) -> Result<(), AtmError>;
211
212    /// # Errors
213    ///
214    /// Returns `AtmError` when ingest-replay state cannot be loaded.
215    fn load_ingest_replay_state(
216        &self,
217        team: &TeamName,
218        agent: &AgentName,
219        source: &ReplaySource,
220    ) -> Result<Option<MailStoreIngestReplayState>, AtmError>;
221
222    /// # Errors
223    ///
224    /// Returns `AtmError` when a mailbox health snapshot cannot be read.
225    fn health_snapshot(
226        &self,
227        team: &TeamName,
228        agent: &AgentName,
229    ) -> Result<MailStoreHealthSnapshot, AtmError>;
230}
231
232/// BOUNDARY-MailStoreDoctor — see docs/atm-core/boundaries.md.
233pub trait MailStoreDoctor: sealed::Sealed + Send + Sync {
234    /// # Errors
235    ///
236    /// Returns `AtmError` when durable mail-store diagnostics cannot be
237    /// collected or summarized into the shared doctor report shape.
238    fn inspect_mail_store(&self) -> Result<MailStoreDoctorReport, AtmError>;
239}