Skip to main content

khive_runtime/
error.rs

1//! Runtime error types.
2
3use std::fmt;
4use std::time::Duration;
5
6use thiserror::Error;
7use uuid::Uuid;
8
9/// Convenience alias for `Result<T, RuntimeError>`.
10pub type RuntimeResult<T> = Result<T, RuntimeError>;
11
12/// What this dispatch boundary can establish about its own domain operation.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
14#[serde(rename_all = "snake_case")]
15pub enum DomainDisposition {
16    /// The handler returned its canonical success value before a later failure.
17    Committed,
18    /// The operation was refused before its handler was invoked.
19    NotCommitted,
20    /// The handler failed, so its domain effects are not established here.
21    Unknown,
22}
23
24impl DomainDisposition {
25    /// Stable wire spelling, shared by request and daemon envelopes.
26    pub const fn as_str(self) -> &'static str {
27        match self {
28            Self::Committed => "committed",
29            Self::NotCommitted => "not_committed",
30            Self::Unknown => "unknown",
31        }
32    }
33}
34
35/// An unchanged runtime error with provenance from one dispatch boundary.
36#[derive(Debug, Error)]
37#[error("{source}")]
38pub struct DispatchError {
39    #[source]
40    source: RuntimeError,
41    disposition: DomainDisposition,
42}
43
44impl DispatchError {
45    pub(crate) fn new(source: RuntimeError, disposition: DomainDisposition) -> Self {
46        Self {
47            source,
48            disposition,
49        }
50    }
51
52    pub(crate) fn before_dispatch(source: RuntimeError) -> Self {
53        Self::new(source, DomainDisposition::NotCommitted)
54    }
55
56    pub(crate) fn after_handler(source: RuntimeError, domain_succeeded: bool) -> Self {
57        Self::new(
58            source,
59            if domain_succeeded {
60                DomainDisposition::Committed
61            } else {
62                DomainDisposition::Unknown
63            },
64        )
65    }
66
67    /// Borrow the original error without changing its classification.
68    pub fn source(&self) -> &RuntimeError {
69        &self.source
70    }
71
72    /// Read provenance belonging to this dispatch, not to a nested operation.
73    pub fn disposition(&self) -> DomainDisposition {
74        self.disposition
75    }
76
77    /// Separate the original error from its dispatch provenance.
78    pub fn into_parts(self) -> (RuntimeError, DomainDisposition) {
79        (self.source, self.disposition)
80    }
81
82    /// Compatibility path for callers that consume runtime errors directly.
83    pub fn into_source(self) -> RuntimeError {
84        self.source
85    }
86}
87
88/// Typed cause of a post-dispatch audit or durable-receipt failure.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum AuditObligationReason {
91    /// The audit batch reported one of its closed terminal outcomes.
92    Terminal(crate::audit_batch::AuditTerminalReason),
93    /// Receipt setup failed before submission produced a terminal outcome.
94    GitDigestReceiptFailure,
95}
96
97/// An obligation failure preserves its typed cause and any storage source.
98#[derive(Debug, Error)]
99#[error("{message}")]
100pub struct AuditObligationFailure {
101    /// Machine-readable terminal or receipt-setup cause.
102    pub reason: AuditObligationReason,
103    /// Domain verb whose post-dispatch obligation failed.
104    pub verb: String,
105    /// Stable caller-visible explanation; excludes the canonical domain value.
106    pub message: String,
107    #[source]
108    source: Option<khive_storage::StorageError>,
109}
110
111impl AuditObligationFailure {
112    /// Preserve a terminal audit outcome without parsing its display text.
113    pub fn new(verb: impl Into<String>, reason: crate::audit_batch::AuditTerminalReason) -> Self {
114        let verb = verb.into();
115        Self {
116            message: format!("audit obligation commit failed for verb {verb:?}: {reason:?}"),
117            verb,
118            reason: AuditObligationReason::Terminal(reason),
119            source: None,
120        }
121    }
122
123    pub(crate) fn from_store(verb: &str, source: khive_storage::StorageError) -> Self {
124        let mut failure = Self::new(verb, crate::audit_batch::AuditTerminalReason::StoreFailure);
125        failure.message = format!("audit obligation commit failed for verb {verb:?}: {source}");
126        failure.source = Some(source);
127        failure
128    }
129
130    pub(crate) fn git_digest_receipt(branch: &'static str) -> Self {
131        Self {
132            reason: AuditObligationReason::GitDigestReceiptFailure,
133            verb: "git.digest".into(),
134            message: format!(
135                "git_digest_receipt_persist_failed: {branch}; git.digest writes may have committed, but no durable success receipt was confirmed; inspect ingest state before retrying"
136            ),
137            source: None,
138        }
139    }
140
141    /// Closed wire-code mapping: a new batch terminal reason requires an arm.
142    pub const fn wire_code(&self) -> &'static str {
143        use crate::audit_batch::AuditTerminalReason;
144        match self.reason {
145            AuditObligationReason::Terminal(reason) => match reason {
146                AuditTerminalReason::PreflightRejected => "preflight_rejected",
147                AuditTerminalReason::AdmissionClosed => "admission_closed",
148                AuditTerminalReason::QueueAdmissionExhausted => "queue_admission_exhausted",
149                AuditTerminalReason::AdmissionDeadlineExpired => "admission_deadline_expired",
150                AuditTerminalReason::ResolutionDeadlineExpired => "resolution_deadline_expired",
151                AuditTerminalReason::IdentityConflict => "identity_conflict",
152                AuditTerminalReason::StoreFailure => "store_failure",
153                AuditTerminalReason::RetryExhausted => "retry_exhausted",
154                AuditTerminalReason::IdempotencyUnsupported => "idempotency_unsupported",
155                AuditTerminalReason::DriverPanicked => "driver_panicked",
156                AuditTerminalReason::DriverCancelled => "driver_cancelled",
157                AuditTerminalReason::DriverJoinLost => "driver_join_lost",
158                AuditTerminalReason::DriverExitedInconsistent => "driver_exited_inconsistent",
159                AuditTerminalReason::DriverAppendAbandoned => "driver_append_abandoned",
160                AuditTerminalReason::StoreWedged => "store_wedged",
161            },
162            AuditObligationReason::GitDigestReceiptFailure => "git_digest_receipt_failure",
163        }
164    }
165}
166
167/// Stable ADR-135 F6 stage and wire code for a finite-wait pooled writer
168/// checkout that expires before SQLite executes.
169pub const WRITER_POOL_CHECKOUT_TIMEOUT_STAGE: &str = "writer_pool_checkout_timeout";
170
171/// Stable ADR-194 WAL capacity stages, shared with the SQLite error source.
172pub use khive_db::{SQLITE_WAL_CAPACITY_REFUSED_STAGE, SQLITE_WAL_CAPACITY_UNAVAILABLE_STAGE};
173
174/// Stable wire code/stage for a bounded write-queue enqueue that never
175/// accepted the request within its configured deadline (#1382, #1643).
176pub const WRITER_QUEUE_SATURATED_STAGE: &str = "writer_queue_saturated";
177
178/// Stable wire code/stage for SQLite write-lock contention after the writer
179/// queue accepted a request but before its operation closure ran.
180pub const WRITER_TASK_BEGIN_BUSY_STAGE: &str = "writer_task_begin_busy";
181
182/// Stable wire code/stage for an ordinary writer request whose operation or
183/// COMMIT failed after the task proved its transaction was rolled back.
184pub const WRITER_TASK_REQUEST_FAILED_STAGE: &str = "writer_task_request_failed";
185
186/// Stable wire code/stage for a request reported by a permanently retired
187/// writer seam.
188pub const WRITER_TASK_TERMINATED_STAGE: &str = "writer_task_terminated";
189
190/// Stable wire code/stage for a bounded storage-admission wait (a
191/// reader/writer handle slot or a pooled reader checkout) that elapsed
192/// before anything was acquired. The operation never started, so the
193/// failure is safe to retry.
194pub const STORAGE_ADMISSION_TIMEOUT_STAGE: &str = "storage_admission_timeout";
195
196/// Stable wire code/stage for a cached read-only transaction proactively
197/// rolled back after pinning a WAL snapshot past the configured
198/// `read_tx_max_age` bound (#1846). Always safe to retry: the eviction only
199/// ever fires on a read-only handle, so no side effect can exist for a
200/// retry to duplicate. Shared by both
201/// [`khive_storage::StorageError::ReadTransactionAgeEvicted`] (clean
202/// rollback) and
203/// [`khive_storage::StorageError::ReadTransactionAgeEvictionCleanupFailed`]
204/// (rollback denied or failed outright) — the rendered `message` field
205/// distinguishes the two; retry behavior is identical for both.
206pub const READ_TX_AGE_EVICTED_STAGE: &str = "read_tx_age_evicted";
207
208/// Stable ADR-131:251 `scope` discriminator carried on a
209/// [`WRITER_QUEUE_SATURATED_STAGE`] failure — distinguishes write-queue
210/// admission saturation from other `unavailable` failure kinds that share
211/// the same `retryable: true` shape but are not bounded by the admission
212/// deadline (e.g. [`WRITER_POOL_CHECKOUT_TIMEOUT_STAGE`], which has no
213/// ADR-131-defined scope and carries `None`).
214pub const WRITER_ADMISSION_SCOPE: &str = "writer_admission";
215
216/// Structured context for a pre-execution admission failure: a finite-wait
217/// pooled writer checkout timeout, a bounded write-queue enqueue timeout, or
218/// a bounded storage-admission wait (reader/writer handle slot or pooled
219/// reader checkout). All happen before SQLite executes the request, so all
220/// are safe to classify as retryable — the request was never accepted, let
221/// alone started.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct AdmissionFailureContext {
224    /// Stable wire stage/code: one of [`WRITER_POOL_CHECKOUT_TIMEOUT_STAGE`],
225    /// [`WRITER_QUEUE_SATURATED_STAGE`], or
226    /// [`STORAGE_ADMISSION_TIMEOUT_STAGE`].
227    pub stage: &'static str,
228    /// The configured deadline that elapsed.
229    pub timeout: Duration,
230    /// Storage capability the request was scoped to, when known. The write
231    /// queue is process-local and unscoped by capability, so this is `None`
232    /// for [`WRITER_QUEUE_SATURATED_STAGE`].
233    pub capability: Option<khive_storage::StorageCapability>,
234    /// Storage operation name, when known.
235    pub operation: Option<String>,
236    /// Database identity supplied by the pool that refused admission, when known.
237    pub pool_identity: Option<String>,
238    /// ADR-131:251 `scope` discriminator. `Some(`[`WRITER_ADMISSION_SCOPE`]`)`
239    /// for [`WRITER_QUEUE_SATURATED_STAGE`]; `None` for
240    /// [`WRITER_POOL_CHECKOUT_TIMEOUT_STAGE`], which ADR-131 does not define a
241    /// scope for.
242    pub scope: Option<&'static str>,
243    /// ADR-131:251 `retry_after_ms` hint — set equal to the admission
244    /// deadline actually applied to the rejected operation, so a retrying
245    /// caller waits at least one full admission window before retrying.
246    /// `Some(timeout_ms)` for [`WRITER_QUEUE_SATURATED_STAGE`]; `None`
247    /// otherwise.
248    pub retry_after_ms: Option<u64>,
249}
250
251/// Structured context for a failure that is proven safe to retry.
252///
253/// This includes the two pre-admission failures above and writer-task BEGIN
254/// contention after queue acceptance but before operation execution. Callers
255/// must inspect `scope`: only `writer_admission` means the queue never
256/// accepted the operation.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct RetryableFailureContext {
259    /// Stable wire stage/code.
260    pub stage: &'static str,
261    /// Configured wait that ended before execution.
262    pub timeout: Duration,
263    /// Storage capability when the failure is capability-scoped.
264    pub capability: Option<khive_storage::StorageCapability>,
265    /// Storage operation when known.
266    pub operation: Option<String>,
267    /// Database identity supplied by the pool that refused admission, when known.
268    pub pool_identity: Option<String>,
269    /// Admission scope only when the queue never accepted the operation.
270    pub scope: Option<&'static str>,
271    /// Server backoff hint when a governing contract defines one.
272    pub retry_after_ms: Option<u64>,
273}
274
275/// Structured writer-request finality recovered from a storage error.
276///
277/// `request_state` answers whether effects are known absent or ambiguous;
278/// `task_terminated` separately answers whether the writer seam remains
279/// usable. `retryable` retains the source error's transient policy and must
280/// not be inferred from rollback finality alone.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub struct WriterTaskFailureContext {
283    /// Stable MCP code/stage for the error family.
284    pub stage: &'static str,
285    /// What the writer can prove about this request's effects.
286    pub request_state: khive_storage::WriterTaskRequestState,
287    /// Whether the writer seam was permanently retired.
288    pub task_terminated: bool,
289    /// Whether the source failure is transient independently of finality.
290    pub retryable: bool,
291}
292
293impl From<AdmissionFailureContext> for RetryableFailureContext {
294    fn from(context: AdmissionFailureContext) -> Self {
295        Self {
296            stage: context.stage,
297            timeout: context.timeout,
298            capability: context.capability,
299            operation: context.operation,
300            pool_identity: context.pool_identity,
301            scope: context.scope,
302            retry_after_ms: context.retry_after_ms,
303        }
304    }
305}
306
307/// Typed disposition for a channel message whose `comm.ingest` write failed.
308///
309/// Every bucket carries the stable [`RuntimeError`] variant name that selected
310/// it. Consumers can therefore make retry/quarantine decisions and report a
311/// reason without inspecting the error's rendered text.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum ChannelIngestFailureClass {
314    /// A pre-execution admission failure that is safe to retry indefinitely.
315    Retryable { reason: &'static str },
316    /// A deterministic policy refusal that should quarantine immediately.
317    Permanent { reason: &'static str },
318    /// An error with no explicit retry or permanent policy classification.
319    Unknown { reason: &'static str },
320}
321
322impl ChannelIngestFailureClass {
323    /// Stable lowercase bucket name persisted on quarantine notifications.
324    pub const fn name(self) -> &'static str {
325        match self {
326            Self::Retryable { .. } => "retryable",
327            Self::Permanent { .. } => "permanent",
328            Self::Unknown { .. } => "unknown",
329        }
330    }
331
332    /// Stable typed error-variant name; never derived from `Display` output.
333    pub const fn reason(self) -> &'static str {
334        match self {
335            Self::Retryable { reason } | Self::Permanent { reason } | Self::Unknown { reason } => {
336                reason
337            }
338        }
339    }
340}
341
342/// Structured context recovered from either a direct SQLite runtime error or
343/// a typed SQLite source preserved inside a storage-driver wrapper.
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct WriterPoolCheckoutTimeoutContext {
346    /// Finite checkout deadline that elapsed before the pooled mutex was
347    /// acquired.
348    pub timeout: Duration,
349    /// Storage capability that wrapped the SQLite source, if one did.
350    pub capability: Option<khive_storage::StorageCapability>,
351    /// Storage operation that wrapped the SQLite source, if one did.
352    pub operation: Option<String>,
353}
354
355/// A guarded edge write (`link`/`link_many`) was refused because one or both
356/// endpoints no longer existed at write time. Names the exact endpoint(s)
357/// missing instead of a generic "source or target" message, and, for a batch
358/// write, which entry in the batch failed first.
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub struct GuardedWriteFailure {
361    /// Index of the failing entry within a `link_many` batch. `None` for the
362    /// singleton `link` path, which has no batch to index into.
363    pub entry_index: Option<usize>,
364    /// The source endpoint id, present only when it is the (or one of the)
365    /// missing endpoint(s).
366    pub missing_source: Option<Uuid>,
367    /// The target endpoint id, present only when it is the (or one of the)
368    /// missing endpoint(s).
369    pub missing_target: Option<Uuid>,
370}
371
372impl fmt::Display for GuardedWriteFailure {
373    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374        let mut missing = Vec::new();
375        if let Some(source) = self.missing_source {
376            missing.push(format!("source {source}"));
377        }
378        if let Some(target) = self.missing_target {
379            missing.push(format!("target {target}"));
380        }
381        let missing = if missing.is_empty() {
382            "endpoint(s)".to_string()
383        } else {
384            missing.join(" and ")
385        };
386        match self.entry_index {
387            Some(index) => write!(
388                f,
389                "batch entry {index}: {missing} no longer exist at write time"
390            ),
391            None => write!(f, "{missing} no longer exist at write time"),
392        }
393    }
394}
395
396impl std::error::Error for GuardedWriteFailure {}
397
398/// A single missing pack dependency.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct MissingPackDependency {
401    pub from: String,
402    pub requires: String,
403}
404
405impl fmt::Display for MissingPackDependency {
406    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407        write!(
408            f,
409            "pack '{}' requires '{}', but '{}' is not in the loaded pack set",
410            self.from, self.requires, self.requires
411        )
412    }
413}
414
415impl std::error::Error for MissingPackDependency {}
416
417/// Multiple missing pack dependencies collected into one error.
418#[derive(Debug, Clone, PartialEq, Eq)]
419pub struct MissingPackDependencies {
420    pub missing: Vec<MissingPackDependency>,
421}
422
423impl fmt::Display for MissingPackDependencies {
424    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
425        let parts: Vec<String> = self.missing.iter().map(ToString::to_string).collect();
426        write!(f, "{}", parts.join("; "))
427    }
428}
429
430impl std::error::Error for MissingPackDependencies {}
431
432/// Circular pack dependency detected during topological sort.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct CircularPackDependency {
435    pub cycle: Vec<String>,
436}
437
438impl fmt::Display for CircularPackDependency {
439    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
440        write!(
441            f,
442            "circular dependency detected among packs: {}",
443            self.cycle.join(" -> ")
444        )
445    }
446}
447
448impl std::error::Error for CircularPackDependency {}
449
450/// All errors produced by the khive-runtime layer.
451///
452/// Where the `GateDenied` audit row of a refused dispatch ended up.
453///
454/// A refusal reaches the caller whatever happens to its audit row; this value
455/// says whether the row the caller could cite exists.
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub enum DenialAuditOutcome {
458    /// The row committed, or an identical row was already present, and
459    /// `audit_event_id` on the refusal names it.
460    Committed,
461    /// The row was built and submitted but did not commit; the audit
462    /// obligation wire code says why.
463    NotCommitted(&'static str),
464    /// No event store is configured, so no row was written.
465    NoStore,
466    /// The refusal comes from a path that writes no audit row (namespace
467    /// authorization, channel policy).
468    NotAudited,
469}
470
471impl DenialAuditOutcome {
472    /// Closed wire spelling: `committed`, `not_committed:<code>`, `no_store`,
473    /// `not_audited`.
474    pub fn wire_code(&self) -> String {
475        match self {
476            Self::Committed => "committed".to_string(),
477            Self::NotCommitted(code) => format!("not_committed:{code}"),
478            Self::NoStore => "no_store".to_string(),
479            Self::NotAudited => "not_audited".to_string(),
480        }
481    }
482}
483
484/// What a refused dispatch can cite: the `GateDenied` audit row's id when
485/// one committed, and where the row ended up otherwise. Boxed on the error
486/// so a refusal does not widen every `Result<_, RuntimeError>` in the tree.
487#[derive(Debug, Clone, PartialEq, Eq)]
488pub struct DenialReceipt {
489    /// The audit row's event id when `audit_outcome` is `Committed`.
490    pub audit_event_id: Option<uuid::Uuid>,
491    /// Whether the row the caller could cite exists.
492    pub audit_outcome: DenialAuditOutcome,
493}
494
495impl DenialReceipt {
496    /// The refusal's path writes no audit row.
497    pub fn not_audited() -> Self {
498        Self {
499            audit_event_id: None,
500            audit_outcome: DenialAuditOutcome::NotAudited,
501        }
502    }
503
504    /// No event store is configured, so no row was written.
505    pub fn no_store() -> Self {
506        Self {
507            audit_event_id: None,
508            audit_outcome: DenialAuditOutcome::NoStore,
509        }
510    }
511}
512
513/// Payload of [`RuntimeError::RefusedWithReceipt`].
514#[derive(Debug, Clone)]
515pub struct ReceiptRefusal {
516    /// Wire `code`, naming which refusal surface produced this.
517    pub code: &'static str,
518    /// The refusal's own sentence, unchanged.
519    pub message: String,
520    /// Id of the durable row the refusal wrote.
521    pub receipt_id: String,
522    /// Why the call was refused, as the receipt records it.
523    pub reason: String,
524    /// Surface-specific structured evidence, an object or `Value::Null`.
525    pub detail: serde_json::Value,
526}
527
528/// Bounded failure evidence; never carries rejected input or a storage error string.
529#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
530#[serde(rename_all = "snake_case")]
531pub enum RefusalRecordingErrorClass {
532    EventStoreUnavailable,
533    EventAppendFailed,
534}
535
536impl RefusalRecordingErrorClass {
537    pub const fn as_str(self) -> &'static str {
538        match self {
539            Self::EventStoreUnavailable => "event_store_unavailable",
540            Self::EventAppendFailed => "event_append_failed",
541        }
542    }
543}
544
545/// Event evidence for one eligible existing target in a refused request.
546/// `Failed` means no confirmed event, not proof that an ambiguous append wrote nothing.
547#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
548#[serde(untagged)]
549pub enum RefusalEventRecording {
550    Recorded {
551        item_index: usize,
552        subject: Uuid,
553        event_id: Uuid,
554    },
555    Failed {
556        item_index: usize,
557        subject: Uuid,
558        error_class: RefusalRecordingErrorClass,
559    },
560}
561
562/// Original refusal and the independent evidence of recording it.
563///
564/// The runtime variant transparently forwards this context's error source, which
565/// deliberately exposes the contained `RuntimeError`, not its `Box` or its own
566/// underlying source. This keeps typed consumers at the original refusal first.
567#[derive(Debug)]
568pub struct RefusalEventContext {
569    pub source: Box<RuntimeError>,
570    pub recordings: Vec<RefusalEventRecording>,
571}
572
573impl std::fmt::Display for RefusalEventContext {
574    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
575        std::fmt::Display::fmt(self.source.as_ref(), formatter)
576    }
577}
578
579impl std::error::Error for RefusalEventContext {
580    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
581        Some(self.source.as_ref())
582    }
583}
584
585/// Repo-anchor facts established before a `git.digest` ingest failed.
586#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
587pub struct ResolutionFacts {
588    pub project_id: Uuid,
589    pub duplicate_anchor_ids: Vec<Uuid>,
590    pub slug_backfilled: bool,
591    pub project_created: bool,
592    pub orphaned_project_id: Option<Uuid>,
593    pub orphaned_note_count: u64,
594}
595
596/// Preserve a resolved anchor's facts while exposing the original typed error.
597#[derive(Debug)]
598pub struct ResolutionFailureContext {
599    pub source: Box<RuntimeError>,
600    pub resolution: ResolutionFacts,
601}
602
603impl std::fmt::Display for ResolutionFailureContext {
604    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605        std::fmt::Display::fmt(self.source.as_ref(), formatter)
606    }
607}
608
609impl std::error::Error for ResolutionFailureContext {
610    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
611        Some(self.source.as_ref())
612    }
613}
614
615/// Variants cover storage, query, validation, namespace isolation, and permission failures.
616/// Callers should match on `InvalidInput` for bad arguments, `NotFound` for missing records,
617/// and `NamespaceMismatch` (reported as not-found) for cross-namespace access attempts.
618#[derive(Debug, Error)]
619pub enum RuntimeError {
620    /// A lint fix stopped, retaining its exact typed cause and confirmed receipts.
621    #[error("{source}")]
622    LintFix {
623        #[source]
624        source: Box<RuntimeError>,
625        detail: Box<crate::graph_lint::LintFixFailureDetail>,
626    },
627
628    /// The domain handler succeeded, but its post-dispatch obligation did not.
629    #[error("{failure}")]
630    AuditObligation {
631        /// Typed reason and source of the obligation failure.
632        #[source]
633        failure: Box<AuditObligationFailure>,
634        /// Exact canonical value held after the domain handler succeeded.
635        domain_result: serde_json::Value,
636    },
637
638    #[error("storage: {0}")]
639    Storage(#[from] khive_storage::StorageError),
640
641    #[error("query: {0}")]
642    Query(#[from] khive_query::QueryError),
643
644    #[error("not found: {0}")]
645    NotFound(String),
646
647    #[error("invalid input: {0}")]
648    InvalidInput(String),
649
650    #[error("invalid input: {0}")]
651    UnknownVerb(String),
652
653    #[error("unconfigured: {0} is not set")]
654    Unconfigured(String),
655
656    #[error("unknown embedding model: {0}")]
657    UnknownModel(String),
658
659    #[error("embedding: {0}")]
660    Embedding(#[from] lattice_embed::EmbedError),
661
662    #[error("ambiguous: {0}")]
663    Ambiguous(String),
664
665    #[error("fusion: {0}")]
666    Fusion(#[from] khive_fusion::FuseError),
667
668    /// `FusionStrategy::Custom { name, .. }` named a strategy no pack has
669    /// registered via `KhiveRuntime::register_fusion_strategy` (ADR-012).
670    /// Fails closed — never falls back to RRF or any other default.
671    #[error("unknown fusion strategy: {0}")]
672    UnknownFusionStrategy(String),
673
674    #[error("internal: {0}")]
675    Internal(String),
676
677    /// An `EventStore` was configured via `with_event_store` but does not
678    /// implement ADR-133's `preflight_event`/`append_events_idempotent`
679    /// pair (`EventStore::supports_idempotent_audit_batch` reports
680    /// `false`). Raised at `build()` time rather than left to fail every
681    /// audited dispatch silently.
682    #[error("audit batch incompatible event store: {0}")]
683    IncompatibleEventStore(String),
684
685    #[error("guarded edge write refused: {0}")]
686    GuardedWriteFailed(GuardedWriteFailure),
687
688    #[error("missing pack dependency: {0}")]
689    MissingPackDependency(MissingPackDependency),
690
691    #[error("missing pack dependencies: {0}")]
692    MissingPackDependencies(MissingPackDependencies),
693
694    #[error("{0}")]
695    CircularPackDependency(CircularPackDependency),
696
697    #[error("pack '{name}' registered twice (indices {first_idx} and {second_idx})")]
698    PackRedeclared {
699        name: String,
700        first_idx: usize,
701        second_idx: usize,
702    },
703
704    /// Two packs declared the same `Visibility::Verb` handler name.
705    /// `Visibility::Subhandler` entries are pack-prefixed and do not
706    /// participate in cross-pack collision checks.
707    #[error(
708        "verb collision: verb {verb:?} declared by both pack {first_pack:?} and pack \
709         {second_pack:?}; rename one handler or use Visibility::Subhandler for internal verbs"
710    )]
711    VerbCollision {
712        verb: String,
713        first_pack: String,
714        second_pack: String,
715    },
716
717    /// A handler advertised a parameter that request parsing owns at the envelope level.
718    #[error(
719        "pack {pack:?} handler {verb:?} declares request-envelope parameter {param:?}; rename the verb argument"
720    )]
721    ReservedEnvelopeParam {
722        pack: String,
723        verb: String,
724        param: String,
725    },
726
727    /// A handler refused the call and wrote a durable receipt naming the refusal.
728    ///
729    /// The durable id rides as its own field. Without it a consumer has to find
730    /// the id inside the refusal sentence with a regular expression, which makes
731    /// the wording of a message part of the contract and breaks silently the
732    /// first time someone rewords it. `message` keeps whatever text the refusal
733    /// already produced, so a reader that does parse it today keeps working.
734    ///
735    /// `reason` is the refusal's own explanation, the same value the receipt
736    /// stores, so a consumer keeping its own command record does not parse the
737    /// sentence for it either. `detail` carries the surface's other structured
738    /// evidence (for exec, the resolved output cap); it must be a JSON object or
739    /// null, and its members are projected beside the fields above without
740    /// overriding them.
741    ///
742    /// Boxed so the refusal's evidence does not widen every `Result` in the
743    /// runtime: the error enum is copied on each `?`, the refusal is rare.
744    #[error("{}", .0.message)]
745    RefusedWithReceipt(Box<ReceiptRefusal>),
746
747    /// Original typed refusal, with recording evidence independent of the refused write.
748    /// Construct through [`Self::with_refusal_events`] so no eligible targets means
749    /// an unchanged, unadorned refusal.
750    #[error(transparent)]
751    RefusedWithEvents { context: RefusalEventContext },
752
753    /// Preserve the original typed failure while carrying facts established
754    /// before a `git.digest` ingest failed.
755    #[error(transparent)]
756    WithResolution { context: ResolutionFailureContext },
757
758    /// Gate denied this verb invocation.
759    ///
760    /// Returned by `VerbRegistry::dispatch` when the configured `Gate` returns
761    /// `GateDecision::Deny`. The pack is never invoked. The `reason` field
762    /// carries the deny message produced by the gate implementation.
763    ///
764    /// `receipt` carries the id of the `GateDenied` audit row when one
765    /// committed, so the caller can cite the refusal, and says whether such
766    /// a row exists.
767    #[error("permission denied for verb {verb:?}: {reason}")]
768    PermissionDenied {
769        verb: String,
770        reason: String,
771        receipt: Box<DenialReceipt>,
772    },
773
774    /// The configured gate could not produce an authorization decision.
775    ///
776    /// This is distinct from [`Self::PermissionDenied`]: the pack or
777    /// intercepted operation is never invoked, but the gate did not answer
778    /// with an explicit denial.
779    #[error("gate unavailable for verb {verb:?}: {reason}")]
780    GateUnavailable { verb: String, reason: String },
781
782    /// A structured [`khive_types::KhiveError`] converted into the runtime
783    /// layer. The full structured error is preserved so callers can inspect
784    /// `kind`, `code`, `details`, and `retry_hint` without information loss.
785    #[error("{0}")]
786    Khive(khive_types::KhiveError),
787
788    /// Record exists but belongs to a different namespace than the provided token.
789    ///
790    /// Externally reported as "not found in this namespace" to avoid leaking
791    /// cross-namespace existence information (timing-oracle mitigation).
792    #[error("not found in this namespace")]
793    NamespaceMismatch { id: uuid::Uuid },
794
795    /// A short-prefix lookup matched more than one record.
796    ///
797    /// `prefix` is the 8+ hex-char prefix supplied by the caller.
798    /// `matches` holds the full UUIDs of all matching records (at most 2 are
799    /// reported to bound the scan — callers must supply the full UUID to disambiguate).
800    #[error("ambiguous prefix {prefix:?}: matches {}", format_uuid_list(matches))]
801    AmbiguousPrefix {
802        prefix: String,
803        matches: Vec<uuid::Uuid>,
804    },
805
806    /// Cross-backend `merge_entity` is unsupported in v1.
807    ///
808    /// Both entities must reside on the same backend. To merge entities on different
809    /// backends, manually export `from_id`, delete it, and re-import on `into_id`'s backend.
810    #[error(
811        "cross-backend merge is not supported: \
812         into_id {into_id} is on backend '{into_backend}', \
813         from_id {from_id} is on backend '{from_backend}'. \
814         Both entities must be on the same backend to merge."
815    )]
816    CrossBackendMergeUnsupported {
817        into_id: uuid::Uuid,
818        from_id: uuid::Uuid,
819        into_backend: String,
820        from_backend: String,
821    },
822
823    // ── Remote Resolution and Content-Hash Verification ──────────────────────
824    /// A `kg://` ref names a remote not declared in `schema.yaml`.
825    #[error("unknown remote: {name:?}")]
826    UnknownRemote { name: String },
827
828    /// A remote cache entry is absent and `--fetch` was not requested.
829    #[error("remote cache missing for remote={remote:?} namespace={namespace:?}")]
830    RemoteCacheMissing { remote: String, namespace: String },
831
832    /// A short ID matches multiple entities in the same namespace or remote cache.
833    #[error("ambiguous id {id:?}: matched {count} records")]
834    AmbiguousId { id: String, count: usize },
835
836    /// A write operation targeted a remote namespace, which is read-only.
837    #[error("cross-namespace write denied: cannot write to remote namespace {namespace:?}")]
838    CrossNamespaceWrite { namespace: String },
839
840    /// Remote cache setup or repair failed. Producers redact the source and
841    /// sanitize diagnostics before constructing this wire-visible error.
842    #[error("remote fetch error for remote={remote:?}: {message}")]
843    RemoteFetchError { remote: String, message: String },
844
845    /// A caller-supplied write budget was exceeded during a Compound apply.
846    ///
847    /// `max_new_entries` is the limit passed by the caller. `attempted_new_entries`
848    /// is `consumed + 1`, i.e. the create that would have exceeded the cap.
849    /// `None` budget never produces this error (unlimited path).
850    #[error(
851        "write budget exceeded: max_new_entries={max_new_entries}, \
852         attempted_new_entries={attempted_new_entries}"
853    )]
854    WriteBudgetExceeded {
855        max_new_entries: u64,
856        attempted_new_entries: u64,
857    },
858
859    /// Write blocked: content matches a secret pattern.
860    ///
861    /// The `SecretMatch` carries the detector name and a masked excerpt
862    /// (`first6...Nchars`). The full candidate is never stored in the error.
863    /// Store a pointer (env-var name, keychain item) rather than the raw value.
864    #[error("write blocked: {0}")]
865    SecretDetected(crate::secret_gate::SecretMatch),
866
867    /// A bounded per-operation deadline elapsed before the operation
868    /// completed (#889). The operation may still be running in the
869    /// background (this is a client-observable timeout, not a cancellation
870    /// signal to the underlying work) — callers should treat this as "no
871    /// answer within budget", not "the operation failed or was rolled back".
872    ///
873    /// Distinct from `#836`'s narrower `ann_ready_timeout_ms`, which bounds
874    /// only a single cold-miss ANN-build wait inside the recall vector leg
875    /// and degrades to an in-band FTS-only result. This variant bounds the
876    /// *entire* operation end-to-end and is surfaced as a typed error so a
877    /// caller under sustained contention gets a fast, clear answer instead
878    /// of hanging until an upstream client-side ceiling (observed at 300s in
879    /// production, #889) fires instead.
880    #[error("{operation} exceeded its {budget_ms}ms deadline (elapsed {elapsed_ms}ms)")]
881    DeadlineExceeded {
882        operation: String,
883        budget_ms: u64,
884        elapsed_ms: u64,
885    },
886}
887
888// Compatibility bridge for concrete backend callers while ADR-071's raw-SQL
889// and backend-handle migrations remain in progress. Classification belongs to
890// the database boundary; the runtime exposes only the storage error envelope.
891impl From<khive_db::SqliteError> for RuntimeError {
892    fn from(error: khive_db::SqliteError) -> Self {
893        Self::Storage(match error {
894            khive_db::SqliteError::RequestReadStopped(error) => error,
895            error => error.into_storage_error(khive_storage::StorageCapability::Sql, "runtime"),
896        })
897    }
898}
899
900impl RuntimeError {
901    /// Attach established repo-anchor facts without changing the source error.
902    pub fn with_resolution(self, resolution: ResolutionFacts) -> Self {
903        Self::WithResolution {
904            context: ResolutionFailureContext {
905                source: Box::new(self),
906                resolution,
907            },
908        }
909    }
910
911    /// Attach only evidence for established, eligible targets. Missing/new targets
912    /// contribute no entry, and an empty batch leaves the error variant unchanged.
913    pub fn with_refusal_events(self, mut recordings: Vec<RefusalEventRecording>) -> Self {
914        if recordings.is_empty() {
915            return self;
916        }
917        match self {
918            Self::RefusedWithEvents { mut context } => {
919                context.recordings.append(&mut recordings);
920                Self::RefusedWithEvents { context }
921            }
922            source => Self::RefusedWithEvents {
923                context: RefusalEventContext {
924                    source: Box::new(source),
925                    recordings,
926                },
927            },
928        }
929    }
930
931    /// Inspect the original typed error without losing its policy/retry
932    /// classification through evidence wrappers.
933    pub fn refusal_source(&self) -> &Self {
934        let mut error = self;
935        loop {
936            error = match error {
937                Self::RefusedWithEvents { context } => &context.source,
938                Self::WithResolution { context } => context.source.as_ref(),
939                Self::LintFix { source, .. } => source.as_ref(),
940                _ => return error,
941            };
942        }
943    }
944
945    /// Whether the immutable stream record policy refused this write.
946    ///
947    /// Only the structured runtime marker establishes this class. Ordinary
948    /// conflicts, caller sequence preconditions, and raw storage failures do
949    /// not acquire a refusal classification from their rendered messages.
950    /// These membership guards refuse before applying the requested domain write.
951    pub fn is_stream_policy_refusal(&self) -> bool {
952        matches!(self.refusal_source(), Self::Khive(error)
953            if error.kind() == khive_types::ErrorKind::Conflict
954                && error.details().and_then(|details| details.get("reason"))
955                    == Some("stream_member"))
956    }
957
958    /// A gate refusal from a path that writes no audit row.
959    pub fn permission_denied(verb: impl Into<String>, reason: impl Into<String>) -> Self {
960        Self::PermissionDenied {
961            verb: verb.into(),
962            reason: reason.into(),
963            receipt: Box::new(DenialReceipt::not_audited()),
964        }
965    }
966
967    /// An [`Internal`](Self::Internal) error whose message is `context: source`.
968    ///
969    /// Storage and SQL failures that have no typed variant of their own use
970    /// this shape, so the rendered text is defined here once.
971    pub fn internal_with_context(context: impl fmt::Display, source: impl fmt::Display) -> Self {
972        Self::Internal(format!("{context}: {source}"))
973    }
974
975    /// Classify a failed inbound channel write without inspecting rendered
976    /// error text.
977    ///
978    /// Existing typed safe-retry failures remain retryable. Secret detection is
979    /// the first deterministic policy refusal and is permanent. `None` from
980    /// [`Self::retryable_failure_context`] is deliberately not interpreted as
981    /// permanent: every other variant starts in the bounded `Unknown` bucket.
982    pub fn channel_ingest_failure_class(&self) -> ChannelIngestFailureClass {
983        let source = self.refusal_source();
984        let reason = source.variant_name();
985        if source.retryable_failure_context().is_some() {
986            ChannelIngestFailureClass::Retryable { reason }
987        } else if matches!(source, Self::SecretDetected(_)) {
988            ChannelIngestFailureClass::Permanent { reason }
989        } else {
990            ChannelIngestFailureClass::Unknown { reason }
991        }
992    }
993
994    /// Stable top-level variant name used by typed policy classifiers.
995    fn variant_name(&self) -> &'static str {
996        match self {
997            Self::AuditObligation { .. } => "AuditObligation",
998            Self::Storage(_) => "Storage",
999            Self::Query(_) => "Query",
1000            Self::NotFound(_) => "NotFound",
1001            Self::InvalidInput(_) => "InvalidInput",
1002            Self::UnknownVerb(_) => "UnknownVerb",
1003            Self::Unconfigured(_) => "Unconfigured",
1004            Self::UnknownModel(_) => "UnknownModel",
1005            Self::Embedding(_) => "Embedding",
1006            Self::Ambiguous(_) => "Ambiguous",
1007            Self::Fusion(_) => "Fusion",
1008            Self::UnknownFusionStrategy(_) => "UnknownFusionStrategy",
1009            Self::Internal(_) => "Internal",
1010            Self::GuardedWriteFailed(_) => "GuardedWriteFailed",
1011            Self::MissingPackDependency(_) => "MissingPackDependency",
1012            Self::MissingPackDependencies(_) => "MissingPackDependencies",
1013            Self::CircularPackDependency(_) => "CircularPackDependency",
1014            Self::PackRedeclared { .. } => "PackRedeclared",
1015            Self::VerbCollision { .. } => "VerbCollision",
1016            Self::ReservedEnvelopeParam { .. } => "ReservedEnvelopeParam",
1017            Self::PermissionDenied { .. } => "PermissionDenied",
1018            Self::GateUnavailable { .. } => "GateUnavailable",
1019            Self::Khive(_) => "Khive",
1020            Self::NamespaceMismatch { .. } => "NamespaceMismatch",
1021            Self::AmbiguousPrefix { .. } => "AmbiguousPrefix",
1022            Self::CrossBackendMergeUnsupported { .. } => "CrossBackendMergeUnsupported",
1023            Self::UnknownRemote { .. } => "UnknownRemote",
1024            Self::RemoteCacheMissing { .. } => "RemoteCacheMissing",
1025            Self::AmbiguousId { .. } => "AmbiguousId",
1026            Self::CrossNamespaceWrite { .. } => "CrossNamespaceWrite",
1027            Self::RemoteFetchError { .. } => "RemoteFetchError",
1028            Self::WriteBudgetExceeded { .. } => "WriteBudgetExceeded",
1029            Self::SecretDetected(_) => "SecretDetected",
1030            Self::DeadlineExceeded { .. } => "DeadlineExceeded",
1031            Self::IncompatibleEventStore(_) => "IncompatibleEventStore",
1032            Self::RefusedWithReceipt { .. } => "RefusedWithReceipt",
1033            Self::RefusedWithEvents { context } => context.source.variant_name(),
1034            Self::WithResolution { context } => context.source.variant_name(),
1035            Self::LintFix { source, .. } => source.variant_name(),
1036        }
1037    }
1038
1039    /// Recover a finite-wait pool-checkout timeout without inspecting rendered
1040    /// error text.
1041    ///
1042    /// Store implementations retain [`khive_db::SqliteError`] as the typed
1043    /// source of `StorageError::Driver`; this method carries that structure
1044    /// through the runtime wrapper for the MCP wire serializer.
1045    pub fn writer_pool_checkout_timeout_context(&self) -> Option<WriterPoolCheckoutTimeoutContext> {
1046        let (sqlite_error, capability, operation) = match self.refusal_source() {
1047            Self::Storage(khive_storage::StorageError::Driver {
1048                capability,
1049                operation,
1050                source,
1051            }) => (
1052                source.downcast_ref::<khive_db::SqliteError>()?,
1053                Some(*capability),
1054                Some(operation.to_string()),
1055            ),
1056            _ => return None,
1057        };
1058
1059        let khive_db::SqliteError::WriterPoolCheckoutTimeout { timeout } = sqlite_error else {
1060            return None;
1061        };
1062        Some(WriterPoolCheckoutTimeoutContext {
1063            timeout: *timeout,
1064            capability,
1065            operation,
1066        })
1067    }
1068
1069    /// Recover either pre-execution write-admission failure this process can
1070    /// produce, by typed variant rather than rendered message text (#1643).
1071    /// Both are safe to classify as retryable: the request was never
1072    /// accepted, so no partial side effect can exist to roll back.
1073    pub fn admission_failure_context(&self) -> Option<AdmissionFailureContext> {
1074        let source = self.refusal_source();
1075        if let Some(context) = self.writer_pool_checkout_timeout_context() {
1076            return Some(AdmissionFailureContext {
1077                stage: WRITER_POOL_CHECKOUT_TIMEOUT_STAGE,
1078                timeout: context.timeout,
1079                capability: context.capability,
1080                operation: context.operation,
1081                pool_identity: None,
1082                scope: None,
1083                retry_after_ms: None,
1084            });
1085        }
1086        if let Self::Storage(khive_storage::StorageError::WriteQueueFull { timeout_ms }) = source {
1087            return Some(AdmissionFailureContext {
1088                stage: WRITER_QUEUE_SATURATED_STAGE,
1089                timeout: Duration::from_millis(*timeout_ms),
1090                capability: None,
1091                operation: None,
1092                pool_identity: None,
1093                scope: Some(WRITER_ADMISSION_SCOPE),
1094                retry_after_ms: Some(*timeout_ms),
1095            });
1096        }
1097        if let Self::Storage(khive_storage::StorageError::AdmissionTimeout {
1098            operation,
1099            timeout_ms,
1100            pool_identity,
1101        }) = source
1102        {
1103            return Some(AdmissionFailureContext {
1104                stage: STORAGE_ADMISSION_TIMEOUT_STAGE,
1105                timeout: Duration::from_millis(*timeout_ms),
1106                capability: None,
1107                operation: Some(operation.to_string()),
1108                pool_identity: pool_identity.clone(),
1109                scope: None,
1110                retry_after_ms: None,
1111            });
1112        }
1113        None
1114    }
1115
1116    /// Recover writer-request finality without parsing the rendered storage
1117    /// error. Ordinary proven rollbacks and terminal writer failures are
1118    /// deliberately distinct even when they carry the same request state.
1119    pub fn writer_task_failure_context(&self) -> Option<WriterTaskFailureContext> {
1120        let Self::Storage(error) = self.refusal_source() else {
1121            return None;
1122        };
1123        match error.without_sqlite_write_stage() {
1124            khive_storage::StorageError::WriterTaskRequestFailed {
1125                request_state,
1126                source,
1127            } => Some(WriterTaskFailureContext {
1128                stage: WRITER_TASK_REQUEST_FAILED_STAGE,
1129                request_state: *request_state,
1130                task_terminated: false,
1131                retryable: source.is_retryable(),
1132            }),
1133            khive_storage::StorageError::WriterTaskTerminated { request_state, .. } => {
1134                Some(WriterTaskFailureContext {
1135                    stage: WRITER_TASK_TERMINATED_STAGE,
1136                    request_state: *request_state,
1137                    task_terminated: true,
1138                    retryable: false,
1139                })
1140            }
1141            _ => None,
1142        }
1143    }
1144
1145    /// Recover every typed failure for which this process can prove that
1146    /// retrying the one failed operation cannot duplicate a side effect.
1147    pub fn retryable_failure_context(&self) -> Option<RetryableFailureContext> {
1148        let mut enclosing = self;
1149        loop {
1150            match enclosing {
1151                Self::LintFix { .. } => return None,
1152                Self::RefusedWithEvents { context } => enclosing = &context.source,
1153                Self::WithResolution { context } => enclosing = &context.source,
1154                _ => break,
1155            }
1156        }
1157
1158        let source = self.refusal_source();
1159        if let Some(context) = self.admission_failure_context() {
1160            return Some(context.into());
1161        }
1162        if let Self::Storage(error) = source {
1163            if let khive_storage::StorageError::WriterTaskBusy { timeout_ms } =
1164                error.without_sqlite_write_stage()
1165            {
1166                return Some(RetryableFailureContext {
1167                    stage: WRITER_TASK_BEGIN_BUSY_STAGE,
1168                    timeout: Duration::from_millis(*timeout_ms),
1169                    capability: None,
1170                    operation: Some("writer_task_begin".to_string()),
1171                    pool_identity: None,
1172                    scope: None,
1173                    retry_after_ms: None,
1174                });
1175            }
1176        }
1177        if let Self::Storage(khive_storage::StorageError::ReadTransactionAgeEvicted {
1178            operation,
1179            max_age_secs,
1180        }) = source
1181        {
1182            return Some(RetryableFailureContext {
1183                stage: READ_TX_AGE_EVICTED_STAGE,
1184                timeout: Duration::from_secs(*max_age_secs),
1185                capability: Some(khive_storage::StorageCapability::Sql),
1186                operation: Some(operation.to_string()),
1187                pool_identity: None,
1188                scope: None,
1189                retry_after_ms: None,
1190            });
1191        }
1192        if let Self::Storage(
1193            khive_storage::StorageError::ReadTransactionAgeEvictionCleanupFailed {
1194                operation,
1195                max_age_secs,
1196                ..
1197            },
1198        ) = source
1199        {
1200            return Some(RetryableFailureContext {
1201                stage: READ_TX_AGE_EVICTED_STAGE,
1202                timeout: Duration::from_secs(*max_age_secs),
1203                capability: Some(khive_storage::StorageCapability::Sql),
1204                operation: Some(operation.to_string()),
1205                pool_identity: None,
1206                scope: None,
1207                retry_after_ms: None,
1208            });
1209        }
1210        None
1211    }
1212}
1213
1214/// Resolve an FTS text-leg search result, failing loud on parser syntax
1215/// errors instead of silently degrading to vector-only fusion.
1216///
1217/// A genuine backend outage (pool exhaustion, connection failure, etc.) is
1218/// NOT a bad query and is returned as-is via the fallthrough `Err(e)` arm;
1219/// `is_fts5_syntax_error` is the narrow gate that tells the two apart.
1220pub fn fts_text_leg_or_err<T>(
1221    result: Result<Vec<T>, RuntimeError>,
1222    context: &'static str,
1223    query: &str,
1224) -> RuntimeResult<Vec<T>> {
1225    match result {
1226        Ok(hits) => Ok(hits),
1227        Err(RuntimeError::Storage(se)) if se.is_fts5_syntax_error() => {
1228            tracing::warn!(
1229                error = %se,
1230                query = %query,
1231                context,
1232                "FTS text leg failed on a parser syntax error; failing loud (#569)"
1233            );
1234            Err(RuntimeError::InvalidInput(format!(
1235                "{context}: FTS query could not be parsed: {se}"
1236            )))
1237        }
1238        Err(e) => Err(e),
1239    }
1240}
1241
1242fn format_uuid_list(uuids: &[uuid::Uuid]) -> String {
1243    uuids
1244        .iter()
1245        .map(uuid::Uuid::to_string)
1246        .collect::<Vec<_>>()
1247        .join(", ")
1248}
1249
1250/// Maps the dependency-light `khive-types` entity-type resolution error onto
1251/// `RuntimeError::InvalidInput` at the pack boundary: `khive-types` cannot
1252/// depend on `khive-runtime`, so it cannot produce `RuntimeError` directly.
1253impl From<khive_types::EntityTypeError> for RuntimeError {
1254    fn from(e: khive_types::EntityTypeError) -> Self {
1255        Self::InvalidInput(e.to_string())
1256    }
1257}
1258
1259impl From<khive_types::KhiveError> for RuntimeError {
1260    fn from(e: khive_types::KhiveError) -> Self {
1261        Self::Khive(e)
1262    }
1263}
1264
1265#[cfg(test)]
1266#[path = "error_refusal_event_context_tests.rs"]
1267mod refusal_event_context_tests;
1268
1269#[cfg(test)]
1270#[path = "error_stream_policy_refusal_tests.rs"]
1271mod stream_policy_refusal_tests;
1272
1273#[cfg(test)]
1274#[path = "error_channel_ingest_failure_class_tests.rs"]
1275mod channel_ingest_failure_class_tests;