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#[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    /// [`crate::storage::AUTO_DATE_FIELD`] is the one documented exception:
52    /// a caller MAY set it (e.g. to date a fact retroactively), so it never
53    /// raises this error.
54    #[error("metadata key '{0}' is reserved")]
55    ReservedKey(String),
56
57    /// Caller-supplied `metadata` (on `remember`/`remember_with_ttl` or a
58    /// context-compiler fragment) exceeded [`crate::limits::MAX_METADATA_BYTES`]
59    /// — a `DoS` guard, since metadata is a keyed lookup facet, not a payload.
60    #[error("metadata of {bytes} bytes exceeds the cap of {max} bytes")]
61    MetadataTooLarge {
62        /// The serialized size of the rejected metadata, in bytes.
63        bytes: usize,
64        /// The cap that was exceeded ([`crate::limits::MAX_METADATA_BYTES`]).
65        max: usize,
66    },
67
68    /// Failure producing a text embedding.
69    #[error("embedding error: {0}")]
70    Embed(#[from] EmbedError),
71
72    /// Failure extracting facts from raw text in
73    /// [`crate::service::MemoryService::remember_extracted`].
74    #[error("extraction error: {0}")]
75    Extract(#[from] ExtractError),
76
77    /// Failure reranking a fused-recall candidate pool in
78    /// [`crate::service::MemoryService::recall_fused_reranked`].
79    #[error("rerank error: {0}")]
80    Rerank(#[from] RerankError),
81
82    /// A fused-recall filter referenced a field name that is not a plain
83    /// identifier, named a reserved key, or carried a non-scalar value.
84    #[error("invalid filter field: {0}")]
85    InvalidFilter(String),
86
87    /// A relation label supplied to [`crate::service::MemoryService::relate`] or
88    /// a [`crate::model::Link`] in
89    /// [`crate::service::MemoryService::remember`] was invalid — empty, too long,
90    /// or contained non-printable characters.
91    #[error("invalid relation label: {0}")]
92    InvalidRelation(String),
93
94    /// A context-compile request carried a token budget that cannot hold any
95    /// context: zero, or not larger than the response reserve the policy
96    /// keeps aside for the model's answer.
97    #[cfg(feature = "context")]
98    #[error("token budget {budget} cannot hold any context (reserve {reserve})")]
99    ContextBudget {
100        /// The caller-supplied token budget.
101        budget: u64,
102        /// The response reserve the policy subtracts from the budget.
103        reserve: u64,
104    },
105
106    /// A context-compile request exceeded a resource cap from
107    /// [`crate::limits`] — too many fragments, or one fragment larger than
108    /// the per-fragment byte ceiling.
109    #[cfg(feature = "context")]
110    #[error("context request over limit: {0}")]
111    ContextOverLimit(String),
112
113    /// A transcript segmentation request failed because of the transcript's
114    /// FORMAT, not its size — e.g. `segmentation.format: "jsonl"` forced on
115    /// a line that does not parse as a `{role, content}` JSON object (see
116    /// [`crate::context::segment::segment_transcript`]). Deliberately
117    /// distinct from [`Self::ContextOverLimit`] (issue #1516, m2): a parsing
118    /// failure is not a budget/cap breach, so a caller filtering on the
119    /// error message no longer sees the misleading "over limit" wording for
120    /// what is really a malformed-input error. Same
121    /// [`ErrorCategory::InvalidInput`] classification as `ContextOverLimit`
122    /// (both map to `INVALID_PARAMS` over MCP) — only the variant, and the
123    /// message, differ.
124    #[cfg(feature = "context")]
125    #[error("transcript segmentation error: {0}")]
126    SegmentationError(String),
127
128    /// A `ctx://source/<hash>` handle was malformed or nothing is stored
129    /// under it (the source was never stored, expired, or was forgotten).
130    #[cfg(feature = "context")]
131    #[error("unknown context source handle: {0}")]
132    UnknownHandle(String),
133
134    /// [`crate::service::MemoryService::explain_compilation`]'s
135    /// `fragment_index` named a position beyond `request.fragments`.
136    #[cfg(feature = "context")]
137    #[error("fragment_index {index} is out of bounds: request.fragments has {len} entries")]
138    FragmentIndexOutOfBounds {
139        /// The out-of-bounds index the caller supplied.
140        index: usize,
141        /// The actual number of fragments in the request.
142        len: usize,
143    },
144
145    /// [`crate::service::MemoryService::explain_compilation`] found no
146    /// decision matching the requested `fragment_id` (and no
147    /// `fragment_index` was given, or it selected nothing new to check).
148    #[cfg(feature = "context")]
149    #[error("the request contains no fragment with id {0}")]
150    FragmentNotFound(u64),
151
152    /// A persisted working context could not be (de)serialized — the stored
153    /// payload predates or postdates this crate's schema.
154    #[cfg(feature = "context")]
155    #[error("working context codec error: {0}")]
156    WorkingContextCodec(String),
157
158    /// A context fragment carried a `path` (V2b-1 path ingestion) but no
159    /// filesystem root is configured (`VELESDB_MEMORY_INGEST_ROOTS` unset or
160    /// empty) — the tool is always advertised, but ingestion itself is
161    /// opt-in. Also the fallback the pure compiler core reports when a
162    /// `path` fragment reaches it unresolved (e.g. a binding that has no
163    /// ingest adapter, such as the WASM build): [`crate::context`] never
164    /// performs I/O itself, so an un-cleared `path` field always means the
165    /// adapter that should have resolved or rejected it was skipped.
166    #[cfg(feature = "context")]
167    #[error(
168        "path ingestion is disabled: set VELESDB_MEMORY_INGEST_ROOTS to enable the `path` field"
169    )]
170    IngestDisabled,
171
172    /// A `path`-referenced fragment resolved (after following symlinks) to a
173    /// location outside every configured ingest root. Carries the
174    /// caller-supplied `path` VERBATIM, never the canonicalized target — the
175    /// resolved location may be filesystem structure the caller has no
176    /// business learning about (e.g. that a symlink escapes).
177    #[cfg(feature = "context")]
178    #[error("path '{0}' is outside the configured ingest roots")]
179    IngestOutsideRoots(String),
180
181    /// A `path`-referenced fragment could not be read for any reason other
182    /// than escaping the ingest roots: a relative path (an MCP server's
183    /// working directory is unpredictable, so only absolute paths are
184    /// accepted), a path that does not exist or is not a plain file
185    /// (directories are rejected), a `path` fragment combined with
186    /// non-empty `content` or a `media` payload (exactly one of `path`,
187    /// `content`, `media` is accepted), or a file whose bytes are not valid
188    /// UTF-8.
189    #[cfg(feature = "context")]
190    #[error("cannot ingest path: {0}")]
191    IngestPath(String),
192
193    /// A `remember` link failed after the fact was stored AND the
194    /// compensating rollback delete also failed — unlike every other error
195    /// from `remember`, the fact **remains stored**. Both errors are
196    /// carried so the caller can see why the write failed and why the
197    /// cleanup couldn't undo it.
198    ///
199    /// Neither field is `#[source]` — deliberately: the `Display` message
200    /// already embeds both errors, and a source chain would double-print
201    /// them in chain-style reports (anyhow, miette). Match on the variant
202    /// to inspect the two errors programmatically.
203    #[error(
204        "link failed ({cause}); rollback delete also failed ({rollback}) — the fact remains stored"
205    )]
206    RollbackFailed {
207        /// The link failure that triggered the rollback.
208        cause: Box<MemoryError>,
209        /// The storage failure that prevented the rollback delete.
210        rollback: Box<MemoryError>,
211    },
212}
213
214impl MemoryError {
215    /// Classify this error into a transport-neutral [`ErrorCategory`]. Adapters
216    /// map the *category*, not the variant, so the client-facing taxonomy stays
217    /// identical across the MCP server and every binding.
218    #[must_use]
219    pub fn category(&self) -> ErrorCategory {
220        match self {
221            Self::EmptyFact
222            | Self::ReservedKey(_)
223            | Self::InvalidFilter(_)
224            | Self::InvalidRelation(_)
225            | Self::MetadataTooLarge { .. } => ErrorCategory::InvalidInput,
226            #[cfg(feature = "context")]
227            Self::ContextBudget { .. } | Self::ContextOverLimit(_) | Self::SegmentationError(_) => {
228                ErrorCategory::InvalidInput
229            }
230            #[cfg(feature = "context")]
231            Self::IngestDisabled | Self::IngestOutsideRoots(_) | Self::IngestPath(_) => {
232                ErrorCategory::InvalidInput
233            }
234            #[cfg(feature = "context")]
235            Self::FragmentIndexOutOfBounds { .. } | Self::FragmentNotFound(_) => {
236                ErrorCategory::InvalidInput
237            }
238            #[cfg(feature = "context")]
239            Self::UnknownHandle(_) => ErrorCategory::NotFound,
240            #[cfg(feature = "context")]
241            Self::WorkingContextCodec(_) => ErrorCategory::Internal,
242            Self::UnknownMemory(_) => ErrorCategory::NotFound,
243            #[cfg(feature = "persistence")]
244            Self::Memory(_) => ErrorCategory::Internal,
245            Self::Storage(_) | Self::Embed(_) | Self::Extract(_) | Self::Rerank(_) => {
246                ErrorCategory::Internal
247            }
248            // The rollback failure is the storage-level fault that matters
249            // to a client: the write is in an unexpected state.
250            Self::RollbackFailed { .. } => ErrorCategory::Internal,
251        }
252    }
253}