<?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: Ankit Verma</title>
    <description>The latest articles on DEV Community by Ankit Verma (@dockndevai).</description>
    <link>https://dev.to/dockndevai</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%2F4090691%2F3aebacd1-e265-44ab-b11a-572397751831.png</url>
      <title>DEV Community: Ankit Verma</title>
      <link>https://dev.to/dockndevai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dockndevai"/>
    <language>en</language>
    <item>
      <title>A question on Discord found two bugs in my RAG system. Fixing them found a third.</title>
      <dc:creator>Ankit Verma</dc:creator>
      <pubDate>Sun, 13 Sep 2026 14:19:05 +0000</pubDate>
      <link>https://dev.to/dockndevai/a-question-on-discord-found-two-bugs-in-my-rag-system-fixing-them-found-a-third-58m6</link>
      <guid>https://dev.to/dockndevai/a-question-on-discord-found-two-bugs-in-my-rag-system-fixing-them-found-a-third-58m6</guid>
      <description>&lt;p&gt;&lt;em&gt;Every test passed. The tests had the same blind spots as the code.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fatx7vglbv89ho4qpq2li.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fatx7vglbv89ho4qpq2li.png" alt="Three cards: one source cited three times, memory aged from the wrong moment, and a 500 that happened once, under a line reading all tests passing" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;A developer saw Ossian's MCP server in a community showcase and asked two questions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do you handle cases where retrieved docs and stored memories conflict? Or when multiple&lt;br&gt;
pieces of context ultimately come from the same underlying source?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had a confident answer to both. Documents and agent memory live in separate tables, are reached&lt;br&gt;
through separate tools, and never meet in a prompt. Duplicates are caught at ingest by content hash.&lt;br&gt;
Done.&lt;/p&gt;

&lt;p&gt;Before replying I read the code to make sure the answer was true. It was half true, and the half&lt;br&gt;
that wasn't is the interesting part.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug one: three passages looked like three sources
&lt;/h2&gt;

&lt;p&gt;Retrieval returns chunks, not documents. Ossian took the top six and numbered them for the model,&lt;br&gt;
so a prompt could look 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;[1] engineering-handbook.txt  …
[2] engineering-handbook.txt  …
[3] engineering-handbook.txt  …
[4] platform-architecture.md  …
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To the model — and to the person reading the citations — that is three sources agreeing and one&lt;br&gt;
dissenting. It is one source said three times. The system prompt even tells the model &lt;em&gt;"if the&lt;br&gt;
context conflicts with itself, say which sources disagree"&lt;/em&gt;, and there was no way for it to tell one&lt;br&gt;
source from several.&lt;/p&gt;

&lt;p&gt;Ingest-time deduplication does nothing here. These are distinct chunks of one legitimate document.&lt;/p&gt;

&lt;p&gt;The fix groups chunks by document id before the prompt is built: one number per document, every&lt;br&gt;
passage kept under it, the document ranked by its best chunk. Two files that merely share a filename&lt;br&gt;
stay separate, because the grouping is on id. On a live question, six retrieved chunks now become&lt;br&gt;
five citations, with one runbook contributing two passages under a single number.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug two: memory decayed from the wrong moment
&lt;/h2&gt;

&lt;p&gt;Agent memory is ranked &lt;code&gt;similarity × importance × 0.5^(age / 30 days)&lt;/code&gt;. Recency matters for memory in&lt;br&gt;
a way it never does for documents: a runbook from three years ago is as true as one from today, a&lt;br&gt;
stated preference from three years ago is not.&lt;/p&gt;

&lt;p&gt;The question is what "age" means. The query measured it from &lt;code&gt;created_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An agent that restates a fact — "the user still prefers British English" — hits a deduplicating&lt;br&gt;
upsert, which updates &lt;code&gt;updated_at&lt;/code&gt; and nothing the ranking reads. A preference confirmed every day&lt;br&gt;
for three months decayed exactly as if it had been said once, three months ago.&lt;/p&gt;

