<?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: Omer Hochman</title>
    <description>The latest articles on DEV Community by Omer Hochman (@omer_hochman).</description>
    <link>https://dev.to/omer_hochman</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%2F4020184%2F5259d759-5650-4fbd-b73e-93bf595a8914.jpg</url>
      <title>DEV Community: Omer Hochman</title>
      <link>https://dev.to/omer_hochman</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/omer_hochman"/>
    <language>en</language>
    <item>
      <title>Your most active user is your test suite.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Sat, 05 Sep 2026 02:55:06 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-most-active-user-is-your-test-suite-4bbb</link>
      <guid>https://dev.to/omer_hochman/your-most-active-user-is-your-test-suite-4bbb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/most-active-user-is-your-test-suite/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Before launch, your end-to-end suite is often the only thing exercising production. Ours registers fixture users, creates databases, and asks real natural-language queries through the exact pipeline a stranger would hit — which is the point of an e2e suite, and also the problem: every dashboard we built quietly measured our robots. Here are the three places it bit us, and the fix for each.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your web analytics count headless browsers as visits
&lt;/h2&gt;

&lt;p&gt;Our weekly funnel said 120 visits. Real-browser visits were about 41. The rest were our own walkers: headless Chromium lands in RUM analytics with &lt;code&gt;userAgentBrowser: "Unknown"&lt;/code&gt; — 76 of the 120 — plus one that identified as headless outright. The fix is a pinned client-class cut: count named browsers (Chrome, Mobile Safari, Edge…) as the filtered number, and report &lt;em&gt;both&lt;/em&gt; numbers side by side. Raw tells you the instrument is alive; filtered tells you whether humans showed up. Reporting only one of them is how you lie to yourself in either direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Your product KPIs saturate on fixture accounts
&lt;/h2&gt;

&lt;p&gt;Our headline onboarding KPI — the share of a new account's first ten queries that succeed — dropped 8 points in one night. No deploy, no incident. A &lt;em&gt;failing e2e run&lt;/em&gt; had burned its asks against the same saturating counters real users increment; the suite's fixture account looked like our worst-onboarded user, because it was.&lt;/p&gt;

&lt;p&gt;The tempting fix is to skip the counters when the caller is a test account — and it's wrong. The write path is the thing under test; fork it for fixtures and your suite stops exercising the pipeline you ship. Filter at &lt;em&gt;read&lt;/em&gt; time instead: keep an explicit founder/test identity list and join it against every user-scoped metric when you pull the number. Writes stay honest, reads stay clean, and the filter is one place to audit instead of a flag threaded through every emit site.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The hardest one: accepting “not yet measurable”
&lt;/h2&gt;

&lt;p&gt;Once the joins were in place, our first-ten-queries KPI read: stranger sample size zero, not yet measurable. That's a worse-feeling number than the unfiltered 5/22 it replaced — and a strictly better one. A KPI computed over founder and fixture traffic isn't a pessimistic estimate of the real number; it's a number about a different population wearing the KPI's name. Shipping it to a scorecard anchors decisions to noise. An honest “N=0” at least tells you what the actual bottleneck is: distribution, not product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule of thumb
&lt;/h2&gt;

&lt;p&gt;A metric that doesn't name its population is measuring your robots. Every user-scoped number on a pre-launch dashboard should say which of three populations it covers — everyone, humans-only, or strangers-only — and the cut that produces it should be pinned in one greppable place (a UA class list, an identity list) rather than re-derived per query. Synthetic traffic through the production pipeline is a feature; synthetic traffic in your KPIs is a bug, and the boundary between them is attribution at read time.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>testing</category>
      <category>startup</category>
    </item>
    <item>
      <title>Your five fallback models are one point of failure.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Fri, 04 Sep 2026 01:33:48 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-five-fallback-models-are-one-point-of-failure-24jb</link>
      <guid>https://dev.to/omer_hochman/your-five-fallback-models-are-one-point-of-failure-24jb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/five-fallback-models-one-provider/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our agentic CI suite drives a real browser with an LLM. The driver model comes from an ordered five-model fallback list — health-probed before each run with real tool-call probes, re-ranked by measured agent competence. Textbook redundancy. It still failed 13 dispatches in a row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model-diverse, provider-identical
&lt;/h2&gt;

&lt;p&gt;The list was diverse in the dimension we could see — five different model families, five different weights — and identical in the dimension that failed: every slug resolved to the same gateway's free pool. That pool saturates as a unit. When model #1 came back &lt;code&gt;429&lt;/code&gt;, models #2 through #5 were rate-limited too, at 04:37 UTC and at 22:14 alike, because the limit lives on the shared pool, not on any model. Five models on one provider are five servers in one rack.&lt;/p&gt;

&lt;p&gt;The probes made it worse, not better. A three-probe health gate picked whichever model answered its probes that minute — and then the pick starved 216 seconds into the run, because passing three probes measures a moment of pool weather, not a claim on future capacity. A flapping pool passes probes all day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tell in the traces
&lt;/h2&gt;

&lt;p&gt;What finally broke the diagnosis open was a latency split in the Playwright traces: the product under test answered its API call in 4 seconds with a 200 — while three tests burned their entire 240-second budget waiting for the &lt;em&gt;driver&lt;/em&gt; to produce the next click. The app was green; the thing testing the app was starving; the suite reported "product red." If your E2E harness and your product share a failure domain, a harness outage is indistinguishable from a product regression until you split them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redundancy has to cross the failure-domain boundary
&lt;/h2&gt;

&lt;p&gt;The fix is a &lt;em&gt;lane&lt;/em&gt;, not a longer list. A lane is the real unit of failure: base URL + API key + candidate models. Fall back between lanes only when the whole primary lane fails its probes — and make the first fallback candidate the &lt;em&gt;same weights hosted by a different provider&lt;/em&gt;, which keeps agent competence constant while moving to an independent pool:&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="c1"&gt;// The fallback unit is the lane, not the model. Walking candidates&lt;/span&gt;
&lt;span class="c1"&gt;// inside one lane retries the same saturated pool with a new name.&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Lane&lt;/span&gt; &lt;span class="o"&gt;=&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;apiKey&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="nl"&gt;candidates&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;LANES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Lane&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="c1"&gt;// Primary: an independent pool. Same weights as the old list’s best&lt;/span&gt;
  &lt;span class="c1"&gt;// performer, hosted elsewhere — competence constant, failure domain new.&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="nx"&gt;NIM_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NIM_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai/gpt-oss-120b&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// Fallback: the gateway’s free pool — five candidates, ONE domain.&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="nx"&gt;GW_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GW_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FREE_SLUGS&lt;/span&gt; &lt;span class="p"&gt;},&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;lane&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;LANES&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;model&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;probeLane&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 3 real tool-call probes&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&gt;// Whole lane failed — only now is trying elsewhere informative.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more boundary matters: budgets. The fallback lane must not raid the quota the application under test runs on — if the rescue driver and the product share a pool, the rescue &lt;em&gt;causes&lt;/em&gt; the next outage. Keep the driver's spend and the product's spend on separate keys, and treat "the driver ran out" as a harness failure, never a product failure.&lt;/p&gt;

