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/// `non_exhaustive` so a future category is a wildcard arm downstream instead
15/// of a breaking release. That trades away the compile-time exhaustiveness the
16/// in-repo adapters relied on, so [`ErrorCategory::ALL`] restores it as a
17/// test-time guard: each adapter iterates `ALL` and asserts its mapping is
18/// total, which turns "someone added a category" from a silent fallback into
19/// a red test naming the unmapped variant.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum ErrorCategory {
23 /// The caller supplied bad input (empty fact, reserved key, malformed
24 /// filter) — a 4xx-style fault.
25 InvalidInput,
26 /// A referenced memory id does not exist.
27 NotFound,
28 /// An internal storage / embedding / extraction failure — a 5xx-style fault.
29 Internal,
30 /// The operation is not supported by the storage backend in use — a
31 /// capability gap, not a caller mistake and not a fault. Introduced with
32 /// the facet split (#1959) so a backend's honest refusal stops being
33 /// billed to the client as invalid input.
34 Unsupported,
35}
36
37impl ErrorCategory {
38 /// Every category, for adapter coverage tests — see the type-level doc.
39 ///
40 /// Lives here because only the defining crate can enumerate a
41 /// `non_exhaustive` enum; an adapter hand-listing the variants would just
42 /// re-create the drift this exists to catch. Adding a variant without
43 /// extending this slice fails `all_lists_every_category` below.
44 pub const ALL: &'static [Self] = &[
45 Self::InvalidInput,
46 Self::NotFound,
47 Self::Internal,
48 Self::Unsupported,
49 ];
50}
51
52/// Errors returned by [`crate::service::MemoryService`].
53///
54/// `non_exhaustive`: adapters classify through [`MemoryError::category`], never
55/// by variant, so new variants must be a non-event downstream — which is also
56/// what lets a variant's payload gain structure one minor release at a time
57/// instead of in one breaking batch.
58///
59/// # `String` payloads are a decision here, not a debt
60///
61/// Every adapter consumes this type through [`MemoryError::category`] plus
62/// `Display`; no payload is read programmatically outside this crate. So a
63/// variant earns a structured payload only when structure is being **lost** —
64/// a source error flattened out of the `source()` chain, or an in-crate
65/// consumer parsing prose — and [`Self::WorkingContextCodec`] is the one that
66/// qualified (it had both). The others carry prose on purpose: their messages
67/// are heterogeneous narratives written for the person reading them
68/// ([`Self::IngestPath`] additionally cites the *requested* path and never the
69/// canonical one, a security decision its module documents), and forcing one
70/// struct template over them would flatten exactly the nuance they exist to
71/// deliver. Re-litigating this per variant is what this paragraph is for.
72#[derive(Debug, thiserror::Error)]
73#[non_exhaustive]
74pub enum MemoryError {
75 /// Failure in the underlying `VelesDB` storage engine.
76 #[error("storage error: {0}")]
77 Storage(#[from] CoreError),
78
79 /// Failure in the Agent Memory SDK. Only constructible with the
80 /// `persistence` feature (the native, file-backed store) — a
81 /// `persistence`-free backend (e.g. `velesdb-wasm`'s in-memory one) never
82 /// touches `velesdb-core`'s `agent` module, so this variant can't arise.
83 #[cfg(feature = "persistence")]
84 #[error("memory error: {0}")]
85 Memory(#[from] AgentMemoryError),
86
87 /// A fact was empty or whitespace-only.
88 #[error("fact text must not be empty")]
89 EmptyFact,
90
91 /// A `remember` link or a `relate` endpoint referenced a memory id that
92 /// does not exist.
93 #[error("memory {0} does not exist")]
94 UnknownMemory(u64),
95
96 /// A fact was longer than [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`],
97 /// the size an embedding model still accepts. Refused BEFORE the embedder
98 /// is called, so the caller learns the limit and its own size instead of
99 /// an opaque backend fault (`ollama embeddings call failed`).
100 #[error(
101 "fact of {bytes} bytes exceeds the embeddable cap of {max} bytes: split it into several \
102 shorter facts, or compile the long text with `compile_context` and remember a summary"
103 )]
104 FactTooLarge {
105 /// The size of the rejected fact, in bytes.
106 bytes: usize,
107 /// The cap that was exceeded ([`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`]).
108 max: usize,
109 },
110
111 /// [`crate::service::MemoryService::remember_with_ttl`] was given
112 /// `Some(0)`. An explicit per-call `0` used to be normalised to "no
113 /// expiry", i.e. a caller who meant "expire immediately" silently got a
114 /// **permanent** fact — the exact opposite intent, with no signal. A TTL
115 /// supplied as *configuration* (`with_default_ttl`, a compile policy's
116 /// `source_ttl_seconds`) still reads `0` as "no TTL policy": that is a
117 /// default, not an intent about one fact.
118 #[error(
119 "ttl_seconds must be greater than 0: omit it to store the fact permanently, or pass the \
120 number of seconds the fact should live"
121 )]
122 ZeroTtl,
123
124 /// [`crate::service::MemoryService::relate`] was asked to link a memory to
125 /// itself. A self-loop states nothing and is traversed by `why` like any
126 /// other edge, so it only adds noise to the evidence trail.
127 #[error(
128 "a memory cannot relate to itself (both endpoints are {0}): pass two different ids, or \
129 record the property in the fact's own metadata"
130 )]
131 SelfRelation(u64),
132
133 /// Caller metadata or a recall filter named a reserved key (`content` or a
134 /// `_veles_`-prefixed system key), which callers may not set or filter on.
135 /// [`crate::storage::AUTO_DATE_FIELD`] is the one documented exception:
136 /// a caller MAY set it (e.g. to date a fact retroactively), so it never
137 /// raises this error.
138 #[error("metadata key '{0}' is reserved")]
139 ReservedKey(String),
140
141 /// Caller-supplied `metadata` (on `remember`/`remember_with_ttl` or a
142 /// context-compiler fragment) exceeded [`crate::limits::MAX_METADATA_BYTES`]
143 /// — a `DoS` guard, since metadata is a keyed lookup facet, not a payload.
144 #[error("metadata of {bytes} bytes exceeds the cap of {max} bytes")]
145 MetadataTooLarge {
146 /// The serialized size of the rejected metadata, in bytes.
147 bytes: usize,
148 /// The cap that was exceeded ([`crate::limits::MAX_METADATA_BYTES`]).
149 max: usize,
150 },
151
152 /// Failure producing a text embedding.
153 #[error("embedding error: {0}")]
154 Embed(#[from] EmbedError),
155
156 /// Failure extracting facts from raw text in
157 /// [`crate::service::MemoryService::remember_extracted`].
158 #[error("extraction error: {0}")]
159 Extract(#[from] ExtractError),
160
161 /// Failure reranking a fused-recall candidate pool in
162 /// [`crate::service::MemoryService::recall_fused_reranked`].
163 #[error("rerank error: {0}")]
164 Rerank(#[from] RerankError),
165
166 /// The online-migration observer could not durably classify a mutation.
167 /// The source write has not run when this error is returned.
168 #[cfg(feature = "persistence")]
169 #[error("migration capture error: {0}")]
170 MigrationCapture(String),
171
172 /// The storage backend in use does not support the requested operation.
173 /// A static description, not prose: the set of refusable operations is
174 /// closed and known at compile time, and adapters display it verbatim.
175 #[error("unsupported by this storage backend: {0}")]
176 Unsupported(&'static str),
177
178 /// A fused-recall filter referenced a field name that is not a plain
179 /// identifier, named a reserved key, or carried a non-scalar value.
180 #[error("invalid filter field: {0}")]
181 InvalidFilter(String),
182
183 /// A relation label supplied to [`crate::service::MemoryService::relate`] or
184 /// a [`crate::model::Link`] in
185 /// [`crate::service::MemoryService::remember`] was invalid — empty, too long,
186 /// or contained non-printable characters.
187 #[error("invalid relation label: {0}")]
188 InvalidRelation(String),
189
190 /// A context-compile request carried a token budget that cannot hold any
191 /// context: zero, or not larger than the response reserve the policy
192 /// keeps aside for the model's answer.
193 #[cfg(feature = "context")]
194 #[error("token budget {budget} cannot hold any context (reserve {reserve})")]
195 ContextBudget {
196 /// The caller-supplied token budget.
197 budget: u64,
198 /// The response reserve the policy subtracts from the budget.
199 reserve: u64,
200 },
201
202 /// A context-compile request exceeded a resource cap from
203 /// [`crate::limits`] — too many fragments, or one fragment larger than
204 /// the per-fragment byte ceiling.
205 #[cfg(feature = "context")]
206 #[error("context request over limit: {0}")]
207 ContextOverLimit(String),
208
209 /// A transcript segmentation request failed because of the transcript's
210 /// FORMAT, not its size — e.g. `segmentation.format: "jsonl"` forced on
211 /// a line that does not parse as a `{role, content}` JSON object (see
212 /// [`crate::context::segment::segment_transcript`]). Deliberately
213 /// distinct from [`Self::ContextOverLimit`] (issue #1516, m2): a parsing
214 /// failure is not a budget/cap breach, so a caller filtering on the
215 /// error message no longer sees the misleading "over limit" wording for
216 /// what is really a malformed-input error. Same
217 /// [`ErrorCategory::InvalidInput`] classification as `ContextOverLimit`
218 /// (both map to `INVALID_PARAMS` over MCP) — only the variant, and the
219 /// message, differ.
220 #[cfg(feature = "context")]
221 #[error("transcript segmentation error: {0}")]
222 SegmentationError(String),
223
224 /// A `ctx://source/<hash>` handle was malformed or nothing is stored
225 /// under it (the source was never stored, expired, or was forgotten).
226 #[cfg(feature = "context")]
227 #[error("unknown context source handle: {0}")]
228 UnknownHandle(String),
229
230 /// [`crate::service::MemoryService::explain_compilation`]'s
231 /// `fragment_index` named a position beyond `request.fragments`.
232 #[cfg(feature = "context")]
233 #[error("fragment_index {index} is out of bounds: request.fragments has {len} entries")]
234 FragmentIndexOutOfBounds {
235 /// The out-of-bounds index the caller supplied.
236 index: usize,
237 /// The actual number of fragments in the request.
238 len: usize,
239 },
240
241 /// [`crate::service::MemoryService::explain_compilation`] found no
242 /// decision matching the requested `fragment_id` (and no
243 /// `fragment_index` was given, or it selected nothing new to check).
244 #[cfg(feature = "context")]
245 #[error("the request contains no fragment with id {0}")]
246 FragmentNotFound(u64),
247
248 /// [`crate::service::MemoryService::save_working_context`] was given a
249 /// `WorkingContext` with nothing in it. The write is an idempotent upsert,
250 /// so an empty save would *replace* — that is, destroy — the rich state
251 /// already stored under this project and session. The one tool whose whole
252 /// job is surviving a context loss must not be able to cause one on a call
253 /// that carries nothing.
254 #[cfg(feature = "context")]
255 #[error(
256 "working context is empty: fill at least one of goal, active_constraints, verified_facts, \
257 open_hypotheses, decisions, exact_evidence or pending_actions — saving an empty state \
258 would replace whatever is already stored under this project and session"
259 )]
260 EmptyWorkingContext,
261
262 /// A persisted working context could not be (de)serialized — the stored
263 /// payload predates or postdates this crate's schema.
264 ///
265 /// The one String-payload variant that gained structure, because it is
266 /// the one that had lost some: half its construction sites flattened a
267 /// `serde_json::Error` into prose, destroying the `source()` chain
268 /// `thiserror` exists to preserve — and it is also the only variant
269 /// matched programmatically (the index reader falls back to an empty
270 /// index on it rather than failing a load). `source` is `None` where the
271 /// corruption is structural (a marker with no body) rather than a codec
272 /// refusal.
273 #[cfg(feature = "context")]
274 #[error("working context codec error: {detail}")]
275 WorkingContextCodec {
276 /// What was being encoded or decoded, and for which slot.
277 detail: String,
278 /// The codec's own refusal, when there is one to preserve.
279 #[source]
280 source: Option<Box<serde_json::Error>>,
281 },
282
283 /// A context fragment carried a `path` (V2b-1 path ingestion) but no
284 /// filesystem root is configured (`VELESDB_MEMORY_INGEST_ROOTS` unset or
285 /// empty) — the tool is always advertised, but ingestion itself is
286 /// opt-in. Also the fallback the pure compiler core reports when a
287 /// `path` fragment reaches it unresolved (e.g. a binding that has no
288 /// ingest adapter, such as the WASM build): [`crate::context`] never
289 /// performs I/O itself, so an un-cleared `path` field always means the
290 /// adapter that should have resolved or rejected it was skipped.
291 #[cfg(feature = "context")]
292 #[error(
293 "path ingestion is disabled: set VELESDB_MEMORY_INGEST_ROOTS to enable the `path` field"
294 )]
295 IngestDisabled,
296
297 /// A `path`-referenced fragment resolved (after following symlinks) to a
298 /// location outside every configured ingest root. Carries the
299 /// caller-supplied `path` VERBATIM, never the canonicalized target — the
300 /// resolved location may be filesystem structure the caller has no
301 /// business learning about (e.g. that a symlink escapes).
302 #[cfg(feature = "context")]
303 #[error("path '{0}' is outside the configured ingest roots")]
304 IngestOutsideRoots(String),
305
306 /// A `path`-referenced fragment could not be read for any reason other
307 /// than escaping the ingest roots: a relative path (an MCP server's
308 /// working directory is unpredictable, so only absolute paths are
309 /// accepted), a path that does not exist or is not a plain file
310 /// (directories are rejected), a `path` fragment combined with
311 /// non-empty `content` or a `media` payload (`path` is exclusive,
312 /// though `content` and `media` may travel together), a fragment
313 /// carrying none of the three, or a file whose bytes are not valid
314 /// UTF-8.
315 #[cfg(feature = "context")]
316 #[error("cannot ingest path: {0}")]
317 IngestPath(String),
318
319 /// A `remember` link failed after the fact was stored AND the
320 /// compensating rollback delete also failed — unlike every other error
321 /// from `remember`, the fact **remains stored**. Both errors are
322 /// carried so the caller can see why the write failed and why the
323 /// cleanup couldn't undo it.
324 ///
325 /// Neither field is `#[source]` — deliberately: the `Display` message
326 /// already embeds both errors, and a source chain would double-print
327 /// them in chain-style reports (anyhow, miette). Match on the variant
328 /// to inspect the two errors programmatically.
329 #[error(
330 "link failed ({cause}); rollback delete also failed ({rollback}) — the fact remains stored"
331 )]
332 RollbackFailed {
333 /// The link failure that triggered the rollback.
334 cause: Box<MemoryError>,
335 /// The storage failure that prevented the rollback delete.
336 rollback: Box<MemoryError>,
337 },
338}
339
340impl MemoryError {
341 /// Classify this error into a transport-neutral [`ErrorCategory`]. Adapters
342 /// map the *category*, not the variant, so the client-facing taxonomy stays
343 /// identical across the MCP server and every binding.
344 #[must_use]
345 pub fn category(&self) -> ErrorCategory {
346 match self {
347 Self::EmptyFact
348 | Self::ReservedKey(_)
349 | Self::InvalidFilter(_)
350 | Self::InvalidRelation(_)
351 | Self::FactTooLarge { .. }
352 | Self::ZeroTtl
353 | Self::SelfRelation(_)
354 | Self::MetadataTooLarge { .. } => ErrorCategory::InvalidInput,
355 Self::Unsupported(_) => ErrorCategory::Unsupported,
356 #[cfg(feature = "context")]
357 Self::EmptyWorkingContext => ErrorCategory::InvalidInput,
358 #[cfg(feature = "context")]
359 Self::ContextBudget { .. } | Self::ContextOverLimit(_) | Self::SegmentationError(_) => {
360 ErrorCategory::InvalidInput
361 }
362 #[cfg(feature = "context")]
363 Self::IngestDisabled | Self::IngestOutsideRoots(_) | Self::IngestPath(_) => {
364 ErrorCategory::InvalidInput
365 }
366 #[cfg(feature = "context")]
367 Self::FragmentIndexOutOfBounds { .. } | Self::FragmentNotFound(_) => {
368 ErrorCategory::InvalidInput
369 }
370 #[cfg(feature = "context")]
371 Self::UnknownHandle(_) => ErrorCategory::NotFound,
372 #[cfg(feature = "context")]
373 Self::WorkingContextCodec { .. } => ErrorCategory::Internal,
374 Self::UnknownMemory(_) => ErrorCategory::NotFound,
375 #[cfg(feature = "persistence")]
376 Self::Memory(_) | Self::MigrationCapture(_) => ErrorCategory::Internal,
377 Self::Storage(_) | Self::Embed(_) | Self::Extract(_) | Self::Rerank(_) => {
378 ErrorCategory::Internal
379 }
380 // The rollback failure is the storage-level fault that matters
381 // to a client: the write is in an unexpected state.
382 Self::RollbackFailed { .. } => ErrorCategory::Internal,
383 }
384 }
385}
386
387#[cfg(test)]
388#[path = "error_tests.rs"]
389mod error_tests;