&lt;p&gt;The obvious fix is wrong. Recall already records &lt;code&gt;last_used_at&lt;/code&gt;, and a comment on it claimed that&lt;br&gt;
recording use &lt;em&gt;"keeps a live memory from decaying away."&lt;/em&gt; It didn't — and it shouldn't. Recall&lt;br&gt;
returns everything that matches, so an old preference and the newer one contradicting it are&lt;br&gt;
recalled &lt;em&gt;together&lt;/em&gt;. Refresh both on read and they tie on recency, which is the one signal that&lt;br&gt;
lets the newer one win.&lt;/p&gt;

&lt;p&gt;So age now runs from the last time something was &lt;strong&gt;said&lt;/strong&gt;, not the last time it was &lt;strong&gt;read&lt;/strong&gt;.&lt;br&gt;
Measured against the running system, after three recalls of both:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Memory&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"switched the editor to the light theme" (fresh)&lt;/td&gt;
&lt;td&gt;0.765&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"prefers the dark theme" (90 days old)&lt;/td&gt;
&lt;td&gt;0.102&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;…then the dark-theme preference is restated&lt;/td&gt;
&lt;td&gt;0.817&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The test suite had a recency test, and it passed the whole time. It backdated &lt;code&gt;created_at&lt;/code&gt; — the&lt;br&gt;
same column the bug read. The test and the code shared an assumption, so the test could only ever&lt;br&gt;
confirm it. The new test fails against the old query; I checked by putting the old line back.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug three: found by building the next thing
&lt;/h2&gt;

&lt;p&gt;With those fixed, I built what I'd meant to build anyway: a Kafka Connect sink, so a table that&lt;br&gt;
Debezium streams into Kafka becomes a corpus that follows the database.&lt;/p&gt;

&lt;p&gt;Its first end-to-end run showed a batch of changes failing with HTTP 500, twice, then succeeding on&lt;br&gt;
the third attempt. The sink's backoff did its job. The backend's log said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;insert or update on table "ingest_events" violates foreign key constraint
Key (document_id)=(…) is not present in table "documents".
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;DELETE&lt;/code&gt; event removed the document, then recorded the event row pointing at the id it had just&lt;br&gt;
deleted. The foreign key rejected it. Because the batch loop didn't catch it, every event in the&lt;br&gt;
batch failed with it.&lt;/p&gt;

&lt;p&gt;And it only happened once per document. The retry found nothing to delete, recorded a null id, and&lt;br&gt;
succeeded. Any pipeline that retries — which is every pipeline worth running — hid it completely.&lt;br&gt;
There were no tests for the event API at all, so nothing else was going to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the sink
&lt;/h2&gt;

&lt;p&gt;A few decisions that are easy to get wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The event id comes from Kafka, not from Debezium.&lt;/strong&gt; Ossian's event API is idempotent on a&lt;br&gt;
caller-supplied id, so the sink needs one that is stable under redelivery. Debezium's source position&lt;br&gt;
looks ideal and isn't: every row of an initial snapshot shares one LSN, so two different rows would&lt;br&gt;
collapse into one id and the second would be silently discarded as a duplicate. The sink uses the&lt;br&gt;
connector name, topic, partition and offset, plus the record timestamp to survive a topic being&lt;br&gt;
recreated with offsets starting from zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A blanked row is removed, not skipped.&lt;/strong&gt; If an update empties every text column, leaving the old&lt;br&gt;
document in place means the corpus keeps answering from text the source no longer has. If &lt;em&gt;none&lt;/em&gt; of&lt;br&gt;
the configured columns exist on the record, though, it's rejected as a misconfiguration — otherwise a&lt;br&gt;
typo in &lt;code&gt;ossian.text.fields&lt;/code&gt; deletes the whole table from the corpus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Placeholders are refused.&lt;/strong&gt; With Postgres' default replica identity, an update that doesn't touch&lt;br&gt;
a large column sends Debezium's &lt;code&gt;__debezium_unavailable_value&lt;/code&gt; instead of the text. Indexing that&lt;br&gt;
would replace a real article with a sentinel string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offsets never move ahead of delivery.&lt;/strong&gt; &lt;code&gt;put()&lt;/code&gt; is synchronous. Rate limits and 5xx back off using&lt;br&gt;
&lt;code&gt;Retry-After&lt;/code&gt; and retry; a record Ossian rejects goes to the dead-letter queue while the rest keep&lt;br&gt;
flowing; a 401 stops the task, so a bad key can't drain an entire topic into the DLQ.&lt;/p&gt;

