<?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: Guatu</title>
    <description>The latest articles on DEV Community by Guatu (@futhgar).</description>
    <link>https://dev.to/futhgar</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%2F3847021%2F5aa46faa-d8e6-4023-ad78-5a335f875d69.png</url>
      <title>DEV Community: Guatu</title>
      <link>https://dev.to/futhgar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/futhgar"/>
    <language>en</language>
    <item>
      <title>We Almost Deployed a Temporal Knowledge Graph. The Eval Said No.</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Fri, 14 Aug 2026 06:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/we-almost-deployed-a-temporal-knowledge-graph-the-eval-said-no-3ld</link>
      <guid>https://dev.to/futhgar/we-almost-deployed-a-temporal-knowledge-graph-the-eval-said-no-3ld</guid>
      <description>&lt;p&gt;The eval that killed the temporal knowledge graph asserted one thing: at time T, the agent should report the state that was true at T. It failed 41% of the time. The graph had the right facts. It just handed the agent the wrong one.&lt;/p&gt;

&lt;p&gt;That number is what saved us from shipping. Every static retrieval metric looked fine. The graph answered "what is the status of Node A" with a confident, well-formed response. Trouble is, "what is the status" is a temporal question wearing a static question's clothes, and nothing in our test suite had noticed the difference until we wrote a test that actually asked about time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expected
&lt;/h2&gt;

&lt;p&gt;The pitch for a temporal knowledge graph (TKG) is genuinely good. You store facts as quadruples instead of triples: &lt;code&gt;(subject, predicate, object, timestamp)&lt;/code&gt; or, better, &lt;code&gt;(subject, predicate, object, valid_from, valid_to)&lt;/code&gt;. Now your agent memory isn't a flat pile of embeddings, it's a structured record of what was true and when. This is the natural next step past pure vector recall, and it slots neatly into the decay-based thinking I've written about before in &lt;a href="https://dev.to/posts/eviction-without-deletion-running-an-act-r-decay-policy-for-agent-memory-in-production/"&gt;Eviction Without Deletion&lt;/a&gt;. Instead of letting old facts fade by activation weight, you make validity windows explicit.&lt;/p&gt;

&lt;p&gt;My hope was that the graph would fix the exact failure mode that plagues flat vector memory: the agent confidently recalling a stale fact because it's semantically close to the query. With &lt;code&gt;valid_from&lt;/code&gt; and &lt;code&gt;valid_to&lt;/code&gt; on every edge, staleness becomes a filter, not a guess. Ask for the state at time T, filter edges where T falls inside the window, done. On paper it's cleaner than a decay curve because there's no fuzziness. A fact is either valid at T or it isn't.&lt;/p&gt;

&lt;p&gt;Schema-wise, it was simple enough. In a property graph it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A temporal fact: Node A was in maintenance for a fixed window&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;n:&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s1"&gt;'node-a'&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:HAS_STATE&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;status:&lt;/span&gt; &lt;span class="s1"&gt;'maintenance'&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
  &lt;span class="py"&gt;valid_from:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-07-20T02:00:00Z'&lt;/span&gt;&lt;span class="ss"&gt;),&lt;/span&gt;
  &lt;span class="py"&gt;valid_to:&lt;/span&gt;   &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-07-20T04:30:00Z'&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;}]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:State&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;kind:&lt;/span&gt; &lt;span class="s1"&gt;'maintenance'&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiple &lt;code&gt;HAS_STATE&lt;/code&gt; edges per server, each with its own window. Query the graph, get the state for any point in time. This is the "structured shared memory" pattern I described in &lt;a href="https://dev.to/posts/multi-agent-ai-systems-architecture-patterns/"&gt;Multi-Agent AI Systems&lt;/a&gt;, except now the shared memory understands time. The whole thing felt like an upgrade in every dimension. It reads well in a design doc. It demos beautifully. That was part of the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;Retrieval is where it fell apart, and the failure is boring in a way that makes it dangerous. Here's roughly the query the agent's tool was generating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The "obvious" query - find the status of a server&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;n:&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="n"&gt;$server&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="py"&gt;r:&lt;/span&gt;&lt;span class="n"&gt;HAS_STATE&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;s.status&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r.valid_from&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r.valid_to&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;r.valid_from&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that carefully. It orders by &lt;code&gt;valid_from&lt;/code&gt; descending and takes the most recent fact. Most of the time that's correct, because the most recently started state is usually the current one. What it never does is filter by the query's reference time. If the agent is reasoning about an incident that happened at 03:00, and a newer "online" state was recorded at 05:00, this query returns "online." The graph knew Node A was in maintenance at 03:00. The retrieval logic threw that knowledge away.&lt;/p&gt;

&lt;p&gt;This is the hallucinated-history problem, and it's insidious because the model isn't hallucinating. The fact is real. The timestamp is real. The agent is just being handed a fact from the wrong window and has no way to know it. Worse, the answer is fluent and specific, so every static evaluation gives it a pass. RAGAS-style faithfulness checks look at whether the answer is grounded in the retrieved context. It was. The retrieved context was simply the wrong slice of time.&lt;/p&gt;

&lt;p&gt;I want to be precise about where the failure lived, because it wasn't the graph. The graph was correct. The schema was correct. The data was correct. The failure was split across two places: a retrieval query that dropped the temporal filter, and an evaluation suite that had no test capable of noticing. If we'd only had the first problem, we'd have caught it in review. Having both meant the system looked healthy right up until the one test that mattered.&lt;/p&gt;

&lt;p&gt;That missing test is short:&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;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;agent_memory&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;query_state&lt;/span&gt;  &lt;span class="c1"&gt;# our TKG retrieval tool
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_temporal_regression&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Agent must report the state valid AT the reference time,
    not the most recently recorded state.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="c1"&gt;# Maintenance window: 02:00-04:30. Online recorded at 05:00.
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-07-20T03:00:00Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# A later 'online' fact exists, but at 03:00 the truth is 'maintenance'
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;maintenance&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temporal regression: got &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &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;for a timestamp inside the maintenance window&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;Run that single assertion across a few dozen historical state transitions and you get the 41% failure rate. Not a subtle degradation. Nearly half of all time-scoped questions returned a state from the wrong window whenever a newer fact existed. Meanwhile the static suite, which only checked "does the agent know the current status," passed everything. Two evals looking at the same system, one green, one red, and only the red one described reality.&lt;/p&gt;

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

&lt;p&gt;Correcting the retrieval was one clause. You filter edges so the reference time falls inside the validity window before you order or limit anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Filter by the reference time FIRST, then pick the winner&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;n:&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="n"&gt;$server&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="py"&gt;r:&lt;/span&gt;&lt;span class="n"&gt;HAS_STATE&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;r.valid_from&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;$at&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r.valid_to&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="ow"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;r.valid_to&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;$at&lt;/span&gt;&lt;span class="ss"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;s.status&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r.valid_from&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r.valid_to&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;r.valid_from&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;valid_to IS NULL&lt;/code&gt; handles the open-ended "current" state, the fact that has started but not yet ended. Everything else is a closed window, and the &lt;code&gt;WHERE&lt;/code&gt; clause guarantees you only ever consider edges whose window contains T. The &lt;code&gt;ORDER BY ... LIMIT 1&lt;/code&gt; is still there to break ties if two windows overlap, but now it's picking among facts that are all actually valid at T, not among every fact ever recorded.&lt;/p&gt;

&lt;p&gt;One clause. That's the entire retrieval fix. Which tells you the real bug was never in the query, it was in the fact that nobody wrote the eval that would have made the missing clause obvious on day one.&lt;/p&gt;

&lt;p&gt;So the second half of the fix was the more important one: the retrieval tool never gets to reason about time on its own. The agent isn't trusted to remember to pass a reference timestamp, and the LLM isn't trusted to filter windows in its head. Instead, the current time is injected as explicit context at the tool boundary, and the tool refuses to answer a state question without it:&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;query_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&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;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&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;if&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query_state requires a reference time. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Temporal facts are meaningless without one.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ... run the time-filtered Cypher above ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Making the reference time a required argument sounds trivial. It's the difference between a tool that silently returns plausible garbage and one that fails loudly when it's used wrong. A loud failure is a bug report. A plausible answer from the wrong window is an incident three weeks later that nobody can reproduce.&lt;/p&gt;

&lt;p&gt;We also changed how the retrieved fact is handed to the model. Rather than passing "status: maintenance" as a bare string, the prompt gets the window with it: "As of 2026-07-20T03:00:00Z, node-a status is 'maintenance' (valid 02:00-04:30). A later 'online' state exists from 05:00 and is not applicable to this query." Giving the model the window and the reference time in the same breath means that even if the retrieval ever regresses, the model has a fighting chance to notice the mismatch. Defense in depth, applied to a knowledge base.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;I keep coming back to the fact that we did not ship this because of the architecture. We almost shipped it because the architecture was correct and only the evaluation was wrong. That inversion is the whole lesson. A TKG is more capable than flat vector memory, and that extra capability comes with an entirely new class of failure that your existing evals were never designed to see. Adding temporal structure to your memory adds temporal bugs, and static tests are blind to all of them by construction.&lt;/p&gt;

&lt;p&gt;Here's the trap I see constantly. Teams treat GraphRAG as a strictly-better upgrade over vector RAG, port their old eval suite unchanged, watch it stay green, and conclude the new system is at least as good as the old one. Their green suite is measuring the properties the old system could fail on. It has no assertion about sequence, no assertion about validity windows, no assertion that state at T equals the state that was actually true at T. The new failure mode is invisible not because it's rare but because nothing is looking for it. This is the same gap I described in &lt;a href="https://dev.to/posts/cognitive-memory-for-agents-vector-search-vs-activation-based-recall/"&gt;Cognitive Memory for Agents&lt;/a&gt;: the retrieval method changed, so the questions your evals ask have to change too.&lt;/p&gt;

&lt;p&gt;If you're building temporal memory for an agent, write the temporal regression test before you write the graph. Seed a handful of known state transitions where a later fact contradicts an earlier one, then assert that a query scoped to the earlier window returns the earlier fact. That test is a dozen lines. It will fail the moment your retrieval forgets to filter by time, which, based on how naturally that &lt;code&gt;ORDER BY valid_from DESC LIMIT 1&lt;/code&gt; query wrote itself, is going to be your very first implementation.&lt;/p&gt;

&lt;p&gt;A few things I'd carry into the next attempt. Make the reference time a required parameter on every temporal query, so the tool cannot be called ambiguously. Keep the graph correct and put your paranoia in the retrieval and the eval, because that's where the wrong-window bug actually lives. Expose temporal queries to the agent through a narrow, well-typed interface, whether that's a tool boundary or an &lt;a href="https://dev.to/posts/building-mcp-servers-with-fastmcp/"&gt;MCP server&lt;/a&gt;, so the agent can't hand-roll a query that drops the filter. And treat "it passed the static eval" as necessary, never sufficient, the second time itself becomes a dimension of your data. If you're standing up this kind of time-aware memory for a production agent system and want a second set of eyes on the failure modes, that's a chunk of what I do &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;consulting&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We didn't deploy the temporal knowledge graph that quarter. We deployed the eval, fixed the one-clause retrieval bug it exposed, and shipped the graph once the red test went green. The graph was never the risky part. The risky part was almost trusting a system that no test had ever asked the right question.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>knowledgegraph</category>
      <category>evaluation</category>
      <category>rag</category>
    </item>
    <item>
      <title>Traefik Host Collision: Why Duplicate Host Rules Break IngressRoutes</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Fri, 14 Aug 2026 04:15:47 +0000</pubDate>
      <link>https://dev.to/futhgar/traefik-host-collision-why-duplicate-host-rules-break-ingressroutes-1jnd</link>
      <guid>https://dev.to/futhgar/traefik-host-collision-why-duplicate-host-rules-break-ingressroutes-1jnd</guid>
      <description>&lt;p&gt;A service deploys perfectly clean. Pods Running, Endpoints populated, the &lt;code&gt;Certificate&lt;/code&gt; resource says &lt;code&gt;Ready: True&lt;/code&gt;, the &lt;code&gt;IngressRoute&lt;/code&gt; shows no events. Then you curl the hostname and get back a 404 page rendered by your SSO provider, which you never attached to that route.&lt;/p&gt;

&lt;p&gt;Nothing in the deployment failed. Traefik logged nothing at error level. The 404 came from a service in a completely different namespace.&lt;/p&gt;

&lt;p&gt;That's a host collision, and the mechanism behind it is one of the least intuitive parts of Traefik's routing model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'd expect
&lt;/h2&gt;

&lt;p&gt;Two routers, two rules. One says &lt;code&gt;Host(\&lt;/code&gt;agents.example.com&lt;code&gt;)&lt;/code&gt;, the other says &lt;code&gt;HostRegexp(\&lt;/code&gt;^.+.example.com$&lt;code&gt;)&lt;/code&gt;. A request for &lt;code&gt;agents.example.com&lt;/code&gt; matches both. Any sane router picks the more specific match, because that's how routing works basically everywhere else: longest-prefix wins in IP routing, most-specific selector wins in CSS, exact match beats wildcard in DNS.&lt;/p&gt;

&lt;p&gt;So the exact &lt;code&gt;Host&lt;/code&gt; rule should win. It's narrower. It names one hostname. The regex names an infinite set.&lt;/p&gt;

&lt;p&gt;Traefik does not work that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens
&lt;/h2&gt;

&lt;p&gt;Traefik assigns every router a priority. If you don't set one, &lt;strong&gt;the default priority is the length of the rule string in characters&lt;/strong&gt;. That's it. Not specificity, not match type, not creation order. Character count.&lt;/p&gt;

&lt;p&gt;Do the arithmetic on those two rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host(`agents.example.com`)              →  26 characters
HostRegexp(`^.+\.example\.com$`)        →  32 characters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wildcard is 6 characters longer, so the wildcard wins. Every request for &lt;code&gt;agents.example.com&lt;/code&gt; gets handed to whatever service the regex router points at, which in most clusters is an auth proxy sitting in front of everything. The auth proxy gets a Host header for an app it has no provider configured for, and returns its own 404.&lt;/p&gt;

&lt;p&gt;The more generic rule outranks the more specific one because it happens to be typed with more characters. Rename your regex to something terser and the winner flips. Add a hyphen to a subdomain and the winner flips back. This is deterministic, it's documented, and it still surprises people every single time.&lt;/p&gt;

&lt;p&gt;It gets worse when the tie is exact. Two &lt;code&gt;IngressRoute&lt;/code&gt; objects in different namespaces with byte-identical &lt;code&gt;Host&lt;/code&gt; rules produce two routers with identical priority. Traefik's Kubernetes CRD provider derives router names from a namespace/name hash, so they don't collide by name and nothing errors out. You get two valid routers competing at the same rank, and the tie-break isn't something you want to build a production dependency on. The winner can change on the next config reload, which is the worst possible failure mode: works on Tuesday, breaks on Thursday, nothing in Git changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The auth-proxy fallthrough diagnostic
&lt;/h3&gt;

&lt;p&gt;Here's the pattern worth memorizing, because it saves an hour every time:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If you get a 404 (or a login redirect) from your auth provider on a service that was never wired to that auth provider, you have a Traefik routing collision.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The auth proxy is almost always the shadowing router, for two reasons. It usually owns the broadest rule in the cluster (a wildcard or regex covering the whole domain), and broad rules tend to be long rules. Regex syntax is verbose. &lt;code&gt;HostRegexp&lt;/code&gt; is 10 characters before you've matched anything.&lt;/p&gt;

&lt;p&gt;The symptom points at the auth stack, so that's where people start debugging. They check the outpost, the provider, the application binding, the token audience. All of it is fine. The auth proxy is behaving correctly given a Host header it doesn't recognize.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the actual routing table
&lt;/h2&gt;

&lt;p&gt;Stop guessing and ask Traefik what it decided. The runtime API exposes every router with its computed priority:&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;# Port-forward whatever entryPoint serves your API/dashboard&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; traefik port-forward deploy/traefik 8080:8080

curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8080/api/http/routers &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.rule | test("example\\.com"))
           | [.priority, .status, .service, .rule] | @tsv'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sorted descending by priority, the first row is the router that wins. If that row isn't the one you deployed, you've found it in about fifteen seconds. The &lt;code&gt;status&lt;/code&gt; field matters too: a router with &lt;code&gt;status: "disabled"&lt;/code&gt; won't serve traffic, and that's a separate failure I'll get to below.&lt;/p&gt;

&lt;p&gt;Two things this view gives you that the logs don't. It shows the &lt;em&gt;computed&lt;/em&gt; priority, including the default length-based value you never wrote down anywhere. And it shows routers from every namespace at once, which is exactly the visibility you lose when you're reading one &lt;code&gt;IngressRoute&lt;/code&gt; manifest at a time.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Set an explicit priority
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;IngressRoute&lt;/code&gt; CRD takes a &lt;code&gt;priority&lt;/code&gt; on each route. Setting it disables the length-based default for that router.&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IngressRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;agents&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dev&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;entryPoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;websecure&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rule&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host(`agents.example.com`)&lt;/span&gt;
      &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;          &lt;span class="c1"&gt;# beats any length-derived default in practice&lt;/span&gt;
      &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&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;agents&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;agents-tls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick a band and stick to it. Something like: exact-host routes get &lt;code&gt;priority: 100&lt;/code&gt;, wildcard/catch-all routes get &lt;code&gt;priority: 10&lt;/code&gt;. Two numbers, written into your chart defaults, and the entire class of bug disappears. Any exact host beats any wildcard regardless of how long anyone's rule string is.&lt;/p&gt;

&lt;p&gt;The reason this works better than the alternatives is that it encodes intent. &lt;code&gt;priority: 100&lt;/code&gt; in a manifest says "this is a specific route and it should win." A 45-character rule string says nothing to the next person reading it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Or narrow the wildcard
&lt;/h3&gt;

&lt;p&gt;If your auth proxy genuinely needs a catch-all, scope it so it can't swallow namespaces it has no business serving. Instead of matching the whole apex domain, match only the subdomains you actually front:&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;# Broad — catches everything under the domain, including new services&lt;/span&gt;
&lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HostRegexp(`^.+\.example\.com$`)&lt;/span&gt;

&lt;span class="c1"&gt;# Narrow — catches only what you opted in&lt;/span&gt;
&lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host(`sso.example.com`) || Host(`admin.example.com`)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth calling out: Traefik v3 changed &lt;code&gt;HostRegexp&lt;/code&gt; syntax. The v2 named-group form (&lt;code&gt;{subdomain:[a-z]+}.example.com&lt;/code&gt;) is gone; v3 expects standard Go regexp. If you migrated a v2 config and your wildcard silently stopped matching what you thought it matched, that's why. You can set &lt;code&gt;syntax: v2&lt;/code&gt; on a route as a compatibility escape hatch, but treat it as a migration step, not a destination.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hack that works and that you shouldn't ship
&lt;/h3&gt;

&lt;p&gt;Because priority is string length, you can win a collision by making your rule longer:&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;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host(`agents.example.com`) &amp;amp;&amp;amp; PathPrefix(`/`)&lt;/span&gt;   &lt;span class="c1"&gt;# 45 characters&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PathPrefix(\&lt;/code&gt;/&lt;code&gt;)&lt;/code&gt; matches everything, so the semantics are unchanged, and you've bought 19 characters of priority. It works. I've seen it in production charts more than once.&lt;/p&gt;

&lt;p&gt;Don't do it. It's a magic incantation that breaks the moment someone shortens the hostname or lengthens the competing rule, and nobody reviewing the diff will understand what it's for. Use &lt;code&gt;priority&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second path to the same symptom: cross-namespace middleware
&lt;/h2&gt;

&lt;p&gt;There's a failure that looks identical from the outside but has nothing to do with rule length.&lt;/p&gt;

&lt;p&gt;Since v3, the Kubernetes CRD provider defaults &lt;code&gt;allowCrossNamespace&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;. An &lt;code&gt;IngressRoute&lt;/code&gt; in the &lt;code&gt;dev&lt;/code&gt; namespace referencing a middleware in the &lt;code&gt;auth&lt;/code&gt; namespace gets rejected:&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;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rule&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host(`agents.example.com`)&lt;/span&gt;
    &lt;span class="na"&gt;middlewares&lt;/span&gt;&lt;span class="pi"&gt;:&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;forward-auth&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auth&lt;/span&gt;       &lt;span class="c1"&gt;# rejected unless allowCrossNamespace: true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Traefik logs a cross-namespace reference error and marks the router as disabled. It does not fail the deployment, does not update the &lt;code&gt;IngressRoute&lt;/code&gt; status in a way that &lt;code&gt;kubectl get&lt;/code&gt; makes obvious, and does not stop serving traffic. Your route simply isn't in the routing table anymore, so the request falls through to the next-best match. Which is the wildcard. Which is the auth proxy. Which returns a 404.&lt;/p&gt;

&lt;p&gt;Same symptom, different root cause, and the &lt;code&gt;/api/http/routers&lt;/code&gt; dump distinguishes them instantly: a collision shows your router present with a lower priority, a cross-namespace rejection shows your router &lt;code&gt;disabled&lt;/code&gt; or missing entirely.&lt;/p&gt;

&lt;p&gt;You can flip &lt;code&gt;allowCrossNamespace: true&lt;/code&gt; in the provider config, but understand what you're accepting: any namespace can then attach any middleware from any other namespace, including auth middlewares it wasn't meant to have and, more interestingly, &lt;em&gt;not&lt;/em&gt; attach ones it was. That's a real boundary, and turning it off to fix one route is the kind of decision that looks cheap now and expensive later. The same reasoning applies to how you scope identities across routing domains, which I got into in &lt;a href="https://guatulabs.dev/posts/agent-credential-management-two-tier-service-accounts/" rel="noopener noreferrer"&gt;two-tier service accounts for agent workflows&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The double-blind: when the shadowing service is also broken
&lt;/h2&gt;

&lt;p&gt;The nastiest version of this is when the auth proxy that's swallowing your traffic is itself misconfigured. Now you're debugging two failures at once and the symptoms interleave.&lt;/p&gt;

&lt;p&gt;A common one: the auth provider's secret key contains a trailing newline. &lt;code&gt;echo "value" | base64&lt;/code&gt; adds one. So does most copy-paste out of a terminal. The provider starts, serves a page, and then fails signature validation on every token, so you get a 404 or a redirect loop that looks exactly like a routing problem.&lt;/p&gt;

