close

DEV Community

Nero
Nero

Posted on

Two Silent Data-Loss Bugs in a Production Memory System

Technical writeup on two independently discovered bugs in Memanto's memory retrieval and storage pipeline. Both bugs cause silent data loss — no errors, no warnings, just missing results.


Context

Memanto is an open-source agent memory system (1.6k+ stars) that provides persistent memory storage and retrieval for AI agents. It uses a vector store backend (Moorcheh) for similarity search, with post-retrieval filters for temporal scoping and confidence thresholding.

The system has three distinct filtering layers:

  1. Backend similarity search — returns top-K ranked results from the vector store
  2. Post-retrieval temporal filter — narrows results to a time window specified by created_after / created_before
  3. Post-retrieval TTL filter — removes expired memories based on a configurable TTL

The bugs live in the interaction between these layers.


Bug 1: TTL Timeline Amnesia

Severity: HIGH
CWE-220: Sensitive Data Under Different Partition

Root Cause

The _fetch_all_memories method fetches results from the backend, then runs post-retrieval filtering. The problem: it only fetches limit + offset rows from the backend before filtering.

# Pseudocode of the vulnerable path
def search_memories(query, limit, created_after=None, created_before=None):
    results = backend.similarity_search(query, top_k=limit + offset)
    results = _apply_temporal_filter(results, created_after, created_before)
    results = _filter_expired_memories(results)
    return results[:limit]
Enter fullscreen mode Exit fullscreen mode

When a temporal window is narrow (e.g., "memories from January 2026") but the top-ranked similarity results are all from a different time period (e.g., June 2026), the January memories get silently excluded — they never made it into the first limit + offset batch.

This makes search_as_of (historical memory queries) non-deterministic. Running the same query twice can return different results depending on which memories happen to rank higher in similarity.

Reproduction

  1. Store 20 "recent" memories (June 2026) and 5 "old" memories (January 2026)
  2. All recent memories rank higher than old ones by vector similarity
  3. Query with created_after="2026-01-01" and created_before="2026-01-31" and limit=10
  4. Expected: 5 January memories returned
  5. Actual: 0 results — all 10 fetched rows are from June, and none pass the temporal filter

Fix

The candidate pool must be widened whenever a temporal or TTL filter is active. Instead of fetching limit + offset rows, the backend query should request MOORCHEH_MAX_TOP_K (the maximum the vector store supports), then apply post-retrieval filters on the full candidate set before paginating.

# Fixed path
def search_memories(query, limit, created_after=None, created_before=None):
    has_post_filter = bool(created_after or created_before)
    fetch_k = MOORCHEH_MAX_TOP_K if has_post_filter else limit + offset
    results = backend.similarity_search(query, top_k=fetch_k)
    results = _apply_temporal_filter(results, created_after, created_before)
    results = _filter_expired_memories(results)
    return results[:limit]
Enter fullscreen mode Exit fullscreen mode

Critical detail: This widening must also apply when only the TTL filter is active (no user-specified temporal filter), because expired top-ranked rows can crowd out valid lower-ranked memories the same way a temporal window can.

Why This Is Subtle

Most temporal queries during development use recent time windows that overlap with the recency-biased ranking, so the bug never manifests. It only surfaces when querying historical data or when the TTL window is tight relative to the data distribution.


Bug 2: Batch Upload Silent Data Loss

Severity: HIGH
CWE-252: Unchecked Return Value

Root Cause

batch_store_memories sends multiple documents to the backend in a single call but only checks the top-level response status. Individual document-level upload failures are silently swallowed.

# Pseudocode of the vulnerable path
def batch_store_memories(documents):
    response = backend.documents.upload(namespace, documents)
    if response["status"] == "ok":
        # All good, right?
        return {"stored": len(documents)}
    raise StorageError("batch upload failed")
Enter fullscreen mode Exit fullscreen mode

The backend's batch upload endpoint returns per-document status (documents[].status) alongside the overall response. If 3 out of 10 documents fail validation, serialization, or size checks, the caller sees status: "ok" and reports 10 stored — but only 7 actually persisted.

Reproduction

  1. Prepare a batch of 10 documents where 3 contain payloads that exceed the backend's per-document size limit
  2. Call batch_store_memories(batch)
  3. Response: {"stored": 10}
  4. Query for the 3 oversized documents — they don't exist

Fix

Validate each document's upload result individually:

def batch_store_memories(documents):
    response = backend.documents.upload(namespace, documents)
    individual = response.get("documents", [])
    failed = [d["id"] for d in individual if d.get("status") != "ok"]
    if failed:
        logger.warning("batch_store: %d/%d documents failed", len(failed), len(documents))
    stored = len(documents) - len(failed)
    return {"stored": stored, "failed": failed if failed else None}
Enter fullscreen mode Exit fullscreen mode

Why This Is Subtle

The top-level status is "ok" because the HTTP request succeeded — the backend accepted the batch. The per-document failures are buried in the response body. Developers testing with small, valid documents never hit the failure path.


Impact

Both bugs are silent — no exceptions, no log warnings, no indication that data was lost. For an AI agent memory system, data loss means:

  • Lost context: The agent forgets information it was told to remember
  • Non-deterministic recall: The same query returns different results at different times
  • Corrupted memory timeline: Historical queries (search_as_of) produce wrong answers

These are exactly the failure modes that erode trust in agent memory systems. When an agent says "I remember X" and then can't recall X on the next query, users assume the AI is unreliable — when in fact the memory system is silently dropping data.


Fixes Submitted

Both fixes are included in PR [^1610](https://github.com/moorcheh-ai/memanto/pull/1610) on the Memanto repository, along with deterministic regression tests that reproduce each bug.

  • test_memory_read_temporal_recall.py — verifies that time-scoped queries recall rows outside the top similarity page
  • test_backend.py — verifies batch upload per-document validation
  • test_temporal_helpers.py — verifies timestamp parsing edge cases and filter robustness

Takeaways

For systems with layered filtering: Always consider the interaction between ranking and filtering. A filter that operates after ranking can silently drop results that should have been included if the candidate pool is smaller than the filter window.

For batch operations: Never trust a top-level success status. Validate per-item results. Batch APIs often return partial success, and partial success without explicit handling is just data loss with a green checkmark.

Top comments (0)