<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mukesh</title>
    <description>The latest articles on DEV Community by Mukesh (@mukesh_13).</description>
    <link>https://dev.to/mukesh_13</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4050775%2Fb4a755b0-7c23-4c89-8f59-090d27151f2b.png</url>
      <title>DEV Community: Mukesh</title>
      <link>https://dev.to/mukesh_13</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mukesh_13"/>
    <language>en</language>
    <item>
      <title>Inside the Memory Decision Loop: How AI Agents Decide What to Remember, Update, or Forget</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Sun, 06 Sep 2026 19:19:34 +0000</pubDate>
      <link>https://dev.to/mukesh_13/inside-the-memory-decision-loop-how-ai-agents-decide-what-to-remember-update-or-forget-1mfl</link>
      <guid>https://dev.to/mukesh_13/inside-the-memory-decision-loop-how-ai-agents-decide-what-to-remember-update-or-forget-1mfl</guid>
      <description>&lt;p&gt;Most people who add "memory" to an AI agent do the same thing: embed every message, throw the vector into Pinecone or pgvector, and call &lt;code&gt;similarity_search&lt;/code&gt; at query time. It works for a demo. It falls apart in production, because nothing ever &lt;em&gt;updates&lt;/em&gt; or &lt;em&gt;deletes&lt;/em&gt; anything — the store only grows, and it fills with contradictions.&lt;/p&gt;

&lt;p&gt;Say a user tells your agent "I live in Austin" in March and "I just moved to Denver" in July. A naive vector store keeps both. At retrieval time, both come back as top-k matches for "where do I live," and now your LLM is holding two contradictory facts with no signal about which one is current. This is the actual problem memory layers like Mem0 are built to solve, and the mechanism is more interesting than "vector DB with extra steps." Here's how it actually works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline has four stages, not one
&lt;/h2&gt;

&lt;p&gt;A naive RAG setup has one stage: embed and store. A real memory layer has four:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Extraction&lt;/strong&gt; — turn a raw conversation turn into candidate facts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval&lt;/strong&gt; — find existing memories that might relate to each candidate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decision&lt;/strong&gt; — for each candidate, decide ADD, UPDATE, DELETE, or NOOP against what's already stored&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consolidation&lt;/strong&gt; — write the resolved state back, not just append to it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first stage is the one everyone builds. The second and third are the ones that make memory actually useful instead of a growing pile of unreconciled embeddings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: extraction is a compression step, not a copy step
&lt;/h2&gt;

&lt;p&gt;You don't embed the raw message "yeah so I just moved to Denver last week, still unpacking boxes everywhere." You run it through an LLM extraction prompt that pulls out the durable fact: &lt;code&gt;user.location = Denver&lt;/code&gt;. Everything about boxes and unpacking is conversational noise — useful for the current turn, useless six weeks from now.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;EXTRACTION_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Extract durable facts about the user from this message.
Ignore small talk, emotional tone, and one-off requests.
Return a list of atomic facts as short subject-predicate-object statements.

Message: {message}
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EXTRACTION_PROMPT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# -&amp;gt; ["user lives in Denver"]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Atomic matters here. "User lives in Denver and works remotely and has a dog" should become three separate candidate facts, not one blob — because each one might independently need to be added, updated, or deleted later, and you can't do that to a fact if it's welded to two unrelated ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: retrieval finds what might conflict, not what's relevant
&lt;/h2&gt;

&lt;p&gt;This is the part that's easy to get backwards. At &lt;em&gt;write&lt;/em&gt; time, you're not doing similarity search to answer a question — you're doing it to find memories that the new candidate might contradict or refine. So for &lt;code&gt;"user lives in Denver"&lt;/code&gt;, you embed the candidate and pull the top few nearest existing memories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;candidate_embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user lives in Denver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;neighbors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vector_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# -&amp;gt; [("user lives in Austin", score=0.89), ("user has a dog", score=0.31), ...]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The high-scoring neighbor (&lt;code&gt;user lives in Austin&lt;/code&gt;, 0.89 similarity) is exactly the case that matters — semantically close enough to be about the same fact, but not identical. That's the conflict the next stage has to resolve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: the actual decision — this is the part people skip
&lt;/h2&gt;

&lt;p&gt;For each candidate + its neighbors, a second LLM call (or in leaner implementations, a classifier) decides what to do. Mem0's public writeups describe exactly this operation: given a new fact and its nearest existing memories, output one of ADD, UPDATE, DELETE, or NOOP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DECISION_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
New fact: {candidate}
Existing related memories: {neighbors}

Decide the operation:
- ADD: new fact, no real overlap with existing memories
- UPDATE: new fact supersedes an existing memory (same subject, changed value)
- DELETE: new fact explicitly contradicts and invalidates an existing memory
- NOOP: new fact is already captured, do nothing

Return: {{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;operation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: ..., &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: ..., &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resolved_fact&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: ...}}
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DECISION_PROMPT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user lives in Denver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;neighbors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;m_204&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user lives in Austin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# -&amp;gt; {"operation": "UPDATE", "target_id": "m_204", "resolved_fact": "user lives in Denver"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the step that turns a vector store into a memory system. Without it, you have an append-only log with a fuzzy search index bolted on. With it, you have something closer to a mutable key-value store where the "key" is semantic similarity instead of an exact string.&lt;/p&gt;

&lt;p&gt;The failure mode to watch for: an overly aggressive DELETE/UPDATE threshold merges facts that only &lt;em&gt;look&lt;/em&gt; similar. "User lives in Denver" and "user was born in Denver" are 0.85+ cosine similar and mean completely different things. This is why the decision step needs the LLM to reason about semantics, not just a similarity score cutoff — a pure threshold rule (&lt;code&gt;if score &amp;gt; 0.8: overwrite&lt;/code&gt;) will silently corrupt memory in exactly the cases where getting it right matters most.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: consolidation — write once, not append
&lt;/h2&gt;

&lt;p&gt;The resolved operation gets applied atomically: an UPDATE overwrites the existing vector and metadata for &lt;code&gt;m_204&lt;/code&gt; (new embedding, incremented version, updated timestamp) rather than inserting a new row and leaving the old one to rot. A DELETE tombstones the memory instead of a silent removal, so you can audit &lt;em&gt;why&lt;/em&gt; something disappeared if a user asks "wait, didn't I tell you I live in Austin?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;operation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;vector_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resolved_fact&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resolved_fact&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Retrieval-time scoring isn't just similarity either
&lt;/h2&gt;

&lt;p&gt;Once memories are clean, the query-time ranking still shouldn't be pure cosine similarity. Production memory layers blend it with recency and access frequency, because a fact from an hour ago and a fact from eight months ago can both be top-3 nearest neighbors, but they're not equally trustworthy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.6&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.25&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;recency_decay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;age_days&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;access_frequency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decay function matters more than its weight suggests — a linear decay treats a 30-day-old fact and a 300-day-old fact as almost the same, while an exponential decay (closer to how the Ebbinghaus forgetting curve actually behaves) lets stale facts fade without a hard expiry that deletes something still true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you're building agent memory and it's just &lt;code&gt;store.add(embedding)&lt;/code&gt; on every turn, you don't have memory — you have an unindexed diary the model reads through a straw. The four-stage loop — extract, retrieve neighbors, decide the operation, consolidate — is what separates a system that gets &lt;em&gt;more&lt;/em&gt; useful as it accumulates history from one that gets slower and more contradictory. That decision step is also the one piece you can't shortcut with a bigger embedding model or a faster vector index; it needs actual reasoning about whether two facts are the same fact, a newer version of it, or something else entirely.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llmops</category>
      <category>programming</category>
    </item>
    <item>
      <title>Agentic AI Is Mostly Marketing. Memory Is the Part That's Real.</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Sat, 05 Sep 2026 19:18:59 +0000</pubDate>
      <link>https://dev.to/mukesh_13/agentic-ai-is-mostly-marketing-memory-is-the-part-thats-real-4b03</link>
      <guid>https://dev.to/mukesh_13/agentic-ai-is-mostly-marketing-memory-is-the-part-thats-real-4b03</guid>
      <description>&lt;p&gt;Open any 'agentic AI' repo trending on GitHub this month and you'll find the same shape: a while loop, a tool-calling wrapper, a retry policy, and a system prompt that says 'you are an autonomous agent.' Strip the prompt out and what's left is a script. A good script, sometimes — but a script. It runs the same way today as it will next month, making the same mistakes in the same places, because nothing about running it changes what it knows.&lt;/p&gt;

&lt;p&gt;That's not a controversial observation in private, but it's an unpopular one to say out loud, because 'agentic' has become the label that gets a repo funded, a blog post shared, and a feature slotted into a roadmap. So here's the hot take: most of what's marketed as agentic AI in 2026 isn't. It's automation with a chat interface bolted on. And the one thing that actually separates an agent from a script — memory that changes future behavior without a human re-editing the code — is treated as an afterthought by nearly everyone building this stuff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Steelmanning the hype
&lt;/h2&gt;

&lt;p&gt;Before tearing this down, it's worth taking the other side seriously, because parts of it are true. Tool-calling loops that plan multi-step tasks, decide which API to hit next, and recover from a failed step without a human in the loop are a genuine capability that didn't exist three years ago. Watching a model decompose 'deploy this and roll back if error rate spikes' into eight correctly-ordered tool calls is not nothing. Frameworks like the current generation of orchestration libraries make it dramatically faster to wire up retries, structured outputs, and multi-agent handoffs than it was to hand-roll that logic in 2023. If your bar for 'agentic' is 'makes autonomous multi-step decisions within a single session,' plenty of production systems clear it honestly.&lt;/p&gt;

&lt;p&gt;The problem is the word implies more than that. An agent, in the sense people actually mean when they get excited about it, is something that gets better — or at least different — the longer it runs, because it's accumulating experience. A system that makes the identical decision on day 200 that it made on day 1, given the identical input, isn't agentic by that definition. It's deterministic, which is often exactly what you want in production, but it's not the thing being sold.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tell: does yesterday change today?
&lt;/h2&gt;