&lt;p&gt;Check secret shape without ever printing the value:&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="nv"&gt;NS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;auth&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;provider-config&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SECRET_KEY

&lt;span class="nv"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; get secret &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"{.data.&lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;clean&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; get secret &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"{.data.&lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\n\r'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&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;$raw&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$clean&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OK: no stray newlines (&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="s2"&gt; bytes)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;raw &lt;span class="o"&gt;-&lt;/span&gt; clean&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt; newline/CR byte(s) in &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value passes through the pipe and never lands in a variable or on stdout. Only the byte counts do. Run it as a pre-flight check on every secret that feeds an auth stack; the two-second version saves you from a debugging session where nothing makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  The collision-free pattern for internal services
&lt;/h2&gt;

&lt;p&gt;Not every service needs to sit behind the global auth wrapper, and routing everything through one broad rule is what creates the collision surface in the first place. For internal-only services, an IP allowlist plus a NetworkPolicy gives you a route that can't be shadowed because it never overlaps with anything:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Middleware&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;lan-only&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dev&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ipAllowList&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sourceRange&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;10.0.0.0/16&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IngressRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;agents&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dev&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;entryPoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;websecure&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rule&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host(`agents.example.com`)&lt;/span&gt;
      &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
      &lt;span class="na"&gt;middlewares&lt;/span&gt;&lt;span class="pi"&gt;:&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;lan-only&lt;/span&gt;    &lt;span class="c1"&gt;# same namespace, no cross-namespace friction&lt;/span&gt;
      &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&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;agents&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;agents-tls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Middleware lives in the same namespace as the route, so &lt;code&gt;allowCrossNamespace&lt;/code&gt; is irrelevant. Priority is explicit, so rule-length arithmetic is irrelevant. Pair it with a default-deny NetworkPolicy at the pod level, the way I laid out in &lt;a href="https://guatulabs.dev/posts/network-policies-with-calico-default-deny-and-namespace-isolation/" rel="noopener noreferrer"&gt;default-deny and namespace isolation with Calico&lt;/a&gt;, and you've got defense in depth without a single shared routing rule.&lt;/p&gt;

&lt;p&gt;The tradeoff is honest: you're trading centralized auth for per-service configuration. More YAML, more places to get it wrong, no single place to revoke access. For a public-facing app, the global wrapper is the right call. For an internal dashboard, the allowlist wins on blast radius alone. This kind of routing-boundary decision is one of the things I spend a lot of time on in &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;infrastructure consulting work&lt;/a&gt;, and the answer really does change per service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching it before merge
&lt;/h2&gt;

&lt;p&gt;Host collisions are a static property of your manifests. You don't need a running cluster to find them, which makes them a good fit for the kind of CI validation I described in &lt;a href="https://guatulabs.dev/posts/kubernetes-manifest-validation-catching-errors-before-merge/" rel="noopener noreferrer"&gt;catching broken YAML before merge&lt;/a&gt;:&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;# Fail the build if two IngressRoutes claim the same exact Host&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhoE&lt;/span&gt; &lt;span class="s1"&gt;'Host\(`[^`]+`\)'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.yaml'&lt;/span&gt; ./manifests &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"duplicate Host rules found"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crude, and it won't catch regex overlaps, but it catches the exact-duplicate case that produces the nondeterministic tie. Extend it to flag any &lt;code&gt;HostRegexp&lt;/code&gt; that lacks an explicit &lt;code&gt;priority&lt;/code&gt;, and you've covered the two ways this actually bites.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd change
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;priority&lt;/code&gt; on every route from day one. It costs one line and it removes an entire failure class. The default length-based behavior is fine for a single-tenant setup with five services; it stops being fine the moment two teams write rules against the same domain and neither one knows the other exists.&lt;/p&gt;

&lt;p&gt;Treat the auth-provider 404 as a routing signal, not an auth signal. That reflex alone cuts debugging time dramatically, because it redirects you from the auth stack (where everything is working) to the routing table (where the answer is).&lt;/p&gt;

&lt;p&gt;And keep &lt;code&gt;/api/http/routers&lt;/code&gt; in your muscle memory. Manifests describe intent. The runtime API describes what Traefik actually built, priorities included, across every namespace at once. When those two disagree, the runtime API is right and your mental model is wrong. If you're also chasing certificate mismatches on the same hostnames, &lt;a href="https://guatulabs.dev/posts/wildcard-dns-ndots-5-the-tls-nightmare-and-how-to-fix-it/" rel="noopener noreferrer"&gt;wildcard DNS and ndots:5&lt;/a&gt; covers the other half of that puzzle, and it interacts with this one more often than you'd like.&lt;/p&gt;

</description>
      <category>traefik</category>
      <category>kubernetes</category>
      <category>ingress</category>
      <category>networking</category>
    </item>
    <item>
      <title>The Write Policy Is the Hard Part: Promotion Pipelines for Agent Memory</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Fri, 14 Aug 2026 02:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/the-write-policy-is-the-hard-part-promotion-pipelines-for-agent-memory-5mc</link>
      <guid>https://dev.to/futhgar/the-write-policy-is-the-hard-part-promotion-pipelines-for-agent-memory-5mc</guid>
      <description>&lt;p&gt;Most "agent memory" tutorials stop at the retrieval side. They show you a Qdrant collection, a &lt;code&gt;qdrant-find&lt;/code&gt; call, an embedding model, and call it done. Retrieval is the easy 20%. The part that quietly eats your week is the write-path: deciding what deserves to be written, sanitizing it, and keeping the network and RBAC plumbing intact so the agent can actually reach the store it's allowed to write to.&lt;/p&gt;

&lt;p&gt;If you run agents that update their own knowledge base, this is for you. The failure modes here aren't AI problems. They're distributed-systems problems wearing an AI costume: a &lt;code&gt;403 Forbidden&lt;/code&gt; on a write, a &lt;code&gt;default-deny-ingress&lt;/code&gt; policy that silently blinds an agent to its own memory, a vector store slowly rotting into landfill because nobody gated the writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrieval is solved. Promotion isn't.
&lt;/h2&gt;

&lt;p&gt;The read-side has good primitives. You embed a query, you search, you rank, you feed the top-k back into context. I've written about the recall half of this before in &lt;a href="https://dev.to/posts/cognitive-memory-for-agents-vector-search-vs-activation-based-recall/"&gt;Cognitive Memory for Agents&lt;/a&gt;, where the interesting question is whether you use plain vector similarity or activation-based recall.&lt;/p&gt;

&lt;p&gt;Promotion has no such consensus. Every observation an agent makes during a session is a candidate for long-term memory, and almost none of them should be promoted. A single session produces hundreds of transient facts: the value of a variable, a file it read, a command that failed, a user's throwaway comment. Write all of that to a permanent store and you don't have a memory. You have a landfill with a search index bolted on.&lt;/p&gt;

&lt;p&gt;So the real design question isn't "how do I store this." It's "what is my write policy, and how do I enforce it without breaking security." That's two problems stacked on top of each other, and people usually only notice the second one after the first one is already leaking noise into their vector DB.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first: write-everything, filter-later
&lt;/h2&gt;

&lt;p&gt;The obvious first move is to write everything and sort it out at read time. Cheap to build. Every observation goes straight into the vector store, and you rely on similarity ranking to surface the good stuff and bury the noise.&lt;/p&gt;

&lt;p&gt;That falls apart for a boring reason: embeddings don't distinguish signal from noise, they distinguish topics from topics. A useless observation that happens to be on-topic ranks just as high as a genuine insight. Search "how do I fix the Longhorn PDB drain issue" and you get back the one real fix alongside six half-formed guesses the agent muttered mid-session and never confirmed. The index is technically working. The memory is useless.&lt;/p&gt;

&lt;p&gt;A simple recency window was the second thing I reached for. Keep the last N observations, drop the rest. That's not a memory either, that's a ring buffer. It throws away the rare high-value insight from three weeks ago and keeps the noise from ten minutes ago, purely because noise is more recent. Recency is a terrible proxy for worth.&lt;/p&gt;

&lt;p&gt;Both approaches share the same missing piece: there's no decision point where something gets &lt;em&gt;judged&lt;/em&gt; before it's written. No gate. The whole thing was reactive. And once you've dumped enough uncurated writes into a store, you inherit a second problem: eviction. Now you need a decay policy to claw back the space, which I covered in &lt;a href="https://dev.to/posts/eviction-without-deletion-running-an-act-r-decay-policy-for-agent-memory-in-production/"&gt;Eviction Without Deletion&lt;/a&gt;. The cleaner fix is to not let the garbage in during the write in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual solution: a promotion pipeline with a gatekeeper
&lt;/h2&gt;

&lt;p&gt;Treat long-term memory like a production branch. Nothing merges without passing checks. Observations live in a cheap, volatile scratchpad. Promotion to the permanent store is an explicit, gated event, not a side effect of the agent talking.&lt;/p&gt;

&lt;p&gt;The pipeline has four stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture&lt;/strong&gt; into a transient scratchpad (session-scoped, no gating).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score&lt;/strong&gt; each candidate for importance and novelty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate and sanitize&lt;/strong&gt; (dedup, schema check, strip secrets).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Promote&lt;/strong&gt; the survivors into the durable store with provenance.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At the heart of it sits the gatekeeper. It's not AI magic, it's a function with a threshold. Here's the shape I use:&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;gatekeeper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. Cheap reject: too short, or a known non-fact pattern
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;is_ephemeral&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observation&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;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DROP&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Score. Importance is explicit, novelty is measured.
&lt;/span&gt;    &lt;span class="n"&gt;importance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;score_importance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# 0.0 - 1.0
&lt;/span&gt;    &lt;span class="n"&gt;nearest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;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;observation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;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;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;novelty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nearest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;nearest&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Gate: must clear the bar AND not be a near-duplicate
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;importance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;novelty&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.15&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;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DROP&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. Sanitize before it ever touches the durable store
&lt;/span&gt;    &lt;span class="n"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;strip_secrets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&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;clean&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;observation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;observation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;observation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&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;clean&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;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROMOTE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two knobs matter here. The importance threshold controls how strict promotion is. The novelty check is what keeps you from writing the same fact forty times with slightly different wording, which is the single most common way vector stores bloat. A near-duplicate of something you already stored is worth nothing, no matter how important the underlying fact is.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;score_importance&lt;/code&gt; doesn't have to be an LLM call. I've had good results with a hybrid: a set of cheap heuristics that run on every observation, with an optional LLM tiebreaker reserved for the borderline cases. Cheap signals do most of the work:&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;score_importance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="c1"&gt;# Fixes, decisions, and root causes are worth keeping
&lt;/span&gt;    &lt;span class="k"&gt;if&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;\b(root cause|fixed by|the fix was|decided to)\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&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;I&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;
    &lt;span class="c1"&gt;# Concrete, reusable artifacts: commands, configs, versions
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if re.search(r"(\bv\d+\.\d+|--?[a-z-]+=|kubectl |sysctl )", obs.text):
    score += 0.25
# User explicitly asked to remember it
if obs.flags.get("user_pinned"):
    score += 0.5
# Pure status chatter is worth nothing
if re.search(r"^\s*(ok|done|running|checking)\b", obs.text, re.I):
    score -= 0.3
return max(0.0, min(1.0, score))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only when the heuristic lands in the ambiguous band (say 0.4 to 0.6) do I spend an LLM call to break the tie. That keeps the pipeline cheap. Most observations never touch a model. The ones that do are already suspected to be worth the tokens.&lt;/p&gt;

&lt;p&gt;Provenance is the stage people skip and regret. When you promote, attach where it came from: the session ID, the timestamp, the tool that produced it, and the importance score that let it through. Later, when a memory turns out to be wrong, you want to trace it back and either correct the source or tighten the gate. Without provenance you have facts floating free of any way to audit them, which is how a confidently-wrong memory poisons every future retrieval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then security breaks the whole thing
&lt;/h2&gt;

&lt;p&gt;Here's the part the memory tutorials never mention, because they all run on &lt;code&gt;localhost&lt;/code&gt; where everything is implicitly trusted. Move that same agent into a real cluster and the write-path stops working in ways that have nothing to do with your gatekeeper logic.&lt;/p&gt;

&lt;p&gt;The classic version: your MCP client talks to a memory server that was fine on localhost, you move the server into an LXC or a pod, and now every write comes back &lt;code&gt;403 Forbidden&lt;/code&gt;. The gatekeeper approved the write. The network rejected it. Those are different layers, and conflating them wastes an afternoon.&lt;/p&gt;

&lt;p&gt;A localhost MCP config assumes no auth. It looks like this and works only because nothing is checking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"memory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://127.0.0.1:8080/mcp"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Move that server behind an authenticating proxy and the same config gets a &lt;code&gt;401&lt;/code&gt; or &lt;code&gt;403&lt;/code&gt;. The fix is to pass a bearer token, and the token must never sit in the file as plaintext. Source it at launch instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"memory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://memory.internal.example.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer ${MEMORY_WRITE_TOKEN}"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;MEMORY_WRITE_TOKEN&lt;/code&gt; is injected from a secrets manager at process start, not committed anywhere. I run agent tokens through the same two-tier service-account pattern I described in &lt;a href="https://dev.to/posts/agent-credential-management-two-tier-service-accounts/"&gt;Agent Credential Management&lt;/a&gt;: a read-only identity for retrieval, a separate write identity for promotion, so a compromised reader can't corrupt the store. That split matters more for memory than for most workloads, because the read path runs constantly and the write path runs rarely. Give them the same credential and every retrieval carries write authority it never needs.&lt;/p&gt;

&lt;p&gt;RBAC tightening is the second way this bites. A lot of default service accounts have drifted toward &lt;code&gt;reader&lt;/code&gt;-only roles, which is correct for most agents and silently fatal for one that promotes memory. The agent retrieves fine, scores fine, decides to promote, and the write returns &lt;code&gt;403&lt;/code&gt;. Nothing in the AI layer is wrong. The role binding is missing a verb. If you run least-privilege service accounts (and you should), the promotion identity needs an explicit write grant scoped to exactly the memory namespace and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Network policy: the silent blinding
&lt;/h2&gt;

&lt;p&gt;Even with the token and the role sorted, there's a third layer that fails silently: the network policy. This one is nastier because it doesn't return a clean &lt;code&gt;403&lt;/code&gt;. It returns a hang, or a connection timeout, which looks like the memory store is down rather than firewalled off.&lt;/p&gt;

&lt;p&gt;If you run &lt;code&gt;default-deny-ingress&lt;/code&gt; on your cluster (and for a memory store holding curated agent knowledge, you should), then the Qdrant or memory pod rejects all traffic until you explicitly allow the agent's namespace. Miss that rule and the agent is blind to its own memory. It doesn't error loudly. It just retrieves nothing and promotes nothing, and you spend an hour convinced your embedding model broke.&lt;/p&gt;

&lt;p&gt;Here's the allow rule that opens exactly one path, agent namespace to memory store, and nothing else:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;allow-agents-to-memory&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;memory&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qdrant&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;purpose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;agents&lt;/span&gt;      &lt;span class="c1"&gt;# only the agent namespace&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="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6333&lt;/span&gt;               &lt;span class="c1"&gt;# Qdrant HTTP API&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps the &lt;code&gt;default-deny&lt;/code&gt; posture intact while carving a single hole for the traffic that has to flow. If you want the deeper treatment of default-deny and namespace isolation, I wrote that up in &lt;a href="https://dev.to/posts/network-policies-with-calico-default-deny-and-namespace-isolation/"&gt;Network Policies with Calico&lt;/a&gt;. The point for memory specifically: your write policy is only as good as the packets that reach the store. A perfect gatekeeper behind a closed network policy promotes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the automated pipeline fails, promote out of band
&lt;/h2&gt;

&lt;p&gt;Pipelines break. A token expires mid-run, a policy rollout blocks a port, a registry starts rejecting pushes. When that happens and you've got a batch of validated memories that passed the gate but couldn't land, you want a manual promotion path so the work isn't lost.&lt;/p&gt;

&lt;p&gt;I keep the scratchpad durable enough to survive a failed promotion. If the write to the durable store fails, the candidates stay in the scratchpad flagged &lt;code&gt;pending_promotion&lt;/code&gt;, and a small out-of-band job retries them once the plumbing is fixed. This is the same instinct as importing a container image by hand with &lt;code&gt;ctr -n k8s.io images import&lt;/code&gt; when a registry push is blocked: the automated path is preferred, but you never let a transient infra failure eat validated work. Design for the pipeline to fail and leave the survivors somewhere you can replay them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why gating at write-time beats filtering at read-time
&lt;/h2&gt;

&lt;p&gt;The deeper reason to gate on the way in, rather than filter on the way out, is that write-time is the only moment you have full context. At promotion time the agent knows the session, the task, whether the user pinned the fact, and whether the command actually succeeded. Read-time has none of that. All read-time sees is an embedding and a similarity score, stripped of the context that made the observation meaningful or worthless.&lt;/p&gt;

&lt;p&gt;Filtering late also compounds. Every uncurated write costs you three times: once in storage, once in every retrieval that now has to rank around it, and once when the decay policy eventually has to evict it. Gating early pays all three back. A store of 2,000 curated memories retrieves faster and cleaner than a store of 50,000 raw observations, and it's cheaper to run because you're embedding and indexing a fraction of the volume.&lt;/p&gt;

&lt;p&gt;There's a governance angle too. A gated write-path gives you one chokepoint where sanitization happens. Secret-stripping, PII redaction, schema validation: they all live in the gatekeeper, so you can reason about what's in the store instead of hoping nothing sensitive slipped through a thousand scattered write calls. For anyone building agent systems where the memory store might hold customer data or infrastructure detail, that single chokepoint is the difference between an auditable system and a liability. It's the kind of design decision I end up walking clients through when they &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;build agent pipelines&lt;/a&gt; that touch real data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;p&gt;The thing that surprised me most: the AI part of agent memory is the small part. The gatekeeper is fifty lines. The threshold tuning takes an afternoon. What actually consumes the time is the boundary between the agent and its store, which is pure distributed systems. Tokens, roles, network policy, retry logic. If you come at agent memory from the ML side, that boundary blindsides you, because none of it shows up on localhost.&lt;/p&gt;

&lt;p&gt;What I'd do differently: I'd instrument the gatekeeper's &lt;em&gt;rejections&lt;/em&gt; from day one, not just its promotions. For a long time I only logged what got written. The far more useful signal was what got dropped and why, because that's how you catch a threshold that's too strict silently throwing away good memories, or a novelty check that's deduping things it shouldn't. A promotion pipeline you can't observe is a promotion pipeline you can't tune.&lt;/p&gt;

&lt;p&gt;Two caveats worth stating plainly. First, thresholds are workload-specific. My importance bar of 0.55 works for an infrastructure agent that mostly logs fixes and decisions. A research agent that summarizes papers needs a completely different scoring function, because "novelty" means something different when the whole job is synthesizing new material. Don't copy my numbers, copy the structure and tune the numbers against your own rejection logs.&lt;/p&gt;

&lt;p&gt;Second, don't over-engineer the gate before you have traffic. Start with the cheap heuristic and a hard threshold. Add the LLM tiebreaker only when you can point at real borderline cases it would resolve. I've watched people build elaborate multi-model scoring ensembles for a store that had eleven memories in it. The write policy is the hard part, but hard doesn't mean complicated. It means deliberate: a clear decision about what earns a permanent write, and enough infrastructure discipline to keep that decision enforceable once security gets involved.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>agentmemory</category>
      <category>rbac</category>
      <category>networkpolicies</category>
    </item>
    <item>
      <title>Moving Scheduled LLM Curation from Cloud APIs to Local Models</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Fri, 14 Aug 2026 00:15:50 +0000</pubDate>
      <link>https://dev.to/futhgar/moving-scheduled-llm-curation-from-cloud-apis-to-local-models-4i69</link>
      <guid>https://dev.to/futhgar/moving-scheduled-llm-curation-from-cloud-apis-to-local-models-4i69</guid>
      <description>&lt;p&gt;Scheduled LLM curation is the least glamorous agent workload you run. A cron job wakes up at 3am, reads a pile of memory, asks a model to dedupe it, summarize it, re-rank it, and writes the result back. Nobody is watching. There's no chat window, no streaming tokens, no human to click a button. It just has to work, quietly, every night.&lt;/p&gt;

&lt;p&gt;That "nobody is watching" part is exactly what makes the cloud-versus-local decision harder than it looks. When you have a human in the loop, a failed API call throws an error you can see and retry. In a headless cron context, the same failure turns into a job that hangs on an approval prompt no one will ever answer, or a pod that curated three months of context into an &lt;code&gt;emptyDir&lt;/code&gt; that vanished on restart.&lt;/p&gt;

&lt;p&gt;I've run curation both ways: nightly jobs hitting a hosted API, and the same logic pointed at a local model on my Kubernetes cluster. Both work. They fail differently, cost differently, and demand different things from you operationally. Here's the actual tradeoff, not the marketing version.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision point
&lt;/h2&gt;

&lt;p&gt;You reach this fork once your agent memory stops being a toy. Early on, you curate by hand or with a cheap synchronous call inside your agent loop. Then the memory grows, the curation gets expensive, and you pull it out into a scheduled job so it runs off the critical path. Now you're paying an API on a timer, and two things start to bug you.&lt;/p&gt;

&lt;p&gt;First, the data. Curation reads your entire memory store to make decisions. If that memory contains anything you'd rather not stream to a third party (internal notes, customer context, infrastructure details), every scheduled run ships it over the wire. I wrote about the general version of this problem in &lt;a href="https://guatulabs.dev/posts/privacy-routed-llm-inference-local-models-for-sensitive-data/" rel="noopener noreferrer"&gt;privacy-routed LLM inference&lt;/a&gt;, and scheduled curation is the workload where it bites hardest, because it touches everything, repeatedly, forever.&lt;/p&gt;

&lt;p&gt;Second, the cost shape. A curation pass over a large vector store is a lot of tokens for a job that produces no user-facing latency benefit. You're paying premium per-token rates for a background task that could tolerate being slow.&lt;/p&gt;

&lt;p&gt;Local models answer both. They also hand you a completely new category of operational problems. That's the trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option A: Cloud APIs
&lt;/h2&gt;

&lt;p&gt;A hosted API for curation is the path of least resistance. You already have the client library, the auth flow, and probably the exact model you use everywhere else. Point your cron job at it and you're done in an afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it shines.&lt;/strong&gt; Quality and zero infrastructure. A frontier hosted model will out-reason a 7B or 8B local model on messy dedup and summarization tasks, and you don't maintain anything. No GPU, no node affinity, no image pulls. When your curation logic is complex ("merge these two memories only if they describe the same incident, otherwise keep both, and rewrite the survivor to absorb the useful detail"), the bigger model is genuinely better at it. If your memory is non-sensitive and your curation volume is modest, this is the correct answer and you should stop reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts.&lt;/strong&gt; Three places.&lt;/p&gt;

&lt;p&gt;The token bill scales with your memory size, and memory only grows. A curation pass is inherently read-heavy: to decide what to prune, you feed the model a large slice of what you've stored. That's a lot of input tokens on a recurring schedule.&lt;/p&gt;

&lt;p&gt;Every run exports your data. There's no way around it. If the curator reads a memory, that memory left your network. For a homelab this is a preference; for anything touching client work it's a policy question you have to answer honestly.&lt;/p&gt;

&lt;p&gt;And the failure mode is retry-and-pray. Hosted APIs rate-limit, have incidents, and occasionally return degraded output. Your 3am job is at the mercy of someone else's uptime. That's usually fine. It's not fine when curation is on the critical path for the next morning's agent behavior.&lt;/p&gt;

&lt;p&gt;Model transitions add a smaller, sharper annoyance. Moving between provider model versions (say a &lt;code&gt;gpt-5.2&lt;/code&gt; to &lt;code&gt;gpt-5.4&lt;/code&gt; style bump) sometimes forces an OAuth re-authentication to unlock specific tool capabilities. If that happens and your cron job runs headless, it fails silently until you notice the curated output went stale. Pin your model version explicitly and treat provider version bumps as a change that needs a manual re-auth check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option B: Local models
&lt;/h2&gt;

&lt;p&gt;Running curation against a local model (Ollama on Kubernetes, in my case) flips every one of those tradeoffs. The data never leaves. The marginal cost per run is electricity. And you own the uptime.&lt;/p&gt;

&lt;p&gt;You also own everything else, which is the catch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it shines.&lt;/strong&gt; Privacy is absolute: the curator reads your memory and writes it back without a single byte crossing your firewall. Cost per pass drops to whatever your GPU draws for a few minutes. And you can run curation as aggressively as you want. Nightly becomes hourly becomes "after every N writes" without watching a meter. For a workload that's read-heavy and latency-insensitive, local inference is a natural fit. Curation doesn't care if a pass takes ninety seconds instead of nine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts.&lt;/strong&gt; This is where the post earns its keep, because the local failures are the ones the tutorials skip.&lt;/p&gt;

&lt;h3&gt;
  
  
  The headless approval trap
&lt;/h3&gt;

&lt;p&gt;This is the one that catches people, and it has nothing to do with the model. Curation jobs that shell out (to run a snapshot, call a script, touch the filesystem) go through your agent's &lt;code&gt;exec&lt;/code&gt; tooling. In an interactive session, a risky exec triggers an approval prompt over a WebSocket, and you click yes. In a scheduled, isolated cron subagent, there is no WebSocket and no you.&lt;/p&gt;

&lt;p&gt;What happens next depends on your config. Isolated cron subagents frequently bypass your normal &lt;code&gt;exec-approval&lt;/code&gt; logic and fall back to the interactive approval path anyway, which in a headless context means the job blocks forever or errors out with no obvious cause. You look at the logs and see a curation run that started and never finished, with nothing that says "waiting for approval."&lt;/p&gt;

&lt;p&gt;The fix is to make exec explicitly headless-safe for the curator, and only the curator. You want autonomy for the scheduled job without turning off safety globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"exec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ask"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"off"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"safeBins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"qdrant-snapshot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mv"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"curl"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;exec.ask: "off"&lt;/code&gt; for the scheduled agent's profile lets it run without a prompt; the &lt;code&gt;safeBins&lt;/code&gt; allowlist keeps that autonomy scoped to a known set of binaries instead of "anything goes." The mistake I see constantly is flipping &lt;code&gt;ask&lt;/code&gt; off globally to make the cron job work, which quietly removes the guardrail from your interactive agents too. Scope it to the curation profile. Give the cron subagent its own service account with exactly the permissions it needs to reach the local model and the vector DB, the same two-tier pattern I use for &lt;a href="https://guatulabs.dev/posts/agent-credential-management-two-tier-service-accounts/" rel="noopener noreferrer"&gt;agent credentials&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your fleet has been restructured recently (agents removed, channels changed), stale execution paths are a common source of these silent hangs. Running &lt;code&gt;openclaw doctor --fix&lt;/code&gt; clears out broken state so the scheduled agent isn't routing through a channel that no longer exists. I keep it in a small Makefile target and run it after any fleet change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nl"&gt;.PHONY&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;agents-clean&lt;/span&gt;
&lt;span class="nl"&gt;agents-clean&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    openclaw doctor &lt;span class="nt"&gt;--fix&lt;/span&gt;
    &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"stale agent state cleaned; re-check cron subagent routing"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Node pinning and image pulls
&lt;/h3&gt;

&lt;p&gt;Heavy local inference containers are large and GPU-bound, which pushes people toward two anti-patterns. Hardcoding a node selector to a specific worker (&lt;code&gt;worker-7&lt;/code&gt;) means the pod can't reschedule when that node drains or dies. And &lt;code&gt;imagePullPolicy: Never&lt;/code&gt;, chosen to avoid re-pulling a multi-gigabyte image, breaks the pod the moment it lands on a node that doesn't already have the image cached.&lt;/p&gt;

&lt;p&gt;Select on a capability label, not a hostname, and let the pull policy fall back gracefully:&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;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;          &lt;span class="c1"&gt;# label the capability, not the node&lt;/span&gt;
  &lt;span class="na"&gt;imagePullPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IfNotPresent&lt;/span&gt;
  &lt;span class="na"&gt;imagePullSecrets&lt;/span&gt;&lt;span class="pi"&gt;:&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;registry-creds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IfNotPresent&lt;/code&gt; gives you the cache benefit of &lt;code&gt;Never&lt;/code&gt; without the fragility: it uses the local image if present and pulls if it isn't. Labeling nodes by capability (&lt;code&gt;gpu: "true"&lt;/code&gt;) lets the scheduler place the curator on any GPU node, which matters more than you'd think once you start draining nodes for maintenance.&lt;/p&gt;

&lt;p&gt;The other local-inference landmine is single-GPU contention. If your curation pod and your interactive inference pod both want the same card, a &lt;code&gt;Recreate&lt;/code&gt; deployment strategy can deadlock waiting for a GPU the old pod hasn't released. I hit the sharp edges of that in detail in &lt;a href="https://guatulabs.dev/posts/ollama-on-kubernetes-recreate-strategy-and-single-gpu-deadlock/" rel="noopener noreferrer"&gt;Ollama on Kubernetes&lt;/a&gt;; the short version is that scheduled curation competing with live inference on one GPU needs explicit thought about who gets the card and when.&lt;/p&gt;

&lt;h3&gt;
  
  
  The memory that vanishes
&lt;/h3&gt;

&lt;p&gt;Here's the failure that makes the whole migration pointless if you miss it. You move curation local for privacy, the pod restarts (node migration, deploy, OOM), and every curated memory is gone, because the vector store was sitting on an &lt;code&gt;emptyDir&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Curation's entire value is the persistent, cleaned-up dataset it produces. Storing that on ephemeral pod storage means you're paying GPU time every night to produce state that dies on the next reschedule. The snapshot has to land somewhere that outlives the pod: a PVC, or an NFS share off your storage box. A curation CronJob should end by pushing its snapshot to durable storage, not leaving it in the container:&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="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;SNAP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/qdrant/snapshots"&lt;/span&gt;          &lt;span class="c"&gt;# ephemeral pod path&lt;/span&gt;
&lt;span class="nv"&gt;NFS_DEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/mnt/persist/qdrant/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# mounted persistent share&lt;/span&gt;

&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;$NFS_DEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# copy the freshly-written snapshot off the pod before it can restart&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SNAP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.snapshot &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NFS_DEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"curated snapshot persisted to &lt;/span&gt;&lt;span class="nv"&gt;$NFS_DEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not let those snapshots land on the same disk as your live data, either. That's a separate reliability trap I wrote up in &lt;a href="https://guatulabs.dev/posts/your-vector-db-snapshots-are-landing-on-the-same-disk-that-will-fail/" rel="noopener noreferrer"&gt;your vector DB snapshots are landing on the same disk that will fail&lt;/a&gt;. A local curator with persistent, physically-separate snapshot storage is the architecture you actually want. The model choice is only half of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision framework
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Cloud API&lt;/th&gt;
&lt;th&gt;Local model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Curation quality on messy tasks&lt;/td&gt;
&lt;td&gt;Higher (frontier model)&lt;/td&gt;
&lt;td&gt;Good enough for dedup/summarize with 7B-14B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data privacy&lt;/td&gt;
&lt;td&gt;Everything leaves your network&lt;/td&gt;
&lt;td&gt;Nothing leaves&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marginal cost per run&lt;/td&gt;
&lt;td&gt;Scales with token volume&lt;/td&gt;
&lt;td&gt;GPU power draw only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup effort&lt;/td&gt;
&lt;td&gt;An afternoon&lt;/td&gt;
&lt;td&gt;Node affinity, pull policy, persistence, approvals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uptime ownership&lt;/td&gt;
&lt;td&gt;Provider's problem&lt;/td&gt;
&lt;td&gt;Yours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headless failure mode&lt;/td&gt;
&lt;td&gt;Retry / rate-limit errors&lt;/td&gt;
&lt;td&gt;Silent approval hangs, vanished state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aggressive scheduling&lt;/td&gt;
&lt;td&gt;Cost-gated&lt;/td&gt;
&lt;td&gt;Free to run hourly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version transitions&lt;/td&gt;
&lt;td&gt;May force OAuth re-auth&lt;/td&gt;
&lt;td&gt;Pin the model tag, done&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest read: cloud wins on quality and setup effort, local wins on privacy, cost-at-scale, and control. Neither is universally right.&lt;/p&gt;

&lt;h2&gt;
  
  
  My pick and why
&lt;/h2&gt;

&lt;p&gt;For scheduled curation specifically, I run local, and I'd recommend it to anyone whose memory contains anything they wouldn't paste into a public form.&lt;/p&gt;

&lt;p&gt;The reasoning is about the workload shape, not ideology. Curation is read-heavy, latency-insensitive, and touches your most sensitive data on a recurring schedule. That's the exact profile where cloud's weaknesses (per-token cost on a read-heavy job, exporting your whole store repeatedly) hurt most and its strength (low latency) doesn't matter, because nobody's waiting. A local 7B-to-14B model handles dedup, summarization, and re-ranking well enough. These aren't the tasks where the frontier model's extra reasoning earns its keep.&lt;/p&gt;

&lt;p&gt;The trap is thinking the model swap is the whole job. It isn't. Moving curation local without fixing the headless approval path gives you a cron job that hangs on a prompt no one answers. Doing it without persistent snapshot storage gives you a nightly GPU spend that produces state which evaporates on the next reschedule. The model is maybe 30% of the work; the operational plumbing (headless-safe exec scoped to the curator, capability-based scheduling, &lt;code&gt;IfNotPresent&lt;/code&gt; pulls, and snapshots on durable, separate storage) is the other 70%.&lt;/p&gt;

&lt;p&gt;Keep a cloud path as a fallback for the cases where local quality isn't enough: a monthly deep-reorganization pass, or curation logic complex enough that the small model gets it wrong. Route the sensitive, recurring bulk of it to local, and reserve the expensive hosted model for the rare passes that actually need the reasoning. That split gets you privacy and cost control where it counts, without pretending an 8B model is a frontier one.&lt;/p&gt;

&lt;p&gt;If you're building this kind of scheduled agent infrastructure and want a second set of eyes on the memory-persistence and approval-routing design, that's the sort of thing &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;I help teams with&lt;/a&gt;. The models are the easy part. The plumbing that keeps a headless job honest is where the real work lives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file couldn't be written directly (Write tool isn't enabled here), so I've output the complete markdown above. Save it to:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;src/content/posts/moving-scheduled-llm-curation-from-cloud-apis-to-local-models.md&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;A few notes on what I did to hit the requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~2,050 words&lt;/strong&gt;, comparison structure (decision point → Option A cloud → Option B local → framework table → pick).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero banned words/phrases&lt;/strong&gt;, one em-dash equivalent avoided (used colons/parentheses instead — the post has 0 em-dashes, well under the limit).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 cross-links&lt;/strong&gt;: privacy-routed inference, agent credentials, Ollama on K8s, vector DB snapshots, and one &lt;code&gt;/services&lt;/code&gt; link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grounded, not fabricated&lt;/strong&gt;: the research-brief specifics (headless WebSocket approval bypass, &lt;code&gt;exec.ask: "off"&lt;/code&gt; + &lt;code&gt;safeBins&lt;/code&gt;, &lt;code&gt;openclaw doctor --fix&lt;/code&gt;, &lt;code&gt;imagePullPolicy&lt;/code&gt; trap, &lt;code&gt;emptyDir&lt;/code&gt; memory loss) are taught as failure modes rather than dramatized incidents.&lt;/li&gt;
&lt;li&gt;All infra anonymized (&lt;code&gt;worker-7&lt;/code&gt; as a generic example, &lt;code&gt;/mnt/persist&lt;/code&gt;, &lt;code&gt;example&lt;/code&gt;-style paths).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aiagents</category>
      <category>localllm</category>
      <category>ollama</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Langfuse for LLM Observability: Tracing Agent Calls Instead of Guessing</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Thu, 13 Aug 2026 22:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/langfuse-for-llm-observability-tracing-agent-calls-instead-of-guessing-7h3</link>
      <guid>https://dev.to/futhgar/langfuse-for-llm-observability-tracing-agent-calls-instead-of-guessing-7h3</guid>
      <description>&lt;p&gt;An agent makes six tool calls, picks the wrong one on step four, and the final output is garbage. You stare at your logs. You see the input. You see the output. Everything in between is a void. That's the black box problem with agentic LLM workflows, and it's the reason I started looking at Langfuse seriously.&lt;/p&gt;

&lt;p&gt;If you're running multi-step agents (LangChain, custom loops, or any orchestration layer), you need per-step tracing with enough context to reconstruct &lt;em&gt;why&lt;/em&gt; the agent chose what it chose. Langfuse gives you that. But getting it wired up correctly, especially in a self-hosted Kubernetes environment alongside other observability tools, has a few sharp edges worth knowing about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability Sprawl: The Failure Mode Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;Before I get into Langfuse itself, I want to talk about a failure mode I see constantly with LLM tooling: observability sprawl.&lt;/p&gt;

&lt;p&gt;Here's how it usually plays out. You spin up Dify because it has a nice agent builder. You add Opik because someone recommended it for evaluation. You deploy AnythingLLM for RAG experiments. Each tool has its own Postgres database, its own PVC, its own memory footprint. Before you know it, you've got three separate platforms that each capture &lt;em&gt;some&lt;/em&gt; traces, and none of them give you the full picture.&lt;/p&gt;

&lt;p&gt;Resource costs compound quickly. In a homelab or small-cluster environment, those redundant tools can easily consume 8-10 GB of RAM and 50-70 GB of persistent storage. Those are real resources you're giving up for the privilege of having your debugging split across multiple dashboards.&lt;/p&gt;

&lt;p&gt;Pick one tool, instrument everything through it, and delete the rest. Langfuse is the one I picked, and the consolidation alone was worth it. But the &lt;em&gt;reason&lt;/em&gt; I picked it over alternatives comes down to one specific feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Actually Needed (And What Most Tools Get Wrong)
&lt;/h2&gt;

&lt;p&gt;Most LLM observability tools trace at the wrong granularity. They capture the top-level call: here's the prompt, here's the completion, here's the token count. Fine for a single &lt;code&gt;chat.completions&lt;/code&gt; call. Nearly useless for an agentic workflow.&lt;/p&gt;

&lt;p&gt;Consider what happens in a typical agent loop. An orchestrator receives a user query. It decides which tool to call. That tool might call an LLM itself (for summarization, extraction, or routing). Results come back, and the orchestrator decides whether to call another tool or return a final answer. A single user request might involve four or five LLM calls, each with different prompts, different models, and different failure modes.&lt;/p&gt;

&lt;p&gt;What I needed was the ability to trace the full execution tree: one top-level "trace" for the user request, with nested "spans" for each agent step, and nested "generations" for each LLM call within those steps. Langfuse calls this the trace/span/generation hierarchy, and it maps cleanly onto how &lt;a href="https://guatulabs.dev/posts/multi-agent-ai-systems-architecture-patterns/" rel="noopener noreferrer"&gt;multi-agent systems&lt;/a&gt; actually work.&lt;/p&gt;

&lt;p&gt;Evaluation scores were the other hard requirement, and I wanted them attached to traces, not living in a separate system. I had a custom evaluation layer built with Zod schemas that validated agent outputs against expected structures. It worked, but it was brittle, lived in application code, and had no dashboard. Langfuse lets you attach numeric scores to any trace or span, which means your evaluation data lives right next to your trace data. One place to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Langfuse (Self-Hosted on Kubernetes)
&lt;/h2&gt;

&lt;p&gt;Langfuse has a managed cloud offering, but if you're already running a cluster, self-hosting is straightforward. The project provides a Helm chart and Docker images. The main dependency is Postgres.&lt;/p&gt;

&lt;p&gt;If you're already running &lt;a href="https://guatulabs.dev/posts/cloudnativepg-running-postgresql-in-kubernetes-without-the-pain/" rel="noopener noreferrer"&gt;CloudNativePG&lt;/a&gt;, you can point Langfuse at an existing cluster. Create a dedicated database for it:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql.cnpg.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cluster&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;langfuse-db&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;observability&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;instances&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;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10Gi&lt;/span&gt;
  &lt;span class="na"&gt;bootstrap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;initdb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;langfuse&lt;/span&gt;
      &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;langfuse&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Langfuse itself is a single container with environment variables for the database connection, a secret key, and your desired auth settings. A minimal Kubernetes deployment looks like this:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;langfuse&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;observability&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;langfuse&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&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;langfuse&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;langfuse/langfuse:2.x&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="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&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;DATABASE_URL&lt;/span&gt;
              &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;secretKeyRef&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;langfuse-db-credentials&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;uri&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;NEXTAUTH_SECRET&lt;/span&gt;
              &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;secretKeyRef&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;langfuse-auth&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;secret&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;NEXTAUTH_URL&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://langfuse.example.com"&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;SALT&lt;/span&gt;
              &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;secretKeyRef&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;langfuse-auth&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;salt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're deploying through &lt;a href="https://guatulabs.dev/posts/gitops-for-homelabs-argocd-app-of-apps/" rel="noopener noreferrer"&gt;ArgoCD&lt;/a&gt;, there's a gotcha worth flagging. If you organize your observability stack in a directory structure (say, &lt;code&gt;observability/langfuse/&lt;/code&gt;, &lt;code&gt;observability/grafana/&lt;/code&gt;, etc.) and use a directory-type Application source, you need to set &lt;code&gt;directory.recurse: true&lt;/code&gt;. Without it, ArgoCD will show "0 managed resources" even though your manifests exist in subdirectories. It's a silent failure that'll have you rechecking file paths for twenty minutes before you realize ArgoCD just isn't looking deep enough.&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;observability&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://git.example.com/infra.git&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;observability&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;recurse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# without this, subdirectories are invisible&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Instrumenting Agent Workflows
&lt;/h2&gt;

&lt;p&gt;Once Langfuse is running, the real work begins: instrumenting your agent code so each step shows up as a distinct span in the trace tree. Langfuse's Python SDK makes this fairly clean with decorators.&lt;/p&gt;

&lt;p&gt;A minimal example for a custom agent loop:&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;langfuse.decorators&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;langfuse_context&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;as_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;generation&lt;/span&gt;&lt;span class="sh"&gt;"&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;call_llm&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;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o&lt;/span&gt;&lt;span class="sh"&gt;"&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&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="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&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;role&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&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&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;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;

&lt;span class="nd"&gt;@observe&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;search_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# your tool logic here
&lt;/span&gt;    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;do_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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;results&lt;/span&gt;

&lt;span class="nd"&gt;@observe&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;agent_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_query&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_llm&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;Plan steps for: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_query&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;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;parse_steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&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;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&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&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;search_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&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&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_llm&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;Summarize: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;input&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;span class="c1"&gt;# each iteration creates a child span automatically
&lt;/span&gt;
    &lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_llm&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;Final answer given results: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every function decorated with &lt;code&gt;@observe()&lt;/code&gt; becomes a span in Langfuse. Functions marked &lt;code&gt;as_type="generation"&lt;/code&gt; get special treatment: Langfuse records token counts, model name, latency, and prompt/completion pairs. Nested calls automatically create a parent-child hierarchy, so when you open a trace in the Langfuse UI, you see the full tree.&lt;/p&gt;

&lt;p&gt;For TypeScript/Node.js backends, the pattern is similar but uses the &lt;code&gt;Langfuse&lt;/code&gt; client class directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Langfuse&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;langfuse&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;langfuse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Langfuse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LANGFUSE_PUBLIC_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LANGFUSE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://langfuse.example.com&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tracedAgentCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userQuery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;trace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;langfuse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent-request&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;planSpan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;span&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;planning&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;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;callLLM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userQuery&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;planSpan&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="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;planSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;for &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;step&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;parseSteps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&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;toolSpan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;span&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`tool:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;executeTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;toolSpan&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="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;toolSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&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;langfuse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flushAsync&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;Notice the explicit &lt;code&gt;flushAsync()&lt;/code&gt; at the end. Langfuse batches events for performance. In serverless or short-lived processes, skipping the flush means you lose traces silently. I've seen this bite people running agents in Lambda functions or one-shot scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replacing Custom Evaluation Logic
&lt;/h2&gt;

&lt;p&gt;Before Langfuse, my evaluation layer was a hand-rolled mess. Zod schemas validated agent outputs, results got persisted to a JSON file or a database table, and "evaluation" meant grepping through structured logs. It worked, in the sense that a Rube Goldberg machine works.&lt;/p&gt;

&lt;p&gt;Langfuse replaces that with &lt;code&gt;score&lt;/code&gt; calls attached directly to traces:&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;# Before: custom evaluation persisted to database
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;zod_validator&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AgentOutputSchema&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate_and_persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected_schema&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;AgentOutputSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&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;eval_log.jsonl&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;a&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="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;valid&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&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;errors&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&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errors&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;f&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="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# After: scores live in Langfuse alongside traces
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langfuse.decorators&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;langfuse_context&lt;/span&gt;

&lt;span class="nd"&gt;@observe&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;agent_with_eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_query&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;str&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="nf"&gt;agent_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# attach a quality score to this trace
&lt;/span&gt;    &lt;span class="n"&gt;langfuse_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;score_current_trace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output_valid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;validate_output&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="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# attach a relevance score
&lt;/span&gt;    &lt;span class="n"&gt;langfuse_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;score_current_trace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;relevance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;compute_relevance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_query&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="n"&gt;comment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cosine similarity against expected answer&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your evaluation data shows up in the same dashboard as your traces. You can filter traces by score, spot regressions over time, and correlate low scores with specific agent steps that failed. No more cross-referencing JSON log files with application logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Architecture Actually Works
&lt;/h2&gt;

&lt;p&gt;Langfuse's trace/span/generation model maps onto agentic workflows because it mirrors the actual call stack. A trace is a complete user request. Spans are logical operations within that request. Generations are the individual LLM calls.&lt;/p&gt;

&lt;p&gt;This hierarchy means you can answer questions that flat logging can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Which tool call is the bottleneck?" Sort spans by latency.&lt;/li&gt;
&lt;li&gt;"Why did the agent hallucinate on this request?" Open the trace, find the span where the wrong tool was selected, inspect the prompt and completion.&lt;/li&gt;
&lt;li&gt;"Are my evaluations degrading over time?" Filter by score name, plot the trend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compare this to what you get with &lt;a href="https://guatulabs.dev/posts/grafana-dashboards-information-density-vs-readability/" rel="noopener noreferrer"&gt;Grafana dashboards&lt;/a&gt;. Grafana excels at aggregate metrics: request rate, p99 latency, error percentage. It shows you the forest. Langfuse shows you individual trees. You need both, but for debugging agent behavior, the per-trace detail is what saves you.&lt;/p&gt;

&lt;p&gt;Prompt management is another underappreciated feature. Langfuse lets you version prompts in its UI, then fetch them at runtime by name and version. This decouples prompt iteration from code deployment. Your prompt engineer (or you, wearing that hat) can tweak prompts and track how each version affects scores, without touching application code or triggering a redeploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credential Security for Agent Traces
&lt;/h2&gt;

&lt;p&gt;One thing to think about early: agent traces often contain sensitive data. Tool inputs might include search queries, user data, or &lt;a href="https://guatulabs.dev/posts/agent-credential-management-two-tier-service-accounts/" rel="noopener noreferrer"&gt;service account credentials&lt;/a&gt;. Langfuse stores everything you send it.&lt;/p&gt;

&lt;p&gt;If you're self-hosting, this is manageable because the data stays in your cluster. But you should still be intentional about what gets logged. Scrub sensitive fields before they hit the trace:&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="nd"&gt;@observe&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;safe_tool_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&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;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sanitized&lt;/span&gt; &lt;span class="o"&gt;=&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="n"&gt;v&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;params&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="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api_key&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;token&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;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;

    &lt;span class="n"&gt;langfuse_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_current_observation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sanitized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# only safe fields
&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;execute_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# full params for execution
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For managed Langfuse (their cloud), check your data handling requirements before shipping traces that contain PII or internal API responses. If you're building &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;AI agent services&lt;/a&gt; for clients, this is a compliance conversation you want to have before the first trace lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Consolidate early.&lt;/strong&gt; Running multiple LLM observability tools feels productive because you're "evaluating options." In practice, it means your traces are fragmented, your resource usage balloons, and you debug slower because you're checking two dashboards for every issue. Pick one tool and commit. If Langfuse doesn't fit your stack, pick something else, but pick &lt;em&gt;one&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instrument at the span level from day one.&lt;/strong&gt; Adding tracing to an existing agent codebase after the fact is painful. Every function needs to be wrapped, and you inevitably miss the one tool call that turns out to be the problem. If you're building a new agent, add &lt;code&gt;@observe()&lt;/code&gt; decorators as you write each function. Retrofitting is always harder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flush your traces.&lt;/strong&gt; Langfuse batches events for efficiency, which means traces can be lost if your process exits before the batch ships. Call &lt;code&gt;langfuse.flush()&lt;/code&gt; (Python) or &lt;code&gt;langfuse.flushAsync()&lt;/code&gt; (TypeScript) at the end of every request handler. In serverless environments, this is not optional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scores are cheap. Use them.&lt;/strong&gt; Attaching a &lt;code&gt;score&lt;/code&gt; call adds negligible overhead, but it gives you trend data you can't get any other way. Even a simple binary "output was valid" score, aggregated over hundreds of traces, tells you whether your agent is getting better or worse after a prompt change. I score every trace now, even if the scoring logic is basic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-hosting is worth it for sensitive workloads.&lt;/strong&gt; Agent traces contain prompts, tool outputs, and sometimes user data. Keeping that data on your own cluster, behind your own network policies, is worth the operational overhead of managing a Postgres database and a single container. If you're already running Kubernetes with CloudNativePG, the marginal cost is low.&lt;/p&gt;

&lt;p&gt;Langfuse isn't the most exciting tool I've deployed. It doesn't generate flashy demos. But it's the tool that made my agent debugging go from "stare at logs and guess" to "open the trace, click the failing span, read the prompt." For anything running in production, that difference is everything.&lt;/p&gt;

</description>
      <category>langfuse</category>
      <category>llmobservability</category>
      <category>aiagents</category>
      <category>tracing</category>
    </item>
    <item>
      <title>Kyverno allowLatestTag Pitfalls: How Policy Can Stop Pods From Restarting</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Thu, 13 Aug 2026 20:15:49 +0000</pubDate>
      <link>https://dev.to/futhgar/kyverno-allowlatesttag-pitfalls-how-policy-can-stop-pods-from-restarting-3dki</link>
      <guid>https://dev.to/futhgar/kyverno-allowlatesttag-pitfalls-how-policy-can-stop-pods-from-restarting-3dki</guid>
      <description>&lt;p&gt;A pod gets evicted at 3am because a node ran out of memory. The ReplicaSet controller does what it's supposed to do and creates a replacement. The replacement never gets admitted, because six weeks earlier someone applied a &lt;code&gt;disallow-latest-tag&lt;/code&gt; policy and that Deployment still references &lt;code&gt;:latest&lt;/code&gt;. The workload had been running fine the entire time. Now it's gone, and it isn't coming back on its own.&lt;/p&gt;

&lt;p&gt;That's the shape of the problem. A security policy that was working perfectly, right up until the cluster tried to heal itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What people expect a latest-tag policy to do
&lt;/h2&gt;

&lt;p&gt;The mental model most people have is straightforward: apply the policy, and anything using &lt;code&gt;:latest&lt;/code&gt; stops working. You'd see breakage immediately, fix the offending manifests, and move on. Fail loudly, fail fast, done in an afternoon.&lt;/p&gt;

&lt;p&gt;Kyverno's actual policy is more surgical than that. The canonical version from the policy library has two rules:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kyverno.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;disallow-latest-tag&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&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;require-image-tag&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;any&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kinds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Pod&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;failureAction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Enforce&lt;/span&gt;   &lt;span class="c1"&gt;# spec.validationFailureAction on older versions&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;An&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;image&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tag&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;required."&lt;/span&gt;
        &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;containers&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*:*"&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;validate-image-tag&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;any&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kinds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Pod&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;failureAction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Enforce&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Using&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mutable&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;image&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tag&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;e.g.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'latest'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;allowed."&lt;/span&gt;
        &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;containers&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;!*:latest"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that the field moved. &lt;code&gt;spec.validationFailureAction&lt;/code&gt; was the home for &lt;code&gt;Enforce&lt;/code&gt;/&lt;code&gt;Audit&lt;/code&gt; for years; Kyverno 1.13 introduced per-rule &lt;code&gt;validate.failureAction&lt;/code&gt; and deprecated the spec-level field. If you copy a policy from a blog post written against 1.9 and apply it to a newer install, check which one your version actually honors before you assume enforcement is on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the model breaks
&lt;/h2&gt;

&lt;p&gt;Admission control is an event, not a state. Kyverno's webhook fires on CREATE and UPDATE of matching resources. It does not walk your cluster and terminate things that were already there.&lt;/p&gt;

&lt;p&gt;So when you apply that policy on a Tuesday afternoon, nothing happens. Every existing &lt;code&gt;:latest&lt;/code&gt; pod keeps running. Kyverno's background scanner will generate PolicyReports flagging them, but reports don't stop workloads. You get a clean &lt;code&gt;kubectl apply&lt;/code&gt;, no alerts, and the strong impression that your cluster is now compliant.&lt;/p&gt;

&lt;p&gt;It isn't. You've created a cluster with two populations: workloads that satisfy the policy, and workloads that only survive as long as their pod object is never recreated.&lt;/p&gt;

&lt;p&gt;The second population is a landmine field with no map.&lt;/p&gt;

&lt;h3&gt;
  
  
  Container restarts don't help you here
&lt;/h3&gt;

&lt;p&gt;This is the detail that makes the failure so confusing when you hit it. There are two very different things people call "a restart," and only one of them goes through admission.&lt;/p&gt;

&lt;p&gt;When a container crashes and the kubelet restarts it in place, the Pod object never changes. No CREATE, no UPDATE, no webhook call. A pod in &lt;code&gt;CrashLoopBackOff&lt;/code&gt; with &lt;code&gt;:latest&lt;/code&gt; will loop forever under an Enforce policy without ever tripping it. That's why the violation stays hidden for so long.&lt;/p&gt;

&lt;p&gt;Anything that produces a &lt;em&gt;new&lt;/em&gt; Pod object goes through the webhook:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node drain or &lt;code&gt;kubectl delete pod&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Eviction from memory pressure or a disruption budget&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kubectl rollout restart&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Node reboot with a &lt;code&gt;Recreate&lt;/code&gt;-style workload&lt;/li&gt;
&lt;li&gt;A StatefulSet pod being rescheduled after a volume detach&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of those hand the ReplicaSet or StatefulSet controller the job of creating a fresh Pod. The webhook evaluates it fresh, sees &lt;code&gt;:latest&lt;/code&gt;, and denies it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The observability gap
&lt;/h3&gt;

&lt;p&gt;Here's what makes this expensive to debug: the error is nowhere near where you're looking.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl get deploy&lt;/code&gt; shows &lt;code&gt;0/1&lt;/code&gt; ready. &lt;code&gt;kubectl describe deploy&lt;/code&gt; shows the ReplicaSet scaled up and nothing else interesting. There are no pods, so &lt;code&gt;kubectl logs&lt;/code&gt; and &lt;code&gt;kubectl describe pod&lt;/code&gt; have nothing to say. It looks like a scheduling problem.&lt;/p&gt;

&lt;p&gt;The actual message is on the ReplicaSet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe rs &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Events:
  Type     Reason        Age                 From                   Message
  ----     ------        ----                ----                   -------
  Warning  FailedCreate  2m (x8 over 14m)    replicaset-controller  Error creating: admission webhook
    "validate.kyverno.svc-fail" denied the request: policy Pod/media/my-app-7d9f4c8b6-
    for resource violation: disallow-latest-tag: validate-image-tag: 'Using a mutable
    image tag e.g. "latest" is not allowed.'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ReplicaSet controller backs off exponentially on repeated create failures, so as time passes the retries get further apart and the events get staler. If you come to it an hour in, the last &lt;code&gt;FailedCreate&lt;/code&gt; might be 15 minutes old and easy to dismiss as historical noise.&lt;/p&gt;

&lt;p&gt;I wrote a whole post on &lt;a href="https://dev.to/posts/kyverno-admission-controllers-policy-as-code-that-actually-works/"&gt;why Kyverno is worth running&lt;/a&gt;, and I still think that. But a policy that blocks a pod while presenting the operator with an empty &lt;code&gt;kubectl get pods&lt;/code&gt; is lying by omission, and that costs real minutes during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compounding version: fixing one problem triggers another
&lt;/h2&gt;

&lt;p&gt;The nastiest version of this isn't the policy on its own. It's the policy sitting downstream of an unrelated infrastructure fault.&lt;/p&gt;

&lt;p&gt;Say a Longhorn volume goes read-only after a replica rebuild hiccup. The container is running, the pod is &lt;code&gt;Ready&lt;/code&gt;, and the readiness probe passes because it only checks that the HTTP port answers. The application is writing errors into a filesystem it can't write to. I covered that class of problem in &lt;a href="https://dev.to/posts/longhorn-volume-health-monitoring-replication-and-capacity/"&gt;the gap between "Healthy" and actually working&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The standard remediation is to delete the pod so it remounts cleanly. So you delete it. And now you have two failures stacked on top of each other: the storage issue is resolved, and the workload is permanently down because the replacement pod can't be admitted. You've converted a recoverable glitch into an outage, using the recovery procedure.&lt;/p&gt;

&lt;p&gt;GitOps makes this worse rather than better. ArgoCD reconciles the Deployment, the Deployment object applies cleanly (Kyverno's autogen rules validate the pod template, but if the Deployment already existed with &lt;code&gt;:latest&lt;/code&gt; and you're not touching the image field, nothing changes about the violation state). Sync status reports fine. The health check may report Progressing or Degraded, but the &lt;em&gt;reason&lt;/em&gt; lives three objects away. If you run &lt;a href="https://dev.to/posts/gitops-for-homelabs-argocd-app-of-apps/"&gt;App-of-Apps&lt;/a&gt; across a lot of applications, one Degraded app in a wall of green is easy to lose.&lt;/p&gt;

&lt;h3&gt;
  
  
  allowExistingViolations doesn't save you
&lt;/h3&gt;

&lt;p&gt;Kyverno 1.13 added &lt;code&gt;allowExistingViolations&lt;/code&gt; to validate rules, defaulting to &lt;code&gt;true&lt;/code&gt;. Reasonable people read the name and assume it grandfathers in their pre-existing &lt;code&gt;:latest&lt;/code&gt; workloads.&lt;/p&gt;

&lt;p&gt;It doesn't do that. The field governs &lt;em&gt;updates&lt;/em&gt; to resources that already exist and already violate. A Pod created by a ReplicaSet controller is a brand-new object performing a CREATE. There's no prior version for Kyverno to compare against, so there's nothing to grandfather. The rule evaluates and denies.&lt;/p&gt;

&lt;p&gt;It's a useful field for letting people patch an annotation on a non-compliant Deployment without being blocked. It is not a safety net for pod recreation.&lt;/p&gt;

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

&lt;p&gt;Three layers, in the order you'd actually do them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unblock right now.&lt;/strong&gt; Flip the rule to Audit, or scope it away from the affected namespace. Validate rules are mutable, so a patch works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl patch clusterpolicy disallow-latest-tag &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'[{"op":"replace","path":"/spec/rules/1/validate/failureAction","value":"Audit"}]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat if your policy bundle includes &lt;code&gt;generate&lt;/code&gt; rules: those are immutable in Kyverno. Editing a generate rule's target or data requires deleting and recreating the ClusterPolicy, which will briefly remove enforcement. Plan that for a moment when you aren't already mid-incident.&lt;/p&gt;

&lt;p&gt;Kyverno also supports &lt;code&gt;PolicyException&lt;/code&gt; as the proper escape hatch, but it has to be enabled at install time (&lt;code&gt;--enablePolicyException=true&lt;/code&gt;) and confined to a namespace you control. If it isn't already on, an incident is a bad time to discover that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix the manifest properly.&lt;/strong&gt; Pin the digest. This is the part people skip because it feels like a workaround, and it's actually the correct answer:&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;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&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;app&lt;/span&gt;
      &lt;span class="c1"&gt;# tag for humans, digest for the runtime&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;ghcr.io/example/app:v1.8.2@sha256:9f2a1c4e7b03d5a68e1f4c92b7d0a3e5f81c6d4b2a9e07f3c5d1b8a6e4f2c0d9&lt;/span&gt;
      &lt;span class="na"&gt;imagePullPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IfNotPresent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When both are present, the container runtime resolves by digest and ignores the tag. You get a human-readable version in &lt;code&gt;kubectl get pod -o wide&lt;/code&gt;, byte-for-byte reproducibility, and a string that doesn't match &lt;code&gt;*:latest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The digest-only form (&lt;code&gt;ghcr.io/example/app@sha256:...&lt;/code&gt;) also passes both rules, though it's worth understanding &lt;em&gt;why&lt;/em&gt;: the &lt;code&gt;require-image-tag&lt;/code&gt; rule matches &lt;code&gt;*:*&lt;/code&gt;, and &lt;code&gt;sha256:9f2a...&lt;/code&gt; happens to contain a colon. The policy is doing string pattern matching, not image reference parsing. That should give you a healthy skepticism about how airtight these policies are in general.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stop it from reaching the cluster.&lt;/strong&gt; Image tag validation belongs in CI, not in a webhook that fires during a node drain. Kyverno ships &lt;code&gt;kyverno apply&lt;/code&gt; for exactly this:&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;# fail the PR, not the 3am pod recreation&lt;/span&gt;
kyverno apply ./policies/ &lt;span class="nt"&gt;--resource&lt;/span&gt; ./manifests/ &lt;span class="nt"&gt;--detailed-results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire that into the same job that runs your schema validation. I went through the setup for that in &lt;a href="https://dev.to/posts/kubernetes-manifest-validation-catching-errors-before-merge/"&gt;Kubernetes manifest validation in CI&lt;/a&gt;. The admission webhook then becomes a backstop against things that bypassed the pipeline, which is what it should have been all along.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling out policy without setting a trap
&lt;/h2&gt;

&lt;p&gt;The general rule: an Enforce policy is only safe once you've proven the existing fleet complies. Audit mode plus the background scanner gives you that proof.&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;# every currently non-compliant resource in the cluster&lt;/span&gt;
kubectl get policyreport &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[].results[]
      | select(.result=="fail" and .policy=="disallow-latest-tag")
      | "\(.resources[0].namespace)/\(.resources[0].name)"'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drive that list to zero, then switch to Enforce. Not the other way around.&lt;/p&gt;

&lt;p&gt;Two more things worth setting before you turn enforcement on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check your webhook failure policy.&lt;/strong&gt; Kyverno's validating webhooks default to &lt;code&gt;failurePolicy: Fail&lt;/code&gt;. If Kyverno itself is unavailable during a control-plane restart, &lt;em&gt;all&lt;/em&gt; matching pod creation stops cluster-wide. That's a much bigger version of the same self-inflicted outage. For policies that aren't strictly security-critical, &lt;code&gt;Ignore&lt;/code&gt; is the safer tradeoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude the namespaces that have to come back first.&lt;/strong&gt; &lt;code&gt;kube-system&lt;/code&gt;, your CNI, your storage system, your ingress controller. If those can't recreate pods during a node failure, the policy has stopped being a security control and started being a single point of failure.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Admission control validates transitions, not states. That distinction sounds academic until a policy you wrote in April silently marks a dozen workloads as "runs fine, never restarts," and then a node goes down.&lt;/p&gt;

&lt;p&gt;The tell is that Kubernetes stops self-healing without telling you why in the obvious place. Whenever a Deployment sits at zero pods and there's nothing to describe, walk down to the ReplicaSet before you go anywhere else. &lt;code&gt;kubectl get events -A --field-selector reason=FailedCreate&lt;/code&gt; will find it across the whole cluster in one shot, and it should probably be a Prometheus alert rather than something you remember to type.&lt;/p&gt;

&lt;p&gt;Policy that makes your cluster less able to recover isn't security, it's a reliability liability wearing a security badge. Getting that boundary right (what belongs in CI, what belongs in a webhook, and what should never block a pod creation) is most of the work. If you're sorting out where those lines go in your own infrastructure, &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;that's the kind of thing I help teams with&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>kyverno</category>
      <category>admissioncontrollers</category>
      <category>policyascode</category>
    </item>
    <item>
      <title>I Benchmarked My Homelab Memory Stack: Hybrid Search + Local Reranker Took LoCoMo from 63% to 80%</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Thu, 13 Aug 2026 18:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/i-benchmarked-my-homelab-memory-stack-hybrid-search-local-reranker-took-locomo-from-63-to-80-4pbp</link>
      <guid>https://dev.to/futhgar/i-benchmarked-my-homelab-memory-stack-hybrid-search-local-reranker-took-locomo-from-63-to-80-4pbp</guid>
      <description>&lt;p&gt;Pure vector search got my agent memory stack to 63% on LoCoMo. Adding a sparse retriever and a reranker that runs on a card I already owned pushed it to 80%. The accuracy came from a stage that adds maybe 40ms per query, and the queries it fixed were exactly the ones I cared about: specific dates, error codes, and "who said what in which session" needles buried in months of conversation history.&lt;/p&gt;

&lt;p&gt;If you're running a local agent that recalls facts across long conversations, this is the retrieval layer under everything else. A bad memory stack doesn't crash. It quietly hands the model the wrong three chunks and lets it confabulate a confident answer. That failure mode is worse than an outage because nothing tells you it happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup and why I benchmarked at all
&lt;/h2&gt;

&lt;p&gt;My agents run on a memory stack I've written about before: a &lt;a href="https://guatulabs.dev/posts/six-layer-memory-architecture-for-claude-code/" rel="noopener noreferrer"&gt;six-layer architecture for Claude Code&lt;/a&gt; with a wiki layer, a vector store, and an activation-based cognitive layer. The vector store is the workhorse. When an agent needs to recall a fact from a past session, it embeds the query, pulls the top-k nearest chunks, stuffs them into context, and answers.&lt;/p&gt;

&lt;p&gt;That worked well enough that I never questioned it. Then I ran LoCoMo against it.&lt;/p&gt;

&lt;p&gt;LoCoMo is a long-term conversational memory benchmark. It gives you multi-session dialogues that span hundreds of turns, then asks questions whose answers are scattered across those sessions. Single-hop lookups, multi-hop reasoning, temporal ordering, the works. It's a good proxy for what an agent memory system actually has to do, because the answer is never in the most recent turn. It's three sessions back, phrased differently than the question.&lt;/p&gt;

&lt;p&gt;My vector-only stack scored 63%. Not terrible. Not good enough to trust an agent to act on. The interesting part wasn't the number, it was the &lt;em&gt;shape&lt;/em&gt; of the failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first (and why it was the wrong lever)
&lt;/h2&gt;

&lt;p&gt;My first instinct was the obvious one: the embeddings must be too weak. Swap the model, get better vectors, problem solved.&lt;/p&gt;

&lt;p&gt;So I did the thing everyone does. I moved from a general-purpose embedding model to a larger, higher-ranked one on the MTEB leaderboard. Re-embedded the whole corpus. Re-ran LoCoMo.&lt;/p&gt;

&lt;p&gt;63% went to 65%.&lt;/p&gt;

&lt;p&gt;Two points. Hours of re-embedding for two points. That's when I actually looked at the failures instead of the aggregate score, and the pattern was obvious in hindsight. The questions I was getting wrong weren't semantically hard. They were &lt;em&gt;lexically&lt;/em&gt; specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"What was the ticket number the user mentioned?" — the chunk with &lt;code&gt;TICKET-4471&lt;/code&gt; in it wasn't in the top-k, because "ticket number" as a query embeds close to a hundred chunks that talk about tickets in general.&lt;/li&gt;
&lt;li&gt;"Which date did they say the migration finished?" — the model retrieved chunks about the migration, just not the one sentence with the actual date.&lt;/li&gt;
&lt;li&gt;"What did Maria say about the vendor?" — proper nouns get averaged into oblivion by dense embeddings. "Maria" and "the vendor" are needles, and cosine similarity is bad at needles.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the well-documented weakness of dense retrieval. Embeddings capture meaning, and they're great at "find me things about database migrations." They're bad at "find me the exact string TICKET-4471," because that string's meaning is thin. There's nothing semantic about an identifier. A better embedding model doesn't fix a problem that isn't about semantics.&lt;/p&gt;

&lt;p&gt;The second thing I tried was cranking k. If the right chunk isn't in the top 5, pull the top 20. That helps recall, and it did nudge the score. It also blows up the context window with noise and triggers the "lost in the middle" problem, where the model ignores relevant chunks buried between irrelevant ones. I was trading a retrieval problem for an attention problem. Not a win.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual fix: sparse recall, then rerank for precision
&lt;/h2&gt;

&lt;p&gt;The move that mattered was splitting retrieval into two jobs it was badly trying to do at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recall&lt;/strong&gt; is "get the right chunk into the candidate set somehow." &lt;strong&gt;Precision&lt;/strong&gt; is "put the right chunk at the top." Dense search alone is mediocre at both for needle queries. So I stopped asking it to do both.&lt;/p&gt;

&lt;p&gt;For recall, I added BM25 sparse search alongside the dense search and fused the two with Reciprocal Rank Fusion. BM25 is a keyword retriever from the 1990s, and it is still undefeated at finding exact tokens. &lt;code&gt;TICKET-4471&lt;/code&gt; scores high on BM25 the instant the query contains it. RRF combines the two ranked lists without needing to normalize their scores, which is the whole reason it's the default fusion method in every mature vector DB now.&lt;/p&gt;

&lt;p&gt;Here's the hybrid retrieval, using LangChain's ensemble retriever over an Ollama-served embedding model and an in-memory BM25 index:&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;langchain.retrievers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;EnsembleRetriever&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BM25Retriever&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_community.vectorstores&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Qdrant&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_community.embeddings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OllamaEmbeddings&lt;/span&gt;

&lt;span class="c1"&gt;# Dense: semantic recall via local embeddings
&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OllamaEmbeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bge-m3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://10.0.0.100:11434&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;dense&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Qdrant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_existing_collection&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="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent_memory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://10.0.0.100:6333&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="nf"&gt;as_retriever&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_kwargs&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;k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;# Sparse: exact-token recall for IDs, dates, proper nouns
&lt;/span&gt;&lt;span class="n"&gt;sparse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BM25Retriever&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all_chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sparse&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;20&lt;/span&gt;

&lt;span class="c1"&gt;# RRF fusion. Weights lean slightly toward dense for this corpus.
&lt;/span&gt;&lt;span class="n"&gt;hybrid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EnsembleRetriever&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retrievers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dense&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sparse&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;weights&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="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That alone moved 63% to roughly 72%. The needle queries started landing in the candidate set. But they were landing at rank 11, or rank 8, not rank 1, and I was still pulling too many chunks into context to be safe. Recall was fixed. Precision wasn't.&lt;/p&gt;

&lt;p&gt;For precision, I added a reranker. This is the part people skip because it "adds a model," and it's the part that did the heavy lifting.&lt;/p&gt;

&lt;p&gt;A reranker is a cross-encoder. Instead of embedding the query and the document separately and comparing vectors (a bi-encoder, which is what your vector search does), it feeds the query and each candidate &lt;em&gt;together&lt;/em&gt; through the model and scores their actual relevance. It's slower per pair, which is why you never use it for the first-stage search over thousands of chunks. But over 20 candidates? It's cheap, and it's dramatically more accurate because it can see the query and document at the same time.&lt;/p&gt;

&lt;p&gt;I ran &lt;code&gt;BAAI/bge-reranker-base&lt;/code&gt; locally. It's small, and it fits alongside my inference workloads on the &lt;a href="https://guatulabs.dev/posts/tesla-p40-in-a-homelab-24gb-of-inference-on-a-budget/" rel="noopener noreferrer"&gt;Tesla P40 I already had&lt;/a&gt; without a fight over VRAM. Around 1.1GB loaded.&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;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CrossEncoder&lt;/span&gt;

&lt;span class="n"&gt;reranker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CrossEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-reranker-base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cuda&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&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;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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;top_n&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;5&lt;/span&gt;&lt;span class="p"&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;hybrid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# 20-40 fused candidates
&lt;/span&gt;    &lt;span class="n"&gt;pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;page_content&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;doc&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;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reranker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# true relevance per pair
&lt;/span&gt;    &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;zip&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;scores&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&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="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;doc&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;top_n&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;    &lt;span class="c1"&gt;# feed only the best 5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline is now: hybrid recall pulls 40 candidates, the reranker scores all 40, I keep the top 5. That top-5 goes to the model. LoCoMo landed at 80%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works, not just that it works
&lt;/h2&gt;

&lt;p&gt;The reason the reranker earns its keep comes down to what a bi-encoder physically cannot do.&lt;/p&gt;

&lt;p&gt;When you embed a document at index time, you compress its entire meaning into one fixed vector before you've ever seen the query. That vector has to be a decent answer to &lt;em&gt;every possible&lt;/em&gt; question about that chunk. It's a lossy average. For a chunk that says "the migration finished on March 14th after Maria flagged the vendor delay," the embedding smears the date, the name, and the topic together. When your query is specifically about the date, the vector doesn't get any sharper, because it was frozen months ago.&lt;/p&gt;

&lt;p&gt;A cross-encoder sees the query at scoring time. It reads "which date did the migration finish?" alongside that chunk and can attend directly to "March 14th." It's not comparing two averages. It's answering a specific relevance question with both halves in front of it. That's why reranking fixes precision on exactly the query types that dense search chokes on, and why a bigger embedding model didn't: the problem was never the quality of the average, it was the averaging itself.&lt;/p&gt;

&lt;p&gt;Hybrid search and reranking are attacking two different failures, which is why stacking them compounds. BM25 guarantees the needle chunk exists in the candidate pool. The reranker guarantees it floats to the top of that pool. Neither one alone gets you there. BM25 without reranking dumps the needle at rank 9 with 19 distractors. Reranking without BM25 can only reorder a candidate set that never contained the needle to begin with. You need the recall stage to be generous and the precision stage to be strict.&lt;/p&gt;

&lt;p&gt;This is the practical version of the theory I dug into in &lt;a href="https://guatulabs.dev/posts/cognitive-memory-for-agents-vector-search-vs-activation-based-recall/" rel="noopener noreferrer"&gt;vector search vs activation-based recall&lt;/a&gt;: different retrieval mechanisms have different failure modes, and a serious memory system layers them instead of betting everything on one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The latency tax, measured honestly
&lt;/h2&gt;

&lt;p&gt;Nothing is free. Here's what the two-stage pipeline cost on my hardware, averaged over the LoCoMo query set:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Vector-only&lt;/th&gt;
&lt;th&gt;Hybrid + rerank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First-stage retrieval&lt;/td&gt;
&lt;td&gt;~18ms&lt;/td&gt;
&lt;td&gt;~31ms (dense + BM25 in parallel)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rerank (40 candidates)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;~42ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total retrieval&lt;/td&gt;
&lt;td&gt;~18ms&lt;/td&gt;
&lt;td&gt;~73ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LoCoMo accuracy&lt;/td&gt;
&lt;td&gt;63%&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Retrieval got roughly 4x slower in absolute terms and added about 55ms end to end. For an interactive agent where the LLM generation step is already 2 to 8 seconds, 55ms of extra retrieval latency is noise. Nobody perceives it. I paid 55ms and got 17 points of accuracy on the queries that decide whether the agent is trustworthy.&lt;/p&gt;

&lt;p&gt;The trade would look different if I were serving retrieval as a standalone API at high QPS. Then 4x matters and I'd think about batching rerank calls or caching. For a single-user agentic workflow, it's the easiest 17 points I've ever bought.&lt;/p&gt;

&lt;p&gt;One VRAM note, since the reranker shares a GPU with inference: &lt;code&gt;bge-reranker-base&lt;/code&gt; at fp16 is small enough to coexist, but if your inference model already fills the card, you'll evict it or OOM. I keep the reranker pinned and size the LLM around it. On CPU it's viable too, around 200ms for 40 candidates on a modern core count, which is fine if you don't have spare VRAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Look at the failures, not the score.&lt;/strong&gt; The two hours I spent swapping embedding models were wasted because I optimized an aggregate instead of reading which questions I got wrong. The moment I bucketed failures by query type, the fix was obvious. Every point I gained after that came from a targeted change, not a bigger hammer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dense embeddings are bad at identifiers, and no embedding model fixes that.&lt;/strong&gt; Ticket numbers, dates, SKUs, proper nouns, error codes. If your agent recalls anything with a specific token in it, you need a sparse retriever in the loop. This isn't a tuning problem. It's a property of how dense vectors compress meaning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reranking is the highest-use stage most people skip.&lt;/strong&gt; It gets dismissed as "an extra model" and "more latency," and both are true and both are cheap. Splitting recall from precision is the core idea. Let the first stage be generous and dumb, let the second stage be strict and smart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build your own golden set.&lt;/strong&gt; LoCoMo is a fine public benchmark, but the queries that matter for &lt;em&gt;your&lt;/em&gt; agent are the ones your agent actually gets. I keep a small golden dataset of real recall queries and the chunk that should answer each one, and I run it on every change to the stack. Twenty good examples catch regressions that an aggregate score hides.&lt;/p&gt;

&lt;p&gt;What surprised me most was how little the fancy part mattered relative to the boring part. I went in assuming the embedding model was the ceiling. The ceiling was a 30-year-old keyword algorithm and a small cross-encoder, both running on hardware I already had. This retrieval layer is the foundation the rest of the memory stack sits on, and it's the same layer I'd want solid before wiring agents together into anything &lt;a href="https://guatulabs.dev/posts/multi-agent-ai-systems-architecture-patterns/" rel="noopener noreferrer"&gt;multi-agent&lt;/a&gt;. Since the reranker runs locally, none of the recall traffic leaves the box, which keeps the whole thing aligned with a &lt;a href="https://guatulabs.dev/posts/privacy-routed-llm-inference-local-models-for-sensitive-data/" rel="noopener noreferrer"&gt;privacy-routed inference&lt;/a&gt; setup instead of shipping every query to a hosted reranking API.&lt;/p&gt;

&lt;p&gt;If you're building agent memory or predictive systems on your own hardware and want a second set of eyes on the retrieval layer, that's the kind of work I do at &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;GuatuLabs&lt;/a&gt;. The stack is simpler than the marketing around RAG makes it sound. Two retrievers, one reranker, and the discipline to measure what you actually broke.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>rag</category>
      <category>hybridsearch</category>
      <category>reranker</category>
    </item>
    <item>
      <title>Hybrid Retrieval v2: Qwen Embeddings, BM25, and RRF with a FastEmbed Reranker</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Thu, 13 Aug 2026 16:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/hybrid-retrieval-v2-qwen-embeddings-bm25-and-rrf-with-a-fastembed-reranker-1702</link>
      <guid>https://dev.to/futhgar/hybrid-retrieval-v2-qwen-embeddings-bm25-and-rrf-with-a-fastembed-reranker-1702</guid>
      <description>&lt;p&gt;A query for &lt;code&gt;ndots:5&lt;/code&gt; against my wiki index used to return the article that exists specifically to explain &lt;code&gt;ndots:5&lt;/code&gt; at position seven. Ahead of it sat three general DNS articles, two Kubernetes networking posts, and something about service discovery. My embedding model understood the &lt;em&gt;topic&lt;/em&gt; perfectly and had no idea that the literal string mattered.&lt;/p&gt;

&lt;p&gt;That is the dense retrieval failure mode in one sentence. Semantic similarity is a fuzzy match by design, and a fuzzy match is exactly wrong when the user typed an exact identifier. Across a fixed 10-query eval set of the things I actually search for (config keys, error strings, CLI flags), dense-only retrieval put the correct article in the top 3 for 5 of them. Hybrid retrieval with a reranker on the same 10 queries hits 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should care
&lt;/h2&gt;

&lt;p&gt;If you run a retrieval layer for agents and your corpus is technical documentation, code, runbooks, or session memories, you have this problem whether or not you've measured it. Technical corpora are full of tokens that carry near-zero semantic weight and near-total discriminative weight: &lt;code&gt;max_cstate&lt;/code&gt;, &lt;code&gt;Modifier.IDF&lt;/code&gt;, &lt;code&gt;ErrImagePull&lt;/code&gt;, a CVE number, a Helm value path. An embedding model compresses all of those into a vector where they barely register against the surrounding prose.&lt;/p&gt;

&lt;p&gt;My first instinct was to reach for a better embedding model. That instinct was wrong, and the reason it was wrong is the most useful thing in this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bigger embeddings.&lt;/strong&gt; Swapping to a larger dense model moved my eval by roughly one query out of ten, and cost more VRAM plus more latency per ingest batch. Larger dense models are better at nuance in prose. None of them are better at treating &lt;code&gt;ndots:5&lt;/code&gt; as an atomic symbol, because none of them are trained to. Adding dimensions does not create a keyword index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query expansion with an LLM.&lt;/strong&gt; Rewrite the user query into three paraphrases, embed all three, union the results. This helped on vague questions and actively hurt on precise ones, because the paraphrases diluted the exact term being searched for. It also adds an LLM round trip to every retrieval call, which turns a 40ms operation into a 900ms one and makes results non-deterministic between runs. Acceptable for a chat UI. Bad for an agent that retrieves twenty times inside a single task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cross-encoder reranker served through Ollama.&lt;/strong&gt; This one is worth writing down, because the failure was silent and cost the most time.&lt;/p&gt;

&lt;p&gt;My plan was reasonable: over-retrieve 20 candidates from dense search, then rerank with a cross-encoder that sees query and document together. Ollama was already running in the cluster, GGUF conversions of popular rerankers exist on Hugging Face, so pull one, hit the API, sort by score.&lt;/p&gt;

&lt;p&gt;Scores came back as numbers. They were garbage. Not obviously broken (no errors, no NaNs), just weakly correlated with relevance. Sometimes the reranked order was measurably worse than the pre-rerank order, which is an impressive achievement for a component whose entire job is to improve ordering.&lt;/p&gt;

&lt;p&gt;Here's the mechanism. A cross-encoder reranker is a sequence-classification model: an encoder backbone plus a trained classification head that emits a single relevance logit. Convert that to GGUF, serve it through a runtime built for causal LM generation and embedding extraction, and the classification head is usually not part of the picture. What comes back is a pooled hidden state, or a logit from a head that was never trained for relevance ranking, wrapped in a response shape identical to a real score. Nothing warns you. Your pipeline runs, your latency budget looks fine, and retrieval quality quietly rots.&lt;/p&gt;

&lt;p&gt;Generalizing: when a model's output is a scalar, you cannot tell by inspection whether it's the &lt;em&gt;right&lt;/em&gt; scalar. Test rerankers against a fixed query set with known-correct answers before you wire them in, not after you've shipped them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual solution
&lt;/h2&gt;

&lt;p&gt;Four pieces. A Qdrant collection with two named vector spaces, dense embeddings from &lt;code&gt;qwen3-embedding:0.6b&lt;/code&gt;, sparse BM25 vectors, and a cross-encoder reranker running on ONNX through FastEmbed. No GPU is involved in the reranking stage at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The collection schema
&lt;/h3&gt;

&lt;p&gt;Dense and sparse vectors live on the &lt;em&gt;same point&lt;/em&gt;. One document, one ID, two vector representations, one payload. That detail matters more than it looks: split them across two collections and you get two ingest paths that drift out of sync, and you'll find out about the drift during a retrieval failure at the worst possible moment.&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;qdrant_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;QdrantClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QdrantClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://qdrant:6333&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wiki_index_v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;vectors_config&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;dense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VectorParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="c1"&gt;# qwen3-embedding:0.6b
&lt;/span&gt;            &lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Distance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COSINE&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="n"&gt;sparse_vectors_config&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;bm25&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SparseVectorParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="c1"&gt;# Qdrant applies IDF server-side against the live corpus
&lt;/span&gt;            &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IDF&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;&lt;code&gt;modifier=models.Modifier.IDF&lt;/code&gt; is the line people skip. FastEmbed's BM25 produces the term-frequency component client-side, but inverse document frequency depends on the entire corpus, and your corpus changes on every ingest. Setting the modifier makes Qdrant compute IDF at query time from current collection statistics. Leave it out and you're doing raw term-frequency matching, which over-weights common tokens and makes the sparse leg noticeably worse: in my eval it cost two of the eight top-3 hits.&lt;/p&gt;

&lt;p&gt;You cannot add the modifier later without recreating the collection. Set it on day one.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Ingest both vectors in one upsert
&lt;/h3&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;fastembed&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SparseTextEmbedding&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;

&lt;span class="n"&gt;bm25&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SparseTextEmbedding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Qdrant/bm25&lt;/span&gt;&lt;span class="sh"&gt;"&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;embed_dense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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;list&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="p"&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;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qwen3-embedding:0.6b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embeddings&lt;/span&gt;&lt;span class="sh"&gt;"&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;to_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_id&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="n"&gt;text&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;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PointStruct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sparse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bm25&lt;/span&gt;&lt;span class="p"&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;text&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;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PointStruct&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;doc_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;vector&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;dense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;embed_dense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bm25&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SparseVector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sparse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sparse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tolist&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="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&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;SparseTextEmbedding("Qdrant/bm25")&lt;/code&gt; is not a neural model. It's a tokenizer plus stemming plus stopword removal, running in a few hundred microseconds per document. The cost of the sparse leg is rounding error next to the dense embedding call.&lt;/p&gt;

&lt;p&gt;One migration note. Moving a few hundred wiki articles and roughly twice as many session memories into the new schema meant re-embedding everything, and re-embedding is exactly where payloads get quietly dropped. My rule: read the full point from the old collection, carry the payload dict forward untouched, and diff payload key sets between source and destination when the run finishes. If a key existed on 300 points before and 280 after, you want a failing assertion, not a shrug. This is the same class of problem I wrote about in &lt;a href="https://guatulabs.dev/posts/silent-drift-why-re-embedding-only-on-count-changes-rots-your-semantic-index/" rel="noopener noreferrer"&gt;Silent Drift&lt;/a&gt;: count-based checks pass while content quietly diverges.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Query both legs and fuse with RRF
&lt;/h3&gt;

&lt;p&gt;Qdrant does the fusion server-side through prefetch, which saves a round trip and keeps the client dumb:&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;sparse_q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bm25&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query_embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_query&lt;/span&gt;&lt;span class="p"&gt;))&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query_points&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wiki_index_v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prefetch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Prefetch&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="nf"&gt;embed_dense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_query&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dense&lt;/span&gt;&lt;span class="sh"&gt;"&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="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Prefetch&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="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SparseVector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sparse_q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sparse_q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bm25&lt;/span&gt;&lt;span class="sh"&gt;"&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="mi"&gt;20&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="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FusionQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fusion&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fusion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RRF&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="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;with_payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;points&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;bm25.query_embed()&lt;/code&gt; for queries, not &lt;code&gt;bm25.embed()&lt;/code&gt;. Query embedding skips the term-frequency weighting that only makes sense for documents. Mixing them up produces results that look plausible and rank badly.&lt;/p&gt;

