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 /// A fact was longer than [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`],
50 /// the size an embedding model still accepts. Refused BEFORE the embedder
51 /// is called, so the caller learns the limit and its own size instead of
52 /// an opaque backend fault (`ollama embeddings call failed`).
53 #[error(
54 "fact of {bytes} bytes exceeds the embeddable cap of {max} bytes: split it into several \
55 shorter facts, or compile the long text with `compile_context` and remember a summary"
56 )]
57 FactTooLarge {
58 /// The size of the rejected fact, in bytes.
59 bytes: usize,
60 /// The cap that was exceeded ([`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`]).
61 max: usize,
62 },
63
64 /// [`crate::service::MemoryService::remember_with_ttl`] was given
65 /// `Some(0)`. An explicit per-call `0` used to be normalised to "no
66 /// expiry", i.e. a caller who meant "expire immediately" silently got a
67 /// **permanent** fact — the exact opposite intent, with no signal. A TTL
68 /// supplied as *configuration* (`with_default_ttl`, a compile policy's
69 /// `source_ttl_seconds`) still reads `0` as "no TTL policy": that is a
70 /// default, not an intent about one fact.
71 #[error(
72 "ttl_seconds must be greater than 0: omit it to store the fact permanently, or pass the \
73 number of seconds the fact should live"
74 )]
75 ZeroTtl,
76
77 /// [`crate::service::MemoryService::relate`] was asked to link a memory to
78 /// itself. A self-loop states nothing and is traversed by `why` like any
79 /// other edge, so it only adds noise to the evidence trail.
80 #[error(
81 "a memory cannot relate to itself (both endpoints are {0}): pass two different ids, or \
82 record the property in the fact's own metadata"
83 )]
84 SelfRelation(u64),
85
86 /// Caller metadata or a recall filter named a reserved key (`content` or a
87 /// `_veles_`-prefixed system key), which callers may not set or filter on.
88 /// [`crate::storage::AUTO_DATE_FIELD`] is the one documented exception:
89 /// a caller MAY set it (e.g. to date a fact retroactively), so it never
90 /// raises this error.
91 #[error("metadata key '{0}' is reserved")]
92 ReservedKey(String),
93
94 /// Caller-supplied `metadata` (on `remember`/`remember_with_ttl` or a
95 /// context-compiler fragment) exceeded [`crate::limits::MAX_METADATA_BYTES`]
96 /// — a `DoS` guard, since metadata is a keyed lookup facet, not a payload.
97 #[error("metadata of {bytes} bytes exceeds the cap of {max} bytes")]
98 MetadataTooLarge {
99 /// The serialized size of the rejected metadata, in bytes.
100 bytes: usize,
101 /// The cap that was exceeded ([`crate::limits::MAX_METADATA_BYTES`]).
102 max: usize,
103 },
104
105 /// Failure producing a text embedding.
106 #[error("embedding error: {0}")]
107 Embed(#[from] EmbedError),
108
109 /// Failure extracting facts from raw text in
110 /// [`crate::service::MemoryService::remember_extracted`].
111 #[error("extraction error: {0}")]
112 Extract(#[from] ExtractError),
113
114 /// Failure reranking a fused-recall candidate pool in
115 /// [`crate::service::MemoryService::recall_fused_reranked`].
116 #[error("rerank error: {0}")]
117 Rerank(#[from] RerankError),
118
119 /// A fused-recall filter referenced a field name that is not a plain
120 /// identifier, named a reserved key, or carried a non-scalar value.
121 #[error("invalid filter field: {0}")]
122 InvalidFilter(String),
123
124 /// A relation label supplied to [`crate::service::MemoryService::relate`] or
125 /// a [`crate::model::Link`] in
126 /// [`crate::service::MemoryService::remember`] was invalid — empty, too long,
127 /// or contained non-printable characters.
128 #[error("invalid relation label: {0}")]
129 InvalidRelation(String),
130
131 /// A context-compile request carried a token budget that cannot hold any
132 /// context: zero, or not larger than the response reserve the policy
133 /// keeps aside for the model's answer.
134 #[cfg(feature = "context")]
135 #[error("token budget {budget} cannot hold any context (reserve {reserve})")]
136 ContextBudget {
137 /// The caller-supplied token budget.
138 budget: u64,
139 /// The response reserve the policy subtracts from the budget.
140 reserve: u64,
141 },
142
143 /// A context-compile request exceeded a resource cap from
144 /// [`crate::limits`] — too many fragments, or one fragment larger than
145 /// the per-fragment byte ceiling.
146 #[cfg(feature = "context")]
147 #[error("context request over limit: {0}")]
148 ContextOverLimit(String),
149
150 /// A transcript segmentation request failed because of the transcript's
151 /// FORMAT, not its size — e.g. `segmentation.format: "jsonl"` forced on
152 /// a line that does not parse as a `{role, content}` JSON object (see
153 /// [`crate::context::segment::segment_transcript`]). Deliberately
154 /// distinct from [`Self::ContextOverLimit`] (issue #1516, m2): a parsing
155 /// failure is not a budget/cap breach, so a caller filtering on the
156 /// error message no longer sees the misleading "over limit" wording for
157 /// what is really a malformed-input error. Same
158 /// [`ErrorCategory::InvalidInput`] classification as `ContextOverLimit`
159 /// (both map to `INVALID_PARAMS` over MCP) — only the variant, and the
160 /// message, differ.
161 #[cfg(feature = "context")]
162 #[error("transcript segmentation error: {0}")]
163 SegmentationError(String),
164
165 /// A `ctx://source/<hash>` handle was malformed or nothing is stored
166 /// under it (the source was never stored, expired, or was forgotten).
167 #[cfg(feature = "context")]
168 #[error("unknown context source handle: {0}")]
169 UnknownHandle(String),
170
171 /// [`crate::service::MemoryService::explain_compilation`]'s
172 /// `fragment_index` named a position beyond `request.fragments`.
173 #[cfg(feature = "context")]
174 #[error("fragment_index {index} is out of bounds: request.fragments has {len} entries")]
175 FragmentIndexOutOfBounds {
176 /// The out-of-bounds index the caller supplied.
177 index: usize,
178 /// The actual number of fragments in the request.
179 len: usize,
180 },
181
182 /// [`crate::service::MemoryService::explain_compilation`] found no
183 /// decision matching the requested `fragment_id` (and no
184 /// `fragment_index` was given, or it selected nothing new to check).
185 #[cfg(feature = "context")]
186 #[error("the request contains no fragment with id {0}")]
187 FragmentNotFound(u64),
188
189 /// [`crate::service::MemoryService::save_working_context`] was given a
190 /// `WorkingContext` with nothing in it. The write is an idempotent upsert,
191 /// so an empty save would *replace* — that is, destroy — the rich state
192 /// already stored under this project and session. The one tool whose whole
193 /// job is surviving a context loss must not be able to cause one on a call
194 /// that carries nothing.
195 #[cfg(feature = "context")]
196 #[error(
197 "working context is empty: fill at least one of goal, active_constraints, verified_facts, \
198 open_hypotheses, decisions, exact_evidence or pending_actions — saving an empty state \
199 would replace whatever is already stored under this project and session"
200 )]
201 EmptyWorkingContext,
202
203 /// A persisted working context could not be (de)serialized — the stored
204 /// payload predates or postdates this crate's schema.
205 #[cfg(feature = "context")]
206 #[error("working context codec error: {0}")]
207 WorkingContextCodec(String),
208
209 /// A context fragment carried a `path` (V2b-1 path ingestion) but no
210 /// filesystem root is configured (`VELESDB_MEMORY_INGEST_ROOTS` unset or
211 /// empty) — the tool is always advertised, but ingestion itself is
212 /// opt-in. Also the fallback the pure compiler core reports when a
213 /// `path` fragment reaches it unresolved (e.g. a binding that has no
214 /// ingest adapter, such as the WASM build): [`crate::context`] never
215 /// performs I/O itself, so an un-cleared `path` field always means the
216 /// adapter that should have resolved or rejected it was skipped.
217 #[cfg(feature = "context")]
218 #[error(
219 "path ingestion is disabled: set VELESDB_MEMORY_INGEST_ROOTS to enable the `path` field"
220 )]
221 IngestDisabled,
222
223 /// A `path`-referenced fragment resolved (after following symlinks) to a
224 /// location outside every configured ingest root. Carries the
225 /// caller-supplied `path` VERBATIM, never the canonicalized target — the
226 /// resolved location may be filesystem structure the caller has no
227 /// business learning about (e.g. that a symlink escapes).
228 #[cfg(feature = "context")]
229 #[error("path '{0}' is outside the configured ingest roots")]
230 IngestOutsideRoots(String),
231
232 /// A `path`-referenced fragment could not be read for any reason other
233 /// than escaping the ingest roots: a relative path (an MCP server's
234 /// working directory is unpredictable, so only absolute paths are
235 /// accepted), a path that does not exist or is not a plain file
236 /// (directories are rejected), a `path` fragment combined with
237 /// non-empty `content` or a `media` payload (`path` is exclusive,
238 /// though `content` and `media` may travel together), a fragment
239 /// carrying none of the three, or a file whose bytes are not valid
240 /// UTF-8.
241 #[cfg(feature = "context")]
242 #[error("cannot ingest path: {0}")]
243 IngestPath(String),
244
245 /// A `remember` link failed after the fact was stored AND the
246 /// compensating rollback delete also failed — unlike every other error
247 /// from `remember`, the fact **remains stored**. Both errors are
248 /// carried so the caller can see why the write failed and why the
249 /// cleanup couldn't undo it.
250 ///
251 /// Neither field is `#[source]` — deliberately: the `Display` message
252 /// already embeds both errors, and a source chain would double-print
253 /// them in chain-style reports (anyhow, miette). Match on the variant
254 /// to inspect the two errors programmatically.
255 #[error(
256 "link failed ({cause}); rollback delete also failed ({rollback}) — the fact remains stored"
257 )]
258 RollbackFailed {
259 /// The link failure that triggered the rollback.
260 cause: Box<MemoryError>,
261 /// The storage failure that prevented the rollback delete.
262 rollback: Box<MemoryError>,
263 },
264}
265
266impl MemoryError {
267 /// Classify this error into a transport-neutral [`ErrorCategory`]. Adapters
268 /// map the *category*, not the variant, so the client-facing taxonomy stays
269 /// identical across the MCP server and every binding.
270 #[must_use]
271 pub fn category(&self) -> ErrorCategory {
272 match self {
273 Self::EmptyFact
274 | Self::ReservedKey(_)
275 | Self::InvalidFilter(_)
276 | Self::InvalidRelation(_)
277 | Self::FactTooLarge { .. }
278 | Self::ZeroTtl
279 | Self::SelfRelation(_)
280 | Self::MetadataTooLarge { .. } => ErrorCategory::InvalidInput,
281 #[cfg(feature = "context")]
282 Self::EmptyWorkingContext => ErrorCategory::InvalidInput,
283 #[cfg(feature = "context")]
284 Self::ContextBudget { .. } | Self::ContextOverLimit(_) | Self::SegmentationError(_) => {
285 ErrorCategory::InvalidInput
286 }
287 #[cfg(feature = "context")]
288 Self::IngestDisabled | Self::IngestOutsideRoots(_) | Self::IngestPath(_) => {
289 ErrorCategory::InvalidInput
290 }
291 #[cfg(feature = "context")]
292 Self::FragmentIndexOutOfBounds { .. } | Self::FragmentNotFound(_) => {
293 ErrorCategory::InvalidInput
294 }
295 #[cfg(feature = "context")]
296 Self::UnknownHandle(_) => ErrorCategory::NotFound,
297 #[cfg(feature = "context")]
298 Self::WorkingContextCodec(_) => ErrorCategory::Internal,
299 Self::UnknownMemory(_) => ErrorCategory::NotFound,
300 #[cfg(feature = "persistence")]
301 Self::Memory(_) => ErrorCategory::Internal,
302 Self::Storage(_) | Self::Embed(_) | Self::Extract(_) | Self::Rerank(_) => {
303 ErrorCategory::Internal
304 }
305 // The rollback failure is the storage-level fault that matters
306 // to a client: the write is in an unexpected state.
307 Self::RollbackFailed { .. } => ErrorCategory::Internal,
308 }
309 }
310}