&lt;p&gt;Here's the test I actually use, and it takes one question: does what happened yesterday change what this system does today, without a human editing a prompt or a config file in between?&lt;/p&gt;

&lt;p&gt;Concretely: I maintain a small autonomous system that has to decide, every cycle, which of several strategies to pursue and how aggressively. The first version was a script in the pejorative sense — fixed thresholds, fixed weights, a case statement dressed up with an LLM call that picked among pre-defined branches. It ran fine. It also made the same misjudgment every time a particular condition recurred, because there was nowhere for the outcome of that misjudgment to go. Nothing recorded it, nothing surfaced it back into the decision, and nothing adjusted the threshold. Rerun the same week and you'd get the same wrong call.&lt;/p&gt;

&lt;p&gt;The fix wasn't a bigger model or a fancier planning loop. It was giving the system a place to write down 'this threshold was wrong, here's what actually happened' and a mechanism to read that back in before making the same class of decision again. Once that existed, the system started quietly correcting itself — tightening thresholds that had produced bad outcomes, loosening ones that had been too conservative — without anyone touching the code. That is the entire difference between the before and after version, and it's the only part of the rewrite I'd defend as making the system more 'agentic' in any sense that matters. Everything else — the tool calls, the retries, the multi-step planning — was already there and already useful, but it wasn't the thing that changed the system's trajectory over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why memory keeps getting treated as an afterthought
&lt;/h2&gt;

&lt;p&gt;The usual pattern in agent projects is: build the orchestration first, get the tool-calling loop working, ship it, and then — if there's time — bolt on a vector database and call it 'memory.' That ordering is backwards, and it's why so many agent projects plateau at a fixed error rate instead of improving. Dumping conversation transcripts into a vector store gives you retrieval, not memory in the sense that matters. Retrieval answers 'what did we talk about before?' Behavior-changing memory answers 'given what happened last time, should I do something different now?' Those are different engineering problems. The first is a search index. The second requires deciding what's worth remembering, when it should be forgotten or superseded, and — critically — where in the decision pipeline the retrieved memory actually gets to override a default.&lt;/p&gt;

&lt;p&gt;Most teams skip that third part entirely. They add memory retrieval to the prompt context and consider the job done, without ever wiring a path for a remembered failure to actually suppress or adjust a future action. The memory exists; it just doesn't have write access to behavior. That's a memory system in name and a search bar in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actionable version of this take
&lt;/h2&gt;

&lt;p&gt;If you're evaluating an agent framework, a memory product, or your own in-house system, skip the marketing copy and ask the one-question test: pick a mistake the system made last week, and ask whether the system is structurally less likely to make that exact mistake today — not because someone patched the prompt, but because the system itself changed something in response to the outcome. If the answer is yes, you're looking at an agent. If the answer is no, you're looking at a very well-orchestrated script, and that's fine — just don't pay agent prices for script behavior. The gap between those two categories isn't the orchestration layer everyone's competing on. It's memory that's actually load-bearing, and right now almost nobody is building for that on purpose.&lt;/p&gt;

</description>
      <category>agenticai</category>
      <category>llmops</category>
      <category>mem0</category>
      <category>ai</category>
    </item>
    <item>
      <title>Recommendations Are Cheap. Enforcement Is the Feature: Building a Backlog-Pressure Gate for Self-Regulating Schedulers</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Fri, 04 Sep 2026 18:51:55 +0000</pubDate>
      <link>https://dev.to/mukesh_13/recommendations-are-cheap-enforcement-is-the-feature-building-a-backlog-pressure-gate-for-c8n</link>
      <guid>https://dev.to/mukesh_13/recommendations-are-cheap-enforcement-is-the-feature-building-a-backlog-pressure-gate-for-c8n</guid>
      <description>&lt;p&gt;Every team has a version of this document: a postmortem, a retro note, a lesson log. Something breaks, someone writes down exactly what should change, and the fix reads as obvious. And then, days later, the same failure happens again, in the same shape, for the same reason -- because the recommendation was never anything more than text.&lt;/p&gt;

&lt;p&gt;I hit this in a very literal way while running an autonomous agent that manages its own task queue. Every day it logs a reflection pass over its own output: what worked, what didn't, what to change tomorrow. For four consecutive days, that reflection log identified the exact same problem -- a scheduler that kept over-allocating a high-friction task type while a low-friction, high-throughput one starved -- and proposed the exact same fix, with formula and threshold spelled out. And for four consecutive days, the scheduler did the opposite of what the log recommended, because nothing in the code actually read the log.&lt;/p&gt;

&lt;p&gt;That gap -- between "we wrote down the fix" and "the fix runs" -- is worth taking seriously as a design problem, not a process problem. Here's the pattern I used to close it, with enough detail that you can drop the shape into any weighted scheduler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: weights that already exist
&lt;/h2&gt;

&lt;p&gt;Most non-trivial schedulers already have some notion of weighted selection -- a set of task types (or routes, or workers, or jobs) each with a base weight, and a pick function that samples from that distribution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;weighted_pick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;upto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;upto&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;upto&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your scheduler is even a little dynamic, you probably also have an override layer on top of the base weights -- a place where you can bump one task type up or down without redeploying. Mine stores it as a small JSON blob in the database: a dict of &lt;code&gt;{task_type: multiplier}&lt;/code&gt;, read once per scheduling decision and multiplied into the base weight, with a safe fallback (clamp to a small positive floor) if a multiplier would zero out or invert a weight entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;weight_overrides&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weight_overrides&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;overrides&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This piece usually already exists for a mundane reason -- someone wanted to manually throttle a noisy job type without a deploy. The mistake is stopping there and assuming manual overrides are the only overrides you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  The missing piece: a signal the scheduler can read for itself
&lt;/h2&gt;

&lt;p&gt;The recommendation that kept getting ignored had a very specific, checkable shape: &lt;em&gt;when the backlog of tasks waiting on a human (or any blocking external step) crosses a threshold, throttle the task types that produce more of that backlog and boost the ones that don't.&lt;/em&gt; That's not a vague sentiment -- it's a query and a table.&lt;/p&gt;

&lt;p&gt;The query counts, over some recent window, how many completed tasks are still carrying an unresolved follow-up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;backlog_pressure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recent_tasks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result_json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;KeyError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the table is just the reflection log's own formula, made literal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;BACKLOG_FRICTION_MULTIPLIERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high_friction_task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;another_high_friction_task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low_friction_task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;BACKLOG_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_backlog_pressure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pressure&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pressure&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;BACKLOG_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;overrides&lt;/span&gt;
    &lt;span class="n"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;task_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;multiplier&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;BACKLOG_FRICTION_MULTIPLIERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;task_type&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;multiplier&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;merged&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wired into the existing override function, this is maybe fifteen lines of new code on top of infrastructure that was already there. That's the point worth underlining: the fix wasn't a new subsystem. It was making an existing subsystem read one more signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shape, specifically
&lt;/h2&gt;

&lt;p&gt;Three properties made this safe to ship without a long review cycle, and they generalize past this one bug:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's additive, not replacing.&lt;/strong&gt; The new function composes with the existing override dict rather than branching around it. If &lt;code&gt;backlog_pressure()&lt;/code&gt; returns zero -- which it does for every existing test fixture, since none of them simulate a week of unresolved tasks -- behavior is provably identical to before the change. You're not asking anyone to trust new logic in the common case; the common case doesn't touch the new logic at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It reuses a read path that's already trusted.&lt;/strong&gt; I didn't add a new database table or a new query method. &lt;code&gt;recent_tasks()&lt;/code&gt; already existed, already had test coverage, and already had a documented row shape. The new code just reads it differently. Every new failure mode you might worry about (malformed JSON, missing keys, a task with no result yet) was already something the surrounding code defended against, so the same defensive pattern -- try/except around &lt;code&gt;json.loads&lt;/code&gt;, &lt;code&gt;.get()&lt;/code&gt; with a default -- covered the new call site for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The threshold is a number, not a vibe.&lt;/strong&gt; "Throttle when things feel backed up" is not enforceable. "Throttle when more than 7 of the last 100 tasks still have &lt;code&gt;action_required&lt;/code&gt; set" is a single &lt;code&gt;if&lt;/code&gt; statement. Converting a recommendation into a gate means converting its trigger condition into something a function can evaluate -- if you can't write that condition down, the recommendation isn't finished yet, no matter how sound the reasoning is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general lesson
&lt;/h2&gt;

&lt;p&gt;A recommendation that only lives in a log is a wish. It becomes a gate the moment three things are true: the trigger condition is a query against data you already collect, the response is a change to a code path that already runs on every cycle, and the default behavior (trigger not met) is provably unchanged from before. If any of those three is missing -- if the trigger needs data you don't have yet, or the response would need a new code path nobody's reviewed, or you can't show the change is a no-op in the common case -- that's exactly where recommendations go to die, re-written in next week's postmortem with the same formula and the same missing enforcement.&lt;/p&gt;

&lt;p&gt;The fix isn't writing better recommendations. Mine were already correct four days running. The fix is treating "convert this into a gate" as its own task, with its own priority, separate from "identify the problem" -- because a system will keep re-discovering the same problem forever if nothing downstream of the discovery ever runs.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>python</category>
      <category>architecture</category>
      <category>aiagents</category>
    </item>
    <item>
      <title>The CircleCI Cache Key Bug That's Silently Serving Your Builds Stale Dependencies</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Thu, 03 Sep 2026 18:32:37 +0000</pubDate>
      <link>https://dev.to/mukesh_13/the-circleci-cache-key-bug-thats-silently-serving-your-builds-stale-dependencies-4jpf</link>
      <guid>https://dev.to/mukesh_13/the-circleci-cache-key-bug-thats-silently-serving-your-builds-stale-dependencies-4jpf</guid>
      <description>&lt;p&gt;Your CircleCI pipeline is green. Every job passes. And yet your app is running against a dependency version that hasn't shipped in a month — nobody committed it, nobody bumped it, it just quietly showed up in production. If you've chased a bug like this, the culprit is almost never your code. It's your cache key.&lt;/p&gt;