&lt;p&gt;End to end against a Postgres table: a snapshot of three rows became three documents answerable with&lt;br&gt;
citations; an update from 180 to 90 days changed the answer and left no old chunk still saying 180;&lt;br&gt;
a delete removed the document and its chunks; a blanked row disappeared; and resetting the sink's&lt;br&gt;
offsets replayed the whole topic — 18 events before, 18 after, no new documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still open
&lt;/h2&gt;

&lt;p&gt;The original question isn't fully answered, and I said so in the reply.&lt;/p&gt;

&lt;p&gt;Memories have no link back to the document they were learned from, so a memory that goes stale when&lt;br&gt;
its source document changes is never flagged. And deduplication is exact: "prefers dark mode" and&lt;br&gt;
"likes dark mode" both persist, and a contradicting memory doesn't supersede the older one — both come&lt;br&gt;
back, and recency decides. Keeping documents and memory apart is staying. The rest is an&lt;br&gt;
&lt;a href="https://github.com/dockndevai/ossian/issues/3" rel="noopener noreferrer"&gt;open design discussion&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson I keep relearning
&lt;/h2&gt;

&lt;p&gt;None of these three were exotic. Each survived because something &lt;em&gt;around&lt;/em&gt; it agreed with it: a test&lt;br&gt;
that backdated the same column the query read, a citation format that looked right in every&lt;br&gt;
screenshot, a retry loop that turned a deterministic failure into a transient one.&lt;/p&gt;

&lt;p&gt;The fastest way I know to find that kind of bug is to explain the system to someone who asks a&lt;br&gt;
precise question — and check the code before you hit send.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ossian: &lt;a href="https://dockndevai.github.io/ossian-site/" rel="noopener noreferrer"&gt;dockndevai.github.io/ossian-site&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://github.com/dockndevai/ossian" rel="noopener noreferrer"&gt;github.com/dockndevai/ossian&lt;/a&gt; ·&lt;br&gt;
Kafka Connect sink: &lt;a href="https://github.com/dockndevai/ossian-kafka-connect" rel="noopener noreferrer"&gt;github.com/dockndevai/ossian-kafka-connect&lt;/a&gt; ·&lt;br&gt;
Issues: &lt;a href="https://github.com/dockndevai/ossian/issues/1" rel="noopener noreferrer"&gt;#1&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://github.com/dockndevai/ossian/issues/2" rel="noopener noreferrer"&gt;#2&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://github.com/dockndevai/ossian/issues/5" rel="noopener noreferrer"&gt;#5&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dockndev.medium.com/a-question-on-discord-found-two-bugs-in-my-rag-system-fixing-them-found-a-third-03a5cd6237d9" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with Claude (Anthropic), working in the codebase it describes; every number and code sample&lt;br&gt;
was verified against the running system. Reviewed and published by&lt;br&gt;
&lt;a href="https://github.com/dockndevai" rel="noopener noreferrer"&gt;@dockndevai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>kafka</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Filter Ordering Is the Whole Game: Building an LLM Gateway on Spring Cloud Gateway</title>
      <dc:creator>Ankit Verma</dc:creator>
      <pubDate>Fri, 28 Aug 2026 12:15:00 +0000</pubDate>
      <link>https://dev.to/dockndevai/filter-ordering-is-the-whole-game-building-an-llm-gateway-on-spring-cloud-gateway-52p3</link>
      <guid>https://dev.to/dockndevai/filter-ordering-is-the-whole-game-building-an-llm-gateway-on-spring-cloud-gateway-52p3</guid>
      <description>&lt;p&gt;Every team that runs more than one self-hosted model eventually builds the same thing. Someone&lt;br&gt;