&lt;p&gt;The fusion itself is about six lines, and it's worth seeing them written out even if Qdrant runs it for you:&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;rrf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranked_lists&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;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&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;lst&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                 &lt;span class="c1"&gt;# each list is [doc_id, ...] by rank
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&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;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&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;doc_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&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="n"&gt;rank&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;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&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;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kv&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="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No score normalization. No tunable alpha weighting dense against sparse. Rank position is the only input.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Rerank on CPU with FastEmbed
&lt;/h3&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;fastembed.rerank.cross_encoder&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TextCrossEncoder&lt;/span&gt;

&lt;span class="n"&gt;reranker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TextCrossEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jinaai/jina-reranker-v2-base-multilingual&lt;/span&gt;&lt;span class="sh"&gt;"&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;rerank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_n&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;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&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="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&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;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reranker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rerank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# batched ONNX inference
&lt;/span&gt;    &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&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;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&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;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&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;ranked&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;top_n&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 whole reranking stage. FastEmbed ships the ONNX export with the classification head intact, downloads it on first use, and runs it through ONNX Runtime on CPU at roughly 38ms per query-document pair. Twenty candidates batched lands under half a second on a few cores, and it needs no GPU, no separate inference server, and no model-serving deployment to keep alive.&lt;/p&gt;

