Skip to main content

atm_storage/
error.rs

1use std::backtrace::{Backtrace, BacktraceStatus};
2use std::error::Error as StdError;
3use std::fmt;
4
5pub use crate::error_codes::AtmErrorCode;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum AtmErrorKind {
9    Config,
10    MissingDocument,
11    Address,
12    Identity,
13    DaemonUnavailable,
14    TeamNotFound,
15    AgentNotFound,
16    MailboxLock,
17    MailboxRead,
18    MailboxWrite,
19    FilePolicy,
20    Internal,
21    Validation,
22    Serialization,
23    Timeout,
24    ObservabilityEmit,
25    ObservabilityBootstrap,
26    ObservabilityQuery,
27    ObservabilityFollow,
28    ObservabilityHealth,
29}
30
31#[derive(Debug)]
32pub struct AtmError {
33    pub code: AtmErrorCode,
34    pub kind: AtmErrorKind,
35    pub message: String,
36    pub recovery: Vec<String>,
37    pub source: Option<Box<dyn StdError + Send + Sync>>,
38    pub backtrace: Backtrace,
39}
40
41impl AtmError {
42    pub fn new(kind: AtmErrorKind, message: impl Into<String>) -> Self {
43        Self::new_with_code(kind.default_code(), kind, message)
44    }
45
46    pub fn new_with_code(
47        code: AtmErrorCode,
48        kind: AtmErrorKind,
49        message: impl Into<String>,
50    ) -> Self {
51        Self {
52            code,
53            kind,
54            message: message.into(),
55            recovery: Vec::new(),
56            source: None,
57            backtrace: Backtrace::capture(),
58        }
59    }
60
61    pub fn is_config(&self) -> bool {
62        self.kind == AtmErrorKind::Config
63    }
64    pub fn is_address(&self) -> bool {
65        self.kind == AtmErrorKind::Address
66    }
67    pub fn is_missing_document(&self) -> bool {
68        self.kind == AtmErrorKind::MissingDocument
69    }
70    pub fn is_identity(&self) -> bool {
71        self.kind == AtmErrorKind::Identity
72    }
73    pub fn is_team_not_found(&self) -> bool {
74        self.kind == AtmErrorKind::TeamNotFound
75    }
76    pub fn is_daemon_unavailable(&self) -> bool {
77        self.kind == AtmErrorKind::DaemonUnavailable
78    }
79    pub fn is_agent_not_found(&self) -> bool {
80        self.kind == AtmErrorKind::AgentNotFound
81    }
82    pub fn is_mailbox_read(&self) -> bool {
83        self.kind == AtmErrorKind::MailboxRead
84    }
85    pub fn is_mailbox_lock(&self) -> bool {
86        self.kind == AtmErrorKind::MailboxLock
87    }
88    pub fn is_mailbox_write(&self) -> bool {
89        self.kind == AtmErrorKind::MailboxWrite
90    }
91    pub fn is_file_policy(&self) -> bool {
92        self.kind == AtmErrorKind::FilePolicy
93    }
94    pub fn is_internal(&self) -> bool {
95        self.kind == AtmErrorKind::Internal
96    }
97    pub fn is_validation(&self) -> bool {
98        self.kind == AtmErrorKind::Validation
99    }
100    pub fn is_serialization(&self) -> bool {
101        self.kind == AtmErrorKind::Serialization
102    }
103    pub fn is_timeout(&self) -> bool {
104        self.kind == AtmErrorKind::Timeout
105    }
106    pub fn is_observability_emit(&self) -> bool {
107        self.kind == AtmErrorKind::ObservabilityEmit
108    }
109    pub fn is_observability_bootstrap(&self) -> bool {
110        self.kind == AtmErrorKind::ObservabilityBootstrap
111    }
112    pub fn is_observability_query(&self) -> bool {
113        self.kind == AtmErrorKind::ObservabilityQuery
114    }
115    pub fn is_observability_follow(&self) -> bool {
116        self.kind == AtmErrorKind::ObservabilityFollow
117    }
118    pub fn is_observability_health(&self) -> bool {
119        self.kind == AtmErrorKind::ObservabilityHealth
120    }
121
122    pub fn with_recovery(mut self, recovery: impl Into<String>) -> Self {
123        self.recovery.push(recovery.into());
124        self
125    }
126
127    pub fn primary_recovery(&self) -> Option<&str> {
128        self.recovery.first().map(String::as_str)
129    }
130
131    pub fn with_source<E>(mut self, source: E) -> Self
132    where
133        E: StdError + Send + Sync + 'static,
134    {
135        self.source = Some(Box::new(source));
136        self
137    }
138
139    pub fn backtrace(&self) -> Option<&Backtrace> {
140        (self.backtrace.status() == BacktraceStatus::Captured).then_some(&self.backtrace)
141    }
142
143    pub fn home_directory_unavailable() -> Self {
144        Self::new_with_code(
145            AtmErrorCode::ConfigHomeUnavailable,
146            AtmErrorKind::Config,
147            "home directory is unavailable",
148        )
149        .with_recovery("Set ATM_HOME or ensure the OS home directory can be resolved.")
150    }
151
152    pub fn atm_home_unresolved(message: impl Into<String>) -> Self {
153        Self::new_with_code(
154            AtmErrorCode::AtmHomeUnresolved,
155            AtmErrorKind::Config,
156            message,
157        )
158        .with_recovery(
159            "Set ATM_HOME or ensure the OS home directory can be resolved before retrying the ATM command.",
160        )
161    }
162
163    pub fn config(message: impl Into<String>) -> Self {
164        Self::new(AtmErrorKind::Config, message).with_recovery(
165            "Check the active ATM configuration, runtime wiring, and local path settings before retrying.",
166        )
167    }
168
169    pub fn address_parse(message: impl Into<String>) -> Self {
170        Self::new(
171            AtmErrorKind::Address,
172            format!("address parse failed: {}", message.into()),
173        )
174        .with_recovery(
175            "Correct the ATM address format and retry with a valid <agent> or <agent>@<team> target.",
176        )
177    }
178
179    pub fn identity_unavailable() -> Self {
180        Self::new_with_code(
181            AtmErrorCode::IdentityUnavailable,
182            AtmErrorKind::Identity,
183            "identity is not configured",
184        )
185        .with_recovery("Set ATM_IDENTITY or provide an explicit command identity override when the command supports one.")
186    }
187
188    pub fn identity_invalid(message: impl Into<String>) -> Self {
189        Self::new_with_code(
190            AtmErrorCode::IdentityInvalid,
191            AtmErrorKind::Identity,
192            format!("caller identity is invalid: {}", message.into()),
193        )
194        .with_recovery(
195            "Set ATM_IDENTITY or provide an explicit command identity override using a valid ATM agent name.",
196        )
197    }
198
199    pub fn identity_conflict(message: impl Into<String>) -> Self {
200        Self::new_with_code(
201            AtmErrorCode::IdentityConflict,
202            AtmErrorKind::Identity,
203            message,
204        )
205        .with_recovery("Stop and report to the user immediately. Resolve the live pid conflict before retrying ATM activity.")
206    }
207
208    pub fn member_already_exists(member: &str, team: &str) -> Self {
209        Self::new_with_code(
210            AtmErrorCode::MemberAlreadyExists,
211            AtmErrorKind::Validation,
212            format!("member '{member}' already exists in team '{team}'"),
213        )
214        .with_recovery(
215            "Use `atm teams update-member` to repair metadata for an existing member instead of retrying `atm teams add-member`.",
216        )
217    }
218
219    pub fn member_not_found(member: &str, team: &str) -> Self {
220        Self::new_with_code(
221            AtmErrorCode::MemberNotFound,
222            AtmErrorKind::AgentNotFound,
223            format!("member '{member}' was not found in team '{team}'"),
224        )
225        .with_recovery(
226            "Confirm the target team/member pair, create the member with `atm teams add-member` if it is genuinely missing, or retry `atm teams update-member` against an existing member row.",
227        )
228    }
229
230    pub fn daemon_unavailable(message: impl Into<String>) -> Self {
231        Self::new_with_code(
232            AtmErrorCode::DaemonUnavailable,
233            AtmErrorKind::DaemonUnavailable,
234            message,
235        )
236        .with_recovery(
237            "Ensure the atm-daemon binary is installed, the daemon socket path is reachable, and ATM_DAEMON_BIN/ATM_HOME are set correctly before retrying.",
238        )
239    }
240
241    pub fn runtime_root_invalid(message: impl Into<String>) -> Self {
242        Self::new_with_code(
243            AtmErrorCode::RuntimeRootInvalid,
244            AtmErrorKind::DaemonUnavailable,
245            message,
246        )
247        .with_recovery(
248            "Repair ATM_HOME, the derived daemon/socket/database root, or the active working directory before retrying the ATM command.",
249        )
250    }
251
252    pub fn runtime_bootstrap_refused(message: impl Into<String>) -> Self {
253        Self::new_with_code(
254            AtmErrorCode::RuntimeBootstrapRefused,
255            AtmErrorKind::DaemonUnavailable,
256            message,
257        )
258        .with_recovery(
259            "Clear the conflicting daemon runtime override or repair the canonical ATM runtime root before retrying the ATM command.",
260        )
261    }
262
263    pub fn socket_override_forbidden(message: impl Into<String>) -> Self {
264        Self::new_with_code(
265            AtmErrorCode::SocketOverrideForbidden,
266            AtmErrorKind::Config,
267            message,
268        )
269        .with_recovery(
270            "Remove ATM_DAEMON_SOCKET; ATM always connects through the OS-user daemon endpoint.",
271        )
272    }
273
274    pub fn daemon_may_have_executed(message: impl Into<String>) -> Self {
275        Self::new_with_code(
276            AtmErrorCode::DaemonMayHaveExecuted,
277            AtmErrorKind::DaemonUnavailable,
278            message,
279        )
280        .with_recovery(
281            "Check the destination mailbox or other service-side effects before retrying this same-host ATM command.",
282        )
283    }
284
285    pub fn daemon_lifecycle_wedge(message: impl Into<String>) -> Self {
286        Self::new_with_code(
287            AtmErrorCode::DaemonLifecycleWedge,
288            AtmErrorKind::DaemonUnavailable,
289            message,
290        )
291        .with_recovery(
292            "Restart the daemon after the local IPC listener and lifecycle-control state fully stop, then inspect daemon logs for the wedged shutdown path.",
293        )
294    }
295
296    pub fn daemon_advisory_session_already_registered(message: impl Into<String>) -> Self {
297        Self::new_with_code(
298            AtmErrorCode::DaemonAdvisorySessionAlreadyRegistered,
299            AtmErrorKind::DaemonUnavailable,
300            message,
301        )
302        .with_recovery(
303            "Unregister the existing advisory session or choose a fresh session id before retrying embedded advisory activation.",
304        )
305    }
306
307    pub fn daemon_advisory_session_not_registered(message: impl Into<String>) -> Self {
308        Self::new_with_code(
309            AtmErrorCode::DaemonAdvisorySessionNotRegistered,
310            AtmErrorKind::DaemonUnavailable,
311            message,
312        )
313        .with_recovery(
314            "Register the advisory session before fetching or draining daemon-owned advisory state.",
315        )
316    }
317
318    pub fn daemon_advisory_session_cleanup_failed(message: impl Into<String>) -> Self {
319        Self::new_with_code(
320            AtmErrorCode::DaemonAdvisorySessionCleanupFailed,
321            AtmErrorKind::DaemonUnavailable,
322            message,
323        )
324        .with_recovery(
325            "Inspect advisory-session lifecycle logs, clean up any orphaned graft session, and retry only after the daemon unregister path is healthy.",
326        )
327    }
328
329    pub fn daemon_launch_gate_rejected(message: impl Into<String>) -> Self {
330        Self::new_with_code(
331            AtmErrorCode::DaemonLaunchGateRejected,
332            AtmErrorKind::DaemonUnavailable,
333            message,
334        )
335        .with_recovery(
336            "Connect to the existing daemon if it is healthy, or resolve stale ownership before retrying another daemon launch.",
337        )
338    }
339
340    pub fn daemon_serving_state_rejected(message: impl Into<String>) -> Self {
341        Self::new_with_code(
342            AtmErrorCode::DaemonServingStateRejected,
343            AtmErrorKind::DaemonUnavailable,
344            message,
345        )
346        .with_recovery(
347            "Stop launching duplicate daemons and inspect the existing runtime owner before retrying startup.",
348        )
349    }
350
351    pub fn daemon_stale_owner_recovery_failed(message: impl Into<String>) -> Self {
352        Self::new_with_code(
353            AtmErrorCode::DaemonStaleOwnerRecoveryFailed,
354            AtmErrorKind::DaemonUnavailable,
355            message,
356        )
357        .with_recovery(
358            "Inspect the recorded owner, confirm no live daemon remains, repair ownership metadata, then retry startup.",
359        )
360    }
361
362    pub fn daemon_auto_start_failed(message: impl Into<String>) -> Self {
363        Self::new_with_code(
364            AtmErrorCode::DaemonAutoStartFailed,
365            AtmErrorKind::DaemonUnavailable,
366            message,
367        )
368        .with_recovery(
369            "Inspect daemon stderr/logs, fix the startup fault, and retry only after the daemon can reach serving state.",
370        )
371    }
372
373    pub fn remote_delivery_outcome_unknown(message: impl Into<String>) -> Self {
374        Self::new_with_code(
375            AtmErrorCode::RemoteDeliveryOutcomeUnknown,
376            AtmErrorKind::DaemonUnavailable,
377            message,
378        )
379        .with_recovery(
380            "Check the destination daemon or mailbox before retrying. If local durable replay is enabled, let the daemon resume the pending handoff rather than guessing success.",
381        )
382    }
383
384    pub fn help_topic_not_found(message: impl Into<String>) -> Self {
385        Self::new_with_code(
386            AtmErrorCode::HelpTopicNotFound,
387            AtmErrorKind::Validation,
388            message,
389        )
390        .with_recovery("Use `atm help --list` to inspect available help topics and subcommands.")
391    }
392
393    pub fn test_fake_transport_injection_failed(message: impl Into<String>) -> Self {
394        Self::new_with_code(
395            AtmErrorCode::TestFakeTransportInjectionFailed,
396            AtmErrorKind::Validation,
397            message,
398        )
399        .with_recovery(
400            "Fix the test seam configuration so it uses a valid FakeClientTransport or LoopbackClientTransport instance.",
401        )
402    }
403
404    pub fn team_unavailable() -> Self {
405        Self::new_with_code(
406            AtmErrorCode::TeamUnavailable,
407            AtmErrorKind::TeamNotFound,
408            "team is not configured",
409        )
410        .with_recovery("Pass an explicit team in the address or configure a default team.")
411    }
412
413    pub fn team_invalid(message: impl Into<String>) -> Self {
414        Self::new_with_code(
415            AtmErrorCode::TeamInvalid,
416            AtmErrorKind::Validation,
417            format!("caller team is invalid: {}", message.into()),
418        )
419        .with_recovery(
420            "Set ATM_TEAM or provide an explicit --team override using a valid ATM team name.",
421        )
422    }
423
424    pub fn team_not_found(team: &str) -> Self {
425        Self::new(
426            AtmErrorKind::TeamNotFound,
427            format!("team '{team}' was not found"),
428        )
429        .with_recovery("Create the team config or target a different team.")
430    }
431
432    pub fn agent_not_found(agent: &str, team: &str) -> Self {
433        Self::new(
434            AtmErrorKind::AgentNotFound,
435            format!("agent '{agent}' was not found in team '{team}'"),
436        )
437        .with_recovery("Update the team membership or target a different recipient.")
438    }
439
440    pub fn validation(message: impl Into<String>) -> Self {
441        Self::new(AtmErrorKind::Validation, message).with_recovery(
442            "Correct the invalid ATM input or mailbox state, then retry the command with a valid target or argument.",
443        )
444    }
445
446    pub fn self_addressed_send_invalid(message: impl Into<String>) -> Self {
447        Self::new_with_code(
448            AtmErrorCode::SelfAddressedSendInvalid,
449            AtmErrorKind::Validation,
450            message,
451        )
452        .with_recovery(
453            "Target a different recipient or use a non-mutating mailbox inspection command instead of sending a message to yourself.",
454        )
455    }
456
457    pub fn empty_nudge_template_body() -> Self {
458        Self::new_with_code(
459            AtmErrorCode::EmptyNudgeTemplateBody,
460            AtmErrorKind::Validation,
461            "built-in nudge template body must be non-empty",
462        )
463        .with_recovery(
464            "Provide a non-empty template body, or use the explicit disable or clear nudge-template command instead of an empty string.",
465        )
466    }
467
468    pub fn caller_context_request_invalid(message: impl Into<String>) -> Self {
469        Self::new_with_code(
470            AtmErrorCode::CallerContextRequestInvalid,
471            AtmErrorKind::Validation,
472            message,
473        )
474        .with_recovery(
475            "Repair the CLI request-builder path so ATM daemon requests always include validated caller_identity and caller_team fields.",
476        )
477    }
478
479    pub fn missing_document(message: impl Into<String>) -> Self {
480        Self::new(AtmErrorKind::MissingDocument, message).with_recovery(
481            "Restore the missing ATM document or recreate it through the documented team-management workflow before retrying.",
482        )
483    }
484
485    pub fn file_policy(message: impl Into<String>) -> Self {
486        Self::new(AtmErrorKind::FilePolicy, message).with_recovery(
487            "Update the referenced file, path, or policy inputs so they satisfy ATM file-policy rules before retrying the command.",
488        )
489    }
490
491    pub fn mailbox_read(message: impl Into<String>) -> Self {
492        Self::new(AtmErrorKind::MailboxRead, message).with_recovery(
493            "Check ATM_HOME, mailbox file permissions, and mailbox JSON syntax before retrying the ATM command.",
494        )
495    }
496
497    pub fn mailbox_lock(message: impl Into<String>) -> Self {
498        Self::new(AtmErrorKind::MailboxLock, message).with_recovery(
499            "Retry after other ATM mailbox activity completes, or wait for the competing process to release its mailbox lock.",
500        )
501    }
502
503    pub fn mailbox_lock_read_only_filesystem(
504        operation: impl fmt::Display,
505        path: &std::path::Path,
506    ) -> Self {
507        Self::new_with_code(
508            AtmErrorCode::MailboxLockReadOnlyFilesystem,
509            AtmErrorKind::MailboxLock,
510            format!(
511                "mailbox lock {operation} failed for {}: filesystem is read-only",
512                path.display()
513            ),
514        )
515        .with_recovery(
516            "Remount the filesystem read-write or point ATM at a writable home with ATM_HOME or --home, then retry the ATM command.",
517        )
518    }
519
520    pub fn mailbox_lock_timeout(path: &std::path::Path) -> Self {
521        Self::new_with_code(
522            AtmErrorCode::MailboxLockTimeout,
523            AtmErrorKind::MailboxLock,
524            format!("timed out waiting for mailbox lock on {}", path.display()),
525        )
526        .with_recovery(
527            "Retry after the competing ATM process finishes, or investigate whether another process is holding the mailbox lock unexpectedly.",
528        )
529    }
530
531    pub fn mailbox_write(message: impl Into<String>) -> Self {
532        Self::new(AtmErrorKind::MailboxWrite, message).with_recovery(
533            "Check that the mailbox/workflow path is writable, has free space, and was not modified concurrently before retrying the ATM command.",
534        )
535    }
536
537    pub fn observability_emit(message: impl Into<String>) -> Self {
538        Self::new(AtmErrorKind::ObservabilityEmit, message).with_recovery(
539            "Verify the observability sink is writable or temporarily disable retained logging while investigating.",
540        )
541    }
542
543    pub fn observability_bootstrap(message: impl Into<String>) -> Self {
544        Self::new(AtmErrorKind::ObservabilityBootstrap, message).with_recovery(
545            "Check the configured observability backend, log directory permissions, and any local path overrides before retrying ATM commands.",
546        )
547    }
548
549    pub fn observability_query(message: impl Into<String>) -> Self {
550        Self::new(AtmErrorKind::ObservabilityQuery, message).with_recovery(
551            "Confirm retained logs exist and the observability backend supports queries for the selected sink and time range.",
552        )
553    }
554
555    pub fn observability_follow(message: impl Into<String>) -> Self {
556        Self::new(AtmErrorKind::ObservabilityFollow, message).with_recovery(
557            "Check that follow/tail is enabled for the active sink and retry with a narrower query if the stream is unavailable.",
558        )
559    }
560
561    pub fn observability_health(message: impl Into<String>) -> Self {
562        Self::new(AtmErrorKind::ObservabilityHealth, message).with_recovery(
563            "Inspect the observability backend health, file sink path, and query backend status, then rerun `atm doctor`.",
564        )
565    }
566}
567
568impl fmt::Display for AtmError {
569    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
570        write!(f, "{}", self.message)?;
571        for recovery in &self.recovery {
572            write!(f, "\n  Recovery: {recovery}")?;
573        }
574        if let Some(source) = self.source() {
575            write!(f, "\n  Source: {source}")?;
576            let mut current = source.source();
577            while let Some(next) = current {
578                write!(f, "\n  Caused by: {next}")?;
579                current = next.source();
580            }
581        }
582        match self.backtrace() {
583            Some(backtrace) => write!(f, "\n  Backtrace:\n{backtrace}")?,
584            None => write!(f, "\n  Backtrace: {:?}", self.backtrace.status())?,
585        }
586        Ok(())
587    }
588}
589
590impl StdError for AtmError {
591    fn source(&self) -> Option<&(dyn StdError + 'static)> {
592        self.source
593            .as_deref()
594            .map(|source| source as &(dyn StdError + 'static))
595    }
596}
597
598impl From<serde_json::Error> for AtmError {
599    fn from(source: serde_json::Error) -> Self {
600        Self::new(AtmErrorKind::Serialization, format!("json error: {source}"))
601            .with_recovery(
602                "Inspect the JSON payload for structural errors and verify the schema matches the expected format.",
603            )
604            .with_source(source)
605    }
606}
607
608impl From<toml::de::Error> for AtmError {
609    fn from(source: toml::de::Error) -> Self {
610        Self::new(AtmErrorKind::Config, format!("toml error: {source}"))
611            .with_recovery(
612                "Inspect the TOML file for syntax errors and verify all required fields are present.",
613            )
614            .with_source(source)
615    }
616}
617
618impl AtmErrorKind {
619    const fn default_code(self) -> AtmErrorCode {
620        match self {
621            Self::Config => AtmErrorCode::ConfigParseFailed,
622            Self::MissingDocument => AtmErrorCode::ConfigTeamMissing,
623            Self::Address => AtmErrorCode::AddressParseFailed,
624            Self::Identity => AtmErrorCode::IdentityUnavailable,
625            Self::DaemonUnavailable => AtmErrorCode::DaemonUnavailable,
626            Self::TeamNotFound => AtmErrorCode::TeamNotFound,
627            Self::AgentNotFound => AtmErrorCode::AgentNotFound,
628            Self::MailboxLock => AtmErrorCode::MailboxLockFailed,
629            Self::MailboxRead => AtmErrorCode::MailboxReadFailed,
630            Self::MailboxWrite => AtmErrorCode::MailboxWriteFailed,
631            Self::FilePolicy => AtmErrorCode::FilePolicyRejected,
632            Self::Internal => AtmErrorCode::InternalError,
633            Self::Validation => AtmErrorCode::MessageValidationFailed,
634            Self::Serialization => AtmErrorCode::SerializationFailed,
635            Self::Timeout => AtmErrorCode::WaitTimeout,
636            Self::ObservabilityEmit => AtmErrorCode::ObservabilityEmitFailed,
637            Self::ObservabilityBootstrap => AtmErrorCode::ObservabilityBootstrapFailed,
638            Self::ObservabilityQuery => AtmErrorCode::ObservabilityQueryFailed,
639            Self::ObservabilityFollow => AtmErrorCode::ObservabilityFollowFailed,
640            Self::ObservabilityHealth => AtmErrorCode::ObservabilityHealthFailed,
641        }
642    }
643}
644
645#[cfg(test)]
646mod tests {
647    use super::{AtmError, AtmErrorCode, AtmErrorKind};
648    use std::error::Error;
649    use std::fmt;
650
651    #[derive(Debug)]
652    struct LeafError(&'static str);
653
654    impl fmt::Display for LeafError {
655        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
656            f.write_str(self.0)
657        }
658    }
659
660    impl Error for LeafError {}
661
662    #[derive(Debug)]
663    struct ParentError {
664        message: &'static str,
665        source: LeafError,
666    }
667
668    impl fmt::Display for ParentError {
669        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
670            f.write_str(self.message)
671        }
672    }
673
674    impl Error for ParentError {
675        fn source(&self) -> Option<&(dyn Error + 'static)> {
676            Some(&self.source)
677        }
678    }
679
680    #[test]
681    fn display_includes_recovery_source_chain_and_backtrace_label() {
682        let error = AtmError::new(AtmErrorKind::Validation, "storage contract failed")
683            .with_recovery("Repair the contract fixture.")
684            .with_source(ParentError {
685                message: "parent source",
686                source: LeafError("leaf source"),
687            });
688
689        let rendered = error.to_string();
690
691        assert!(rendered.contains("storage contract failed"));
692        assert!(rendered.contains("Recovery: Repair the contract fixture."));
693        assert!(rendered.contains("Source: parent source"));
694        assert!(rendered.contains("Caused by: leaf source"));
695        assert!(rendered.contains("Backtrace:"));
696    }
697
698    #[test]
699    fn member_not_found_uses_agent_not_found_kind() {
700        let error = AtmError::member_not_found("test-agent", "test-team");
701
702        assert_eq!(error.code, AtmErrorCode::MemberNotFound);
703        assert!(error.is_agent_not_found());
704    }
705}