&lt;p&gt;This is a five-minute read and a fifteen-minute fix. Quick Win Friday, deployed to your &lt;code&gt;.circleci/config.yml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode
&lt;/h2&gt;

&lt;p&gt;CircleCI's dependency caching works on a simple contract: you compute a key from something that changes when your dependencies change (usually a lockfile checksum), and you save/restore a cache tied to that key. The contract breaks in three specific, extremely common ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;You checksum the wrong file.&lt;/strong&gt; &lt;code&gt;{{ checksum "package.json" }}&lt;/code&gt; looks reasonable until someone bumps a transitive dependency via &lt;code&gt;package-lock.json&lt;/code&gt; without touching &lt;code&gt;package.json&lt;/code&gt;. The checksum doesn't move. CircleCI happily hands back last week's &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;restore_keys&lt;/code&gt; does prefix matching, and people think it does exact matching.&lt;/strong&gt; CircleCI tries your primary key first, then falls through &lt;code&gt;restore_keys&lt;/code&gt; in order, and the first one is a &lt;em&gt;prefix&lt;/em&gt; match against existing cache entries — not "give me the newest exact match." If your restore_keys list is too coarse (e.g. just &lt;code&gt;v1-deps-&lt;/code&gt;), you can restore a cache built from a completely different branch, with a completely different lockfile, and the job won't fail. It'll just quietly install nothing (cache hit, &lt;code&gt;npm ci&lt;/code&gt; sees the modules are "there") or run against the wrong versions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;There's no version escape hatch.&lt;/strong&gt; When you inevitably need to force everyone's cache to invalidate — a corrupted cache entry, a package manager migration, a lockfile format change — there's no cheap way to do it, because the key format was never designed with a manual buster in mind.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each of these fails silently. No red X. No error in the logs. Just a build that ran with stale state, and a bug report three days later that nobody can reproduce locally because local &lt;code&gt;node_modules&lt;/code&gt; is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Replace whatever your current cache block looks like with this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;restore_cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;v3-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;v3-deps-{{ .Branch }}-&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;v3-deps-&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install dependencies&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yarn install --frozen-lockfile&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;save_cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v3-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node_modules&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;~/.cache/yarn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four specific changes, each fixing one of the failure modes above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Checksum the lockfile, not the manifest.&lt;/strong&gt; &lt;code&gt;yarn.lock&lt;/code&gt; / &lt;code&gt;package-lock.json&lt;/code&gt; / &lt;code&gt;poetry.lock&lt;/code&gt; / &lt;code&gt;Cargo.lock&lt;/code&gt; — whatever actually pins your resolved versions. That's the only file where "nothing changed" is a true statement about your dependency tree.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;--frozen-lockfile&lt;/code&gt; (or &lt;code&gt;npm ci&lt;/code&gt;, or &lt;code&gt;poetry install --no-update&lt;/code&gt;) as the install command, always.&lt;/strong&gt; This is the safety net for the failure modes you haven't fixed yet: if the cache did restore something stale, a frozen install refuses to silently proceed with a mismatched lockfile instead of quietly reconciling it. You want that job to go red, not go green with the wrong tree.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Order &lt;code&gt;restore_keys&lt;/code&gt; from most to least specific, and stop one level short of "matches anything."&lt;/strong&gt; Branch-scoped exact match first, branch-scoped prefix second, global prefix last as a genuine last resort for a brand-new branch. Don't just have &lt;code&gt;v3-deps-&lt;/code&gt; as your only fallback — that's the line that lets a &lt;code&gt;feature/rewrite-auth&lt;/code&gt; branch restore a cache from &lt;code&gt;main&lt;/code&gt; with a different lockfile entirely.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bump the leading version token (&lt;code&gt;v3-&lt;/code&gt; → &lt;code&gt;v4-&lt;/code&gt;) whenever you need a clean slate.&lt;/strong&gt; This is your manual cache-buster. Because it's baked into the key itself, forcing invalidation for everyone is a one-line PR, not a support ticket to CircleCI or a trip through the project settings UI to nuke caches by hand.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verifying it actually worked
&lt;/h2&gt;

&lt;p&gt;Don't just ship the YAML change and trust it. Add a one-line assertion job step for a week while you confirm behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Confirm lockfile/cache agreement&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;yarn list --pattern "your-critical-package" | tee /tmp/resolved.txt&lt;/span&gt;
      &lt;span class="s"&gt;grep -q "$(grep 'your-critical-package' yarn.lock | head -1 | cut -d'@' -f2)" /tmp/resolved.txt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adjust the package name to whatever dependency has bitten you before, or whatever your team would most want to know shipped the wrong version. If that grep ever fails, your cache and your lockfile have diverged — and now it fails loud, in CI, instead of quiet, in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The monorepo trap
&lt;/h2&gt;