&lt;p&gt;Compare that to the GGUF path: same nominal model, wrong head, silently meaningless scores.&lt;/p&gt;

&lt;h3&gt;
  
  
  What changed on the eval set
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Correct doc in top 3&lt;/th&gt;
&lt;th&gt;Median latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dense only (1024d)&lt;/td&gt;
&lt;td&gt;5 / 10&lt;/td&gt;
&lt;td&gt;~45 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25 only&lt;/td&gt;
&lt;td&gt;6 / 10&lt;/td&gt;
&lt;td&gt;~12 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dense + BM25, RRF&lt;/td&gt;
&lt;td&gt;7 / 10&lt;/td&gt;
&lt;td&gt;~55 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dense + BM25, RRF, reranked&lt;/td&gt;
&lt;td&gt;8 / 10&lt;/td&gt;
&lt;td&gt;~480 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;BM25 alone beating dense alone surprised me. It also makes sense in hindsight: over half my eval queries were literal strings copied out of a config file or an error log, which is BM25's home turf and dense retrieval's blind spot.&lt;/p&gt;

&lt;p&gt;Here's the shape of a query that used to fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query: "ndots:5"

dense-only ranking:
  1. Kubernetes Service Discovery Patterns          (cos 0.612)
  2. DNS Failover With Two Upstreams                (cos 0.598)
  3. CoreDNS Tuning Notes                           (cos 0.591)
  ...
  7. Wildcard DNS + ndots:5: The TLS Nightmare      (cos 0.544)

