Skip to main content

rch_common/errors/
catalog.rs

1//! Error Catalog for Remote Compilation Helper
2//!
3//! This module defines a comprehensive error catalog with unique error codes,
4//! categorized by subsystem. Each error includes:
5//! - A unique code (RCH-E001 through RCH-E999)
6//! - A human-readable message template
7//! - Remediation steps
8//! - Documentation links where applicable
9//!
10//! # Error Code Ranges
11//!
12//! | Range      | Category    | Description                          |
13//! |------------|-------------|--------------------------------------|
14//! | E001-E099  | Config      | Configuration and setup errors       |
15//! | E100-E199  | Network     | Network and SSH connectivity         |
16//! | E200-E299  | Worker      | Worker selection and management      |
17//! | E300-E399  | Build       | Compilation and build errors         |
18//! | E400-E499  | Transfer    | File transfer and sync errors        |
19//! | E500-E599  | Internal    | Internal/unexpected errors           |
20//!
21//! ## Extended Sub-Ranges (within existing categories)
22//!
23//! | Range      | Subcategory        | Description                           |
24//! |------------|--------------------|---------------------------------------|
25//! | E013-E018  | Config/PathDeps    | Path-dependency resolution errors     |
26//! | E019-E024  | Config/Closure     | Dependency-closure planner errors     |
27//! | E210-E219  | Worker/Storage     | Disk pressure and storage errors      |
28//! | E310-E319  | Build/Triage       | Process triage integration errors     |
29//! | E320-E325  | Build/Cancellation | Build cancellation lifecycle errors   |
30//! | E410-E414  | Transfer/Preflight | Remote dependency preflight errors    |
31//!
32//! # Example
33//!
34//! ```rust
35//! use rch_common::errors::catalog::{ErrorCode, ErrorEntry};
36//!
37//! let error = ErrorCode::ConfigNotFound;
38//! let entry = error.entry();
39//!
40//! println!("Error {}: {}", entry.code, entry.message);
41//! for step in entry.remediation {
42//!     println!("  - {}", step);
43//! }
44//! ```
45
46use serde::{Deserialize, Serialize};
47use std::fmt;
48
49/// Error code enumeration covering all RCH error scenarios.
50///
51/// Each variant maps to a unique error code in the RCH-Exxx format.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
54#[non_exhaustive]
55pub enum ErrorCode {
56    // =========================================================================
57    // Config Errors (E001-E099)
58    // =========================================================================
59    /// Configuration file not found
60    ConfigNotFound,
61    /// Configuration file could not be read
62    ConfigReadError,
63    /// Configuration file contains invalid TOML syntax
64    ConfigParseError,
65    /// Configuration contains invalid values
66    ConfigValidationError,
67    /// Environment variable has invalid value
68    ConfigEnvError,
69    /// Profile not found in configuration
70    ConfigProfileNotFound,
71    /// No workers configured
72    ConfigNoWorkers,
73    /// Worker configuration is invalid
74    ConfigInvalidWorker,
75    /// SSH key path is invalid or inaccessible
76    ConfigSshKeyError,
77    /// Socket path is invalid
78    ConfigSocketPathError,
79
80    // -- Path-Dependency Resolution (E013-E018) --
81    /// Cargo manifest parse failure during path-dependency resolution
82    PathDepManifestParseFailed,
83    /// Path dependency declared but target directory not found
84    PathDepMissing,
85    /// Cyclic path dependency detected
86    PathDepCyclic,
87    /// Path dependency violates canonical-root policy
88    PathDepPolicyViolation,
89    /// cargo metadata invocation failed
90    PathDepMetadataFailed,
91    /// cargo metadata output could not be parsed
92    PathDepMetadataParseFailed,
93
94    // -- Dependency-Closure Planner (E019-E024) --
95    /// Dependency closure plan computation failed
96    ClosurePlanFailed,
97    /// Closure entered fail-open state (unverifiable dependency data)
98    ClosureFailOpen,
99    /// High-risk path dependencies in closure
100    ClosureHighRisk,
101    /// Required closure data is missing or incomplete
102    ClosureMissingData,
103    /// Closure sync action ordering is non-deterministic
104    ClosureNonDeterministic,
105    /// Closure manifest fingerprint mismatch
106    ClosureFingerprintMismatch,
107
108    // =========================================================================
109    // Network Errors (E100-E199)
110    // =========================================================================
111    /// SSH connection failed
112    SshConnectionFailed,
113    /// SSH authentication failed
114    SshAuthFailed,
115    /// SSH key not found or invalid format
116    SshKeyError,
117    /// SSH known hosts verification failed
118    SshHostKeyError,
119    /// SSH command execution timed out
120    SshTimeout,
121    /// SSH session terminated unexpectedly
122    SshSessionDropped,
123    /// DNS resolution failed for worker host
124    NetworkDnsError,
125    /// Network unreachable
126    NetworkUnreachable,
127    /// Connection refused by remote host
128    NetworkConnectionRefused,
129    /// TCP connection timed out
130    NetworkTimeout,
131
132    // =========================================================================
133    // Worker Errors (E200-E299)
134    // =========================================================================
135    /// No workers available for selection
136    WorkerNoneAvailable,
137    /// All workers are unhealthy
138    WorkerAllUnhealthy,
139    /// Worker failed health check
140    WorkerHealthCheckFailed,
141    /// Worker self-test failed
142    WorkerSelfTestFailed,
143    /// Worker is at capacity
144    WorkerAtCapacity,
145    /// Worker missing required toolchain
146    WorkerMissingToolchain,
147    /// Worker state is inconsistent
148    WorkerStateError,
149    /// Worker circuit breaker is open
150    WorkerCircuitOpen,
151    /// Worker selection strategy failed
152    WorkerSelectionFailed,
153    /// Worker load query failed
154    WorkerLoadQueryFailed,
155
156    // -- Disk Pressure / Storage (E210-E219) --
157    /// Worker disk usage is critically high
158    WorkerDiskPressureCritical,
159    /// Worker disk usage is elevated (warning threshold)
160    WorkerDiskPressureWarning,
161    /// Worker disk pressure telemetry is stale or missing
162    WorkerTelemetryGap,
163    /// Worker disk I/O utilization is too high for scheduling
164    WorkerDiskIoHigh,
165    /// Worker memory pressure exceeds scheduling threshold
166    WorkerMemoryPressureHigh,
167    /// Disk reclaim/ballast eviction failed on worker
168    WorkerReclaimFailed,
169    /// Disk headroom estimation too low for build reservation
170    WorkerDiskHeadroomInsufficient,
171    /// Active build protection prevented reclaim operation
172    WorkerReclaimProtected,
173
174    // =========================================================================
175    // Build Errors (E300-E399)
176    // =========================================================================
177    /// Remote compilation failed
178    BuildCompilationFailed,
179    /// Build command not recognized
180    BuildUnknownCommand,
181    /// Build process was killed by signal
182    BuildKilledBySignal,
183    /// Build timed out
184    BuildTimeout,
185    /// Build output capture failed
186    BuildOutputError,
187    /// Remote working directory error
188    BuildWorkdirError,
189    /// Toolchain wrapper failed
190    BuildToolchainError,
191    /// Build environment setup failed
192    BuildEnvError,
193    /// Incremental build state corrupted
194    BuildIncrementalError,
195    /// Build artifact not found
196    BuildArtifactMissing,
197    /// Remote build succeeded but the artifact sync-back matched zero build
198    /// outputs — local artifacts may be STALE (bd-mpbav)
199    BuildArtifactSyncEmpty,
200    /// Remote build succeeded but returned executables for a different target
201    /// triple than the requesting host expects (GitHub #65)
202    BuildArtifactForeignTarget,
203    /// Cargo workspace inheritance failed under remote topology
204    BuildCargoWorkspaceInheritance,
205
206    // -- Process Triage (E310-E319) --
207    /// Process triage adapter binary unavailable or not installed
208    ProcessTriageAdapterUnavailable,
209    /// Process detector could not classify process with sufficient confidence
210    ProcessTriageDetectorUncertain,
211    /// Process triage action violates safe-action policy
212    ProcessTriagePolicyViolation,
213    /// Transport error communicating with process triage adapter
214    ProcessTriageTransportError,
215    /// Process triage executor encountered a runtime error
216    ProcessTriageExecutorError,
217    /// Process triage operation timed out
218    ProcessTriageTimeout,
219    /// Process triage returned partial or incomplete results
220    ProcessTriagePartialResult,
221    /// Invalid process triage request (malformed input)
222    ProcessTriageInvalidRequest,
223
224    // -- Cancellation (E320-E325) --
225    /// Graceful cancel signal dispatched
226    CancelGracefulSent,
227    /// Escalated to forced kill after timeout
228    CancelEscalatedKill,
229    /// Failed to kill remote process via SSH
230    CancelRemoteKillFailed,
231    /// Post-cancel cleanup encountered errors
232    CancelCleanupFailed,
233    /// Slots not properly released after cancel
234    CancelSlotLeak,
235    /// Cancellation exceeded policy time budget
236    CancelTimeoutExceeded,
237
238    // =========================================================================
239    // Transfer Errors (E400-E499)
240    // =========================================================================
241    /// Rsync transfer failed
242    TransferRsyncFailed,
243    /// File sync timed out
244    TransferTimeout,
245    /// Source files not found
246    TransferSourceMissing,
247    /// Destination path error
248    TransferDestError,
249    /// Insufficient disk space on worker
250    TransferDiskFull,
251    /// Permission denied during transfer
252    TransferPermissionDenied,
253    /// Transfer checksum mismatch
254    TransferChecksumError,
255    /// Binary download failed
256    TransferBinaryFailed,
257    /// Partial transfer detected
258    TransferIncomplete,
259    /// Transfer protocol error
260    TransferProtocolError,
261    /// Required dependency path missing on remote worker after sync
262    DependencyPreflightMissing,
263    /// Dependency root was not refreshed before preflight
264    DependencyPreflightStale,
265    /// Dependency preflight could not determine remote state
266    DependencyPreflightUnknown,
267    /// Dependency topology policy blocked remote execution
268    DependencyPreflightPolicyViolation,
269    /// Dependency preflight planning timed out
270    DependencyPreflightTimeout,
271
272    // =========================================================================
273    // Internal Errors (E500-E599)
274    // =========================================================================
275    /// Daemon socket connection failed
276    InternalDaemonSocket,
277    /// Daemon protocol error
278    InternalDaemonProtocol,
279    /// Daemon not running
280    InternalDaemonNotRunning,
281    /// Inter-process communication error
282    InternalIpcError,
283    /// Unexpected internal state
284    InternalStateError,
285    /// Serialization/deserialization error
286    InternalSerdeError,
287    /// Hook execution failed
288    InternalHookError,
289    /// Metrics collection error
290    InternalMetricsError,
291    /// Logging system error
292    InternalLoggingError,
293    /// Update check failed
294    InternalUpdateError,
295}
296
297impl ErrorCode {
298    /// Returns the numeric error code (without prefix).
299    #[must_use]
300    pub const fn code_number(&self) -> u16 {
301        match self {
302            // Config (001-099)
303            Self::ConfigNotFound => 1,
304            Self::ConfigReadError => 2,
305            Self::ConfigParseError => 3,
306            Self::ConfigValidationError => 4,
307            Self::ConfigEnvError => 5,
308            Self::ConfigProfileNotFound => 6,
309            Self::ConfigNoWorkers => 7,
310            Self::ConfigInvalidWorker => 8,
311            Self::ConfigSshKeyError => 9,
312            Self::ConfigSocketPathError => 10,
313
314            // Path-Dependency (013-018)
315            Self::PathDepManifestParseFailed => 13,
316            Self::PathDepMissing => 14,
317            Self::PathDepCyclic => 15,
318            Self::PathDepPolicyViolation => 16,
319            Self::PathDepMetadataFailed => 17,
320            Self::PathDepMetadataParseFailed => 18,
321
322            // Dependency-Closure (019-024)
323            Self::ClosurePlanFailed => 19,
324            Self::ClosureFailOpen => 20,
325            Self::ClosureHighRisk => 21,
326            Self::ClosureMissingData => 22,
327            Self::ClosureNonDeterministic => 23,
328            Self::ClosureFingerprintMismatch => 24,
329
330            // Network (100-199)
331            Self::SshConnectionFailed => 100,
332            Self::SshAuthFailed => 101,
333            Self::SshKeyError => 102,
334            Self::SshHostKeyError => 103,
335            Self::SshTimeout => 104,
336            Self::SshSessionDropped => 105,
337            Self::NetworkDnsError => 106,
338            Self::NetworkUnreachable => 107,
339            Self::NetworkConnectionRefused => 108,
340            Self::NetworkTimeout => 109,
341
342            // Worker (200-299)
343            Self::WorkerNoneAvailable => 200,
344            Self::WorkerAllUnhealthy => 201,
345            Self::WorkerHealthCheckFailed => 202,
346            Self::WorkerSelfTestFailed => 203,
347            Self::WorkerAtCapacity => 204,
348            Self::WorkerMissingToolchain => 205,
349            Self::WorkerStateError => 206,
350            Self::WorkerCircuitOpen => 207,
351            Self::WorkerSelectionFailed => 208,
352            Self::WorkerLoadQueryFailed => 209,
353
354            // Disk Pressure / Storage (210-219)
355            Self::WorkerDiskPressureCritical => 210,
356            Self::WorkerDiskPressureWarning => 211,
357            Self::WorkerTelemetryGap => 212,
358            Self::WorkerDiskIoHigh => 213,
359            Self::WorkerMemoryPressureHigh => 214,
360            Self::WorkerReclaimFailed => 215,
361            Self::WorkerDiskHeadroomInsufficient => 216,
362            Self::WorkerReclaimProtected => 217,
363
364            // Build (300-399)
365            Self::BuildCompilationFailed => 300,
366            Self::BuildUnknownCommand => 301,
367            Self::BuildKilledBySignal => 302,
368            Self::BuildTimeout => 303,
369            Self::BuildOutputError => 304,
370            Self::BuildWorkdirError => 305,
371            Self::BuildToolchainError => 306,
372            Self::BuildEnvError => 307,
373            Self::BuildIncrementalError => 308,
374            Self::BuildArtifactMissing => 309,
375            // 326 (past the cancellation block E320-E325): codes occupy free
376            // slots of the E300-E399 range, not contiguous positions.
377            Self::BuildArtifactSyncEmpty => 326,
378            Self::BuildArtifactForeignTarget => 327,
379            Self::BuildCargoWorkspaceInheritance => 329,
380
381            // Process Triage (310-319)
382            Self::ProcessTriageAdapterUnavailable => 310,
383            Self::ProcessTriageDetectorUncertain => 311,
384            Self::ProcessTriagePolicyViolation => 312,
385            Self::ProcessTriageTransportError => 313,
386            Self::ProcessTriageExecutorError => 314,
387            Self::ProcessTriageTimeout => 315,
388            Self::ProcessTriagePartialResult => 316,
389            Self::ProcessTriageInvalidRequest => 317,
390
391            // Cancellation (320-325)
392            Self::CancelGracefulSent => 320,
393            Self::CancelEscalatedKill => 321,
394            Self::CancelRemoteKillFailed => 322,
395            Self::CancelCleanupFailed => 323,
396            Self::CancelSlotLeak => 324,
397            Self::CancelTimeoutExceeded => 325,
398
399            // Transfer (400-499)
400            Self::TransferRsyncFailed => 400,
401            Self::TransferTimeout => 401,
402            Self::TransferSourceMissing => 402,
403            Self::TransferDestError => 403,
404            Self::TransferDiskFull => 404,
405            Self::TransferPermissionDenied => 405,
406            Self::TransferChecksumError => 406,
407            Self::TransferBinaryFailed => 407,
408            Self::TransferIncomplete => 408,
409            Self::TransferProtocolError => 409,
410            Self::DependencyPreflightMissing => 410,
411            Self::DependencyPreflightStale => 411,
412            Self::DependencyPreflightUnknown => 412,
413            Self::DependencyPreflightPolicyViolation => 413,
414            Self::DependencyPreflightTimeout => 414,
415
416            // Internal (500-599)
417            Self::InternalDaemonSocket => 500,
418            Self::InternalDaemonProtocol => 501,
419            Self::InternalDaemonNotRunning => 502,
420            Self::InternalIpcError => 503,
421            Self::InternalStateError => 504,
422            Self::InternalSerdeError => 505,
423            Self::InternalHookError => 506,
424            Self::InternalMetricsError => 507,
425            Self::InternalLoggingError => 508,
426            Self::InternalUpdateError => 509,
427        }
428    }
429
430    /// Returns the formatted error code string (e.g., "RCH-E001").
431    #[must_use]
432    pub fn code_string(&self) -> String {
433        format!("RCH-E{:03}", self.code_number())
434    }
435
436    /// Returns the error category.
437    #[must_use]
438    pub const fn category(&self) -> ErrorCategory {
439        match self.code_number() {
440            1..=99 => ErrorCategory::Config,
441            100..=199 => ErrorCategory::Network,
442            200..=299 => ErrorCategory::Worker,
443            300..=399 => ErrorCategory::Build,
444            400..=499 => ErrorCategory::Transfer,
445            500..=599 => ErrorCategory::Internal,
446            _ => ErrorCategory::Internal,
447        }
448    }
449
450    /// Returns the full error entry with all metadata.
451    #[must_use]
452    pub fn entry(&self) -> ErrorEntry {
453        ErrorEntry {
454            code: self.code_string(),
455            category: self.category(),
456            message: self.message().to_string(),
457            remediation: self
458                .remediation()
459                .iter()
460                .map(|s| (*s).to_string())
461                .collect(),
462            doc_url: self.doc_url().map(String::from),
463        }
464    }
465
466    /// Returns the error message template.
467    #[must_use]
468    pub const fn message(&self) -> &'static str {
469        match self {
470            // Config
471            Self::ConfigNotFound => "Configuration file not found",
472            Self::ConfigReadError => "Failed to read configuration file",
473            Self::ConfigParseError => "Configuration file contains invalid TOML syntax",
474            Self::ConfigValidationError => "Configuration contains invalid values",
475            Self::ConfigEnvError => "Environment variable has invalid value",
476            Self::ConfigProfileNotFound => "Profile not found in configuration",
477            Self::ConfigNoWorkers => "No workers are configured",
478            Self::ConfigInvalidWorker => "Worker configuration is invalid",
479            Self::ConfigSshKeyError => "SSH key path is invalid or inaccessible",
480            Self::ConfigSocketPathError => "Socket path is invalid or inaccessible",
481
482            // Path-Dependency
483            Self::PathDepManifestParseFailed => {
484                "Cargo manifest parse failure during path-dependency resolution"
485            }
486            Self::PathDepMissing => "Path dependency declared but target directory not found",
487            Self::PathDepCyclic => "Cyclic path dependency detected in dependency graph",
488            Self::PathDepPolicyViolation => {
489                "Path dependency violates canonical-root topology policy"
490            }
491            Self::PathDepMetadataFailed => "cargo metadata invocation failed",
492            Self::PathDepMetadataParseFailed => "cargo metadata output could not be parsed",
493
494            // Dependency-Closure
495            Self::ClosurePlanFailed => "Dependency closure plan computation failed",
496            Self::ClosureFailOpen => {
497                "Dependency closure entered fail-open state due to unverifiable data"
498            }
499            Self::ClosureHighRisk => "High-risk path dependencies detected in closure plan",
500            Self::ClosureMissingData => "Required dependency closure data is missing or incomplete",
501            Self::ClosureNonDeterministic => "Closure sync action ordering is non-deterministic",
502            Self::ClosureFingerprintMismatch => "Closure manifest fingerprint mismatch detected",
503
504            // Network
505            Self::SshConnectionFailed => "SSH connection to worker failed",
506            Self::SshAuthFailed => "SSH authentication failed",
507            Self::SshKeyError => "SSH key not found or has invalid format",
508            Self::SshHostKeyError => "SSH host key verification failed",
509            Self::SshTimeout => "SSH command execution timed out",
510            Self::SshSessionDropped => "SSH session terminated unexpectedly",
511            Self::NetworkDnsError => "DNS resolution failed for worker host",
512            Self::NetworkUnreachable => "Network is unreachable",
513            Self::NetworkConnectionRefused => "Connection refused by remote host",
514            Self::NetworkTimeout => "TCP connection timed out",
515
516            // Worker
517            Self::WorkerNoneAvailable => "No workers available for selection",
518            Self::WorkerAllUnhealthy => "All configured workers are unhealthy",
519            Self::WorkerHealthCheckFailed => "Worker failed health check",
520            Self::WorkerSelfTestFailed => "Worker self-test failed",
521            Self::WorkerAtCapacity => "Worker is at maximum capacity",
522            Self::WorkerMissingToolchain => "Worker is missing required toolchain",
523            Self::WorkerStateError => "Worker state is inconsistent",
524            Self::WorkerCircuitOpen => "Worker circuit breaker is open",
525            Self::WorkerSelectionFailed => "Worker selection strategy failed",
526            Self::WorkerLoadQueryFailed => "Failed to query worker load",
527
528            // Disk Pressure / Storage
529            Self::WorkerDiskPressureCritical => "Worker disk usage is critically high",
530            Self::WorkerDiskPressureWarning => "Worker disk usage has exceeded warning threshold",
531            Self::WorkerTelemetryGap => "Worker disk pressure telemetry is stale or missing",
532            Self::WorkerDiskIoHigh => "Worker disk I/O utilization is too high for scheduling",
533            Self::WorkerMemoryPressureHigh => "Worker memory pressure exceeds scheduling threshold",
534            Self::WorkerReclaimFailed => "Disk reclaim operation failed on worker",
535            Self::WorkerDiskHeadroomInsufficient => {
536                "Insufficient disk headroom for build reservation"
537            }
538            Self::WorkerReclaimProtected => "Active build protection prevented reclaim operation",
539
540            // Build
541            Self::BuildCompilationFailed => "Remote compilation failed",
542            Self::BuildUnknownCommand => "Build command not recognized",
543            Self::BuildKilledBySignal => "Build process was killed by signal",
544            Self::BuildTimeout => "Build operation timed out",
545            Self::BuildOutputError => "Failed to capture build output",
546            Self::BuildWorkdirError => "Remote working directory error",
547            Self::BuildToolchainError => "Toolchain wrapper failed",
548            Self::BuildEnvError => "Build environment setup failed",
549            Self::BuildIncrementalError => "Incremental build state is corrupted",
550            Self::BuildArtifactMissing => "Build artifact not found",
551            Self::BuildArtifactSyncEmpty => {
552                "Remote build succeeded but no build outputs were synced back"
553            }
554            Self::BuildArtifactForeignTarget => {
555                "Retrieved build artifacts target a different platform than this host"
556            }
557            Self::BuildCargoWorkspaceInheritance => {
558                "Cargo workspace inheritance failed under remote topology"
559            }
560
561            // Process Triage
562            Self::ProcessTriageAdapterUnavailable => {
563                "Process triage adapter is unavailable or not installed"
564            }
565            Self::ProcessTriageDetectorUncertain => {
566                "Process detector could not classify with sufficient confidence"
567            }
568            Self::ProcessTriagePolicyViolation => {
569                "Process triage action violates safe-action policy"
570            }
571            Self::ProcessTriageTransportError => {
572                "Transport error communicating with process triage adapter"
573            }
574            Self::ProcessTriageExecutorError => {
575                "Process triage executor encountered a runtime error"
576            }
577            Self::ProcessTriageTimeout => "Process triage operation timed out",
578            Self::ProcessTriagePartialResult => {
579                "Process triage returned partial or incomplete results"
580            }
581            Self::ProcessTriageInvalidRequest => "Invalid process triage request",
582
583            // Cancellation
584            Self::CancelGracefulSent => "Graceful cancel signal dispatched",
585            Self::CancelEscalatedKill => "Escalated to forced kill after timeout",
586            Self::CancelRemoteKillFailed => "Failed to kill remote process via SSH",
587            Self::CancelCleanupFailed => "Post-cancel cleanup encountered errors",
588            Self::CancelSlotLeak => "Slots not properly released after cancel",
589            Self::CancelTimeoutExceeded => "Cancellation exceeded policy time budget",
590
591            // Transfer
592            Self::TransferRsyncFailed => "Rsync transfer failed",
593            Self::TransferTimeout => "File sync operation timed out",
594            Self::TransferSourceMissing => "Source files not found",
595            Self::TransferDestError => "Destination path error",
596            Self::TransferDiskFull => "Insufficient disk space on worker",
597            Self::TransferPermissionDenied => "Permission denied during file transfer",
598            Self::TransferChecksumError => "Transfer checksum mismatch",
599            Self::TransferBinaryFailed => "Binary download failed",
600            Self::TransferIncomplete => "Transfer completed partially",
601            Self::TransferProtocolError => "Transfer protocol error",
602            Self::DependencyPreflightMissing => {
603                "Remote dependency preflight found a missing required path"
604            }
605            Self::DependencyPreflightStale => {
606                "Remote dependency preflight found a stale dependency root"
607            }
608            Self::DependencyPreflightUnknown => {
609                "Remote dependency preflight could not verify dependency state"
610            }
611            Self::DependencyPreflightPolicyViolation => {
612                "Remote dependency preflight blocked a topology policy violation"
613            }
614            Self::DependencyPreflightTimeout => "Remote dependency preflight planning timed out",
615
616            // Internal
617            Self::InternalDaemonSocket => "Failed to connect to daemon socket",
618            Self::InternalDaemonProtocol => "Daemon protocol error",
619            Self::InternalDaemonNotRunning => "RCH daemon is not running",
620            Self::InternalIpcError => "Inter-process communication error",
621            Self::InternalStateError => "Unexpected internal state",
622            Self::InternalSerdeError => "Serialization/deserialization error",
623            Self::InternalHookError => "Hook execution failed",
624            Self::InternalMetricsError => "Metrics collection error",
625            Self::InternalLoggingError => "Logging system error",
626            Self::InternalUpdateError => "Update check failed",
627        }
628    }
629
630    /// Returns remediation steps for this error.
631    #[must_use]
632    pub const fn remediation(&self) -> &'static [&'static str] {
633        match self {
634            // Config
635            Self::ConfigNotFound => &[
636                "Run 'rch init' to create a default configuration",
637                "Check if ~/.config/rch/config.toml exists",
638                "Set RCH_CONFIG_DIR environment variable to specify custom config directory",
639            ],
640            Self::ConfigReadError => &[
641                "Check file permissions on the configuration file",
642                "Verify the file is not corrupted",
643                "Ensure no other process has locked the file",
644            ],
645            Self::ConfigParseError => &[
646                "Run 'rch config validate' to identify syntax errors",
647                "Check TOML syntax at the indicated line",
648                "Ensure all strings are properly quoted",
649            ],
650            Self::ConfigValidationError => &[
651                "Run 'rch config validate' for detailed diagnostics",
652                "Check that all required fields are present",
653                "Verify values are within allowed ranges",
654            ],
655            Self::ConfigEnvError => &[
656                "Check the environment variable value format",
657                "Unset the variable to use config file defaults",
658                "See 'rch help env' for valid environment variables",
659            ],
660            Self::ConfigProfileNotFound => &[
661                "List available profiles with 'rch config profiles'",
662                "Create the profile in your configuration file",
663                "Check for typos in the profile name",
664            ],
665            Self::ConfigNoWorkers => &[
666                "Add at least one worker to your configuration",
667                "Run 'rch discover' to find available workers",
668                "Check the [workers] section in your config",
669            ],
670            Self::ConfigInvalidWorker => &[
671                "Verify worker hostname is correct",
672                "Check SSH username and key configuration",
673                "Ensure remote_base_dir is a valid path",
674            ],
675            Self::ConfigSshKeyError => &[
676                "Check that the SSH key file exists",
677                "Verify file permissions (should be 600)",
678                "Ensure the key format is valid (ssh-keygen -y -f KEY)",
679            ],
680            Self::ConfigSocketPathError => &[
681                "Check directory permissions for socket path",
682                "Ensure parent directory exists",
683                "Try using the default socket path",
684            ],
685
686            // Path-Dependency
687            Self::PathDepManifestParseFailed => &[
688                "Check Cargo.toml syntax with 'cargo verify-project'",
689                "Ensure all path-dependency Cargo.toml files are valid TOML",
690                "Run 'cargo metadata' manually to see detailed parse errors",
691            ],
692            Self::PathDepMissing => &[
693                "Verify the path in Cargo.toml [dependencies] exists on disk",
694                "Check for typos in the dependency path value",
695                "Ensure all workspace members are checked out",
696            ],
697            Self::PathDepCyclic => &[
698                "Review the path dependency graph for cycles",
699                "Run 'cargo metadata' to visualize the dependency tree",
700                "Break the cycle by restructuring crate boundaries",
701            ],
702            Self::PathDepPolicyViolation => &[
703                "Ensure all path dependencies are under the configured canonical root (path_topology.canonical_root, default /data/projects)",
704                "Check that paths resolve within allowed topology prefixes",
705                "Review the PathTopologyPolicy configuration",
706            ],
707            Self::PathDepMetadataFailed => &[
708                "Verify 'cargo' is installed and on PATH",
709                "Check that Cargo.toml is a valid project manifest",
710                "Try running 'cargo metadata --format-version=1' manually",
711            ],
712            Self::PathDepMetadataParseFailed => &[
713                "Run 'cargo metadata --format-version=1' and check JSON output",
714                "Ensure cargo version is recent enough for the workspace layout",
715                "Check for toolchain incompatibilities with rust-toolchain.toml",
716            ],
717
718            // Dependency-Closure
719            Self::ClosurePlanFailed => &[
720                "Check that all path dependencies are resolvable",
721                "Run 'cargo metadata' to verify dependency graph health",
722                "Review dependency closure planner logs for specific failures",
723            ],
724            Self::ClosureFailOpen => &[
725                "The transfer will proceed with project root only (fail-open semantics)",
726                "Check path dependency graph health to restore full closure",
727                "Review the fail-open reason in structured diagnostics output",
728            ],
729            Self::ClosureHighRisk => &[
730                "Review the high-risk dependencies flagged in the plan",
731                "Ensure all dependency paths are canonical and stable",
732                "Consider pinning dependency versions to reduce risk",
733            ],
734            Self::ClosureMissingData => &[
735                "Ensure Cargo.toml and Cargo.lock are present and valid",
736                "Check that all workspace members are accessible",
737                "Run 'cargo update' to regenerate lock file if needed",
738            ],
739            Self::ClosureNonDeterministic => &[
740                "Report this as a bug — closure ordering must be deterministic",
741                "Check for filesystem race conditions or concurrent modifications",
742                "Retry the operation to see if the ordering stabilizes",
743            ],
744            Self::ClosureFingerprintMismatch => &[
745                "A dependency manifest changed since the plan was computed",
746                "Recompute the closure plan to pick up the latest manifests",
747                "Check for concurrent modifications to Cargo.toml files",
748            ],
749
750            // Network
751            Self::SshConnectionFailed => &[
752                "Verify the worker host is reachable: ping <host>",
753                "Check that SSH service is running on the worker",
754                "Verify firewall allows SSH (port 22)",
755                "Try connecting manually: ssh <user>@<host>",
756            ],
757            Self::SshAuthFailed => &[
758                "Verify SSH key is in authorized_keys on the worker",
759                "Check SSH key passphrase if applicable",
760                "Ensure ssh-agent is running with key loaded",
761                "Try: ssh-add -l to list loaded keys",
762            ],
763            Self::SshKeyError => &[
764                "Check that the SSH key file exists at the configured path",
765                "Verify key file permissions are 600",
766                "Regenerate key if format is corrupted",
767            ],
768            Self::SshHostKeyError => &[
769                "Accept the host key: ssh <user>@<host> (confirm fingerprint)",
770                "Check known_hosts for conflicting entries",
771                "Update known_hosts_policy in config if appropriate",
772            ],
773            Self::SshTimeout => &[
774                "Check network connectivity to the worker",
775                "Increase timeout in configuration",
776                "Verify worker is not overloaded",
777            ],
778            Self::SshSessionDropped => &[
779                "Check network stability",
780                "Verify worker has not rebooted",
781                "Look for keepalive settings in SSH config",
782            ],
783            Self::NetworkDnsError => &[
784                "Verify worker hostname is correct",
785                "Check DNS server configuration",
786                "Try using IP address instead of hostname",
787            ],
788            Self::NetworkUnreachable => &[
789                "Check network connection on local machine",
790                "Verify VPN connection if required",
791                "Check routing to worker network",
792            ],
793            Self::NetworkConnectionRefused => &[
794                "Verify SSH service is running on worker",
795                "Check if worker firewall allows connections",
796                "Ensure correct port is being used",
797            ],
798            Self::NetworkTimeout => &[
799                "Check network latency to worker",
800                "Verify worker is responsive",
801                "Increase connection timeout in config",
802            ],
803
804            // Worker
805            Self::WorkerNoneAvailable => &[
806                "Configure at least one worker in config.toml",
807                "Run 'rch discover' to find available workers",
808                "Check that configured workers are enabled",
809            ],
810            Self::WorkerAllUnhealthy => &[
811                "Run 'rch doctor' to diagnose worker issues",
812                "Check individual worker connectivity",
813                "Review worker health check logs",
814            ],
815            Self::WorkerHealthCheckFailed => &[
816                "Verify SSH connectivity to worker",
817                "Check worker disk space and load",
818                "Review health check timeout settings",
819            ],
820            Self::WorkerSelfTestFailed => &[
821                "Run 'rch self-test --worker <name>' for details",
822                "Verify Rust toolchain on worker",
823                "Check worker has required dependencies",
824            ],
825            Self::WorkerAtCapacity => &[
826                "Wait for current builds to complete",
827                "Add more workers to distribute load",
828                "Increase max_concurrent_builds on worker",
829            ],
830            Self::WorkerMissingToolchain => &[
831                "Install required toolchain on worker",
832                "Run 'rustup show' on worker to verify",
833                "Update worker toolchain configuration",
834            ],
835            Self::WorkerStateError => &[
836                "Restart the RCH daemon: rchd restart",
837                "Check for stale lock files",
838                "Review daemon logs for details",
839            ],
840            Self::WorkerCircuitOpen => &[
841                "Wait for circuit breaker reset period",
842                "Check worker health manually",
843                "Review recent build failures on worker",
844            ],
845            Self::WorkerSelectionFailed => &[
846                "Verify at least one worker is healthy",
847                "Check selection strategy configuration",
848                "Review worker weights and priorities",
849            ],
850            Self::WorkerLoadQueryFailed => &[
851                "Verify SSH connectivity to worker",
852                "Check that load query command works on worker",
853                "Review timeout settings for load queries",
854            ],
855
856            // Disk Pressure / Storage
857            Self::WorkerDiskPressureCritical => &[
858                "Worker disk usage is above 95% — builds will not be scheduled here",
859                "Clean up old build caches: rch cache clean --worker <id>",
860                "Check disk usage on worker: ssh <worker> df -h",
861            ],
862            Self::WorkerDiskPressureWarning => &[
863                "Worker disk usage is above 80% — scheduling priority reduced",
864                "Consider cleaning old caches: rch cache clean --worker <id>",
865                "Monitor disk usage trend to prevent critical state",
866            ],
867            Self::WorkerTelemetryGap => &[
868                "Worker disk telemetry is stale — pressure assessment is unreliable",
869                "Check worker health: rch workers probe <id>",
870                "Verify telemetry collection is running on the worker",
871            ],
872            Self::WorkerDiskIoHigh => &[
873                "Worker disk I/O is saturated — builds may experience latency",
874                "Wait for current I/O-heavy operations to complete",
875                "Check for stuck or runaway processes: rch workers probe <id>",
876            ],
877            Self::WorkerMemoryPressureHigh => &[
878                "Worker memory pressure is high — scheduling priority reduced",
879                "Check for memory leaks or over-committed builds on the worker",
880                "Review worker slot count to prevent over-scheduling",
881            ],
882            Self::WorkerReclaimFailed => &[
883                "Disk space reclaim operation failed on the worker",
884                "Check worker filesystem health and permissions",
885                "Inspect both root and temp usage: ssh <worker> 'df -h / /tmp'",
886            ],
887            Self::WorkerDiskHeadroomInsufficient => &[
888                "Estimated build disk requirement exceeds available free space",
889                "Try a different worker with more headroom",
890                "Clean up old build artifacts to free space",
891            ],
892            Self::WorkerReclaimProtected => &[
893                "Active build artifacts were protected from reclaim",
894                "Wait for current builds to complete before retrying reclaim",
895                "Only idle cache entries are eligible for eviction",
896            ],
897
898            // Build
899            Self::BuildCompilationFailed => &[
900                "Review compilation errors in output",
901                "Verify code compiles locally first",
902                "Check for missing dependencies on worker",
903            ],
904            Self::BuildUnknownCommand => &[
905                "Check that the command is supported",
906                "Verify cargo/rustc version compatibility",
907                "Review RCH command pattern configuration",
908            ],
909            Self::BuildKilledBySignal => &[
910                "Check worker system logs for OOM killer",
911                "Review build memory requirements",
912                "Check if build was manually interrupted",
913            ],
914            Self::BuildTimeout => &[
915                "Increase build timeout in configuration",
916                "Check for infinite loops or hangs",
917                "Verify worker is not overloaded",
918            ],
919            Self::BuildOutputError => &[
920                "Check worker disk space",
921                "Verify PTY allocation settings",
922                "Review output buffer configuration",
923            ],
924            Self::BuildWorkdirError => &[
925                "Verify remote_base_dir is writable",
926                "Check directory permissions on worker",
927                "Ensure path does not contain special characters",
928            ],
929            Self::BuildToolchainError => &[
930                "Verify toolchain is installed on worker",
931                "Check rustup default toolchain",
932                "Review toolchain override settings",
933            ],
934            Self::BuildEnvError => &[
935                "Check environment variable configuration",
936                "Verify required environment is set on worker",
937                "Review shell initialization on worker",
938            ],
939            Self::BuildIncrementalError => &[
940                "Run 'cargo clean' on remote workspace",
941                "Delete incremental compilation cache",
942                "Try full rebuild with --release",
943            ],
944            Self::BuildArtifactMissing => &[
945                "Verify build completed successfully",
946                "Check artifact path configuration",
947                "Review build output for artifact location",
948            ],
949            Self::BuildArtifactSyncEmpty => &[
950                "Treat the local build as INCOMPLETE: local binaries may be stale",
951                "Re-run the build (or build locally) before trusting any binary",
952                "Check that the cargo profile's output directory matches the artifact patterns",
953            ],
954            Self::BuildArtifactForeignTarget => &[
955                "Treat the local target directory as POISONED: rebuild for this host",
956                "Pin the build to an explicit --target so the output triple is unambiguous",
957                "Restrict the project to a same-platform worker (RCH_WORKER, or a worker `os` declaration)",
958            ],
959            Self::BuildCargoWorkspaceInheritance => &[
960                "Verify dependency workspace metadata roots are synced",
961                "Isolate remote sync roots from unrelated outer Cargo workspaces",
962                "Check path dependency manifests for workspace-inherited fields",
963            ],
964
965            // Process Triage
966            Self::ProcessTriageAdapterUnavailable => &[
967                "Ensure the process triage adapter binary is installed",
968                "Check PATH includes the adapter binary location",
969                "Verify the adapter version is compatible with this RCH version",
970            ],
971            Self::ProcessTriageDetectorUncertain => &[
972                "Process classification was inconclusive — no action taken",
973                "Review the process list manually for suspicious entries",
974                "Adjust detector confidence threshold if false negatives are common",
975            ],
976            Self::ProcessTriagePolicyViolation => &[
977                "The requested action is blocked by safe-action policy",
978                "Review the escalation level required for this action class",
979                "Use a lower-risk action class or request manual approval",
980            ],
981            Self::ProcessTriageTransportError => &[
982                "Communication with the process triage adapter failed",
983                "Verify the adapter process is running and responsive",
984                "Check for socket/pipe errors in adapter logs",
985            ],
986            Self::ProcessTriageExecutorError => &[
987                "The process triage executor encountered a runtime error",
988                "Check adapter logs for detailed error output",
989                "Verify the target process is still running",
990            ],
991            Self::ProcessTriageTimeout => &[
992                "Process triage operation exceeded the configured timeout",
993                "Increase timeout in ProcessTriageTimeoutPolicy if needed",
994                "Check for adapter hangs or system-level resource contention",
995            ],
996            Self::ProcessTriagePartialResult => &[
997                "Not all requested triage actions completed successfully",
998                "Review the partial result for which actions succeeded",
999                "Retry failed actions individually for better diagnostics",
1000            ],
1001            Self::ProcessTriageInvalidRequest => &[
1002                "The process triage request is malformed or missing required fields",
1003                "Validate request against the ProcessTriage contract schema",
1004                "Check the contract schema version compatibility",
1005            ],
1006
1007            // Cancellation
1008            Self::CancelGracefulSent => &[
1009                "Graceful cancellation signal (SIGTERM) was sent to the build process",
1010                "The build should terminate within the grace period",
1011                "Use force cancel if the process does not respond",
1012            ],
1013            Self::CancelEscalatedKill => &[
1014                "Build did not respond to graceful cancel within the grace period",
1015                "SIGKILL was sent to forcefully terminate the process",
1016                "Check worker for orphaned processes if this occurs frequently",
1017            ],
1018            Self::CancelRemoteKillFailed => &[
1019                "SSH kill command to the remote worker failed",
1020                "Verify SSH connectivity to the worker",
1021                "Check that the remote process PID is still valid",
1022            ],
1023            Self::CancelCleanupFailed => &[
1024                "Post-cancellation cleanup did not complete successfully",
1025                "Check worker disk space and permissions",
1026                "Verify remote working directory state manually",
1027            ],
1028            Self::CancelSlotLeak => &[
1029                "Worker slots were not properly released after cancellation",
1030                "Check worker slot accounting for inconsistencies",
1031                "Restart the daemon if slot leak persists",
1032            ],
1033            Self::CancelTimeoutExceeded => &[
1034                "Cancellation did not complete within the policy time budget",
1035                "The build may still be running on the worker",
1036                "Consider increasing cancellation timeouts or force-cancelling",
1037            ],
1038
1039            // Transfer
1040            Self::TransferRsyncFailed => &[
1041                "Verify rsync is installed on both ends",
1042                "Check SSH connectivity to worker",
1043                "Review rsync exclude patterns",
1044            ],
1045            Self::TransferTimeout => &[
1046                "Increase transfer timeout in configuration",
1047                "Check network bandwidth to worker",
1048                "Consider using incremental sync",
1049            ],
1050            Self::TransferSourceMissing => &[
1051                "Verify source files exist locally",
1052                "Check file patterns in configuration",
1053                "Review .rchignore exclusions",
1054            ],
1055            Self::TransferDestError => &[
1056                "Check remote directory permissions and ownership",
1057                "Verify remote_base_dir is valid",
1058                "Ensure sufficient disk space on worker",
1059            ],
1060            Self::TransferDiskFull => &[
1061                "Clean up old builds on worker",
1062                "Check disk usage: df -h on worker",
1063                "Increase disk allocation for worker",
1064            ],
1065            Self::TransferPermissionDenied => &[
1066                "Check ownership of /data/projects/<repo> on the worker",
1067                "Verify SSH user has write permissions",
1068                "Repair drift if needed: sudo chown -R <ssh-user>:<ssh-user> /data/projects/<repo>",
1069            ],
1070            Self::TransferChecksumError => &[
1071                "Retry the transfer",
1072                "Check for network issues",
1073                "Verify file integrity on source",
1074            ],
1075            Self::TransferBinaryFailed => &[
1076                "Check network connectivity",
1077                "Verify binary URL is accessible",
1078                "Try manual download to diagnose",
1079            ],
1080            Self::TransferIncomplete => &[
1081                "Retry the transfer operation",
1082                "Check for network interruptions",
1083                "Review transfer logs for details",
1084            ],
1085            Self::TransferProtocolError => &[
1086                "Verify rsync version compatibility",
1087                "Check SSH protocol settings",
1088                "Review transfer configuration",
1089            ],
1090            Self::DependencyPreflightMissing => &[
1091                "Inspect the dependency preflight evidence for the missing remote path",
1092                "Ensure every dependency root in the closure is synced to the worker",
1093                "Verify Cargo.toml and required source entrypoints exist on the worker",
1094            ],
1095            Self::DependencyPreflightStale => &[
1096                "Rerun after a successful sync of all dependency roots",
1097                "Check whether transfer estimation skipped a dependency root",
1098                "Inspect dependency preflight evidence for the stale root",
1099            ],
1100            Self::DependencyPreflightUnknown => &[
1101                "Inspect sync and SSH logs for the dependency preflight probe",
1102                "Retry after worker connectivity and shell probes are healthy",
1103                "Use verbose output to view the full dependency preflight report",
1104            ],
1105            Self::DependencyPreflightPolicyViolation => &[
1106                "Move path dependencies under the configured canonical project root",
1107                "Use /data/projects or /dp topology aliases consistently",
1108                "Inspect the dependency closure policy diagnostics before retrying",
1109            ],
1110            Self::DependencyPreflightTimeout => &[
1111                "Retry after system load decreases",
1112                "Inspect cargo metadata latency for the workspace",
1113                "Increase planner timeout only after ruling out dependency graph issues",
1114            ],
1115
1116            // Internal
1117            Self::InternalDaemonSocket => &[
1118                "Start the daemon: rchd start",
1119                "Check socket path permissions",
1120                "Verify no stale socket file exists",
1121            ],
1122            Self::InternalDaemonProtocol => &[
1123                "Restart the daemon: rchd restart",
1124                "Check for version mismatch between rch and rchd",
1125                "Review daemon logs for details",
1126            ],
1127            Self::InternalDaemonNotRunning => &[
1128                "Start the daemon: rchd start",
1129                "Check if daemon crashed: journalctl -u rchd",
1130                "Verify daemon configuration",
1131            ],
1132            Self::InternalIpcError => &[
1133                "Restart the daemon",
1134                "Check system message queue limits",
1135                "Review logs for detailed error",
1136            ],
1137            Self::InternalStateError => &[
1138                "Restart the daemon",
1139                "Clear any lock files",
1140                "Report bug if persists",
1141            ],
1142            Self::InternalSerdeError => &[
1143                "Check for corrupted state files",
1144                "Clear cache and restart",
1145                "Report bug with reproduction steps",
1146            ],
1147            Self::InternalHookError => &[
1148                "Verify hook script exists and is executable",
1149                "Check hook script for errors",
1150                "Review hook timeout settings",
1151            ],
1152            Self::InternalMetricsError => &[
1153                "Check metrics file permissions",
1154                "Verify disk space for metrics",
1155                "Review metrics configuration",
1156            ],
1157            Self::InternalLoggingError => &[
1158                "Check log directory permissions",
1159                "Verify disk space for logs",
1160                "Review logging configuration",
1161            ],
1162            Self::InternalUpdateError => &[
1163                "Check network connectivity",
1164                "Verify update server is reachable",
1165                "Try manual update check",
1166            ],
1167        }
1168    }
1169
1170    /// Returns documentation URL for this error, if available.
1171    #[must_use]
1172    pub const fn doc_url(&self) -> Option<&'static str> {
1173        // Use specific doc pages for new sub-ranges when available
1174        match self {
1175            Self::PathDepManifestParseFailed
1176            | Self::PathDepMissing
1177            | Self::PathDepCyclic
1178            | Self::PathDepPolicyViolation
1179            | Self::PathDepMetadataFailed
1180            | Self::PathDepMetadataParseFailed => Some("https://rch.dev/docs/path-deps"),
1181
1182            Self::ClosurePlanFailed
1183            | Self::ClosureFailOpen
1184            | Self::ClosureHighRisk
1185            | Self::ClosureMissingData
1186            | Self::ClosureNonDeterministic
1187            | Self::ClosureFingerprintMismatch => Some("https://rch.dev/docs/dependency-closure"),
1188
1189            Self::WorkerDiskPressureCritical
1190            | Self::WorkerDiskPressureWarning
1191            | Self::WorkerTelemetryGap
1192            | Self::WorkerDiskIoHigh
1193            | Self::WorkerMemoryPressureHigh
1194            | Self::WorkerReclaimFailed
1195            | Self::WorkerDiskHeadroomInsufficient
1196            | Self::WorkerReclaimProtected => Some("https://rch.dev/docs/disk-pressure"),
1197
1198            Self::ProcessTriageAdapterUnavailable
1199            | Self::ProcessTriageDetectorUncertain
1200            | Self::ProcessTriagePolicyViolation
1201            | Self::ProcessTriageTransportError
1202            | Self::ProcessTriageExecutorError
1203            | Self::ProcessTriageTimeout
1204            | Self::ProcessTriagePartialResult
1205            | Self::ProcessTriageInvalidRequest => Some("https://rch.dev/docs/process-triage"),
1206
1207            Self::CancelGracefulSent
1208            | Self::CancelEscalatedKill
1209            | Self::CancelRemoteKillFailed
1210            | Self::CancelCleanupFailed
1211            | Self::CancelSlotLeak
1212            | Self::CancelTimeoutExceeded => Some("https://rch.dev/docs/cancellation"),
1213
1214            Self::DependencyPreflightMissing
1215            | Self::DependencyPreflightStale
1216            | Self::DependencyPreflightUnknown
1217            | Self::DependencyPreflightPolicyViolation
1218            | Self::DependencyPreflightTimeout => Some("https://rch.dev/docs/sync"),
1219
1220            _ => match self.category() {
1221                ErrorCategory::Config => Some("https://rch.dev/docs/config"),
1222                ErrorCategory::Network => Some("https://rch.dev/docs/ssh"),
1223                ErrorCategory::Worker => Some("https://rch.dev/docs/workers"),
1224                ErrorCategory::Build => Some("https://rch.dev/docs/builds"),
1225                ErrorCategory::Transfer => Some("https://rch.dev/docs/sync"),
1226                ErrorCategory::Internal => Some("https://rch.dev/docs/troubleshooting"),
1227            },
1228        }
1229    }
1230
1231    /// Returns all error codes.
1232    #[must_use]
1233    pub const fn all() -> &'static [ErrorCode] {
1234        &[
1235            // Config
1236            Self::ConfigNotFound,
1237            Self::ConfigReadError,
1238            Self::ConfigParseError,
1239            Self::ConfigValidationError,
1240            Self::ConfigEnvError,
1241            Self::ConfigProfileNotFound,
1242            Self::ConfigNoWorkers,
1243            Self::ConfigInvalidWorker,
1244            Self::ConfigSshKeyError,
1245            Self::ConfigSocketPathError,
1246            // Path-Dependency
1247            Self::PathDepManifestParseFailed,
1248            Self::PathDepMissing,
1249            Self::PathDepCyclic,
1250            Self::PathDepPolicyViolation,
1251            Self::PathDepMetadataFailed,
1252            Self::PathDepMetadataParseFailed,
1253            // Dependency-Closure
1254            Self::ClosurePlanFailed,
1255            Self::ClosureFailOpen,
1256            Self::ClosureHighRisk,
1257            Self::ClosureMissingData,
1258            Self::ClosureNonDeterministic,
1259            Self::ClosureFingerprintMismatch,
1260            // Network
1261            Self::SshConnectionFailed,
1262            Self::SshAuthFailed,
1263            Self::SshKeyError,
1264            Self::SshHostKeyError,
1265            Self::SshTimeout,
1266            Self::SshSessionDropped,
1267            Self::NetworkDnsError,
1268            Self::NetworkUnreachable,
1269            Self::NetworkConnectionRefused,
1270            Self::NetworkTimeout,
1271            // Worker
1272            Self::WorkerNoneAvailable,
1273            Self::WorkerAllUnhealthy,
1274            Self::WorkerHealthCheckFailed,
1275            Self::WorkerSelfTestFailed,
1276            Self::WorkerAtCapacity,
1277            Self::WorkerMissingToolchain,
1278            Self::WorkerStateError,
1279            Self::WorkerCircuitOpen,
1280            Self::WorkerSelectionFailed,
1281            Self::WorkerLoadQueryFailed,
1282            // Disk Pressure / Storage
1283            Self::WorkerDiskPressureCritical,
1284            Self::WorkerDiskPressureWarning,
1285            Self::WorkerTelemetryGap,
1286            Self::WorkerDiskIoHigh,
1287            Self::WorkerMemoryPressureHigh,
1288            Self::WorkerReclaimFailed,
1289            Self::WorkerDiskHeadroomInsufficient,
1290            Self::WorkerReclaimProtected,
1291            // Build
1292            Self::BuildCompilationFailed,
1293            Self::BuildUnknownCommand,
1294            Self::BuildKilledBySignal,
1295            Self::BuildTimeout,
1296            Self::BuildOutputError,
1297            Self::BuildWorkdirError,
1298            Self::BuildToolchainError,
1299            Self::BuildEnvError,
1300            Self::BuildIncrementalError,
1301            Self::BuildArtifactMissing,
1302            Self::BuildArtifactSyncEmpty,
1303            Self::BuildArtifactForeignTarget,
1304            Self::BuildCargoWorkspaceInheritance,
1305            // Process Triage
1306            Self::ProcessTriageAdapterUnavailable,
1307            Self::ProcessTriageDetectorUncertain,
1308            Self::ProcessTriagePolicyViolation,
1309            Self::ProcessTriageTransportError,
1310            Self::ProcessTriageExecutorError,
1311            Self::ProcessTriageTimeout,
1312            Self::ProcessTriagePartialResult,
1313            Self::ProcessTriageInvalidRequest,
1314            // Cancellation
1315            Self::CancelGracefulSent,
1316            Self::CancelEscalatedKill,
1317            Self::CancelRemoteKillFailed,
1318            Self::CancelCleanupFailed,
1319            Self::CancelSlotLeak,
1320            Self::CancelTimeoutExceeded,
1321            // Transfer
1322            Self::TransferRsyncFailed,
1323            Self::TransferTimeout,
1324            Self::TransferSourceMissing,
1325            Self::TransferDestError,
1326            Self::TransferDiskFull,
1327            Self::TransferPermissionDenied,
1328            Self::TransferChecksumError,
1329            Self::TransferBinaryFailed,
1330            Self::TransferIncomplete,
1331            Self::TransferProtocolError,
1332            Self::DependencyPreflightMissing,
1333            Self::DependencyPreflightStale,
1334            Self::DependencyPreflightUnknown,
1335            Self::DependencyPreflightPolicyViolation,
1336            Self::DependencyPreflightTimeout,
1337            // Internal
1338            Self::InternalDaemonSocket,
1339            Self::InternalDaemonProtocol,
1340            Self::InternalDaemonNotRunning,
1341            Self::InternalIpcError,
1342            Self::InternalStateError,
1343            Self::InternalSerdeError,
1344            Self::InternalHookError,
1345            Self::InternalMetricsError,
1346            Self::InternalLoggingError,
1347            Self::InternalUpdateError,
1348        ]
1349    }
1350}
1351
1352impl fmt::Display for ErrorCode {
1353    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1354        write!(f, "{}: {}", self.code_string(), self.message())
1355    }
1356}
1357
1358/// Error category for grouping related errors.
1359#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1360#[serde(rename_all = "lowercase")]
1361pub enum ErrorCategory {
1362    /// Configuration and setup errors (E001-E099)
1363    Config,
1364    /// Network and SSH connectivity errors (E100-E199)
1365    Network,
1366    /// Worker selection and management errors (E200-E299)
1367    Worker,
1368    /// Compilation and build errors (E300-E399)
1369    Build,
1370    /// File transfer and sync errors (E400-E499)
1371    Transfer,
1372    /// Internal/unexpected errors (E500-E599)
1373    Internal,
1374}
1375
1376impl ErrorCategory {
1377    /// Returns a human-readable name for the category.
1378    #[must_use]
1379    pub const fn name(&self) -> &'static str {
1380        match self {
1381            Self::Config => "Configuration",
1382            Self::Network => "Network",
1383            Self::Worker => "Worker",
1384            Self::Build => "Build",
1385            Self::Transfer => "Transfer",
1386            Self::Internal => "Internal",
1387        }
1388    }
1389
1390    /// Returns a short description of the category.
1391    #[must_use]
1392    pub const fn description(&self) -> &'static str {
1393        match self {
1394            Self::Config => "Configuration file and environment setup issues",
1395            Self::Network => "SSH connectivity and network communication issues",
1396            Self::Worker => "Remote worker selection, health, and management issues",
1397            Self::Build => "Remote compilation and build process issues",
1398            Self::Transfer => "File synchronization and transfer issues",
1399            Self::Internal => "Internal errors that may indicate bugs",
1400        }
1401    }
1402}
1403
1404impl fmt::Display for ErrorCategory {
1405    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1406        write!(f, "{}", self.name())
1407    }
1408}
1409
1410/// Complete error entry with all metadata.
1411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1412pub struct ErrorEntry {
1413    /// Error code string (e.g., "RCH-E001")
1414    pub code: String,
1415    /// Error category
1416    pub category: ErrorCategory,
1417    /// Human-readable error message
1418    pub message: String,
1419    /// Steps to remediate the error
1420    pub remediation: Vec<String>,
1421    /// Documentation URL, if available
1422    pub doc_url: Option<String>,
1423}
1424
1425impl ErrorEntry {
1426    /// Formats the error for display with full remediation steps.
1427    #[must_use]
1428    pub fn format_full(&self) -> String {
1429        let mut output = format!("[{}] {}\n\n", self.code, self.message);
1430
1431        if !self.remediation.is_empty() {
1432            output.push_str("Remediation steps:\n");
1433            for (i, step) in self.remediation.iter().enumerate() {
1434                output.push_str(&format!("  {}. {}\n", i + 1, step));
1435            }
1436        }
1437
1438        if let Some(url) = &self.doc_url {
1439            output.push_str(&format!("\nFor more information: {}\n", url));
1440        }
1441
1442        output
1443    }
1444
1445    /// Formats the error as a single line.
1446    #[must_use]
1447    pub fn format_brief(&self) -> String {
1448        format!("[{}] {}", self.code, self.message)
1449    }
1450}
1451
1452impl fmt::Display for ErrorEntry {
1453    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1454        write!(f, "{}", self.format_brief())
1455    }
1456}
1457
1458#[cfg(test)]
1459mod tests {
1460    use super::*;
1461
1462    #[test]
1463    fn test_error_code_numbers_are_unique() {
1464        let mut seen = std::collections::HashSet::new();
1465        for code in ErrorCode::all() {
1466            let num = code.code_number();
1467            assert!(
1468                seen.insert(num),
1469                "Duplicate error code number: {} for {:?}",
1470                num,
1471                code
1472            );
1473        }
1474    }
1475
1476    #[test]
1477    fn test_error_code_format() {
1478        assert_eq!(ErrorCode::ConfigNotFound.code_string(), "RCH-E001");
1479        assert_eq!(ErrorCode::SshConnectionFailed.code_string(), "RCH-E100");
1480        assert_eq!(ErrorCode::WorkerNoneAvailable.code_string(), "RCH-E200");
1481        assert_eq!(ErrorCode::BuildCompilationFailed.code_string(), "RCH-E300");
1482        assert_eq!(ErrorCode::TransferRsyncFailed.code_string(), "RCH-E400");
1483        assert_eq!(ErrorCode::InternalDaemonSocket.code_string(), "RCH-E500");
1484
1485        // New subcategory codes
1486        assert_eq!(
1487            ErrorCode::PathDepManifestParseFailed.code_string(),
1488            "RCH-E013"
1489        );
1490        assert_eq!(ErrorCode::ClosurePlanFailed.code_string(), "RCH-E019");
1491        assert_eq!(
1492            ErrorCode::WorkerDiskPressureCritical.code_string(),
1493            "RCH-E210"
1494        );
1495        assert_eq!(
1496            ErrorCode::ProcessTriageAdapterUnavailable.code_string(),
1497            "RCH-E310"
1498        );
1499        assert_eq!(
1500            ErrorCode::DependencyPreflightMissing.code_string(),
1501            "RCH-E410"
1502        );
1503    }
1504
1505    #[test]
1506    fn test_error_categories() {
1507        assert_eq!(ErrorCode::ConfigNotFound.category(), ErrorCategory::Config);
1508        assert_eq!(
1509            ErrorCode::SshConnectionFailed.category(),
1510            ErrorCategory::Network
1511        );
1512        assert_eq!(
1513            ErrorCode::WorkerNoneAvailable.category(),
1514            ErrorCategory::Worker
1515        );
1516        assert_eq!(
1517            ErrorCode::BuildCompilationFailed.category(),
1518            ErrorCategory::Build
1519        );
1520        assert_eq!(
1521            ErrorCode::TransferRsyncFailed.category(),
1522            ErrorCategory::Transfer
1523        );
1524        assert_eq!(
1525            ErrorCode::InternalDaemonSocket.category(),
1526            ErrorCategory::Internal
1527        );
1528    }
1529
1530    #[test]
1531    fn test_all_errors_have_message() {
1532        for code in ErrorCode::all() {
1533            let msg = code.message();
1534            assert!(!msg.is_empty(), "Error {:?} has empty message", code);
1535        }
1536    }
1537
1538    #[test]
1539    fn test_all_errors_have_remediation() {
1540        for code in ErrorCode::all() {
1541            let steps = code.remediation();
1542            assert!(
1543                !steps.is_empty(),
1544                "Error {:?} has no remediation steps",
1545                code
1546            );
1547        }
1548    }
1549
1550    #[test]
1551    fn test_error_entry_serialization() {
1552        let entry = ErrorCode::ConfigNotFound.entry();
1553        let json = serde_json::to_string(&entry).expect("serialization failed");
1554        assert!(json.contains("RCH-E001"));
1555        assert!(json.contains("config"));
1556
1557        let parsed: ErrorEntry = serde_json::from_str(&json).expect("deserialization failed");
1558        assert_eq!(parsed.code, "RCH-E001");
1559        assert_eq!(parsed.category, ErrorCategory::Config);
1560    }
1561
1562    #[test]
1563    fn test_error_code_serialization() {
1564        let code = ErrorCode::SshAuthFailed;
1565        let json = serde_json::to_string(&code).expect("serialization failed");
1566        assert_eq!(json, "\"SSH_AUTH_FAILED\"");
1567
1568        let parsed: ErrorCode = serde_json::from_str(&json).expect("deserialization failed");
1569        assert_eq!(parsed, ErrorCode::SshAuthFailed);
1570    }
1571
1572    #[test]
1573    fn test_format_full() {
1574        let entry = ErrorCode::ConfigNotFound.entry();
1575        let formatted = entry.format_full();
1576
1577        assert!(formatted.contains("[RCH-E001]"));
1578        assert!(formatted.contains("Configuration file not found"));
1579        assert!(formatted.contains("Remediation steps:"));
1580        assert!(formatted.contains("rch init"));
1581    }
1582
1583    #[test]
1584    fn test_format_brief() {
1585        let entry = ErrorCode::ConfigNotFound.entry();
1586        let formatted = entry.format_brief();
1587
1588        assert_eq!(formatted, "[RCH-E001] Configuration file not found");
1589    }
1590
1591    #[test]
1592    fn test_display_implementations() {
1593        let code = ErrorCode::ConfigNotFound;
1594        let display = format!("{}", code);
1595        assert!(display.contains("RCH-E001"));
1596        assert!(display.contains("Configuration file not found"));
1597
1598        let category = ErrorCategory::Config;
1599        assert_eq!(format!("{}", category), "Configuration");
1600    }
1601
1602    #[test]
1603    fn test_category_ranges() {
1604        // Verify each category has errors in the correct range
1605        for code in ErrorCode::all() {
1606            let num = code.code_number();
1607            let cat = code.category();
1608            match cat {
1609                ErrorCategory::Config => assert!(num < 100, "{:?} should be < 100", code),
1610                ErrorCategory::Network => {
1611                    assert!((100..200).contains(&num), "{:?} should be 100-199", code)
1612                }
1613                ErrorCategory::Worker => {
1614                    assert!((200..300).contains(&num), "{:?} should be 200-299", code)
1615                }
1616                ErrorCategory::Build => {
1617                    assert!((300..400).contains(&num), "{:?} should be 300-399", code)
1618                }
1619                ErrorCategory::Transfer => {
1620                    assert!((400..500).contains(&num), "{:?} should be 400-499", code)
1621                }
1622                ErrorCategory::Internal => {
1623                    assert!((500..600).contains(&num), "{:?} should be 500-599", code)
1624                }
1625            }
1626        }
1627    }
1628
1629    // =========================================================================
1630    // Contract Tests — Code Stability (bd-vvmd.6.1)
1631    // =========================================================================
1632
1633    /// Contract test: path-dependency error codes are stable across versions.
1634    #[test]
1635    fn test_path_dep_error_codes_stable() {
1636        assert_eq!(ErrorCode::PathDepManifestParseFailed.code_number(), 13);
1637        assert_eq!(ErrorCode::PathDepMissing.code_number(), 14);
1638        assert_eq!(ErrorCode::PathDepCyclic.code_number(), 15);
1639        assert_eq!(ErrorCode::PathDepPolicyViolation.code_number(), 16);
1640        assert_eq!(ErrorCode::PathDepMetadataFailed.code_number(), 17);
1641        assert_eq!(ErrorCode::PathDepMetadataParseFailed.code_number(), 18);
1642    }
1643
1644    /// Contract test: dependency-closure error codes are stable.
1645    #[test]
1646    fn test_closure_error_codes_stable() {
1647        assert_eq!(ErrorCode::ClosurePlanFailed.code_number(), 19);
1648        assert_eq!(ErrorCode::ClosureFailOpen.code_number(), 20);
1649        assert_eq!(ErrorCode::ClosureHighRisk.code_number(), 21);
1650        assert_eq!(ErrorCode::ClosureMissingData.code_number(), 22);
1651        assert_eq!(ErrorCode::ClosureNonDeterministic.code_number(), 23);
1652        assert_eq!(ErrorCode::ClosureFingerprintMismatch.code_number(), 24);
1653    }
1654
1655    /// Contract test: disk pressure/storage error codes are stable.
1656    #[test]
1657    fn test_disk_pressure_error_codes_stable() {
1658        assert_eq!(ErrorCode::WorkerDiskPressureCritical.code_number(), 210);
1659        assert_eq!(ErrorCode::WorkerDiskPressureWarning.code_number(), 211);
1660        assert_eq!(ErrorCode::WorkerTelemetryGap.code_number(), 212);
1661        assert_eq!(ErrorCode::WorkerDiskIoHigh.code_number(), 213);
1662        assert_eq!(ErrorCode::WorkerMemoryPressureHigh.code_number(), 214);
1663        assert_eq!(ErrorCode::WorkerReclaimFailed.code_number(), 215);
1664        assert_eq!(ErrorCode::WorkerDiskHeadroomInsufficient.code_number(), 216);
1665        assert_eq!(ErrorCode::WorkerReclaimProtected.code_number(), 217);
1666    }
1667
1668    /// Contract test: process triage error codes are stable.
1669    #[test]
1670    fn test_process_triage_error_codes_stable() {
1671        assert_eq!(
1672            ErrorCode::ProcessTriageAdapterUnavailable.code_number(),
1673            310
1674        );
1675        assert_eq!(ErrorCode::ProcessTriageDetectorUncertain.code_number(), 311);
1676        assert_eq!(ErrorCode::ProcessTriagePolicyViolation.code_number(), 312);
1677        assert_eq!(ErrorCode::ProcessTriageTransportError.code_number(), 313);
1678        assert_eq!(ErrorCode::ProcessTriageExecutorError.code_number(), 314);
1679        assert_eq!(ErrorCode::ProcessTriageTimeout.code_number(), 315);
1680        assert_eq!(ErrorCode::ProcessTriagePartialResult.code_number(), 316);
1681        assert_eq!(ErrorCode::ProcessTriageInvalidRequest.code_number(), 317);
1682    }
1683
1684    /// Contract test: cancellation error codes are stable.
1685    #[test]
1686    fn test_cancellation_error_codes_stable() {
1687        assert_eq!(ErrorCode::CancelGracefulSent.code_number(), 320);
1688        assert_eq!(ErrorCode::CancelEscalatedKill.code_number(), 321);
1689        assert_eq!(ErrorCode::CancelRemoteKillFailed.code_number(), 322);
1690        assert_eq!(ErrorCode::CancelCleanupFailed.code_number(), 323);
1691        assert_eq!(ErrorCode::CancelSlotLeak.code_number(), 324);
1692        assert_eq!(ErrorCode::CancelTimeoutExceeded.code_number(), 325);
1693    }
1694
1695    /// Contract test: topology-sensitive build error codes are stable.
1696    #[test]
1697    fn test_build_topology_error_codes_stable() {
1698        assert_eq!(ErrorCode::BuildCargoWorkspaceInheritance.code_number(), 329);
1699        assert_eq!(
1700            ErrorCode::BuildCargoWorkspaceInheritance.category(),
1701            ErrorCategory::Build
1702        );
1703    }
1704
1705    /// Contract test: artifact-sync-emptiness build error code is stable.
1706    #[test]
1707    fn test_build_artifact_sync_empty_error_code_stable() {
1708        assert_eq!(ErrorCode::BuildArtifactSyncEmpty.code_number(), 326);
1709        assert_eq!(
1710            ErrorCode::BuildArtifactSyncEmpty.category(),
1711            ErrorCategory::Build
1712        );
1713        assert_eq!(ErrorCode::BuildArtifactSyncEmpty.code_string(), "RCH-E326");
1714        // Remediation must name the stale-local-artifact hazard.
1715        assert!(
1716            ErrorCode::BuildArtifactSyncEmpty
1717                .remediation()
1718                .iter()
1719                .any(|step| step.to_lowercase().contains("stale")),
1720            "RCH-E326 remediation must warn about stale local artifacts"
1721        );
1722    }
1723
1724    /// Contract test: the foreign-target artifact build error code is stable.
1725    #[test]
1726    fn test_build_artifact_foreign_target_error_code_stable() {
1727        assert_eq!(ErrorCode::BuildArtifactForeignTarget.code_number(), 327);
1728        assert_eq!(
1729            ErrorCode::BuildArtifactForeignTarget.category(),
1730            ErrorCategory::Build
1731        );
1732        assert_eq!(
1733            ErrorCode::BuildArtifactForeignTarget.code_string(),
1734            "RCH-E327"
1735        );
1736        // Remediation must tell the operator the local tree needs a rebuild.
1737        assert!(
1738            ErrorCode::BuildArtifactForeignTarget
1739                .remediation()
1740                .iter()
1741                .any(|step| step.to_lowercase().contains("rebuild")),
1742            "RCH-E327 remediation must tell the operator to rebuild for this host"
1743        );
1744    }
1745
1746    /// Contract test: dependency preflight error codes are stable.
1747    #[test]
1748    fn test_dependency_preflight_error_codes_stable() {
1749        assert_eq!(ErrorCode::DependencyPreflightMissing.code_number(), 410);
1750        assert_eq!(ErrorCode::DependencyPreflightStale.code_number(), 411);
1751        assert_eq!(ErrorCode::DependencyPreflightUnknown.code_number(), 412);
1752        assert_eq!(
1753            ErrorCode::DependencyPreflightPolicyViolation.code_number(),
1754            413
1755        );
1756        assert_eq!(ErrorCode::DependencyPreflightTimeout.code_number(), 414);
1757    }
1758
1759    /// Contract test: new error codes belong to correct categories.
1760    #[test]
1761    fn test_new_error_codes_correct_categories() {
1762        // Path-dep and closure are in Config range (E001-E099)
1763        assert_eq!(ErrorCode::PathDepCyclic.category(), ErrorCategory::Config);
1764        assert_eq!(
1765            ErrorCode::ClosurePlanFailed.category(),
1766            ErrorCategory::Config
1767        );
1768
1769        // Disk pressure in Worker range (E200-E299)
1770        assert_eq!(
1771            ErrorCode::WorkerDiskPressureCritical.category(),
1772            ErrorCategory::Worker
1773        );
1774        assert_eq!(
1775            ErrorCode::WorkerReclaimProtected.category(),
1776            ErrorCategory::Worker
1777        );
1778
1779        // Process triage in Build range (E300-E399)
1780        assert_eq!(
1781            ErrorCode::ProcessTriageTimeout.category(),
1782            ErrorCategory::Build
1783        );
1784        assert_eq!(
1785            ErrorCode::ProcessTriageInvalidRequest.category(),
1786            ErrorCategory::Build
1787        );
1788
1789        // Cancellation in Build range (E300-E399)
1790        assert_eq!(
1791            ErrorCode::CancelGracefulSent.category(),
1792            ErrorCategory::Build
1793        );
1794        assert_eq!(
1795            ErrorCode::CancelTimeoutExceeded.category(),
1796            ErrorCategory::Build
1797        );
1798
1799        // Dependency preflight failures happen after sync and belong to Transfer.
1800        assert_eq!(
1801            ErrorCode::DependencyPreflightMissing.category(),
1802            ErrorCategory::Transfer
1803        );
1804        assert_eq!(
1805            ErrorCode::DependencyPreflightTimeout.category(),
1806            ErrorCategory::Transfer
1807        );
1808    }
1809
1810    /// Contract test: all new error codes have doc URLs pointing to correct sections.
1811    #[test]
1812    fn test_new_error_codes_doc_urls() {
1813        assert_eq!(
1814            ErrorCode::PathDepCyclic.doc_url(),
1815            Some("https://rch.dev/docs/path-deps")
1816        );
1817        assert_eq!(
1818            ErrorCode::ClosureFailOpen.doc_url(),
1819            Some("https://rch.dev/docs/dependency-closure")
1820        );
1821        assert_eq!(
1822            ErrorCode::WorkerDiskPressureCritical.doc_url(),
1823            Some("https://rch.dev/docs/disk-pressure")
1824        );
1825        assert_eq!(
1826            ErrorCode::ProcessTriageTimeout.doc_url(),
1827            Some("https://rch.dev/docs/process-triage")
1828        );
1829        assert_eq!(
1830            ErrorCode::CancelGracefulSent.doc_url(),
1831            Some("https://rch.dev/docs/cancellation")
1832        );
1833        assert_eq!(
1834            ErrorCode::DependencyPreflightMissing.doc_url(),
1835            Some("https://rch.dev/docs/sync")
1836        );
1837    }
1838
1839    /// Contract test: new error codes roundtrip through JSON serialization.
1840    #[test]
1841    fn test_new_error_codes_json_roundtrip() {
1842        let new_codes = [
1843            ErrorCode::PathDepManifestParseFailed,
1844            ErrorCode::ClosurePlanFailed,
1845            ErrorCode::WorkerDiskPressureCritical,
1846            ErrorCode::ProcessTriageAdapterUnavailable,
1847            ErrorCode::DependencyPreflightMissing,
1848        ];
1849
1850        for code in new_codes {
1851            let json = serde_json::to_string(&code).expect("serialization failed");
1852            let parsed: ErrorCode = serde_json::from_str(&json).expect("deserialization failed");
1853            assert_eq!(parsed, code, "Roundtrip failed for {:?}", code);
1854
1855            // Entry should also roundtrip
1856            let entry = code.entry();
1857            let entry_json = serde_json::to_string(&entry).expect("entry serialization failed");
1858            let parsed_entry: ErrorEntry =
1859                serde_json::from_str(&entry_json).expect("entry deserialization failed");
1860            assert_eq!(parsed_entry.code, code.code_string());
1861        }
1862    }
1863
1864    /// Contract test: total error code count is as expected (guards against accidental removal).
1865    #[test]
1866    fn test_total_error_code_count() {
1867        let total = ErrorCode::all().len();
1868        // 10 config + 12 path-dep/closure + 10 network + 10 worker + 8 storage
1869        // + 10 build + 8 process-triage + 6 cancellation + 15 transfer + 10 internal = 99
1870        assert!(
1871            total >= 100,
1872            "Expected at least 100 error codes (was {}); did a code get accidentally removed?",
1873            total,
1874        );
1875    }
1876}