Skip to main content

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;