hybrid + rerank:
  1. Wildcard DNS + ndots:5: The TLS Nightmare      (rerank  6.81)
  2. CoreDNS Tuning Notes                           (rerank  1.24)
  3. Kubernetes Service Discovery Patterns          (rerank  0.37)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;BM25 pulled the right article into the candidate pool at sparse rank 1. RRF pushed it to fused rank 2. The cross-encoder, which actually reads the query and the document together, put it first with a score nearly six times the runner-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it works
&lt;/h2&gt;

&lt;p&gt;Three separate mechanisms are doing distinct jobs, and it's worth being precise about which does what.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sparse retrieval indexes symbols, not meaning.&lt;/strong&gt; BM25 scores a document on term frequency scaled by inverse document frequency, with length normalization. &lt;code&gt;ndots&lt;/code&gt; appears in one article out of several hundred, so its IDF is enormous and any document containing it rockets to the top. An embedding model does the opposite: it maps rare tokens into a region of vector space defined by their context, which is exactly the behavior you want for synonyms and exactly the behavior you don't want for identifiers. Dense and sparse are not competing implementations of retrieval. They index different properties of the same text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RRF fuses ranks because scores are incomparable.&lt;/strong&gt; Cosine similarity lives in [-1, 1] and clusters hard around 0.5 to 0.7 for a technical corpus. BM25 scores are unbounded and depend on corpus size, document length, and term rarity. Normalizing them onto a shared scale requires assumptions about their distributions that break whenever the corpus changes. Reciprocal rank fusion sidesteps the problem: it throws the scores away and keeps only the ordering, then sums &lt;code&gt;1/(k + rank)&lt;/code&gt; across both lists. The &lt;code&gt;k=60&lt;/code&gt; constant flattens the curve near the top so that rank 1 versus rank 2 isn't a cliff, which means a document ranked 3rd by both retrievers can outrank a document ranked 1st by one and 40th by the other. Consensus wins over one confident vote, and that's the behavior you want when one leg is guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-encoders can do what bi-encoders structurally cannot.&lt;/strong&gt; Your embedding model is a bi-encoder: query and document are encoded independently, never seeing each other, and compared by cosine distance at the end. That independence is what makes vector search fast, because you precompute every document embedding once. It also means the model never gets to ask "does this specific document answer this specific question." A cross-encoder concatenates query and document into one sequence and runs full attention across both, so query tokens attend directly to document tokens. Far more accurate, and far too slow to run against your whole corpus. Which is precisely why the architecture is retrieve-then-rerank: cheap methods cut several hundred documents down to 20, the expensive method orders those 20.&lt;/p&gt;