stands up vLLM for a 70B model, someone else runs Ollama for the small stuff, and within a month&lt;br&gt;
you need to answer questions nobody asked at the start: &lt;em&gt;which team burned 40 million tokens last&lt;br&gt;
week?&lt;/em&gt; &lt;em&gt;Can we stop the analytics service from calling the expensive model?&lt;/em&gt; &lt;em&gt;What happens when&lt;br&gt;
vLLM falls over mid-sprint?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Spring Cloud Gateway already solves routing, retries and circuit breaking. What it doesn't know is&lt;br&gt;
that the interesting part of an LLM request is &lt;em&gt;inside the JSON body&lt;/em&gt;, and that the interesting&lt;br&gt;
part of the response is a &lt;code&gt;usage&lt;/code&gt; object that arrives last. So I built a starter that adds exactly&lt;br&gt;
that  -  virtual keys, token quotas, usage metering and failover  -  as a library you drop into an&lt;br&gt;
existing gateway.&lt;/p&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/dockndevai/spring-llm-gateway" rel="noopener noreferrer"&gt;dockndevai/spring-llm-gateway&lt;/a&gt;.&lt;br&gt;
This post is about the parts that were harder than they looked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8sw3n667gza4w7v8rf9t.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8sw3n667gza4w7v8rf9t.gif" alt="One endpoint routing to local Ollama and an NVIDIA GPU cloud, with virtual keys enforced" width="799" height="481"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Counting requests is the wrong unit
&lt;/h2&gt;

&lt;p&gt;The stock &lt;code&gt;RequestRateLimiter&lt;/code&gt; counts requests. For LLM traffic that number means almost nothing  - &lt;br&gt;
one request can be worth four tokens or forty thousand. A per-minute &lt;em&gt;token&lt;/em&gt; budget is the only&lt;br&gt;
limit that maps to load and spend.&lt;/p&gt;

&lt;p&gt;That creates an awkward problem: you don't know what a request costs until it's finished. So the&lt;br&gt;
quota filter reserves an estimate up front  - &lt;br&gt;
&lt;code&gt;prompt_chars / chars_per_token + max_tokens&lt;/code&gt;  -  and reconciles afterwards against the real usage&lt;br&gt;
scraped off the response. Over-reservations get refunded, under-reservations charged.&lt;/p&gt;

&lt;p&gt;Reserving up front is the part that matters. If you only charged after the fact, a burst of&lt;br&gt;
concurrent requests would all pass a check that each of them individually would fail. You can watch&lt;br&gt;
the reconciliation work in the response headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;req 1: HTTP 200  remaining=138
req 2: HTTP 200  remaining=98
req 3: HTTP 200  remaining=52
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each request reserves 62 and actually uses ~40, so ~22 comes back before the next one is measured.&lt;br&gt;
The numbers only add up because the refund happened.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reading the response without consuming it
&lt;/h2&gt;

&lt;p&gt;Metering needs the &lt;code&gt;usage&lt;/code&gt; object, which means reading the response body  -  without breaking&lt;br&gt;
streaming for the client.&lt;/p&gt;

&lt;p&gt;Two things make this tractable. First, &lt;code&gt;DataBuffer.toString(Charset)&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; advance the read&lt;br&gt;
position, so you can observe a chunk and still pass it downstream untouched. Second, &lt;code&gt;usage&lt;/code&gt; is&lt;br&gt;
last in both response shapes: a top-level field on a non-streamed response, and the final &lt;code&gt;data:&lt;/code&gt;&lt;br&gt;
frame of an SSE stream. So you never buffer the whole body  -  just a bounded 8 KB rolling tail.&lt;/p&gt;

&lt;p&gt;The parsing has one trap. The obvious regex is &lt;code&gt;"usage"\s*:\s*\{[^{}]*\}&lt;/code&gt;, and it works right up&lt;br&gt;
until it doesn't: current vLLM and OpenAI builds nest &lt;code&gt;prompt_tokens_details&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;completion_tokens_details&lt;/code&gt; &lt;em&gt;inside&lt;/em&gt; the usage object, and a flat character class can't match&lt;br&gt;
across nested braces. I ended up with a brace-balanced scan that walks backwards from the last&lt;br&gt;
&lt;code&gt;"usage"&lt;/code&gt; and skips anything truncated or &lt;code&gt;null&lt;/code&gt;  -  which also handles the intermediate stream&lt;br&gt;
frames, where &lt;code&gt;"usage":null&lt;/code&gt; appears on every chunk.&lt;/p&gt;

