1use 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#[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#[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 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#[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 pub summary: String,
268 #[serde(default, skip_serializing_if = "Vec::is_empty")]
269 pub evidence: Vec<AgentMailEvidenceRef>,
270 pub delivery_mode: AgentMailDeliveryMode,
271 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 #[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 #[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 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 pub idempotent_replay: bool,
471}
472
473#[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}