&lt;p&gt;On the first dispatch with the swapped lanes, the test that had starved at 216 seconds passed in 14.9 — same suite, same app, same weights. Nothing about the product changed; only the failure domain did. (This is a build note from &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the database you query in plain English. Honest split: a CI/infra lesson from our E2E harness, not a product feature.)&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>An "open question" that's already decided is worse than one that's still open.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Thu, 03 Sep 2026 01:28:05 +0000</pubDate>
      <link>https://dev.to/omer_hochman/an-open-question-thats-already-decided-is-worse-than-one-thats-still-open-3619</link>
      <guid>https://dev.to/omer_hochman/an-open-question-thats-already-decided-is-worse-than-one-thats-still-open-3619</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/decided-questions-rot-in-your-decision-log/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every long-lived codebase grows a decision log — ADRs, a &lt;code&gt;DECISIONS.md&lt;/code&gt;, per-feature records — and every one of those grows an &lt;em&gt;Open questions&lt;/em&gt; section. The failure mode isn't the open questions. It's the entries that were quietly &lt;em&gt;answered&lt;/em&gt; — in a PR, a standup, a founder's one-line reply — and never re-labelled. A bullet that reads "we should probably cap the queue at 7,000 ops/day" is a decision wearing an open question's clothes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a decided-but-unmarked bullet does to a reader
&lt;/h2&gt;

&lt;p&gt;A reader who hits that bullet does one of two bad things. Either they treat the settled call as unsettled and re-litigate it — burning a design discussion on a question someone already closed — or they build on top of a "maybe" that was actually a "yes," and their design inherits a hedge that no longer exists. Both cost more than an honestly open question would, because the log &lt;em&gt;looks&lt;/em&gt; authoritative while pointing nowhere. A vague decision documented is worse than none.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two moves fix it
&lt;/h2&gt;

&lt;p&gt;First, make &lt;em&gt;resolved&lt;/em&gt; a first-class, greppable state. A decided bullet keeps its line but gains a marker word — &lt;code&gt;Resolved&lt;/code&gt;, &lt;code&gt;Decided:&lt;/code&gt;, &lt;code&gt;Parked&lt;/code&gt;, or a strikethrough — plus a pointer to the decision's canonical home. The questions list is where a question's lifecycle is visible; it is never where the decision's body lives. When you answer an open question, the &lt;em&gt;same commit&lt;/em&gt; moves the body to the canonical record and leaves only the marked pointer behind — never a second copy that can drift.&lt;/p&gt;

&lt;p&gt;Second, count the unmarked ones as debt. If &lt;em&gt;resolved&lt;/em&gt; is greppable, so is &lt;em&gt;unresolved&lt;/em&gt; — and a number you can compute is a number you can drive to zero:&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;# Ambiguity debt: open-question bullets with no resolution marker.&lt;/span&gt;
&lt;span class="c"&gt;# -h suppresses the filename prefix grep -A puts on context lines —&lt;/span&gt;
&lt;span class="c"&gt;# without it the bullet match never fires and the count is always 0.&lt;/span&gt;
&lt;span class="c"&gt;# Case-INSENSITIVE on the marker set — a case-sensitive grep counts&lt;/span&gt;
&lt;span class="c"&gt;# "resolved" and "Resolved" differently and the number drifts.&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-hA9999&lt;/span&gt; &lt;span class="s1"&gt;'^## Open questions'&lt;/span&gt; docs/features/&lt;span class="k"&gt;*&lt;/span&gt;/FEATURE.md &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^\s*- '&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vciE&lt;/span&gt; &lt;span class="s1"&gt;'resolved|decided:|parked|deferred|~~'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The case-insensitivity is not a nitpick — it's the difference between a metric and a mood. We track this count as a scorecard row, and the first version drifted for exactly that reason: two agents marked resolutions with different capitalization, the grep saw only one, and the "debt" number moved without any question changing state. Pin the counting method next to the number it produces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a count, and not a cleanup day
&lt;/h2&gt;

&lt;p&gt;A one-off sweep fixes today's rot and leaves the seam that produced it. The count makes the seam visible continuously: every answered-but-unmarked bullet is +1 debt that someone will notice, and driving one bullet to a marked resolution is a small, complete, verifiable unit of work — research the answer, write it in the canonical home, mark the pointer. A question only a human with prod access or a checkbook can answer moves to a separate blocked-on-human list and off the count, so the number only tracks debt an engineer can actually retire.&lt;/p&gt;

&lt;p&gt;(This is a build note from &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the database you query in plain English. Our decision log is agent-operated, which makes the rot mechanical instead of cultural — but the fix is the same one a human team needs. Honest split: a decision-hygiene lesson, not a product feature.)&lt;/p&gt;

</description>
      <category>documentation</category>
      <category>architecture</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Your metric is only as honest as the layer you emit it from.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Wed, 02 Sep 2026 01:19:21 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-metric-is-only-as-honest-as-the-layer-you-emit-it-from-54ma</link>
      <guid>https://dev.to/omer_hochman/your-metric-is-only-as-honest-as-the-layer-you-emit-it-from-54ma</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/emit-metrics-where-the-distinction-is-certain/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We wanted one number: the destructive-op retry rate — the share of write previews a user abandons instead of confirming. Our pipeline renders every destructive statement as a diff first; the user re-sends with a confirm flag to commit. So the rate is &lt;code&gt;1 - committed / preview_rendered&lt;/code&gt;, and it takes exactly two counter events to compute. The only real design decision is where those two events get emitted — and the obvious answer is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The route doesn't know what it's confirming
&lt;/h2&gt;

&lt;p&gt;The obvious emit site is the HTTP route. It already has everything an event wants: the principal, the surface, the request, the response status. Emit &lt;code&gt;preview_rendered&lt;/code&gt; when the response asks for confirmation, &lt;code&gt;committed&lt;/code&gt; when a request carrying &lt;code&gt;confirm: true&lt;/code&gt; returns 200. Done — except the route does not actually know whether the statement was a write. That fact is decided two layers down, where the generated SQL is inspected and the preview-vs-commit branch is taken. The route sees a flag and a status code.&lt;/p&gt;

&lt;p&gt;Here is the failure that makes it concrete: a client sends a read with a stray &lt;code&gt;confirm: true&lt;/code&gt; — a retried request, an over-eager SDK default, a copy-pasted call. The read succeeds, the route emits &lt;code&gt;committed&lt;/code&gt;, and there is no matching &lt;code&gt;preview_rendered&lt;/code&gt; because reads never preview. The numerator now exceeds the denominator, and your retry rate goes negative — a number that cannot exist. Nothing errored. Every layer behaved correctly. The metric was simply emitted above the layer where its defining distinction is known.&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="c1"&gt;// Route layer — the tempting emit site. It has the principal, the&lt;/span&gt;
&lt;span class="c1"&gt;// surface, the status. It does NOT know whether the plan was a write:&lt;/span&gt;
&lt;span class="c1"&gt;// emit "committed" on any confirm:true 200 and a stray-confirm read&lt;/span&gt;
&lt;span class="c1"&gt;// inflates the numerator. The rate goes negative.&lt;/span&gt;

