Skip to main content

atm_core/
protocol.rs

1//! Shared protocol DTOs for the core transport boundary family.
2
3use std::env;
4use std::fmt;
5use std::io::Read;
6use std::io::Write;
7use std::num::NonZeroU64;
8use std::path::Path;
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicU64, Ordering};
11
12use interprocess::local_socket::{GenericFilePath, Name, ToFsName};
13use serde::{Deserialize, Serialize};
14
15use crate::ack::{AckOutcome, AckRequest};
16use crate::clear::{ClearOutcome, ClearQuery};
17use crate::doctor::{DoctorQuery, DoctorReport};
18use crate::error::{AtmError, AtmErrorKind};
19use crate::error_codes::AtmErrorCode;
20use crate::home;
21use crate::list::{ListOutcome, ListQuery};
22use crate::read::{PeekQuery, ReadOutcome, ReadQuery};
23use crate::send::{SendOutcome, SendRequest};
24use crate::types::{AgentName, IsoTimestamp, TeamName};
25
26const DAEMON_SOCKET_FILENAME: &str = "atm-daemon.sock";
27
28/// Shared protocol send-shaped request envelope.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub enum SendRequestEnvelope {
31    Compose(SendRequest),
32    Acknowledge(AckRequest),
33}
34
35/// Shared protocol send-shaped response envelope.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub enum SendResponseEnvelope {
38    Sent(SendOutcome),
39    Acknowledged(AckOutcome),
40}
41
42/// Shared protocol request envelope.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub enum RequestEnvelope {
45    Send(SendRequestEnvelope),
46    CompatibilityPreflight(CompatibilityPreflight),
47    Heartbeat(TeamMemberHeartbeatRequest),
48    List(ListQuery),
49    Peek(PeekQuery),
50    Receive(ReadQuery),
51    Clear(ClearQuery),
52    Doctor(DoctorQuery),
53}
54
55/// Shared protocol response envelope.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub enum ResponseEnvelope {
58    Send(SendResponseEnvelope),
59    CompatibilityVerdict(CompatibilityVerdict),
60    Heartbeat(TeamMemberHeartbeatResponse),
61    List(ListOutcome),
62    Peek(Box<ReadOutcome>),
63    Receive(Box<ReadOutcome>),
64    Clear(ClearOutcome),
65    Doctor(Box<DoctorReport>),
66    Error(ProtocolErrorEnvelope),
67}
68
69/// Serialized daemon-side ATM error for protocol transport.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71pub struct ProtocolErrorEnvelope {
72    pub code: AtmErrorCode,
73    pub message: String,
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub recovery: Vec<String>,
76}
77
78impl ProtocolErrorEnvelope {
79    pub fn from_error(error: &AtmError) -> Self {
80        Self {
81            code: error.code,
82            message: error.message.clone(),
83            recovery: error.recovery.clone(),
84        }
85    }
86
87    pub fn into_atm_error(self) -> AtmError {
88        let mut error =
89            AtmError::new_with_code(self.code, error_kind_for_code(self.code), self.message);
90        for recovery in self.recovery {
91            error = error.with_recovery(recovery);
92        }
93        error
94    }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98#[serde(transparent)]
99pub struct ReleaseVersion(String);
100
101impl ReleaseVersion {
102    pub fn parse(value: impl AsRef<str>) -> Result<Self, AtmError> {
103        let value = value
104            .as_ref()
105            .trim()
106            .strip_prefix('v')
107            .unwrap_or(value.as_ref().trim());
108        let mut parts = value.split('.');
109        let valid = (0..3).all(|_| {
110            parts.next().is_some_and(|part| {
111                !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())
112            })
113        }) && parts.next().is_none();
114        if !valid {
115            return Err(AtmError::new_with_code(
116                AtmErrorCode::ClientDaemonVersionIncompatible,
117                AtmErrorKind::DaemonUnavailable,
118                format!("invalid ATM release version `{value}`"),
119            )
120            .with_recovery(
121                "Install a matching released atm and atm-daemon pair before retrying.",
122            ));
123        }
124        Ok(Self(value.to_string()))
125    }
126
127    pub fn current() -> Self {
128        Self::parse(env!("CARGO_PKG_VERSION")).expect("package version must be semver")
129    }
130}
131
132impl fmt::Display for ReleaseVersion {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        f.write_str(&self.0)
135    }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct CompatibilityPreflight {
140    pub client_release: ReleaseVersion,
141    pub wire_version: u16,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145pub enum CompatibilityVerdict {
146    Compatible {
147        daemon_release: ReleaseVersion,
148    },
149    Incompatible {
150        client_release: ReleaseVersion,
151        daemon_release: ReleaseVersion,
152        code: AtmErrorCode,
153    },
154}
155
156const fn error_kind_for_code(code: AtmErrorCode) -> AtmErrorKind {
157    if let Some(kind) = structural_error_kind_for_code(code) {
158        kind
159    } else if let Some(kind) = observability_error_kind_for_code(code) {
160        kind
161    } else {
162        validation_error_kind_for_code(code)
163    }
164}
165
166const fn structural_error_kind_for_code(code: AtmErrorCode) -> Option<AtmErrorKind> {
167    match code {
168        AtmErrorCode::ConfigHomeUnavailable
169        | AtmErrorCode::AtmHomeUnresolved
170        | AtmErrorCode::ConfigParseFailed
171        | AtmErrorCode::ConfigRetiredHookMembersKey
172        | AtmErrorCode::ConfigRetiredLegacyHookKeys
173        | AtmErrorCode::ConfigTeamParseFailed
174        | AtmErrorCode::ConfigTeamMissing => Some(AtmErrorKind::Config),
175        AtmErrorCode::IdentityUnavailable
176        | AtmErrorCode::IdentityInvalid
177        | AtmErrorCode::WarningIdentityDrift
178        | AtmErrorCode::IdentityConflict => Some(AtmErrorKind::Identity),
179        AtmErrorCode::MemberAlreadyExists => Some(AtmErrorKind::Validation),
180        AtmErrorCode::MemberNotFound => Some(AtmErrorKind::AgentNotFound),
181        AtmErrorCode::AddressParseFailed => Some(AtmErrorKind::Address),
182        AtmErrorCode::TeamUnavailable | AtmErrorCode::TeamNotFound => {
183            Some(AtmErrorKind::TeamNotFound)
184        }
185        AtmErrorCode::AgentNotFound => Some(AtmErrorKind::AgentNotFound),
186        AtmErrorCode::MailboxReadFailed | AtmErrorCode::WarningMailboxRecordSkipped => {
187            Some(AtmErrorKind::MailboxRead)
188        }
189        AtmErrorCode::MailboxWriteFailed => Some(AtmErrorKind::MailboxWrite),
190        AtmErrorCode::MailboxLockFailed
191        | AtmErrorCode::MailboxLockReadOnlyFilesystem
192        | AtmErrorCode::MailboxLockTimeout
193        | AtmErrorCode::WarningStaleMailboxLock => Some(AtmErrorKind::MailboxLock),
194        AtmErrorCode::FilePolicyRejected | AtmErrorCode::FileReferenceRewriteFailed => {
195            Some(AtmErrorKind::FilePolicy)
196        }
197        AtmErrorCode::InternalError => Some(AtmErrorKind::Internal),
198        AtmErrorCode::SerializationFailed => Some(AtmErrorKind::Serialization),
199        AtmErrorCode::WaitTimeout => Some(AtmErrorKind::Timeout),
200        _ => None,
201    }
202}
203
204const fn observability_error_kind_for_code(code: AtmErrorCode) -> Option<AtmErrorKind> {
205    match code {
206        AtmErrorCode::DaemonUnavailable
207        | AtmErrorCode::RuntimeRootInvalid
208        | AtmErrorCode::RuntimeBootstrapRefused
209        | AtmErrorCode::SocketOverrideForbidden
210        | AtmErrorCode::DaemonMayHaveExecuted
211        | AtmErrorCode::DaemonLifecycleWedge
212        | AtmErrorCode::DaemonLaunchGateRejected
213        | AtmErrorCode::DaemonServingStateRejected
214        | AtmErrorCode::DaemonStaleOwnerRecoveryFailed
215        | AtmErrorCode::DaemonAutoStartFailed
216        | AtmErrorCode::DaemonConnectionSaturated
217        | AtmErrorCode::ClientDaemonVersionIncompatible
218        | AtmErrorCode::DaemonAdvisorySessionAlreadyRegistered
219        | AtmErrorCode::DaemonAdvisorySessionNotRegistered
220        | AtmErrorCode::DaemonAdvisorySessionCleanupFailed
221        | AtmErrorCode::RemoteDeliveryOutcomeUnknown
222        | AtmErrorCode::WarningSqliteHealthDegraded
223        | AtmErrorCode::PostSendAdvisoryDeliveryFailed => Some(AtmErrorKind::DaemonUnavailable),
224        AtmErrorCode::ObservabilityEmitFailed => Some(AtmErrorKind::ObservabilityEmit),
225        AtmErrorCode::ObservabilityQueryFailed => Some(AtmErrorKind::ObservabilityQuery),
226        AtmErrorCode::ObservabilityFollowFailed => Some(AtmErrorKind::ObservabilityFollow),
227        AtmErrorCode::ObservabilityHealthFailed
228        | AtmErrorCode::ObservabilityHealthOk
229        | AtmErrorCode::WarningObservabilityHealthDegraded => {
230            Some(AtmErrorKind::ObservabilityHealth)
231        }
232        AtmErrorCode::ObservabilityBootstrapFailed => Some(AtmErrorKind::ObservabilityBootstrap),
233        _ => None,
234    }
235}
236
237const fn validation_error_kind_for_code(code: AtmErrorCode) -> AtmErrorKind {
238    match code {
239        AtmErrorCode::MessageValidationFailed
240        | AtmErrorCode::SelfAddressedSendInvalid
241        | AtmErrorCode::EmptyNudgeTemplateBody
242        | AtmErrorCode::HelpTopicNotFound
243        | AtmErrorCode::AckInvalidState
244        | AtmErrorCode::ClearInvalidState
245        | AtmErrorCode::WarningInvalidTeamMemberSkipped
246        | AtmErrorCode::WarningMalformedAtmFieldIgnored
247        | AtmErrorCode::WarningOriginInboxEntrySkipped
248        | AtmErrorCode::WarningMissingTeamConfigFallback
249        | AtmErrorCode::WarningSendAlertStateDegraded
250        | AtmErrorCode::WarningRosterDrift
251        | AtmErrorCode::WarningBaselineMemberMissing
252        | AtmErrorCode::WarningRestoreInProgress
253        | AtmErrorCode::WarningHookSkipped
254        | AtmErrorCode::WarningHookExecutionFailed
255        | AtmErrorCode::PostSendPaneMissing
256        | AtmErrorCode::PostSendTmuxSendFailed
257        | AtmErrorCode::PostSendGraftUnavailable
258        | AtmErrorCode::TestFakeTransportInjectionFailed
259        | AtmErrorCode::TeamInvalid
260        | AtmErrorCode::CallerContextRequestInvalid => AtmErrorKind::Validation,
261        _ => AtmErrorKind::Validation,
262    }
263}
264
265/// Raw protocol frame payload plus the shared ATM frame header fields.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct FramePayload {
268    pub request_id: RequestId,
269    pub message_kind: MessageKind,
270    pub flags: u16,
271    pub bytes: Vec<u8>,
272}
273
274/// Maximum encoded daemon request/response frame size.
275pub const MAX_DAEMON_FRAME_BYTES: usize = 1024 * 1024;
276pub const ATM_FRAME_MAGIC: u32 = u32::from_be_bytes(*b"ATMD");
277pub const ATM_FRAME_VERSION_V1: u16 = 1;
278pub const ATM_FRAME_FLAGS_V1: u16 = 0;
279pub const ATM_FRAME_HEADER_BYTES: usize = 22;
280
281static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
284#[serde(transparent)]
285pub struct RequestId(NonZeroU64);
286
287impl RequestId {
288    pub fn new(request_id: u64) -> Result<Self, AtmError> {
289        let request_id = NonZeroU64::new(request_id).ok_or_else(|| {
290            AtmError::validation(
291                "ATM daemon protocol request_id must be non-zero",
292            )
293            .with_recovery(
294                "Retry with a client and daemon build that populate non-zero ATM daemon request ids.",
295            )
296        })?;
297        Ok(Self(request_id))
298    }
299
300    pub const fn into_inner(self) -> u64 {
301        self.0.get()
302    }
303}
304
305impl fmt::Display for RequestId {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        write!(f, "{}", self.0)
308    }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312#[repr(u16)]
313pub enum MessageKind {
314    SendComposeRequest = 0x0001,
315    SendAcknowledgeRequest = 0x0002,
316    HeartbeatRequest = 0x0003,
317    CompatibilityPreflightRequest = 0x0009,
318    ListRequest = 0x0004,
319    PeekRequest = 0x0005,
320    ReceiveRequest = 0x0006,
321    ClearRequest = 0x0007,
322    DoctorRequest = 0x0008,
323    SendSentResponse = 0x1001,
324    SendAcknowledgedResponse = 0x1002,
325    HeartbeatResponse = 0x1003,
326    CompatibilityVerdictResponse = 0x1009,
327    ListResponse = 0x1004,
328    PeekResponse = 0x1005,
329    ReceiveResponse = 0x1006,
330    ClearResponse = 0x1007,
331    DoctorResponse = 0x1008,
332    ErrorResponse = 0x1fff,
333}
334
335impl MessageKind {
336    pub const fn code(self) -> u16 {
337        self as u16
338    }
339
340    pub const fn is_request(self) -> bool {
341        matches!(
342            self,
343            Self::SendComposeRequest
344                | Self::SendAcknowledgeRequest
345                | Self::HeartbeatRequest
346                | Self::CompatibilityPreflightRequest
347                | Self::ListRequest
348                | Self::PeekRequest
349                | Self::ReceiveRequest
350                | Self::ClearRequest
351                | Self::DoctorRequest
352        )
353    }
354
355    pub const fn is_response(self) -> bool {
356        !self.is_request()
357    }
358}
359
360impl TryFrom<u16> for MessageKind {
361    type Error = AtmError;
362
363    fn try_from(value: u16) -> Result<Self, Self::Error> {
364        let kind = match value {
365            0x0001 => Self::SendComposeRequest,
366            0x0002 => Self::SendAcknowledgeRequest,
367            0x0003 => Self::HeartbeatRequest,
368            0x0009 => Self::CompatibilityPreflightRequest,
369            0x0004 => Self::ListRequest,
370            0x0005 => Self::PeekRequest,
371            0x0006 => Self::ReceiveRequest,
372            0x0007 => Self::ClearRequest,
373            0x0008 => Self::DoctorRequest,
374            0x1001 => Self::SendSentResponse,
375            0x1002 => Self::SendAcknowledgedResponse,
376            0x1003 => Self::HeartbeatResponse,
377            0x1009 => Self::CompatibilityVerdictResponse,
378            0x1004 => Self::ListResponse,
379            0x1005 => Self::PeekResponse,
380            0x1006 => Self::ReceiveResponse,
381            0x1007 => Self::ClearResponse,
382            0x1008 => Self::DoctorResponse,
383            0x1fff => Self::ErrorResponse,
384            _ => {
385                return Err(AtmError::validation(format!(
386                    "unsupported ATM daemon frame message kind 0x{value:04x}"
387                ))
388                .with_recovery(
389                    "Align the CLI and daemon builds so both sides speak the same ATM daemon protocol version before retrying.",
390                ));
391            }
392        };
393        Ok(kind)
394    }
395}
396
397#[derive(Debug, Clone, Default)]
398pub struct JsonAtmProtocolCodec;
399
400impl crate::boundary::sealed::Sealed for JsonAtmProtocolCodec {}
401
402impl crate::boundary::AtmProtocol for JsonAtmProtocolCodec {
403    fn request_to_frame(
404        &self,
405        request_id: RequestId,
406        request: RequestEnvelope,
407    ) -> Result<FramePayload, AtmError> {
408        request_to_frame_payload(request_id, request)
409    }
410
411    fn request_from_frame(
412        &self,
413        frame: FramePayload,
414    ) -> Result<(RequestId, RequestEnvelope), AtmError> {
415        request_from_frame_payload(frame)
416    }
417
418    fn response_to_frame(
419        &self,
420        request_id: RequestId,
421        response: ResponseEnvelope,
422    ) -> Result<FramePayload, AtmError> {
423        response_to_frame_payload(request_id, response)
424    }
425
426    fn response_from_frame(
427        &self,
428        frame: FramePayload,
429    ) -> Result<(RequestId, ResponseEnvelope), AtmError> {
430        response_from_frame_payload(frame)
431    }
432}
433
434pub fn next_request_id() -> RequestId {
435    loop {
436        let request_id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed);
437        if let Some(request_id) = NonZeroU64::new(request_id) {
438            return RequestId(request_id);
439        }
440    }
441}
442
443pub fn request_to_frame_payload(
444    request_id: RequestId,
445    request: RequestEnvelope,
446) -> Result<FramePayload, AtmError> {
447    Ok(FramePayload {
448        request_id,
449        message_kind: request_message_kind(&request),
450        flags: ATM_FRAME_FLAGS_V1,
451        bytes: serde_json::to_vec(&request).map_err(AtmError::from)?,
452    })
453}
454
455pub fn request_from_frame_payload(
456    frame: FramePayload,
457) -> Result<(RequestId, RequestEnvelope), AtmError> {
458    if !frame.message_kind.is_request() {
459        return Err(AtmError::validation(format!(
460            "ATM daemon request decoder received non-request message kind 0x{:04x}",
461            frame.message_kind.code()
462        ))
463        .with_recovery(
464            "Align the CLI and daemon builds so both sides agree on request and response packet roles before retrying.",
465        ));
466    }
467    let request = match frame.message_kind {
468        MessageKind::SendComposeRequest
469        | MessageKind::SendAcknowledgeRequest
470        | MessageKind::ListRequest
471        | MessageKind::PeekRequest
472        | MessageKind::ReceiveRequest
473        | MessageKind::ClearRequest => {
474            let value = serde_json::from_slice::<serde_json::Value>(&frame.bytes)
475                .map_err(AtmError::from)?;
476            validate_required_caller_context_fields(frame.message_kind, &value)?;
477            serde_json::from_value(value).map_err(AtmError::from)?
478        }
479        _ => serde_json::from_slice(&frame.bytes).map_err(AtmError::from)?,
480    };
481    Ok((frame.request_id, request))
482}
483
484fn validate_required_caller_context_fields(
485    message_kind: MessageKind,
486    value: &serde_json::Value,
487) -> Result<(), AtmError> {
488    let envelope = value.as_object().ok_or_else(|| {
489        AtmError::caller_context_request_invalid(
490            "daemon request payload must be a JSON object with caller_identity and caller_team",
491        )
492    })?;
493    let payload = caller_context_payload_object(message_kind, envelope)?;
494    parse_required_caller_identity(payload)?;
495    parse_required_caller_team(payload)?;
496    Ok(())
497}
498
499fn caller_context_payload_object(
500    message_kind: MessageKind,
501    envelope: &serde_json::Map<String, serde_json::Value>,
502) -> Result<&serde_json::Map<String, serde_json::Value>, AtmError> {
503    match message_kind {
504        MessageKind::SendComposeRequest => nested_payload_object(envelope, &["Send", "Compose"]),
505        MessageKind::SendAcknowledgeRequest => {
506            nested_payload_object(envelope, &["Send", "Acknowledge"])
507        }
508        MessageKind::ListRequest => nested_payload_object(envelope, &["List"]),
509        MessageKind::PeekRequest => nested_payload_object(envelope, &["Peek"]),
510        MessageKind::ReceiveRequest => nested_payload_object(envelope, &["Receive"]),
511        MessageKind::ClearRequest => nested_payload_object(envelope, &["Clear"]),
512        _ => unreachable!("caller-context validation only runs for caller-owned request kinds"),
513    }
514}
515
516fn nested_payload_object<'a>(
517    object: &'a serde_json::Map<String, serde_json::Value>,
518    path: &[&str],
519) -> Result<&'a serde_json::Map<String, serde_json::Value>, AtmError> {
520    let mut current = object;
521    for key in path {
522        let next = current.get(*key).ok_or_else(|| {
523            AtmError::caller_context_request_invalid(format!(
524                "daemon request payload is missing `{key}` envelope field"
525            ))
526        })?;
527        current = next.as_object().ok_or_else(|| {
528            AtmError::caller_context_request_invalid(format!(
529                "daemon request `{key}` envelope field must be a JSON object"
530            ))
531        })?;
532    }
533    Ok(current)
534}
535
536fn parse_required_caller_identity(
537    object: &serde_json::Map<String, serde_json::Value>,
538) -> Result<AgentName, AtmError> {
539    let value = object.get("caller_identity").ok_or_else(|| {
540        AtmError::caller_context_request_invalid("daemon request is missing caller_identity")
541    })?;
542    let raw = value.as_str().ok_or_else(|| {
543        AtmError::caller_context_request_invalid("daemon request caller_identity must be a string")
544    })?;
545    raw.parse::<AgentName>().map_err(|error| {
546        AtmError::caller_context_request_invalid(format!(
547            "daemon request caller_identity is invalid: {}",
548            error.message
549        ))
550    })
551}
552
553fn parse_required_caller_team(
554    object: &serde_json::Map<String, serde_json::Value>,
555) -> Result<TeamName, AtmError> {
556    let value = object.get("caller_team").ok_or_else(|| {
557        AtmError::caller_context_request_invalid("daemon request is missing caller_team")
558    })?;
559    let raw = value.as_str().ok_or_else(|| {
560        AtmError::caller_context_request_invalid("daemon request caller_team must be a string")
561    })?;
562    raw.parse::<TeamName>().map_err(|error| {
563        AtmError::caller_context_request_invalid(format!(
564            "daemon request caller_team is invalid: {}",
565            error.message
566        ))
567    })
568}
569
570pub fn response_to_frame_payload(
571    request_id: RequestId,
572    response: ResponseEnvelope,
573) -> Result<FramePayload, AtmError> {
574    Ok(FramePayload {
575        request_id,
576        message_kind: response_message_kind(&response),
577        flags: ATM_FRAME_FLAGS_V1,
578        bytes: serde_json::to_vec(&response).map_err(AtmError::from)?,
579    })
580}
581
582pub fn response_from_frame_payload(
583    frame: FramePayload,
584) -> Result<(RequestId, ResponseEnvelope), AtmError> {
585    if !frame.message_kind.is_response() {
586        return Err(AtmError::validation(format!(
587            "ATM daemon response decoder received non-response message kind 0x{:04x}",
588            frame.message_kind.code()
589        ))
590        .with_recovery(
591            "Align the CLI and daemon builds so both sides agree on request and response packet roles before retrying.",
592        ));
593    }
594    let response = serde_json::from_slice(&frame.bytes).map_err(AtmError::from)?;
595    Ok((frame.request_id, response))
596}
597
598pub fn write_frame(
599    writer: &mut impl Write,
600    frame: &FramePayload,
601    write_error: &'static str,
602) -> Result<(), AtmError> {
603    if frame.flags != ATM_FRAME_FLAGS_V1 {
604        return Err(AtmError::validation(format!(
605            "unsupported ATM daemon frame flags 0x{:04x} for version {}",
606            frame.flags, ATM_FRAME_VERSION_V1
607        ))
608        .with_recovery(
609            "Retry with a supported ATM daemon client/server build that uses protocol version 1 flags.",
610        ));
611    }
612    if frame.bytes.len() > MAX_DAEMON_FRAME_BYTES {
613        return Err(AtmError::daemon_unavailable(
614            "daemon frame exceeded the maximum supported size",
615        )
616        .with_recovery(
617            "Reduce the daemon request/response payload size before retrying the ATM command.",
618        ));
619    }
620    let mut header = [0u8; ATM_FRAME_HEADER_BYTES];
621    header[0..4].copy_from_slice(&ATM_FRAME_MAGIC.to_be_bytes());
622    header[4..6].copy_from_slice(&ATM_FRAME_VERSION_V1.to_be_bytes());
623    header[6..8].copy_from_slice(&frame.message_kind.code().to_be_bytes());
624    header[8..10].copy_from_slice(&frame.flags.to_be_bytes());
625    header[10..18].copy_from_slice(&frame.request_id.into_inner().to_be_bytes());
626    header[18..22].copy_from_slice(&(frame.bytes.len() as u32).to_be_bytes());
627    writer
628        .write_all(&header)
629        .and_then(|_| writer.write_all(&frame.bytes))
630        .map_err(|source| AtmError::daemon_unavailable(write_error).with_source(source))
631}
632
633pub fn read_frame(
634    reader: &mut impl Read,
635    read_error: &'static str,
636    oversize_error: &'static str,
637) -> Result<Option<FramePayload>, AtmError> {
638    let Some(header) = read_frame_header(reader, read_error)? else {
639        return Ok(None);
640    };
641
642    let header = decode_frame_header(header, oversize_error)?;
643    Ok(Some(read_frame_payload(reader, header, read_error)?))
644}
645
646#[derive(Debug, Clone, PartialEq, Eq)]
647pub struct FrameHeader {
648    pub request_id: RequestId,
649    pub message_kind: MessageKind,
650    pub flags: u16,
651    pub payload_length: usize,
652}
653
654pub fn decode_frame_header(
655    header: [u8; ATM_FRAME_HEADER_BYTES],
656    oversize_error: &'static str,
657) -> Result<FrameHeader, AtmError> {
658    let magic = u32::from_be_bytes(header[0..4].try_into().expect("magic"));
659    if magic != ATM_FRAME_MAGIC {
660        return Err(AtmError::validation(format!(
661            "unsupported ATM daemon frame magic 0x{magic:08x}"
662        ))
663        .with_recovery(
664            "Retry with an ATM client and daemon build that both speak the documented ATM daemon protocol.",
665        ));
666    }
667
668    let version = u16::from_be_bytes(header[4..6].try_into().expect("version"));
669    if version != ATM_FRAME_VERSION_V1 {
670        return Err(AtmError::validation(format!(
671            "unsupported ATM daemon frame version {version}"
672        ))
673        .with_recovery(
674            "Align the CLI and daemon builds so both sides use the same ATM daemon protocol version before retrying.",
675        ));
676    }
677
678    let message_kind =
679        MessageKind::try_from(u16::from_be_bytes(header[6..8].try_into().expect("kind")))?;
680    let flags = u16::from_be_bytes(header[8..10].try_into().expect("flags"));
681    if flags != ATM_FRAME_FLAGS_V1 {
682        return Err(AtmError::validation(format!(
683            "unsupported ATM daemon frame flags 0x{flags:04x} for version {version}"
684        ))
685        .with_recovery(
686            "Retry with a supported ATM daemon client/server build that uses the version-1 flag contract.",
687        ));
688    }
689    let request_id = RequestId::new(u64::from_be_bytes(
690        header[10..18].try_into().expect("request id"),
691    ))?;
692    let payload_length = u32::from_be_bytes(header[18..22].try_into().expect("payload")) as usize;
693    if payload_length > MAX_DAEMON_FRAME_BYTES {
694        return Err(AtmError::daemon_unavailable(oversize_error).with_recovery(
695            "Reduce the daemon request/response payload size before retrying the ATM command.",
696        ));
697    }
698
699    Ok(FrameHeader {
700        request_id,
701        message_kind,
702        flags,
703        payload_length,
704    })
705}
706
707pub fn read_frame_payload(
708    reader: &mut impl Read,
709    header: FrameHeader,
710    read_error: &'static str,
711) -> Result<FramePayload, AtmError> {
712    let mut bytes = vec![0u8; header.payload_length];
713    reader
714        .read_exact(&mut bytes)
715        .map_err(|source| AtmError::daemon_unavailable(read_error).with_source(source))?;
716    Ok(FramePayload {
717        request_id: header.request_id,
718        message_kind: header.message_kind,
719        flags: header.flags,
720        bytes,
721    })
722}
723
724pub fn read_frame_header(
725    reader: &mut impl Read,
726    read_error: &'static str,
727) -> Result<Option<[u8; ATM_FRAME_HEADER_BYTES]>, AtmError> {
728    let mut header = [0u8; ATM_FRAME_HEADER_BYTES];
729    let read = reader
730        .read(&mut header[..1])
731        .map_err(|source| AtmError::daemon_unavailable(read_error).with_source(source))?;
732    if read == 0 {
733        return Ok(None);
734    }
735    reader
736        .read_exact(&mut header[1..])
737        .map_err(|source| AtmError::daemon_unavailable(read_error).with_source(source))?;
738    Ok(Some(header))
739}
740
741fn request_message_kind(request: &RequestEnvelope) -> MessageKind {
742    match request {
743        RequestEnvelope::Send(SendRequestEnvelope::Compose(_)) => MessageKind::SendComposeRequest,
744        RequestEnvelope::Send(SendRequestEnvelope::Acknowledge(_)) => {
745            MessageKind::SendAcknowledgeRequest
746        }
747        RequestEnvelope::CompatibilityPreflight(_) => MessageKind::CompatibilityPreflightRequest,
748        RequestEnvelope::Heartbeat(_) => MessageKind::HeartbeatRequest,
749        RequestEnvelope::List(_) => MessageKind::ListRequest,
750        RequestEnvelope::Peek(_) => MessageKind::PeekRequest,
751        RequestEnvelope::Receive(_) => MessageKind::ReceiveRequest,
752        RequestEnvelope::Clear(_) => MessageKind::ClearRequest,
753        RequestEnvelope::Doctor(_) => MessageKind::DoctorRequest,
754    }
755}
756
757fn response_message_kind(response: &ResponseEnvelope) -> MessageKind {
758    match response {
759        ResponseEnvelope::Send(SendResponseEnvelope::Sent(_)) => MessageKind::SendSentResponse,
760        ResponseEnvelope::Send(SendResponseEnvelope::Acknowledged(_)) => {
761            MessageKind::SendAcknowledgedResponse
762        }
763        ResponseEnvelope::CompatibilityVerdict(_) => MessageKind::CompatibilityVerdictResponse,
764        ResponseEnvelope::Heartbeat(_) => MessageKind::HeartbeatResponse,
765        ResponseEnvelope::List(_) => MessageKind::ListResponse,
766        ResponseEnvelope::Peek(_) => MessageKind::PeekResponse,
767        ResponseEnvelope::Receive(_) => MessageKind::ReceiveResponse,
768        ResponseEnvelope::Clear(_) => MessageKind::ClearResponse,
769        ResponseEnvelope::Doctor(_) => MessageKind::DoctorResponse,
770        ResponseEnvelope::Error(_) => MessageKind::ErrorResponse,
771    }
772}
773
774/// Read one daemon frame into memory while enforcing the shared size cap.
775///
776/// # Errors
777///
778/// Returns [`AtmError`] when the stream cannot be read or when the payload
779/// exceeds [`MAX_DAEMON_FRAME_BYTES`].
780pub fn read_bounded_stream(
781    stream: &mut impl Read,
782    read_error: &'static str,
783    oversize_error: &'static str,
784) -> Result<Vec<u8>, AtmError> {
785    let mut bytes = Vec::new();
786    let mut chunk = [0u8; 8192];
787    loop {
788        let read = stream
789            .read(&mut chunk)
790            .map_err(|source| AtmError::daemon_unavailable(read_error).with_source(source))?;
791        if read == 0 {
792            return Ok(bytes);
793        }
794        if bytes.len().saturating_add(read) > MAX_DAEMON_FRAME_BYTES {
795            return Err(AtmError::daemon_unavailable(oversize_error).with_recovery(
796                "Reduce the daemon request/response payload size before retrying the ATM command.",
797            ));
798        }
799        bytes.extend_from_slice(&chunk[..read]);
800    }
801}
802
803/// Resolve the active daemon socket path for the ATM request transport.
804///
805/// # Errors
806///
807/// Returns [`AtmError`] when the accepted ATM runtime root cannot be resolved.
808pub fn daemon_socket_path() -> Result<PathBuf, AtmError> {
809    if env::var_os("ATM_DAEMON_SOCKET").is_some_and(|value| !value.is_empty()) {
810        return Err(AtmError::socket_override_forbidden(
811            "ATM_DAEMON_SOCKET cannot override the host singleton endpoint",
812        )
813        .with_recovery(
814            "Remove ATM_DAEMON_SOCKET and connect through the OS-user ATM daemon endpoint.",
815        ));
816    }
817    Ok(platform_local_ipc_endpoint_path(
818        home::current_host_runtime_scope()?.socket,
819    ))
820}
821
822/// Resolve the canonical daemon socket path for one accepted ATM home root.
823pub fn daemon_socket_path_from_home(home_dir: &Path) -> PathBuf {
824    platform_local_ipc_endpoint_path(
825        home::host_runtime_dir_from_home(home_dir).join(DAEMON_SOCKET_FILENAME),
826    )
827}
828
829/// Resolve the active local IPC name for the ATM request transport.
830///
831/// # Errors
832///
833/// Returns [`AtmError`] when the active endpoint cannot be mapped to a valid
834/// platform-local IPC name.
835pub fn daemon_local_ipc_name() -> Result<Name<'static>, AtmError> {
836    daemon_local_ipc_name_from_path(&daemon_socket_path()?)
837}
838
839/// Convert one daemon endpoint path into the concrete platform-local IPC name
840/// used by the same-host transport.
841///
842/// # Errors
843///
844/// Returns [`AtmError`] when the endpoint cannot be represented by the current
845/// platform-local IPC implementation.
846pub fn daemon_local_ipc_name_from_path(endpoint_path: &Path) -> Result<Name<'static>, AtmError> {
847    let normalized = platform_local_ipc_endpoint_path(endpoint_path.to_path_buf());
848    normalized
849        .into_os_string()
850        .to_fs_name::<GenericFilePath>()
851        .map_err(|source| {
852            AtmError::daemon_unavailable(format!(
853                "failed to map daemon local IPC endpoint {} to a supported platform-local IPC name",
854                endpoint_path.display()
855            ))
856            .with_source(source)
857            .with_recovery(
858                "Set ATM_DAEMON_SOCKET to a valid daemon local IPC endpoint and retry the ATM command.",
859            )
860        })
861}
862
863#[cfg(windows)]
864fn platform_local_ipc_endpoint_path(path: PathBuf) -> PathBuf {
865    const WINDOWS_PIPE_PREFIX: &str = r"\\.\pipe\";
866
867    let raw = path.to_string_lossy();
868    if raw.starts_with(WINDOWS_PIPE_PREFIX) {
869        return path;
870    }
871
872    let mut hash = 0xcbf29ce484222325u64;
873    for byte in raw.as_bytes() {
874        hash ^= u64::from(*byte);
875        hash = hash.wrapping_mul(0x100000001b3);
876    }
877
878    let mut leaf = path
879        .file_stem()
880        .map(|value| value.to_string_lossy().into_owned())
881        .unwrap_or_else(|| "atm-daemon".to_string());
882    leaf.retain(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_');
883    if leaf.is_empty() {
884        leaf = "atm-daemon".to_string();
885    }
886
887    PathBuf::from(format!(r"\\.\pipe\atm-{}-{hash:016x}", leaf))
888}
889
890#[cfg(not(windows))]
891fn platform_local_ipc_endpoint_path(path: PathBuf) -> PathBuf {
892    path
893}
894
895/// Shared notification event payload.
896#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
897#[serde(rename_all = "snake_case")]
898pub enum NotificationKind {
899    Delivery,
900    #[deprecated(note = "Phase AD obsolete: historical reconcile/watch only")]
901    ReconcileComplete,
902}
903
904impl fmt::Display for NotificationKind {
905    #[allow(
906        deprecated,
907        reason = "Phase AD obsolete transport strings remain stable for historical reconcile/watch decoding and formatting support."
908    )]
909    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
910        let value = match self {
911            Self::Delivery => "delivery",
912            Self::ReconcileComplete => "reconcile_complete",
913        };
914        f.write_str(value)
915    }
916}
917
918#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
919pub struct NotificationEvent {
920    pub kind: NotificationKind,
921    pub detail: String,
922    pub team: Option<TeamName>,
923    pub agent: Option<AgentName>,
924}
925
926/// Runtime heartbeat activity transported into the daemon status cache.
927#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
928#[serde(rename_all = "snake_case")]
929pub enum HeartbeatActivity {
930    ActiveToolUse,
931    Idle,
932    SessionEnded,
933}
934
935/// One daemon heartbeat request for one team member identity.
936#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
937pub struct TeamMemberHeartbeatRequest {
938    pub team: TeamName,
939    pub member: AgentName,
940    pub pid: u32,
941    pub observed_at: IsoTimestamp,
942    pub activity: HeartbeatActivity,
943}
944
945/// One daemon heartbeat response after runtime-state application.
946#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
947pub struct TeamMemberHeartbeatResponse {
948    pub team: TeamName,
949    pub member: AgentName,
950    pub pid: u32,
951    #[serde(default)]
952    pub pid_changed: bool,
953    pub state: RuntimeMemberState,
954    #[serde(default, skip_serializing_if = "Option::is_none")]
955    pub last_active_at: Option<IsoTimestamp>,
956}
957
958/// Runtime-owned live-state projection for one known team member.
959#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
960#[serde(rename_all = "snake_case")]
961pub enum RuntimeMemberState {
962    Unknown,
963    IdentityConflict,
964    Offline,
965    Idle,
966    Active,
967}
968
969/// Process-level daemon liveness state used by doctor and status queries.
970#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
971#[serde(rename_all = "snake_case")]
972pub enum RuntimeLivenessState {
973    Running,
974    Unavailable,
975}
976
977/// Request-serving readiness state used by doctor and status queries.
978#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
979#[serde(rename_all = "snake_case")]
980pub enum RuntimeReadinessState {
981    Ready,
982    Degraded,
983    Unavailable,
984}
985
986/// Aggregate live-member counts carried in daemon runtime snapshots.
987#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
988pub struct RuntimeStatusCounts {
989    pub active_members: usize,
990    pub idle_members: usize,
991    pub offline_members: usize,
992    pub unknown_members: usize,
993}
994
995/// Runtime status snapshot transport payload.
996#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
997pub struct RuntimeStatusSnapshot {
998    pub liveness: RuntimeLivenessState,
999    pub readiness: RuntimeReadinessState,
1000    #[serde(default, skip_serializing_if = "Option::is_none")]
1001    pub detail: Option<String>,
1002    #[serde(default, skip_serializing_if = "Option::is_none")]
1003    pub singleton_owner_pid: Option<u32>,
1004    #[serde(default)]
1005    pub degraded_ingest: bool,
1006    #[serde(default)]
1007    pub member_counts: RuntimeStatusCounts,
1008}
1009
1010/// Watch subscription request payload.
1011#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1012#[deprecated(note = "Phase AD obsolete: historical reconcile/watch only")]
1013pub struct WatchSubscriptionRequest {
1014    pub home_dir: PathBuf,
1015    pub team: TeamName,
1016    pub agent: AgentName,
1017}
1018
1019/// Watch event batch transport payload.
1020#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1021#[deprecated(note = "Phase AD obsolete: historical reconcile/watch only")]
1022pub struct WatchEventBatch {
1023    pub paths: Vec<PathBuf>,
1024}
1025
1026/// Reconcile request transport payload.
1027#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1028#[deprecated(note = "Phase AD obsolete: historical reconcile/watch only")]
1029pub struct ReconcileRequest {
1030    pub home_dir: PathBuf,
1031    pub team: TeamName,
1032    pub agent: AgentName,
1033}
1034
1035/// Reconcile outcome transport payload.
1036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1037#[deprecated(note = "Phase AD obsolete: historical reconcile/watch only")]
1038pub struct ReconcileResult {
1039    pub observed_paths: usize,
1040    pub imported_sources: usize,
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use std::path::PathBuf;
1046
1047    use super::{
1048        DAEMON_SOCKET_FILENAME, HeartbeatActivity, ProtocolErrorEnvelope, RequestEnvelope,
1049        ResponseEnvelope, RuntimeLivenessState, RuntimeMemberState, RuntimeReadinessState,
1050        RuntimeStatusCounts, RuntimeStatusSnapshot, TeamMemberHeartbeatRequest,
1051        TeamMemberHeartbeatResponse, daemon_socket_path, daemon_socket_path_from_home,
1052        next_request_id, platform_local_ipc_endpoint_path, request_from_frame_payload,
1053        request_to_frame_payload,
1054    };
1055    use crate::error::AtmError;
1056    use crate::error_codes::AtmErrorCode;
1057    use crate::list::ListQuery;
1058    use crate::send::{SendMessageSource, SendRequest};
1059    use crate::test_support::{EnvGuard, TEST_SENDER, TEST_TEAM};
1060    use crate::types::{AgentName, IsoTimestamp, ReadSelection, TeamName};
1061    use serial_test::serial;
1062    use tempfile::TempDir;
1063
1064    #[test]
1065    fn heartbeat_request_envelope_round_trips() {
1066        let observed_at = IsoTimestamp::now();
1067        let request = RequestEnvelope::Heartbeat(TeamMemberHeartbeatRequest {
1068            team: TeamName::from_validated("test-team"),
1069            member: AgentName::from_validated("test-agent"),
1070            pid: 4242,
1071            observed_at,
1072            activity: HeartbeatActivity::ActiveToolUse,
1073        });
1074
1075        let encoded = serde_json::to_vec(&request).expect("encode heartbeat request");
1076        let decoded: RequestEnvelope =
1077            serde_json::from_slice(&encoded).expect("decode heartbeat request");
1078
1079        match decoded {
1080            RequestEnvelope::Heartbeat(decoded) => {
1081                assert_eq!(decoded.team, TeamName::from_validated("test-team"));
1082                assert_eq!(decoded.member, AgentName::from_validated("test-agent"));
1083                assert_eq!(decoded.pid, 4242);
1084                assert_eq!(decoded.observed_at, observed_at);
1085                assert_eq!(decoded.activity, HeartbeatActivity::ActiveToolUse);
1086            }
1087            other => panic!("expected heartbeat request, got {other:?}"),
1088        }
1089    }
1090
1091    #[test]
1092    fn heartbeat_response_envelope_round_trips() {
1093        let last_active_at = IsoTimestamp::now();
1094        let response = ResponseEnvelope::Heartbeat(TeamMemberHeartbeatResponse {
1095            team: TeamName::from_validated("test-team"),
1096            member: AgentName::from_validated("test-agent"),
1097            pid: 4242,
1098            pid_changed: true,
1099            state: RuntimeMemberState::Active,
1100            last_active_at: Some(last_active_at),
1101        });
1102
1103        let encoded = serde_json::to_vec(&response).expect("encode heartbeat response");
1104        let decoded: ResponseEnvelope =
1105            serde_json::from_slice(&encoded).expect("decode heartbeat response");
1106
1107        match decoded {
1108            ResponseEnvelope::Heartbeat(decoded) => {
1109                assert_eq!(decoded.team, TeamName::from_validated("test-team"));
1110                assert_eq!(decoded.member, AgentName::from_validated("test-agent"));
1111                assert_eq!(decoded.pid, 4242);
1112                assert!(decoded.pid_changed);
1113                assert_eq!(decoded.state, RuntimeMemberState::Active);
1114                assert_eq!(decoded.last_active_at, Some(last_active_at));
1115            }
1116            other => panic!("expected heartbeat response, got {other:?}"),
1117        }
1118    }
1119
1120    #[test]
1121    fn runtime_status_snapshot_round_trips() {
1122        let snapshot = RuntimeStatusSnapshot {
1123            liveness: RuntimeLivenessState::Running,
1124            readiness: RuntimeReadinessState::Ready,
1125            detail: Some("runtime cache ready".to_string()),
1126            singleton_owner_pid: Some(777),
1127            degraded_ingest: false,
1128            member_counts: RuntimeStatusCounts {
1129                active_members: 2,
1130                idle_members: 1,
1131                offline_members: 1,
1132                unknown_members: 3,
1133            },
1134        };
1135
1136        let encoded = serde_json::to_vec(&snapshot).expect("encode runtime snapshot");
1137        let decoded: RuntimeStatusSnapshot =
1138            serde_json::from_slice(&encoded).expect("decode runtime snapshot");
1139
1140        assert_eq!(decoded, snapshot);
1141    }
1142
1143    #[test]
1144    fn daemon_socket_path_from_home_uses_atm_home_runtime_subtree() {
1145        let tempdir = TempDir::new().expect("tempdir");
1146        let logical_endpoint =
1147            crate::home::host_runtime_dir_from_home(tempdir.path()).join(DAEMON_SOCKET_FILENAME);
1148
1149        assert_eq!(
1150            daemon_socket_path_from_home(tempdir.path()),
1151            platform_local_ipc_endpoint_path(logical_endpoint)
1152        );
1153    }
1154
1155    #[test]
1156    #[serial(env)]
1157    fn daemon_socket_path_ignores_atm_home() {
1158        let tempdir = TempDir::new().expect("tempdir");
1159        let atm_home = tempdir.path().join("atm-home");
1160        let os_home = tempdir.path().join("os-home");
1161        let _env = EnvGuard::set_many([
1162            ("ATM_HOME", Some(atm_home.to_str().expect("utf8 atm home"))),
1163            ("ATM_DAEMON_SOCKET", None),
1164            ("HOME", Some(os_home.to_str().expect("utf8 os home"))),
1165        ]);
1166
1167        assert_eq!(
1168            daemon_socket_path().expect("daemon socket path"),
1169            platform_local_ipc_endpoint_path(
1170                crate::home::current_host_runtime_scope()
1171                    .expect("host scope")
1172                    .socket,
1173            )
1174        );
1175    }
1176
1177    #[test]
1178    #[serial(env)]
1179    fn daemon_socket_path_rejects_override() {
1180        let _env = EnvGuard::set_many([("ATM_DAEMON_SOCKET", Some("/tmp/alternate.sock"))]);
1181        assert!(daemon_socket_path().is_err());
1182    }
1183
1184    #[test]
1185    fn protocol_error_envelope_preserves_remote_delivery_outcome_unknown_recovery() {
1186        let error = AtmError::remote_delivery_outcome_unknown(
1187            "remote peer delivery outcome is unknown and replay persistence failed",
1188        )
1189        .with_source(
1190            AtmError::daemon_unavailable("remote replay store is not configured").with_recovery(
1191                "Restore the host-scoped ATM durable replay store before retrying remote delivery so atm-daemon can resume unknown peer handoffs safely.",
1192            ),
1193        );
1194        let envelope = ProtocolErrorEnvelope::from_error(&error);
1195        let round_trip = envelope.into_atm_error();
1196
1197        assert_eq!(round_trip.code, AtmErrorCode::RemoteDeliveryOutcomeUnknown);
1198        assert_eq!(round_trip.message, error.message);
1199        assert_eq!(round_trip.recovery, error.recovery);
1200    }
1201
1202    #[test]
1203    fn protocol_error_envelope_round_trips_member_not_found_as_agent_not_found() {
1204        let error = AtmError::member_not_found(TEST_SENDER, TEST_TEAM);
1205        let envelope = ProtocolErrorEnvelope::from_error(&error);
1206        let round_trip = envelope.into_atm_error();
1207
1208        assert_eq!(round_trip.code, AtmErrorCode::MemberNotFound);
1209        assert!(round_trip.is_agent_not_found());
1210    }
1211
1212    fn test_atm_home_dir() -> PathBuf {
1213        std::env::temp_dir().join("atm-protocol-test-home")
1214    }
1215
1216    fn test_workspace_dir() -> PathBuf {
1217        std::env::temp_dir().join("atm-protocol-test-workspace")
1218    }
1219
1220    #[test]
1221    fn request_from_frame_payload_accepts_nested_send_caller_context() {
1222        let request = RequestEnvelope::Send(super::SendRequestEnvelope::Compose(
1223            SendRequest::new(
1224                test_atm_home_dir(),
1225                test_workspace_dir(),
1226                AgentName::from_validated(TEST_SENDER),
1227                "recipient@test-team",
1228                TeamName::from_validated(TEST_TEAM),
1229                SendMessageSource::Inline("hello".to_string()),
1230                None,
1231                false,
1232                None,
1233                false,
1234            )
1235            .expect("send request"),
1236        ));
1237
1238        let frame = request_to_frame_payload(next_request_id(), request).expect("frame");
1239        let (_request_id, decoded) =
1240            request_from_frame_payload(frame).expect("decode nested send request");
1241
1242        match decoded {
1243            RequestEnvelope::Send(super::SendRequestEnvelope::Compose(request)) => {
1244                assert_eq!(request.caller_identity.as_str(), TEST_SENDER);
1245                assert_eq!(request.caller_team.as_str(), TEST_TEAM);
1246            }
1247            other => panic!("expected send request, got {other:?}"),
1248        }
1249    }
1250
1251    #[test]
1252    fn request_from_frame_payload_accepts_nested_list_caller_context() {
1253        let request = RequestEnvelope::List(
1254            ListQuery::new(
1255                test_atm_home_dir(),
1256                test_workspace_dir(),
1257                AgentName::from_validated(TEST_SENDER),
1258                Some("recipient@test-team"),
1259                TeamName::from_validated(TEST_TEAM),
1260                ReadSelection::Unread,
1261                false,
1262                Some(25),
1263                None,
1264                None,
1265                None,
1266                None,
1267            )
1268            .expect("list query"),
1269        );
1270
1271        let frame = request_to_frame_payload(next_request_id(), request).expect("frame");
1272        let (_request_id, decoded) =
1273            request_from_frame_payload(frame).expect("decode nested list request");
1274
1275        match decoded {
1276            RequestEnvelope::List(query) => {
1277                assert_eq!(query.caller_identity.as_str(), TEST_SENDER);
1278                assert_eq!(query.caller_team.as_str(), TEST_TEAM);
1279            }
1280            other => panic!("expected list request, got {other:?}"),
1281        }
1282    }
1283}