<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Expiration on RockB</title><link>https://baeseokjae.github.io/tags/expiration/</link><description>Recent content in Expiration on RockB</description><image><title>RockB</title><url>https://baeseokjae.github.io/images/og-default.png</url><link>https://baeseokjae.github.io/images/og-default.png</link></image><generator>Hugo</generator><language>en-us</language><lastBuildDate>Wed, 28 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://baeseokjae.github.io/tags/expiration/index.xml" rel="self" type="application/rss+xml"/><item><title>When an Expired Memory Blocks the New One: mem0 OSS Expiry, Deduplication and the Write That Never Lands</title><link>https://baeseokjae.github.io/posts/mem0-expired-memory-blocks-rewrites-2026/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0000</pubDate><guid>https://baeseokjae.github.io/posts/mem0-expired-memory-blocks-rewrites-2026/</guid><description>On mem0ai 2.2.1 an expired memory still reaches the extraction prompt and the md5 dedup set, so a fresh add() of the same fact returns an empty list. Read the code path, the provider call an expiry update still makes, and a delete-then-add workaround.</description><content:encoded><![CDATA[<p>Your agent stored <code>user prefers aisle seats</code> with an expiration date. The date passed. The user says it again. You call <code>add()</code> with a fresh <code>expiration_date</code>, get no exception, get an empty <code>results</code> list, and <code>get_all()</code> shows nothing. The fact never lands, and it will never land as long as the expired copy is what the search returns.</p>
<p>On mem0ai 2.2.1 — the Python OSS package, self-hosted — the cause is a read/write asymmetry. Expiration is applied as a filter on the read paths but not on the ADD path. The expired row is still handed to the extraction LLM as an &ldquo;existing memory&rdquo;, and its <code>hash</code> is still loaded into the dedup set, so the fresh write is dropped as a duplicate of a record the user can no longer see. The same write machinery adds a second dependency: an update that only touches metadata or the expiry date still calls the embedding provider, and a provider error stops that update before anything is persisted.</p>
<p>This article walks the exact code path at a pinned tag, separates what is verified in source from what is only reported in the issue tracker, and gives a write pattern that does not depend on a fix shipping.</p>
<p>Everything below is scoped to <code>mem0ai==2.2.1</code> (Python, self-hosted vector store). The hosted platform is out of scope, and no recall or accuracy benchmark is claimed.</p>
<h2 id="the-two-minute-check">The two-minute check</h2>
<p>Before reading further, decide which failure you actually have. The first two look identical from the caller&rsquo;s side — a successful call, no error, nothing visible. The third surfaces as an update that failed or changed nothing.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>mem <span style="color:#f92672">=</span> Memory<span style="color:#f92672">.</span>from_config(cfg)          <span style="color:#75715e"># pinned: mem0ai==2.2.1</span>
</span></span><span style="display:flex;"><span>res <span style="color:#f92672">=</span> mem<span style="color:#f92672">.</span>add(<span style="color:#e6db74">&#34;user prefers aisle seats&#34;</span>, user_id<span style="color:#f92672">=</span>u,
</span></span><span style="display:flex;"><span>              expiration_date<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;2026-11-30&#34;</span>)   <span style="color:#75715e"># future date, same fact as an expired row</span>
</span></span><span style="display:flex;"><span>print(res)                                  <span style="color:#75715e"># empty results means the write was suppressed</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># get_all() takes entity scope inside `filters`; top-level user_id is rejected on 2.2.1</span>
</span></span><span style="display:flex;"><span>print(mem<span style="color:#f92672">.</span>get_all(filters<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;user_id&#34;</span>: u})[<span style="color:#e6db74">&#34;results&#34;</span>])                     <span style="color:#75715e"># expired rows stay hidden</span>
</span></span><span style="display:flex;"><span>print(mem<span style="color:#f92672">.</span>get_all(filters<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;user_id&#34;</span>: u}, show_expired<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)[<span style="color:#e6db74">&#34;results&#34;</span>])  <span style="color:#75715e"># the ghost row is still there</span>
</span></span></code></pre></div><p>If <code>results</code> is empty while <code>show_expired=True</code> still lists the old row, you are in the expired-record suppression case. If the date itself is impossible (<code>2099-02-30</code>), you are in the CLI validation case. If a metadata-only or expiry-only <code>update()</code> raises a provider error — or leaves the record unchanged because your retry wrapper swallowed one — you are in the unnecessary-embedding path described in issue #7561, covered below.</p>
<h2 id="expiration-in-221-hides-a-record-it-does-not-delete-it">Expiration in 2.2.1 hides a record; it does not delete it</h2>
<p><code>_payload_is_expired</code> compares the stored <code>expiration_date</code> against today&rsquo;s UTC date, and both <code>get_all()</code> and <code>search()</code> skip rows it flags unless <code>show_expired=True</code> is passed. The helper spans lines 442-451 of <code>mem0/memory/main.py</code> at tag <code>v2.2.1</code>, with the comparison at line 449 and the <code>except ValueError: return False</code> closing it at 450-451. The read-path filters are applied at lines 1369 and 1684. Because the date comparison is <code>&lt; now</code>, a memory that expires today is still visible today.</p>
<p>That is a visibility filter over live rows, not a deletion and not a tombstone. Nothing in the expiry helper removes the vector, and there is no expiry-driven cleanup pass in the file. So the row keeps its payload, keeps its <code>hash</code>, and keeps being a candidate for any code path that does not call <code>_payload_is_expired</code>. Source: <a href="https://raw.githubusercontent.com/mem0ai/mem0/v2.2.1/mem0/memory/main.py">mem0ai/mem0 v2.2.1 main.py</a>.</p>
<h2 id="the-add-path-never-applies-that-filter">The ADD path never applies that filter</h2>
<p><code>_add_to_vector_store()</code> runs in phases. Phase 1 retrieves candidate existing memories with <code>vector_store.search(...)</code> at <code>top_k=10</code> and immediately maps them into the extraction prompt without checking expiry (lines 925-943):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Phase 1: Existing memory retrieval</span>
</span></span><span style="display:flex;"><span>existing_results <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>vector_store<span style="color:#f92672">.</span>search(
</span></span><span style="display:flex;"><span>    query<span style="color:#f92672">=</span>parsed_messages,
</span></span><span style="display:flex;"><span>    vectors<span style="color:#f92672">=</span>query_embedding,
</span></span><span style="display:flex;"><span>    top_k<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>,
</span></span><span style="display:flex;"><span>    filters<span style="color:#f92672">=</span>search_filters,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span><span style="color:#f92672">...</span>
</span></span><span style="display:flex;"><span>existing_memories<span style="color:#f92672">.</span>append({<span style="color:#e6db74">&#34;id&#34;</span>: str(idx), <span style="color:#e6db74">&#34;text&#34;</span>: mem<span style="color:#f92672">.</span>payload<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;data&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>)})
</span></span></code></pre></div><p>Phase 5 then builds the dedup set from the same unfiltered list (lines 1007-1013):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Phase 4: Per-memory CPU processing + Phase 5: Hash dedup</span>
</span></span><span style="display:flex;"><span>existing_hashes <span style="color:#f92672">=</span> set()
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> mem <span style="color:#f92672">in</span> existing_results:
</span></span><span style="display:flex;"><span>    h <span style="color:#f92672">=</span> mem<span style="color:#f92672">.</span>payload<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;hash&#34;</span>) <span style="color:#66d9ef">if</span> hasattr(mem, <span style="color:#e6db74">&#34;payload&#34;</span>) <span style="color:#f92672">and</span> mem<span style="color:#f92672">.</span>payload <span style="color:#66d9ef">else</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> h:
</span></span><span style="display:flex;"><span>        existing_hashes<span style="color:#f92672">.</span>add(h)
</span></span></code></pre></div><p>Two consequences follow, and only one of them is obvious.</p>
<p>The first is prompt contamination. The extraction LLM receives the expired text inside the &ldquo;existing memories&rdquo; block. With an additive prompt, the model is being asked whether the incoming statement adds anything new relative to what it is shown — and it is shown the expired copy. So the model can legitimately decide there is nothing new to extract, and return no memories at all.</p>
<p>The second is silent deduplication. If extraction does emit the text, the loop at lines 1022-1025 computes <code>hashlib.md5(text.encode()).hexdigest()</code> and skips the record when that hash is in <code>existing_hashes</code>. The expired row&rsquo;s hash is in that set.</p>
<p>Both routes end at the same branch. When every candidate record is skipped, the function saves the messages for context and returns an empty list to <code>add()</code> (lines 1043-1045, with <code>return []</code> at line 1045). No exception, no warning at default log level — the caller sees a successful-shaped result object with nothing in it.</p>
<p>This is the mechanism the reporter describes in <a href="https://github.com/mem0ai/mem0/issues/7612">mem0ai/mem0#7612</a>, which reports it for both <code>Memory</code> and <code>AsyncMemory</code>. The issue is open, created 2026-10-12, with three comments at the time this analysis read it.</p>
<h2 id="why-the-suppression-is-deterministic-only-sometimes">Why the suppression is deterministic only sometimes</h2>
<p>The dedup gate is <code>md5</code> over the exact extracted string, not over a normalized form and not over a semantic embedding. <code>text_lemmatized</code> is computed for BM25, and it is stored in the payload, but it is not what participates in deduplication.</p>
<p>That has a practical implication for anyone trying to reproduce the bug: the failure is exact only when the extraction LLM emits the same characters as the stored, expired copy. Rephrase the fact (<code>aisle seats</code> versus <code>prefers an aisle seat</code>) and the hash differs, the dedup check passes, and a new row is written — while the stale one stays hidden. Two readers with the same package version can therefore disagree about whether the bug exists, depending on their prompt and model. Treat &ldquo;reproduces&rdquo; as &ldquo;same extraction text on the same top-10 result set&rdquo;, not as a version-level guarantee.</p>
<p>The contaminated-prompt half of the mechanism is softer still, because it depends on how the model reacts to seeing its own expired text. Source-level inspection establishes that the expired row is passed in; it cannot establish what a given model does with it.</p>
<h2 id="the-other-half-clearing-an-expiry-still-calls-the-embedding-provider">The other half: clearing an expiry still calls the embedding provider</h2>
<p>If the row is hidden rather than deleted, the obvious repair is to clear its expiry and let the next <code>add()</code> update it in place. In the Python SDK at 2.2.1 the field handling works: <code>update()</code> takes <code>expiration_date</code> with a sentinel default, and an explicit <code>None</code> normalizes to <code>None</code>, which <code>_payload_is_expired</code> treats as &ldquo;no expiry&rdquo;:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> expiration_date <span style="color:#f92672">is</span> <span style="color:#f92672">not</span> _UNSET:
</span></span><span style="display:flex;"><span>    update_metadata <span style="color:#f92672">=</span> update_metadata <span style="color:#f92672">or</span> {}
</span></span><span style="display:flex;"><span>    update_metadata[<span style="color:#e6db74">&#34;expiration_date&#34;</span>] <span style="color:#f92672">=</span> _normalize_expiration_date(expiration_date)
</span></span></code></pre></div><p>That is lines 1871-1873, and <code>_normalize_expiration_date(None)</code> returns <code>None</code> at lines 427-429. Note the sentinel: omitting the argument leaves the field alone, while passing <code>None</code> clears it. Collapsing those two cases in your own wrapper is how you get an &ldquo;update that silently removes the expiry&rdquo;.</p>
<p>What the SDK does not give you is a free clear. <code>update()</code> itself embeds only when you pass new text — lines 1875-1877 leave the map empty otherwise:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>existing_embeddings <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> text <span style="color:#f92672">is</span> <span style="color:#f92672">not</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>    existing_embeddings[text] <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>embedding_model<span style="color:#f92672">.</span>embed(text, <span style="color:#e6db74">&#34;update&#34;</span>)
</span></span></code></pre></div><p>That guard is where a source skim stops, and it is not the last word on the call path. <code>update()</code> passes the map to <code>_update_memory()</code> at line 1879. There, a missing <code>data</code> argument is replaced with the stored text (lines 2065-2067), and the embedding step is a cache-or-embed:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> data <span style="color:#f92672">in</span> existing_embeddings:
</span></span><span style="display:flex;"><span>    embeddings <span style="color:#f92672">=</span> existing_embeddings[data]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>    embeddings <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>embedding_model<span style="color:#f92672">.</span>embed(data, <span style="color:#e6db74">&#34;update&#34;</span>)
</span></span></code></pre></div><p>For a metadata-only or expiry-only call the map is empty, so the <code>else</code> branch always runs: line 2085 embeds the unchanged text, and only then does line 2087 write the payload through <code>vector_store.update</code>. The provider call is unnecessary — the vector cannot change — but it sits on the critical path. If it raises, the payload write is never reached, and the expiry is still set on the stored row.</p>
<p>That is exactly what <a href="https://github.com/mem0ai/mem0/issues/7561">mem0ai/mem0#7561</a> reports: each metadata-only, expiration-set and expiration-clear operation calls embedding once, and injecting an embedding <code>APIConnectionError</code> prevents the update while the existing record stays intact. The issue is open, created 2026-10-07, with four comments when read on 2026-10-28, and its environment block names an installed <code>mem0ai</code> 2.2.1 distribution running against a real <code>Memory</code>/<code>AsyncMemory</code>, a temporary local Qdrant and in-memory SQLite, mocking only the external OpenAI <code>Embeddings.create</code> boundary. That is the same code the excerpts above come from, which is why this is a confirmed defect on the pinned version rather than an ageing report.</p>
<p>So the clear is conditional: it works when your embedding provider is healthy — at the cost of one embedding call the operation does not need — and raises before persisting when it is not. If the provider is the component that is failing, treat delete-then-add as the fallback rather than the clear, with the caveat in the workaround section that <code>add()</code> embeds too: the fallback changes which failure you see, not whether a provider is needed.</p>
<p>The CLIs are worse off. Both expose <code>--expires</code> to set an expiration and neither exposes a clear operation, and their platform backends only attach <code>expiration_date</code> when the value is truthy, so an explicit null is dropped rather than sent as a clear. That gap is documented in <a href="https://github.com/mem0ai/mem0/issues/7699">mem0ai/mem0#7699</a>, open since 2026-10-21. If your agent&rsquo;s memory maintenance runs through the CLI, the SDK escape hatch is not reachable from it — you need the SDK path or an explicit delete.</p>
<h2 id="the-third-path-an-impossible-date-that-escapes-validation">The third path: an impossible date that escapes validation</h2>
<p><code>2099-02-30</code> is correctly shaped and impossible. Per <a href="https://github.com/mem0ai/mem0/issues/7713">mem0ai/mem0#7713</a> (open, created 2026-10-23), the Python CLI&rsquo;s shared <code>_validate_expires()</code> helper lets such a date reach <code>date.fromisoformat</code>, which raises <code>ValueError</code> — uncaught, and in agent mode outside the normal JSON error envelope, so a calling agent gets a traceback-shaped failure instead of a structured error it can branch on.</p>
<p>The reporter&rsquo;s reproduction invokes the real Typer parser with a mocked backend and telemetry disabled; no credentials or live HTTP requests are involved. That is a valid way to test input validation, and it is also why the article treats this as a reported CLI defect rather than something verified against a live install here: the CLI ships separately from the <code>mem0ai</code> wheel, so a wheel-version pin does not pin the CLI.</p>
<p>The SDK raises the same <code>ValueError</code> deliberately. <code>_normalize_expiration_date</code> wraps <code>date.fromisoformat</code> and re-raises with a readable message (&ldquo;expiration_date must be a valid date in YYYY-MM-DD format.&rdquo;) at lines 435-438. The difference is where the error surfaces, not whether it exists: your boundary should reject the date before either the CLI or the SDK has to.</p>
<h2 id="what-actually-holds-at-mem0ai-221">What actually holds at mem0ai 2.2.1</h2>
<table>
	<thead>
			<tr>
					<th>Behaviour</th>
					<th>Holds at <code>mem0ai==2.2.1</code>?</th>
					<th>Basis</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Expired row suppresses a fresh inferred <code>add()</code> of identical text</td>
					<td>Yes</td>
					<td>unfiltered <code>existing_results</code> in Phase 1/5, <code>return []</code> at 1045</td>
			</tr>
			<tr>
					<td>Expiry hides rather than deletes</td>
					<td>Yes</td>
					<td><code>_payload_is_expired</code> used only on read paths</td>
			</tr>
			<tr>
					<td>SDK clears an expiry via <code>expiration_date=None</code></td>
					<td>Yes, at the cost of one embedding call</td>
					<td>sentinel at 1871-1873; embed at 2085 before write at 2087</td>
			</tr>
			<tr>
					<td>CLI can clear an expiry</td>
					<td>No</td>
					<td>no flag; falsy value omitted (<a href="https://github.com/mem0ai/mem0/issues/7699">#7699</a>)</td>
			</tr>
			<tr>
					<td>Metadata- or expiry-only <code>update()</code> calls the embedding provider</td>
					<td>Yes</td>
					<td>cache miss in <code>_update_memory</code>, embed at 2085 (<a href="https://github.com/mem0ai/mem0/issues/7561">#7561</a>)</td>
			</tr>
			<tr>
					<td>Impossible CLI date raises uncaught <code>ValueError</code></td>
					<td>Reported, not re-executed</td>
					<td><a href="https://github.com/mem0ai/mem0/issues/7713">#7713</a></td>
			</tr>
	</tbody>
</table>
<p>Version context: <code>mem0ai</code> 2.2.1 was uploaded 2026-09-25, per <a href="https://pypi.org/pypi/mem0ai/json">PyPI metadata</a>. The npm/TypeScript line is a separate release train (<code>ts-v3.3.1</code>) and is not covered by any claim here.</p>
<h2 id="a-write-pattern-that-does-not-wait-for-a-fix">A write pattern that does not wait for a fix</h2>
<p>The fix belongs upstream. Your agent still has to store the preference tonight, so the pattern below removes the dependency on both halves of the suppression: it deletes the hidden row by identity, writes the new fact with a clean dedup set, and asserts visibility instead of trusting the return value.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> datetime <span style="color:#f92672">import</span> date, datetime, timezone
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">_valid_date</span>(s: str) <span style="color:#f92672">-&gt;</span> str:
</span></span><span style="display:flex;"><span>    d <span style="color:#f92672">=</span> date<span style="color:#f92672">.</span>fromisoformat(s)                       <span style="color:#75715e"># raises on 2099-02-30</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> d <span style="color:#f92672">&lt;</span> datetime<span style="color:#f92672">.</span>now(timezone<span style="color:#f92672">.</span>utc)<span style="color:#f92672">.</span>date():
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">&#34;expiration_date is already in the past&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> d<span style="color:#f92672">.</span>isoformat()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">rewrite_fact</span>(mem, text: str, user_id: str, expires: str) <span style="color:#f92672">-&gt;</span> dict:
</span></span><span style="display:flex;"><span>    expires <span style="color:#f92672">=</span> _valid_date(expires)
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 1. remove any copy that is expired (hidden from reads, still in dedup)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> old <span style="color:#f92672">in</span> mem<span style="color:#f92672">.</span>get_all(filters<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;user_id&#34;</span>: user_id}, show_expired<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)[<span style="color:#e6db74">&#34;results&#34;</span>]:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> old[<span style="color:#e6db74">&#34;memory&#34;</span>] <span style="color:#f92672">==</span> text <span style="color:#f92672">and</span> old<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;expiration_date&#34;</span>) <span style="color:#f92672">and</span> \
</span></span><span style="display:flex;"><span>           date<span style="color:#f92672">.</span>fromisoformat(old[<span style="color:#e6db74">&#34;expiration_date&#34;</span>]) <span style="color:#f92672">&lt;</span> datetime<span style="color:#f92672">.</span>now(timezone<span style="color:#f92672">.</span>utc)<span style="color:#f92672">.</span>date():
</span></span><span style="display:flex;"><span>            mem<span style="color:#f92672">.</span>delete(old[<span style="color:#e6db74">&#34;id&#34;</span>])
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 2. write with a dedup set that no longer contains the ghost</span>
</span></span><span style="display:flex;"><span>    res <span style="color:#f92672">=</span> mem<span style="color:#f92672">.</span>add(text, user_id<span style="color:#f92672">=</span>user_id, expiration_date<span style="color:#f92672">=</span>expires)
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 3. assert the outcome the API will not assert for you</span>
</span></span><span style="display:flex;"><span>    visible <span style="color:#f92672">=</span> [m <span style="color:#66d9ef">for</span> m <span style="color:#f92672">in</span> mem<span style="color:#f92672">.</span>get_all(filters<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;user_id&#34;</span>: user_id})[<span style="color:#e6db74">&#34;results&#34;</span>]
</span></span><span style="display:flex;"><span>               <span style="color:#66d9ef">if</span> m[<span style="color:#e6db74">&#34;memory&#34;</span>] <span style="color:#f92672">==</span> text]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> visible:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">RuntimeError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;mem0 accepted the write but it is not visible: </span><span style="color:#e6db74">{</span>res<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {<span style="color:#e6db74">&#34;added&#34;</span>: res, <span style="color:#e6db74">&#34;visible_ids&#34;</span>: [m[<span style="color:#e6db74">&#34;id&#34;</span>] <span style="color:#66d9ef">for</span> m <span style="color:#f92672">in</span> visible]}
</span></span></code></pre></div><p>Three details make this work rather than merely look tidy. <code>show_expired=True</code> is required on the lookup, because the row you must delete is precisely the one the default read hides. <code>get_all()</code> returns a <code>{&quot;results&quot;: [...]}</code> envelope in 2.2.1 and rejects top-level <code>user_id</code>, so both the lookup and the verification index <code>[&quot;results&quot;]</code> and pass the user through <code>filters</code>. The delete happens before the add, so the expired hash is absent from the <code>top_k=10</code> result set that Phase 5 reads. And the final assertion is on <code>get_all()</code>, not on the return value of <code>add()</code>, because an empty <code>results</code> list is a legal return for &ldquo;everything was deduplicated&rdquo;.</p>
<p>The assertion generalizes into a test you can keep:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">test_expired_memory_does_not_block_rewrite</span>(mem, user_id):
</span></span><span style="display:flex;"><span>    mem<span style="color:#f92672">.</span>add(<span style="color:#e6db74">&#34;user prefers aisle seats&#34;</span>, user_id<span style="color:#f92672">=</span>user_id, expiration_date<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;2026-01-01&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> mem<span style="color:#f92672">.</span>get_all(filters<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;user_id&#34;</span>: user_id})[<span style="color:#e6db74">&#34;results&#34;</span>] <span style="color:#f92672">==</span> []        <span style="color:#75715e"># expired = hidden</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> len(mem<span style="color:#f92672">.</span>get_all(filters<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;user_id&#34;</span>: user_id},
</span></span><span style="display:flex;"><span>                           show_expired<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)[<span style="color:#e6db74">&#34;results&#34;</span>]) <span style="color:#f92672">==</span> <span style="color:#ae81ff">1</span>               <span style="color:#75715e"># still stored</span>
</span></span><span style="display:flex;"><span>    rewrite_fact(mem, <span style="color:#e6db74">&#34;user prefers aisle seats&#34;</span>, user_id, <span style="color:#e6db74">&#34;2026-12-31&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> len([m <span style="color:#66d9ef">for</span> m <span style="color:#f92672">in</span> mem<span style="color:#f92672">.</span>get_all(filters<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;user_id&#34;</span>: user_id})[<span style="color:#e6db74">&#34;results&#34;</span>]
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> m[<span style="color:#e6db74">&#34;memory&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;user prefers aisle seats&#34;</span>]) <span style="color:#f92672">==</span> <span style="color:#ae81ff">1</span>
</span></span></code></pre></div><p>One operational note that follows from the same code: delete-then-add does not make the embedding provider optional, because <code>add()</code> embeds as well — once for the Phase 1 search query and once per extracted memory. What it changes is where the failure lands. The delete runs first, so a failed <code>add()</code> leaves the fact absent rather than merely stale, which is worse if you cannot retry. If the row must survive a failed write, add a differently-worded copy first, confirm it is visible, and only then delete the expired original. And if you write through the CLI, validate the date yourself before it reaches <code>_validate_expires()</code>; the SDK&rsquo;s <code>ValueError</code> is useful, the CLI&rsquo;s uncaught one is not.</p>
<p>This pattern instantiates the vendor-agnostic checks in <a href="/posts/agent-memory-write-path-reliability-2026/">agent memory write-path reliability</a>: a write is not complete until a read confirms it, and dedup must operate on the same visibility scope as the read path. If you have not set up mem0 yet, the <a href="/posts/mem0-agent-memory-guide-2026/">mem0 setup guide</a> covers the configuration this pattern assumes; if the question is whether to stay on mem0 at all, compare frameworks in <a href="/posts/mem0-vs-zep-production-guide-2026/">mem0 vs Zep</a> before debugging expiry, and see the <a href="/posts/best-ai-agent-memory-frameworks-2026/">agent memory framework landscape</a> for the wider field.</p>
<h2 id="what-this-article-does-and-does-not-establish">What this article does and does not establish</h2>
<p>Verified by reading the tag, not by running an install: the file at <code>v2.2.1</code> was fetched on 2026-10-28 and hashed <code>f329ca4a17af1fbefb32ac10d4cc7ae7ddbdb7435a87eb74c30915cff98b8b7c</code>, and the phases, guards, sentinel handling and <code>return []</code> quoted above are from that file. No live <code>add()</code> or <code>update()</code> was executed for this piece, so the end-to-end failures are reconstructed from the pinned control flow and reported by the issue authors against their own installs — including the <code>update()</code> embed at line 2085, which the #7561 reporter observed on an installed 2.2.1 with only the external embedding boundary mocked. The CLI date defect is likewise reported, with the reporter&rsquo;s own mocked-backend reproduction, and the CLI is not pinned by the wheel version. Every check here is author-side verification against a public tag you can fetch yourself; no fixture set or evidence directory is offered for download.</p>
<p>Out of scope and not claimed: hosted-platform behaviour, non-Qdrant vector stores beyond the reporter&rsquo;s local setup, async variants as separately tested code paths (they contain the same unfiltered retrieval and dedup blocks), and any statement about recall quality. The one thing worth carrying away is structural: when a system implements expiry as a read-time filter, every write path that reuses unfiltered search results inherits a memory of things the user can no longer see — and a metadata-only update that re-embeds its own text inherits a provider dependency it never needed.</p>
]]></content:encoded></item></channel></rss>