&lt;p&gt;If you're on a Yarn/npm workspace or a monorepo with multiple lockfiles, the checksum function only hashes what you tell it to. &lt;code&gt;checksum "yarn.lock"&lt;/code&gt; at the repo root won't catch a change to a workspace package's own dependencies unless your package manager writes that resolution back into the root lockfile (most do, but verify it for yours). If you have nested lockfiles that aren't supposed to exist, that's usually the actual bug — but if they're intentional, checksum all of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;restore_cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;v3-deps-{{ checksum "yarn.lock" }}-{{ checksum "packages/api/yarn.lock" }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What this actually costs you
&lt;/h2&gt;

&lt;p&gt;Fifteen minutes: five to edit the config, five to push a branch and watch the cache key change in the CircleCI job output, five to add and then remove the verification step once you trust it. No infrastructure change, no new tooling, no dependency on anything outside &lt;code&gt;.circleci/config.yml&lt;/code&gt;. And it closes off the specific class of bug that's hardest to debug precisely because it never announces itself — the build that lies to you by saying nothing at all.&lt;/p&gt;

</description>
      <category>circleci</category>
      <category>devops</category>
      <category>programming</category>
      <category>backend</category>
    </item>
    <item>
      <title>Agent Memory in Production: Mem0 vs Zep vs LangChain Memory vs Redis DIY</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Wed, 02 Sep 2026 18:33:27 +0000</pubDate>
      <link>https://dev.to/mukesh_13/agent-memory-in-production-mem0-vs-zep-vs-langchain-memory-vs-redis-diy-3oo4</link>
      <guid>https://dev.to/mukesh_13/agent-memory-in-production-mem0-vs-zep-vs-langchain-memory-vs-redis-diy-3oo4</guid>
      <description>&lt;p&gt;Every agent framework now ships some flavor of "memory," and every vendor swears theirs is the one that will stop your agent from re-introducing itself to a user it's talked to fifty times. The trending conversation this week — &lt;em&gt;Your AI Remembers Everything and Trusts All of It&lt;/em&gt; — points at the real failure mode: memory tools are good at storing, and bad at deciding what's still true. That's the axis I actually care about when picking a tool, not embedding dimensionality or vector store choice.&lt;/p&gt;

&lt;p&gt;I ran four approaches against the same workload: a support agent that needs to remember user preferences, prior tickets, and corrections across sessions, with facts that occasionally contradict earlier facts ("actually I moved to Denver" after "I live in Austin"). Here's how Mem0, Zep, LangChain's memory classes, and a hand-rolled Redis setup actually behaved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contenders
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mem0&lt;/strong&gt; — a managed (or self-hosted) memory layer that sits between your LLM calls and storage. It extracts facts from conversation turns, scores them, and resolves conflicts against existing memory before writing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zep&lt;/strong&gt; — session memory plus a temporal knowledge graph. It doesn't just store facts, it timestamps them and lets you query "what did we believe was true as of X."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LangChain memory&lt;/strong&gt; (&lt;code&gt;ConversationSummaryBufferMemory&lt;/code&gt;, &lt;code&gt;VectorStoreRetrieverMemory&lt;/code&gt;) — not a product, a set of classes you wire to whatever vector store you already run. No extraction logic beyond what you write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redis + RedisVL, DIY&lt;/strong&gt; — no framework at all. You store embeddings and metadata yourself, and write your own retrieval and conflict logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the differences actually show up
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fact extraction and conflict resolution
&lt;/h3&gt;

&lt;p&gt;This is the part every vendor undersells and every homegrown system underestimates.&lt;/p&gt;

&lt;p&gt;With Mem0, sending a new turn triggers an LLM call that extracts candidate facts, compares them against existing memory for the user, and either adds, updates, or discards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mem0&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Memory&lt;/span&gt;

&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Memory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I moved to Denver last month, hate the traffic though&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;u123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# later
&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;where does the user live&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;u123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# returns the Denver fact, with the Austin fact marked stale, not deleted
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "not deleted" part matters — Mem0 keeps history rather than overwriting, which is the only way to answer "did the user used to live somewhere else" later. That's real engineering, not just a wrapper around upsert.&lt;/p&gt;

&lt;p&gt;Zep does something structurally different: it builds a temporal graph, so instead of "current fact wins," you get an edge with a validity window. That's more expressive if your agent needs to reason about &lt;em&gt;when&lt;/em&gt; something changed, not just what's current — useful for, say, an agent that has to explain "your subscription was on the annual plan until March, then you switched." For a support bot that only needs "what's true now," that expressiveness is overhead you pay for in query complexity.&lt;/p&gt;

&lt;p&gt;LangChain's memory classes do none of this. &lt;code&gt;VectorStoreRetrieverMemory&lt;/code&gt; will happily return both the Austin and Denver facts with similar cosine scores, and it's on you to decide which one to trust. I've seen teams ship this to production assuming semantic similarity implies recency — it doesn't, and the failure is silent: the agent picks whichever fact happens to embed slightly closer to the query.&lt;/p&gt;

&lt;p&gt;Redis DIY has the same problem, minus even the vector-store convenience. You get exactly the conflict resolution you write, which for most teams under deadline is "none," until a customer complaint reveals it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Latency and cost
&lt;/h3&gt;

&lt;p&gt;Mem0's extraction step is an extra LLM call per write — real latency (typically 300-800ms in my testing with a small extraction model) and real token cost. For write-heavy agents, where every turn creates memory candidates, that adds up. Mem0 mitigates this with async writes and batched extraction, but you're still paying for a second model call on top of your main completion.&lt;/p&gt;

&lt;p&gt;Zep's graph writes are cheaper per-turn by default (no full LLM extraction unless you enable it), but querying the graph for anything beyond "latest fact" costs more at read time — graph traversal isn't as cheap as a vector similarity search.&lt;/p&gt;

&lt;p&gt;LangChain memory and Redis DIY are the cheapest at write time because they do the least work — you're paying for embedding calls, not extraction. The cost shows up later as engineering time and, if you skip conflict resolution, as wrong answers in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where each one actually wins
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Managed fact extraction + conflict resolution, minimal glue code&lt;/td&gt;
&lt;td&gt;Mem0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temporal reasoning ("what did we believe on date X")&lt;/td&gt;
&lt;td&gt;Zep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already deep in LangChain, prototyping, low memory volume&lt;/td&gt;
&lt;td&gt;LangChain memory classes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full control, existing Redis infra, team has bandwidth to own conflict logic&lt;/td&gt;
&lt;td&gt;Redis + RedisVL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The honest tradeoff nobody puts in the docs
&lt;/h2&gt;

&lt;p&gt;Every memory tool that does extraction and conflict resolution for you is making a judgment call on your behalf about what to trust — which is exactly the failure mode this week's trending post is pointing at. Mem0's "keep history, mark stale" approach is more conservative than Zep's graph-edge model, which is more conservative than "vector similarity as truth," which is what LangChain memory and naive Redis setups default to whether you intended it or not.&lt;/p&gt;

&lt;p&gt;If you're evaluating these for a production agent, don't benchmark retrieval latency first. Feed each one a deliberately contradictory conversation — a user correcting themselves twice — and check what the agent says a week later when asked "where do I live." That single test surfaces the actual difference between these tools faster than any latency chart, because it's the difference between a memory system and a fact dump with vectors on top.&lt;/p&gt;

&lt;p&gt;For most teams building a support or personal-assistant agent that needs to stay right over months of conversation, that argues for Mem0 or Zep over rolling your own — the extraction and conflict-resolution logic is genuinely hard to get right, and it's exactly the kind of undifferentiated engineering worth buying instead of building.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>memory</category>
      <category>llmops</category>
    </item>
    <item>
      <title>The 400-Character Bug: How Dict Key Order Silently Deleted Our Agent's Most Important Field</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:52:37 +0000</pubDate>
      <link>https://dev.to/mukesh_13/the-400-character-bug-how-dict-key-order-silently-deleted-our-agents-most-important-field-4edl</link>
      <guid>https://dev.to/mukesh_13/the-400-character-bug-how-dict-key-order-silently-deleted-our-agents-most-important-field-4edl</guid>
      <description>&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;We run a small autonomous agent that closes out its own day: it pulls every task it ran, hands the results to an LLM, and asks it to extract lessons — what worked, what didn't, what needs a human. One of those lessons is supposed to flag &lt;code&gt;action_required&lt;/code&gt;: the field a task sets when it's stuck waiting on something only a person can do (confirm an email address, approve a listing, whatever).&lt;/p&gt;

&lt;p&gt;For several days running, the nightly summary kept missing a blocker that was sitting in plain sight. Five demo deployments were stalled on one specific owner action. Every single task that hit that blocker wrote it into &lt;code&gt;action_required&lt;/code&gt;. And every night, the LLM-generated lessons talked about task volume, about revenue estimates, about strategy mix — and said nothing about the blocker at all. Not "low priority." Not "deprioritized." Just absent, like it had never been logged.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong theories
&lt;/h2&gt;

&lt;p&gt;First guess: prompt problem. Maybe the lesson-extraction prompt wasn't asking the model to look for blockers explicitly enough. We tightened the prompt, added an explicit instruction to surface &lt;code&gt;action_required&lt;/code&gt; when present. No change.&lt;/p&gt;

&lt;p&gt;Second guess: the model was just deprioritizing it — LLMs summarizing long inputs are known to drop details that seem secondary. We reordered the prompt to put escalation instructions first, added a one-line example of what a good blocker-flag looks like. Still nothing.&lt;/p&gt;

&lt;p&gt;Third guess, and the one that wasted the most time: maybe the extraction function itself had a bug — some off-by-one in how it parsed the model's response before writing lessons to the database. We reread &lt;code&gt;_extract_lessons()&lt;/code&gt; line by line. It was fine. It was correctly reading whatever the model gave it. The model just was never being given the field in the first place.&lt;/p&gt;

&lt;p&gt;That reframing — stop looking at what happens &lt;em&gt;after&lt;/em&gt; the LLM call, start looking at what goes &lt;em&gt;into&lt;/em&gt; it — is what actually cracked it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual root cause
&lt;/h2&gt;

&lt;p&gt;The function that builds the nightly context, &lt;code&gt;_build_day_summary()&lt;/code&gt;, takes each task's result object, serializes it, and truncates it before folding it into the day's summary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;summary_line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_dict&lt;/span&gt;&lt;span class="p"&gt;())[:&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks harmless. It's a guard against one verbose task result blowing up the token budget for the whole day's summary. Four hundred characters felt generous.&lt;/p&gt;

&lt;p&gt;The problem was &lt;code&gt;as_dict()&lt;/code&gt;'s key order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;as_dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;earned_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;earned_usd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spent_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_usd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_required&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;potential_revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;potential_revenue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;artifacts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;artifacts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;notes&lt;/code&gt; comes before &lt;code&gt;action_required&lt;/code&gt;. &lt;code&gt;notes&lt;/code&gt; is free-text and often runs long — a task explaining what it did, what it found, what it's thinking about trying next. On any task where &lt;code&gt;notes&lt;/code&gt; ran past roughly 300-350 characters, the JSON serialization pushed &lt;code&gt;action_required&lt;/code&gt; past the 400-character cutoff. &lt;code&gt;json.dumps[:400]&lt;/code&gt; doesn't care that it's mid-object; it just chops the string. The field wasn't malformed, wasn't null, wasn't skipped by any explicit logic anywhere. It simply didn't exist yet at the byte offset where the string got cut off.&lt;/p&gt;

&lt;p&gt;No exception. No warning log. No test failure. The truncated JSON was still syntactically broken in a way that would fail to &lt;code&gt;json.loads()&lt;/code&gt; if anyone tried — but nobody did, because the string was only ever going &lt;em&gt;into&lt;/em&gt; a prompt as text, never parsed back out. A field being silently amputated by a length limit produces no error signal anywhere in the stack. It just produces an LLM that was never shown the thing it needed to reason about.&lt;/p&gt;

&lt;p&gt;And it's a genuinely nasty bug to catch by inspection, because it's &lt;em&gt;data-dependent&lt;/em&gt;. Short &lt;code&gt;notes&lt;/code&gt; field, the bug doesn't fire. Verbose &lt;code&gt;notes&lt;/code&gt; field, it does. Whether you see it depends entirely on how chatty that day's tasks happened to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix stopped truncating raw JSON text and started truncating &lt;em&gt;after&lt;/em&gt; deciding what mattered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_task_summary_fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result_json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result_json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result_json&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))[:&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;action_required&lt;/code&gt; is pulled out and given its own untruncated slot instead of competing with &lt;code&gt;notes&lt;/code&gt; for space inside a single character budget. We also added a rollup — &lt;code&gt;pending_action_required_count&lt;/code&gt; — computed once per day and handed to the LLM as an explicit number, so the model doesn't have to infer backlog size from scattered prose across a dozen task summaries. Malformed JSON still falls back to a raw truncated string, so nothing regresses for genuinely broken task output.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;Character-count truncation on serialized structured data is a silent information-loss bug by construction: it has no failure mode that looks like a failure. It doesn't throw, doesn't return an error, doesn't even produce invalid output most of the time — it just quietly decides, based on how verbose an unrelated field was that day, whether your most important field exists.&lt;/p&gt;

&lt;p&gt;The test suite for this code passed the whole time. It asserted that lessons got extracted, that the LLM call didn't blow up, that malformed results were handled — all real, all necessary, none of it enough. Nobody had written a test that asserted &lt;em&gt;which specific fields survive summarization when other fields are long&lt;/em&gt;, because that's not the kind of thing you think to test until you've watched it fail in production for two days straight.&lt;/p&gt;

&lt;p&gt;That's exactly the class of bug a CI pipeline is built to catch and a code reviewer is built to miss — it requires running the function against a synthetic input engineered to be adversarial (a long &lt;code&gt;notes&lt;/code&gt;, a short &lt;code&gt;action_required&lt;/code&gt;), not reading the function and reasoning about what it does. A five-line golden test — feed the summarizer a task result with a 500-character &lt;code&gt;notes&lt;/code&gt; field and a non-empty &lt;code&gt;action_required&lt;/code&gt;, assert the output still contains it — would have failed on the very first commit that introduced the character slice, months before it ever reached production. The fix isn't "write more tests" in the abstract. It's: for any code that trims, truncates, or samples structured data before it reaches a consumer that can't ask follow-up questions, the pipeline should have a check for exactly which fields are guaranteed to survive — and it should run on every push, not get discovered by a human noticing a pattern across several days of missing alerts.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>llmops</category>
      <category>debugging</category>
      <category>automation</category>
    </item>
    <item>
      <title>I Gave a Vultr Box a Heartbeat: Self-Healing Docker Containers in 20 Minutes with systemd, Not Cron</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Mon, 31 Aug 2026 18:32:39 +0000</pubDate>
      <link>https://dev.to/mukesh_13/i-gave-a-vultr-box-a-heartbeat-self-healing-docker-containers-in-20-minutes-with-systemd-not-cron-3k42</link>
      <guid>https://dev.to/mukesh_13/i-gave-a-vultr-box-a-heartbeat-self-healing-docker-containers-in-20-minutes-with-systemd-not-cron-3k42</guid>
      <description>&lt;p&gt;Your Docker container crashes at 3 a.m. Cron notices at :00, :05, :10 — up to five minutes of downtime before anyone even checks. And if the crash loop is fast enough, a naive &lt;code&gt;restart: always&lt;/code&gt; in &lt;code&gt;docker-compose.yml&lt;/code&gt; will just spin the container up and down forever, hammering your database with reconnect storms.&lt;/p&gt;

&lt;p&gt;What you actually want is a watchdog: something that checks health more often than cron allows, restarts with backoff instead of blindly looping, and tells you when it happens. Here's how to build that on a $6/mo Vultr VPS in about 20 minutes, using &lt;code&gt;systemd&lt;/code&gt; timers instead of cron because systemd gives you logging, dependency ordering, and sub-minute intervals for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Provision the box
&lt;/h2&gt;

&lt;p&gt;If you already have a Vultr instance, skip to step 2. Otherwise, using the Vultr CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vultr-cli instance create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; ewr &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--plan&lt;/span&gt; vc2-1c-2gb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--os&lt;/span&gt; 387 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--label&lt;/span&gt; docker-watchdog-demo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--host&lt;/span&gt; docker-watchdog-demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--os 387&lt;/code&gt; is Ubuntu 24.04 LTS at time of writing; check &lt;code&gt;vultr-cli os list&lt;/code&gt; if that's stale. SSH in once it's provisioned, then install Docker with the official convenience script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log out and back in so the group change takes effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Deploy a container worth watching
&lt;/h2&gt;

&lt;p&gt;We'll simulate a flaky service — a tiny web app that has a &lt;code&gt;/kill&lt;/code&gt; endpoint to crash itself on demand, so you can test the watchdog without waiting for a real bug.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;flaky-app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:alpine&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flaky-app&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wget"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-q"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--spider"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;restart: unless-stopped&lt;/code&gt; handles the trivial case (process exits) but does nothing if the container is &lt;em&gt;running&lt;/em&gt; yet unhealthy — hung, deadlocked, or serving 500s. That's the gap the watchdog closes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Write the watchdog
&lt;/h2&gt;

&lt;p&gt;This script checks Docker's own health status, restarts unhealthy containers with exponential backoff, and caps retries so a genuinely broken container doesn't restart-loop forever.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# /usr/local/bin/docker-watchdog.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;STATE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/docker-watchdog
&lt;span class="nv"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WATCHDOG_WEBHOOK_URL&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;MAX_RETRIES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5

&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

notify&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0
  curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$msg&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;cid &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;-q&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Name}}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s#^/##'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;health&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$health&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"unhealthy"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue

  &lt;/span&gt;&lt;span class="nv"&gt;count_file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.count"&lt;/span&gt;
  &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$count_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; count &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; MAX_RETRIES &lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt; still unhealthy after &lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="s2"&gt; restarts, giving up"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="k"&gt;continue
  fi

  &lt;/span&gt;&lt;span class="nv"&gt;backoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; count &lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt; is unhealthy, restarting (attempt &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;count+1&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;, waited &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;backoff&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s backoff)"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$backoff&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  docker restart &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;count+1&lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$count_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  notify &lt;span class="s2"&gt;"⚠️ docker-watchdog restarted *&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;* (attempt &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;count+1&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MAX_RETRIES&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;) on &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# reset counters for containers that recovered on their own&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.count&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; .count&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;health&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{if .State.Health}}{{.State.Health.Status}}{{else}}healthy{{end}}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;gone&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$health&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"healthy"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x /usr/local/bin/docker-watchdog.sh
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /var/lib/docker-watchdog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backoff counter is the piece cron-based versions usually skip: without it, a container that fails its healthcheck every 10 seconds gets restarted every 10 seconds, forever, which is worse than doing nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Run it on a systemd timer, not cron
&lt;/h2&gt;

