velesdb_memory/error.rs
1//! Error type for the memory layer.
2
3#[cfg(feature = "persistence")]
4use velesdb_core::agent::AgentMemoryError;
5use velesdb_core::Error as CoreError;
6
7use crate::embedder::EmbedError;
8use crate::extract::ExtractError;
9use crate::rerank::RerankError;
10
11/// The transport-neutral class of a [`MemoryError`] — the single source of
12/// truth every adapter maps onto its own error channel (JSON-RPC code, napi
13/// status, `PyO3` exception type), so the taxonomy can never drift between them.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ErrorCategory {
16 /// The caller supplied bad input (empty fact, reserved key, malformed
17 /// filter) — a 4xx-style fault.
18 InvalidInput,
19 /// A referenced memory id does not exist.
20 NotFound,
21 /// An internal storage / embedding / extraction failure — a 5xx-style fault.
22 Internal,
23}
24
25/// Errors returned by [`crate::service::MemoryService`].
26#[derive(Debug, thiserror::Error)]
27pub enum MemoryError {
28 /// Failure in the underlying `VelesDB` storage engine.
29 #[error("storage error: {0}")]
30 Storage(#[from] CoreError),
31
32 /// Failure in the Agent Memory SDK. Only constructible with the
33 /// `persistence` feature (the native, file-backed store) — a
34 /// `persistence`-free backend (e.g. `velesdb-wasm`'s in-memory one) never
35 /// touches `velesdb-core`'s `agent` module, so this variant can't arise.
36 #[cfg(feature = "persistence")]
37 #[error("memory error: {0}")]
38 Memory(#[from] AgentMemoryError),
39
40 /// A fact was empty or whitespace-only.
41 #[error("fact text must not be empty")]
42 EmptyFact,
43
44 /// A `remember` link or a `relate` endpoint referenced a memory id that
45 /// does not exist.
46 #[error("memory {0} does not exist")]
47 UnknownMemory(u64),
48
49 /// Caller metadata or a recall filter named a reserved key (`content` or a
50 /// `_veles_`-prefixed system key), which callers may not set or filter on.
51 #[error("metadata key '{0}' is reserved")]
52 ReservedKey(String),
53
54 /// Caller-supplied `metadata` (on `remember`/`remember_with_ttl` or a
55 /// context-compiler fragment) exceeded [`crate::limits::MAX_METADATA_BYTES`]
56 /// — a `DoS` guard, since metadata is a keyed lookup facet, not a payload.
57 #[error("metadata of {bytes} bytes exceeds the cap of {max} bytes")]
58 MetadataTooLarge {
59 /// The serialized size of the rejected metadata, in bytes.
60 bytes: usize,
61 /// The cap that was exceeded ([`crate::limits::MAX_METADATA_BYTES`]).
62 max: usize,
63 },
64
65 /// Failure producing a text embedding.
66 #[error("embedding error: {0}")]
67 Embed(#[from] EmbedError),
68
69 /// Failure extracting facts from raw text in
70 /// [`crate::service::MemoryService::remember_extracted`].
71 #[error("extraction error: {0}")]
72 Extract(#[from] ExtractError),
73
74 /// Failure reranking a fused-recall candidate pool in
75 /// [`crate::service::MemoryService::recall_fused_reranked`].
76 #[error("rerank error: {0}")]
77 Rerank(#[from] RerankError),
78
79 /// A fused-recall filter referenced a field name that is not a plain
80 /// identifier, named a reserved key, or carried a non-scalar value.
81 #[error("invalid filter field: {0}")]
82 InvalidFilter(String),
83
84 /// A relation label supplied to [`crate::service::MemoryService::relate`] or
85 /// a [`crate::model::Link`] in
86 /// [`crate::service::MemoryService::remember`] was invalid — empty, too long,
87 /// or contained non-printable characters.
88 #[error("invalid relation label: {0}")]
89 InvalidRelation(String),
90
91 /// A context-compile request carried a token budget that cannot hold any
92 /// context: zero, or not larger than the response reserve the policy
93 /// keeps aside for the model's answer.
94 #[cfg(feature = "context")]
95 #[error("token budget {budget} cannot hold any context (reserve {reserve})")]
96 ContextBudget {
97 /// The caller-supplied token budget.
98 budget: u64,
99 /// The response reserve the policy subtracts from the budget.
100 reserve: u64,
101 },
102
103 /// A context-compile request exceeded a resource cap from
104 /// [`crate::limits`] — too many fragments, or one fragment larger than
105 /// the per-fragment byte ceiling.
106 #[cfg(feature = "context")]
107 #[error("context request over limit: {0}")]
108 ContextOverLimit(String),
109
110 /// A `ctx://source/<hash>` handle was malformed or nothing is stored
111 /// under it (the source was never stored, expired, or was forgotten).
112 #[cfg(feature = "context")]
113 #[error("unknown context source handle: {0}")]
114 UnknownHandle(String),
115
116 /// [`crate::service::MemoryService::explain_compilation`]'s
117 /// `fragment_index` named a position beyond `request.fragments`.
118 #[cfg(feature = "context")]
119 #[error("fragment_index {index} is out of bounds: request.fragments has {len} entries")]
120 FragmentIndexOutOfBounds {
121 /// The out-of-bounds index the caller supplied.
122 index: usize,
123 /// The actual number of fragments in the request.
124 len: usize,
125 },
126
127 /// [`crate::service::MemoryService::explain_compilation`] found no
128 /// decision matching the requested `fragment_id` (and no
129 /// `fragment_index` was given, or it selected nothing new to check).
130 #[cfg(feature = "context")]
131 #[error("the request contains no fragment with id {0}")]
132 FragmentNotFound(u64),
133
134 /// A persisted working context could not be (de)serialized — the stored
135 /// payload predates or postdates this crate's schema.
136 #[cfg(feature = "context")]
137 #[error("working context codec error: {0}")]
138 WorkingContextCodec(String),
139
140 /// A context fragment carried a `path` (V2b-1 path ingestion) but no
141 /// filesystem root is configured (`VELESDB_MEMORY_INGEST_ROOTS` unset or
142 /// empty) — the tool is always advertised, but ingestion itself is
143 /// opt-in. Also the fallback the pure compiler core reports when a
144 /// `path` fragment reaches it unresolved (e.g. a binding that has no
145 /// ingest adapter, such as the WASM build): [`crate::context`] never
146 /// performs I/O itself, so an un-cleared `path` field always means the
147 /// adapter that should have resolved or rejected it was skipped.
148 #[cfg(feature = "context")]
149 #[error(
150 "path ingestion is disabled: set VELESDB_MEMORY_INGEST_ROOTS to enable the `path` field"
151 )]
152 IngestDisabled,
153
154 /// A `path`-referenced fragment resolved (after following symlinks) to a
155 /// location outside every configured ingest root. Carries the
156 /// caller-supplied `path` VERBATIM, never the canonicalized target — the
157 /// resolved location may be filesystem structure the caller has no
158 /// business learning about (e.g. that a symlink escapes).
159 #[cfg(feature = "context")]
160 #[error("path '{0}' is outside the configured ingest roots")]
161 IngestOutsideRoots(String),
162
163 /// A `path`-referenced fragment could not be read for any reason other
164 /// than escaping the ingest roots: a relative path (an MCP server's
165 /// working directory is unpredictable, so only absolute paths are
166 /// accepted), a path that does not exist or is not a plain file
167 /// (directories are rejected), a `path` fragment combined with
168 /// non-empty `content` or a `media` payload (exactly one of `path`,
169 /// `content`, `media` is accepted), or a file whose bytes are not valid
170 /// UTF-8.
171 #[cfg(feature = "context")]
172 #[error("cannot ingest path: {0}")]
173 IngestPath(String),
174
175 /// A `remember` link failed after the fact was stored AND the
176 /// compensating rollback delete also failed — unlike every other error
177 /// from `remember`, the fact **remains stored**. Both errors are
178 /// carried so the caller can see why the write failed and why the
179 /// cleanup couldn't undo it.
180 ///
181 /// Neither field is `#[source]` — deliberately: the `Display` message
182 /// already embeds both errors, and a source chain would double-print
183 /// them in chain-style reports (anyhow, miette). Match on the variant
184 /// to inspect the two errors programmatically.
185 #[error(
186 "link failed ({cause}); rollback delete also failed ({rollback}) — the fact remains stored"
187 )]
188 RollbackFailed {
189 /// The link failure that triggered the rollback.
190 cause: Box<MemoryError>,
191 /// The storage failure that prevented the rollback delete.
192 rollback: Box<MemoryError>,
193 },
194}
195
196impl MemoryError {
197 /// Classify this error into a transport-neutral [`ErrorCategory`]. Adapters
198 /// map the *category*, not the variant, so the client-facing taxonomy stays
199 /// identical across the MCP server and every binding.
200 #[must_use]
201 pub fn category(&self) -> ErrorCategory {
202 match self {
203 Self::EmptyFact
204 | Self::ReservedKey(_)
205 | Self::InvalidFilter(_)
206 | Self::InvalidRelation(_)
207 | Self::MetadataTooLarge { .. } => ErrorCategory::InvalidInput,
208 #[cfg(feature = "context")]
209 Self::ContextBudget { .. } | Self::ContextOverLimit(_) => ErrorCategory::InvalidInput,
210 #[cfg(feature = "context")]
211 Self::IngestDisabled | Self::IngestOutsideRoots(_) | Self::IngestPath(_) => {
212 ErrorCategory::InvalidInput
213 }
214 #[cfg(feature = "context")]
215 Self::FragmentIndexOutOfBounds { .. } | Self::FragmentNotFound(_) => {
216 ErrorCategory::InvalidInput
217 }
218 #[cfg(feature = "context")]
219 Self::UnknownHandle(_) => ErrorCategory::NotFound,
220 #[cfg(feature = "context")]
221 Self::WorkingContextCodec(_) => ErrorCategory::Internal,
222 Self::UnknownMemory(_) => ErrorCategory::NotFound,
223 #[cfg(feature = "persistence")]
224 Self::Memory(_) => ErrorCategory::Internal,
225 Self::Storage(_) | Self::Embed(_) | Self::Extract(_) | Self::Rerank(_) => {
226 ErrorCategory::Internal
227 }
228 // The rollback failure is the storage-level fault that matters
229 // to a client: the write is in an unexpected state.
230 Self::RollbackFailed { .. } => ErrorCategory::Internal,
231 }
232 }
233}