&lt;span class="c1"&gt;// Orchestrator — both facts are certain on exactly this boundary.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confirm&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isWriteVerb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;planSql&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// A write plan rendered as a diff, nothing executed: the denominator.&lt;/span&gt;
  &lt;span class="nf"&gt;emit&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;feature.destructive.preview_rendered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;surface&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="na"&gt;requires_confirm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// ...execution happens only past this point...&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confirm&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isWriteVerb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;planSql&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Exec success on an approved diff: the numerator.&lt;/span&gt;
  &lt;span class="nf"&gt;emit&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;feature.destructive.committed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;surface&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Thread facts down, don't pull decisions up
&lt;/h2&gt;

&lt;p&gt;The fix is not teaching the route to re-detect writes — that duplicates the write-detection logic in a second place, and the two copies will drift. The fix is moving the emission down to the layer where &lt;code&gt;isWriteVerb(sql)&lt;/code&gt; and the preview-vs-commit branch are already decided. There, both events fire on exactly the boundary they measure, and a stray-confirm read emits nothing at all.&lt;/p&gt;

&lt;p&gt;Moving down costs you one thing: the lower layer didn't know the surface the request came from — web app, CLI, MCP — and the events are only useful sliced by surface. So thread that one field down through the request. Passing one known fact down is cheap and drift-free; hoisting a whole decision up is neither. And when a caller doesn't thread the field — an internal path with no surface — skip the emit rather than fabricating a value. A gap in the data is honest; a guessed label is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;A metric's emit point is the lowest layer where the distinction it encodes is certain. Above that layer you are guessing, and a guessed metric is worse than no metric, because it looks precise while being structurally wrong — negative rates, inflated numerators, denominators that undercount. The same reasoning fixes the dedup key: a volume event like this one keys on the request, not on a per-principal-per-day bucket, because collapsing repeated previews from one user would erase exactly the retries the metric exists to count. Decide what distinction the number encodes, find the lowest layer where that distinction is a fact, and emit there.&lt;/p&gt;

&lt;p&gt;(This is a build note from &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the database you query in plain English — where destructive statements render as a diff you approve before anything runs. Honest split: this is an instrumentation-design lesson, not a product feature.)&lt;/p&gt;

</description>
      <category>programming</category>
      <category>observability</category>
      <category>architecture</category>
    </item>
    <item>
      <title>You need to rotate an encryption key. You don't need a key-version column.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Tue, 01 Sep 2026 01:19:56 +0000</pubDate>
      <link>https://dev.to/omer_hochman/you-need-to-rotate-an-encryption-key-you-dont-need-a-key-version-column-1h50</link>
      <guid>https://dev.to/omer_hochman/you-need-to-rotate-an-encryption-key-you-dont-need-a-key-version-column-1h50</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/rotate-encryption-key-without-a-version-column/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We seal secrets at rest — a customer's database DSN, a stored API key — under a key-encryption key (KEK). Sooner or later you have to rotate that KEK: it's been in an env var too long, it might have leaked, or a policy just says every key expires. And the moment you plan the rotation, a reflex kicks in: add a &lt;code&gt;key_version&lt;/code&gt; column so you know which key sealed which row. Skip it. That column is a second source of truth for something the ciphertext already has to tell you, and the two will drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ciphertext is already self-describing
&lt;/h2&gt;

&lt;p&gt;You cannot decrypt AES-GCM without the IV, so the IV was never a secret and it already lives inside the stored blob — every sealed value is a self-describing string, not opaque bytes. A key version is exactly the same kind of fact: metadata you need in hand before you can decrypt. So it belongs in the same place the IV does — the blob — not in a sibling column that every write path has to keep in sync. One token of prefix carries it:&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="c1"&gt;// The sealed blob already carries its IV. Give it a version tag too.&lt;/span&gt;
&lt;span class="c1"&gt;//   nbe2.&amp;lt;v&amp;gt;.&amp;lt;iv&amp;gt;.&amp;lt;ciphertext&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;// Bumping the KEK is a one-token change: v1 -&amp;gt; v2. No schema migration,&lt;/span&gt;
&lt;span class="c1"&gt;// no key_version column, no ALTER TABLE, no backfill job on write.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;seal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintext&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="nx"&gt;kek&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Kek&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomIv&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;ct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;aesGcmSeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kek&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;material&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`nbe2.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;kek&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&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="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iv&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="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ct&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="c1"&gt;// version IS the tag&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&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="nx"&gt;keyring&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Keyring&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[,&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&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;kek&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;keyring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;byVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// pick the key the blob names&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;aesGcmOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fromB64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;kek&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;material&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;fromB64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the prefix buys you that a column doesn't
&lt;/h2&gt;

&lt;p&gt;Rotation becomes a sequence of cheap, interruptible steps instead of a migration. You add the new key to the keyring alongside the old one — a two-key overlap window. Reads pick the right key by the tag the blob carries, so nothing breaks the instant you flip. Writes always seal under the newest version, so every row that gets touched migrates itself. Then you do one sweep for the cold rows that nobody wrote, and drop the old key when no blob still carries its version.&lt;/p&gt;

&lt;p&gt;The part people reach for the column to solve — "find the rows still on the old key" — the prefix already solves, and solves better: stale rows are filterable by their tag without decrypting a single one. A &lt;code&gt;WHERE blob LIKE 'nbe1.%'&lt;/code&gt; finds every row that needs re-wrapping straight off the index. The &lt;code&gt;key_version&lt;/code&gt; column only earns its keep if that sweep has to find stale rows blind — and it never does, because the tag is right there in the value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one nuance: derived keys vs. stored DEKs
&lt;/h2&gt;

&lt;p&gt;If each row stores its own data-encryption key (DEK) wrapped by the KEK, "re-wrap" is cheap and never touches plaintext: decrypt the little DEK with the old KEK, re-encrypt it with the new one, leave the bulk ciphertext untouched. If instead your content key is HKDF-derived straight from the KEK — no stored DEK — then re-wrap means decrypt-then-reseal the secret itself. That sounds heavier, but for the short secrets this pattern guards (a DSN, an API key — tens of bytes, not gigabytes) it's trivial. Either way the version tag is what tells the sweep which branch a given row needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;The encrypted blob is the source of truth for how to decrypt it — key version included. Anything you also store in a column is a second copy that can drift from the first, and the day it drifts is the day you can't decrypt a row you thought you could. Put the version where the IV already lives, rotate by bumping a prefix, and let the tag drive both the read path and the sweep. Reach for a column only when you have a query that genuinely can't be answered from the value itself — and key rotation isn't one of them.&lt;/p&gt;

&lt;p&gt;(This is a build note from &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the database you query in plain English — where connection secrets are sealed at rest and the SQL is generated, validated, and shown to you before it runs. Honest split: this is a security-architecture lesson, not a product feature.)&lt;/p&gt;