&lt;p&gt;Cron's minimum resolution is one minute, it has no built-in logging beyond mail (which is rarely configured), and a hung script just silently occupies a slot forever. systemd timers fix all three: sub-minute intervals, &lt;code&gt;journalctl&lt;/code&gt; logging automatically, and a &lt;code&gt;RuntimeMaxSec&lt;/code&gt; to kill a hung run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/docker-watchdog.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Docker container health watchdog&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;docker.service&lt;/span&gt;
&lt;span class="py"&gt;Requires&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;docker.service&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/docker-watchdog.sh&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;WATCHDOG_WEBHOOK_URL=https://hooks.slack.com/services/REPLACE/ME&lt;/span&gt;
&lt;span class="py"&gt;TimeoutStartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/docker-watchdog.timer
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Run docker-watchdog every 15 seconds&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnBootSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;15s&lt;/span&gt;
&lt;span class="py"&gt;OnUnitActiveSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;15s&lt;/span&gt;
&lt;span class="py"&gt;AccuracySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1s&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; docker-watchdog.timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 15-second interval is something cron simply cannot do — its floor is 60 seconds, and even that requires an extra wrapper loop to hit reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Prove it works
&lt;/h2&gt;

&lt;p&gt;Crash the container on purpose and watch the recovery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;flaky-app sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'kill 1'&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; docker-watchdog.service &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within 15 seconds you should see the watchdog detect the unhealthy state, apply backoff, and restart it — and if you wired up &lt;code&gt;WATCHDOG_WEBHOOK_URL&lt;/code&gt;, a Slack message lands at the same moment. Check the timer's own schedule and history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl list-timers docker-watchdog.timer
systemctl status docker-watchdog.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Lock the box down
&lt;/h2&gt;

&lt;p&gt;Since this VPS is now doing something worth protecting, restrict it to the ports you actually need with the Vultr firewall (or &lt;code&gt;ufw&lt;/code&gt; locally):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vultr-cli firewall group create &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"docker-watchdog"&lt;/span&gt;
&lt;span class="c"&gt;# then attach rules for 22/tcp (your IP only) and 8080/tcp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't expose the Docker socket or the watchdog's webhook URL beyond what's needed — the script only needs local &lt;code&gt;docker.sock&lt;/code&gt; access, which it already has by running as root via systemd.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to take it from here
&lt;/h2&gt;