&lt;p&gt;There's one more thing you have to do, and it's easy to miss: &lt;strong&gt;vLLM never sends a usage block for a&lt;br&gt;
stream unless you ask for it.&lt;/strong&gt; If the request doesn't set &lt;code&gt;stream_options.include_usage&lt;/code&gt;, every&lt;br&gt;
streamed request meters as zero tokens. The gateway injects it when absent  -  and then has to fix&lt;br&gt;
&lt;code&gt;Content-Length&lt;/code&gt; on the request decorator, because the body just got longer.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ordering is the actual design
&lt;/h2&gt;

&lt;p&gt;This is where most of the real work went. Spring Cloud Gateway assembles the filter chain &lt;em&gt;after&lt;/em&gt;&lt;br&gt;
route selection, and the orders interact in ways that are invisible until something silently&lt;br&gt;
returns zero.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Order&lt;/th&gt;
&lt;th&gt;Filter&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MIN_VALUE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RemoveCachedBodyFilter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MIN_VALUE + 1000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AdaptCachedBodyGlobalFilter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MIN_VALUE + 1500&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;body parsing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;mine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;metering&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;mine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NettyWriteResponseFilter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1, 2, 3...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;route filters  -  auth, quota, circuit breaker&lt;/td&gt;
&lt;td&gt;by list position&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOWEST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NettyRoutingFilter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stock&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three constraints hold it together:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Body parsing must run after &lt;code&gt;AdaptCachedBodyGlobalFilter&lt;/code&gt;.&lt;/strong&gt; The route predicate that selects on&lt;br&gt;
&lt;code&gt;model&lt;/code&gt; has to read the body during route selection. That consumes it. &lt;code&gt;AdaptCachedBodyGlobalFilter&lt;/code&gt;&lt;br&gt;
is what swaps the cached bytes back onto the exchange  -  parse any earlier and you read an empty&lt;br&gt;
body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metering must run before &lt;code&gt;NettyWriteResponseFilter&lt;/code&gt;&lt;/strong&gt; (order &amp;lt; -1). That filter captures its own&lt;br&gt;
&lt;code&gt;exchange.getResponse()&lt;/code&gt; reference before writing. Install a response decorator later in the chain&lt;br&gt;
and it is simply never written into. No error, no warning  -  every request just meters as zero&lt;br&gt;
tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route filters are ordered 1, 2, 3... by their position in the YAML list&lt;/strong&gt;, which puts them after&lt;br&gt;
both global filters. That's what lets auth and quota rely on the parsed body already being there.&lt;/p&gt;

&lt;p&gt;I nearly got this wrong in an interesting way. I started to force explicit orders on the auth and&lt;br&gt;
quota filters to guarantee auth ran first  -  then found that default filters and route filters are&lt;br&gt;
index-ordered into a &lt;em&gt;single stably sorted list&lt;/em&gt;. With &lt;code&gt;LlmAuth&lt;/code&gt;/&lt;code&gt;LlmQuota&lt;/code&gt; in &lt;code&gt;default-filters&lt;/code&gt;&lt;br&gt;
and a route-level &lt;code&gt;CircuitBreaker&lt;/code&gt;, forcing orders interleaves them as auth -&amp;gt; breaker -&amp;gt; quota.&lt;br&gt;
Keeping the natural index ordering and documenting it was the right call.&lt;/p&gt;

&lt;p&gt;One more: the rewritten request's &lt;code&gt;getBody()&lt;/code&gt; has to be re-subscribable. A &lt;code&gt;DataBuffer&lt;/code&gt; can only be&lt;br&gt;
read once, and both retries and the circuit breaker re-subscribe. &lt;code&gt;Flux.defer&lt;/code&gt; wrapping a freshly&lt;br&gt;
wrapped byte array on every subscription.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two reactive traps that produce no error
&lt;/h2&gt;

