<?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>Blog Pipeline Fence Corruption: How a Naive Sed Rule Swallowed Markdown Code Blocks</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Mon, 31 Aug 2026 22:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/blog-pipeline-fence-corruption-how-a-naive-sed-rule-swallowed-markdown-code-blocks-43p4</link>
      <guid>https://dev.to/futhgar/blog-pipeline-fence-corruption-how-a-naive-sed-rule-swallowed-markdown-code-blocks-43p4</guid>
      <description>&lt;p&gt;A draft came out of my blog generation pipeline with the frontmatter intact, the title correct, the word count in range, and roughly 1,400 words of prose rendered inside a single syntax-highlighted code block. Everything after the first &lt;code&gt;bash&lt;/code&gt; example was gray monospace on a dark background. The build passed. The exit code was 0.&lt;/p&gt;

&lt;p&gt;One &lt;code&gt;sed&lt;/code&gt; expression caused it, and it had been sitting in the generation script since the pipeline moved from a local model to a cloud backend. It did exactly what it was told to do, which was the whole problem.&lt;/p&gt;

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

&lt;p&gt;The setup is boring and probably familiar. A shell script calls a model in non-interactive mode, captures stdout, and writes the result to &lt;code&gt;src/content/posts/&amp;lt;slug&amp;gt;.md&lt;/code&gt;. Astro picks it up from the content collection, Zod validates the frontmatter, and &lt;code&gt;npm run build&lt;/code&gt; renders it.&lt;/p&gt;

&lt;p&gt;Models like to wrap their output. You ask for "a complete markdown file, output only the markdown," and a meaningful fraction of the time you get back something shaped 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="err"&gt;```&lt;/span&gt;&lt;span class="s"&gt;markdown&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Whatever"&lt;/span&gt;

&lt;span class="s"&gt;Post body here.&lt;/span&gt;
&lt;span class="err"&gt;```&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That outer fence is not part of the document. Write it to disk as-is and Astro sees a code block where the frontmatter should be, Zod finds no &lt;code&gt;title&lt;/code&gt;, and the build fails loudly. Loud failures are fine. You fix them and move on.&lt;/p&gt;

&lt;p&gt;So the pipeline stripped the wrapper before writing. That was the intent, and the expected outcome was a clean file with the outer fence gone and every internal code block untouched.&lt;/p&gt;

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

&lt;p&gt;Here is the rule, as it existed in the generation script:&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;# The "cleanup" that caused the problem&lt;/span&gt;
claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^```markdown$//; s/^```$//'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the second expression on its own: &lt;code&gt;s/^&lt;/code&gt;`&lt;code&gt;$//&lt;/code&gt;. It replaces any line consisting of exactly three backticks with an empty line. Every line. Not the last one, not the wrapper's closer specifically, all of them.&lt;/p&gt;