&lt;p&gt;This pattern generalizes past a single VPS: point the same script at a remote Docker context (&lt;code&gt;DOCKER_HOST=ssh://...&lt;/code&gt;) to watch containers on a fleet from one control box, or swap the webhook for a PagerDuty Events API call if 3 a.m. pages are your actual problem. The core idea stays the same — sub-minute detection, bounded backoff, and a notification the moment state changes, all from tooling that ships with every modern Linux box by default.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>linux</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Refresh Token Rotation Under the Hood: How Auth0 Catches a Stolen Token Before It's Ever Replayed</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Sun, 30 Aug 2026 20:29:24 +0000</pubDate>
      <link>https://dev.to/mukesh_13/refresh-token-rotation-under-the-hood-how-auth0-catches-a-stolen-token-before-its-ever-replayed-3n1l</link>
      <guid>https://dev.to/mukesh_13/refresh-token-rotation-under-the-hood-how-auth0-catches-a-stolen-token-before-its-ever-replayed-3n1l</guid>
      <description>&lt;p&gt;Most explanations of refresh token rotation stop at "the old token gets swapped for a new one." That's true, but it skips the part that actually matters: how does the authorization server know a stolen token was used &lt;em&gt;before&lt;/em&gt; the legitimate client tries to use it? The answer is a small piece of state most engineers never think about — a &lt;strong&gt;token family&lt;/strong&gt; — and the reuse-detection algorithm built on top of it. Let's open it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem rotation alone doesn't solve
&lt;/h2&gt;

&lt;p&gt;A static, long-lived refresh token is a bearer secret with an unlimited replay window. If it leaks — from a compromised mobile device, a logged network request, a misconfigured CI cache — an attacker holds a valid credential for as long as the token's TTL allows, often 30-90 days, with zero signal to the legitimate owner.&lt;/p&gt;

&lt;p&gt;Rotation on its own only shrinks the &lt;em&gt;window&lt;/em&gt;: every refresh call returns a new refresh token and burns the old one. That's better, but if you stop there, an attacker who grabs a token can still race the real client, use it once, and the server has no way to tell "attacker used it" apart from "the app used it."&lt;/p&gt;

&lt;p&gt;The piece that actually catches theft is &lt;strong&gt;reuse detection&lt;/strong&gt;, and it requires the server to track lineage, not just validity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token families: the data structure under the hood
&lt;/h2&gt;

&lt;p&gt;Every refresh token belongs to a family, created at the moment the &lt;em&gt;first&lt;/em&gt; refresh token is issued (typically at login). A family is conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;family_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;f_9a2e...&lt;/span&gt;
&lt;span class="na"&gt;lineage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;rt_001 (used)&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;rt_002 (used)&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;rt_003 (active)&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;user_42&lt;/span&gt;
&lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;mobile-app-ios&lt;/span&gt;
&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;active | revoked&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each rotation doesn't just generate a fresh token — it appends to that array and marks the &lt;em&gt;previous&lt;/em&gt; token as spent, not deleted. That distinction between "spent" and "deleted" is the whole trick: the server needs to remember that rt_001 and rt_002 &lt;em&gt;existed and were already consumed&lt;/em&gt;, so it can recognize them if they show up again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The algorithm, step by step
&lt;/h2&gt;

&lt;p&gt;On every &lt;code&gt;POST /oauth/token&lt;/code&gt; with &lt;code&gt;grant_type=refresh_token&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Look up the token by its opaque value (or, in JWT-encoded refresh tokens, by the &lt;code&gt;jti&lt;/code&gt; claim) and resolve its family.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the token matches the family's current active token&lt;/strong&gt; — normal path. Mark it spent, generate a new token, append it to the lineage, return it. Everything continues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the token matches a token in the family's lineage but it's already marked spent&lt;/strong&gt; — this is the reuse signal. Somebody just replayed a token that was already exchanged once. That can only happen if two parties (the legitimate client and an attacker, or two copies of a client after a token was exfiltrated) had the same refresh token at the same time.&lt;/li&gt;
&lt;li&gt;On reuse detection: revoke the &lt;strong&gt;entire family&lt;/strong&gt;, not just the offending token. Every token in that lineage, including the current active one the legitimate client is holding, becomes invalid. This forces a full re-authentication.&lt;/li&gt;
&lt;li&gt;Optionally, fire a security event (Auth0 calls this a "breached refresh token" event) so the application layer can notify the user, force a password reset, or flag the session for review.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is the part people get wrong when they roll this themselves — the instinct is to just reject the reused token and move on. But if you don't nuke the whole family, the attacker's copy of the &lt;em&gt;next&lt;/em&gt; token (if they got far enough to see it) or the legitimate client's still-valid token leaves a live credential in play. Killing the lineage is what turns "detected an anomaly" into "closed the hole."&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal implementation
&lt;/h2&gt;

&lt;p&gt;Here's the reuse-check logic stripped to its core, independent of any particular auth vendor's SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rotateRefreshToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;presentedToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refreshTokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;presentedToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_grant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;family&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokenFamilies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;familyId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Reuse detected — the token was valid once, but already consumed.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokenFamilies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;family&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auditLog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refresh_token_reuse&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;family&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;familyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;family&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_grant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// client must re-authenticate&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;family&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_grant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// family already revoked&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refreshTokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markSpent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refreshTokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;familyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;family&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;newToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two lookups that make this work — "is this token spent" and "is this family still active" — are exactly the state a naive rotation implementation skips, because a naive version just deletes old tokens instead of marking them spent. Deletion throws away the evidence you need to detect the attack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grace-period wrinkle
&lt;/h2&gt;

&lt;p&gt;Real clients aren't perfectly reliable. A mobile app on a flaky connection might send a refresh request, lose the response, and retry with the &lt;em&gt;same&lt;/em&gt; refresh token a few seconds later — which looks identical to reuse from the server's point of view. Auth0 and most production implementations handle this with a short &lt;strong&gt;reuse grace period&lt;/strong&gt; (on the order of seconds, configurable), during which a repeated request for the most-recently-spent token returns the &lt;em&gt;same&lt;/em&gt; already-issued replacement instead of triggering revocation. Outside that window, reuse is treated as theft. Getting this window right is a genuine tuning problem: too long and you widen the attacker's usable replay margin; too short and flaky networks start locking real users out of their sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters even if you're not rolling your own
&lt;/h2&gt;

&lt;p&gt;If you're using Auth0, Okta, or another provider, you don't write this state machine — but you do configure it, and debugging "why did this user get logged out everywhere" tickets requires knowing it exists. If you're building your own OAuth server (say, for a service-to-service or IoT use case where a hosted IdP doesn't fit), rotation without family tracking gives you a false sense of security: you'll pass a pen test that only checks "does the old token get rejected" while remaining blind to the actual replay-race scenario that reuse detection is built to catch.&lt;/p&gt;

&lt;p&gt;The one-line summary worth remembering: rotation limits &lt;em&gt;how long&lt;/em&gt; a stolen token works; family-based reuse detection is what tells you &lt;em&gt;that&lt;/em&gt; it was stolen at all.&lt;/p&gt;

</description>
      <category>oauth</category>
      <category>auth0</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AI Review Isn't the Gate. Your CI Pipeline Still Is.</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Sat, 29 Aug 2026 19:59:11 +0000</pubDate>
      <link>https://dev.to/mukesh_13/ai-review-isnt-the-gate-your-ci-pipeline-still-is-5741</link>
      <guid>https://dev.to/mukesh_13/ai-review-isnt-the-gate-your-ci-pipeline-still-is-5741</guid>
      <description>&lt;p&gt;Two weeks ago I watched a pull request get merged with an AI-authored review comment that said "LGTM, nice defensive coding here" — attached to a diff that introduced an unguarded array index into a payment reconciliation job. The array could be empty. It has been empty, twice, in production, since. Nobody had tested the reviewer.&lt;/p&gt;

&lt;p&gt;That's not a hypothetical. It's the exact failure mode buried in the discourse this week when developers started asking why AI "promoted every developer to reviewer" without anyone stopping to ask what happens when the reviewer itself is wrong. Here's my hot take: AI code review should never be a merge gate. It should be a colleague you consult, not a bouncer you trust with a badge — and if your CI pipeline doesn't already know that, your pipeline is broken, not your reviewer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The steelman: AI review is genuinely useful
&lt;/h2&gt;

&lt;p&gt;Let's not pretend the tools are bad. Point an LLM at a diff and it will catch things a tired human on their fourth PR of the day will miss: a missing null check, a docstring that no longer matches the function signature, an SQL query concatenated instead of parameterized, an off-by-one in a loop bound. It does this in seconds, for free, on every single PR, without getting bored or defensive. Teams report real velocity gains from using it as a first pass — fewer round-trips, fewer "please add a test for the empty case" comments a human reviewer would've had to type out by hand.&lt;/p&gt;

&lt;p&gt;None of that is in dispute. The problem isn't that AI review is bad at reviewing. The problem is that nobody has decided who reviews it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "who reviews the reviewer" isn't a philosophical question
&lt;/h2&gt;

&lt;p&gt;Run the same diff through the same model twice and you can get two different verdicts. That's not a bug you can file — it's the nature of the tool. A human reviewer who flip-flops on the same code gets a performance conversation. An LLM that flip-flops gets nothing, because nobody's tracking it. There's no regression suite for the reviewer, no versioned behavior, no way to say "this is worse than it was last month," because you never wrote down what "good" looked like in the first place.&lt;/p&gt;

&lt;p&gt;Compare that to everything else that gets to block a merge in a mature pipeline: a test suite has a known pass/fail history. A linter has a changelog. A coverage threshold is a number you can graph over time. When any of those regress, you can point at the exact commit that broke them. When your AI reviewer starts approving diffs it should reject, you find out from the incident, not from the pipeline.&lt;/p&gt;

&lt;p&gt;That asymmetry is the actual hot take: it's not that AI review is untrustworthy, it's that teams have quietly given a non-deterministic, non-versioned, non-tested system the same authority as a deterministic one — and then act surprised when it behaves like the former.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: keep the gate deterministic, keep the AI advisory
&lt;/h2&gt;

&lt;p&gt;If you're running CircleCI, this is a fifteen-minute change to your &lt;code&gt;config.yml&lt;/code&gt;, not a philosophical debate. Split "AI says this looks fine" from "this diff is allowed to merge" into two separate workflow jobs, and only put the deterministic one in branch protection's required checks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2.1&lt;/span&gt;

&lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pr-checks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;test-and-lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ai-review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
          &lt;span class="na"&gt;requires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;   &lt;span class="c1"&gt;# runs in parallel, blocks nothing&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test-and-lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;docker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cimg/node:20.11&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run lint&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test -- --coverage&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Enforce coverage floor&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx nyc check-coverage --lines &lt;/span&gt;&lt;span class="m"&gt;80&lt;/span&gt;

  &lt;span class="na"&gt;ai-review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;docker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cimg/node:20.11&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Post advisory AI review comment&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node scripts/ai-review.js --post-comment --no-fail-on-issues&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--no-fail-on-issues&lt;/code&gt; flag on the AI review job isn't a suggestion, it's load-bearing: that job's exit code should never be able to fail the build. In GitHub or GitLab branch protection, only &lt;code&gt;test-and-lint&lt;/code&gt; goes in the required-checks list. The AI reviewer gets to talk. It doesn't get to vote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the reviewer the way you test everything else
&lt;/h2&gt;

&lt;p&gt;Here's the part most teams skip entirely: if you're going to keep an AI reviewer around — and you should, it's useful — give it the same regression discipline you give your code. Build a small golden set of past PRs where you already know the right call: five that should have been rejected (the null-check miss, the SQL injection, the empty-array bug that got through), five that were legitimately fine. Store the diffs and the expected verdicts in the repo.&lt;/p&gt;

&lt;p&gt;Then add a scheduled CircleCI pipeline — nightly or weekly — that replays that golden set through whatever model and prompt you're currently using, and fails loudly if the verdicts drift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nightly-reviewer-regression&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
          &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;only&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;reviewer-regression-test&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;reviewer-regression-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;docker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cimg/node:20.11&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Replay golden PR set against current reviewer&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node scripts/reviewer-regression.js --golden-set ./fixtures/reviewer-golden --fail-on-mismatch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This costs you maybe an hour of setup and a handful of pipeline credits a month. What it buys you is the one thing "AI promoted every developer to reviewer" discourse keeps skipping past: proof, over time, that the reviewer you're trusting today still catches the bug it caught six months ago, on the model version you're actually running now — not the one you tested when you first turned it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Landing the argument
&lt;/h2&gt;