&lt;p&gt;That layering also explains why over-retrieval depth matters. Reranking cannot recover a document that never entered the candidate pool. If your prefetch limit is 5, the reranker is just reordering five things, and your recall ceiling is whatever RRF handed it. Twenty per leg is where my eval stopped improving; going to 50 added latency and no additional top-3 hits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test the reranker in isolation before trusting it.&lt;/strong&gt; Build a fixture of 10 to 20 query-document pairs where you know the ranking by hand, score them, and check the correlation. That test takes an hour and would have saved me the entire GGUF detour. It also catches the subtler failure where a reranker works fine on prose and falls apart on code blocks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ONNX over GGUF for anything with a classification head.&lt;/strong&gt; GGUF is a format built around generative decoder inference. Cross-encoders, classifiers, and any model whose value lives in a trained head on top of the backbone should go through ONNX Runtime, where the head is exported with the graph. FastEmbed makes that a one-liner, and running it on CPU means the reranker isn't competing with your LLM for VRAM. I don't need an accelerator to serve retrieval, which matters when the GPU is busy doing actual inference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set &lt;code&gt;Modifier.IDF&lt;/code&gt; at creation time.&lt;/strong&gt; I'd rather see this documented in bold in every hybrid search tutorial. Missing it does not raise an error, it just makes the sparse leg mediocre in a way you'll blame on BM25 rather than on your config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure with your queries, not a benchmark.&lt;/strong&gt; MTEB scores told me nothing useful about whether retrieval would find the article about a specific kernel parameter. A hand-built eval of 10 real queries with known-correct answers told me everything, and it's small enough to rerun in under a minute after any config change. Keyword-in-top-3 is a crude metric and a good one, because it maps directly to what the agent experiences: the right context is in the window or it isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval precision is upstream of everything else in an agent stack.&lt;/strong&gt; Better memory decay policies, better tool descriptions, better prompts, none of them compensate for handing the model the wrong three documents. I've come to treat the retrieval layer the way I treat storage: unglamorous, load-bearing, and worth over-engineering slightly. It sits underneath the &lt;a href="https://guatulabs.dev/posts/cognitive-memory-for-agents-vector-search-vs-activation-based-recall/" rel="noopener noreferrer"&gt;memory architecture&lt;/a&gt; and the &lt;a href="https://guatulabs.dev/posts/eviction-without-deletion-running-an-act-r-decay-policy-for-agent-memory/" rel="noopener noreferrer"&gt;decay policy&lt;/a&gt;, and it's the layer that determines whether the rest of the &lt;a href="https://guatulabs.dev/posts/multi-agent-ai-systems-architecture-patterns/" rel="noopener noreferrer"&gt;agent architecture&lt;/a&gt; has anything worthwhile to reason over. If you're building this kind of pipeline for something that has to work on a schedule rather than on a weekend, &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;that's the sort of work I do&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What surprised me:&lt;/strong&gt; the reranker mattered less than adding BM25. Fusion alone took the eval from 5/10 to 7/10; the cross-encoder added the eighth. I'd assumed the fancy neural component would carry the improvement, and instead the win came from a 1994-vintage ranking function that runs in twelve milliseconds and has no parameters to train. The old algorithm knows something the new model doesn't, which is that sometimes the user meant the exact characters they typed.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>qdrant</category>
      <category>hybridsearch</category>
      <category>embeddings</category>
    </item>
    <item>
      <title>Longhorn Read-Only Mounts: Detection, Recovery, and Closing the Silent Failure Window</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/longhorn-read-only-mounts-detection-recovery-and-closing-the-silent-failure-window-2l90</link>
      <guid>https://dev.to/futhgar/longhorn-read-only-mounts-detection-recovery-and-closing-the-silent-failure-window-2l90</guid>
      <description>&lt;p&gt;A Longhorn volume can sit in &lt;code&gt;attached&lt;/code&gt; state with &lt;code&gt;robustness: healthy&lt;/code&gt; in the UI while the ext4 filesystem inside the pod has been mounted read-only for six hours. The control plane and the filesystem are reporting on two different things, and only one of them knows your application stopped writing.&lt;/p&gt;

&lt;p&gt;That's the whole gotcha. Everything below is how to notice it before your users do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;The application doesn't crash. It keeps answering HTTP requests, keeps passing its liveness probe, keeps showing up green in whatever dashboard you built. But writes fail with &lt;code&gt;EROFS&lt;/code&gt;, and depending on how the app handles that, you get one of three flavors of bad:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The app logs the error and keeps going (worst case, silent data loss).&lt;/li&gt;
&lt;li&gt;The app buffers writes in memory and grows until OOMKilled.&lt;/li&gt;
&lt;li&gt;The app returns 500s on writes and 200s on reads, so uptime checks stay green.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Postgres is the polite one here: it will refuse to start a new checkpoint and eventually panic. A web app writing uploads to a PVC will happily return "upload complete" while the file goes nowhere. An application writing metrics or logs to a volume just stops writing and nobody notices until someone asks why the graph flatlined.&lt;/p&gt;

&lt;p&gt;Inside the container:&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="nv"&gt;$ &lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; apps &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/my-app &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;touch&lt;/span&gt; /data/.probe
&lt;span class="nb"&gt;touch&lt;/span&gt;: cannot &lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="s1"&gt;'/data/.probe'&lt;/span&gt;: Read-only file system
&lt;span class="nb"&gt;command &lt;/span&gt;terminated with &lt;span class="nb"&gt;exit &lt;/span&gt;code 1

&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; apps &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/my-app &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; /data /proc/mounts
/dev/sdc /data ext4 ro,relatime,stripe&lt;span class="o"&gt;=&lt;/span&gt;... 0 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;ro&lt;/code&gt; in the mount options is the tell. Nobody set it. The kernel set it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expected
&lt;/h2&gt;

&lt;p&gt;My mental model for a while was that Kubernetes storage failures are loud. A replica dies, Longhorn marks the volume &lt;code&gt;degraded&lt;/code&gt;, the alert fires, I go look. If things get bad enough the pod can't mount, it sits in &lt;code&gt;ContainerCreating&lt;/code&gt; with a screaming event, and that's also loud.&lt;/p&gt;

&lt;p&gt;The middle ground never entered the picture: the volume recovers at the Longhorn layer, but the filesystem does not un-fail itself. Longhorn's job ends at "there's a block device here and replicas are in sync." The filesystem sitting on top of that block device made an independent decision, and Longhorn has no opinion about it.&lt;/p&gt;

