Skip to main content

sqlite_graphrag/
errors.rs

1//! Library-wide error type.
2//!
3//! `AppError` is the single error type returned by every public API in the
4//! crate. Each variant maps to a deterministic exit code through
5//! `AppError::exit_code`, which the binary propagates to the shell on
6//! failure. See the README for the full exit code contract.
7
8use crate::i18n::{current, Language};
9use thiserror::Error;
10
11/// Unified error type for all CLI and library operations.
12///
13/// Each variant corresponds to a distinct failure category. The
14/// [`AppError::exit_code`] method converts a variant into a stable numeric
15/// code so that shell callers and LLM agents can route on it.
16///
17/// # SemVer Policy
18///
19/// This enum is `#[non_exhaustive]`. New variants may be added in minor
20/// releases without breaking downstream match arms (use a wildcard `_`).
21#[derive(Error, Debug)]
22#[non_exhaustive]
23pub enum AppError {
24    /// Input failed schema, length or format validation. Maps to exit code `1`.
25    ///
26    /// This variant groups multiple validation failure causes. Callers that need
27    /// programmatic retry decisions should use [`AppError::is_retryable`] instead
28    /// of parsing the message string.
29    #[error("validation error: {0}")]
30    Validation(String),
31
32    /// External binary required for operation was not found in PATH. Maps to exit code `1`.
33    #[error("binary not found: {name} — ensure it is installed and in PATH")]
34    BinaryNotFound { name: String },
35
36    /// Remote service signaled rate limiting; caller should retry with backoff. Maps to exit code `1`.
37    #[error("rate limited: {detail}")]
38    RateLimited { detail: String },
39
40    /// Operation exceeded its time budget. Maps to exit code `1`.
41    #[error("timeout after {duration_secs}s: {operation}")]
42    Timeout {
43        operation: String,
44        duration_secs: u64,
45    },
46
47    /// A memory or entity with the same `(namespace, name)` already exists. Maps to exit code `9`.
48    #[error("duplicate detected: {0}")]
49    Duplicate(String),
50
51    /// Optimistic update lost the race because `updated_at` changed. Maps to exit code `3`.
52    #[error("conflict: {0}")]
53    Conflict(String),
54
55    /// The requested record does not exist or was soft-deleted. Maps to exit code `4`.
56    #[error("not found: {0}")]
57    NotFound(String),
58
59    /// Namespace could not be resolved from flag, environment or markers. Maps to exit code `5`.
60    #[error("namespace not resolved: {0}")]
61    NamespaceError(String),
62
63    /// Payload exceeded one of the configured body, name or batch limits. Maps to exit code `6`.
64    #[error("limit exceeded: {0}")]
65    LimitExceeded(String),
66
67    /// Low-level SQLite error propagated from `rusqlite`. Maps to exit code `10`.
68    #[error("database error: {0}")]
69    Database(#[from] rusqlite::Error),
70
71    /// Embedding generation via `fastembed` failed or produced the wrong shape. Maps to exit code `11`.
72    #[error("embedding error: {0}")]
73    Embedding(String),
74
75    /// The `sqlite-vec` extension could not load or register its virtual table. Maps to exit code `12`.
76    #[error("sqlite-vec extension failed: {0}")]
77    VecExtension(String),
78
79    /// SQLite returned `SQLITE_BUSY` after exhausting retries. Maps to exit code `15` (was `13` before v2.0.0; relocated to free `13` for BatchPartialFailure per PRD).
80    #[error("database busy: {0}")]
81    DbBusy(String),
82
83    /// Batch operation failed partially — N of M items failed. Maps to exit code `13` (PRD 1822).
84    ///
85    /// Reserved for use in `import`, `reindex` and batch stdin (BLOCK 3/4). Variant present
86    /// since v2.0.0 even if call-sites do not yet exist — stable exit code mapping.
87    #[error("batch partial failure: {failed} of {total} items failed")]
88    BatchPartialFailure { total: usize, failed: usize },
89
90    /// Filesystem I/O error while reading or writing the database or cache. Maps to exit code `14`.
91    #[error("IO error: {0}")]
92    Io(#[from] std::io::Error),
93
94    /// Unexpected internal error surfaced through `anyhow`. Maps to exit code `20`.
95    #[error(transparent)]
96    Internal(#[from] anyhow::Error),
97
98    /// JSON serialization or deserialization failure. Maps to exit code `20`.
99    #[error("json error: {0}")]
100    Json(#[from] serde_json::Error),
101
102    /// Another instance is already running and holds the advisory lock. Maps to exit code `75`.
103    ///
104    /// Use `--allow-parallel` to skip the lock or `--wait-lock SECONDS` to retry.
105    #[error("lock busy: {0}")]
106    LockBusy(String),
107
108    /// All concurrency slots are occupied after the wait timeout. Maps to exit code `75`.
109    ///
110    /// Occurs when [`crate::constants::MAX_CONCURRENT_CLI_INSTANCES`] instances are already
111    /// active and the wait limit [`crate::constants::CLI_LOCK_DEFAULT_WAIT_SECS`] is exhausted.
112    #[error(
113        "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
114         use --max-concurrency or wait for other invocations to finish"
115    )]
116    AllSlotsFull { max: usize, waited_secs: u64 },
117
118    /// A heavy long-running job is already running for this job_type/namespace
119    /// pair. Maps to exit code `75` (the same `EX_TEMPFAIL` code used by the
120    /// CLI semaphore).
121    ///
122    /// G28-B (v1.0.68): ensures at most one `enrich`, `ingest --mode
123    /// claude-code`, or `ingest --mode codex` runs at a time per namespace.
124    /// Use `--wait-job-singleton <SECONDS>` (per-command) to poll until the
125    /// other invocation finishes.
126    #[error(
127        "job {job_type} for namespace '{namespace}' is already running (exit 75); \
128         wait for it to finish or pass --wait-job-singleton <SECONDS>"
129    )]
130    JobSingletonLocked { job_type: String, namespace: String },
131
132    /// Available memory is below the minimum required to load the model. Maps to exit code `77`.
133    ///
134    /// Returned when `sysinfo` reports available memory below
135    /// [`crate::constants::MIN_AVAILABLE_MEMORY_MB`] MiB before starting the ONNX model load.
136    #[error(
137        "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
138         to load the model; abort other loads or use --skip-memory-guard (exit 77)"
139    )]
140    LowMemory { available_mb: u64, required_mb: u64 },
141}
142
143impl AppError {
144    /// Returns the deterministic process exit code for this error variant.
145    ///
146    /// The codes follow the contract documented in the README: `1` for
147    /// validation, `9` for duplicates (moved from `2` in v1.0.52), `3` for conflicts, `4` for missing
148    /// records, `5` for namespace errors, `6` for limit violations, `10`–`14`
149    /// for infrastructure failures, `13` for BatchPartialFailure (PRD 1822),
150    /// `15` for DbBusy (migrated from `13` in v2.0.0), `20` for internal errors,
151    /// `75` (EX_TEMPFAIL) when the advisory CLI lock is held or all concurrency
152    /// slots are exhausted, and `77` when available memory is insufficient to
153    /// load the embedding model.
154    ///
155    /// # Examples
156    ///
157    /// ```
158    /// use sqlite_graphrag::errors::AppError;
159    ///
160    /// assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
161    /// assert_eq!(AppError::Duplicate("ns/mem".into()).exit_code(), 9);
162    /// assert_eq!(AppError::Conflict("ts changed".into()).exit_code(), 3);
163    /// assert_eq!(AppError::NotFound("id 42".into()).exit_code(), 4);
164    /// assert_eq!(AppError::NamespaceError("no marker".into()).exit_code(), 5);
165    /// assert_eq!(AppError::LimitExceeded("body too large".into()).exit_code(), 6);
166    /// assert_eq!(AppError::Embedding("wrong dim".into()).exit_code(), 11);
167    /// assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
168    /// assert_eq!(AppError::LockBusy("another instance".into()).exit_code(), 75);
169    /// ```
170    #[inline]
171    #[must_use]
172    pub fn exit_code(&self) -> i32 {
173        match self {
174            Self::Validation(_) => 1,
175            Self::BinaryNotFound { .. } => 1,
176            Self::RateLimited { .. } => 1,
177            Self::Timeout { .. } => 1,
178            Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
179            Self::Conflict(_) => 3,
180            Self::NotFound(_) => 4,
181            Self::NamespaceError(_) => 5,
182            Self::LimitExceeded(_) => 6,
183            Self::Database(_) => 10,
184            Self::Embedding(_) => 11,
185            Self::VecExtension(_) => 12,
186            Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
187            Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
188            Self::Io(_) => 14,
189            Self::Internal(_) => 20,
190            Self::Json(_) => 20,
191            Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
192            Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
193            Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
194            Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
195        }
196    }
197
198    /// Returns `true` when the error is transient and the operation may
199    /// succeed on retry with backoff.
200    ///
201    /// # Examples
202    ///
203    /// ```
204    /// use sqlite_graphrag::errors::AppError;
205    ///
206    /// assert!(AppError::DbBusy("busy".into()).is_retryable());
207    /// assert!(AppError::LockBusy("held".into()).is_retryable());
208    /// assert!(!AppError::NotFound("x".into()).is_retryable());
209    /// assert!(!AppError::Validation("bad".into()).is_retryable());
210    /// ```
211    #[inline]
212    #[must_use]
213    pub fn is_retryable(&self) -> bool {
214        matches!(
215            self,
216            Self::DbBusy(_)
217                | Self::LockBusy(_)
218                | Self::AllSlotsFull { .. }
219                | Self::JobSingletonLocked { .. }
220                | Self::LowMemory { .. }
221                | Self::RateLimited { .. }
222                | Self::Timeout { .. }
223        )
224    }
225
226    /// Returns `true` when the error is permanent and must NOT be retried.
227    ///
228    /// Complement to [`Self::is_retryable`]. Errors not classified by either
229    /// method (e.g. `Database`, `Io`, `Internal`) are ambiguous — the caller
230    /// decides based on context.
231    ///
232    /// # Examples
233    ///
234    /// ```
235    /// use sqlite_graphrag::errors::AppError;
236    ///
237    /// assert!(AppError::Validation("bad".into()).is_permanent());
238    /// assert!(!AppError::DbBusy("busy".into()).is_permanent());
239    /// ```
240    #[inline]
241    #[must_use]
242    pub fn is_permanent(&self) -> bool {
243        matches!(
244            self,
245            Self::Validation(_)
246                | Self::BinaryNotFound { .. }
247                | Self::Duplicate(_)
248                | Self::NotFound(_)
249                | Self::NamespaceError(_)
250                | Self::LimitExceeded(_)
251                | Self::VecExtension(_)
252        )
253    }
254
255    /// Returns the localized error message in the active language (`--lang` / `SQLITE_GRAPHRAG_LANG`).
256    ///
257    /// In English the text is identical to the `Display` generated by thiserror.
258    /// In Portuguese the prefixes and messages are translated to PT-BR.
259    pub fn localized_message(&self) -> String {
260        self.localized_message_for(current())
261    }
262
263    /// Returns the localized message for the explicitly provided language.
264    /// Useful in tests that cannot depend on the global `OnceLock`.
265    ///
266    /// # Examples
267    ///
268    /// ```
269    /// use sqlite_graphrag::errors::AppError;
270    /// use sqlite_graphrag::i18n::Language;
271    ///
272    /// let err = AppError::NotFound("mem-xyz".into());
273    ///
274    /// let en = err.localized_message_for(Language::English);
275    /// assert!(en.contains("not found"));
276    ///
277    /// let pt = err.localized_message_for(Language::Portuguese);
278    /// assert!(pt.contains("n\u{e3}o encontrado"));
279    /// ```
280    pub fn localized_message_for(&self, lang: Language) -> String {
281        match lang {
282            Language::English => self.to_string(),
283            Language::Portuguese => self.to_string_pt(),
284        }
285    }
286
287    fn to_string_pt(&self) -> String {
288        use crate::i18n::validation::app_error_pt as pt;
289        match self {
290            Self::Validation(msg) => pt::validation(msg),
291            Self::BinaryNotFound { name } => pt::binary_not_found(name),
292            Self::RateLimited { detail } => pt::rate_limited(detail),
293            Self::Timeout {
294                operation,
295                duration_secs,
296            } => pt::timeout(operation, *duration_secs),
297            Self::Duplicate(msg) => pt::duplicate(msg),
298            Self::Conflict(msg) => pt::conflict(msg),
299            Self::NotFound(msg) => pt::not_found(msg),
300            Self::NamespaceError(msg) => pt::namespace_error(msg),
301            Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
302            Self::Database(e) => pt::database(&e.to_string()),
303            Self::Embedding(msg) => pt::embedding(msg),
304            Self::VecExtension(msg) => pt::vec_extension(msg),
305            Self::DbBusy(msg) => pt::db_busy(msg),
306            Self::BatchPartialFailure { total, failed } => {
307                pt::batch_partial_failure(*total, *failed)
308            }
309            Self::Io(e) => pt::io(&e.to_string()),
310            Self::Internal(e) => pt::internal(&e.to_string()),
311            Self::Json(e) => pt::json(&e.to_string()),
312            Self::LockBusy(msg) => pt::lock_busy(msg),
313            Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
314            Self::JobSingletonLocked {
315                job_type,
316                namespace,
317            } => pt::job_singleton_locked(job_type, namespace),
318            Self::LowMemory {
319                available_mb,
320                required_mb,
321            } => pt::low_memory(*available_mb, *required_mb),
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use std::io;
330
331    #[test]
332    fn exit_code_validation_returns_1() {
333        assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
334    }
335
336    #[test]
337    fn exit_code_duplicate_returns_9() {
338        assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
339    }
340
341    #[test]
342    fn exit_code_conflict_returns_3() {
343        assert_eq!(
344            AppError::Conflict("updated_at changed".into()).exit_code(),
345            3
346        );
347    }
348
349    #[test]
350    fn exit_code_not_found_returns_4() {
351        assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
352    }
353
354    #[test]
355    fn exit_code_namespace_error_returns_5() {
356        assert_eq!(
357            AppError::NamespaceError("not resolved".into()).exit_code(),
358            5
359        );
360    }
361
362    #[test]
363    fn exit_code_limit_exceeded_returns_6() {
364        assert_eq!(
365            AppError::LimitExceeded("body too large".into()).exit_code(),
366            6
367        );
368    }
369
370    #[test]
371    fn exit_code_embedding_returns_11() {
372        assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
373    }
374
375    #[test]
376    fn exit_code_vec_extension_returns_12() {
377        assert_eq!(
378            AppError::VecExtension("extension did not load".into()).exit_code(),
379            12
380        );
381    }
382
383    #[test]
384    fn exit_code_db_busy_returns_15() {
385        assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
386    }
387
388    #[test]
389    fn exit_code_batch_partial_failure_returns_13() {
390        assert_eq!(
391            AppError::BatchPartialFailure {
392                total: 10,
393                failed: 3
394            }
395            .exit_code(),
396            13
397        );
398    }
399
400    #[test]
401    fn display_batch_partial_failure_includes_counts() {
402        let err = AppError::BatchPartialFailure {
403            total: 50,
404            failed: 7,
405        };
406        let msg = err.to_string();
407        assert!(msg.contains("7"));
408        assert!(msg.contains("50"));
409        // to_string() uses the English #[error] attr; PT is in localized_message_for
410        assert!(msg.contains("batch partial failure"));
411    }
412
413    #[test]
414    fn exit_code_io_returns_14() {
415        let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
416        assert_eq!(AppError::Io(io_err).exit_code(), 14);
417    }
418
419    #[test]
420    fn exit_code_internal_returns_20() {
421        let anyhow_err = anyhow::anyhow!("unexpected internal error");
422        assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
423    }
424
425    #[test]
426    fn exit_code_json_returns_20() {
427        let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
428        assert_eq!(AppError::Json(json_err).exit_code(), 20);
429    }
430
431    #[test]
432    fn exit_code_lock_busy_returns_75() {
433        assert_eq!(
434            AppError::LockBusy("another active instance".into()).exit_code(),
435            75
436        );
437    }
438
439    #[test]
440    fn display_validation_includes_message() {
441        let err = AppError::Validation("invalid id".into());
442        assert!(err.to_string().contains("invalid id"));
443        assert!(err.to_string().contains("validation error"));
444    }
445
446    #[test]
447    fn display_duplicate_includes_message() {
448        let err = AppError::Duplicate("proj/mem".into());
449        assert!(err.to_string().contains("proj/mem"));
450        assert!(err.to_string().contains("duplicate detected"));
451    }
452
453    #[test]
454    fn display_not_found_includes_message() {
455        let err = AppError::NotFound("id 42".into());
456        assert!(err.to_string().contains("id 42"));
457        assert!(err.to_string().contains("not found"));
458    }
459
460    #[test]
461    fn display_embedding_includes_message() {
462        let err = AppError::Embedding("wrong dimension".into());
463        assert!(err.to_string().contains("wrong dimension"));
464        assert!(err.to_string().contains("embedding error"));
465    }
466
467    #[test]
468    fn display_lock_busy_includes_message() {
469        let err = AppError::LockBusy("pid 1234".into());
470        assert!(err.to_string().contains("pid 1234"));
471        assert!(err.to_string().contains("lock busy"));
472    }
473
474    #[test]
475    fn from_io_error_converts_correctly() {
476        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
477        let app_err: AppError = io_err.into();
478        assert_eq!(app_err.exit_code(), 14);
479        assert!(app_err.to_string().contains("IO error"));
480    }
481
482    #[test]
483    fn from_anyhow_error_converts_correctly() {
484        let anyhow_err = anyhow::anyhow!("internal detail");
485        let app_err: AppError = anyhow_err.into();
486        assert_eq!(app_err.exit_code(), 20);
487        assert!(app_err.to_string().contains("internal detail"));
488    }
489
490    #[test]
491    fn from_serde_json_error_converts_correctly() {
492        let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
493        let app_err: AppError = json_err.into();
494        assert_eq!(app_err.exit_code(), 20);
495        assert!(app_err.to_string().contains("json error"));
496    }
497
498    #[test]
499    fn exit_code_lock_busy_matches_constant() {
500        assert_eq!(
501            AppError::LockBusy("test".into()).exit_code(),
502            crate::constants::CLI_LOCK_EXIT_CODE
503        );
504    }
505
506    #[test]
507    fn localized_message_en_equals_to_string() {
508        let err = AppError::NotFound("mem-x".into());
509        assert_eq!(
510            err.localized_message_for(crate::i18n::Language::English),
511            err.to_string()
512        );
513    }
514
515    // Detailed Portuguese-specific assertions live in `src/i18n.rs`
516    // (the bilingual module). Here we only verify that delegation is wired
517    // correctly, without embedding PT strings in this English-only file.
518
519    #[test]
520    fn localized_message_pt_differs_from_en() {
521        let err = AppError::NotFound("mem-x".into());
522        let en = err.localized_message_for(crate::i18n::Language::English);
523        let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
524        assert_ne!(en, pt, "PT and EN must produce distinct messages");
525        assert!(pt.contains("mem-x"), "PT must include the variant payload");
526    }
527
528    #[test]
529    fn localized_message_pt_delegates_to_app_error_pt_helper() {
530        use crate::i18n::validation::app_error_pt as pt;
531
532        let cases: Vec<(AppError, String)> = vec![
533            (AppError::Validation("x".into()), pt::validation("x")),
534            (AppError::Duplicate("x".into()), pt::duplicate("x")),
535            (AppError::Conflict("x".into()), pt::conflict("x")),
536            (AppError::NotFound("x".into()), pt::not_found("x")),
537            (
538                AppError::NamespaceError("x".into()),
539                pt::namespace_error("x"),
540            ),
541            (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
542            (AppError::Embedding("x".into()), pt::embedding("x")),
543            (AppError::VecExtension("x".into()), pt::vec_extension("x")),
544            (AppError::DbBusy("x".into()), pt::db_busy("x")),
545            (
546                AppError::BatchPartialFailure {
547                    total: 10,
548                    failed: 3,
549                },
550                pt::batch_partial_failure(10, 3),
551            ),
552            (AppError::LockBusy("x".into()), pt::lock_busy("x")),
553            (
554                AppError::AllSlotsFull {
555                    max: 4,
556                    waited_secs: 60,
557                },
558                pt::all_slots_full(4, 60),
559            ),
560            (
561                AppError::LowMemory {
562                    available_mb: 100,
563                    required_mb: 500,
564                },
565                pt::low_memory(100, 500),
566            ),
567            (
568                AppError::BinaryNotFound {
569                    name: "claude".into(),
570                },
571                pt::binary_not_found("claude"),
572            ),
573            (
574                AppError::RateLimited {
575                    detail: "429".into(),
576                },
577                pt::rate_limited("429"),
578            ),
579            (
580                AppError::Timeout {
581                    operation: "op".into(),
582                    duration_secs: 30,
583                },
584                pt::timeout("op", 30),
585            ),
586        ];
587
588        for (err, expected) in cases {
589            let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
590            assert_eq!(actual, expected, "delegation mismatch");
591        }
592    }
593
594    #[test]
595    fn is_retryable_transient_errors() {
596        assert!(AppError::DbBusy("x".into()).is_retryable());
597        assert!(AppError::LockBusy("x".into()).is_retryable());
598        assert!(AppError::AllSlotsFull {
599            max: 4,
600            waited_secs: 60
601        }
602        .is_retryable());
603        assert!(AppError::LowMemory {
604            available_mb: 100,
605            required_mb: 500
606        }
607        .is_retryable());
608        assert!(AppError::RateLimited {
609            detail: "429".into()
610        }
611        .is_retryable());
612        assert!(AppError::Timeout {
613            operation: "op".into(),
614            duration_secs: 30
615        }
616        .is_retryable());
617    }
618
619    #[test]
620    fn is_retryable_permanent_errors() {
621        assert!(!AppError::Validation("x".into()).is_retryable());
622        assert!(!AppError::NotFound("x".into()).is_retryable());
623        assert!(!AppError::Duplicate("x".into()).is_retryable());
624        assert!(!AppError::Conflict("x".into()).is_retryable());
625        assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
626    }
627
628    #[test]
629    fn exit_code_new_variants() {
630        assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
631        assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
632        assert_eq!(
633            AppError::Timeout {
634                operation: "x".into(),
635                duration_secs: 5
636            }
637            .exit_code(),
638            1
639        );
640    }
641
642    #[test]
643    fn app_error_size_does_not_exceed_budget() {
644        let size = std::mem::size_of::<AppError>();
645        assert!(
646            size <= 128,
647            "AppError is {size} bytes — exceeds 128-byte budget; \
648             consider boxing large variants to reduce memcpy cost in Result propagation"
649        );
650    }
651}