</description>
      <category>security</category>
      <category>database</category>
      <category>architecture</category>
    </item>
    <item>
      <title>You added a second SQL engine. Your text-to-SQL model is still being told it's the first one.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Mon, 31 Aug 2026 01:26:31 +0000</pubDate>
      <link>https://dev.to/omer_hochman/you-added-a-second-sql-engine-your-text-to-sql-model-is-still-being-told-its-the-first-one-2d92</link>
      <guid>https://dev.to/omer_hochman/you-added-a-second-sql-engine-your-text-to-sql-model-is-still-being-told-its-the-first-one-2d92</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/text-to-sql-planner-told-wrong-dialect/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A text-to-SQL model is dialect-aware by design. You hand it a target dialect, and it obliges — name Postgres, get Postgres. So when we added a second engine, ClickHouse alongside Postgres, the model kept doing exactly what it does well: writing SQL for the dialect it was told. The trouble is what it was told. The happy path compiled, the Postgres queries still worked, and a class of analytical questions on the ClickHouse databases started coming back subtly wrong. The model wasn't confused. It was confidently writing Postgres for a database that speaks ClickHouse, because one line told it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The planner was never the problem
&lt;/h2&gt;

&lt;p&gt;The prompt already carries a &lt;code&gt;Dialect:&lt;/code&gt; line — the system prompt says "emit SQL valid for the named dialect," the few-shot exemplars are tagged with theirs. The model reads that field and complies. So the bug isn't in the model or the prompt. It's in the one place that fills the field, and it looks like the most innocent line in the file:&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="c1"&gt;// The request type — grew for the first two engines, then stopped.&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PlanRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;goal&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="nl"&gt;dialect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgres&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sqlite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// never grew a "clickhouse" member&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// The call site — a literal that was correct exactly once.&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="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;goal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;goal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dialect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgres&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// hardcoded; db.engine is right there, unused&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;dialect: "postgres"&lt;/code&gt; was true on the day it was written, when Postgres was the only engine. It is a fact frozen into a literal. The database row already knows its real engine — &lt;code&gt;db.engine&lt;/code&gt; is sitting one field away — but nothing carries that value the few inches into the request. The model is downstream of a lie it has no way to detect.&lt;/p&gt;

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

&lt;p&gt;Nothing logs "wrong dialect," because from every layer's point of view nothing went wrong. The type-checker is happy — &lt;code&gt;"postgres"&lt;/code&gt; is a valid member of the union. The planner is happy — it got a dialect and emitted valid SQL for it. Every Postgres database on the platform keeps working, so the whole happy path stays green. The failure only surfaces on the analytical grammar that is the entire reason you added the second engine — &lt;code&gt;LIMIT n BY&lt;/code&gt;, &lt;code&gt;quantile(0.5)(x)&lt;/code&gt;, &lt;code&gt;ARRAY JOIN&lt;/code&gt;, &lt;code&gt;WITH ROLLUP&lt;/code&gt; — none of which the model will ever reach for while it believes it's writing Postgres. The feature that justified the new engine is precisely the feature that silently degrades.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is a value, not a transpile layer
&lt;/h2&gt;

&lt;p&gt;The reflex is to reach for a translation layer — write Postgres, run it through SQLGlot or an ANTLR grammar, transpile to ClickHouse. Don't. Those pull a parser-and-grammar bundle that busts an edge/Workers deploy, and they solve a problem you don't have: the model can already write ClickHouse. It just has to be asked. The fix is to make the dialect flow from the row instead of a literal, and — this is the load-bearing half — to make the type refuse to compile until every call site does so.&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="c1"&gt;// 1. Widen the type. This is what turns the bug into a compile error:&lt;/span&gt;
&lt;span class="c1"&gt;//    every hardcoded `dialect: "postgres"` call site now fails to build&lt;/span&gt;
&lt;span class="c1"&gt;//    until it proves it handles the new member.&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Dialect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgres&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sqlite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clickhouse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Map the engine the row already carries to its dialect — one place.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dialectFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DbEngine&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Dialect&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clickhouse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clickhouse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgres&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Thread the value, not a literal.&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="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;goal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;goal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dialect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;dialectFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;engine&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;Widening the type first is the trick that makes this safe. Add &lt;code&gt;"clickhouse"&lt;/code&gt; to the union and the compiler walks you to every call site that still hardcodes the first engine — the two plan sites, the retry-repair site, anywhere a literal slipped in. A runtime bug you'd have to catch with a live ClickHouse database and a discerning eye becomes a build error you can't merge past. You are not trusting yourself to remember every place; you are making the type remember for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generator and the validator are twins
&lt;/h2&gt;

&lt;p&gt;There's a matching bug one layer down, and it's worth fixing in the same breath. The &lt;a href="https://dev.to/blog/postgres-validator-rejects-valid-clickhouse-sql/"&gt;SQL validator makes the same first-engine assumption&lt;/a&gt;: a Postgres-pinned parser false-rejects valid ClickHouse grammar as a parse failure. Fix the generator alone and you've just relocated the damage — now the planner correctly emits ClickHouse SQL and the validator vetoes it as invalid. The generator and the validator both silently assume engine #1, and they have to stop assuming it together, or you trade a wrong-answer bug for a rejected-query bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;When you add a second engine, the dialect stops being config you set once and becomes a value that must flow from the row on every request. Grep for every place the first engine's name appears as a literal — that list is your bug list. Then widen the dialect type before you touch anything else, so the compiler converts the ones you'd have missed from silent wrong answers into loud build failures. A frozen literal is a fact that was true once; the type is what keeps it from staying true after it isn't.&lt;/p&gt;

&lt;p&gt;(This is a build note from &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the database you query in plain English — where the SQL is generated, validated, and shown to you before it runs. Honest split: this is an architecture lesson about multi-engine prompting, not a product feature.)&lt;/p&gt;

</description>
      <category>sql</category>
      <category>llm</category>
      <category>database</category>
    </item>
    <item>
      <title>You added ClickHouse. Your Postgres SQL validator now rejects valid queries — quietly.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Sun, 30 Aug 2026 01:15:57 +0000</pubDate>
      <link>https://dev.to/omer_hochman/you-added-clickhouse-your-postgres-sql-validator-now-rejects-valid-queries-quietly-2okd</link>
      <guid>https://dev.to/omer_hochman/you-added-clickhouse-your-postgres-sql-validator-now-rejects-valid-queries-quietly-2okd</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/postgres-validator-rejects-valid-clickhouse-sql/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We generate SQL from plain English, and before any of it touches a database we validate it. So when we added a second engine — ClickHouse alongside Postgres — the tempting move was obvious: reuse the validator we already trust. It ran clean, the tests were green, and a class of real user questions started coming back &lt;code&gt;parse_failed&lt;/code&gt; for no reason anyone could see. The validator wasn't broken. It was doing exactly what it was told, on the wrong engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  One parser, pinned to one dialect
&lt;/h2&gt;

