Skip to main content

khive_runtime/
error.rs

1//! Runtime error types.
2
3use std::fmt;
4
5use thiserror::Error;
6use uuid::Uuid;
7
8/// Convenience alias for `Result<T, RuntimeError>`.
9pub type RuntimeResult<T> = Result<T, RuntimeError>;
10
11/// A guarded edge write (`link`/`link_many`) was refused because one or both
12/// endpoints no longer existed at write time. Names the exact endpoint(s)
13/// missing instead of a generic "source or target" message, and, for a batch
14/// write, which entry in the batch failed first.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct GuardedWriteFailure {
17    /// Index of the failing entry within a `link_many` batch. `None` for the
18    /// singleton `link` path, which has no batch to index into.
19    pub entry_index: Option<usize>,
20    /// The source endpoint id, present only when it is the (or one of the)
21    /// missing endpoint(s).
22    pub missing_source: Option<Uuid>,
23    /// The target endpoint id, present only when it is the (or one of the)
24    /// missing endpoint(s).
25    pub missing_target: Option<Uuid>,
26}
27
28impl fmt::Display for GuardedWriteFailure {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        let mut missing = Vec::new();
31        if let Some(source) = self.missing_source {
32            missing.push(format!("source {source}"));
33        }
34        if let Some(target) = self.missing_target {
35            missing.push(format!("target {target}"));
36        }
37        let missing = if missing.is_empty() {
38            "endpoint(s)".to_string()
39        } else {
40            missing.join(" and ")
41        };
42        match self.entry_index {
43            Some(index) => write!(
44                f,
45                "batch entry {index}: {missing} no longer exist at write time"
46            ),
47            None => write!(f, "{missing} no longer exist at write time"),
48        }
49    }
50}
51
52impl std::error::Error for GuardedWriteFailure {}
53
54/// A single missing pack dependency.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct MissingPackDependency {
57    pub from: String,
58    pub requires: String,
59}
60
61impl fmt::Display for MissingPackDependency {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        write!(
64            f,
65            "pack '{}' requires '{}', but '{}' is not in the loaded pack set",
66            self.from, self.requires, self.requires
67        )
68    }
69}
70
71impl std::error::Error for MissingPackDependency {}
72
73/// Multiple missing pack dependencies collected into one error.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct MissingPackDependencies {
76    pub missing: Vec<MissingPackDependency>,
77}
78
79impl fmt::Display for MissingPackDependencies {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        let parts: Vec<String> = self.missing.iter().map(ToString::to_string).collect();
82        write!(f, "{}", parts.join("; "))
83    }
84}
85
86impl std::error::Error for MissingPackDependencies {}
87
88/// Circular pack dependency detected during topological sort.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct CircularPackDependency {
91    pub cycle: Vec<String>,
92}
93
94impl fmt::Display for CircularPackDependency {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(
97            f,
98            "circular dependency detected among packs: {}",
99            self.cycle.join(" -> ")
100        )
101    }
102}
103
104impl std::error::Error for CircularPackDependency {}
105
106/// All errors produced by the khive-runtime layer.
107///
108/// Variants cover storage, query, validation, namespace isolation, and permission failures.
109/// Callers should match on `InvalidInput` for bad arguments, `NotFound` for missing records,
110/// and `NamespaceMismatch` (reported as not-found) for cross-namespace access attempts.
111#[derive(Debug, Error)]
112pub enum RuntimeError {
113    #[error("storage: {0}")]
114    Storage(#[from] khive_storage::StorageError),
115
116    #[error("sqlite: {0}")]
117    Sqlite(#[from] khive_db::SqliteError),
118
119    #[error("query: {0}")]
120    Query(#[from] khive_query::QueryError),
121
122    #[error("not found: {0}")]
123    NotFound(String),
124
125    #[error("invalid input: {0}")]
126    InvalidInput(String),
127
128    #[error("unconfigured: {0} is not set")]
129    Unconfigured(String),
130
131    #[error("unknown embedding model: {0}")]
132    UnknownModel(String),
133
134    #[error("embedding: {0}")]
135    Embedding(#[from] lattice_embed::EmbedError),
136
137    #[error("ambiguous: {0}")]
138    Ambiguous(String),
139
140    #[error("fusion: {0}")]
141    Fusion(#[from] khive_fusion::FuseError),
142
143    #[error("internal: {0}")]
144    Internal(String),
145
146    #[error("guarded edge write refused: {0}")]
147    GuardedWriteFailed(GuardedWriteFailure),
148
149    #[error("missing pack dependency: {0}")]
150    MissingPackDependency(MissingPackDependency),
151
152    #[error("missing pack dependencies: {0}")]
153    MissingPackDependencies(MissingPackDependencies),
154
155    #[error("{0}")]
156    CircularPackDependency(CircularPackDependency),
157
158    #[error("pack '{name}' registered twice (indices {first_idx} and {second_idx})")]
159    PackRedeclared {
160        name: String,
161        first_idx: usize,
162        second_idx: usize,
163    },
164
165    /// Two packs declared the same `Visibility::Verb` handler name.
166    /// `Visibility::Subhandler` entries are pack-prefixed and do not
167    /// participate in cross-pack collision checks.
168    #[error(
169        "verb collision: verb {verb:?} declared by both pack {first_pack:?} and pack \
170         {second_pack:?}; rename one handler or use Visibility::Subhandler for internal verbs"
171    )]
172    VerbCollision {
173        verb: String,
174        first_pack: String,
175        second_pack: String,
176    },
177
178    /// Gate denied this verb invocation.
179    ///
180    /// Returned by `VerbRegistry::dispatch` when the configured `Gate` returns
181    /// `GateDecision::Deny`. The pack is never invoked. The `reason` field
182    /// carries the deny message produced by the gate implementation.
183    #[error("permission denied for verb {verb:?}: {reason}")]
184    PermissionDenied { verb: String, reason: String },
185
186    /// A structured [`khive_types::KhiveError`] converted into the runtime
187    /// layer. The full structured error is preserved so callers can inspect
188    /// `kind`, `code`, `details`, and `retry_hint` without information loss.
189    #[error("{0}")]
190    Khive(khive_types::KhiveError),
191
192    /// Record exists but belongs to a different namespace than the provided token.
193    ///
194    /// Externally reported as "not found in this namespace" to avoid leaking
195    /// cross-namespace existence information (timing-oracle mitigation).
196    #[error("not found in this namespace")]
197    NamespaceMismatch { id: uuid::Uuid },
198
199    /// A short-prefix lookup matched more than one record.
200    ///
201    /// `prefix` is the 8+ hex-char prefix supplied by the caller.
202    /// `matches` holds the full UUIDs of all matching records (at most 2 are
203    /// reported to bound the scan — callers must supply the full UUID to disambiguate).
204    #[error("ambiguous prefix {prefix:?}: matches {}", format_uuid_list(matches))]
205    AmbiguousPrefix {
206        prefix: String,
207        matches: Vec<uuid::Uuid>,
208    },
209
210    /// Cross-backend `merge_entity` is unsupported in v1.
211    ///
212    /// Both entities must reside on the same backend. To merge entities on different
213    /// backends, manually export `from_id`, delete it, and re-import on `into_id`'s backend.
214    #[error(
215        "cross-backend merge is not supported: \
216         into_id {into_id} is on backend '{into_backend}', \
217         from_id {from_id} is on backend '{from_backend}'. \
218         Both entities must be on the same backend to merge."
219    )]
220    CrossBackendMergeUnsupported {
221        into_id: uuid::Uuid,
222        from_id: uuid::Uuid,
223        into_backend: String,
224        from_backend: String,
225    },
226
227    // ── Remote Resolution and Content-Hash Verification ──────────────────────
228    /// A `kg://` ref names a remote not declared in `schema.yaml`.
229    #[error("unknown remote: {name:?}")]
230    UnknownRemote { name: String },
231
232    /// A remote cache entry is absent and `--fetch` was not requested.
233    #[error("remote cache missing for remote={remote:?} namespace={namespace:?}")]
234    RemoteCacheMissing { remote: String, namespace: String },
235
236    /// A short ID matches multiple entities in the same namespace or remote cache.
237    #[error("ambiguous id {id:?}: matched {count} records")]
238    AmbiguousId { id: String, count: usize },
239
240    /// A write operation targeted a remote namespace, which is read-only.
241    #[error("cross-namespace write denied: cannot write to remote namespace {namespace:?}")]
242    CrossNamespaceWrite { namespace: String },
243
244    /// A remote fetch failed (network error, authentication failure, etc.).
245    #[error("remote fetch error for remote={remote:?}: {message}")]
246    RemoteFetchError { remote: String, message: String },
247
248    /// A caller-supplied write budget was exceeded during a Compound apply.
249    ///
250    /// `max_new_entries` is the limit passed by the caller. `attempted_new_entries`
251    /// is `consumed + 1`, i.e. the create that would have exceeded the cap.
252    /// `None` budget never produces this error (unlimited path).
253    #[error(
254        "write budget exceeded: max_new_entries={max_new_entries}, \
255         attempted_new_entries={attempted_new_entries}"
256    )]
257    WriteBudgetExceeded {
258        max_new_entries: u64,
259        attempted_new_entries: u64,
260    },
261
262    /// Write blocked: content matches a secret pattern.
263    ///
264    /// The `SecretMatch` carries the detector name and a masked excerpt
265    /// (`first6...Nchars`). The full candidate is never stored in the error.
266    /// Store a pointer (env-var name, keychain item) rather than the raw value.
267    #[error("write blocked: {0}")]
268    SecretDetected(crate::secret_gate::SecretMatch),
269}
270
271/// Resolve an FTS text-leg search result, failing loud on parser syntax
272/// errors instead of silently degrading to vector-only fusion.
273///
274/// A genuine backend outage (pool exhaustion, connection failure, etc.) is
275/// NOT a bad query and is returned as-is via the fallthrough `Err(e)` arm;
276/// `is_fts5_syntax_error` is the narrow gate that tells the two apart.
277pub fn fts_text_leg_or_err<T>(
278    result: Result<Vec<T>, RuntimeError>,
279    context: &'static str,
280    query: &str,
281) -> RuntimeResult<Vec<T>> {
282    match result {
283        Ok(hits) => Ok(hits),
284        Err(RuntimeError::Storage(se)) if se.is_fts5_syntax_error() => {
285            tracing::warn!(
286                error = %se,
287                query = %query,
288                context,
289                "FTS text leg failed on a parser syntax error; failing loud (#569)"
290            );
291            Err(RuntimeError::InvalidInput(format!(
292                "{context}: FTS query could not be parsed: {se}"
293            )))
294        }
295        Err(e) => Err(e),
296    }
297}
298
299fn format_uuid_list(uuids: &[uuid::Uuid]) -> String {
300    let shorts: Vec<String> = uuids
301        .iter()
302        .map(|u| u.to_string()[..8].to_string())
303        .collect();
304    shorts.join(", ")
305}
306
307/// Maps the dependency-light `khive-types` entity-type resolution error onto
308/// `RuntimeError::InvalidInput` at the pack boundary: `khive-types` cannot
309/// depend on `khive-runtime`, so it cannot produce `RuntimeError` directly.
310impl From<khive_types::EntityTypeError> for RuntimeError {
311    fn from(e: khive_types::EntityTypeError) -> Self {
312        Self::InvalidInput(e.to_string())
313    }
314}
315
316impl From<khive_types::KhiveError> for RuntimeError {
317    fn from(e: khive_types::KhiveError) -> Self {
318        Self::Khive(e)
319    }
320}