&lt;p&gt;These both cost me real time, and neither throws anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Mono&amp;lt;Void&amp;gt;&lt;/code&gt; and &lt;code&gt;switchIfEmpty&lt;/code&gt;.&lt;/strong&gt; The auth filter looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;principalResolver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flatMap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;authorize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;switchIfEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defer&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;unauthorized&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;  &lt;span class="c1"&gt;// wrong&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reads fine. It's broken. &lt;code&gt;chain.filter()&lt;/code&gt; returns &lt;code&gt;Mono&amp;lt;Void&amp;gt;&lt;/code&gt;, which &lt;em&gt;always&lt;/em&gt; completes empty  -  so&lt;br&gt;
&lt;code&gt;switchIfEmpty&lt;/code&gt; fired on every &lt;strong&gt;successful&lt;/strong&gt; request. The upstream was called and then a 401 was&lt;br&gt;
written over the top of it. The tell was a log line showing the stub upstream had received the&lt;br&gt;
request while the client got a 401. Fold the &lt;code&gt;Optional&lt;/code&gt; explicitly instead; don't put&lt;br&gt;
&lt;code&gt;switchIfEmpty&lt;/code&gt; downstream of anything returning &lt;code&gt;Mono&amp;lt;Void&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;exchangeToMono&lt;/code&gt; releases the connection when its Mono terminates.&lt;/strong&gt; The failover handler fetched&lt;br&gt;
the secondary upstream's response and handed the body to a &lt;code&gt;ServerResponse&lt;/code&gt; to be written later.&lt;br&gt;
Status 200, correct headers, empty body  -  because the &lt;code&gt;ServerResponse&lt;/code&gt; Mono completed immediately,&lt;br&gt;
WebClient released the connection, and &lt;em&gt;then&lt;/em&gt; something tried to read the body. For a streaming&lt;br&gt;
proxy you want a plain &lt;code&gt;WebHandler&lt;/code&gt; that writes straight to the exchange inside the callback, so the&lt;br&gt;
connection stays alive exactly as long as the body is streaming.&lt;/p&gt;

&lt;h2&gt;
  
  
  What only showed up when I ran it
&lt;/h2&gt;

&lt;p&gt;The build was green and the feature list was complete. Then I ran it against a real Ollama on a&lt;br&gt;
laptop, and four things fell out immediately.&lt;/p&gt;

&lt;p&gt;The worst: &lt;strong&gt;&lt;code&gt;auth.mode=jwt&lt;/code&gt; wouldn't start at all.&lt;/strong&gt; The Keycloak resolver bean was guarded by&lt;br&gt;
&lt;code&gt;@ConditionalOnBean(ReactiveJwtDecoder.class)&lt;/code&gt;  -  but my auto-configuration wasn't ordered after&lt;br&gt;
&lt;code&gt;ReactiveOAuth2ResourceServerAutoConfiguration&lt;/code&gt;, so the condition was evaluated before that decoder&lt;br&gt;
existed and backed the bean off entirely. The application then died complaining about a missing&lt;br&gt;
&lt;code&gt;PrincipalResolver&lt;/code&gt;, which points nowhere near the actual cause.&lt;/p&gt;

&lt;p&gt;My tests missed it because I'd unit-tested the resolver class directly and never booted the&lt;br&gt;
application in that mode. That's the lesson worth keeping: &lt;strong&gt;&lt;code&gt;@ConditionalOnBean&lt;/code&gt; on a bean from&lt;br&gt;
another auto-configuration is an ordering bug waiting to happen&lt;/strong&gt;, and testing a class is not&lt;br&gt;
testing its wiring. The fix was &lt;code&gt;afterName&lt;/code&gt; ordering plus dropping the condition so a genuinely&lt;br&gt;
missing decoder names &lt;em&gt;itself&lt;/em&gt; in the error. I wrote the integration test first and confirmed it&lt;br&gt;
failed against the old wiring before fixing it.&lt;/p&gt;