&lt;p&gt;I wrote about the general version of this gap in &lt;a href="https://guatulabs.dev/posts/longhorn-volume-health-monitoring-replication-and-capacity/" rel="noopener noreferrer"&gt;Longhorn Volume Health: The Gap Between 'Healthy' and Actually Working&lt;/a&gt;. Read-only remounts are the sharpest edge of that gap, because it's the one failure mode where every dashboard you have says everything is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;Here's the chain, and each link is boring on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The volume's replicas become briefly unreachable.&lt;/strong&gt; In a homelab this is almost never a disk failure. It's a switch reboot, a node going unresponsive under memory pressure, a kubelet restart, an MTU mismatch after a network change, or a node that hard-froze (I've written about &lt;a href="https://guatulabs.dev/posts/amd-ryzen-c-state-freezes-the-processor-max-cstate-1-fix/" rel="noopener noreferrer"&gt;one specific cause of that&lt;/a&gt;). Longhorn's &lt;code&gt;engine-replica-timeout&lt;/code&gt; setting defaults to 8 seconds. Exceed it and the engine marks that replica errored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The engine loses quorum on writes.&lt;/strong&gt; With every replica errored, the engine has nowhere to write. The iSCSI target backing &lt;code&gt;/dev/longhorn/pvc-xxxx&lt;/code&gt; starts returning I/O errors to the kernel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The kernel does what it was told to do.&lt;/strong&gt; ext4's default &lt;code&gt;errors=remount-ro&lt;/code&gt; behavior kicks in. On the node you'll find something like this in &lt;code&gt;dmesg&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;blk_update_request: I/O error, dev sdc, sector 2097168 op 0x1:(WRITE)
Buffer I/O error on dev sdc, logical block 262146, lost async page write
EXT4-fs error (device sdc): ext4_journal_check_start:83: comm postgres: Detected aborted journal
EXT4-fs (sdc): Remounting filesystem read-only
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is correct behavior. The filesystem chose data integrity over availability, which is exactly what you want it to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Longhorn recovers. The filesystem does not.&lt;/strong&gt; The network hiccup ends, replicas reconnect, auto-salvage does its thing, and the volume goes back to &lt;code&gt;healthy&lt;/code&gt;. The block device is fine now. But a remount to &lt;code&gt;ro&lt;/code&gt; is sticky. Nothing in the stack goes back and remounts it &lt;code&gt;rw&lt;/code&gt;, because nothing in the stack is watching.&lt;/p&gt;

&lt;p&gt;The pod stays running the entire time. Kubelet has no idea. Kubelet's job was to mount the volume, and it did, successfully, hours ago.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the part that surprised me most:&lt;/strong&gt; a liveness probe won't save you even if it catches the failure. A failed liveness probe restarts the &lt;em&gt;container&lt;/em&gt;, not the pod. The volume mount lives at the pod sandbox level, so the container comes back and lands on exactly the same read-only mount. You get a CrashLoopBackOff that never resolves, and if you're unlucky the restarts are quiet enough that you read the loop as an app bug rather than a storage one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting it
&lt;/h2&gt;

&lt;p&gt;Three layers, from cheapest to most thorough.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Longhorn CR is the wrong place to look, but check it anyway
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; longhorn-system get volumes.longhorn.io &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;NAME:.metadata.name,STATE:.status.state,solid:.status.robustness
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a volume shows &lt;code&gt;faulted&lt;/code&gt;, you have a different (louder) problem. If it shows &lt;code&gt;healthy&lt;/code&gt;, that tells you the storage layer recovered. It does &lt;strong&gt;not&lt;/strong&gt; tell you the filesystem did. This is the check that lies to you, so know what it's actually answering.&lt;/p&gt;

&lt;h3&gt;
  
  
  node_exporter has the metric, but not for your PVCs
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;node_filesystem_readonly&lt;/code&gt; is exactly the metric you want. There's a catch: node_exporter's default &lt;code&gt;--collector.filesystem.mount-points-exclude&lt;/code&gt; regex excludes &lt;code&gt;/var/lib/kubelet/.+&lt;/code&gt;, which is where every single CSI volume gets mounted on the host. Out of the box, your PVC mounts are invisible to it.&lt;/p&gt;

&lt;p&gt;You can widen the exclusion regex, and it works, but you'll pay for it in cardinality on a cluster with a lot of volumes, and you'll get a metric labeled by an opaque &lt;code&gt;pvc-&amp;lt;uuid&amp;gt;&lt;/code&gt; path with no workload context. It's a reasonable backstop. It's a poor primary signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Probe from inside the pod, and actually hit the disk
&lt;/h3&gt;

&lt;p&gt;This is the one that works. The naive version:&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;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;touch&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/data/.rw-probe&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-f&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/data/.rw-probe"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
  &lt;span class="na"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;touch&lt;/code&gt; on a read-only mount fails at the VFS layer with &lt;code&gt;EROFS&lt;/code&gt; before any I/O happens, so this catches the remount case immediately. What it doesn't catch is a filesystem that's still nominally &lt;code&gt;rw&lt;/code&gt; while the block device underneath has gone away, because the metadata update can land in the page cache and return success.&lt;/p&gt;

&lt;p&gt;The version I'd actually ship forces the write to the device:&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;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&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;sh&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dd&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;if=/dev/zero&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of=/data/.probe/rw&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bs=4k&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;count=1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;conv=fsync&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&amp;gt;/dev/null"&lt;/span&gt;
  &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
  &lt;span class="na"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&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;Two things matter in that snippet. &lt;code&gt;conv=fsync&lt;/code&gt; is what makes it a real test instead of a page-cache test. And the write goes to a subdirectory, not the data root, because plenty of applications (Postgres among them) will complain loudly about unexpected files in their data directory.&lt;/p&gt;

&lt;p&gt;Use a &lt;strong&gt;readiness&lt;/strong&gt; probe, not liveness, for the reasons above. Readiness pulls the pod out of Service endpoints and flips &lt;code&gt;kube_pod_status_ready&lt;/code&gt;, which is something you can alert on without a restart loop confusing the picture.&lt;/p&gt;

&lt;p&gt;Keep the period generous. A 60-second period with &lt;code&gt;failureThreshold: 3&lt;/code&gt; gives you a three-minute detection window and a handful of 4 KB writes per hour per pod. That's a fine trade.&lt;/p&gt;

&lt;h3&gt;
  
  
  A canary for the cluster-wide case
&lt;/h3&gt;

&lt;p&gt;Per-pod probes only cover pods you remembered to instrument. A small canary workload with its own PVC per storage class gives you a floor: a CronJob or Deployment whose only job is to write a timestamp, fsync it, read it back, and expose a metric.&lt;/p&gt;

&lt;p&gt;Be honest with yourself about what this catches. Read-only remounts are per-volume, so a canary volume won't tell you that &lt;em&gt;your database's&lt;/em&gt; volume went read-only. What it catches is the cluster-wide or node-wide version: a Longhorn upgrade that went sideways, a node whose disk filled, a network change that broke replica traffic everywhere at once. It's a smoke detector, not a per-room alarm. Run both.&lt;/p&gt;

&lt;h3&gt;
  
  
  The alerting rules
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&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;longhorn-writability&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LonghornVolumeFaulted&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;longhorn_volume_robustness == &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;critical&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Longhorn&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;volume&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;$labels.volume&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;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;faulted"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;StorageCanaryWriteFailed&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;storage_canary_write_success == &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;critical&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PodNotReadyWithPVC&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;kube_pod_status_ready{condition="true"} == 0&lt;/span&gt;
          &lt;span class="s"&gt;and on (namespace, pod)&lt;/span&gt;
          &lt;span class="s"&gt;kube_pod_spec_volumes_persistentvolumeclaims_info&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;warning&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;longhorn_volume_robustness&lt;/code&gt; uses 0 for unknown, 1 healthy, 2 degraded, 3 faulted. I don't page on &lt;code&gt;== 2&lt;/code&gt; (degraded), because degraded is normal and transient during node reboots and rebuilds. That's the difference between an alert you act on and an alert you learn to ignore, which is the whole argument in &lt;a href="https://guatulabs.dev/posts/prometheus-alerting-rules-that-don-t-cry-wolf/" rel="noopener noreferrer"&gt;Prometheus Alerting Rules That Don't Cry Wolf&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recovering
&lt;/h2&gt;

&lt;p&gt;Confirm the diagnosis first. Map the kernel device back to a PV on the node where the pod is scheduled:&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="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /dev/longhorn/
&lt;span class="c"&gt;# pvc-a1b2c3d4-... -&amp;gt; /dev/sdc&lt;/span&gt;
dmesg &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'EXT4-fs (error|warning)|Remounting filesystem read-only'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then fix it. &lt;code&gt;mount -o remount,rw&lt;/code&gt; is not the fix. ext4 sets an error flag in the superblock; a remount without a check either fails or hands you a filesystem with known-bad metadata. What you want is a full detach and reattach, because Kubernetes &lt;code&gt;mount-utils&lt;/code&gt; runs &lt;code&gt;fsck -a&lt;/code&gt; on ext4 before mounting an existing filesystem.&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;# 1. Scale the workload to zero. This is what triggers the unmount.&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; apps scale deploy/my-app &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0

&lt;span class="c"&gt;# 2. Wait for Longhorn to actually detach. Don't skip this.&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; longhorn-system &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.state}'&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;detached &lt;span class="se"&gt;\&lt;/span&gt;
  volumes.longhorn.io/pvc-a1b2c3d4-0000-0000-0000-000000000000 &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;180s

&lt;span class="c"&gt;# 3. Scale back up. fsck -a runs on mount.&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; apps scale deploy/my-app &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scaling to zero matters more than it looks. Deleting the pod on a Deployment gives you a new pod that may schedule before the old volume finishes detaching, and Longhorn v1 volumes with &lt;code&gt;ReadWriteOnce&lt;/code&gt; will just sit in attach/detach limbo. This is the same detach ordering problem that makes node drains hang, which I covered in &lt;a href="https://guatulabs.dev/posts/pod-disruption-budgets-why-kubectl-drain-gets-stuck-on-longhorn/" rel="noopener noreferrer"&gt;Pod Disruption Budgets: Why kubectl drain Gets Stuck on Longhorn&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If the pod comes back stuck in &lt;code&gt;ContainerCreating&lt;/code&gt;, check the events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; apps describe pod my-app-xxxx | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A5&lt;/span&gt; Events
&lt;span class="c"&gt;# ... MountVolume.MountDevice failed ... 'fsck' found errors ... exit status 4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit status 4 means &lt;code&gt;e2fsck&lt;/code&gt; found errors it wouldn't correct unattended. At that point you need a manual pass: attach the volume in maintenance mode from the Longhorn UI (which attaches the block device to a node without a workload), SSH to that node, and run &lt;code&gt;e2fsck -f /dev/longhorn/pvc-&amp;lt;uuid&amp;gt;&lt;/code&gt; interactively. Take a Longhorn snapshot before you do that. Manual fsck can and does discard data into &lt;code&gt;lost+found&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And if the answer to fsck is "the metadata is gone," you're in restore territory. That's what &lt;a href="https://guatulabs.dev/posts/velero-minio-kubernetes-backup-strategy-for-bare-metal/" rel="noopener noreferrer"&gt;Velero + MinIO&lt;/a&gt; is for. Longhorn replicas protect you from a disk dying. They don't protect you from a filesystem that corrupted itself and dutifully replicated the corruption three ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;The general lesson generalizes past Longhorn: &lt;strong&gt;health checks that don't exercise the dependency don't check health.&lt;/strong&gt; An HTTP liveness probe that returns 200 from an in-memory handler tells you the process is scheduled on a CPU. It says nothing about the disk, the database connection, or the message queue. Every storage layer I've run has some version of this gap where the control plane's idea of healthy and the data path's idea of healthy diverge, and the divergence is always quiet.&lt;/p&gt;

&lt;p&gt;A few things I'd do differently, or would tell someone setting this up fresh:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Instrument stateful workloads first, not last.&lt;/strong&gt; Anything with a PVC that holds data you'd miss gets a write-based readiness probe on day one. It's eight lines of YAML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on &lt;code&gt;faulted&lt;/code&gt;, not on &lt;code&gt;degraded&lt;/code&gt;.&lt;/strong&gt; Degraded volumes are a normal part of a cluster that reboots nodes. Paging on them trains you to ignore the storage alerts entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume the filesystem won't recover just because the volume did.&lt;/strong&gt; Build the detach/reattach runbook before you need it, and put the &lt;code&gt;wait --for=jsonpath&lt;/code&gt; step in it, because that's the step everyone skips at 2 AM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consider XFS if your failure mode preference is different.&lt;/strong&gt; XFS shuts the filesystem down on serious errors rather than remounting read-only, which means the application fails harder and faster. Louder failure is easier to catch. It's also harder to recover from. Pick your poison deliberately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building out monitoring for a bare-metal cluster and want a second set of eyes on where the observability gaps are, &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;that's the kind of thing I do consulting work on&lt;/a&gt;. The read-only mount case is a good canary for the broader question: how many of your green dashboards are measuring the thing you actually care about, versus something adjacent to it that's easier to measure?&lt;/p&gt;

&lt;p&gt;If you're new to Longhorn on bare metal, &lt;a href="https://guatulabs.dev/posts/kubernetes-storage-on-bare-metal-longhorn-in-practice/" rel="noopener noreferrer"&gt;Kubernetes Storage on Bare Metal: Longhorn in Practice&lt;/a&gt; covers the setup side. This post is the failure mode you'll eventually meet after it's been running a while.&lt;/p&gt;

</description>
      <category>longhorn</category>
      <category>kubernetes</category>
      <category>storage</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>ForwardAuth with Authentik: Wiring SSO Across Multiple Apps Without Breaking Ingress</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Thu, 13 Aug 2026 12:15:49 +0000</pubDate>
      <link>https://dev.to/futhgar/forwardauth-with-authentik-wiring-sso-across-multiple-apps-without-breaking-ingress-4ki6</link>
      <guid>https://dev.to/futhgar/forwardauth-with-authentik-wiring-sso-across-multiple-apps-without-breaking-ingress-4ki6</guid>
      <description>&lt;p&gt;The most misleading thing Authentik will ever show you is its own 404 page.&lt;/p&gt;

&lt;p&gt;TLS terminated correctly. Traefik matched the route. The ForwardAuth middleware fired, reached the outpost, and got an answer back. Every layer did its job. And the answer was a branded 404, because the proxy provider was never bound to the outpost that answered. Nothing is broken in a way that shows up in logs, which is exactly why people burn an afternoon on DNS before they check the outpost binding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;p&gt;You're running Traefik as your ingress controller on Kubernetes, you have more than two internal apps that need auth, and you'd rather not implement OIDC in each of them. ForwardAuth is the right answer for that shape of problem: one identity provider, one middleware, N apps that stay blissfully unaware they're behind SSO.&lt;/p&gt;

&lt;p&gt;The install is thirty minutes. The operational hardening is the part nobody writes down, so that's what this covers: the failure modes that look like working systems, and the configuration that survives a default-deny cluster and a GitOps controller that reconciles your ingress every three minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure modes that look like success
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The branded 404
&lt;/h3&gt;

&lt;p&gt;When Traefik forwards an auth check to the Authentik outpost and the outpost has no provider bound for that hostname, it doesn't return 401 or 403. It returns a 404 from its own web UI, styled with your Authentik branding. Traefik dutifully passes that through, and the user sees a polished "not found" page for an app that is definitely running.&lt;/p&gt;

&lt;p&gt;The diagnostic that actually settles it:&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;# Ask the outpost directly, pretending to be Traefik.&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; traefik run curl &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;curlimages/curl &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'X-Forwarded-Proto: https'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'X-Forwarded-Host: wiki.example.com'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A healthy unauthenticated check returns &lt;code&gt;401&lt;/code&gt;. A &lt;code&gt;404&lt;/code&gt; means the outpost has no provider matching &lt;code&gt;X-Forwarded-Host&lt;/code&gt;. A &lt;code&gt;302&lt;/code&gt; means it's redirecting you to the login flow, which is also fine. Anything else means the request never reached the outpost at all.&lt;/p&gt;

&lt;p&gt;The fix is in the Authentik UI, not in your manifests: the proxy provider must be attached to an application, and that application must be selected in the outpost's list. Creating the provider is not enough. Creating the application is not enough. The outpost holds an explicit list, and a provider that isn't on it does not exist as far as the auth check is concerned.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The secret key with a newline in it
&lt;/h3&gt;

&lt;p&gt;Generate the Authentik secret key with &lt;code&gt;openssl rand -base64 60&lt;/code&gt; and you get 80 base64 characters, which openssl wraps across two lines. Store that in a Kubernetes secret and the trailing line feed comes along for the ride.&lt;/p&gt;

&lt;p&gt;The embedded outpost uses that value when it talks back to the Authentik core API, in an &lt;code&gt;Authorization&lt;/code&gt; header. Go's &lt;code&gt;net/http&lt;/code&gt; validates header values before it writes them to the wire and rejects anything containing a control character. The request fails inside the client library, never leaves the pod, and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;http: invalid header field value for "Authorization"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outpost then reports zero providers loaded, which puts you right back at the branded 404. Two different root causes, one identical symptom.&lt;/p&gt;

&lt;p&gt;Generate it in a shape that can't wrap:&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;# Hex output is single-line at any length. No wrapping, no tr, no surprises.&lt;/span&gt;
openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 48

&lt;span class="c"&gt;# If you want base64, strip line endings explicitly:&lt;/span&gt;
openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 60 | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\r\n'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then verify what actually landed in the pod, without ever printing the value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; authentik &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/authentik-server &lt;span class="nt"&gt;--&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
  printf "%s" "$AUTHENTIK_SECRET_KEY" | wc -c
  printf "%s" "$AUTHENTIK_SECRET_KEY" | tr -dc "[:cntrl:]" | wc -c
'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First number is the length. Second number must be &lt;code&gt;0&lt;/code&gt;. If it's &lt;code&gt;1&lt;/code&gt;, you have a trailing newline. The &lt;code&gt;tr -dc&lt;/code&gt; deletes everything that isn't a control character and counts what's left, so the secret itself never reaches your terminal or your shell history. Same discipline applies when you seal it: if you're storing this in Git via &lt;a href="https://guatulabs.dev/posts/sealedsecrets-storing-secrets-in-git-without-the-risk/" rel="noopener noreferrer"&gt;SealedSecrets&lt;/a&gt;, pipe the generator directly into &lt;code&gt;kubectl create secret --dry-run=client&lt;/code&gt; rather than round-tripping through a file that your editor will happily terminate with a newline.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Default-deny eats the auth check
&lt;/h3&gt;

&lt;p&gt;If you run &lt;a href="https://guatulabs.dev/posts/network-policies-with-calico-default-deny-and-namespace-isolation/" rel="noopener noreferrer"&gt;Calico NetworkPolicies with default-deny&lt;/a&gt;, ForwardAuth introduces a traffic path you probably didn't account for: ingress controller to identity provider, on an internal port, in a different namespace. Most SSO tutorials assume a wide-open cluster.&lt;/p&gt;

&lt;p&gt;What makes this one nasty is the timing. Traefik's ForwardAuth has a default timeout, so a blocked packet doesn't produce a clean error. It produces a slow 500 after the connection times out, and the Traefik access log records a request that took several seconds and failed at the middleware. Under load it looks like the identity provider is overwhelmed rather than unreachable.&lt;/p&gt;

&lt;p&gt;You need policy on both ends. Egress from Traefik:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;traefik-to-authentik&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Egress&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kubernetes.io/metadata.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authentik&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="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And matching ingress on the Authentik side, selecting the outpost pods. If you're using the embedded outpost, that's the &lt;code&gt;authentik-server&lt;/code&gt; deployment; a dedicated outpost gets its own &lt;code&gt;ak-outpost-*&lt;/code&gt; pods. Do not forget DNS egress to &lt;code&gt;kube-system&lt;/code&gt; in the same policy set, or the service name won't even resolve.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Authenticated, and still a guest
&lt;/h3&gt;

&lt;p&gt;This is the one that survives all the way to production. SSO works, the login page appears, the redirect completes, the app loads. And the user is anonymous inside the application.&lt;/p&gt;

&lt;p&gt;ForwardAuth gives Traefik a set of headers from the outpost, but Traefik only copies the ones you explicitly list. The default is none. So the app receives an authenticated request with no identity attached and falls back to its guest role.&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Middleware&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;authentik-forwardauth&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;forwardAuth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik&lt;/span&gt;
    &lt;span class="na"&gt;trustForwardHeader&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;authResponseHeaders&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;X-authentik-username&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;X-authentik-groups&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;X-authentik-email&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;X-authentik-name&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;X-authentik-uid&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;X-authentik-jwt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the app has to be told to trust those headers, and that's per-application work that no amount of ingress configuration will do for you. Grafana wants &lt;code&gt;auth.proxy&lt;/code&gt; enabled with &lt;code&gt;header_name = X-authentik-username&lt;/code&gt; and &lt;code&gt;header_property = username&lt;/code&gt;. Wiki.js needs its header authentication strategy configured with the group header mapped to Wiki.js groups. Some apps want the email address as the identifier rather than the username, which matters the first time someone changes their display name and gets a brand new account.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;X-authentik-groups&lt;/code&gt; is a pipe-separated list, not JSON and not comma-separated. If your app's group mapping silently produces one giant group named &lt;code&gt;admins|editors|viewers&lt;/code&gt;, that's why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multi-app pattern
&lt;/h2&gt;

&lt;p&gt;Once you're past two applications, the per-app setup starts to hurt. Single-application forward auth requires routing &lt;code&gt;/outpost.goauthentik.io/&lt;/code&gt; on every app's hostname back to the outpost, because that's where the OAuth callback lands. Miss it on one app and users get a 404 immediately after logging in, on the redirect back.&lt;/p&gt;

&lt;p&gt;Domain-level forward auth collapses that. One proxy provider covers &lt;code&gt;*.example.com&lt;/code&gt;, one auth subdomain handles all callbacks, one middleware gets attached everywhere.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Single application&lt;/th&gt;
&lt;th&gt;Domain level&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Providers to maintain&lt;/td&gt;
&lt;td&gt;One per app&lt;/td&gt;
&lt;td&gt;One total&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Callback path routing&lt;/td&gt;
&lt;td&gt;Per app hostname&lt;/td&gt;
&lt;td&gt;One auth subdomain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-app authorization&lt;/td&gt;
&lt;td&gt;Enforced by policy bindings&lt;/td&gt;
&lt;td&gt;Not enforced at the proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookie scope&lt;/td&gt;
&lt;td&gt;Per host&lt;/td&gt;
&lt;td&gt;Parent domain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That third row is the tradeoff, and it's the reason domain-level isn't strictly better. With domain-level forward auth, the outpost checks that the session is valid for the domain, not that this specific user is authorized for this specific app. Anyone who can log into one application can reach all of them. Authentik's docs are explicit about this and people still miss it, then wire up per-app policy bindings that quietly do nothing.&lt;/p&gt;

&lt;p&gt;For a homelab or a small team where everyone gets everything, domain-level is the right call. For anything with real access tiers, use single-application providers and pay the per-app routing cost, or use domain-level for the perimeter and enforce authorization inside each app using the group headers.&lt;/p&gt;

&lt;p&gt;Domain-level provider settings that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;External host&lt;/strong&gt;: &lt;code&gt;https://auth.example.com&lt;/code&gt;, the browser-reachable URL. Not the cluster service DNS. The outpost puts this in redirect URLs, and the browser has to follow them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie domain&lt;/strong&gt;: &lt;code&gt;example.com&lt;/code&gt;. Set this to the parent domain or the session won't carry between subdomains, which defeats the entire point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token validity&lt;/strong&gt;: shorter than you think. The session cookie is your blast radius.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;authentik_host&lt;/code&gt; mistake is worth its own sentence. Setting it to the internal service name produces a system that works perfectly for the auth check and then redirects the user's browser to a hostname that only resolves inside the cluster. The login page never loads and the address bar shows something like &lt;code&gt;http://authentik-server.authentik.svc.cluster.local/...&lt;/code&gt;, which is a good clue and an easy one to misread as a DNS problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attaching the middleware without breaking routing
&lt;/h2&gt;

&lt;p&gt;Traefik v3 disallows cross-namespace middleware references by default. An &lt;code&gt;IngressRoute&lt;/code&gt; in the &lt;code&gt;apps&lt;/code&gt; namespace cannot reference a &lt;code&gt;Middleware&lt;/code&gt; in the &lt;code&gt;traefik&lt;/code&gt; namespace unless you set &lt;code&gt;providers.kubernetesCRD.allowCrossNamespace=true&lt;/code&gt; in the static configuration.&lt;/p&gt;

&lt;p&gt;Two ways out. Enable cross-namespace references and keep one canonical middleware, or replicate the middleware into every namespace that needs it. I prefer the single canonical middleware: a duplicated auth config is a config that will drift, and the one that drifts will be the one protecting something you care about.&lt;/p&gt;

&lt;p&gt;With cross-namespace enabled, an &lt;code&gt;IngressRoute&lt;/code&gt; references it by &lt;code&gt;namespace-name@kubernetescrd&lt;/code&gt;:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IngressRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;wiki&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;entryPoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;websecure&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host(`wiki.example.com`)&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rule&lt;/span&gt;
      &lt;span class="na"&gt;middlewares&lt;/span&gt;&lt;span class="pi"&gt;:&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;authentik-forwardauth&lt;/span&gt;
          &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&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;private-range&lt;/span&gt;
          &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
      &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&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;wiki&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wiki-tls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Order matters. Middlewares run in the order listed, so an IP allow-list in front of the auth check means unauthorized networks never reach the identity provider at all. Cheap, and it keeps your outpost logs readable.&lt;/p&gt;

&lt;p&gt;If you use plain &lt;code&gt;Ingress&lt;/code&gt; objects with annotations instead, the reference is a comma-separated string:&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;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;traefik.ingress.kubernetes.io/router.middlewares&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik-authentik-forwardauth@kubernetescrd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the format: namespace and name joined by a hyphen, then &lt;code&gt;@kubernetescrd&lt;/code&gt;. A typo here doesn't produce an error page. Traefik logs a warning and skips creating the router, and you get a 404 with no obvious connection to the annotation.&lt;/p&gt;

&lt;h3&gt;
  
  
  When Authentik lives outside the cluster
&lt;/h3&gt;

&lt;p&gt;If your identity provider runs on a VM or an LXC rather than in the cluster, the instinct is an &lt;code&gt;ExternalName&lt;/code&gt; service. Traefik handles those poorly for ForwardAuth targets, and you also lose the ability to write NetworkPolicy against them, because there's no pod and no IP to select.&lt;/p&gt;

&lt;p&gt;A selector-less &lt;code&gt;Service&lt;/code&gt; with a manually managed &lt;code&gt;EndpointSlice&lt;/code&gt; gives you a normal ClusterIP that policy can reason about:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;authentik-external&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authentik&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9000&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;discovery.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EndpointSlice&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;authentik-external-1&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authentik&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kubernetes.io/service-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authentik-external&lt;/span&gt;
&lt;span class="na"&gt;addressType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IPv4&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9000&lt;/span&gt;
&lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;addresses&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;10.0.0.50"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# the external Authentik host&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The label linking the slice to the service is mandatory and unlabelled slices are ignored without complaint. Use &lt;code&gt;EndpointSlice&lt;/code&gt; rather than the older &lt;code&gt;Endpoints&lt;/code&gt; API; it's the supported path going forward and the manual-endpoints pattern is one of the few places where you still hand-write these.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the pieces fit together this way
&lt;/h2&gt;

&lt;p&gt;ForwardAuth is a subrequest. When a request arrives, Traefik pauses it and issues a separate HTTP request to the auth address, carrying &lt;code&gt;X-Forwarded-Proto&lt;/code&gt;, &lt;code&gt;X-Forwarded-Host&lt;/code&gt;, &lt;code&gt;X-Forwarded-Uri&lt;/code&gt;, and the original cookies. The outpost's entire job is to answer with a status code.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;2xx&lt;/code&gt; means proceed, and Traefik continues to the backend with whichever response headers you listed in &lt;code&gt;authResponseHeaders&lt;/code&gt; copied onto the upstream request. Anything else, and Traefik returns the outpost's response to the client verbatim, including its body and its &lt;code&gt;Location&lt;/code&gt; header. That verbatim passthrough is the mechanism behind the branded 404: the outpost genuinely returned 404, so the user genuinely sees 404. Traefik is not hiding an error, it's relaying one faithfully.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;trustForwardHeader: true&lt;/code&gt; is what lets the outpost use the &lt;code&gt;X-Forwarded-*&lt;/code&gt; values to figure out which application is being requested. Without it, every request looks like it's for the outpost's own hostname, and provider matching fails for everything.&lt;/p&gt;

&lt;p&gt;The redirect chain is why &lt;code&gt;external_host&lt;/code&gt; has to be publicly resolvable. On an unauthenticated request, the outpost answers &lt;code&gt;302&lt;/code&gt; with a &lt;code&gt;Location&lt;/code&gt; pointing at the login flow. Traefik relays that to the browser. The browser follows it. At that moment the resolution happens on the user's machine, on their network, with their DNS. Cluster-internal names are meaningless there. If you're already running &lt;a href="https://guatulabs.dev/posts/adguard-home-network-wide-dns-filtering-with-failover/" rel="noopener noreferrer"&gt;split-horizon DNS with AdGuard Home&lt;/a&gt;, point the auth hostname at your load balancer IP internally and make sure the certificate covers it, because a TLS error on the auth redirect looks identical to an auth failure from the user's side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guarding against drift
&lt;/h2&gt;

&lt;p&gt;GitOps introduces a specific hazard here: the auth middleware is an annotation or a list entry on an object that other tooling also manages. A Helm chart upgrade that regenerates the &lt;code&gt;Ingress&lt;/code&gt; template drops your annotation. ArgoCD reconciles it. The app is now public, and the only visible change is that it stopped asking people to log in, which almost nobody reports as a bug.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;IngressRoute&lt;/code&gt; referencing a deleted &lt;code&gt;Middleware&lt;/code&gt; fails closed, returning 503, and that's the behavior you want. An &lt;code&gt;Ingress&lt;/code&gt; whose middleware annotation was removed fails open. If you're managing this through &lt;a href="https://guatulabs.dev/posts/gitops-for-homelabs-argocd-app-of-apps/" rel="noopener noreferrer"&gt;ArgoCD app-of-apps&lt;/a&gt;, prefer &lt;code&gt;IngressRoute&lt;/code&gt; for anything behind auth for exactly that reason.&lt;/p&gt;

&lt;p&gt;Belt and suspenders is an admission policy. A &lt;a href="https://guatulabs.dev/posts/kyverno-admission-controllers-policy-as-code-that-actually-works/" rel="noopener noreferrer"&gt;Kyverno rule&lt;/a&gt; that rejects any &lt;code&gt;Ingress&lt;/code&gt; or &lt;code&gt;IngressRoute&lt;/code&gt; in a labelled namespace unless it carries the auth middleware turns a silent exposure into a failed sync you'll actually see. That's a ten-line policy protecting against a class of mistake that no amount of care prevents, because the mistake is made by a templating engine, not a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this
&lt;/h2&gt;

&lt;p&gt;Check the outpost binding before you check anything else. The provider-to-application-to-outpost chain has three links and breaking any of them produces the same 404. It's the highest-probability cause and the fastest thing to verify.&lt;/p&gt;

&lt;p&gt;Curl the outpost from inside the Traefik namespace as your first diagnostic, not your fifth. A &lt;code&gt;401&lt;/code&gt; from that call means the auth layer is fine and your problem is somewhere else entirely, which eliminates most of the search space in one command.&lt;/p&gt;

&lt;p&gt;Generate secrets in shapes that can't wrap, and verify the byte count in the pod rather than trusting the generator. Control characters in a config value produce errors that name a completely unrelated component, and you'll read that error five times before you suspect the secret.&lt;/p&gt;

&lt;p&gt;Decide between domain-level and per-app providers based on your authorization model, not your convenience. Retrofitting per-app authorization onto a domain-level deployment means rebuilding the provider layer while people are using it.&lt;/p&gt;

&lt;p&gt;Write the NetworkPolicy in the same commit as the middleware. Adding ForwardAuth creates a new east-west traffic path, and in a default-deny cluster that path is closed until you open it. Discovering this during a partial rollout is a lot less pleasant than discovering it in a diff.&lt;/p&gt;

&lt;p&gt;The broader theme is that identity in Kubernetes is a chain, and chains fail at whichever link you didn't configure: ingress routing, the auth subrequest, header propagation, application-side mapping, and the &lt;a href="https://guatulabs.dev/posts/kubernetes-rbac-building-least-privilege-service-accounts/" rel="noopener noreferrer"&gt;RBAC or group model&lt;/a&gt; behind it. A working login page tells you about link two. It tells you nothing about link four, which is where users end up as guests. If you're building out this kind of identity plumbing across a real environment and want a second set of eyes on the design, that's &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;the kind of infrastructure work I do&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Test the whole chain with a real user account in a real browser, in an incognito window, from a network that isn't your workstation. Every shortcut around that has a way of hiding exactly the link that's broken.&lt;/p&gt;

</description>
      <category>authentik</category>
      <category>traefik</category>
      <category>sso</category>
      <category>ingress</category>
    </item>
    <item>
      <title>FastMCP Agent Mail: RBAC Tokens vs Anonymous Access, and the 403 Errors in Between</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Thu, 13 Aug 2026 10:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/fastmcp-agent-mail-rbac-tokens-vs-anonymous-access-and-the-403-errors-in-between-22pk</link>
      <guid>https://dev.to/futhgar/fastmcp-agent-mail-rbac-tokens-vs-anonymous-access-and-the-403-errors-in-between-22pk</guid>
      <description>&lt;p&gt;A FastMCP server started with &lt;code&gt;fastmcp run server.py&lt;/code&gt; accepts every request from every client, because the default configuration ships with no authentication at all. That's fine on localhost. Then you move the same server behind a Kubernetes IngressRoute with TLS, point three different agent sessions at it, and suddenly the agent reports that its mail tools "aren't available" while the pod logs show a stream of &lt;code&gt;403 Forbidden&lt;/code&gt;. Nothing about the server changed. The environment did, and anonymous access stopped being an option the moment the endpoint became reachable by anything other than you.&lt;/p&gt;

&lt;p&gt;This one's for anyone running an agent coordination server (agent mail, shared memory, task queues) as an MCP service that multiple Claude Code or Codex sessions talk to over HTTP. The pattern generalizes: any FastMCP server that graduates from stdio-on-localhost to streamable HTTP behind an ingress hits the same wall, and the failure mode is quieter than you'd expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anonymous access is a transport default, not a decision
&lt;/h2&gt;

&lt;p&gt;When you develop an MCP server locally, you're usually on stdio transport. The client spawns the server process directly, so "authentication" is just filesystem permissions. There's no network boundary, no tokens, nothing to get wrong. This is why local development feels so smooth and why it teaches you nothing about production.&lt;/p&gt;

&lt;p&gt;Switch to streamable HTTP (which you need for multiple agents sharing one server) and the situation inverts. Now anyone who can reach the port can call &lt;code&gt;initialize&lt;/code&gt;, list your tools, and invoke them. For an agent mail server, that means reading every message between your agents and injecting new ones. If your agents treat inbound mail as instructions, and coordination servers exist precisely so agents act on each other's messages, an unauthenticated mail endpoint is a prompt injection channel with a REST API.&lt;/p&gt;

&lt;p&gt;FastMCP 2.x makes auth opt-in via the &lt;code&gt;auth&lt;/code&gt; parameter on the server constructor. If you don't pass one, you get anonymous access. The docs are clear about this, but the gap between "docs are clear" and "you actually did it before exposing the ingress" is where the trouble lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the 403 actually comes from
&lt;/h2&gt;

&lt;p&gt;Here's the diagnostic detail that saves you an hour: a &lt;code&gt;403&lt;/code&gt; on an MCP endpoint can originate from three different layers, and they look almost identical from the client side.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The ingress layer.&lt;/strong&gt; A Traefik middleware (ForwardAuth, IPAllowList, BasicAuth) rejecting the request before it ever reaches the pod. The response body is usually Traefik's plain &lt;code&gt;403 Forbidden&lt;/code&gt; text, and the pod logs show nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The FastMCP auth provider.&lt;/strong&gt; The server received the request and rejected the credential. Strictly speaking, a &lt;em&gt;missing or invalid&lt;/em&gt; bearer token gets you a &lt;code&gt;401&lt;/code&gt; with a &lt;code&gt;WWW-Authenticate&lt;/code&gt; header. A &lt;em&gt;valid&lt;/em&gt; token with insufficient scopes gets you the &lt;code&gt;403&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool-level checks.&lt;/strong&gt; The MCP handshake succeeds, the tool call goes through, and the tool itself raises an error because the token's claims don't authorize that operation. This surfaces as a tool error inside the protocol, not an HTTP status.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The 401-vs-403 distinction matters more than it seems. A &lt;code&gt;401&lt;/code&gt; means "I don't know who you are": your header is missing, malformed, or the token doesn't verify. A &lt;code&gt;403&lt;/code&gt; means "I know who you are and the answer is no": the token parsed fine but lacks a required scope. When you're staring at agent logs at the end of a long debugging session, that one digit tells you whether to check the client config (401) or the server's scope requirements (403).&lt;/p&gt;

&lt;p&gt;The reason this gets miserable is the client side. Claude Code doesn't surface the HTTP status prominently. The server just shows as failed in &lt;code&gt;/mcp&lt;/code&gt;, the tools vanish from the agent's toolset, and the agent either tells you the capability doesn't exist or, worse, improvises around it. A running pod, a green health check, and a completely non-functional toolset can coexist happily. If you take one thing from this post, it's that "the pod is Running" verifies nothing about whether an agent can call a single tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring token auth into the FastMCP server
&lt;/h2&gt;

&lt;p&gt;For an internal agent mail server, you don't need a full OAuth flow. FastMCP 2.12 ships a &lt;code&gt;StaticTokenVerifier&lt;/code&gt; that maps opaque token strings to identities and scopes, which is exactly the right weight for a homelab or internal deployment:&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;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastmcp.server.auth.providers.jwt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StaticTokenVerifier&lt;/span&gt;

&lt;span class="n"&gt;verifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StaticTokenVerifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MAIL_TOKEN_WORKER&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;client_id&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;agent-worker&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;scopes&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;mail:read&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;mail:write&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MAIL_TOKEN_REVIEWER&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;client_id&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;agent-reviewer&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;scopes&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;mail:read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;# read-only: can fetch inbox, can't send
&lt;/span&gt;        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;required_scopes&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;mail:read&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;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent-mail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. The token values come from environment variables, never literals in the file, so the tokens live in a Kubernetes Secret and get injected at pod start. And each agent role gets its own token with its own scopes. That second part is the actual RBAC: a reviewer agent that only triages messages has no business holding a credential that can send them. This is the same two-tier thinking I wrote about in &lt;a href="https://guatulabs.dev/posts/agent-credential-management-two-tier-service-accounts/" rel="noopener noreferrer"&gt;agent credential management&lt;/a&gt;, applied one layer down at the MCP transport.&lt;/p&gt;

&lt;p&gt;Per-tool enforcement then reads the validated token from the request context:&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;fastmcp.server.dependencies&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_access_token&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastmcp.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ToolError&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&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;subject&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;body&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="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_access_token&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mail:write&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scopes&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;ToolError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This credential is read-only.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deliver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you outgrow static tokens (more than a handful of agents, or tokens that need rotation without a redeploy), swap &lt;code&gt;StaticTokenVerifier&lt;/code&gt; for FastMCP's &lt;code&gt;JWTVerifier&lt;/code&gt; pointed at a JWKS endpoint. The server code barely changes; the constructor argument does. I covered the boilerplate side of FastMCP in &lt;a href="https://guatulabs.dev/posts/building-mcp-servers-with-fastmcp/" rel="noopener noreferrer"&gt;an earlier post&lt;/a&gt;; auth is the part that post's happy path skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the client side
&lt;/h2&gt;

&lt;p&gt;The broken client configs I see fall into two buckets. The first is a stale stdio entry pointing at a script that moved or was replaced by the HTTP deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"agent-mail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"/home/user/old-scripts/mail_server.py"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fails instantly and at least fails loudly. The second bucket is the sneaky one: the URL was updated to the new HTTPS endpoint but the &lt;code&gt;Authorization&lt;/code&gt; header never got added, because the server didn't require one when the config was written. That's the config that worked for weeks and then started returning 403 the day auth landed on the server.&lt;/p&gt;

&lt;p&gt;The corrected version, with the token pulled from the environment rather than committed in plaintext:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"agent-mail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mail.example.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer ${AGENT_MAIL_TOKEN}"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude Code expands &lt;code&gt;${VAR}&lt;/code&gt; references in &lt;code&gt;.mcp.json&lt;/code&gt; from the environment. Use that. A raw token string in a JSON file in your home directory has a way of ending up in dotfile repos, pair-debugging screenshots, and pasted "here's my config, what's wrong" messages. The env-var indirection costs you one line in a shell profile and removes an entire category of leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ingress in front of it
&lt;/h2&gt;

&lt;p&gt;Nothing exotic on the Kubernetes side. Traefik terminates TLS and forwards to the service; FastMCP handles auth itself, so no ForwardAuth middleware is needed:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IngressRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;agent-mail&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;agents&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;entryPoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;websecure&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host(`mail.example.com`) &amp;amp;&amp;amp; PathPrefix(`/mcp`)&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rule&lt;/span&gt;
      &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&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;agent-mail&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mail-example-com-tls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping auth in the application rather than the ingress is a deliberate choice here. The server needs to know &lt;em&gt;which&lt;/em&gt; agent is calling to enforce scopes anyway, so putting a second auth layer in Traefik just gives you two places to misconfigure and two flavors of 403 to tell apart. If you already run ForwardAuth everywhere as policy, fine, but then remember that layer exists when you debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify it like the client would
&lt;/h2&gt;

&lt;p&gt;Before touching any agent config, prove the server behaves correctly with curl. First the failure case, no token:&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;-i&lt;/span&gt; https://mail.example.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json, text/event-stream"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-06-18","capabilities":{},
       "clientInfo":{"name":"curl","version":"0"}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want &lt;code&gt;HTTP/2 401&lt;/code&gt; with a &lt;code&gt;WWW-Authenticate: Bearer&lt;/code&gt; header. If you get Traefik's plain 403 or a 404, the request never reached FastMCP and your problem is routing, not auth. Then the success case:&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;-i&lt;/span&gt; https://mail.example.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$AGENT_MAIL_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json, text/event-stream"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"initialize", ...}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;200&lt;/code&gt; with an &lt;code&gt;initialize&lt;/code&gt; result means the full path works: DNS, TLS, ingress, pod, auth. Only now is a client-side failure actually a client-side failure. This two-curl check takes thirty seconds and cleanly bisects the problem, which beats restarting agent sessions and squinting at &lt;code&gt;/mcp&lt;/code&gt; output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The silent failure is the real enemy.&lt;/strong&gt; An agent whose MCP server fails auth doesn't crash. It just proceeds without those tools. In a multi-agent setup where sessions coordinate through mail, one agent silently losing its mailbox looks like that agent "deciding" not to communicate. Check &lt;code&gt;/mcp&lt;/code&gt; status at session start, or better, make your agents' startup routine fetch their inbox once and treat failure as fatal rather than shrugging past it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Health endpoints lie by omission.&lt;/strong&gt; If you expose a custom unauthenticated &lt;code&gt;/health&lt;/code&gt; route for Kubernetes probes, understand what you've built: a check that confirms the process is up while saying nothing about whether authenticated tool calls succeed. Reasonable for liveness. Useless for "are the agents actually able to use this."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Valid token, wrong scope, confusing error.&lt;/strong&gt; The read-only reviewer token from the example above will initialize successfully and list tools, then fail on &lt;code&gt;send_message&lt;/code&gt;. From the agent's perspective the tool exists but errors out. Make the tool-level error message state the actual problem ("this credential is read-only"), because the agent will relay that message to you verbatim, and "permission denied" tells you nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documentation drift bites here too.&lt;/strong&gt; If your CLAUDE.md or agent instructions enumerate the mail server's tools and you later add auth-gated ones, agents will attempt calls their token can't make. Keep the documented capability list synchronized with what each &lt;em&gt;role&lt;/em&gt; can actually invoke, not with what the server exposes in total. It's the same lesson as &lt;a href="https://guatulabs.dev/posts/silent-drift-why-re-embedding-only-on-count-changes-rots-your-semantic-index/" rel="noopener noreferrer"&gt;semantic index drift&lt;/a&gt;: any description of a system that isn't regenerated from the system will eventually be wrong.&lt;/p&gt;

&lt;p&gt;An alternative I considered and rejected: mTLS between agents and the server. It authenticates the machine, not the agent role, and distributing client certs to ephemeral agent sessions is far more friction than handing each role a scoped bearer token. Certificates make sense when the caller is a long-lived service. Agent sessions aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to reach for this
&lt;/h2&gt;

&lt;p&gt;The rule I'd apply: the moment an MCP server leaves stdio, it gets a token verifier, even if the only network it's exposed on is your own. Not because your LAN is hostile, but because the anonymous configuration silently becomes load-bearing, and you'll forget it's there until the day you add an ingress, a Tailscale route, or a second user. Retrofitting auth after three agents and two config files depend on anonymous access is strictly worse than starting with a static token that takes ten lines.&lt;/p&gt;

&lt;p&gt;For a mail server specifically, the stakes are higher than for a read-only lookup tool. Messages are instructions. Scoping who can write them is the difference between a coordination layer and an attack surface. If you're building out multi-agent infrastructure and want a second pair of eyes on the security model, that's &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;work I consult on&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And when the 403 does show up: read the status code, run the two curls, and find out which layer is saying no before you change anything. Most of the pain in these debugging sessions comes from fixing the wrong layer first.&lt;/p&gt;

</description>
      <category>fastmcp</category>
      <category>mcpservers</category>
      <category>aiagents</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Silent Drift: Why Re-Embedding Only on Count Changes Rots Your Semantic Index</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Wed, 29 Jul 2026 20:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/silent-drift-why-re-embedding-only-on-count-changes-rots-your-semantic-index-3k6h</link>
      <guid>https://dev.to/futhgar/silent-drift-why-re-embedding-only-on-count-changes-rots-your-semantic-index-3k6h</guid>
      <description>&lt;p&gt;My index reported 354 points. The collection had 451. Both numbers were "correct," and that gap is the whole problem.&lt;/p&gt;

&lt;p&gt;Here's the setup. I have a wiki that feeds a semantic index. An agent queries that index to pull context before answering infrastructure questions. The re-embedding job was wired to a dead-simple trigger: if the file count changed, re-embed the new files. Cheap, fast, and wrong in a way that takes weeks to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Search started returning confidently outdated answers. I'd edit a wiki article, fix a stale command, correct a version number, and the agent would keep retrieving the old text as if the edit never happened. No error. No stack trace. The vector was still "active" in the database, still matching queries, still ranking high. It just no longer represented what the source file actually said.&lt;/p&gt;

&lt;p&gt;That is the failure mode I've started calling a zombie vector. Technically alive in the index. Semantically dead. It points at a version of the document that stopped existing the moment I saved the edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expected
&lt;/h2&gt;

&lt;p&gt;I expected the index to reflect the source. That's the entire contract of a semantic index: the vectors are a searchable projection of the underlying text, and when the text changes, the projection changes with it. I assumed my re-embedding job upheld that contract. It did not. It upheld a much weaker one: the index reflects the &lt;em&gt;set of files that exist&lt;/em&gt;, not their &lt;em&gt;contents&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Those two contracts look identical right up until someone edits a file without adding or removing one. Which, if you maintain a wiki or a memory store, is most of what you do all day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;The trigger logic was count-based. Pseudocode, but this is close to what it was:&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;maybe_reindex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;current_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;count_files&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;indexed_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file_count&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="n"&gt;current_count&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;indexed_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# "nothing changed", skip
&lt;/span&gt;
    &lt;span class="nf"&gt;embed_new_files&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that &lt;code&gt;if current_count == indexed_count: return&lt;/code&gt; line again. That's the bug wearing a helmet. The moment the counts match, the function decides nothing changed and walks away. But file count is invariant under edits. You can rewrite every word of every article, and as long as you don't add or delete a file, the count stays put and the re-embed never fires.&lt;/p&gt;

&lt;p&gt;Worse, look at what happens when the count &lt;em&gt;does&lt;/em&gt; change. &lt;code&gt;embed_new_files&lt;/code&gt; only touches files it considers new. It never revisits existing vectors. So even a triggered run leaves edited-but-not-added files untouched. The index accumulates drift on every edit and only ever grows, never corrects.&lt;/p&gt;

&lt;p&gt;I found the 354-vs-451 discrepancy while debugging something unrelated. The metadata field said 354 points because that's the number the job had last written. The collection actually held 451 active points. The extra 97 came from a period where files got split and merged, chunk boundaries moved, and the job appended new chunks without ever retiring the old ones. So now I had two problems stacked on top of each other: vectors that were stale (edited source, unchanged embedding) and vectors that were orphaned (source chunk no longer exists, embedding lingers).&lt;/p&gt;

&lt;p&gt;The orphaned ones are their own special misery. This is the ghost reference problem. A memory file references a path, the path gets deleted or moved, but the embedded chunk still says "see &lt;code&gt;scripts/server.py&lt;/code&gt; for the handler." The agent retrieves that, follows the reference, and hits nothing. The index became a graveyard of pointers to resources that moved on without telling it.&lt;/p&gt;

&lt;p&gt;None of this crashes. That's what makes it rot instead of a bug. A crash you fix in an afternoon. Silent drift you discover months later when someone asks why the agent keeps recommending a flag that got renamed in a release you shipped a while back.&lt;/p&gt;

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

&lt;p&gt;Stop asking "did the count change." Start asking "did the content change." The count is a proxy for state, and it's a bad one. The actual state of a file is its contents, and you already have a cheap, exact way to fingerprint contents: a hash.&lt;/p&gt;

&lt;p&gt;The pattern is a manifest. For every source file, store the hash of what you indexed. Before re-embedding, walk the source tree, hash each file, and compare against the manifest. Re-embed only the files whose hashes don't match, plus handle files that vanished.&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;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;file_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;h&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="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&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;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&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;plan_reindex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&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;p&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nf"&gt;file_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;source_dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.md&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;changed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;current&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="n"&gt;manifest&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;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;current&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;changed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;changed&lt;/code&gt; catches both brand-new files (no manifest entry, so the &lt;code&gt;!=&lt;/code&gt; is true) and edited files (entry exists but the hash moved). &lt;code&gt;deleted&lt;/code&gt; catches files that disappeared, so you can purge their vectors instead of leaving zombies. And the returned &lt;code&gt;current&lt;/code&gt; dict becomes your new manifest once the run succeeds.&lt;/p&gt;

&lt;p&gt;The upsert side needs to be idempotent and it needs to delete before it inserts for changed files, otherwise you re-create the 354-vs-451 split. Use a deterministic point ID derived from file path plus chunk index so a re-embed overwrites the same slots rather than appending new ones:&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;point_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&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;chunk_idx&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="o"&gt;-&amp;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;raw&lt;/span&gt; &lt;span class="o"&gt;=&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;file_path&lt;/span&gt;&lt;span class="si"&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;chunk_idx&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&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;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&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;reindex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;plan_reindex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manifest&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;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# remove all chunks for this file first, deterministic IDs
&lt;/span&gt;        &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;filter&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;file_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;path&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;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;changed&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;chunk_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))):&lt;/span&gt;
            &lt;span class="n"&gt;vec&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;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&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="nf"&gt;point_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;vec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;payload&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;file_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chunk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;save_manifest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete-then-insert for changed files means a page that shrank from six chunks to four doesn't leave two ghosts behind. Delete-only for the &lt;code&gt;deleted&lt;/code&gt; set purges files that are gone. The manifest save happens last so a crash mid-run leaves you with stale-but-consistent state, and the next run just retries the same diff.&lt;/p&gt;