&lt;p&gt;The hot take isn't "don't use AI to review code." It's that review authority and review assistance are different jobs, and collapsing them into one CI step is how a genuinely useful tool quietly becomes an ungoverned one. Keep your merge gate boring, deterministic, and versioned — tests, lint, coverage, security scan. Let the AI reviewer talk as loudly as it wants in the PR comments. And once a quarter, put the reviewer itself through the same regression suite you'd demand of any other piece of code that gets to have an opinion about what ships. If you wouldn't merge a linter you'd never run against a test case, don't merge-gate on a reviewer you've never tested either.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>ai</category>
      <category>codereview</category>
      <category>devops</category>
    </item>
    <item>
      <title>Three Unrelated Pipelines, One Root Cause: Building a CircleCI Gate That Catches Broken LLM JSON Before It Ships</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Fri, 28 Aug 2026 19:04:59 +0000</pubDate>
      <link>https://dev.to/mukesh_13/three-unrelated-pipelines-one-root-cause-building-a-circleci-gate-that-catches-broken-llm-json-2dj5</link>
      <guid>https://dev.to/mukesh_13/three-unrelated-pipelines-one-root-cause-building-a-circleci-gate-that-catches-broken-llm-json-2dj5</guid>
      <description>&lt;p&gt;Yesterday, three completely unrelated pipelines in my system failed the same way within 48 hours.&lt;/p&gt;

&lt;p&gt;One generates blog articles. One generates local-business outreach demos. One generates client proposals. They don't share a codebase, a prompt template, or an owner. What they share is a single line buried in each of them: &lt;code&gt;json.loads(response)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;All three broke with a variation of the same error: &lt;em&gt;"content generated but JSON parse failed."&lt;/em&gt; Not a crash — worse. A silent stall. The model did its job, produced something, and the parser choked on it downstream, after the artifact was already written to disk. By the time anyone noticed, the failure was three layers removed from its cause.&lt;/p&gt;

&lt;p&gt;If you're shipping any product where an LLM's output gets parsed as structured data — and at this point, whose isn't — this is worth thirty minutes of your CI pipeline's time, because it will happen to you too, and it will happen more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this keeps happening
&lt;/h2&gt;

&lt;p&gt;The instinct is to treat "parse the model's JSON" as a one-line implementation detail. It isn't. It's a contract between two systems that drift independently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model provider ships a checkpoint update and the model gets &lt;em&gt;more polite&lt;/em&gt; — it now wraps JSON in a friendly sentence ("Sure, here's the JSON you asked for:") that it didn't add last week.&lt;/li&gt;
&lt;li&gt;Someone edits a prompt template for an unrelated reason and accidentally changes whether the model treats a trailing comma as fine.&lt;/li&gt;
&lt;li&gt;A field that used to always be a string starts arriving as &lt;code&gt;null&lt;/code&gt; on some fraction of calls because the model decided ambiguity was better represented that way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are your bugs, exactly. But they become your outage, because your parser has zero tolerance for any of them, and nothing tells you the contract changed until a human notices missing output days later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that doesn't scale: patch each pipeline
&lt;/h2&gt;

&lt;p&gt;The first move is obvious and it's what I did initially — wrap the &lt;code&gt;json.loads&lt;/code&gt; call, catch the exception, log it, retry once with a stricter re-prompt. Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_llm_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Strip markdown code fences if the model added them
&lt;/span&gt;    &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;```

(?:json)?\s*(\{.*\})\s*

```&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOTALL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_with_repair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Your previous response failed to parse as JSON &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;last_error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;). Return ONLY valid JSON, no prose, no code fences.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parse_llm_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed to get valid JSON after &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; attempts: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;last_error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works. It also completely misses the point, because I wrote a version of this three separate times, once per pipeline, and the underlying contract violation still wasn't caught until runtime — after real API spend, after a task was already marked in-progress, after the failure had to be discovered rather than prevented.&lt;/p&gt;

&lt;p&gt;The deeper mistake was validating &lt;strong&gt;after&lt;/strong&gt; the artifact was generated and stored, instead of before the task was allowed to complete. Downstream validation finds the bug. It doesn't stop three independent teams (or three independent pipelines, if you're a solo dev) from rediscovering it separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that scales: treat it as a contract test, run in CI
&lt;/h2&gt;

&lt;p&gt;The actual fix wasn't a better try/except. It was moving the check to a layer where a bad contract gets caught &lt;em&gt;before&lt;/em&gt; it ships, on every commit that touches a prompt template or a parsing schema — not after a customer-facing task fails.&lt;/p&gt;

