Skip to main content

codewhale_protocol/
agent_mail.rs

1//! Canonical protocol contract for durable communication between Codewhale tasks.
2//!
3//! Agent Mail is distinct from same-session subagent control messages. An envelope
4//! is persisted by the runtime, scoped to an owner and workspace, and projected
5//! into a destination turn only at an explicit safe boundary. The summary is the
6//! complete model-visible payload: runtimes must sanitize it before constructing
7//! an envelope, and this module enforces the wire-size and control-character
8//! boundary.
9
10use std::error::Error;
11use std::fmt;
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Deserializer, Serialize};
15use uuid::Uuid;
16
17pub const AGENT_MAIL_SCHEMA_VERSION: u32 = 1;
18
19pub const AGENT_MAIL_EVENT_QUEUED: &str = "agent_mail.queued";
20pub const AGENT_MAIL_EVENT_DELIVERING: &str = "agent_mail.delivering";
21pub const AGENT_MAIL_EVENT_DELIVERED: &str = "agent_mail.delivered";
22pub const AGENT_MAIL_EVENT_READ: &str = "agent_mail.read";
23pub const AGENT_MAIL_EVENT_DELIVERY_FAILED: &str = "agent_mail.delivery_failed";
24
25pub const MAX_AGENT_MAIL_MESSAGE_ID_BYTES: usize = 80;
26pub const MAX_AGENT_MAIL_OPAQUE_ID_BYTES: usize = 128;
27pub const MAX_AGENT_MAIL_DISPLAY_LABEL_BYTES: usize = 64;
28pub const MAX_AGENT_MAIL_SUMMARY_BYTES: usize = 2_048;
29pub const MAX_AGENT_MAIL_EVIDENCE_REFS: usize = 8;
30pub const MAX_AGENT_MAIL_EVIDENCE_LABEL_BYTES: usize = 96;
31pub const MAX_AGENT_MAIL_HOPS: u8 = 4;
32pub const MAX_AGENT_MAIL_DELIVERY_ATTEMPTS: u8 = 8;
33pub const MAX_AGENT_MAIL_FAILURE_MESSAGE_BYTES: usize = 256;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct AgentMailValidationError {
37    pub field: &'static str,
38    pub message: String,
39}
40
41impl AgentMailValidationError {
42    fn new(field: &'static str, message: impl Into<String>) -> Self {
43        Self {
44            field,
45            message: message.into(),
46        }
47    }
48}
49
50impl fmt::Display for AgentMailValidationError {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        write!(f, "invalid Agent Mail {}: {}", self.field, self.message)
53    }
54}
55
56impl Error for AgentMailValidationError {}
57
58/// Stable, caller-supplied idempotency key for an Agent Mail envelope.
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
60#[serde(transparent)]
61pub struct AgentMailMessageId(String);
62
63impl AgentMailMessageId {
64    #[must_use]
65    pub fn new() -> Self {
66        Self(format!("mail_{}", Uuid::new_v4().simple()))
67    }
68
69    pub fn parse(value: impl Into<String>) -> Result<Self, AgentMailValidationError> {
70        let value = value.into();
71        validate_message_id(&value)?;
72        Ok(Self(value))
73    }
74
75    #[must_use]
76    pub fn as_str(&self) -> &str {
77        &self.0
78    }
79
80    #[must_use]
81    pub fn into_string(self) -> String {
82        self.0
83    }
84}
85
86impl Default for AgentMailMessageId {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl fmt::Display for AgentMailMessageId {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.write_str(&self.0)
95    }
96}
97
98impl TryFrom<String> for AgentMailMessageId {
99    type Error = AgentMailValidationError;
100
101    fn try_from(value: String) -> Result<Self, Self::Error> {
102        Self::parse(value)
103    }
104}
105
106impl TryFrom<&str> for AgentMailMessageId {
107    type Error = AgentMailValidationError;
108
109    fn try_from(value: &str) -> Result<Self, Self::Error> {
110        Self::parse(value)
111    }
112}
113
114impl<'de> Deserialize<'de> for AgentMailMessageId {
115    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
116    where
117        D: Deserializer<'de>,
118    {
119        let value = String::deserialize(deserializer)?;
120        Self::parse(value).map_err(serde::de::Error::custom)
121    }
122}
123
124/// Durable ownership and routing scope resolved by the receiving runtime.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126pub struct AgentMailAddress {
127    pub owner_id: String,
128    pub workspace_id: String,
129    pub thread_id: String,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub task_id: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub session_id: Option<String>,
134}
135
136impl AgentMailAddress {
137    pub fn validate(&self) -> Result<(), AgentMailValidationError> {
138        validate_opaque_id("address.owner_id", &self.owner_id)?;
139        validate_opaque_id("address.workspace_id", &self.workspace_id)?;
140        validate_opaque_id("address.thread_id", &self.thread_id)?;
141        if let Some(task_id) = &self.task_id {
142            validate_opaque_id("address.task_id", task_id)?;
143        }
144        if let Some(session_id) = &self.session_id {
145            validate_opaque_id("address.session_id", session_id)?;
146        }
147        if self.task_id.is_none() && self.session_id.is_none() {
148            return Err(AgentMailValidationError::new(
149                "address",
150                "task_id or session_id is required",
151            ));
152        }
153        Ok(())
154    }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158pub struct AgentMailSender {
159    /// Runtime-authorized stable identity; never a free-form transcript name.
160    pub identity: String,
161    pub display_label: String,
162}
163
164impl AgentMailSender {
165    pub fn validate(&self) -> Result<(), AgentMailValidationError> {
166        validate_opaque_id("sender.identity", &self.identity)?;
167        validate_bounded_text(
168            "sender.display_label",
169            &self.display_label,
170            MAX_AGENT_MAIL_DISPLAY_LABEL_BYTES,
171            false,
172        )
173    }
174}
175
176#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
177#[serde(rename_all = "snake_case")]
178pub enum AgentMailDeliveryMode {
179    QueueOnly,
180    WakeAtSafeBoundary,
181}
182
183#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
184#[serde(rename_all = "snake_case")]
185pub enum AgentMailEvidenceKind {
186    RuntimeEvent,
187    TurnItem,
188    ArtifactReceipt,
189}
190
191/// Bounded pointer to evidence already authorized by the destination runtime.
192///
193/// `reference_id` is deliberately opaque: paths and URLs are not valid evidence
194/// references and must not be smuggled through this contract.
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
196pub struct AgentMailEvidenceRef {
197    pub kind: AgentMailEvidenceKind,
198    pub reference_id: String,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub label: Option<String>,
201}
202
203impl AgentMailEvidenceRef {
204    pub fn validate(&self) -> Result<(), AgentMailValidationError> {
205        validate_opaque_id("evidence.reference_id", &self.reference_id)?;
206        if let Some(label) = &self.label {
207            validate_bounded_text(
208                "evidence.label",
209                label,
210                MAX_AGENT_MAIL_EVIDENCE_LABEL_BYTES,
211                false,
212            )?;
213        }
214        Ok(())
215    }
216}
217
218#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
219#[serde(rename_all = "snake_case")]
220pub enum AgentMailStatus {
221    Queued,
222    Delivering,
223    Delivered,
224    Read,
225    Failed,
226}
227
228#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
229#[serde(rename_all = "snake_case")]
230pub enum AgentMailFailureCode {
231    AuthorizationDenied,
232    DestinationUnavailable,
233    DeliveryRejected,
234    Persistence,
235    AttemptLimit,
236    InvalidEnvelope,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240pub struct AgentMailFailureReceipt {
241    pub code: AgentMailFailureCode,
242    pub message: String,
243    pub retryable: bool,
244    pub failed_at: DateTime<Utc>,
245}
246
247impl AgentMailFailureReceipt {
248    pub fn validate(&self) -> Result<(), AgentMailValidationError> {
249        validate_bounded_text(
250            "failure.message",
251            &self.message,
252            MAX_AGENT_MAIL_FAILURE_MESSAGE_BYTES,
253            false,
254        )
255    }
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
259pub struct AgentMailEnvelope {
260    #[serde(default = "default_agent_mail_schema_version")]
261    pub schema_version: u32,
262    pub message_id: AgentMailMessageId,
263    pub source: AgentMailAddress,
264    pub destination: AgentMailAddress,
265    pub sender: AgentMailSender,
266    /// Sanitized, bounded content presented to the destination task and UI.
267    pub summary: String,
268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
269    pub evidence: Vec<AgentMailEvidenceRef>,
270    pub delivery_mode: AgentMailDeliveryMode,
271    /// Explicit loop-breaking decision. It must agree with `delivery_mode`.
272    pub trigger_turn: bool,
273    pub hop_count: u8,
274    pub status: AgentMailStatus,
275    pub created_at: DateTime<Utc>,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub delivered_at: Option<DateTime<Utc>>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub read_at: Option<DateTime<Utc>>,
280    #[serde(default)]
281    pub attempt_count: u8,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub failure: Option<AgentMailFailureReceipt>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub delivery_turn_id: Option<String>,
286}
287
288impl AgentMailEnvelope {
289    pub fn validate(&self) -> Result<(), AgentMailValidationError> {
290        if self.schema_version != AGENT_MAIL_SCHEMA_VERSION {
291            return Err(AgentMailValidationError::new(
292                "schema_version",
293                format!("expected {AGENT_MAIL_SCHEMA_VERSION}"),
294            ));
295        }
296        self.source.validate()?;
297        self.destination.validate()?;
298        self.sender.validate()?;
299        validate_summary_and_delivery(
300            &self.summary,
301            &self.evidence,
302            self.delivery_mode,
303            self.trigger_turn,
304            self.hop_count,
305        )?;
306        if self.attempt_count > MAX_AGENT_MAIL_DELIVERY_ATTEMPTS {
307            return Err(AgentMailValidationError::new(
308                "attempt_count",
309                format!("must be at most {MAX_AGENT_MAIL_DELIVERY_ATTEMPTS}"),
310            ));
311        }
312        if let Some(turn_id) = &self.delivery_turn_id {
313            validate_opaque_id("delivery_turn_id", turn_id)?;
314        }
315
316        match self.status {
317            AgentMailStatus::Queued => {
318                require_absent(self.delivered_at.is_some(), "delivered_at", "queued")?;
319                require_absent(self.read_at.is_some(), "read_at", "queued")?;
320                require_absent(self.failure.is_some(), "failure", "queued")?;
321                require_absent(
322                    self.delivery_turn_id.is_some(),
323                    "delivery_turn_id",
324                    "queued",
325                )?;
326            }
327            AgentMailStatus::Delivering => {
328                if self.attempt_count == 0 {
329                    return Err(AgentMailValidationError::new(
330                        "attempt_count",
331                        "delivering mail requires at least one attempt",
332                    ));
333                }
334                require_absent(self.delivered_at.is_some(), "delivered_at", "delivering")?;
335                require_absent(self.read_at.is_some(), "read_at", "delivering")?;
336                require_absent(self.failure.is_some(), "failure", "delivering")?;
337            }
338            AgentMailStatus::Delivered => self.validate_delivered(false)?,
339            AgentMailStatus::Read => self.validate_delivered(true)?,
340            AgentMailStatus::Failed => {
341                if self.attempt_count == 0 {
342                    return Err(AgentMailValidationError::new(
343                        "attempt_count",
344                        "failed mail requires at least one attempt",
345                    ));
346                }
347                let failure = self.failure.as_ref().ok_or_else(|| {
348                    AgentMailValidationError::new("failure", "failed mail requires a receipt")
349                })?;
350                failure.validate()?;
351                if failure.failed_at < self.created_at {
352                    return Err(AgentMailValidationError::new(
353                        "failure.failed_at",
354                        "cannot precede created_at",
355                    ));
356                }
357                require_absent(self.delivered_at.is_some(), "delivered_at", "failed")?;
358                require_absent(self.read_at.is_some(), "read_at", "failed")?;
359            }
360        }
361        Ok(())
362    }
363
364    fn validate_delivered(&self, read: bool) -> Result<(), AgentMailValidationError> {
365        let delivered_at = self.delivered_at.as_ref().ok_or_else(|| {
366            AgentMailValidationError::new("delivered_at", "delivered mail requires a timestamp")
367        })?;
368        if delivered_at < &self.created_at {
369            return Err(AgentMailValidationError::new(
370                "delivered_at",
371                "cannot precede created_at",
372            ));
373        }
374        if self.delivery_turn_id.is_none() {
375            return Err(AgentMailValidationError::new(
376                "delivery_turn_id",
377                "delivered mail requires a destination turn",
378            ));
379        }
380        if self.attempt_count == 0 {
381            return Err(AgentMailValidationError::new(
382                "attempt_count",
383                "delivered mail requires at least one attempt",
384            ));
385        }
386        require_absent(self.failure.is_some(), "failure", "delivered")?;
387        match (read, self.read_at.as_ref()) {
388            (true, Some(read_at)) if read_at >= delivered_at => Ok(()),
389            (true, Some(_)) => Err(AgentMailValidationError::new(
390                "read_at",
391                "cannot precede delivered_at",
392            )),
393            (true, None) => Err(AgentMailValidationError::new(
394                "read_at",
395                "read mail requires a timestamp",
396            )),
397            (false, Some(_)) => Err(AgentMailValidationError::new(
398                "read_at",
399                "delivered mail cannot have read_at before entering read status",
400            )),
401            (false, None) => Ok(()),
402        }
403    }
404
405    /// Compares only immutable delivery intent, ignoring lifecycle state.
406    #[must_use]
407    pub fn is_idempotent_replay_of(&self, other: &Self) -> bool {
408        self.schema_version == other.schema_version
409            && self.message_id == other.message_id
410            && self.source == other.source
411            && self.destination == other.destination
412            && self.sender == other.sender
413            && self.summary == other.summary
414            && self.evidence == other.evidence
415            && self.delivery_mode == other.delivery_mode
416            && self.trigger_turn == other.trigger_turn
417            && self.hop_count == other.hop_count
418    }
419
420    /// Checks whether a replayed API request describes this persisted message.
421    #[must_use]
422    pub fn matches_send_request(&self, request: &AgentMailSendRequest) -> bool {
423        self.message_id == request.message_id
424            && self.source.thread_id == request.source_thread_id
425            && self.destination.thread_id == request.destination_thread_id
426            && self.sender == request.sender
427            && self.summary == request.summary
428            && self.evidence == request.evidence
429            && self.delivery_mode == request.delivery_mode
430            && self.trigger_turn == request.trigger_turn
431            && self.hop_count == request.hop_count
432    }
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
436pub struct AgentMailSendRequest {
437    pub message_id: AgentMailMessageId,
438    pub source_thread_id: String,
439    pub destination_thread_id: String,
440    pub sender: AgentMailSender,
441    /// Runtime-sanitized before the request becomes a persisted envelope.
442    pub summary: String,
443    #[serde(default, skip_serializing_if = "Vec::is_empty")]
444    pub evidence: Vec<AgentMailEvidenceRef>,
445    pub delivery_mode: AgentMailDeliveryMode,
446    pub trigger_turn: bool,
447    #[serde(default)]
448    pub hop_count: u8,
449}
450
451impl AgentMailSendRequest {
452    pub fn validate(&self) -> Result<(), AgentMailValidationError> {
453        validate_opaque_id("source_thread_id", &self.source_thread_id)?;
454        validate_opaque_id("destination_thread_id", &self.destination_thread_id)?;
455        self.sender.validate()?;
456        validate_summary_and_delivery(
457            &self.summary,
458            &self.evidence,
459            self.delivery_mode,
460            self.trigger_turn,
461            self.hop_count,
462        )
463    }
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
467pub struct AgentMailSendResponse {
468    pub envelope: AgentMailEnvelope,
469    /// True when the message id and immutable intent already existed.
470    pub idempotent_replay: bool,
471}
472
473/// Canonical payload placed in every Agent Mail runtime event.
474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
475pub struct AgentMailEventPayload {
476    pub mail: AgentMailEnvelope,
477}
478
479fn default_agent_mail_schema_version() -> u32 {
480    AGENT_MAIL_SCHEMA_VERSION
481}
482
483fn validate_message_id(value: &str) -> Result<(), AgentMailValidationError> {
484    if !value.starts_with("mail_") {
485        return Err(AgentMailValidationError::new(
486            "message_id",
487            "must start with mail_",
488        ));
489    }
490    if value.len() > MAX_AGENT_MAIL_MESSAGE_ID_BYTES {
491        return Err(AgentMailValidationError::new(
492            "message_id",
493            format!("must be at most {MAX_AGENT_MAIL_MESSAGE_ID_BYTES} bytes"),
494        ));
495    }
496    if !value
497        .bytes()
498        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
499    {
500        return Err(AgentMailValidationError::new(
501            "message_id",
502            "contains unsupported characters",
503        ));
504    }
505    if value.len() == "mail_".len() {
506        return Err(AgentMailValidationError::new(
507            "message_id",
508            "requires an id after mail_",
509        ));
510    }
511    Ok(())
512}
513
514fn validate_opaque_id(field: &'static str, value: &str) -> Result<(), AgentMailValidationError> {
515    if value.is_empty() {
516        return Err(AgentMailValidationError::new(field, "must not be empty"));
517    }
518    if value.len() > MAX_AGENT_MAIL_OPAQUE_ID_BYTES {
519        return Err(AgentMailValidationError::new(
520            field,
521            format!("must be at most {MAX_AGENT_MAIL_OPAQUE_ID_BYTES} bytes"),
522        ));
523    }
524    if value == "."
525        || value.contains("..")
526        || !value.bytes().all(|byte| {
527            byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':' | b'@')
528        })
529    {
530        return Err(AgentMailValidationError::new(
531            field,
532            "must be an opaque id, not a path or URL",
533        ));
534    }
535    Ok(())
536}
537
538fn validate_bounded_text(
539    field: &'static str,
540    value: &str,
541    max_bytes: usize,
542    allow_line_breaks: bool,
543) -> Result<(), AgentMailValidationError> {
544    if value.is_empty() {
545        return Err(AgentMailValidationError::new(field, "must not be empty"));
546    }
547    if value.len() > max_bytes {
548        return Err(AgentMailValidationError::new(
549            field,
550            format!("must be at most {max_bytes} bytes"),
551        ));
552    }
553    if value.trim() != value {
554        return Err(AgentMailValidationError::new(
555            field,
556            "must not have leading or trailing whitespace",
557        ));
558    }
559    let has_forbidden_control = value
560        .chars()
561        .any(|ch| ch.is_control() && !(allow_line_breaks && matches!(ch, '\n' | '\t')));
562    if has_forbidden_control {
563        return Err(AgentMailValidationError::new(
564            field,
565            "contains unsupported control characters",
566        ));
567    }
568    Ok(())
569}
570
571fn validate_summary_and_delivery(
572    summary: &str,
573    evidence: &[AgentMailEvidenceRef],
574    delivery_mode: AgentMailDeliveryMode,
575    trigger_turn: bool,
576    hop_count: u8,
577) -> Result<(), AgentMailValidationError> {
578    validate_bounded_text("summary", summary, MAX_AGENT_MAIL_SUMMARY_BYTES, true)?;
579    if evidence.len() > MAX_AGENT_MAIL_EVIDENCE_REFS {
580        return Err(AgentMailValidationError::new(
581            "evidence",
582            format!("must contain at most {MAX_AGENT_MAIL_EVIDENCE_REFS} references"),
583        ));
584    }
585    for reference in evidence {
586        reference.validate()?;
587    }
588    if hop_count > MAX_AGENT_MAIL_HOPS {
589        return Err(AgentMailValidationError::new(
590            "hop_count",
591            format!("must be at most {MAX_AGENT_MAIL_HOPS}"),
592        ));
593    }
594    let expected_trigger = matches!(delivery_mode, AgentMailDeliveryMode::WakeAtSafeBoundary);
595    if trigger_turn != expected_trigger {
596        return Err(AgentMailValidationError::new(
597            "trigger_turn",
598            "must be false for queue_only and true for wake_at_safe_boundary",
599        ));
600    }
601    Ok(())
602}
603
604fn require_absent(
605    present: bool,
606    field: &'static str,
607    status: &'static str,
608) -> Result<(), AgentMailValidationError> {
609    if present {
610        return Err(AgentMailValidationError::new(
611            field,
612            format!("must be absent while status is {status}"),
613        ));
614    }
615    Ok(())
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    fn address(thread_id: &str) -> AgentMailAddress {
623        AgentMailAddress {
624            owner_id: "acct_local".into(),
625            workspace_id: "ws_123".into(),
626            thread_id: thread_id.into(),
627            task_id: Some(format!("task_{thread_id}")),
628            session_id: None,
629        }
630    }
631
632    fn queued_envelope() -> AgentMailEnvelope {
633        AgentMailEnvelope {
634            schema_version: AGENT_MAIL_SCHEMA_VERSION,
635            message_id: AgentMailMessageId::parse("mail_123").unwrap(),
636            source: address("thr_a"),
637            destination: address("thr_b"),
638            sender: AgentMailSender {
639                identity: "agent_a".into(),
640                display_label: "Agent A".into(),
641            },
642            summary: "A bounded handoff".into(),
643            evidence: vec![AgentMailEvidenceRef {
644                kind: AgentMailEvidenceKind::RuntimeEvent,
645                reference_id: "evt_42".into(),
646                label: Some("Build receipt".into()),
647            }],
648            delivery_mode: AgentMailDeliveryMode::QueueOnly,
649            trigger_turn: false,
650            hop_count: 0,
651            status: AgentMailStatus::Queued,
652            created_at: Utc::now(),
653            delivered_at: None,
654            read_at: None,
655            attempt_count: 0,
656            failure: None,
657            delivery_turn_id: None,
658        }
659    }
660
661    #[test]
662    fn queued_envelope_roundtrips_with_canonical_event_names() {
663        let envelope = queued_envelope();
664        envelope.validate().unwrap();
665        let value = serde_json::to_value(AgentMailEventPayload {
666            mail: envelope.clone(),
667        })
668        .unwrap();
669        let decoded: AgentMailEventPayload = serde_json::from_value(value).unwrap();
670        assert_eq!(decoded.mail, envelope);
671        assert_eq!(AGENT_MAIL_EVENT_QUEUED, "agent_mail.queued");
672        assert_eq!(
673            AGENT_MAIL_EVENT_DELIVERY_FAILED,
674            "agent_mail.delivery_failed"
675        );
676    }
677
678    #[test]
679    fn rejects_bounds_controls_paths_and_excess_hops() {
680        assert!(AgentMailMessageId::parse("../../secret").is_err());
681        let mut envelope = queued_envelope();
682        envelope.summary = format!("ok\0{}", "x".repeat(MAX_AGENT_MAIL_SUMMARY_BYTES));
683        assert!(envelope.validate().is_err());
684
685        let mut envelope = queued_envelope();
686        envelope.evidence[0].reference_id = "/tmp/transcript".into();
687        assert!(envelope.validate().is_err());
688
689        let mut envelope = queued_envelope();
690        envelope.hop_count = MAX_AGENT_MAIL_HOPS + 1;
691        assert!(envelope.validate().is_err());
692    }
693
694    #[test]
695    fn requires_task_or_session_and_consistent_wake_control() {
696        let mut envelope = queued_envelope();
697        envelope.destination.task_id = None;
698        assert!(envelope.validate().is_err());
699
700        let mut envelope = queued_envelope();
701        envelope.trigger_turn = true;
702        assert!(envelope.validate().is_err());
703        envelope.delivery_mode = AgentMailDeliveryMode::WakeAtSafeBoundary;
704        assert!(envelope.validate().is_ok());
705    }
706
707    #[test]
708    fn lifecycle_fields_are_validated() {
709        let mut envelope = queued_envelope();
710        envelope.status = AgentMailStatus::Delivered;
711        envelope.attempt_count = 1;
712        envelope.delivered_at = Some(envelope.created_at);
713        envelope.delivery_turn_id = Some("turn_1".into());
714        assert!(envelope.validate().is_ok());
715
716        envelope.status = AgentMailStatus::Read;
717        assert!(envelope.validate().is_err());
718        envelope.read_at = envelope.delivered_at;
719        assert!(envelope.validate().is_ok());
720    }
721
722    #[test]
723    fn replay_equivalence_ignores_delivery_state_but_not_intent() {
724        let queued = queued_envelope();
725        let mut delivered = queued.clone();
726        delivered.status = AgentMailStatus::Delivered;
727        delivered.attempt_count = 1;
728        delivered.created_at += chrono::Duration::seconds(1);
729        delivered.delivered_at = Some(delivered.created_at);
730        delivered.delivery_turn_id = Some("turn_1".into());
731        assert!(queued.is_idempotent_replay_of(&delivered));
732
733        delivered.summary = "Different intent under the same id".into();
734        assert!(!queued.is_idempotent_replay_of(&delivered));
735    }
736}