Edge Cases
Recall has a documented behaviour for every degenerate case the codebase has encountered. This page lists each case, what the response looks like, and what the caller should do about it.
An account with zero memories
The user's collection exists but is empty. Vector search returns [], the response is { "memories": [] }. The platform does not silently switch to full-text search on the durable store. The UI should render the empty state.
The query is out of distribution
The vector lookup returns far fewer than k rows or none at all because nothing semantically close exists. memories is short or empty. Try a shorter query, drop a tag filter, or widen the time range — the engine will not pad the result set with random rows.
Clusters are sparse, so the cluster factor is empty
When few or no memories have been clustered yet, cluster_score is 0 for the unassigned rows. The 0.20 weight just contributes nothing on those rows; the rest of the formula compensates. You will see this on brand-new accounts before the clustering pass has run for the first time.
Tier-restricted recall keeps its honesty
filters.tier = "hot" can cut a 20-row vector hit set down to 2 because most matching memories live in warm. The response still has 2 rows; it is not auto-padded. To search broader, drop the tier filter or set computation_tier=high.
Archived and soft-deleted memories are invisible
Both states are filtered before the index call. Even if you know an id, you cannot recall a soft-deleted memory through POST /memory/query. Admins can use the restore endpoint within the seven-day grace; everyone else sees nothing.
Decryption failed but the row still returns
If a row's ciphertext is corrupted or its key version is no longer available, the recall path still returns the row — but with the text replaced by the literal string [REDACTED — DECRYPTION FAILED]. The row is ranked slightly lower so it sinks below valid rows. Render it as plain text; do not try to re-encrypt or recover.
The row has no vector at all
A memory with vector = NULL never appears in semantic recall. It is reachable only via direct lookup or the (separate) full-text endpoints. A background sweep retries embedding for these rows.
The embedding service times out
If the query embedding step takes more than 5 seconds, the request returns HTTP 504. Retry once with the same query — the cache will catch the previously-computed vector and the second call returns immediately.
Every filter is too tight
If filters jointly match zero rows, the engine may run a single relaxed pass (typically by dropping the time range) and return whatever survives. The response has applied_filters.expansion_used=true so the caller can show those rows under a "broader matches" header.
Cost-gated rows have no quality score
Rows whose intelligence_status = "skipped" never had quality, freshness, or confidence scores computed because the cost gate decided they were not worth it. Recall still returns them — the quality factor just contributes 0 (which is its default weight anyway).
Multi-intent decomposition fails
Long compound queries are decomposed into sub-queries before retrieval. If the decomposer cannot produce sub-queries cleanly, the engine falls back to a single-pass query and sets multi_intent=false in the response. No error is raised.