&lt;p&gt;Our validator is an AST parse — &lt;code&gt;node-sql-parser&lt;/code&gt;, configured &lt;code&gt;database: "PostgreSQL"&lt;/code&gt;. Point that at a ClickHouse database and valid analytical grammar the query planner correctly emits — &lt;code&gt;LIMIT n BY&lt;/code&gt;, &lt;code&gt;quantile(0.5)(x)&lt;/code&gt;, &lt;code&gt;ARRAY JOIN&lt;/code&gt;, &lt;code&gt;WITH ROLLUP&lt;/code&gt; — fails the parse. The user asked a perfectly good question, the model wrote perfectly good ClickHouse, and the guardrail in the middle rejected it. Nothing in the logs said "wrong dialect." It just said the query was invalid, which it wasn't.&lt;/p&gt;

&lt;p&gt;The seductive part is that it never looks like a dialect bug. Every Postgres query still validates. The happy path is green. The failure only shows up on the analytical grammar that is the entire reason you added ClickHouse in the first place — so the feature that motivated the second engine is precisely the feature the validator quietly vetoes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is not a second parser
&lt;/h2&gt;

&lt;p&gt;The reflex is to go find a ClickHouse parser. Don't. &lt;code&gt;node-sql-parser&lt;/code&gt; has no ClickHouse dialect, and the ANTLR4 grammars that do are far too heavy for an edge/Workers bundle. Chasing a per-engine parser is chasing the wrong problem — because the validator was never really one job.&lt;/p&gt;

&lt;p&gt;Look at what it actually does and it splits cleanly in two. First, it enforces a destructive-verb allowlist — no &lt;code&gt;DROP&lt;/code&gt;, no &lt;code&gt;DELETE&lt;/code&gt;, no &lt;code&gt;TRUNCATE&lt;/code&gt; reaching a read path. Second, it walks the AST to catch a dangerous verb smuggled inside a CTE or subquery. Only the second job needs a parser. The first is a leading-verb check — dialect-agnostic, already correct on every engine, because &lt;code&gt;DROP&lt;/code&gt; is &lt;code&gt;DROP&lt;/code&gt; in ClickHouse and Postgres alike.&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="c1"&gt;// Security-load-bearing: dialect-agnostic, authoritative on EVERY engine.&lt;/span&gt;
&lt;span class="c1"&gt;// A leading destructive verb is a hard reject regardless of parser support.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DESTRUCTIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;drop|delete|truncate|alter|update|insert|grant|revoke&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DESTRUCTIVE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&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;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;destructive_verb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Defense-in-depth: AST walk for a verb smuggled into a CTE/subquery.&lt;/span&gt;
&lt;span class="c1"&gt;// Best-effort PER ENGINE — a parse failure here means "wrong parser for&lt;/span&gt;
&lt;span class="c1"&gt;// this dialect", NOT "dangerous query". It must never veto a valid query.&lt;/span&gt;
&lt;span class="k"&gt;try&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;ast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;dialectFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasNestedDestructive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ast&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;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;destructive_verb&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;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ClickHouse grammar the PG parser can't read lands here. That is not&lt;/span&gt;
  &lt;span class="c1"&gt;// a security signal. The allowlist above already made the safety call.&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;allow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why the layering matters
&lt;/h2&gt;

&lt;p&gt;Once the two jobs are separated, the security-load-bearing layer works on every engine and the dialect-locked layer degrades gracefully. A parse failure from the wrong dialect stops being a verdict on whether the query is safe and becomes what it always was: a statement about which parser you happened to load. The allowlist already made the safety call, up front, on grammar that never varies. So you lose nothing on security and you stop rejecting correct SQL.&lt;/p&gt;

&lt;p&gt;This is the same instinct behind layered guardrails everywhere: no single rule is trusted to be both complete and correct on every input. The widely-reported agent-that-dropped-a-production-database incident had multiple safeguards and still lost data — the lesson isn't "add one perfect check," it's that the authoritative safety layer has to hold even when a best-effort layer above it can't run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;In a multi-engine guardrail, a parse failure from the wrong dialect means "wrong parser," not "dangerous query" — never let it decide whether a valid query runs. Keep the dialect-agnostic allowlist authoritative on every engine; make the AST parse best-effort per engine. When you add a second dialect, the bug won't announce itself as a dialect bug — it'll look like a validator that's merely stricter than you remembered. Check what your guardrail is actually pinned to before you trust its rejections.&lt;/p&gt;

&lt;p&gt;(This is a build note from &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the database you query in plain English — where the SQL is generated, validated, and shown to you before it runs. Honest split: this is an engineering lesson about multi-engine guardrails, not a product feature.)&lt;/p&gt;

</description>
      <category>sql</category>
      <category>clickhouse</category>
      <category>security</category>
    </item>
    <item>
      <title>We read the agent-memory benchmarks. Almost none measure analysis.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Sat, 29 Aug 2026 01:20:29 +0000</pubDate>
      <link>https://dev.to/omer_hochman/we-read-the-agent-memory-benchmarks-almost-none-measure-analysis-4khi</link>
      <guid>https://dev.to/omer_hochman/we-read-the-agent-memory-benchmarks-almost-none-measure-analysis-4khi</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/agent-memory-benchmarks-measure-recall-not-analysis/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We're building nlqdb as analytical memory for AI agents — a real database an agent queries with &lt;code&gt;GROUP BY&lt;/code&gt; and &lt;code&gt;JOIN&lt;/code&gt;, not a fuzzy fact store. So before writing our own memory-quality benchmark, we read the ones the field already uses: LoCoMo, LongMemEval, and Mem0's and Zep's evaluations. The short version: almost none of them measure what we assumed, and almost every headline number is self-reported.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the benchmarks actually score
&lt;/h2&gt;

&lt;p&gt;The canonical benchmarks are end-to-end question-answering suites. LoCoMo (Snap Research, ACL 2024) builds very long multi-session dialogues — around 300 turns over up to 35 sessions — and asks single-hop, multi-hop, temporal, commonsense, and adversarial questions, graded by string-match F1. LongMemEval (ICLR 2025) embeds 500 questions across five separately-scored abilities: information extraction, multi-session reasoning, temporal reasoning, knowledge updates, and abstention. Both grade whether the final answer is right — not whether the right memory was retrieved.&lt;/p&gt;

&lt;p&gt;That distinction matters. In classic retrieval you measure &lt;code&gt;recall@k&lt;/code&gt;, &lt;code&gt;precision@k&lt;/code&gt;, &lt;code&gt;MRR&lt;/code&gt;, and &lt;code&gt;nDCG&lt;/code&gt; against labeled-relevant items. We went looking for those numbers in agent-memory evaluations and mostly didn't find them: the field grades QA accuracy (or an LLM-as-judge), not component-level retrieval. There is no agreed ground-truth-relevance standard for agent memory. If you want to measure retrieval quality directly, you have to label relevance yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Almost every headline number is self-reported
&lt;/h2&gt;

&lt;p&gt;Mem0 reports a 26% relative improvement over OpenAI's memory on LoCoMo; Zep reports 94.8% on Deep Memory Retrieval and up to 18.5% on LongMemEval. Read the author lists: these are the vendors' own papers. None has been cleanly reproduced by a neutral third party, and the two vendors publicly dispute each other's LoCoMo methodology. The benchmark itself is soft — an independent audit found roughly 6.4% of LoCoMo's answer key is simply wrong, and re-scoring with a corrected judge prompt swung one system from 84% to 58%. Treat every leaderboard number as directional, not settled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing nobody measures: analysis over memory
&lt;/h2&gt;

