1use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use serde::{Deserialize, Serialize};
7use serde_json::Map;
8use tracing::warn;
9
10use crate::address::AgentAddress;
11use crate::boundary::PostSendHookEmitter;
12use crate::config;
13use crate::delivery_execution::{
14 DeliveryTransitionContext, emit_delivery_plan_transitions, execute_delivery_plan,
15};
16use crate::delivery_plan::{
17 DeliveryPlan, delivery_plan_disposition, logical_messages_from_persistence,
18};
19use crate::delivery_policy::{
20 DeliveryEventFamily, DeliveryPolicyCoordinator, DeliveryRecipientSnapshot,
21};
22use crate::error::AtmError;
23use crate::error_codes::AtmErrorCode;
24use crate::observability::{CommandEvent, ObservabilityPort, action_name, outcome_label};
25use crate::schema::{AckIntentFields, AtmMessageId, InboxMessage, ThreadMode};
26use crate::service_runtime::{LocalServiceRuntime, RetainedServiceRuntime};
27use crate::service_runtime_store::{RetainedMailboxRuntime, default_runtime};
28use crate::threading::{ThreadIndex, canonical_sender_identity, is_ephemeral};
29use crate::types::{AgentName, CommandAction, IsoTimestamp, TaskId, TeamName};
30
31#[allow(
32 dead_code,
33 reason = "Z.6 removes the production send-path config gate; alert-state helpers remain test-covered until the follow-on cleanup deletes the obsolete file-backed alert seam."
34)]
35mod alert_state;
36mod delivery_persistence;
37pub(crate) mod file_policy;
38pub(crate) mod hook;
39#[allow(
40 dead_code,
41 reason = "The s11 merge-forward keeps the older hook_tmux helper module while this branch still inlines the tmux seam in hook.rs; the follow-on cleanup can delete the obsolete duplicate once the AD forward-merge settles."
42)]
43mod hook_tmux;
44pub mod input;
45mod missing_config_notice;
46#[doc(hidden)]
47pub mod nudge_template;
48mod persistence;
49pub(crate) mod summary;
50
51pub(crate) use delivery_persistence::{DeliveryPersistenceDisposition, DeliveryPersistenceResult};
52pub(crate) use persistence::persist_message;
53
54pub(super) const POST_SEND_HOOK_TIMEOUT: Duration = Duration::from_secs(5);
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub enum SendMessageSource {
58 Inline(String),
59 File {
60 path: PathBuf,
61 message: Option<String>,
62 },
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SendRequest {
67 pub home_dir: PathBuf,
68 pub current_dir: PathBuf,
69 pub caller_identity: AgentName,
70 pub caller_team: TeamName,
71 pub to: AgentAddress,
72 pub message_source: SendMessageSource,
73 pub summary_override: Option<String>,
74 pub requires_ack: bool,
75 pub task_id: Option<TaskId>,
76 pub parent_message_id: Option<AtmMessageId>,
77 pub thread_mode: Option<ThreadMode>,
78 pub expires_at: Option<crate::types::IsoTimestamp>,
79 pub dry_run: bool,
80}
81
82impl SendRequest {
83 #[allow(clippy::too_many_arguments)]
84 pub fn new(
85 home_dir: PathBuf,
86 current_dir: PathBuf,
87 caller_identity: AgentName,
88 to: &str,
89 caller_team: TeamName,
90 message_source: SendMessageSource,
91 summary_override: Option<String>,
92 requires_ack: bool,
93 task_id: Option<TaskId>,
94 dry_run: bool,
95 ) -> Result<Self, AtmError> {
96 Ok(Self {
97 home_dir,
98 current_dir,
99 caller_identity,
100 caller_team,
101 to: to.parse()?,
102 message_source,
103 summary_override,
104 requires_ack,
105 task_id,
106 parent_message_id: None,
107 thread_mode: None,
108 expires_at: None,
109 dry_run,
110 })
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct SendOutcome {
117 pub action: CommandAction,
118 pub team: TeamName,
119 pub agent: AgentName,
120 pub sender: AgentName,
121 pub outcome: SendCommandOutcome,
122 pub message_id: AtmMessageId,
123 pub requires_ack: bool,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub task_id: Option<TaskId>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub summary: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub message: Option<String>,
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub warnings: Vec<WarningEntry>,
132 #[serde(default, skip_serializing_if = "is_false")]
133 pub dry_run: bool,
134}
135
136#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
137#[serde(rename_all = "snake_case")]
138pub enum SendCommandOutcome {
139 Sent,
140 DryRun,
141}
142
143impl SendCommandOutcome {
144 pub fn as_str(self) -> &'static str {
145 match self {
146 Self::Sent => "sent",
147 Self::DryRun => "dry_run",
148 }
149 }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153pub struct WarningEntry {
154 pub message: String,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub code: Option<AtmErrorCode>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub recovery: Option<String>,
159}
160
161impl WarningEntry {
162 pub fn new(message: impl Into<String>, recovery: Option<impl Into<String>>) -> Self {
163 Self {
164 message: message.into(),
165 code: None,
166 recovery: recovery.map(Into::into),
167 }
168 }
169
170 pub fn with_code(
171 code: AtmErrorCode,
172 message: impl Into<String>,
173 recovery: Option<impl Into<String>>,
174 ) -> Self {
175 Self {
176 message: message.into(),
177 code: Some(code),
178 recovery: recovery.map(Into::into),
179 }
180 }
181
182 pub fn render(&self) -> String {
183 let message = match self.code {
184 Some(code) if !self.message.contains(code.as_str()) => {
185 format!("{} [{}]", self.message, code.as_str())
186 }
187 _ => self.message.clone(),
188 };
189 match &self.recovery {
190 Some(recovery) => format!("{message} Recovery: {recovery}"),
191 None => message,
192 }
193 }
194}
195
196pub fn send_mail(
213 request: SendRequest,
214 observability: &dyn ObservabilityPort,
215) -> Result<SendOutcome, AtmError> {
216 let runtime = default_runtime()?;
217 send_mail_with_runtime(request, observability, &runtime)
218}
219
220pub fn send_mail_with_runtime(
221 request: SendRequest,
222 observability: &dyn ObservabilityPort,
223 runtime: &LocalServiceRuntime,
224) -> Result<SendOutcome, AtmError> {
225 send_mail_with_runtime_impl(request, observability, runtime, None)
226}
227
228pub fn send_mail_with_runtime_and_post_send_emitter(
229 request: SendRequest,
230 observability: &dyn ObservabilityPort,
231 runtime: &LocalServiceRuntime,
232 post_send_emitter: &dyn PostSendHookEmitter,
233) -> Result<SendOutcome, AtmError> {
234 send_mail_with_runtime_impl(request, observability, runtime, Some(post_send_emitter))
235}
236
237fn send_mail_with_runtime_impl<
238 R: RetainedServiceRuntime + RetainedMailboxRuntime + crate::boundary::sealed::Sealed,
239>(
240 request: SendRequest,
241 observability: &dyn ObservabilityPort,
242 runtime: &R,
243 post_send_emitter: Option<&dyn PostSendHookEmitter>,
244) -> Result<SendOutcome, AtmError> {
245 let context = prepare_send_context(runtime, &request)?;
246 let task_id = request.task_id.clone();
247 let requires_ack = request.requires_ack || task_id.is_some();
248 let body = resolve_message_body(
249 &request.message_source,
250 &request.current_dir,
251 &request.home_dir,
252 &context.recipient.team,
253 )?;
254 let summary = summary::build_summary(&body, request.summary_override.clone());
255 let message_id = AtmMessageId::new();
256 let timestamp = IsoTimestamp::now();
257
258 let persistence = persist_send_message(
259 runtime,
260 &request,
261 &context,
262 &body,
263 &summary,
264 message_id,
265 timestamp,
266 requires_ack,
267 task_id.clone(),
268 )?;
269 finalize_send_outcome(
270 runtime,
271 observability,
272 post_send_emitter,
273 &request,
274 &context,
275 &body,
276 &summary,
277 message_id,
278 requires_ack,
279 task_id,
280 persistence,
281 )
282}
283
284#[expect(
285 clippy::too_many_arguments,
286 reason = "Y.6 closeout keeps the explicit send outcome pieces visible at the sprint seam."
287)]
288fn finalize_send_outcome<
289 R: RetainedServiceRuntime + RetainedMailboxRuntime + crate::boundary::sealed::Sealed,
290>(
291 runtime: &R,
292 observability: &dyn ObservabilityPort,
293 post_send_emitter: Option<&dyn PostSendHookEmitter>,
294 request: &SendRequest,
295 context: &SendExecutionContext,
296 body: &str,
297 summary: &str,
298 message_id: AtmMessageId,
299 requires_ack: bool,
300 task_id: Option<TaskId>,
301 persistence: DeliveryPersistenceResult,
302) -> Result<SendOutcome, AtmError> {
303 let command_outcome = if request.dry_run {
304 SendCommandOutcome::DryRun
305 } else {
306 SendCommandOutcome::Sent
307 };
308 let mut outcome = build_send_outcome(
309 request,
310 context,
311 body,
312 summary,
313 message_id,
314 requires_ack,
315 task_id.clone(),
316 command_outcome,
317 &persistence,
318 );
319 if !request.dry_run {
320 if let Some(warning) = build_claude_roster_warning(runtime, request, context)? {
321 outcome.warnings.push(warning);
322 }
323 let post_send_messages = post_send_messages_from_persistence(&persistence, requires_ack)?;
324 hook::emit_post_send_effects(
325 runtime,
326 &mut outcome.warnings,
327 context.post_send_config.as_ref(),
328 post_send_emitter,
329 &context.recipient,
330 &context.delivery_snapshot,
331 &post_send_messages,
332 );
333 let plan = build_send_delivery_plan(context, requires_ack, &persistence)?;
334 let execution = execute_delivery_plan(runtime, context.command_config.as_ref(), &plan)?;
335 emit_delivery_plan_transitions(
336 observability,
337 DeliveryTransitionContext {
338 family: context.delivery_family,
339 team: &context.recipient.team,
340 agent: &context.recipient.agent,
341 sender: &context.canonical_sender,
342 message_id,
343 task_id: task_id.clone(),
344 },
345 &plan,
346 &execution,
347 )?;
348 outcome.warnings.extend(execution.warnings);
349 }
350 emit_send_command_event(
351 observability,
352 command_outcome.as_str(),
353 &outcome,
354 task_id,
355 &context.canonical_sender,
356 );
357 Ok(outcome)
358}
359
360fn build_claude_roster_warning<R: RetainedServiceRuntime + RetainedMailboxRuntime>(
361 runtime: &R,
362 request: &SendRequest,
363 context: &SendExecutionContext,
364) -> Result<Option<WarningEntry>, AtmError> {
365 if !matches!(
366 context.delivery_snapshot.harness,
367 crate::delivery_policy::DeliveryHarnessPath::ClaudeCode
368 ) {
369 return Ok(None);
370 }
371 let team_dir = runtime.team_dir(&request.home_dir, &context.recipient.team)?;
372 if !team_dir.join("config.json").is_file() {
373 if !context.inbox_path.exists() {
374 return Ok(None);
375 }
376 let mut warnings = Vec::new();
377 missing_config_notice::warn_missing_team_config(
378 runtime,
379 request,
380 &context.recipient,
381 &team_dir,
382 &context.inbox_path,
383 &mut warnings,
384 )?;
385 return Ok(warnings.into_iter().next());
386 }
387 let roster = runtime.load_claude_code_team_roster(&context.recipient.team)?;
388 if roster.contains_member(&context.recipient.agent) {
389 return Ok(None);
390 }
391 Ok(Some(WarningEntry::new(
392 format!(
393 "'{}' is not on claude code roster {}/config.json",
394 context.recipient.agent, context.recipient.team
395 ),
396 Some(
397 "Import the member through the approved Claude config-ingress path or project ATM roster truth back into config.json before relying on Claude compatibility delivery.",
398 ),
399 )))
400}
401
402#[expect(
403 clippy::too_many_arguments,
404 reason = "Y.6 closeout keeps the explicit send outcome fields aligned with the command contract."
405)]
406fn build_send_outcome(
407 request: &SendRequest,
408 context: &SendExecutionContext,
409 body: &str,
410 summary: &str,
411 message_id: AtmMessageId,
412 requires_ack: bool,
413 task_id: Option<TaskId>,
414 command_outcome: SendCommandOutcome,
415 persistence: &DeliveryPersistenceResult,
416) -> SendOutcome {
417 let mut outcome = SendOutcome {
418 action: CommandAction::Send,
419 team: context.recipient.team.clone(),
420 agent: context.recipient.agent.clone(),
421 sender: context.canonical_sender.clone(),
422 outcome: command_outcome,
423 message_id,
424 requires_ack,
425 task_id,
426 summary: Some(summary.to_string()),
427 message: request.dry_run.then_some(body.to_string()),
428 warnings: context.warnings.clone(),
429 dry_run: request.dry_run,
430 };
431 outcome
432 .warnings
433 .extend(persistence.warnings.iter().cloned());
434 outcome
435}
436
437fn build_send_delivery_plan(
438 context: &SendExecutionContext,
439 requires_ack: bool,
440 persistence: &DeliveryPersistenceResult,
441) -> Result<DeliveryPlan, AtmError> {
442 Ok(DeliveryPlan::new(
443 crate::delivery_plan::DeliveryPlanKind::Send,
444 delivery_plan_disposition(persistence.disposition),
445 crate::delivery_plan::delivery_target_for_snapshot(
446 &context.inbox_path,
447 &context.delivery_snapshot,
448 ),
449 context.recipient.clone(),
450 logical_messages_from_persistence(persistence, requires_ack, false)
451 .map_err(|error| {
452 AtmError::mailbox_write(error.to_string()).with_recovery(
453 "Repair the persisted delivery record shape before retrying delivery-plan execution.",
454 )
455 })?,
456 persistence.warnings.clone(),
457 ))
458}
459
460fn post_send_messages_from_persistence(
461 persistence: &DeliveryPersistenceResult,
462 requires_ack: bool,
463) -> Result<Vec<crate::delivery_plan::LogicalMessage>, AtmError> {
464 crate::delivery_plan::LogicalMessage::new(
465 persistence.original_message.clone(),
466 requires_ack,
467 false,
468 )
469 .map(|message| vec![message])
470 .map_err(|error| {
471 AtmError::mailbox_write(error.to_string()).with_recovery(
472 "Repair the persisted delivery record shape before retrying post-send emission.",
473 )
474 })
475}
476
477struct SendExecutionContext {
478 command_config: Option<config::AtmConfig>,
479 post_send_config: Option<config::AtmConfig>,
480 recipient: ResolvedRecipient,
481 canonical_sender: AgentName,
482 inbox_path: PathBuf,
483 delivery_snapshot: DeliveryRecipientSnapshot,
484 delivery_family: DeliveryEventFamily,
485 warnings: Vec<WarningEntry>,
486}
487
488fn prepare_send_context<
489 R: RetainedServiceRuntime + RetainedMailboxRuntime + crate::boundary::sealed::Sealed,
490>(
491 runtime: &R,
492 request: &SendRequest,
493) -> Result<SendExecutionContext, AtmError> {
494 let command_config = runtime.load_config(&request.current_dir)?;
495 let (post_send_config, warnings) = match hook::load_post_send_config_for_sender(
496 runtime,
497 &request.caller_team,
498 &request.caller_identity,
499 ) {
500 Ok(config) => (config, Vec::new()),
501 Err(error) => (
502 None,
503 vec![WarningEntry::with_code(
504 error.code,
505 format!(
506 "warning: post-send hook config lookup failed for {}@{}: {}.",
507 request.caller_identity, request.caller_team, error.message
508 ),
509 error.primary_recovery().map(str::to_owned),
510 )],
511 ),
512 };
513 let canonical_sender = request.caller_identity.clone();
514 let recipient = resolve_recipient(&request.to, &request.caller_team, command_config.as_ref())?;
515 validate_non_self_recipient(&canonical_sender, &request.caller_team, &recipient)?;
516 let team_dir = runtime.team_dir(&request.home_dir, &recipient.team)?;
517 if !team_dir.exists() {
518 return Err(AtmError::team_not_found(&recipient.team));
519 }
520 let inbox_path = runtime.inbox_path(&request.home_dir, &recipient.team, &recipient.agent)?;
521 let delivery_policy = DeliveryPolicyCoordinator::new();
522 let delivery_snapshot =
523 delivery_policy.resolve_recipient_snapshot(runtime, &recipient.team, &recipient.agent)?;
524 let delivery_family = DeliveryPolicyCoordinator::resolve_send_family(
525 request.parent_message_id,
526 request.thread_mode,
527 );
528 Ok(SendExecutionContext {
529 command_config,
530 post_send_config,
531 recipient,
532 canonical_sender,
533 inbox_path,
534 delivery_snapshot,
535 delivery_family,
536 warnings,
537 })
538}
539
540#[expect(
541 clippy::too_many_arguments,
542 reason = "Send persistence needs the explicit request/body/message envelope fields documented in the Y.4 state-machine seam."
543)]
544fn persist_send_message<R: RetainedServiceRuntime + RetainedMailboxRuntime>(
545 runtime: &R,
546 request: &SendRequest,
547 context: &SendExecutionContext,
548 body: &str,
549 summary: &str,
550 message_id: AtmMessageId,
551 timestamp: IsoTimestamp,
552 requires_ack: bool,
553 task_id: Option<TaskId>,
554) -> Result<DeliveryPersistenceResult, AtmError> {
555 let ack_intent = AckIntentFields::from_requires_ack(requires_ack, timestamp);
556 if request.dry_run {
557 return Ok(DeliveryPersistenceResult::persisted(InboxMessage {
558 from: context.canonical_sender.clone(),
559 text: body.to_string(),
560 timestamp,
561 read: false,
562 source_team: Some(request.caller_team.clone()),
563 summary: Some(summary.to_string()),
564 message_id: Some(message_id),
565 requires_ack: ack_intent.requires_ack,
566 pending_ack_at: ack_intent.pending_ack_at,
567 acknowledged_at: ack_intent.acknowledged_at,
568 acknowledges_message_id: None,
569 parent_message_id: request.parent_message_id,
570 thread_mode: request.thread_mode,
571 expires_at: request.expires_at,
572 task_id: task_id.clone(),
573 extra: Map::new(),
574 }));
575 }
576 let envelope = InboxMessage {
577 from: context.canonical_sender.clone(),
578 text: body.to_string(),
579 timestamp,
580 read: false,
581 source_team: Some(request.caller_team.clone()),
582 summary: Some(summary.to_string()),
583 message_id: Some(message_id),
584 requires_ack: ack_intent.requires_ack,
585 pending_ack_at: ack_intent.pending_ack_at,
586 acknowledged_at: ack_intent.acknowledged_at,
587 acknowledges_message_id: None,
588 parent_message_id: request.parent_message_id,
589 thread_mode: request.thread_mode,
590 expires_at: request.expires_at,
591 task_id: task_id.clone(),
592 extra: Map::new(),
593 };
594 let persistence = persist_message(
595 runtime,
596 &request.home_dir,
597 &context.delivery_snapshot,
598 &context.inbox_path,
599 &envelope,
600 false,
601 )?;
602 Ok(persistence)
603}
604
605fn emit_send_command_event(
606 observability: &dyn ObservabilityPort,
607 outcome_name: &'static str,
608 outcome: &SendOutcome,
609 task_id: Option<TaskId>,
610 canonical_sender: &AgentName,
611) {
612 if let Err(error) = observability.emit(CommandEvent {
613 command: "send",
614 action: action_name("send"),
615 outcome: outcome_label(outcome_name),
616 team: outcome.team.clone(),
617 agent: outcome.agent.clone(),
618 sender: canonical_sender.clone(),
619 message_id: Some(outcome.message_id),
620 requires_ack: outcome.requires_ack,
621 dry_run: outcome.dry_run,
622 task_id,
623 error_code: None,
624 error_message: None,
625 }) {
626 warn!(%error, command = "send", action = "send", "failed to emit send command event");
627 }
628}
629
630#[derive(Debug, Clone, PartialEq, Eq)]
631pub(crate) struct ResolvedRecipient {
632 pub(crate) agent: AgentName,
633 pub(crate) team: TeamName,
634}
635
636pub(crate) fn validate_non_self_recipient(
637 sender: &AgentName,
638 sender_team: &TeamName,
639 recipient: &ResolvedRecipient,
640) -> Result<(), AtmError> {
641 if sender
642 .as_str()
643 .eq_ignore_ascii_case(recipient.agent.as_str())
644 && sender_team
645 .as_str()
646 .eq_ignore_ascii_case(recipient.team.as_str())
647 {
648 return Err(AtmError::self_addressed_send_invalid(format!(
649 "self-addressed messages are invalid ATM input: '{sender}@{sender_team}' may not send to itself"
650 )));
651 }
652 Ok(())
653}
654
655#[cfg(test)]
656mod self_address_tests {
657 use super::{ResolvedRecipient, validate_non_self_recipient};
658 use crate::error_codes::AtmErrorCode;
659 use crate::types::{AgentName, TeamName};
660
661 #[test]
662 fn validate_non_self_recipient_rejects_case_variant_self_target() {
663 let error = validate_non_self_recipient(
664 &AgentName::from_validated("Sender-A"),
665 &TeamName::from_validated("Test-Team"),
666 &ResolvedRecipient {
667 agent: AgentName::from_validated("sender-a"),
668 team: TeamName::from_validated("test-team"),
669 },
670 )
671 .expect_err("case-variant self target must be rejected");
672
673 assert!(error.is_validation(), "{error:?}");
674 assert_eq!(error.code, AtmErrorCode::SelfAddressedSendInvalid);
675 }
676}
677
678fn resolve_recipient(
679 target_address: &AgentAddress,
680 caller_team: &TeamName,
681 config: Option<&config::AtmConfig>,
682) -> Result<ResolvedRecipient, AtmError> {
683 let team = target_address
684 .team
685 .as_deref()
686 .and_then(|team| team.parse().ok())
687 .or_else(|| Some(caller_team.clone()))
688 .ok_or_else(AtmError::team_unavailable)?;
689
690 Ok(ResolvedRecipient {
691 agent: config::aliases::resolve_agent_name(&target_address.agent, config)?,
692 team,
693 })
694}
695
696fn resolve_message_body(
697 source: &SendMessageSource,
698 current_dir: &Path,
699 home_dir: &Path,
700 team_name: &TeamName,
701) -> Result<String, AtmError> {
702 match source {
703 SendMessageSource::Inline(message) => input::validate_message_text(message.clone()),
704 SendMessageSource::File { path, message } => {
705 input::validate_message_text(file_policy::process_file_reference(
706 path,
707 message.as_deref(),
708 team_name,
709 current_dir,
710 home_dir,
711 )?)
712 }
713 }
714}
715
716fn is_false(value: &bool) -> bool {
717 !*value
718}
719
720fn prepare_threaded_message(
721 envelope: &mut InboxMessage,
722 inbox_messages: &[InboxMessage],
723) -> Result<(), AtmError> {
724 match (
725 envelope.parent_message_id,
726 envelope.thread_mode,
727 envelope.expires_at,
728 ) {
729 (None, None, _) => Ok(()),
730 (Some(_), Some(_), Some(_)) => Err(AtmError::validation(
731 "ephemeral messages may not participate in a message thread",
732 )
733 .with_recovery(
734 "Send the message either as a standalone ephemeral note or as a non-ephemeral thread update.",
735 )),
736 (Some(parent_id), Some(_), None) => validate_thread_append(envelope, inbox_messages, parent_id),
737 (Some(_), None, _) | (None, Some(_), _) => Err(AtmError::validation(
738 "thread updates must set both parent_message_id and thread_mode",
739 )
740 .with_recovery(
741 "Provide both the parent message id and either add-details or supersede when appending to an existing thread.",
742 )),
743 }
744}
745
746fn validate_thread_append(
747 envelope: &mut InboxMessage,
748 inbox_messages: &[InboxMessage],
749 parent_id: AtmMessageId,
750) -> Result<(), AtmError> {
751 let index = ThreadIndex::new(inbox_messages);
752 let parent = index.message(parent_id).ok_or_else(|| {
753 AtmError::validation(format!(
754 "thread parent message {} was not found in the recipient inbox",
755 parent_id
756 ))
757 .with_recovery(
758 "Refresh the recipient inbox state and retry the update against a message id that still exists in that thread.",
759 )
760 })?;
761
762 if is_ephemeral(parent) {
763 return Err(AtmError::validation(
764 "ephemeral messages may not be updated or superseded",
765 )
766 .with_recovery(
767 "Send a fresh standalone message instead of trying to append to an ephemeral message.",
768 ));
769 }
770
771 let Some(root_id) = index.root_id(parent_id) else {
772 return Err(AtmError::validation(format!(
773 "thread root could not be resolved for parent message {}",
774 parent_id
775 ))
776 .with_recovery(
777 "Repair the malformed message thread or resend the correction as a fresh standalone message.",
778 ));
779 };
780 let root = index.message(root_id).ok_or_else(|| {
781 AtmError::validation(format!(
782 "thread root message {} was not found in the recipient inbox",
783 root_id
784 ))
785 .with_recovery(
786 "Repair the malformed message thread or resend the correction as a fresh standalone message.",
787 )
788 })?;
789
790 if canonical_sender_identity(root) != canonical_sender_identity(envelope) {
791 return Err(AtmError::validation(
792 "only the original sender may append details or supersede a message thread",
793 )
794 .with_recovery(
795 "Send a new message instead of appending to a thread you did not originate.",
796 ));
797 }
798
799 if index.has_successor(parent_id) {
800 return Err(AtmError::validation(format!(
801 "message {} already has a successor; ATM threads are strictly linear",
802 parent_id
803 ))
804 .with_recovery(
805 "Append to the current terminal message in the thread instead of branching from an older message.",
806 ));
807 }
808
809 let thread_requires_ack = index.thread_requires_ack(parent_id);
810 envelope.requires_ack = thread_requires_ack;
811 envelope.pending_ack_at = thread_requires_ack.then_some(envelope.timestamp);
812 envelope.acknowledged_at = None;
813 Ok(())
814}
815
816#[allow(
817 dead_code,
818 reason = "Retained for the dormant direct post-send hook helper."
819)]
820pub(super) fn qualified_sender_identity(
821 sender: &AgentName,
822 sender_team: Option<&TeamName>,
823) -> String {
824 sender_team
825 .map(|team| format!("{sender}@{team}"))
826 .unwrap_or_else(|| sender.to_string())
827}
828
829#[cfg(test)]
830mod graft_warning_tests;
831#[cfg(test)]
832mod tests;