Skip to main content

agent_orchestrator/
error.rs

1use anyhow::Error as AnyError;
2use std::fmt;
3
4/// High-level category assigned to orchestrator failures.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum ErrorCategory {
7    /// The caller supplied invalid or incomplete input.
8    UserInput,
9    /// Configuration content failed validation.
10    ConfigValidation,
11    /// The requested object or resource was not found.
12    NotFound,
13    /// The system state does not permit the requested operation.
14    InvalidState,
15    /// Security policy denied the requested operation.
16    SecurityDenied,
17    /// An external dependency such as I/O, transport, or database failed.
18    ExternalDependency,
19    /// An internal invariant was violated.
20    InternalInvariant,
21}
22
23impl ErrorCategory {
24    /// Returns the stable machine-readable label for the category.
25    pub fn as_str(self) -> &'static str {
26        match self {
27            Self::UserInput => "user_input",
28            Self::ConfigValidation => "config_validation",
29            Self::NotFound => "not_found",
30            Self::InvalidState => "invalid_state",
31            Self::SecurityDenied => "security_denied",
32            Self::ExternalDependency => "external_dependency",
33            Self::InternalInvariant => "internal_invariant",
34        }
35    }
36}
37
38/// Canonical error type returned by public orchestrator APIs.
39#[derive(Debug)]
40pub struct OrchestratorError {
41    category: ErrorCategory,
42    operation: &'static str,
43    subject: Option<String>,
44    source: AnyError,
45}
46
47impl OrchestratorError {
48    /// Builds an error with an explicit category and operation label.
49    pub fn new(
50        category: ErrorCategory,
51        operation: &'static str,
52        source: impl Into<AnyError>,
53    ) -> Self {
54        Self {
55            category,
56            operation,
57            subject: None,
58            source: source.into(),
59        }
60    }
61
62    /// Attaches an optional resource or subject identifier to the error.
63    pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
64        self.subject = Some(subject.into());
65        self
66    }
67
68    /// Returns the assigned error category.
69    pub fn category(&self) -> ErrorCategory {
70        self.category
71    }
72
73    /// Returns the operation label associated with the error.
74    pub fn operation(&self) -> &'static str {
75        self.operation
76    }
77
78    /// Returns the optional subject attached to the error.
79    pub fn subject(&self) -> Option<&str> {
80        self.subject.as_deref()
81    }
82
83    /// Returns the formatted source error message.
84    pub fn message(&self) -> String {
85        self.source.to_string()
86    }
87
88    /// Builds a [`ErrorCategory::UserInput`] error.
89    pub fn user_input(operation: &'static str, source: impl Into<AnyError>) -> Self {
90        Self::new(ErrorCategory::UserInput, operation, source)
91    }
92
93    /// Builds a [`ErrorCategory::ConfigValidation`] error.
94    pub fn config_validation(operation: &'static str, source: impl Into<AnyError>) -> Self {
95        Self::new(ErrorCategory::ConfigValidation, operation, source)
96    }
97
98    /// Builds a [`ErrorCategory::NotFound`] error.
99    pub fn not_found(operation: &'static str, source: impl Into<AnyError>) -> Self {
100        Self::new(ErrorCategory::NotFound, operation, source)
101    }
102
103    /// Builds a [`ErrorCategory::InvalidState`] error.
104    pub fn invalid_state(operation: &'static str, source: impl Into<AnyError>) -> Self {
105        Self::new(ErrorCategory::InvalidState, operation, source)
106    }
107
108    /// Builds a [`ErrorCategory::SecurityDenied`] error.
109    pub fn security_denied(operation: &'static str, source: impl Into<AnyError>) -> Self {
110        Self::new(ErrorCategory::SecurityDenied, operation, source)
111    }
112
113    /// Builds a [`ErrorCategory::ExternalDependency`] error.
114    pub fn external_dependency(operation: &'static str, source: impl Into<AnyError>) -> Self {
115        Self::new(ErrorCategory::ExternalDependency, operation, source)
116    }
117
118    /// Builds a [`ErrorCategory::InternalInvariant`] error.
119    pub fn internal_invariant(operation: &'static str, source: impl Into<AnyError>) -> Self {
120        Self::new(ErrorCategory::InternalInvariant, operation, source)
121    }
122}
123
124impl fmt::Display for OrchestratorError {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        match &self.subject {
127            Some(subject) => write!(f, "{} [{}]: {}", self.operation, subject, self.source),
128            None => write!(f, "{}: {}", self.operation, self.source),
129        }
130    }
131}
132
133impl std::error::Error for OrchestratorError {
134    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
135        Some(self.source.root_cause())
136    }
137}
138
139/// Standard result type used by the public orchestrator API.
140pub type Result<T> = std::result::Result<T, OrchestratorError>;
141
142impl From<anyhow::Error> for OrchestratorError {
143    fn from(value: anyhow::Error) -> Self {
144        OrchestratorError::internal_invariant("internal", value)
145    }
146}
147
148impl From<std::io::Error> for OrchestratorError {
149    fn from(value: std::io::Error) -> Self {
150        OrchestratorError::external_dependency("internal", value)
151    }
152}
153
154impl From<rusqlite::Error> for OrchestratorError {
155    fn from(value: rusqlite::Error) -> Self {
156        OrchestratorError::external_dependency("internal", value)
157    }
158}
159
160impl From<serde_json::Error> for OrchestratorError {
161    fn from(value: serde_json::Error) -> Self {
162        OrchestratorError::internal_invariant("internal", value)
163    }
164}
165
166impl From<serde_yaml::Error> for OrchestratorError {
167    fn from(value: serde_yaml::Error) -> Self {
168        OrchestratorError::internal_invariant("internal", value)
169    }
170}
171
172fn classify_by_message(operation: &'static str, error: AnyError) -> OrchestratorError {
173    let message = error.to_string();
174    let lower = message.to_ascii_lowercase();
175
176    if lower.starts_with("[invalid_")
177        || lower.contains(" category: validation")
178        || lower.contains(" validation ")
179        || lower.contains("must define at least one")
180        || lower.contains("metadata.project=")
181    {
182        return OrchestratorError::config_validation(operation, error);
183    }
184
185    if lower.contains("not found")
186        || lower.contains("does not exist")
187        || lower.contains("no such")
188        || lower.contains("unknown resource type")
189        || lower.contains("unknown list resource type")
190        || lower.contains("unknown builtin provider")
191        || lower.contains("provider '") && lower.contains("not found")
192    {
193        return OrchestratorError::not_found(operation, error);
194    }
195
196    if lower.starts_with("use --force")
197        || lower.contains("no resumable task found")
198        || lower.contains("daemon is ")
199        || lower.contains("no active encryption key")
200        || lower.contains("no incomplete rotation found")
201        || lower.contains("cannot begin rotation")
202        || lower.contains("task-scoped workflow accepts at most one")
203        || lower.contains("no qa/security markdown files found")
204    {
205        return OrchestratorError::invalid_state(operation, error);
206    }
207
208    if lower.contains("client certificate")
209        || lower.contains("permission denied")
210        || lower.contains("unauthenticated")
211        || lower.contains("access denied")
212    {
213        return OrchestratorError::security_denied(operation, error);
214    }
215
216    if lower.contains("task_id or --latest required")
217        || lower.contains("invalid ")
218        || lower.contains("cannot be empty")
219        || lower.contains("cannot include '..'")
220        || lower.contains("must be a relative path")
221        || lower.contains("label selector")
222        || lower.contains("no valid --target-file entries found")
223    {
224        return OrchestratorError::user_input(operation, error);
225    }
226
227    if lower.contains("failed to")
228        || lower.contains("sqlite")
229        || lower.contains("database")
230        || lower.contains("i/o")
231        || lower.contains("io error")
232        || lower.contains("connection")
233        || lower.contains("timeout")
234        || lower.contains("transport")
235        || lower.contains("git ")
236    {
237        return OrchestratorError::external_dependency(operation, error);
238    }
239
240    OrchestratorError::internal_invariant(operation, error)
241}
242
243/// Classifies a task-related error into an [`OrchestratorError`].
244pub fn classify_task_error(
245    operation: &'static str,
246    error: impl Into<AnyError>,
247) -> OrchestratorError {
248    classify_by_message(operation, error.into())
249}
250
251/// Classifies a resource-management error into an [`OrchestratorError`].
252pub fn classify_resource_error(
253    operation: &'static str,
254    error: impl Into<AnyError>,
255) -> OrchestratorError {
256    classify_by_message(operation, error.into())
257}
258
259/// Classifies a store-backend error into an [`OrchestratorError`].
260pub fn classify_store_error(
261    operation: &'static str,
262    error: impl Into<AnyError>,
263) -> OrchestratorError {
264    classify_by_message(operation, error.into())
265}
266
267/// Classifies a system-level error into an [`OrchestratorError`].
268pub fn classify_system_error(
269    operation: &'static str,
270    error: impl Into<AnyError>,
271) -> OrchestratorError {
272    classify_by_message(operation, error.into())
273}
274
275/// Classifies a secret-management error into an [`OrchestratorError`].
276pub fn classify_secret_error(
277    operation: &'static str,
278    error: impl Into<AnyError>,
279) -> OrchestratorError {
280    classify_by_message(operation, error.into())
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn classify_task_latest_missing_as_invalid_state() {
289        let err = classify_task_error("task.start", anyhow::anyhow!("no resumable task found"));
290        assert_eq!(err.category(), ErrorCategory::InvalidState);
291    }
292
293    #[test]
294    fn classify_manifest_policy_error_as_config_validation() {
295        let err = classify_system_error(
296            "system.manifest_validate",
297            anyhow::anyhow!(
298                "[INVALID_WORKSPACE] workspace 'default' qa_targets cannot be empty\n  category: validation"
299            ),
300        );
301        assert_eq!(err.category(), ErrorCategory::ConfigValidation);
302    }
303
304    #[test]
305    fn classify_missing_project_as_not_found() {
306        let err = classify_resource_error(
307            "resource.get",
308            anyhow::anyhow!("project not found: missing-project"),
309        );
310        assert_eq!(err.category(), ErrorCategory::NotFound);
311    }
312
313    #[test]
314    fn classify_invalid_target_file_as_user_input() {
315        let err = classify_task_error(
316            "task.create",
317            anyhow::anyhow!("no valid --target-file entries found"),
318        );
319        assert_eq!(err.category(), ErrorCategory::UserInput);
320    }
321
322    #[test]
323    fn classify_secret_rotation_without_key_as_invalid_state() {
324        let err = classify_secret_error(
325            "secret.rotate",
326            anyhow::anyhow!(
327                "SecretStore write blocked: no active encryption key (all keys revoked or retired)"
328            ),
329        );
330        assert_eq!(err.category(), ErrorCategory::InvalidState);
331    }
332
333    // ---- ErrorCategory::as_str() for all variants ----
334
335    #[test]
336    fn error_category_as_str_all_variants() {
337        assert_eq!(ErrorCategory::UserInput.as_str(), "user_input");
338        assert_eq!(
339            ErrorCategory::ConfigValidation.as_str(),
340            "config_validation"
341        );
342        assert_eq!(ErrorCategory::NotFound.as_str(), "not_found");
343        assert_eq!(ErrorCategory::InvalidState.as_str(), "invalid_state");
344        assert_eq!(ErrorCategory::SecurityDenied.as_str(), "security_denied");
345        assert_eq!(
346            ErrorCategory::ExternalDependency.as_str(),
347            "external_dependency"
348        );
349        assert_eq!(
350            ErrorCategory::InternalInvariant.as_str(),
351            "internal_invariant"
352        );
353    }
354
355    // ---- Builder methods and accessors ----
356
357    #[test]
358    fn orchestrator_error_new_and_accessors() {
359        let err = OrchestratorError::new(
360            ErrorCategory::NotFound,
361            "resource.get",
362            anyhow::anyhow!("widget not found"),
363        );
364        assert_eq!(err.category(), ErrorCategory::NotFound);
365        assert_eq!(err.operation(), "resource.get");
366        assert_eq!(err.subject(), None);
367        assert_eq!(err.message(), "widget not found");
368    }
369
370    #[test]
371    fn orchestrator_error_with_subject() {
372        let err = OrchestratorError::new(
373            ErrorCategory::UserInput,
374            "task.create",
375            anyhow::anyhow!("bad input"),
376        )
377        .with_subject("my-task");
378        assert_eq!(err.subject(), Some("my-task"));
379        assert_eq!(err.category(), ErrorCategory::UserInput);
380        assert_eq!(err.operation(), "task.create");
381        assert_eq!(err.message(), "bad input");
382    }
383
384    // ---- Display impl ----
385
386    #[test]
387    fn display_without_subject() {
388        let err = OrchestratorError::new(
389            ErrorCategory::InternalInvariant,
390            "op",
391            anyhow::anyhow!("boom"),
392        );
393        let display = format!("{}", err);
394        assert_eq!(display, "op: boom");
395    }
396
397    #[test]
398    fn display_with_subject() {
399        let err = OrchestratorError::new(
400            ErrorCategory::NotFound,
401            "resource.get",
402            anyhow::anyhow!("missing"),
403        )
404        .with_subject("proj-1");
405        let display = format!("{}", err);
406        assert_eq!(display, "resource.get [proj-1]: missing");
407    }
408
409    // ---- From conversions ----
410
411    #[test]
412    fn from_anyhow_error() {
413        let anyhow_err = anyhow::anyhow!("anyhow failure");
414        let err: OrchestratorError = anyhow_err.into();
415        assert_eq!(err.category(), ErrorCategory::InternalInvariant);
416        assert_eq!(err.operation(), "internal");
417    }
418
419    #[test]
420    fn from_io_error() {
421        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file gone");
422        let err: OrchestratorError = io_err.into();
423        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
424        assert_eq!(err.operation(), "internal");
425    }
426
427    #[test]
428    fn from_rusqlite_error() {
429        let sqlite_err = rusqlite::Error::SqliteFailure(
430            rusqlite::ffi::Error::new(1),
431            Some("db locked".to_string()),
432        );
433        let err: OrchestratorError = sqlite_err.into();
434        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
435        assert_eq!(err.operation(), "internal");
436    }
437
438    #[test]
439    fn from_serde_json_error() {
440        let json_err = serde_json::from_str::<serde_json::Value>("{{bad").unwrap_err();
441        let err: OrchestratorError = json_err.into();
442        assert_eq!(err.category(), ErrorCategory::InternalInvariant);
443        assert_eq!(err.operation(), "internal");
444    }
445
446    #[test]
447    fn from_serde_yaml_error() {
448        let yaml_err = serde_yaml::from_str::<serde_yaml::Value>(":\n  :\n- -").unwrap_err();
449        let err: OrchestratorError = yaml_err.into();
450        assert_eq!(err.category(), ErrorCategory::InternalInvariant);
451        assert_eq!(err.operation(), "internal");
452    }
453
454    // ---- classify_by_message branches ----
455
456    #[test]
457    fn classify_permission_denied_as_security() {
458        let err = classify_task_error("op", anyhow::anyhow!("Permission denied for resource"));
459        assert_eq!(err.category(), ErrorCategory::SecurityDenied);
460    }
461
462    #[test]
463    fn classify_access_denied_as_security() {
464        let err = classify_task_error("op", anyhow::anyhow!("Access denied by policy"));
465        assert_eq!(err.category(), ErrorCategory::SecurityDenied);
466    }
467
468    #[test]
469    fn classify_client_certificate_as_security() {
470        let err = classify_task_error("op", anyhow::anyhow!("client certificate expired"));
471        assert_eq!(err.category(), ErrorCategory::SecurityDenied);
472    }
473
474    #[test]
475    fn classify_unauthenticated_as_security() {
476        let err = classify_task_error("op", anyhow::anyhow!("unauthenticated request"));
477        assert_eq!(err.category(), ErrorCategory::SecurityDenied);
478    }
479
480    #[test]
481    fn classify_failed_to_as_external_dependency() {
482        let err = classify_task_error("op", anyhow::anyhow!("failed to connect to server"));
483        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
484    }
485
486    #[test]
487    fn classify_sqlite_as_external_dependency() {
488        let err = classify_task_error("op", anyhow::anyhow!("sqlite error: disk full"));
489        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
490    }
491
492    #[test]
493    fn classify_timeout_as_external_dependency() {
494        let err = classify_task_error("op", anyhow::anyhow!("request timeout after 30s"));
495        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
496    }
497
498    #[test]
499    fn classify_database_as_external_dependency() {
500        let err = classify_task_error("op", anyhow::anyhow!("database connection lost"));
501        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
502    }
503
504    #[test]
505    fn classify_io_error_message_as_external_dependency() {
506        let err = classify_task_error("op", anyhow::anyhow!("i/o error reading file"));
507        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
508    }
509
510    #[test]
511    fn classify_connection_as_external_dependency() {
512        let err = classify_task_error("op", anyhow::anyhow!("connection refused"));
513        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
514    }
515
516    #[test]
517    fn classify_transport_as_external_dependency() {
518        let err = classify_task_error("op", anyhow::anyhow!("transport layer error"));
519        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
520    }
521
522    #[test]
523    fn classify_git_as_external_dependency() {
524        let err = classify_task_error("op", anyhow::anyhow!("git push failed"));
525        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
526    }
527
528    #[test]
529    fn classify_invalid_prefix_as_user_input() {
530        let err = classify_task_error("op", anyhow::anyhow!("invalid port number"));
531        assert_eq!(err.category(), ErrorCategory::UserInput);
532    }
533
534    #[test]
535    fn classify_cannot_be_empty_as_user_input() {
536        let err = classify_task_error("op", anyhow::anyhow!("name cannot be empty"));
537        assert_eq!(err.category(), ErrorCategory::UserInput);
538    }
539
540    #[test]
541    fn classify_task_id_required_as_user_input() {
542        let err = classify_task_error("op", anyhow::anyhow!("task_id or --latest required"));
543        assert_eq!(err.category(), ErrorCategory::UserInput);
544    }
545
546    #[test]
547    fn classify_cannot_include_dotdot_as_user_input() {
548        let err = classify_task_error("op", anyhow::anyhow!("path cannot include '..'"));
549        assert_eq!(err.category(), ErrorCategory::UserInput);
550    }
551
552    #[test]
553    fn classify_must_be_relative_path_as_user_input() {
554        let err = classify_task_error("op", anyhow::anyhow!("must be a relative path"));
555        assert_eq!(err.category(), ErrorCategory::UserInput);
556    }
557
558    #[test]
559    fn classify_label_selector_as_user_input() {
560        let err = classify_task_error("op", anyhow::anyhow!("bad label selector syntax"));
561        assert_eq!(err.category(), ErrorCategory::UserInput);
562    }
563
564    #[test]
565    fn classify_fallback_as_internal_invariant() {
566        let err = classify_task_error("op", anyhow::anyhow!("something completely unexpected"));
567        assert_eq!(err.category(), ErrorCategory::InternalInvariant);
568    }
569
570    #[test]
571    fn classify_validation_keyword_as_config_validation() {
572        let err = classify_task_error(
573            "op",
574            anyhow::anyhow!("field has category: validation error"),
575        );
576        assert_eq!(err.category(), ErrorCategory::ConfigValidation);
577    }
578
579    #[test]
580    fn classify_must_define_at_least_one_as_config_validation() {
581        let err = classify_task_error("op", anyhow::anyhow!("must define at least one target"));
582        assert_eq!(err.category(), ErrorCategory::ConfigValidation);
583    }
584
585    #[test]
586    fn classify_metadata_project_as_config_validation() {
587        let err = classify_task_error("op", anyhow::anyhow!("metadata.project=foo is invalid"));
588        assert_eq!(err.category(), ErrorCategory::ConfigValidation);
589    }
590
591    #[test]
592    fn classify_validation_word_as_config_validation() {
593        let err = classify_task_error("op", anyhow::anyhow!("schema validation failed"));
594        assert_eq!(err.category(), ErrorCategory::ConfigValidation);
595    }
596
597    #[test]
598    fn classify_does_not_exist_as_not_found() {
599        let err = classify_resource_error("op", anyhow::anyhow!("resource does not exist"));
600        assert_eq!(err.category(), ErrorCategory::NotFound);
601    }
602
603    #[test]
604    fn classify_no_such_as_not_found() {
605        let err = classify_resource_error("op", anyhow::anyhow!("no such file or directory"));
606        assert_eq!(err.category(), ErrorCategory::NotFound);
607    }
608
609    #[test]
610    fn classify_unknown_resource_type_as_not_found() {
611        let err = classify_resource_error("op", anyhow::anyhow!("unknown resource type 'Foo'"));
612        assert_eq!(err.category(), ErrorCategory::NotFound);
613    }
614
615    #[test]
616    fn classify_unknown_list_resource_type_as_not_found() {
617        let err = classify_resource_error("op", anyhow::anyhow!("unknown list resource type 'X'"));
618        assert_eq!(err.category(), ErrorCategory::NotFound);
619    }
620
621    #[test]
622    fn classify_unknown_builtin_provider_as_not_found() {
623        let err = classify_resource_error("op", anyhow::anyhow!("unknown builtin provider 'Y'"));
624        assert_eq!(err.category(), ErrorCategory::NotFound);
625    }
626
627    #[test]
628    fn classify_use_force_as_invalid_state() {
629        let err = classify_task_error("op", anyhow::anyhow!("use --force to override"));
630        assert_eq!(err.category(), ErrorCategory::InvalidState);
631    }
632
633    #[test]
634    fn classify_daemon_is_as_invalid_state() {
635        let err = classify_system_error("op", anyhow::anyhow!("daemon is not running"));
636        assert_eq!(err.category(), ErrorCategory::InvalidState);
637    }
638
639    #[test]
640    fn classify_no_incomplete_rotation_as_invalid_state() {
641        let err = classify_secret_error("op", anyhow::anyhow!("no incomplete rotation found"));
642        assert_eq!(err.category(), ErrorCategory::InvalidState);
643    }
644
645    #[test]
646    fn classify_cannot_begin_rotation_as_invalid_state() {
647        let err = classify_secret_error("op", anyhow::anyhow!("cannot begin rotation now"));
648        assert_eq!(err.category(), ErrorCategory::InvalidState);
649    }
650
651    #[test]
652    fn classify_task_scoped_workflow_limit_as_invalid_state() {
653        let err = classify_task_error(
654            "op",
655            anyhow::anyhow!("task-scoped workflow accepts at most one step"),
656        );
657        assert_eq!(err.category(), ErrorCategory::InvalidState);
658    }
659
660    #[test]
661    fn classify_no_qa_markdown_as_invalid_state() {
662        let err = classify_system_error(
663            "op",
664            anyhow::anyhow!("no qa/security markdown files found in directory"),
665        );
666        assert_eq!(err.category(), ErrorCategory::InvalidState);
667    }
668
669    #[test]
670    fn classify_io_error_keyword_as_external_dependency() {
671        let err = classify_task_error("op", anyhow::anyhow!("io error on socket"));
672        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
673    }
674
675    // ---- Convenience builder helpers ----
676
677    #[test]
678    fn convenience_builders_set_correct_category() {
679        let cases: Vec<(OrchestratorError, ErrorCategory)> = vec![
680            (
681                OrchestratorError::user_input("op", anyhow::anyhow!("e")),
682                ErrorCategory::UserInput,
683            ),
684            (
685                OrchestratorError::config_validation("op", anyhow::anyhow!("e")),
686                ErrorCategory::ConfigValidation,
687            ),
688            (
689                OrchestratorError::not_found("op", anyhow::anyhow!("e")),
690                ErrorCategory::NotFound,
691            ),
692            (
693                OrchestratorError::invalid_state("op", anyhow::anyhow!("e")),
694                ErrorCategory::InvalidState,
695            ),
696            (
697                OrchestratorError::security_denied("op", anyhow::anyhow!("e")),
698                ErrorCategory::SecurityDenied,
699            ),
700            (
701                OrchestratorError::external_dependency("op", anyhow::anyhow!("e")),
702                ErrorCategory::ExternalDependency,
703            ),
704            (
705                OrchestratorError::internal_invariant("op", anyhow::anyhow!("e")),
706                ErrorCategory::InternalInvariant,
707            ),
708        ];
709        for (err, expected) in cases {
710            assert_eq!(err.category(), expected);
711        }
712    }
713
714    // ---- std::error::Error impl ----
715
716    #[test]
717    fn std_error_source_returns_root_cause() {
718        let err = OrchestratorError::new(
719            ErrorCategory::InternalInvariant,
720            "op",
721            anyhow::anyhow!("root cause"),
722        );
723        let std_err: &dyn std::error::Error = &err;
724        assert!(std_err.source().is_some());
725    }
726
727    // ---- classify wrappers delegate correctly ----
728
729    #[test]
730    fn classify_store_error_delegates() {
731        let err = classify_store_error("store.put", anyhow::anyhow!("database locked"));
732        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
733        assert_eq!(err.operation(), "store.put");
734    }
735
736    #[test]
737    fn classify_system_error_delegates() {
738        let err = classify_system_error("sys.check", anyhow::anyhow!("timeout waiting"));
739        assert_eq!(err.category(), ErrorCategory::ExternalDependency);
740        assert_eq!(err.operation(), "sys.check");
741    }
742
743    #[test]
744    fn classify_resource_error_delegates() {
745        let err =
746            classify_resource_error("res.list", anyhow::anyhow!("unknown list resource type"));
747        assert_eq!(err.category(), ErrorCategory::NotFound);
748        assert_eq!(err.operation(), "res.list");
749    }
750}