&lt;p&gt;Here's the gap that matters for us. Every system in the field stores facts and retrieves them. A vector store can recall "Alice has a $50k deal." What none of them benchmark — and most structurally can't do — is analysis over that memory: "show the top 5 deals by value, grouped by stage, for enterprise accounts only." That's a &lt;code&gt;GROUP BY&lt;/code&gt; with a &lt;code&gt;HAVING&lt;/code&gt; and a &lt;code&gt;JOIN&lt;/code&gt;, and a fuzzy fact store has no query planner to run it. We could not find a single benchmark that isolates analytical queries over episodic memory against vector or graph memory on identical data. The field measures recall of facts, not reasoning across them.&lt;/p&gt;

&lt;p&gt;There's even supporting evidence hiding in LoCoMo's own results: restructuring raw dialogue into a "database of assertions" lifted temporal-question F1 from 21.3 to 41.9. Structure helps most exactly where LLMs are weakest — temporal and multi-hop reasoning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a database does not win (the honest part)
&lt;/h2&gt;

&lt;p&gt;A real database is not a free win everywhere, and pretending otherwise would be dishonest. For fuzzy semantic recall over unstructured text — "find the thing I said that's kind of like this" — embedding similarity still beats exact SQL. Every serious system relies on it, including the most database-native academic proposal we found, which is Postgres-based and still falls back to &lt;code&gt;pgvector&lt;/code&gt; nearest-neighbor. Pure analytical SQL cannot replace vector search for unstructured recall. The honest wedge is analytical memory, not memory without embeddings — which is why hybrid recall is on our roadmap, not our marketing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;We're adding an agent-memory-quality eval to the same benchmark harness we already run for text-to-SQL accuracy. It scores four axes: retrieval precision and recall (against relevance labels we define, since the field has none), temporal reasoning, forgetting and contradiction resolution, and consolidation. And it includes the analytical-memory task nobody else runs: aggregation and ordering over episodic memory, head-to-head against a vector-recall baseline on the same data — reported honestly, including the questions where a pure-SQL store loses. We'll publish the numbers here as they land, reproducible, not self-graded.&lt;/p&gt;

&lt;p&gt;If you're building an agent and you want its memory to answer questions, not just echo facts back, that's the whole idea. You can point an agent at a live nlqdb database and start asking in plain English.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>database</category>
    </item>
    <item>
      <title>We shipped 18 SEO pages and got 1 referral. The links only pointed one way.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Thu, 27 Aug 2026 01:32:48 +0000</pubDate>
      <link>https://dev.to/omer_hochman/we-shipped-18-seo-pages-and-got-1-referral-the-links-only-pointed-one-way-1e1i</link>
      <guid>https://dev.to/omer_hochman/we-shipped-18-seo-pages-and-got-1-referral-the-links-only-pointed-one-way-1e1i</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/one-way-internal-links-leak-yield/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For a few weeks the surface count was the number that moved. Comparison pages, how-to pages, engineering posts — the sitemap kept growing. Referral traffic did not. It sat at roughly one external visit a week the whole time. More pages, same trickle. When volume climbs and yield doesn't, the pages aren't the problem; the graph connecting them is.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tree, not a mesh
&lt;/h2&gt;

&lt;p&gt;We drew our own internal links and the shape was obvious in hindsight. Every blog post linked &lt;em&gt;down&lt;/em&gt; to the deep guide it anchored — a post about duplicate-row detection linked to &lt;code&gt;/solve/find-duplicate-rows-in-my-data&lt;/code&gt;, a comparison post linked to &lt;code&gt;/vs/mode&lt;/code&gt;. Nothing linked back up. &lt;code&gt;/blog → /solve&lt;/code&gt;, never &lt;code&gt;/solve → /blog&lt;/code&gt;. The graph was a tree with the deep pages as leaves, and the freshest content — the posts we most needed crawled and indexed — hung off the ends with zero internal inbound links.&lt;/p&gt;

&lt;p&gt;That one-way arrow costs you twice, once for machines and once for humans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost one: authority flows the way the link points
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Links pass authority in the direction they point.&lt;/strong&gt; Every forward link we shipped fed the deep evergreen page and starved the new post. The URL that most needed a crawl signal — the one published yesterday — was the one with no internal links pointing at it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A page with zero internal inbound links is nearly invisible to a crawler.&lt;/strong&gt; The sitemap tells a bot the URL &lt;em&gt;exists&lt;/em&gt;; internal links tell it the URL &lt;em&gt;matters&lt;/em&gt;. We were publishing pages that only the first told anyone about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fresh pages have the shortest runway.&lt;/strong&gt; An evergreen guide accrues links over months. A post has days to prove it's worth indexing. Pointing all the authority the other way is exactly backwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cost two: a one-way link is a dead end for a reader
&lt;/h2&gt;

&lt;p&gt;The human cost is simpler and you can feel it. A searcher lands on &lt;code&gt;/solve/find-top-n-rows-per-group&lt;/code&gt; from Google, reads the answer, and… stops. There is no next hop. The session is one page long, the reader bounces, and the analytics tell you the page 'didn't engage' when really it just had nowhere to send anyone. A page that answers a question and then offers the obvious follow-up keeps the reader on the site; a page that answers and then goes quiet hands them back to the search results.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was a field we already had
&lt;/h2&gt;

&lt;p&gt;Each post already declared the guide it anchored — a typed &lt;code&gt;anchor&lt;/code&gt; field with a label and a path, used to render the forward link. That field is bidirectional information; we were only reading it one way. The deep page knew nothing about the posts pointing at it, but the posts knew exactly which deep page they belonged to. So we inverted the same data: at build time, for each &lt;code&gt;/solve&lt;/code&gt; and &lt;code&gt;/vs&lt;/code&gt; page, collect the posts whose &lt;code&gt;anchor&lt;/code&gt; names it and render a reciprocal "Further reading" link back. No new content, no new data model — one derived link per relationship, generated from the field that already drove the forward one.&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="c1"&gt;// The forward link already existed on each post:&lt;/span&gt;
&lt;span class="c1"&gt;//   anchor: { label: 'Find top-N rows per group', path: '/solve/...' }&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// Invert it once, at build time, to get every backlink for free:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;backlinks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;post&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;BLOG_POSTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;backlinks&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="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;backlinks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// /solve/[slug] and /vs/[slug] now render backlinks.get(currentPath)&lt;/span&gt;
&lt;span class="c1"&gt;// as a 'Further reading' block — the mesh closes with zero new data.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every anchored deep page now has at least one internal inbound link, and every post has a two-way relationship with the guide it belongs to. The link count went up without a single new page; the graph went from a tree to a mesh. (&lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt; is the database you talk to; this is one of the notes from building it in the open.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson: measure the graph, not the count
&lt;/h2&gt;