&lt;p&gt;Markdown fences are a toggle, not a container. An opening fence with an info string (&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;/code&gt;bash `) turns code mode on. A bare closing fence turns it off. Delete every bare closing fence in a document and you have deleted every "off" switch while keeping every "on" switch.&lt;/p&gt;

&lt;p&gt;Corruption follows deterministically from there. The first &lt;code&gt;&lt;/code&gt;`&lt;code&gt;bash&lt;/code&gt; in the post opens a code block, and that block never closes. Every heading, paragraph, table, and link after it becomes code block content. The renderer is behaving correctly. It was handed a document that says, in effect, "the rest of this file is bash."&lt;/p&gt;

&lt;p&gt;Nothing about the file is malformed by CommonMark rules, which is why it looked like corrupted prose rather than a syntax error. An unclosed fence at end-of-document is legal and closes implicitly. The parser doesn't complain, the markdown linter doesn't complain, and Astro renders it happily into a page nobody would read past the first screenful.&lt;/p&gt;

&lt;p&gt;That first expression, &lt;code&gt;s/^&lt;/code&gt;&lt;code&gt;&lt;/code&gt;markdown$//&lt;code&gt;, carried a smaller version of the same flaw. It emptied any line that was exactly &lt;/code&gt; &lt;code&gt;&lt;/code&gt;&lt;code&gt;markdown&lt;/code&gt;, which is a legitimate thing to write in a post about markdown tooling. Delete that opener and you're left with an orphaned closer, which then opens a code block for the remainder of the file. Same failure, inverted.&lt;/p&gt;

&lt;p&gt;Two things kept this from being obvious for longer than I'd like.&lt;/p&gt;

&lt;p&gt;Local models mostly didn't trigger it. The Ollama-backed path had different post-processing and rarely emitted the &lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;/code&gt;markdown &lt;code&gt; wrapper in the first place, which is the kind of behavioral difference you don't discover until you [move a scheduled generation job between backends](https://guatulabs.dev/posts/moving-scheduled-llm-curation-from-cloud-apis-to-local-models/). So the &lt;/code&gt;sed` rule was written for a problem that mostly existed on one branch of the pipeline, and it damaged output on both.&lt;/p&gt;

&lt;p&gt;Beyond that, nothing in the pipeline had an opinion about document structure. It checked word count. It checked frontmatter fields. It ran an anti-slop word filter. Every one of those checks passes cleanly on a file whose entire body is trapped inside a code block, because none of them parse the document as a document. When the only signal your automation emits is an exit code, you get told "success" for an entire class of failures that never touches the exit code. Structural validity has to be asserted explicitly, the same way you assert that a Kubernetes manifest is valid &lt;a href="https://guatulabs.dev/posts/kubernetes-manifest-validation-catching-errors-before-merge/" rel="noopener noreferrer"&gt;before it merges&lt;/a&gt; rather than after it's applied to a live cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, part one: stop using substitution for a positional problem
&lt;/h2&gt;

&lt;p&gt;The wrapper is a positional artifact. It lives on line 1 and on the final non-empty line, and nowhere else. A substitution rule anchored with &lt;code&gt;^...$&lt;/code&gt; has no concept of position, so it was the wrong tool from the first commit.&lt;/p&gt;

&lt;p&gt;What replaced it matches on a signature rather than a pattern, and touches exactly two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;unwrap_markdown_fence&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;l1 l2 last lastno fences

  &lt;span class="nv"&gt;l1&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'1p'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;l2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'2p'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;lastno&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;: &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;last&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;lastno&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;p"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="c"&gt;# Signature of a whole-document wrapper:&lt;/span&gt;
  &lt;span class="c"&gt;# line 1 is a fence, line 2 opens frontmatter, last non-empty line closes it.&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$l1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="s1"&gt;'```markdown'&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s1"&gt;'```md'&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s1"&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;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0 &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;esac&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$l2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'---'&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$last&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'```'&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0

  &lt;span class="c"&gt;# Even fence count means the wrapper pairs cleanly with itself.&lt;/span&gt;
  &lt;span class="nv"&gt;fences&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'^```'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;((&lt;/span&gt; fences % 2 &lt;span class="o"&gt;==&lt;/span&gt; 0 &lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0

  &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"1d;&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;lastno&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;d"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking line 2 for &lt;code&gt;---&lt;/code&gt; is what makes this safe. A wrapped document always has the frontmatter delimiter immediately after the opener, because that's how every post in the collection starts. A legitimate post that happens to open with a code fence will not have &lt;code&gt;---&lt;/code&gt; sitting on line 2.&lt;/p&gt;

&lt;p&gt;Every guard clause returns 0 instead of failing. If the signature doesn't match, the function does nothing and the file passes through untouched. A cleanup step should be inert when it isn't confident, not aggressive. The old rule was the opposite: maximally confident, zero context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, part two: assert structure, don't count it
&lt;/h2&gt;

&lt;p&gt;Unwrapping correctly is necessary but not sufficient, because the model itself can drop a closing fence. Any repair you write can also have bugs. So the pipeline needed something that reads the finished file and asserts the body is actually prose.&lt;/p&gt;

&lt;p&gt;My first instinct was to count fences and check for an even number. That check is close to worthless. A document with a missing closer and a stray opener has an even count and is still broken, and a post that legitimately uses four-backtick fences to demonstrate nested markdown throws the count off entirely. Counting tells you about symbols. You need to know about state.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;check-fences.sh&lt;/code&gt; walks the file, tracks whether it's inside a code block, and looks for content that has no business being there:&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;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'
  /^```/ {
    if (!in_code) { in_code=1; open_line=NR } else { in_code=0 }
    next
  }
  # Markdown structures that should never appear inside a fence
  in_code &amp;amp;&amp;amp; /^#{1,6} / {
    printf "line %d: heading inside code block opened at line %d\n", NR, open_line
    bad=1
  }
  in_code &amp;amp;&amp;amp; /^\|.*\|$/ {
    printf "line %d: table row inside code block opened at line %d\n", NR, open_line
    bad=1
  }
  END {
    if (in_code) {
      printf "unclosed fence opened at line %d (EOF)\n", open_line
      bad=1
    }
    exit bad
  }
'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Heading detection is what actually catches the &lt;code&gt;sed&lt;/code&gt; bug. A swallowed document always contains &lt;code&gt;## Something&lt;/code&gt; inside the runaway block, because posts have sections. Table rows catch the same failure in posts built around comparison tables. The end-of-file check catches the simpler case where the model just forgot a closer.&lt;/p&gt;

&lt;p&gt;False positives exist and I decided to live with them. A shell script demonstrating comment syntax can contain a line starting with &lt;code&gt;#&lt;/code&gt;, but the awk pattern requires a space after one to six hashes at column zero, and code comments in the posts I generate rarely look like a markdown heading with a capitalized sentence after it. When it does misfire, the failure mode is a rejected draft rather than a published page of gray monospace, and I'll take that trade every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, part three: repair before you reject
&lt;/h2&gt;

&lt;p&gt;A validator that only says no creates its own failure mode. The instinct when a generation step fails is to retry, but retrying an expensive model call to fix a mechanical, deterministic defect is the wrong shape of solution. Worse, the regenerated draft is a different draft. You've thrown away good prose to fix a missing three-character line, and the new attempt can trip a different gate entirely.&lt;/p&gt;

&lt;p&gt;Corruption from a missing closer has exactly one correct repair, so &lt;code&gt;repair-fences.sh&lt;/code&gt; performs it directly and runs before the check:&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;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'
  /^```/ { in_code = !in_code; print; next }
  # A heading inside a code block means the previous fence never closed.
  in_code &amp;amp;&amp;amp; /^#{1,6} / { print "```"; in_code=0; print; next }
  { print }
  END { if (in_code) print "```" }
'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ordering matters here. Repair runs first, the checker runs second, and the checker is the gate. If repair does its job, the check passes and the draft survives. If repair produces something still structurally broken, the check fails and the pipeline stops with a specific line number instead of a shrug. Repair is allowed to be optimistic precisely because it isn't the thing making the final call. That separation of "attempt a fix" from "verify the fix" is the same discipline that makes plan-and-apply workflows trustworthy in &lt;a href="https://guatulabs.dev/posts/automating-infrastructure-with-opentofu-and-github-actions/" rel="noopener noreferrer"&gt;infrastructure automation&lt;/a&gt;, and it applies just as well to a text pipeline.&lt;/p&gt;

&lt;p&gt;One caveat worth stating plainly: a repair step that mutates content is a liability if you can't see what it did. Mine writes a line to the run log whenever it changes the file, including the line number where it inserted a closer. Silent auto-repair is how you end up debugging a bug that a script already "fixed" three stages earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the gates fight each other
&lt;/h2&gt;

&lt;p&gt;Content quality gates and structural gates want different things, and stacking them naively produces a pipeline that rejects everything.&lt;/p&gt;

&lt;p&gt;My anti-slop checker is a hard gate. Banned words, banned phrases, an em-dash budget, a rule against consecutive paragraphs opening with the same word. It's deterministic and it's strict, which is the point. But a hard gate on prose quality combined with a hard gate on structure means a draft has to clear both in a single generation, and the retry cost is a full regeneration.&lt;/p&gt;

&lt;p&gt;The ordering that works for me: run mechanical repairs first (fence balance, wrapper stripping, frontmatter normalization), then run the structural gate, then run the quality gate, and only regenerate on quality failures. Mechanical problems get fixed in place because they have one right answer. Prose problems get sent back to the model with the specific violations included in the retry prompt, which turns a blind retry into a targeted revision. Feeding the checker's own output back into the next attempt is what makes the loop converge instead of thrashing, and it's the same feedback-shaped design that keeps &lt;a href="https://guatulabs.dev/posts/six-layer-memory-architecture-for-claude-code/" rel="noopener noreferrer"&gt;multi-layer agent memory systems&lt;/a&gt; from degrading over repeated passes.&lt;/p&gt;

&lt;p&gt;Anything a script can repair deterministically should never reach the model twice. That's the rule I landed on, and the fence bug is the clearest example of why: a missing &lt;code&gt;&lt;/code&gt;`&lt;code&gt;&lt;/code&gt; cost an entire draft and a model call when a five-line awk program fixes it in milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters beyond one blog
&lt;/h2&gt;

&lt;p&gt;Text transformation with line-anchored regex is a trap any time the text has stateful syntax, and markdown is full of it. Fences toggle. List indentation nests. Frontmatter delimiters and horizontal rules are the same three characters in different positions. HTML comments and YAML block scalars both swallow content until a terminator. A &lt;code&gt;sed&lt;/code&gt; expression sees a line. Your document has a grammar, and lines don't know what mode they're in.&lt;/p&gt;

&lt;p&gt;Two habits keep this from biting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Match on position or signature, not on pattern, when the artifact is positional.&lt;/strong&gt; If what you're stripping is "line 1 and the last line," write a rule that says line 1 and the last line. Anchors are not position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert the shape of the output, not just the presence of the output.&lt;/strong&gt; Word count, file existence, and frontmatter fields all confirm that &lt;em&gt;something&lt;/em&gt; landed on disk. None of them confirm it's a document. A structural check that understands the format is the only thing that closes the gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The broader pattern generalizes past blog tooling. Any pipeline that pipes model output through shell transformations and into a build system has this exposure, and the failure is quiet by construction: exit code 0, valid file, wrong document. If you're building automation where a model's output feeds downstream systems and you'd rather not discover the corruption from a reader, that validation layer is worth designing up front. It's a chunk of what I end up building for people in &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;agent and pipeline work&lt;/a&gt;, and it's almost always cheaper than the cleanup.&lt;/p&gt;

&lt;p&gt;What I'd do differently: I'd have written &lt;code&gt;check-fences.sh&lt;/code&gt; before I wrote any transformation step at all. The validator is 20 lines of awk. The &lt;code&gt;sed&lt;/code&gt; rule it protects against took a week of published output to notice, and noticing required a human looking at a rendered page. Automation that can't tell you it's broken isn't automation, it's a faster way to be wrong.&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>shellscripting</category>
      <category>automation</category>
      <category>llmtooling</category>
    </item>
    <item>
      <title>Tailscale Kernel TUN in Unprivileged LXC: Direct SSH Without Userspace Networking</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Sat, 22 Aug 2026 06:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/tailscale-kernel-tun-in-unprivileged-lxc-direct-ssh-without-userspace-networking-18la</link>
      <guid>https://dev.to/futhgar/tailscale-kernel-tun-in-unprivileged-lxc-direct-ssh-without-userspace-networking-18la</guid>
      <description>&lt;p&gt;&lt;code&gt;tailscale up --tun=userspace-networking&lt;/code&gt; gets you a green dot in the admin console and almost nothing else. The node appears in your tailnet, &lt;code&gt;tailscale status&lt;/code&gt; looks healthy, and then you try to SSH into that container from your laptop and the connection hangs until TCP gives up. Two lines in the LXC config file fix it, and the container stays unprivileged.&lt;/p&gt;

&lt;p&gt;That's the whole post, really. But those two lines only make sense once you understand why every guide pushes you toward userspace mode in the first place, and what you're giving up by staying there.&lt;/p&gt;

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

&lt;p&gt;Anyone running services in unprivileged LXC containers on Proxmox who wants those containers to be real tailnet members with their own &lt;code&gt;100.64.0.0/10&lt;/code&gt; address. Not reachable &lt;em&gt;through&lt;/em&gt; something else. Reachable directly, over WireGuard, with a kernel network interface that &lt;code&gt;ip addr&lt;/code&gt; can see.&lt;/p&gt;

&lt;p&gt;If you're already routing everything through a subnet router, you have a working setup and this is an optional upgrade. I covered that pattern in &lt;a href="https://guatulabs.dev/posts/tailscale-subnet-router-remote-access-without-traditional-vpn/" rel="noopener noreferrer"&gt;Tailscale Subnet Routers&lt;/a&gt;. Treat this as the next rung on the ladder: instead of one node advertising routes on behalf of everyone else, each container carries its own identity, its own ACL surface, and its own direct path to peers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What userspace networking actually costs you
&lt;/h2&gt;

&lt;p&gt;Every LXC-and-Tailscale guide I've read lands on the same instruction: pass &lt;code&gt;--tun=userspace-networking&lt;/code&gt; and move on. It works because it sidesteps the problem entirely. Rather than asking the kernel for a TUN device, tailscaled runs a userspace TCP/IP stack (gVisor's netstack) inside its own process and never opens &lt;code&gt;/dev/net/tun&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those costs stay invisible until you trip over one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outbound traffic needs a proxy.&lt;/strong&gt; In userspace mode, tailscaled exposes SOCKS5 and HTTP proxies on a local port. Nothing on the system routes to &lt;code&gt;100.64.0.0/10&lt;/code&gt; automatically, because there is no interface and no route. Every client has to be told about the proxy:&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;# userspace mode: this is the only way out&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ALL_PROXY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;socks5://localhost:1055/
curl http://100.64.0.20:8080/health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Miss that env var in a systemd unit, a cron job, or a nested container, and the traffic silently takes the normal default route instead. No error. It just goes somewhere else, which is the worst failure mode a network can have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inbound to a normal daemon doesn't happen.&lt;/strong&gt; Your &lt;code&gt;sshd&lt;/code&gt; binds &lt;code&gt;0.0.0.0:22&lt;/code&gt; on the container's LAN interface. Packets arriving over the tailnet terminate inside tailscaled's netstack, and there's no path from netstack to a socket the kernel owns unless you build one explicitly with &lt;code&gt;tailscale serve&lt;/code&gt;, or you switch to Tailscale SSH where tailscaled itself is the SSH server. Tailscale SSH genuinely works in userspace mode, which is why plenty of people never notice the limitation. But that's tailscaled's SSH implementation, not OpenSSH. If you care about host key pinning, &lt;code&gt;authorized_keys&lt;/code&gt; command restrictions, &lt;code&gt;Match&lt;/code&gt; blocks, or an &lt;code&gt;sshd_config&lt;/code&gt; you've tuned, you now maintain two parallel SSH stories on the same box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UDP is a mess.&lt;/strong&gt; Netstack's UDP support has been partial for years. Concrete symptom: mosh does not work. &lt;code&gt;mosh-server&lt;/code&gt; starts fine, prints its port and key, and the client sits there forever because the datagrams never arrive. If you SSH over flaky mobile links and lean on mosh to survive roaming, userspace mode is a dead end.&lt;/p&gt;

&lt;p&gt;None of this is a bug. It's a documented tradeoff, and for a container that only needs to &lt;em&gt;call out&lt;/em&gt; to a couple of HTTP endpoints, it's a perfectly reasonable one. The problem is that most guides present it as the answer rather than the fallback it actually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The false starts
&lt;/h2&gt;

&lt;p&gt;First instinct, recommended constantly on forums: make the container privileged. Set &lt;code&gt;unprivileged: 0&lt;/code&gt;, restore, done, TUN works. It also hands the container a root that maps to real host root, which throws away the single most valuable property of an unprivileged container. For a machine that's about to sit on a VPN and accept inbound connections from anywhere, that trade is exactly backwards.&lt;/p&gt;

&lt;p&gt;Second instinct: &lt;code&gt;--features nesting=1&lt;/code&gt;. That flag shows up in nearly every Proxmox thread about containers doing unusual things, and it's genuinely required for Docker-in-LXC and systemd cgroup delegation. It does nothing for TUN. Nesting controls whether the container can see and mount its own cgroup hierarchy and its own procfs/sysfs views. Device access is a completely separate mechanism. Set it, restart, observe zero change, spend twenty minutes wondering what you got wrong.&lt;/p&gt;

&lt;p&gt;AppArmor is the third dead end. There's a pile of advice suggesting &lt;code&gt;lxc.apparmor.profile: unconfined&lt;/code&gt; to get device access working. That's a sledgehammer for a problem AppArmor isn't causing. The default &lt;code&gt;lxc-container-default-cgns&lt;/code&gt; profile does not block &lt;code&gt;/dev/net/tun&lt;/code&gt; usage; it blocks a set of mount, ptrace, and &lt;code&gt;/proc&lt;/code&gt; write operations, none of which sit in the path here. Dropping confinement to fix a device permission issue is the kind of change that looks like it worked and quietly widens the blast radius. I've seen this pattern bite people in other contexts too, which is the same theme as &lt;a href="https://guatulabs.dev/posts/unprivileged-lxc-docker-the-runc-sysctl-permission-trap/" rel="noopener noreferrer"&gt;the runc sysctl trap&lt;/a&gt;: the fix that "works" is usually the one that removed a boundary you wanted.&lt;/p&gt;

&lt;p&gt;What's actually stopping you is much narrower than any of that.&lt;/p&gt;

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

&lt;p&gt;Three conditions have to hold: the host has the &lt;code&gt;tun&lt;/code&gt; module loaded, the container's device cgroup allows that specific char device, and the device node is bind-mounted into the container's filesystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Load &lt;code&gt;tun&lt;/code&gt; on the host and persist it
&lt;/h3&gt;

&lt;p&gt;On the Proxmox host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lsmod | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; tun &lt;span class="o"&gt;||&lt;/span&gt; modprobe tun
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /dev/net/tun
&lt;span class="c"&gt;# crw-rw-rw- 1 root root 10, 200 Aug 14 09:12 /dev/net/tun&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist it so a host reboot doesn't quietly undo everything:&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;echo &lt;/span&gt;tun &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/modules-load.d/tun.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do this &lt;strong&gt;before&lt;/strong&gt; the next step. The bind mount below has a source path on the host. If &lt;code&gt;/dev/net/tun&lt;/code&gt; doesn't exist when the container starts, the mount fails and the container refuses to start, which is a confusing way to learn that a kernel module wasn't loaded.&lt;/p&gt;

&lt;p&gt;Repeat this on every node in the cluster. A container that migrates to a host without &lt;code&gt;tun&lt;/code&gt; loaded will fail to start there, and you'll be debugging a mount error at the worst possible moment.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Add two lines to the container config
&lt;/h3&gt;

&lt;p&gt;Edit &lt;code&gt;/etc/pve/lxc/&amp;lt;CTID&amp;gt;.conf&lt;/code&gt; on the host that currently owns 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;lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none &lt;span class="nb"&gt;bind&lt;/span&gt;,create&lt;span class="o"&gt;=&lt;/span&gt;file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No &lt;code&gt;unprivileged: 0&lt;/code&gt;, no AppArmor changes, no nesting. Two details matter in the second line. The target path &lt;code&gt;dev/net/tun&lt;/code&gt; has &lt;strong&gt;no leading slash&lt;/strong&gt;, because LXC resolves mount targets relative to the container rootfs. And &lt;code&gt;create=file&lt;/code&gt; tells LXC to create the target inode if it doesn't already exist, which it won't on a fresh container.&lt;/p&gt;

&lt;p&gt;The first line uses cgroup v2 syntax. Anything running Proxmox 8.x is on the unified hierarchy, so &lt;code&gt;lxc.cgroup2.devices.allow&lt;/code&gt; is what you want. Older setups on cgroup v1 used &lt;code&gt;lxc.cgroup.devices.allow&lt;/code&gt; with the same argument format; if you're copying a snippet from a 2019 forum post, check which one it uses.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Stop and start the container from the host
&lt;/h3&gt;

&lt;p&gt;This part catches people. Rebooting from inside the container does not re-read the config file, because the config is consumed by LXC on container creation, not by the init system inside it. You need a full stop/start cycle driven from the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pct stop &amp;lt;CTID&amp;gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pct start &amp;lt;CTID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Until you do that, &lt;code&gt;/dev/net/tun&lt;/code&gt; simply won't exist inside the container, and you'll assume the config was wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Drop the userspace flag and bring Tailscale up
&lt;/h3&gt;

&lt;p&gt;If tailscaled was previously running in userspace mode, that flag lives in &lt;code&gt;/etc/default/tailscaled&lt;/code&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;# /etc/default/tailscaled - before&lt;/span&gt;
&lt;span class="nv"&gt;FLAGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"--tun=userspace-networking"&lt;/span&gt;

&lt;span class="c"&gt;# after&lt;/span&gt;
&lt;span class="nv"&gt;FLAGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart and authenticate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl restart tailscaled
tailscale up &lt;span class="nt"&gt;--hostname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-container-01 &lt;span class="nt"&gt;--accept-dns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I set &lt;code&gt;--accept-dns=false&lt;/code&gt; on servers by habit. MagicDNS rewriting &lt;code&gt;/etc/resolv.conf&lt;/code&gt; on a box that already has opinions about DNS is a fight you don't need, especially if you're running your own resolver like &lt;a href="https://guatulabs.dev/posts/adguard-home-network-wide-dns-filtering-with-failover/" rel="noopener noreferrer"&gt;AdGuard Home&lt;/a&gt;. Turn it on deliberately if you want it.&lt;/p&gt;

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

&lt;p&gt;Four checks, in order. Each one fails differently, which is what makes them useful.&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. The device exists and is world-writable&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /dev/net/tun
&lt;span class="c"&gt;# crw-rw-rw- 1 nobody nogroup 10, 200 Aug 14 09:12 /dev/net/tun&lt;/span&gt;

&lt;span class="c"&gt;# 2. A real kernel interface, not a netstack fiction&lt;/span&gt;
ip addr show tailscale0
&lt;span class="c"&gt;# 3: tailscale0: &amp;lt;POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP&amp;gt; mtu 1280 ...&lt;/span&gt;
&lt;span class="c"&gt;#     inet 100.64.0.31/32 scope global tailscale0&lt;/span&gt;

&lt;span class="c"&gt;# 3. Routes for the tailnet are installed in the container's table&lt;/span&gt;
ip route | &lt;span class="nb"&gt;grep &lt;/span&gt;100.64
&lt;span class="c"&gt;# 100.64.0.0/10 dev tailscale0 scope link&lt;/span&gt;

&lt;span class="c"&gt;# 4. Peer connectivity is direct, not relayed&lt;/span&gt;
tailscale ping 100.64.0.20
&lt;span class="c"&gt;# pong from db-node (100.64.0.20) via 192.0.2.14:41641 in 1ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fourth line is the one worth reading carefully. &lt;code&gt;via &amp;lt;ip&amp;gt;:&amp;lt;port&amp;gt;&lt;/code&gt; means a direct WireGuard tunnel between the two hosts. If it says &lt;code&gt;via DERP(sfo)&lt;/code&gt; instead, you're relaying through Tailscale's infrastructure, which still works but adds latency and depends on someone else's servers. &lt;code&gt;tailscale netcheck&lt;/code&gt; will tell you whether UDP is reachable and whether you're behind a NAT that's blocking direct paths.&lt;/p&gt;

&lt;p&gt;Then test the thing you came for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh user@100.64.0.31       &lt;span class="c"&gt;# OpenSSH, your config, your keys&lt;/span&gt;
mosh user@100.64.0.31      &lt;span class="c"&gt;# UDP works now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Three separate mechanisms have to cooperate, and the reason so much bad advice exists is that people conflate them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Device cgroup allowlist.&lt;/strong&gt; An unprivileged LXC container starts with a deny-by-default device policy and a short allowlist covering the basics: &lt;code&gt;/dev/null&lt;/code&gt;, &lt;code&gt;/dev/zero&lt;/code&gt;, &lt;code&gt;/dev/full&lt;/code&gt;, &lt;code&gt;/dev/random&lt;/code&gt;, &lt;code&gt;/dev/urandom&lt;/code&gt;, &lt;code&gt;/dev/tty&lt;/code&gt;, and the pty devices. The TUN driver registers as char device major 10, minor 200. It's not on that list, so any &lt;code&gt;open()&lt;/code&gt; on it returns &lt;code&gt;EPERM&lt;/code&gt; regardless of file permissions. Adding &lt;code&gt;c 10:200 rwm&lt;/code&gt; grants read, write, and mknod for exactly one device node and nothing else. That's the surgical part: you're not opening a category, you're naming a device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bind mount.&lt;/strong&gt; Passing the cgroup check gives permission to use the device, but the container's &lt;code&gt;/dev&lt;/code&gt; is a fresh tmpfs populated by LXC. Creating the node yourself with &lt;code&gt;mknod&lt;/code&gt; inside the container fails, because unprivileged containers don't hold &lt;code&gt;CAP_MKNOD&lt;/code&gt; in a namespace where it means anything for device creation. Bind-mounting the host's existing node sidesteps that entirely: the inode already exists, LXC just makes it visible at the right path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Namespaced capability.&lt;/strong&gt; Opening the device is only half the operation. Actually creating an interface requires the &lt;code&gt;TUNSETIFF&lt;/code&gt; ioctl, which needs &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt;. Here's the part that makes the whole approach safe: &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt; is evaluated against the user namespace that owns the network namespace. The container's root has full &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt; over its own netns, and zero authority over the host's. So tailscaled can create &lt;code&gt;tailscale0&lt;/code&gt;, set addresses, and install routes inside the container while remaining completely unable to touch host networking. No host privilege is granted at any point.&lt;/p&gt;

&lt;h3&gt;
  
  
  About that &lt;code&gt;nobody:nogroup&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Ownership showing as &lt;code&gt;nobody:nogroup&lt;/code&gt; inside the container is expected, not a symptom. The host node is owned by uid 0. An unprivileged container maps its uid 0 to something like host uid 100000, and host uid 0 falls outside the mapped range, so the kernel reports it as the overflow uid (65534) which resolves to &lt;code&gt;nobody&lt;/code&gt;. The mode is &lt;code&gt;0666&lt;/code&gt;, so every process in the container can open it anyway. People see &lt;code&gt;nobody:nogroup&lt;/code&gt;, assume permissions are broken, and go chase a &lt;code&gt;chown&lt;/code&gt; that will never work. Nothing to fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Direct paths versus the subnet router model
&lt;/h3&gt;

&lt;p&gt;With a kernel interface, the container installs a route for &lt;code&gt;100.64.0.0/10&lt;/code&gt; and encrypts packets itself. Traffic to a peer leaves as UDP on tailscaled's port (41641 by default) over the container's normal LAN interface, straight to the peer's endpoint. No hairpinning through a subnet router, no dependency on one container staying healthy, no single node whose reboot takes out remote access to a dozen services.&lt;/p&gt;

&lt;p&gt;The ACL story improves too. A subnet router advertises a CIDR, so your policy can only reason about IP ranges. Per-node membership lets you tag containers and write rules against identity:&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="nl"&gt;"acls"&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="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"accept"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"src"&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;"tag:admin"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dst"&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;"tag:lxc-app:22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tag:lxc-app:3000"&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;Tag the container at join time with &lt;code&gt;tailscale up --advertise-tags=tag:lxc-app&lt;/code&gt;, using an auth key whose owner is permitted to assign that tag. Now access is a property of the machine, not a property of whatever subnet it happens to sit in. That distinction matters a lot once more than one person needs access, and it's the same principle behind &lt;a href="https://guatulabs.dev/posts/agent-credential-management-two-tier-service-accounts/" rel="noopener noreferrer"&gt;two-tier service accounts for agents&lt;/a&gt;: scope by identity, not by network position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you restart anything
&lt;/h2&gt;

&lt;p&gt;Step 3 requires stopping the container, which means killing every process inside it. Worth five minutes of preparation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Save your terminal state.&lt;/strong&gt; If you live in tmux inside that container, &lt;code&gt;prefix + Ctrl-s&lt;/code&gt; with tmux-resurrect saves the session layout and running programs. Losing a session with twelve panes of half-finished debugging is a self-inflicted wound.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm a recent backup.&lt;/strong&gt; &lt;code&gt;pct listsnapshot &amp;lt;CTID&amp;gt;&lt;/code&gt; or a check in Proxmox Backup Server. The config change is easy to revert, but "the container won't start now" is much less stressful with a restore point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know how you'll get back in.&lt;/strong&gt; &lt;code&gt;pct enter &amp;lt;CTID&amp;gt;&lt;/code&gt; from the host works regardless of container networking. If your only access path is SSH over the very network you're changing, fix that first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record the change.&lt;/strong&gt; Two lines in a config file are trivially forgettable six months later when a fresh container mysteriously lacks TUN. Put them in whatever provisioning script or template you use, alongside the rest of your &lt;a href="https://guatulabs.dev/posts/building-production-homelab/" rel="noopener noreferrer"&gt;cluster build notes&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Device access is not privilege.&lt;/strong&gt; The instinct to reach for &lt;code&gt;unprivileged: 0&lt;/code&gt; comes from treating container security as a binary. It isn't. The device cgroup, capability sets, mount entries, and idmaps are independent knobs, and almost every "just make it privileged" answer online is someone who found one knob and gave up on the rest. Name the exact device you need and grant that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flags are not interchangeable.&lt;/strong&gt; &lt;code&gt;nesting=1&lt;/code&gt; fixes cgroup visibility. AppArmor profiles gate mounts and ptrace. Neither touches &lt;code&gt;/dev&lt;/code&gt;. Reading the actual mechanism took less time than trying flags at random, and I'd skip straight to the mechanism next time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load the module first, always.&lt;/strong&gt; Container start failing on a missing bind-mount source is a confusing error to debug backwards, and the &lt;code&gt;/etc/modules-load.d/tun.conf&lt;/code&gt; file has to exist on every node the container can migrate to. Cluster-wide, not per-node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick one SSH story.&lt;/strong&gt; Running both OpenSSH and Tailscale SSH on the same node means two authorization models, two audit trails, and two places to revoke access. With a kernel TUN device you get to use plain OpenSSH over the tailnet, so I turn Tailscale SSH off on these containers rather than leaving both paths live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The surprise was how boring the fix is.&lt;/strong&gt; Two config lines, one module, one restart. All the complexity lives in understanding why the defaults deny it, which is generally where the interesting part of infrastructure work hides. If you're designing this kind of access model for a team rather than a homelab, that's a conversation I have often through &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;GuatuLabs&lt;/a&gt;, and the boundary questions get more interesting once compliance and multiple operators enter the picture.&lt;/p&gt;

&lt;p&gt;One caveat worth stating plainly: this gives the container a kernel TUN device, not the right to advertise subnet routes. If you also want it acting as a subnet router, you need IP forwarding enabled inside the container and &lt;code&gt;--advertise-routes&lt;/code&gt; on the Tailscale side, which is a different set of tradeoffs. For the common case (a container that should be its own node, reachable directly, with working UDP and your real &lt;code&gt;sshd&lt;/code&gt;), the two lines above are the entire job.&lt;/p&gt;

</description>
      <category>tailscale</category>
      <category>proxmox</category>
      <category>lxc</category>
      <category>networking</category>
    </item>
    <item>
      <title>Systemd-tmpfiles vs Plex: When /tmp Aging Breaks EAC3 Transcoding</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Sat, 22 Aug 2026 04:15:47 +0000</pubDate>
      <link>https://dev.to/futhgar/systemd-tmpfiles-vs-plex-when-tmp-aging-breaks-eac3-transcoding-507</link>
      <guid>https://dev.to/futhgar/systemd-tmpfiles-vs-plex-when-tmp-aging-breaks-eac3-transcoding-507</guid>
      <description>&lt;p&gt;A Plex server with 90 days of uptime, a green service status, and perfectly working H.264 playback can still fail every single EAC3 transcode with a generic "Conversion failed" error. Nothing about Plex changed. No update, no config edit, no disk full. A systemd timer quietly deleted the transcoder's scratch directory out from under it, and Plex has no idea.&lt;/p&gt;

&lt;p&gt;This one is worth understanding if you run Plex on any systemd-based Linux, whether that's bare metal, a VM, or an unprivileged LXC on Proxmox. It's especially relevant right now because Debian 13 enabled automatic /tmp cleaning by default, so setups that ran fine for years on Debian 11 or 12 inherit this failure mode on upgrade without touching a single Plex setting.&lt;/p&gt;

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

&lt;p&gt;Plex handles most audio transcoding with its bundled ffmpeg fork. But Dolby Digital Plus (EAC3) is licensed differently, so Plex ships a separate binary called EasyAudioEncoder, usually abbreviated EAE. It doesn't work like a normal encoder that reads stdin and writes stdout. It's a watch-folder daemon: Plex drops audio into a directory, EAE picks it up, converts it, and writes the result back. That watch folder lives under a per-instance temp directory that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/tmp/pms-4f3a2b1c-9d8e-7f6a-5b4c-3d2e1f0a9b8c/EasyAudioEncoder/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail: Plex creates that directory tree exactly once, when the &lt;code&gt;plexmediaserver&lt;/code&gt; service starts. There's no health check on it, no recreation logic, no periodic touch. As long as the service keeps running, Plex assumes the directory it made on day one is still there.&lt;/p&gt;

&lt;p&gt;Meanwhile, systemd-tmpfiles has its own opinion about /tmp. On most modern distros, &lt;code&gt;/usr/lib/tmpfiles.d/tmp.conf&lt;/code&gt; contains a rule like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="c"&gt;# Clear /tmp entries older than 10 days, /var/tmp after 30&lt;/span&gt;
&lt;span class="err"&gt;q&lt;/span&gt; &lt;span class="err"&gt;/tmp&lt;/span&gt; &lt;span class="err"&gt;1777&lt;/span&gt; &lt;span class="err"&gt;root&lt;/span&gt; &lt;span class="err"&gt;root&lt;/span&gt; &lt;span class="err"&gt;10d&lt;/span&gt;
&lt;span class="err"&gt;q&lt;/span&gt; &lt;span class="err"&gt;/var/tmp&lt;/span&gt; &lt;span class="err"&gt;1777&lt;/span&gt; &lt;span class="err"&gt;root&lt;/span&gt; &lt;span class="err"&gt;root&lt;/span&gt; &lt;span class="err"&gt;30d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;systemd-tmpfiles-clean.timer&lt;/code&gt; fires 15 minutes after boot and then once a day, removing anything under /tmp whose access, modification, and change times are all older than the age threshold. If you're a heavy Plex user who transcodes EAC3 daily, the timestamps stay fresh and you never notice. If your household mostly direct-plays and only occasionally hits EAC3 content, the watch folder sits idle, crosses the 10-day line, and gets reaped.&lt;/p&gt;

&lt;p&gt;From that point on, the server is a zombie in one specific dimension. Everything that doesn't need EAE works. Anything that does fails at session start.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in the logs
&lt;/h2&gt;

&lt;p&gt;The client-side error is useless: "Conversion failed. The transcoder failed to start up." The actual story is in &lt;code&gt;Plex Media Server.log&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR - [Req#a4f/Transcode] EAE watchfolder is not writable:
  /tmp/pms-4f3a2b1c-.../EasyAudioEncoder/Convert to WAV (to 8ch or less) (~4f3a)/
ERROR - [Req#a4f/Transcode] Transcode runner appears to have died
DEBUG - Job exit code 187
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 187 from the transcoder job plus "EAE watchfolder is not writable" is the signature. If you go look, the path either doesn't exist at all or the &lt;code&gt;pms-*&lt;/code&gt; parent is gone entirely. You can confirm the cleanup timer is the culprit by checking when it last ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl list-timers systemd-tmpfiles-clean.timer
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-ld&lt;/span&gt; /tmp/pms-&lt;span class="k"&gt;*&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"gone"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The immediate fix is boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl restart plexmediaserver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restarting recreates the whole &lt;code&gt;/tmp/pms-*&lt;/code&gt; tree and EAC3 transcodes work again, instantly. Which is exactly why this problem generates so many confused forum threads that end with "restarted it and it works now, no idea why." The restart fixes the symptom and destroys the evidence, and ten days of idle EAE later it's back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The permanent fix: a tmpfiles.d exclusion
&lt;/h2&gt;

&lt;p&gt;You don't need to disable /tmp cleaning, and you shouldn't. The &lt;code&gt;tmpfiles.d&lt;/code&gt; format has an exclusion type built for exactly this. Drop one file on the host running Plex:&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;# /etc/tmpfiles.d/plex-eae.conf&lt;/span&gt;
&lt;span class="c"&gt;# Exempt Plex's per-instance temp tree from age-based cleanup.&lt;/span&gt;
&lt;span class="c"&gt;# Lowercase 'x' ignores the path AND everything below it.&lt;/span&gt;
x /tmp/pms-&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then verify systemd actually picked it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-tmpfiles &lt;span class="nt"&gt;--cat-config&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A1&lt;/span&gt; plex-eae
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The character matters here, and it's an easy place to get burned. &lt;code&gt;tmpfiles.d&lt;/code&gt; defines two exclusion types that look almost identical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;x /tmp/pms-*&lt;/code&gt; excludes the matching paths and their entire contents from cleaning. This is what you want.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;X /tmp/pms-*&lt;/code&gt; excludes only the directory itself. Its contents still age out, which puts you right back in the same failure with a slightly different shape: the &lt;code&gt;pms-*&lt;/code&gt; directory survives but the &lt;code&gt;EasyAudioEncoder&lt;/code&gt; subtree inside it gets reaped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Globs are legal in exclusion rules and evaluated at cleaning time, so the rule keeps working even though the UUID in the directory name changes on every service restart.&lt;/p&gt;

&lt;p&gt;To prove the fix without waiting ten days, force a cleanup pass with an aggressive age and watch the directory survive:&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;# Dry-run style check: run the cleaner with a 1-second age threshold&lt;/span&gt;
&lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"30 days ago"&lt;/span&gt; /tmp/pms-&lt;span class="k"&gt;*&lt;/span&gt;/EasyAudioEncoder 2&amp;gt;/dev/null
systemd-tmpfiles &lt;span class="nt"&gt;--clean&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-ld&lt;/span&gt; /tmp/pms-&lt;span class="k"&gt;*&lt;/span&gt;/EasyAudioEncoder &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;"exclusion works"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the directory is still there after &lt;code&gt;--clean&lt;/code&gt; runs against a backdated mtime, the rule is live. This kind of "verify the fix actually engages" step is the same discipline I argued for in the &lt;a href="https://guatulabs.dev/posts/handling-longhorn-read-only-mounts-detection-recovery-and-preventing-silent-failures/" rel="noopener noreferrer"&gt;Longhorn read-only mounts post&lt;/a&gt;: a service that reports healthy while an underlying filesystem assumption has silently broken is the worst class of failure, because nothing pages you until a user complains.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alternative fix: move EAE out of /tmp entirely
&lt;/h2&gt;

&lt;p&gt;If you'd rather not maintain an exclusion rule, you can relocate Plex's temp tree to a path systemd-tmpfiles doesn't manage. EAE derives its working directory from the process environment, not from the "Transcoder temporary directory" setting in the Plex UI. That UI setting moves the video transcode segments, but the EAE watch folders follow &lt;code&gt;TMPDIR&lt;/code&gt;. So changing the setting in the web UI does not fix this problem, which is a trap plenty of people fall into before finding the real mechanism.&lt;/p&gt;

&lt;p&gt;A systemd drop-in handles it cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl edit plexmediaserver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;TMPDIR=/var/lib/plexmediaserver/tmp&lt;/span&gt;
&lt;span class="py"&gt;ExecStartPre&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/mkdir -p /var/lib/plexmediaserver/tmp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a &lt;code&gt;daemon-reload&lt;/code&gt; and restart, the &lt;code&gt;pms-*&lt;/code&gt; tree lands under a path with no age rule attached. The tradeoff is that nothing cleans it up anymore, so orphaned trees from unclean shutdowns accumulate until you notice. The &lt;code&gt;x&lt;/code&gt; exclusion in /tmp doesn't have that problem, because the directory still gets wiped on reboot like everything else in /tmp. That's the main reason I'd pick the exclusion rule over the relocation: it changes the minimum necessary behavior and leaves the rest of the /tmp lifecycle intact.&lt;/p&gt;

&lt;p&gt;There's a third option worth knowing about even if you don't use it: &lt;code&gt;PrivateTmp=true&lt;/code&gt; in the unit. Systemd's private tmp directories get mounted under &lt;code&gt;/tmp/systemd-private-*&lt;/code&gt;, and stock &lt;code&gt;tmp.conf&lt;/code&gt; ships exclusion rules for those paths, so they're immune to aging by design. Plex's packaged unit doesn't enable it, and flipping it on changes how the service sees paths shared with other processes, so I'd treat it as trivia rather than the recommended fix here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas and adjacent traps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Debian 13 flipped the default.&lt;/strong&gt; Debian historically did not age out /tmp contents on a running system, which is why this bite pattern was mostly a Fedora/Arch/RHEL story for years. Trixie moved /tmp to tmpfs and enabled the 10-day /tmp and 30-day /var/tmp cleanup rules by default. If your Plex LXC or VM got upgraded to Debian 13, you inherited this behavior with zero warning. The exact same Plex install that ran for two years without an EAE incident now has a ten-day fuse that resets only on service restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LXC makes the uptime trap worse.&lt;/strong&gt; Containers restart less often than you think. A Proxmox host reboot cycles every VM, but LXCs with long-running services rack up months of uptime effortlessly, and every one of those days is a day the tmpfiles timer inside the container keeps ticking. Long uptime feels like a stability trophy. For any service that stages state in /tmp at startup and never checks on it again, uptime is actually the risk factor. If you're running Plex inside an unprivileged LXC (a reasonable choice, and the general pattern is covered in &lt;a href="https://guatulabs.dev/posts/building-production-homelab/" rel="noopener noreferrer"&gt;Building a Production Homelab&lt;/a&gt;), put the &lt;code&gt;tmpfiles.d&lt;/code&gt; exclusion inside the container, since that's where the timer that does the reaping lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker installs behave differently.&lt;/strong&gt; A Plex container image doesn't run systemd inside it, so there's no tmpfiles timer to reap anything, and /tmp lives and dies with the container. This failure mode is specific to package installs on systemd hosts and systemd-based LXCs. If you migrated from Docker to a native package and suddenly hit this after a couple of weeks, that's why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't fix it by disabling the timer.&lt;/strong&gt; Masking &lt;code&gt;systemd-tmpfiles-clean.timer&lt;/code&gt; works, but now nothing ages out /var/tmp either, and every other well-behaved consumer of the aging policy loses it too. Scoped exclusions exist precisely so you don't have to make that trade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plex isn't the only service with this shape.&lt;/strong&gt; Anything that creates sockets, PID files, or working directories under /tmp at startup and holds a path reference for the life of the process is exposed. PostgreSQL's Unix socket default, some JVM apps with &lt;code&gt;java.io.tmpdir&lt;/code&gt; scratch state, and various daemons with startup-created lock directories all have variants of this. Distros ship tmpfiles exclusions for the common ones (X11 sockets, systemd's own private dirs). Third-party packages are on their own, and Plex's package doesn't ship one. Auditing long-running services for this dependency class is the kind of infrastructure-drift review I end up doing in &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;consulting engagements&lt;/a&gt; more often than any glamorous architecture work, because drift between OS policy and application assumptions is invisible until it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The application didn't break. The OS didn't malfunction. Two correct behaviors, Plex creating its EAE workspace once at startup and systemd-tmpfiles aging out idle /tmp entries, combined into a failure with a built-in delay timer. That's what makes it nasty: the cause and the symptom are separated by ten days and belong to two different layers of the stack.&lt;/p&gt;

&lt;p&gt;The fix is a one-line file, &lt;code&gt;x /tmp/pms-*&lt;/code&gt; in &lt;code&gt;/etc/tmpfiles.d/plex-eae.conf&lt;/code&gt;, verified with &lt;code&gt;systemd-tmpfiles --cat-config&lt;/code&gt; and a forced &lt;code&gt;--clean&lt;/code&gt; pass. Reach for this pattern any time a long-running service treats /tmp as if it were persistent, and be suspicious of any "it works after a restart" fix where nobody can explain what the restart actually repaired. On a systemd host, a startup-created /tmp directory plus long uptime is a scheduled outage; the only question is the date.&lt;/p&gt;

</description>
      <category>plex</category>
      <category>systemd</category>
      <category>tmpfiles</category>
      <category>transcoding</category>
    </item>
    <item>
      <title>Pushgateway Heartbeat Gotcha: When ndots and NetworkPolicy Silently Eat Your Alerts</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Sat, 22 Aug 2026 02:15:49 +0000</pubDate>
      <link>https://dev.to/futhgar/pushgateway-heartbeat-gotcha-when-ndots-and-networkpolicy-silently-eat-your-alerts-3908</link>
      <guid>https://dev.to/futhgar/pushgateway-heartbeat-gotcha-when-ndots-and-networkpolicy-silently-eat-your-alerts-3908</guid>
      <description>&lt;p&gt;A CronJob exits 0, Pushgateway shows a heartbeat metric that hasn't updated in days, and Prometheus fires nothing. Every dashboard is green while the one thing the heartbeat was supposed to guarantee (that you'd know when the job stops working) has quietly stopped being true.&lt;/p&gt;

&lt;p&gt;This is a gotcha about two Kubernetes features that are each fine on their own: the default &lt;code&gt;ndots:5&lt;/code&gt; DNS behavior and namespace-scoped NetworkPolicies. Put them in the same cluster with a wildcard DNS record and a push-based monitoring pattern, and you get a failure mode where the monitoring infrastructure itself is the victim, which means nothing tells you it happened.&lt;/p&gt;

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

&lt;p&gt;The heartbeat pattern for batch jobs is simple and well established. Prometheus can't scrape a pod that lived for forty seconds, so instead the job pushes a timestamp to Pushgateway when it finishes, Prometheus scrapes Pushgateway on its normal cycle, and an alert rule fires if the timestamp goes stale. A dead man's switch:&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;# Last line of the backup job's script&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"backup_last_success_timestamp &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @- &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://pushgateway.monitoring.svc.cluster.local:9091/metrics/job/nightly-backup"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BackupTooOld&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;time() - backup_last_success_timestamp &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;86400&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;15m&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;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the backup breaks, the timestamp stops advancing, the expression crosses the threshold, and I get paged. That's the theory. The name in the curl command is even the full service FQDN, so DNS should be unambiguous. It is not.&lt;/p&gt;

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

&lt;p&gt;Three independent failures stack here, and each one masks the next. That stacking is the whole gotcha; any single layer would have been a five-minute fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: ndots:5 turns your FQDN into a wildcard lookup
&lt;/h3&gt;

&lt;p&gt;A pod with the default &lt;code&gt;dnsPolicy: ClusterFirst&lt;/code&gt; gets a resolv.conf that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;search&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;-&lt;span class="n"&gt;jobs&lt;/span&gt;.&lt;span class="n"&gt;svc&lt;/span&gt;.&lt;span class="n"&gt;cluster&lt;/span&gt;.&lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;.&lt;span class="n"&gt;cluster&lt;/span&gt;.&lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="n"&gt;cluster&lt;/span&gt;.&lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="n"&gt;lab&lt;/span&gt;.&lt;span class="n"&gt;example&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
&lt;span class="n"&gt;nameserver&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;96&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;
&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="n"&gt;ndots&lt;/span&gt;:&lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the last search domain. The kubelet appends the node's own search domains after the cluster ones, so if your nodes carry an internal domain like &lt;code&gt;lab.example.com&lt;/code&gt;, every pod inherits it.&lt;/p&gt;

&lt;p&gt;Now count the dots in &lt;code&gt;pushgateway.monitoring.svc.cluster.local&lt;/code&gt;. Four. The &lt;code&gt;ndots:5&lt;/code&gt; option means any name with fewer than five dots is treated as relative, and the resolver walks the search list before trying the name as-is. So your "fully qualified" name generates these queries, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;pushgateway.monitoring.svc.cluster.local.ai-jobs.svc.cluster.local&lt;/code&gt; → NXDOMAIN&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pushgateway.monitoring.svc.cluster.local.svc.cluster.local&lt;/code&gt; → NXDOMAIN&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pushgateway.monitoring.svc.cluster.local.cluster.local&lt;/code&gt; → NXDOMAIN&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pushgateway.monitoring.svc.cluster.local.lab.example.com&lt;/code&gt; → &lt;strong&gt;an answer&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is the trap. If your internal DNS has a wildcard record (&lt;code&gt;*.lab.example.com&lt;/code&gt; pointing at your ingress controller's LoadBalancer VIP, a common setup for homelab TLS and per-app hostnames), that query matches the wildcard and returns the ingress VIP. The resolver got an answer, so it stops. The actual absolute name, the one that resolves to the real Pushgateway ClusterIP, never gets queried.&lt;/p&gt;

&lt;p&gt;Your heartbeat is now aimed at Traefik on port 9091. I've written about this exact mechanism corrupting TLS validation in &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 + ndots:5: The TLS Nightmare&lt;/a&gt;; the Pushgateway variant is nastier because the failure has no user-facing symptom at all.&lt;/p&gt;

&lt;p&gt;The wildcard also changes the &lt;em&gt;character&lt;/em&gt; of the failure. Without it, step 4 would return NXDOMAIN, the resolver would fall through to the absolute name, and everything would work (slower, with three wasted queries, but working). The wildcard converts a harmless inefficiency into a wrong answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: NetworkPolicy makes the wrong answer unreachable, quietly
&lt;/h3&gt;

&lt;p&gt;The monitoring namespace runs default-deny ingress, the pattern from &lt;a href="https://guatulabs.dev/posts/network-policies-with-calico-default-deny-and-namespace-isolation/" rel="noopener noreferrer"&gt;Network Policies with Calico: Default Deny and Namespace Isolation&lt;/a&gt;. Pushgateway has an allow rule for Prometheus scrapes and for the namespaces that existed when the policy was written.&lt;/p&gt;

&lt;p&gt;Two things go wrong at this layer. First, the connection isn't even headed to Pushgateway anymore; it's headed to a LoadBalancer VIP that sits outside the pod and service CIDRs. If the workload namespace has an egress policy scoped to in-cluster traffic, that packet gets dropped on the way out. Second, even after you fix DNS, a &lt;em&gt;new&lt;/em&gt; namespace (say you just added an &lt;code&gt;ai-jobs&lt;/code&gt; namespace for LLM batch work) isn't in Pushgateway's ingress allow-list, so its pushes get dropped on arrival instead.&lt;/p&gt;

&lt;p&gt;Either way, the drop is silent by design. NetworkPolicy denies don't send RST packets or ICMP errors with most CNI configurations. The curl just hangs until its TCP timeout, which on a default curl with no &lt;code&gt;--max-time&lt;/code&gt; can be over two minutes. The job runtime gets longer and nobody notices, because who watches the runtime of a job that's succeeding?&lt;/p&gt;

&lt;p&gt;This is the maintenance-burden face of NetworkPolicy that doesn't show up in tutorials: an allow-list written in March is wrong by August, because namespaces get added and every new one silently falls outside the rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: both the script and the alert fail open
&lt;/h3&gt;

&lt;p&gt;Here's where it becomes invisible. The push was the last line of the script, and it looked like this in the failure 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;-s&lt;/span&gt; &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @- &lt;span class="s2"&gt;"http://..."&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;|| true&lt;/code&gt; was there so a flaky Pushgateway wouldn't fail an otherwise-good backup. Reasonable instinct, terrible outcome: the curl times out, the job still exits 0, and Kubernetes reports the CronJob as succeeding.&lt;/p&gt;

&lt;p&gt;The alert rule fails open too, and this part catches almost everyone. &lt;code&gt;time() - backup_last_success_timestamp &amp;gt; 86400&lt;/code&gt; only evaluates when the series exists. Pushgateway restarted at some point (it stores pushed metrics in memory unless you enable persistence), the series vanished, and the expression started returning an empty result. Empty isn't "greater than 86400." Empty is nothing. No data, no alert.&lt;/p&gt;

&lt;p&gt;So: the DNS layer sends the heartbeat to the wrong place, the network layer drops it without an error, the script layer hides the failure from Kubernetes, and the alerting layer can't fire on a metric that doesn't exist. Four layers, zero signals.&lt;/p&gt;

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

&lt;p&gt;Work from the inside out: make the failure loud first, then fix the path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Make the push failure fail the job.&lt;/strong&gt; Drop the &lt;code&gt;|| true&lt;/code&gt;, add &lt;code&gt;-f&lt;/code&gt; so HTTP errors count, and bound the timeout so a network drop fails in seconds instead of minutes:&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;echo&lt;/span&gt; &lt;span class="s2"&gt;"backup_last_success_timestamp &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 &lt;span class="nt"&gt;--retry&lt;/span&gt; 2 &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @- &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://pushgateway.monitoring.svc.cluster.local.:9091/metrics/job/nightly-backup"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the trailing dot on the hostname. That marks the name as absolute and bypasses the search list entirely, which makes this one command immune to the ndots problem regardless of pod configuration. It's the cheapest fix in this whole post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Fix ndots at the pod level.&lt;/strong&gt; The trailing dot fixes one command; &lt;code&gt;dnsConfig&lt;/code&gt; fixes every lookup the pod makes:&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;batch/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;CronJob&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;jobTemplate&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;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;dnsConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;options&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;ndots&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;2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;ndots:2&lt;/code&gt;, any name with two or more dots gets tried as an absolute name first. &lt;code&gt;pushgateway.monitoring.svc.cluster.local&lt;/code&gt; resolves on the first query, and even the short form &lt;code&gt;pushgateway.monitoring&lt;/code&gt; still works because the search list is consulted after the absolute attempt fails. You lose nothing for cluster-internal names and you stop feeding four-dot FQDNs to your wildcard record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Expand the NetworkPolicy with labels, not names.&lt;/strong&gt; Enumerating source namespaces by name is what made the policy rot when a new namespace arrived. Select on a label instead:&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-pushgateway-clients&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;monitoring&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;pushgateway&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;Ingress&lt;/span&gt;&lt;span class="pi"&gt;]&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;pushgateway-client&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="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;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9091&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then onboarding a new batch namespace is one label away: &lt;code&gt;kubectl label namespace ai-jobs pushgateway-client=true&lt;/code&gt;. The policy stops being a file you have to remember to edit and becomes a contract the namespace opts into. Pair it with a Kyverno rule that requires the label on namespaces containing CronJobs if you want it enforced rather than remembered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Alert on absence, not just staleness.&lt;/strong&gt; The dead man's switch needs to fire when the series is missing, because "missing" is exactly what the failure mode produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BackupHeartbeatMissing&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;(time() - backup_last_success_timestamp &amp;gt; 86400)&lt;/span&gt;
    &lt;span class="s"&gt;or&lt;/span&gt;
    &lt;span class="s"&gt;absent(backup_last_success_timestamp)&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;15m&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;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;absent()&lt;/code&gt; function returns a value of 1 when the series doesn't exist, and the &lt;code&gt;or&lt;/code&gt; makes the rule cover both stale and gone. One caveat: &lt;code&gt;absent()&lt;/code&gt; collapses labels, so if you push heartbeats for several jobs under one metric name with a &lt;code&gt;job&lt;/code&gt; label, you need one &lt;code&gt;absent()&lt;/code&gt; clause per job (or a recording-rule pattern). Tedious, but the alternative is the exact blind spot this post is about. The reasoning behind alerting on the negative space is the same one I laid out 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;: an alert that can't fire when the pipeline feeding it dies isn't an alert, it's decoration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Verify the path from inside the namespace.&lt;/strong&gt; Don't trust the fix until you've watched the resolution happen where the workload runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl run &lt;span class="nt"&gt;-n&lt;/span&gt; ai-jobs dbg &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  getent hosts pushgateway.monitoring.svc.cluster.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns your ingress VIP instead of a ClusterIP, the wildcard is still winning. Follow with a &lt;code&gt;curl -v --max-time 5&lt;/code&gt; to port 9091 to confirm the NetworkPolicy actually passes traffic, because DNS being right says nothing about the packet getting through.&lt;/p&gt;

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

&lt;p&gt;You'll hit this if your cluster combines three things that are each individually recommended: a wildcard DNS record for ingress convenience, default-deny NetworkPolicies, and push-based heartbeats for batch workloads. Plenty of production clusters and most serious homelabs check all three boxes. The layers interact in a way none of their docs mention, and the interaction specifically targets the observability path, so the usual answer ("the alert will catch it") doesn't apply. The alert is the thing that's broken.&lt;/p&gt;

&lt;p&gt;The deeper lesson is about failure direction. Every layer in this chain failed &lt;em&gt;open&lt;/em&gt;: the wildcard answered instead of erroring, the policy dropped instead of rejecting, the script swallowed the exit code, the alert evaluated to empty. Whenever you build a monitoring path, walk it backwards and ask what each hop does when its input disappears. Anything that goes quiet instead of loud needs an &lt;code&gt;absent()&lt;/code&gt;, a &lt;code&gt;-f&lt;/code&gt;, or a timeout wrapped around it. This applies well beyond Pushgateway; the same audit caught a gap in how I verify vector database backups, which I covered in &lt;a href="https://guatulabs.dev/posts/backing-up-qdrant-snapshots-correctly-from-emptydir-to-nfs-persistent-backups/" rel="noopener noreferrer"&gt;Backing Up Qdrant Snapshots Correctly&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Cross-layer failures like this one, where DNS, network policy, and alerting each look correct in isolation, are the most expensive class of problem in infrastructure work, and they're a big part of the reliability engineering &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;I consult on&lt;/a&gt;. The fix is rarely clever. It's knowing that the layers talk to each other, and testing the seams instead of the components.&lt;/p&gt;

&lt;p&gt;What I'd tell you to do today, in order: add the trailing dot to your push URLs, add &lt;code&gt;absent()&lt;/code&gt; to every dead man's switch you own, and run &lt;code&gt;getent hosts&lt;/code&gt; for your internal service names from inside a workload namespace. The first two take five minutes. The third will either confirm you're fine or save you from finding out the hard way that your heartbeats have been landing in Traefik's 404 handler.&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>pushgateway</category>
      <category>dns</category>
      <category>networkpolicies</category>
    </item>
    <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>
  </channel>
</rss>