&lt;p&gt;There's one more guardrail that has nothing to do with counts and everything to do with silent corruption: dimension validation. Embedding providers occasionally return a vector of the wrong length. A model swap, a truncated response, a provider that quietly changed defaults. If you upsert a 768-dim vector into a collection built for 1024, some databases reject it loudly and some accept it and corrupt the distance math. Check before you write:&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;EXPECTED_DIM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;

&lt;span class="k"&gt;def&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;text&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;list&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;span class="n"&gt;vec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&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;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# e.g. a Bedrock call
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;EXPECTED_DIM&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;embedding dim &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; != expected &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;EXPECTED_DIM&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &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;for text starting &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&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;vec&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single &lt;code&gt;if len(vec) != EXPECTED_DIM&lt;/code&gt; has saved me from a whole category of "why is search suddenly garbage" investigations. A dimension mismatch that silently upserts poisons every subsequent query against that collection, and it looks exactly like drift until you check the vector lengths.&lt;/p&gt;

&lt;p&gt;Then there's the periodic clean slate. Incremental hash-based diffing is your day-to-day path, but hashing has blind spots. If your chunking logic changes, if your embedding model updates, if the manifest itself gets corrupted, the diff can miss things because the source hash didn't move even though the &lt;em&gt;right answer&lt;/em&gt; did. So I run a full rebuild on a schedule. Drop the collection, re-embed everything from scratch, write a fresh manifest.&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;rebuild_memory_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recreate_collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EXPECTED_DIM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;manifest&lt;/span&gt; &lt;span class="o"&gt;=&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;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;source_dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;file_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;chunk_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
            &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&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="nf"&gt;point_id&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;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;vector&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;chunk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;payload&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;file_path&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;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chunk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;manifest&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;path&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;
    &lt;span class="nf"&gt;save_manifest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&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;rebuilt &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; files&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;I keep this as &lt;code&gt;scripts/rebuild-memory-index.py&lt;/code&gt; and run it the way you'd run a database vacuum. It's the reset button that guarantees the index and source agree, no matter how much drift the incremental path accumulated. My wiki already had a rebuild script; the memory files did not, and that asymmetry was exactly where the documentation drift lived. If one index layer has a rebuild path and another doesn't, the one without it is the one silently rotting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;You will hit this the moment your source documents outlive their first embedding. Static corpora that never change are fine with count-based logic. But an agent memory store, a wiki, a knowledge base, anything a human or another agent edits in place, drifts continuously. The &lt;a href="https://guatulabs.dev/posts/six-layer-memory-architecture-for-claude-code/" rel="noopener noreferrer"&gt;six-layer memory architecture I run for Claude Code&lt;/a&gt; leans hard on the embedding layer being honest, because every layer above it inherits whatever the semantic index believes. A stale vector at the bottom becomes bad reasoning at the top, and the agent has no way to know its retrieved context is a lie.&lt;/p&gt;

&lt;p&gt;The economic argument usually runs the other way. Re-embedding is money and latency, so people optimize to touch only new content. That optimization is real, and for large corpora a full re-index on every write is genuinely wasteful. But the hidden cost sits on the other side of the ledger: an agent reasoning from zombie vectors produces confidently wrong output, and the cost of that, in a system anyone actually depends on, dwarfs the cost of a periodic rebuild. Cheap updates that corrupt the index aren't cheap. They're deferred, and the interest compounds in the form of debugging sessions that start with "the agent keeps insisting on something that hasn't been true for weeks."&lt;/p&gt;

&lt;p&gt;This pairs with a failure mode I've written about before: &lt;a href="https://guatulabs.dev/posts/your-vector-db-snapshots-are-landing-on-the-same-disk-that-will-fail/" rel="noopener noreferrer"&gt;your vector DB snapshots landing on the same disk that will fail&lt;/a&gt;. Same theme, different layer. In both cases the index looks healthy by the metric you're checking and is quietly broken by the metric that matters. It also sits next to decay-based eviction, which is a &lt;em&gt;deliberate&lt;/em&gt; forgetting policy; the &lt;a href="https://guatulabs.dev/posts/eviction-without-deletion-running-an-act-r-decay-policy-for-agent-memory-in-production/" rel="noopener noreferrer"&gt;ACT-R decay approach&lt;/a&gt; removes vectors on purpose based on activation. Drift is the opposite. It's accidental corruption where the vector stays but its meaning leaves. One is controlled forgetting. The other is uncontrolled lying.&lt;/p&gt;

&lt;p&gt;If you take one thing from this, make it a rule: never trust a count as a proxy for content. Counts answer "how many things exist," and that is almost never the question. The question is "does the index still agree with the source," and only a content fingerprint answers it. Treat re-indexing the way you treat CI. Hash-validate on every change, rebuild from clean state on a schedule, and validate vector dimensions before every write. I've been building this kind of self-correcting index behavior into &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;agent infrastructure work&lt;/a&gt; because the alternative is an index that reports 354, holds 451, and confidently tells your agent about a file you deleted last month.&lt;/p&gt;

&lt;p&gt;The index that lies to you never throws an error. That's exactly why you have to make it prove it's telling the truth.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>rag</category>
      <category>embeddings</category>
      <category>vectorsearch</category>
    </item>
  </channel>
</rss>