&lt;p&gt;Publishing volume is not distribution. A pile of pages that only link one direction is a stack of dead ends wearing a sitemap. The metric that actually predicts yield is the shape of the internal link graph — how many pages a reader (or a crawler) can reach from any starting point, and whether authority can flow to the pages that need it. Before you write the next page, check that the last one links both ways. The cheapest distribution win is usually a link you already have the data to draw.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>contentmarketing</category>
    </item>
    <item>
      <title>The timeout that looked like a hallucination</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Wed, 26 Aug 2026 01:53:26 +0000</pubDate>
      <link>https://dev.to/omer_hochman/the-timeout-that-looked-like-a-hallucination-1m0o</link>
      <guid>https://dev.to/omer_hochman/the-timeout-that-looked-like-a-hallucination-1m0o</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/llm-timeout-looks-like-hallucination/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our NL→SQL benchmark reported that a frontier model "emitted junk" — non-SQL, unparseable — on 5 of 150 hard questions, dragging its score below a floor a frontier model has no business sitting under. The number was real. The explanation was wrong. The model never hallucinated on those five. It was answering fine and we killed it mid-sentence, then filed the crime under the wrong charge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clamp you measure instead of the model
&lt;/h2&gt;

&lt;p&gt;The request layer aborted every model call at a 5-second timeout — a budget &lt;strong&gt;inherited from the production hot path&lt;/strong&gt;, where a user is staring at a spinner and 5 seconds is already too long. That budget is correct for what ships. It is exactly wrong for a benchmark, whose entire job is to measure what the model &lt;em&gt;can&lt;/em&gt; do, not what fits in a latency SLA. A reasoning model on a hard schema wants seconds of chain-of-thought; a 5-second clamp truncates it. So the benchmark wasn't scoring the model. It was scoring the clamp — and reporting the clamp's verdict as the model's.&lt;/p&gt;

&lt;p&gt;The rule that fell out of this: &lt;strong&gt;a benchmark's timeout budget is a measurement instrument, not a config value to reuse.&lt;/strong&gt; Separate "what ships" (the prod hot-path budget) from "what the model can do" (the eval budget). Point the frontier eval lane at its own generous budget and the score jumps to reflect the model, not the SLA. That single change lifted the lane off its false floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  An aborted read is not a parse error
&lt;/h2&gt;

&lt;p&gt;The second bug is why the truncation disguised itself. When the timeout fired, an &lt;code&gt;AbortController&lt;/code&gt; cancelled the in-flight fetch. The code was mid-&lt;code&gt;res.json()&lt;/code&gt;, so that read rejected — and the surrounding &lt;code&gt;catch&lt;/code&gt; did the natural, wrong thing: no JSON, therefore the model returned something unparseable, therefore classify it &lt;code&gt;parse&lt;/code&gt; (model emitted non-SQL). But the read didn't fail because the body was junk. It failed because &lt;em&gt;we&lt;/em&gt; pulled the plug. The abort and the parse failure land in the same &lt;code&gt;catch&lt;/code&gt;, and only one of them is the model's fault.&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;try&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;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// WRONG: every failure here reads as "model returned non-SQL"&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;parse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Right: the signal tells you who aborted the read.&lt;/span&gt;
&lt;span class="k"&gt;try&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;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&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="na"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timeout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// infra, not model&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;parse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;                                   &lt;span class="c1"&gt;// genuinely unparseable&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Classify by the signal state, not by "the JSON didn't parse." An &lt;code&gt;AbortError&lt;/code&gt; when your own controller fired is a &lt;code&gt;timeout&lt;/code&gt; — an infra event you own — not a model-quality datapoint. Conflate them and every timeout inflates your hallucination rate and deflates the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tell was in the latencies
&lt;/h2&gt;

&lt;p&gt;We should have caught this without reading a line of code, because the evidence was sitting in the numbers. Every single "hallucination" had a latency of 5000–5004 ms. Real model failures scatter across the latency distribution; these were pinned to the wall, all five clustered at exactly the timeout. A failure mode that always takes the same round number of milliseconds is never the model — it's a clock. &lt;strong&gt;Log per-attempt latency next to every failure&lt;/strong&gt;, or you will stare at a bogus quality regression and never see the fingerprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest split
&lt;/h2&gt;

&lt;p&gt;This is eval-harness measurement integrity, not a claim that the model is better than you thought — though in this case it was. None of it makes a weak model strong. It stops an infra artifact from being scored as incompetence. If you benchmark any model behind a timeout: give the eval its own budget separate from prod, classify aborts by the signal not the exception, and log the latency next to the verdict. Otherwise the first thing your benchmark measures is your own patience.&lt;/p&gt;

&lt;p&gt;(This is how we score the frontier lane for &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the data layer you ask in English — the eval harness runs a separate capability budget from the production ask path, so the number reflects the model, not the SLA.)&lt;/p&gt;

</description>
      <category>llm</category>
      <category>benchmarking</category>
      <category>eval</category>
    </item>
    <item>
      <title>Your "best model" toggle quietly serves the cheap model. Ship a 409 instead.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Tue, 25 Aug 2026 01:34:38 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-best-model-toggle-quietly-serves-the-cheap-model-ship-a-409-instead-77o</link>
      <guid>https://dev.to/omer_hochman/your-best-model-toggle-quietly-serves-the-cheap-model-ship-a-409-instead-77o</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/model-preset-fail-loud/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every AI product has a model picker now — Fast / Balanced / Best, or a dropdown of names. You add one to your app. Then you hit the branch that decides how good a knob it is: the user picked &lt;strong&gt;Best&lt;/strong&gt;, but the premium lane isn't available right now. No API key on file, no paid plan, the metered lane is dark. What do you do?&lt;/p&gt;

&lt;p&gt;The tempting answer is to quietly serve the default chain. The request succeeds, an answer comes back, nobody sees an error. It feels graceful. It is the single worst thing you can do, and it took us a review pass to stop doing it ourselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  A placebo knob is worse than no knob
&lt;/h2&gt;

&lt;p&gt;When "Best" silently downgrades to "cheapest", the user asked for one thing and got another, and the &lt;em&gt;only&lt;/em&gt; signal they ever get is quality variance. Their answers are a little worse than they expected, intermittently, for reasons they can't see. They won't file a bug that says "the Best toggle is a no-op" — they'll conclude your product is unreliable and churn. You've spent UI real estate and a database column building a control that lies, and the lie is expensive precisely because it's invisible.&lt;/p&gt;

&lt;p&gt;A product with no picker at all is more honest: it makes no promise it can't keep. So if you ship the knob, the knob has to never lie about which chain answered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest contract is three lines
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;fast&lt;/code&gt; &lt;strong&gt;pins the cheap chain — always.&lt;/strong&gt; Even when a stored provider key &lt;em&gt;would&lt;/em&gt; auto-upgrade the request. An explicit per-request instruction beats an ambient credential: the caller who wrote &lt;code&gt;model: fast&lt;/code&gt; into a nightly CI job means it, and silently spending their premium budget because a key happens to be on file is the same betrayal in the other direction.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;best&lt;/code&gt; &lt;strong&gt;gets a real frontier lane, or it fails loud.&lt;/strong&gt; If the premium lane is live for this caller, route to it. If not, return a &lt;code&gt;409&lt;/code&gt; with a machine-branchable error code (&lt;code&gt;model_unavailable&lt;/code&gt;) and a fix-it link — not a 200 with a trace note nobody reads. An error the caller can branch on is a contract; a buried warning is a shrug.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;auto&lt;/code&gt; / absent &lt;strong&gt;stays the default chain.&lt;/strong&gt; No opinion expressed, no promise made, cheapest-that-works — the behavior you already had before the knob existed.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// one pure function, shared by every surface&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;selectDispatchLane&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preset&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fast&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;        &lt;span class="c1"&gt;// pin, ignore stored keys&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preset&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;best&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasFrontierLane&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;frontier&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;model_unavailable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FIX_IT_URL&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultLane&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;                    &lt;span class="c1"&gt;// auto / absent&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The refusal is your demand signal
&lt;/h2&gt;