&lt;p&gt;The other three were smaller but all real: the README's own quick-start command didn't work&lt;br&gt;
(&lt;code&gt;-am&lt;/code&gt; on &lt;code&gt;spring-boot:run&lt;/code&gt; drags the parent POM into the reactor  -  "Unable to find a suitable main&lt;br&gt;
class"); &lt;code&gt;ollama: {}&lt;/code&gt; in YAML binds to &lt;em&gt;nothing&lt;/em&gt;, because Spring's binder needs at least one leaf&lt;br&gt;
property to create a map entry; and my failover tests used &lt;code&gt;localhost:1&lt;/code&gt; as the "dead" port, which&lt;br&gt;
on macOS burns six DNS queries and ~10 seconds before failing instead of refusing. With a properly&lt;br&gt;
closed port it refuses in 50ms, and the test got 4x faster.&lt;/p&gt;

&lt;p&gt;That last one produced a false alarm I had to walk back. I'd measured failover at 15 seconds and&lt;br&gt;
started writing it up as a performance problem. It was entirely an artifact of the port I'd picked&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; real failover is 0.23 seconds end to end, including the model generating a reply.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Worth knowing if you build on SCG 4.3
&lt;/h2&gt;

&lt;p&gt;A few things that cost me time and aren't well signposted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The config prefix moved. It's &lt;code&gt;spring.cloud.gateway.server.webflux.*&lt;/code&gt; now, and the starter is
&lt;code&gt;spring-cloud-starter-gateway-server-webflux&lt;/code&gt;. The old &lt;code&gt;spring-cloud-starter-gateway&lt;/code&gt; is a
deprecated alias.&lt;/li&gt;
&lt;li&gt;Model ids contain dots, so config map keys need bracket notation:
&lt;code&gt;models."[llama-3.1-70b-instruct]"&lt;/code&gt;. The dotted form binds as nested properties and silently
never matches.&lt;/li&gt;
&lt;li&gt;Multi-argument filters need the expanded &lt;code&gt;args&lt;/code&gt; form. &lt;code&gt;CircuitBreaker=name=x,fallbackUri=y&lt;/code&gt; binds
only the &lt;em&gt;first&lt;/em&gt; positional argument and drops &lt;code&gt;fallbackUri&lt;/code&gt; without a word of complaint. I lost a
while to a fallback that was never being invoked.&lt;/li&gt;
&lt;li&gt;Raise resilience4j's &lt;code&gt;TimeLimiter&lt;/code&gt;. The 1-second default will trip on healthy generation traffic.&lt;/li&gt;
&lt;li&gt;Building on JDK 23+? Annotation processors on the classpath are off by default, so
&lt;code&gt;spring-boot-configuration-processor&lt;/code&gt; silently produces no metadata and your IDE completion
quietly stops working. &lt;code&gt;&amp;lt;proc&amp;gt;full&amp;lt;/proc&amp;gt;&lt;/code&gt; brings it back.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shape that worked
&lt;/h2&gt;

&lt;p&gt;The thing I'd repeat on the next one: lean on the framework's machinery instead of reimplementing&lt;br&gt;
it. Failover here is the stock &lt;code&gt;CircuitBreaker&lt;/code&gt; filter with &lt;code&gt;fallbackUri: forward:/__llm/fallback&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; I supply only the replay handler. The state machine, the sliding window, the half-open probing
all stay Spring's. Same with the model predicate, which is modelled directly on Spring's own
&lt;code&gt;ReadBodyRoutePredicateFactory&lt;/code&gt; rather than inventing a body-caching scheme.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing is a dependency plus YAML. If you already run Spring Cloud Gateway, you add one&lt;br&gt;
artifact and two filter names and you have virtual keys, token quotas, per-tenant metering and&lt;br&gt;
failover.&lt;/p&gt;

&lt;p&gt;Code, README and the full test suite: &lt;a href="https://github.com/dockndevai/spring-llm-gateway" rel="noopener noreferrer"&gt;github.com/dockndevai/spring-llm-gateway&lt;/a&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>llm</category>
      <category>architecture</category>
    </item>
    <item>
      <title>8 safe-by-default MCP servers for infra — and the governance model behind them</title>
      <dc:creator>Ankit Verma</dc:creator>
      <pubDate>Sun, 23 Aug 2026 16:26:47 +0000</pubDate>
      <link>https://dev.to/dockndevai/8-safe-by-default-mcp-servers-formcp-ai-devops-opensource-infra-and-the-governance-model-4gi8</link>
      <guid>https://dev.to/dockndevai/8-safe-by-default-mcp-servers-formcp-ai-devops-opensource-infra-and-the-governance-model-4gi8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🚀 &lt;strong&gt;Update (Sep 8):&lt;/strong&gt; the suite is now &lt;strong&gt;11 servers&lt;/strong&gt; (added Grafana) and is &lt;strong&gt;live on Product Hunt today&lt;/strong&gt; → &lt;a href="https://www.producthunt.com/products/dockndevai-mcp-server-suite" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/dockndevai-mcp-server-suite&lt;/a&gt; — feedback + support very welcome!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Giving an AI agent access to production infrastructure is a great way to move fast — and a great way to have it drop a database because a prompt was ambiguous. I wanted the upside without the footguns, so I built a family of &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; servers that share one safe-by-default governance model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;MCP lets an agent call tools. For infra, those tools can be &lt;code&gt;delete_topic&lt;/code&gt;, &lt;code&gt;DROP TABLE&lt;/code&gt;, &lt;code&gt;delete_resource_group&lt;/code&gt;. The usual answer is "just don't expose the dangerous ones" — but then the server isn't useful when you &lt;em&gt;do&lt;/em&gt; need them. I wanted graduated, explicit control instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The governance model
&lt;/h2&gt;

&lt;p&gt;Every server shares the same layered policy engine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access modes&lt;/strong&gt; — &lt;code&gt;read-only&lt;/code&gt; → &lt;code&gt;read-write&lt;/code&gt; → &lt;code&gt;admin&lt;/code&gt;. Tools above the current mode are &lt;em&gt;never even registered&lt;/em&gt;, so the model can't call what it can't see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allowlists&lt;/strong&gt; — scope to specific realms / namespaces / topics / databases / projects / subscriptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protected resources&lt;/strong&gt; — system/prod resources (kube-system, the &lt;code&gt;system&lt;/code&gt; DB, internal Kafka topics, the master realm) are readable but never mutable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Destructive gating&lt;/strong&gt; — deletes need an explicit &lt;code&gt;ALLOW_DELETE&lt;/code&gt; flag on top of admin mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed confirmation&lt;/strong&gt; — high-impact ops (delete a project / resource group) require a &lt;code&gt;confirm&lt;/code&gt; value that echoes the exact target name. A boolean isn't enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret redaction&lt;/strong&gt; — credentials are stripped before anything reaches the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dry-run + audit&lt;/strong&gt; — preview writes without executing; every guarded op emits a JSON audit line to stderr.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The servers
&lt;/h2&gt;

&lt;p&gt;All MIT-licensed, TypeScript, published on npm as &lt;code&gt;@dockndevai/mcp-*&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;mcp-kubernetes&lt;/strong&gt; — pods, logs, deployments, scale/restart, apply, exec&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-kafka&lt;/strong&gt; — topics, consumer groups + lag, create/alter/reset&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-clickhouse&lt;/strong&gt; — schema, queries, SQL-classified read/write/destructive gating&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-debezium&lt;/strong&gt; — CDC connector status, config, lifecycle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-oci&lt;/strong&gt; — Oracle Cloud discovery + Terraform generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-azure&lt;/strong&gt; — Azure Resource Manager inventory, tags, VM power, lifecycle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-azure-devops&lt;/strong&gt; — boards, repos, pipelines, projects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-keycloak&lt;/strong&gt; — realms, users, clients, roles, groups&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try one
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npx -y @dockndevai/mcp-kubernetes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Or in Claude Code:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claude mcp add kubernetes -e KUBECONFIG_PATH=~/.kube/config -e K8S_MODE=read-only -- npx -y @dockndevai/mcp-kubernetes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Every repo has per-client setup for Claude, Cursor, Codex, VS Code, and Windsurf.&lt;/p&gt;

&lt;p&gt;Repos: &lt;a href="https://github.com/dockndevai" rel="noopener noreferrer"&gt;https://github.com/dockndevai&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd love feedback — especially on whether the mode + typed-confirmation split is the right default for infrastructure MCP servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update — v0.1.1: machine-readable safety
&lt;/h2&gt;

&lt;p&gt;Every tool across all 8 servers now ships MCP tool annotations (&lt;code&gt;readOnlyHint&lt;/code&gt;, &lt;code&gt;destructiveHint&lt;/code&gt;, &lt;code&gt;idempotentHint&lt;/code&gt;, &lt;code&gt;openWorldHint&lt;/code&gt;), derived automatically from each tool's access capability. The safe-by-default model is no longer just documented in the README — the host can read it and decide what to auto-approve versus prompt on. A test keeps every hint consistent with its tool's capability. Live now on npm and the official MCP registry.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>devops</category>
      <category>kubernetes</category>
    </item>
  </channel>
</rss>