&lt;p&gt;Step one: freeze a fixture set of real model responses, both the ones that parsed fine and the malformed ones that caused the actual incidents. Keep them as JSONL, one response per line, alongside an expected outcome:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"raw": "{\"title\": \"Post\", \"tags\": [\"a\"]}", "should_parse": true}
{"raw": "Sure, here's the JSON:\n```

json\n{\"title\": \"Post\"}\n

```", "should_parse": true}
{"raw": "{\"title\": \"Post\", \"tags\": [\"a\",]}", "should_parse": false}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step two: a pytest suite that runs the real parser against every fixture and asserts the outcome matches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pipeline.parsing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;parse_llm_json&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_fixtures&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fixtures/llm_responses.jsonl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;case&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;load_fixtures&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_parser_contract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;should_parse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;parse_llm_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;# must not raise
&lt;/span&gt;    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raises&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;parse_llm_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step three, the part that actually stopped the repeat: a dedicated CircleCI job gating anything touching prompts or parsing code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2.1&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;llm-contract-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;docker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cimg/python:3.12&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install -r requirements.txt&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run LLM output contract tests&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pytest tests/test_llm_contract.py -v&lt;/span&gt;

&lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;build-and-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;llm-contract-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;only&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/.*/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time a prompt template, a parser, or a schema changes, this job runs the full fixture set — the good responses and the bad ones I've actually seen in production — in about eight seconds. If a change makes the parser reject a response it used to accept, or accept one it used to (correctly) reject, the build fails before merge, not three pipelines and three days later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed operationally
&lt;/h2&gt;

&lt;p&gt;The fixture file is now append-only: every time a pipeline hits a new malformed-response shape in production, it gets added to &lt;code&gt;fixtures/llm_responses.jsonl&lt;/code&gt; as a new &lt;code&gt;should_parse: false&lt;/code&gt; case before the fix ships. That turns every incident into a permanent regression test instead of a one-off patch. Three months in, the fixture set has caught two prompt-template edits that would have silently broken parsing again — both caught in CI, both zero-impact in production.&lt;/p&gt;

&lt;p&gt;The lesson wasn't "add a try/except." It was that an LLM's output shape is an interface with a version history, and the same discipline you'd apply to an external API contract — recorded fixtures, explicit pass/fail cases, a CI gate — applies here too. The parsing bug wasn't three bugs in three pipelines. It was one missing test suite.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>python</category>
      <category>ai</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Give Your Mem0 Agent Session-Scoped Memory in 15 Minutes (One Filter You're Probably Skipping)</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 19:11:49 +0000</pubDate>
      <link>https://dev.to/mukesh_13/give-your-mem0-agent-session-scoped-memory-in-15-minutes-one-filter-youre-probably-skipping-5139</link>
      <guid>https://dev.to/mukesh_13/give-your-mem0-agent-session-scoped-memory-in-15-minutes-one-filter-youre-probably-skipping-5139</guid>
      <description>&lt;p&gt;You add memory to your agent with Mem0, ship it, and it works great in your dev environment where you're the only user. Then you go multi-tenant — real users, real sessions — and three weeks later someone reports that the agent "remembers" something they never told it. It's not a hallucination. It's another user's memory, served straight from your own vector store.&lt;/p&gt;

&lt;p&gt;This is the single most common Mem0 integration bug I run into, and the fix is one filter you're probably not passing consistently. Here's the 15-minute version.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that causes it
&lt;/h2&gt;

&lt;p&gt;Most Mem0 quickstarts look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mem0&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Memory&lt;/span&gt;

&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Memory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I prefer flights with no layovers and I&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;m vegetarian&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks scoped — you passed &lt;code&gt;user_id="alice"&lt;/code&gt;. The bug isn't in &lt;code&gt;add()&lt;/code&gt;. It's in &lt;code&gt;search()&lt;/code&gt;, three files away, written by a different part of the team (or you, two sprints later) without the same discipline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# somewhere in the RAG/retrieval layer
&lt;/span&gt;&lt;span class="n"&gt;relevant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;what are the user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s travel preferences?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;user_id&lt;/code&gt;. No &lt;code&gt;filters&lt;/code&gt;. Mem0 will happily return the closest semantic matches across &lt;em&gt;every&lt;/em&gt; memory in the store — Alice's vegetarian preference bleeding into Bob's session, or worse, into an agent that's actively talking to Bob. The write path was scoped. The read path wasn't. Because both calls succeed and return plausible-looking data, this ships, passes QA (one tester, one session), and only shows up once you have concurrent real users.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line fix
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;search()&lt;/code&gt; call needs the same scoping identity as the &lt;code&gt;add()&lt;/code&gt; call that created the memory. If you passed &lt;code&gt;user_id&lt;/code&gt; on write, pass it on every read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;relevant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;what are the user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s travel preferences?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the 80% fix. But scoping by &lt;code&gt;user_id&lt;/code&gt; alone isn't enough once you have more than one agent or more than one conversation thread per user — which is most real products within a month of launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part people miss: agent_id and run_id
&lt;/h2&gt;

&lt;p&gt;Mem0 supports three identity dimensions, not one: &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;agent_id&lt;/code&gt;, and &lt;code&gt;run_id&lt;/code&gt;. If your product has multiple agents (a support bot and a booking bot, say) sharing the same user base, scoping by &lt;code&gt;user_id&lt;/code&gt; alone means the booking bot's memories leak into the support bot's context — technically the right user, wrong agent, still a correctness bug.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User wants the booking bot to always confirm price before charging&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;booking-bot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User asked support to stop sending SMS notifications&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;support-bot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# retrieval inside booking-bot's context
&lt;/span&gt;&lt;span class="n"&gt;relevant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payment preferences&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;booking-bot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;agent_id&lt;/code&gt; on both calls, &lt;code&gt;search()&lt;/code&gt; from the booking bot can surface the SMS-notification memory that belongs to a completely different conversational context. It's not wrong data exactly — it's real, it's Alice's — but it's the wrong memory for this agent to be reasoning with, and it will show up in the prompt as if it's relevant.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;run_id&lt;/code&gt; is the same idea one level down: scope to a single session or task run when you don't want memory to carry across unrelated conversations with the same agent (a returning support ticket vs. an old, resolved one, for example).&lt;/p&gt;

&lt;h2&gt;
  
  
  Advanced filtering for anything beyond exact match
&lt;/h2&gt;

&lt;p&gt;Once you're past simple identity scoping, Mem0's platform API accepts a &lt;code&gt;filters&lt;/code&gt; dict with &lt;code&gt;AND&lt;/code&gt;/&lt;code&gt;OR&lt;/code&gt; logic on top of metadata you attach at write time — useful for things like "only memories from the last 30 days" or "only memories tagged &lt;code&gt;billing&lt;/code&gt;":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Card ending 4242 declined for insufficient funds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing-bot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payment_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resolved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;relevant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payment issues&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing-bot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AND&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payment_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resolved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what turns "the agent remembers everything about this user" into "the agent remembers the right thing for this exact context" — which is the actual goal, not raw recall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 15-minute checklist
&lt;/h2&gt;

&lt;p&gt;Go do this right now, it's faster than reading the rest of this article twice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Grep your codebase for every &lt;code&gt;.search(&lt;/code&gt; call against your Mem0 client.&lt;/li&gt;
&lt;li&gt;For each one, check it passes the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;user_id&lt;/code&gt; (and &lt;code&gt;agent_id&lt;/code&gt;/&lt;code&gt;run_id&lt;/code&gt; if you use them) as the &lt;code&gt;add()&lt;/code&gt; calls that populate that memory space.&lt;/li&gt;
&lt;li&gt;Any &lt;code&gt;search()&lt;/code&gt; call missing scoping is a live cross-tenant leak — fix it before anything else on this list.&lt;/li&gt;
&lt;li&gt;If two agents share a user base, add &lt;code&gt;agent_id&lt;/code&gt; to every add/search pair, not just the ones you've noticed problems with.&lt;/li&gt;
&lt;li&gt;Write one test: add a scoped memory for &lt;code&gt;user_id="test-a"&lt;/code&gt;, search as &lt;code&gt;user_id="test-b"&lt;/code&gt;, assert the result is empty. This is the regression test that catches the bug before your users do.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The underlying lesson generalizes past Mem0: any memory or retrieval layer that supports scoping only prevents leaks if scoping is enforced symmetrically on both write and read. Write-side discipline without read-side discipline isn't partial protection — it's a false sense of security with a matching demo that works perfectly until a second user shows up.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llmops</category>
      <category>memory</category>
      <category>python</category>
    </item>
    <item>
      <title>Mem0 vs Zep vs LangChain Memory vs Letta: Which One Actually Remembers?</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:03:18 +0000</pubDate>
      <link>https://dev.to/mukesh_13/mem0-vs-zep-vs-langchain-memory-vs-letta-which-one-actually-remembers-2j47</link>
      <guid>https://dev.to/mukesh_13/mem0-vs-zep-vs-langchain-memory-vs-letta-which-one-actually-remembers-2j47</guid>
      <description>&lt;p&gt;Most "AI memory" demos are a vector store with a marketing label. You embed every message, cosine-search the top-k on the next turn, and call it memory. It works until turn 40, when the agent confidently tells a user their favorite color is blue because that's what came back highest-ranked — even though they corrected it three messages later.&lt;/p&gt;

&lt;p&gt;Real memory isn't retrieval. It's deciding what's still true. That distinction is why four very different architectures — Mem0, Zep, LangChain's memory classes, and Letta (formerly MemGPT) — all claim the same territory but solve almost none of the same problems. Here's what each one actually does under the hood, where it breaks, and which one you should reach for.&lt;/p&gt;

&lt;h2&gt;
  
  
  LangChain memory: primitives, not a system
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ConversationBufferMemory&lt;/code&gt;, &lt;code&gt;ConversationSummaryMemory&lt;/code&gt;, &lt;code&gt;ConversationKGMemory&lt;/code&gt;, &lt;code&gt;VectorStoreRetrieverMemory&lt;/code&gt; — these are building blocks, not a memory service. You own the extraction logic, the storage schema, and every decision about what gets kept or discarded. &lt;code&gt;ConversationSummaryMemory&lt;/code&gt; re-summarizes the whole history on every turn, which means cost and latency grow with conversation length even though the output size doesn't. &lt;code&gt;ConversationKGMemory&lt;/code&gt; extracts triples but has no mechanism to invalidate a triple once a new fact contradicts it — old and new coexist in the graph, and retrieval has no way to prefer one.&lt;/p&gt;

&lt;p&gt;This is fine if you want full control and are building something bespoke on top of LangGraph's checkpointing. It's the wrong choice if you want memory to just work, because "just work" is precisely the part LangChain leaves as an exercise for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; you're already deep in LangGraph, you have specific extraction logic you don't want a black box making decisions about, and you're willing to build conflict resolution yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mem0: LLM-mediated add/update/delete
&lt;/h2&gt;

&lt;p&gt;Mem0's core loop is a two-stage LLM call. First, an extraction pass pulls candidate facts out of a message ("user prefers dark mode," "user is allergic to shellfish"). Second — and this is the part most memory layers skip — a second LLM call compares each candidate against the &lt;em&gt;existing&lt;/em&gt; memories for that user and decides: ADD (net new), UPDATE (same entity, changed value), DELETE (contradicted), or NOOP (already known). That decision is what stops the shellfish-allergy memory from sitting next to a stale "user eats shrimp regularly" memory forever.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mem0&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Memory&lt;/span&gt;
&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Memory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I used to like coffee but I&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ve switched to tea&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;u1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# extraction: {preference: tea}, conflict check against
# existing {preference: coffee} -&amp;gt; UPDATE, not ADD
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Memories are stored as embeddings in a pluggable vector store (Qdrant, Chroma, pgvector, Weaviate) with metadata, and Mem0 added a graph layer (Neo4j-backed) for relationship queries — "who does the user report to" style facts that a flat vector store handles badly. Addition is async by default, so it doesn't block your response path, which matters if you're calling &lt;code&gt;add()&lt;/code&gt; after every turn in a latency-sensitive chat app.&lt;/p&gt;

&lt;p&gt;The honest tradeoff: the conflict-resolution LLM call is an extra hop with extra cost and extra latency on the write path, and if your extraction prompt is too aggressive you'll get memory bloat — hundreds of low-value "facts" that dilute retrieval quality. Mem0 gives you knobs (custom extraction prompts, memory-type separation) but you still have to tune them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; you have a multi-session, multi-user product (support bot, personal assistant, CRM copilot) where facts genuinely change over time and you need automatic reconciliation instead of a growing pile of contradictions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zep: temporal knowledge graphs
&lt;/h2&gt;

&lt;p&gt;Zep's differentiator is Graphiti, its temporal-graph engine. Instead of choosing between "keep the old fact" or "overwrite it," Zep timestamps edges with both event time and ingestion time (a bi-temporal model) and marks superseded facts as invalid rather than deleting them. Ask Zep "where did the user work in 2023" and it can answer correctly even after the user has since changed jobs, because the graph retains history instead of collapsing to a single current value.&lt;/p&gt;

&lt;p&gt;This is genuinely different from Mem0's ADD/UPDATE/DELETE model — Zep never deletes, it invalidates, which means you get an audit trail for free. That's valuable for compliance-sensitive domains but it's overkill if you only ever care about the &lt;em&gt;current&lt;/em&gt; state of a fact and don't need to reason about when it changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; you need point-in-time correctness — support timelines, longitudinal user profiles, anything where "what did we believe was true at time X" is a real query, not just "what's true now."&lt;/p&gt;

&lt;h2&gt;
  
  
  Letta (MemGPT): the agent edits its own memory
&lt;/h2&gt;

&lt;p&gt;Letta flips the architecture entirely. Instead of an external pipeline deciding what to remember, the LLM agent itself gets memory-editing functions (&lt;code&gt;core_memory_append&lt;/code&gt;, &lt;code&gt;core_memory_replace&lt;/code&gt;, &lt;code&gt;archival_memory_insert&lt;/code&gt;) as tools it can call mid-conversation. Context is split into an OS-style hierarchy: core memory (small, always in the prompt, directly editable), and archival/recall memory (external, paged in via search when relevant). The agent decides, at inference time, what's worth writing down and what's worth paging back in.&lt;/p&gt;

&lt;p&gt;The upside is nuance — the agent can decide "this is important enough for core memory" versus "this is archival trivia" based on actual conversational context, not a fixed extraction heuristic. The downside is cost and predictability: every memory operation is now an extra tool call inside the agent's own reasoning loop, which adds latency and makes memory writes non-deterministic across runs. Debugging "why didn't it remember X" means inspecting the agent's tool-call trace, not a pipeline log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; you're building a long-running autonomous agent (not a request/response chatbot) where memory management is itself part of the task the agent should reason about — not a side effect you want abstracted away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision, compressed
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Full control, already on LangGraph&lt;/td&gt;
&lt;td&gt;LangChain memory primitives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-user product, facts change, want automatic reconciliation&lt;/td&gt;
&lt;td&gt;Mem0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need to know what was true &lt;em&gt;when&lt;/em&gt;, audit trail&lt;/td&gt;
&lt;td&gt;Zep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autonomous long-running agent, memory as part of the task&lt;/td&gt;
&lt;td&gt;Letta&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The question to ask before picking any of these isn't "which has the best retrieval." Retrieval is the easy 20%. It's "who decides when a memory is wrong, and how do they find out." A vector store with no conflict resolution will happily retrieve a stale fact with high cosine similarity and hand it to your agent with total confidence. That's not memory — that's a very expensive way to remember things incorrectly.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llmops</category>
      <category>machinelearning</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