&lt;p&gt;Here's the bonus that makes failing loud strictly better than degrading quietly. Every &lt;code&gt;model_unavailable&lt;/code&gt; you return is a user telling you, in the clearest possible terms, that they want a paid lane you haven't lit yet. Count them and you have a demand curve with an honest denominator — real requests for the upgrade, not survey wishes. A silent downgrade throws that signal away; a 409 files it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it one function, or your surfaces will drift
&lt;/h2&gt;

&lt;p&gt;The trap after you get the contract right is re-implementing it per surface. Your HTTP API, SDK, CLI, MCP server, and web app each resolve the preset, and the fourth one you write will subtly disagree with the first — one of them forgets the anonymous short-circuit and serves &lt;code&gt;best&lt;/code&gt; off the free path before the lane check ever runs. Put the precedence in one pure function every surface calls, cover it with one test file, and the knob means the same thing everywhere by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A model knob is a contract, not a capability.&lt;/strong&gt; The free chain still answers most questions fine — this was never about having the best model. It's about the toggle never lying about which chain answered. Pin when told to pin, upgrade when you can, and fail loud with a code the caller can act on when you can't. Anything else is a placebo, and placebos cost you the trust you built the picker to earn.&lt;/p&gt;

&lt;p&gt;(This is how the model preset works on &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the data layer you ask in English: &lt;code&gt;fast&lt;/code&gt; pins the free chain, &lt;code&gt;best&lt;/code&gt; gets a frontier lane or a &lt;code&gt;409 model_unavailable&lt;/code&gt; with a fix-it link, and one shared function resolves it across the API, SDK, CLI, MCP server, and the &lt;code&gt;&amp;lt;nlq-data&amp;gt;&lt;/code&gt; element — so the knob can't drift and can't lie.)&lt;/p&gt;

</description>
      <category>llm</category>
      <category>api</category>
      <category>ux</category>
    </item>
    <item>
      <title>Your LLM health probe passed. Your agent still starved.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Sat, 22 Aug 2026 20:13:32 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-llm-health-probe-passed-your-agent-still-starved-50d0</link>
      <guid>https://dev.to/omer_hochman/your-llm-health-probe-passed-your-agent-still-starved-50d0</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/llm-preflight-probe-health/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We gate an end-to-end suite on a live free LLM: before the agent test runs, a pre-flight probe checks the model is up. The probe was green. The suite failed anyway — six runs in a row. Every failure was the agent starving mid-task on a model the probe had just certified healthy. Here is what a health probe in front of an LLM provider has to actually check, learned one red run at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Probe the exact shape you'll use — not a 1-token ping
&lt;/h2&gt;

&lt;p&gt;The cheap probe is a one-token completion: send "hi", get a token back, call it healthy. But your agent doesn't send "hi" — it does a tool-call round trip: system prompt, tool schemas, a turn that must emit a well-formed tool call. A model (or a saturated free pool) can return a token for "hi" and still fail to produce a valid tool call under load. Probe the shape you depend on. Ours now does a real tool-call round trip and asserts the response parses as a tool invocation, because that is the capability the suite needs — not liveness.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Check the body, not the status
&lt;/h2&gt;

&lt;p&gt;This one cost us the most time. The probe checked &lt;code&gt;res.ok&lt;/code&gt; and moved on. But an AI gateway in front of the provider wraps an upstream 429 in an HTTP 200 envelope — the transport succeeded, the body carries the error. &lt;code&gt;res.ok&lt;/code&gt; is necessary, never sufficient, for anything behind a gateway.&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="c1"&gt;// Looks healthy. Isn't.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gatewayUrl&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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;healthy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 200 wrapping an upstream 429&lt;/span&gt;

&lt;span class="c1"&gt;// The upstream status lives in the body.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&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="nx"&gt;finish_reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unhealthy&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Saturated free pools flap — require N consecutive healthy probes
&lt;/h2&gt;

&lt;p&gt;A single healthy probe against a free model pool is a coin flip when the pool is busy: the next request lands on a different, throttled backend. One green probe means "a backend was free 40 ms ago," not "the pool is healthy." We now require three consecutive healthy probes before the gate opens — enough to distinguish a stable pool from a flapping one, cheap enough to stay fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Probe-time health can't promise a 15-minute window
&lt;/h2&gt;

&lt;p&gt;Even three green probes only certify the moment they ran. A suite that takes fifteen minutes will outlive that certification — the pool that was healthy at minute zero can throttle at minute nine. A pre-flight gate reduces the odds of starting a doomed run; it cannot guarantee the run finishes. Pair the gate with client-side backoff and retry inside the run, so a mid-run throttle is absorbed instead of failing the suite. The gate and the backoff do different jobs; you need both.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Never hard-code one model — walk an ordered candidate list
&lt;/h2&gt;

&lt;p&gt;Our six red runs all pointed at one hard-coded free model. When that single model's pool got busy, there was no fallback and no signal beyond a failed suite. The fix is an ordered candidate list: probe the first, fall through to the next on an unhealthy verdict, and run against the first that clears. One model is a single point of failure you control — so don't build one.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Probe-healthy is not agent-competent
&lt;/h2&gt;

&lt;p&gt;The subtlest lesson: a model can ace the probe and still be useless to the agent. Trace-verified in our runs, a probe-healthy free model spammed forbidden tools, leaked raw &lt;code&gt;&amp;lt;|tool_call_id|&amp;gt;&lt;/code&gt; framing tokens into its tool arguments, and burned a 240-second budget without ever reaching a verdict. Health and competence are different axes. Gate CI on health — is the pool up — but rank your candidate list by demonstrated competence — which model actually finishes the task. A model that flunks the task never earns the top slot no matter how green its probe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest split
&lt;/h2&gt;

&lt;p&gt;This is CI reliability engineering, not a claim about model quality. None of it makes a weak model strong; it stops a busy free pool from being scored as a broken product. If you gate any pipeline on a live LLM provider: probe the real shape, read the body not the status, require consecutive greens, pair the gate with in-run backoff, walk a candidate list, and rank that list by competence — not by whichever model answered your ping first.&lt;/p&gt;

&lt;p&gt;(These probes guard the agent E2E suite for &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the data layer you ask in English — we run the free-model chain in CI on purpose, so the harness has to tell a saturated pool apart from a real regression.)&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ci</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
