<?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: robzepdev</title>
    <description>The latest articles on DEV Community by robzepdev (@robzepdev).</description>
    <link>https://dev.to/robzepdev</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%2F4137777%2F874c6eed-962a-4f8e-8d70-5e2e732ca7f8.png</url>
      <title>DEV Community: robzepdev</title>
      <link>https://dev.to/robzepdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/robzepdev"/>
    <language>en</language>
    <item>
      <title>Structured output guarantees the shape, not the lengths</title>
      <dc:creator>robzepdev</dc:creator>
      <pubDate>Wed, 07 Oct 2026 07:29:50 +0000</pubDate>
      <link>https://dev.to/robzepdev/structured-output-guarantees-the-shape-not-the-lengths-3p5</link>
      <guid>https://dev.to/robzepdev/structured-output-guarantees-the-shape-not-the-lengths-3p5</guid>
      <description>&lt;p&gt;I run the first real batch of my release tracker on 2026-09-28: 126 releases, one Message Batches&lt;br&gt;
call, done in 17 minutes. The API reported 126 successes out of 126. My pipeline stored 122.&lt;/p&gt;

&lt;p&gt;Four summaries were thrown away by my own validation. Not because the model hallucinated, not&lt;br&gt;
because the JSON was malformed, not because a field was missing. Because the title was six&lt;br&gt;
characters too long.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;IMAGE 1: the Axios 1.19.0 page in production, showing "error: summary unavailable"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The four
&lt;/h2&gt;

&lt;p&gt;Here they are, with their length against a limit of 120:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Release&lt;/th&gt;
&lt;th&gt;Title&lt;/th&gt;
&lt;th&gt;Characters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Axios 1.19.0&lt;/td&gt;
&lt;td&gt;Axios 1.19.0 raises the &lt;code&gt;form-data&lt;/code&gt; floor to fix a CRLF injection advisory and fixes NO_PROXY, interceptor and progress bugs&lt;/td&gt;
&lt;td&gt;124&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js 26.8.0&lt;/td&gt;
&lt;td&gt;Node.js 26.8.0 adds ZIP APIs in &lt;code&gt;zlib&lt;/code&gt;, AES-SIV cipher modes, REPL syntax highlighting and new &lt;code&gt;sqlite&lt;/code&gt; statement methods&lt;/td&gt;
&lt;td&gt;121&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turborepo&lt;/td&gt;
&lt;td&gt;Workspace task orchestration replaces topological chunking, plus remote shared build artifacts and &lt;code&gt;sideEffectsCache&lt;/code&gt; rework.&lt;/td&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pnpm 11.27.0&lt;/td&gt;
&lt;td&gt;pnpm 11.27.0 isolates registry metadata caches per URL path, adds global &lt;code&gt;nodeDownloadMirrors&lt;/code&gt;, and fixes a store symlink flaw&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read them. They are good titles. Every other field was correct: the key points were within the&lt;br&gt;
allowed count, every citation resolved to a real line of the release notes, the link between the&lt;br&gt;
breaking changes and the "action required" verdict held, and &lt;code&gt;stop_reason&lt;/code&gt; was &lt;code&gt;end_turn&lt;/code&gt; on all&lt;br&gt;
four. These were paid, correct summaries, discarded over punctuation-sized overshoot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why structured output did not stop it
&lt;/h2&gt;

&lt;p&gt;My request carries the Zod schema as &lt;code&gt;output_config.format&lt;/code&gt;. Constrained decoding is doing real work&lt;br&gt;
there: I have never once received malformed JSON, a missing required field, or a string where an&lt;br&gt;
array belongs. That part is a guarantee, and it is worth having.&lt;/p&gt;

&lt;p&gt;But the guarantee is about the &lt;strong&gt;shape&lt;/strong&gt; of the JSON, not about the &lt;strong&gt;values&lt;/strong&gt; inside it. A&lt;br&gt;
&lt;code&gt;maxLength&lt;/code&gt; on a string is not a structural constraint that a decoder can enforce token by token,&lt;br&gt;
the way it can enforce that an object closes or that a key is one of five allowed names. By the time&lt;br&gt;
the model is 118 characters into a title, nothing in the machinery is counting, and nothing is going&lt;br&gt;
to stop it at 120.&lt;/p&gt;

&lt;p&gt;So counting characters stays something the model does by eye. And by eye, it misses by a little.&lt;/p&gt;

&lt;p&gt;Not for lack of asking. My prompt is explicit:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hard limit: 120 characters, spaces included. Count them before answering.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It aimed well: 122 of 126 landed under the limit, and the four that missed were 1 to 6 characters&lt;br&gt;
over. That is not a model that ignored the instruction. That is a model doing arithmetic on a string&lt;br&gt;
it is in the middle of writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that cannot be expressed at all
&lt;/h2&gt;

&lt;p&gt;Worse than a &lt;code&gt;maxLength&lt;/code&gt; that is ignored is a rule that never reaches the schema in the first place.&lt;/p&gt;

&lt;p&gt;My summary schema has a &lt;code&gt;.refine()&lt;/code&gt; on it: a summary that lists breaking changes cannot also report&lt;br&gt;
"safe to upgrade". That is the whole product in one line, and it is the one rule I would least like&lt;br&gt;
to be decorative.&lt;/p&gt;

&lt;p&gt;It does not survive the trip to JSON Schema. A cross-field invariant is not expressible there at&lt;br&gt;
all, so it exists only in my own parsing step, after the response comes back. On this batch it held&lt;br&gt;
on all 126, and I believe it mostly holds because the prompt makes the relationship obvious. But&lt;br&gt;
"it held 126 times" is an observation, not a guarantee, and I should not confuse the two. The&lt;br&gt;
&lt;code&gt;.refine()&lt;/code&gt; stays, and it stays as the last word: anything that fails it is not shown.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is a gap, not a number
&lt;/h2&gt;

&lt;p&gt;The obvious move is to raise the limit. I did, but the interesting part is what I did &lt;strong&gt;not&lt;/strong&gt; raise.&lt;/p&gt;

&lt;p&gt;The schema maximum went from 120 to 140. &lt;strong&gt;The prompt still asks for 120.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That distance is the entire fix. The model keeps aiming at 120, which is the length I actually want,&lt;br&gt;
and the few characters it overshoots land in a margin instead of destroying a paid summary. If I had&lt;br&gt;
raised the prompt to 140 as well, I would have moved the same problem to 141 and learned nothing.&lt;/p&gt;

&lt;p&gt;The prompt text is untouched, so &lt;code&gt;PROMPT_VERSION&lt;/code&gt; stays at &lt;code&gt;v4&lt;/code&gt; and my evaluation set stays&lt;br&gt;
comparable across the change. A schema widening is not a prompt change, and it was useful that the&lt;br&gt;
versioning made me say which one this was.&lt;/p&gt;

&lt;p&gt;I also considered truncating at the limit. I decided against it: a title cut mid-word is worse than&lt;br&gt;
a title that runs slightly long, and it would hide the problem instead of measuring it. I checked&lt;br&gt;
the layout with a real 140-character title instead: two lines at 1280px, four lines at 390px, no&lt;br&gt;
overflow. The title lives in exactly one place, the &lt;code&gt;&amp;lt;h2&amp;gt;&lt;/code&gt; of the release page, which made that&lt;br&gt;
cheap to verify.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;IMAGE 2: React 19.3.0 in production, a title that fit, with its five key points and line citations&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Do not pay twice for the same answer
&lt;/h2&gt;

&lt;p&gt;The four summaries existed. They were generated, they were billed, and they were correct. Resending&lt;br&gt;
them would have cost about 12 cents, which is nothing, and would also have been the wrong habit.&lt;/p&gt;

&lt;p&gt;So the repair reads the saved batch results from disk, revalidates them through exactly the same&lt;br&gt;
parser the pages use, and writes &lt;code&gt;UPDATE&lt;/code&gt; statements to a &lt;code&gt;.sql&lt;/code&gt; file. All four pass at 140. The&lt;br&gt;
script touches no database: applying the file stays a deliberate gesture in the SQL editor of the&lt;br&gt;
environment I mean.&lt;/p&gt;

&lt;p&gt;There is a trap hiding in that sentence, and it nearly cost me the option. The &lt;code&gt;expires_at&lt;/code&gt; of a&lt;br&gt;
batch is &lt;strong&gt;24 hours from creation&lt;/strong&gt;, not the result retention period of 29 days that the&lt;br&gt;
documentation makes easy to assume. I had saved the 126 results to a local file the same day, out of&lt;br&gt;
habit rather than foresight, and that habit is the only reason this repair was free. Save the&lt;br&gt;
results file. It costs nothing and it buys you every repair you have not thought of yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost to learn
&lt;/h2&gt;

&lt;p&gt;The batch billed $4.16: a mean of $0.033 per summary, a median of $0.0245. I had estimated $2.94,&lt;br&gt;
measured on 14 releases I had picked by hand. The median was nearly right. The tail was not: real&lt;br&gt;
release notes are longer than the ones I chose to test with, and the ten most expensive summaries&lt;br&gt;
were 21% of the spend. That is a separate lesson and it deserves its own post.&lt;/p&gt;

&lt;p&gt;The 3% discard is what I actually came away with. It is not a bug I fixed, it is a number I did not&lt;br&gt;
have before: the measured distance between what structured output promises and what I had assumed it&lt;br&gt;
promised.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest status at the time of writing:&lt;/strong&gt; that SQL has not been applied yet, so those four releases&lt;br&gt;
still read "summary unavailable" in production. The screenshot above is the live page, not a staged&lt;br&gt;
one. The repair is written and verified; running it is a manual step still sitting in my queue.&lt;/p&gt;




&lt;p&gt;The tracker is a side project: it watches releases of languages, frameworks and libraries and turns&lt;br&gt;
their release notes into summaries that say whether you need to act before upgrading. Everything&lt;br&gt;
above is from the real run, not a reconstruction.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Enforce your free-plan limit in the database, not in the UI</title>
      <dc:creator>robzepdev</dc:creator>
      <pubDate>Mon, 28 Sep 2026 10:29:19 +0000</pubDate>
      <link>https://dev.to/robzepdev/enforce-your-free-plan-limit-in-the-database-not-in-the-ui-330p</link>
      <guid>https://dev.to/robzepdev/enforce-your-free-plan-limit-in-the-database-not-in-the-ui-330p</guid>
      <description>&lt;p&gt;Your free plan says "up to 10". Where does that 10 actually live?&lt;/p&gt;

&lt;p&gt;In most apps I have read, it lives in the interface: a button that disables itself at ten, a count in a store, maybe a check in the route handler that writes the row. That is a suggestion, not a limit. A second tab, a page left open since yesterday, a direct call to the API with a token from devtools, and the eleventh row is in the table.&lt;/p&gt;

&lt;p&gt;I put mine in Postgres. This is what that took, and the three things I got wrong on the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The product is a release tracker: you follow the technologies in your stack and it tells you what shipped and whether upgrading needs work. The Free plan follows up to 10.&lt;/p&gt;

&lt;p&gt;Follows are the one thing a signed in developer writes, and row level security already limits every row to their own user id, so the browser talks to the database directly. There is no route of mine in the middle:&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;follow&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;follows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;developerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;technology_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;technologyId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;follows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;technology_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a deliberate choice, and it is what makes the rest of this possible: if the database refuses, the refusal arrives in the mutation that asked, not in a log I will read next week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule, in the database
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enforce_follow_rules&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;
  &lt;span class="n"&gt;followed&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;retired&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;technologies&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;technology_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'A retired Technology cannot be followed'&lt;/span&gt;
      &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;errcode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'FRTRD'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;perform&lt;/span&gt; &lt;span class="n"&gt;pg_advisory_xact_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hashtextextended&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plans&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'free'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'free'&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&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="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;followed&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;follows&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;followed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
      &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'The Free Plan follows at most 10 Technologies'&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;errcode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'FLIMT'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="n"&gt;enforce_follow_rules&lt;/span&gt;
  &lt;span class="k"&gt;before&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;follows&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;each&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enforce_follow_rules&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in there are worth more than the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The advisory lock.&lt;/strong&gt; Without it the whole thing is theatre. Two tabs inserting at the same moment both run the count, both read nine, both pass. &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt; on a hash of the user id serialises the decision per developer and releases at the end of the transaction. It is exactly the "added from another session" case the feature is supposed to survive, and it is the one I would have shipped broken if I had not written a test that inserts a follow from outside the browser first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;security definer&lt;/code&gt;.&lt;/strong&gt; The trigger applies to the service role too. That is what I want, and it also means my own test fixtures cannot set up a followed retired technology directly: they have to follow first and retire after, which is the only way the case appears in real life anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The plan check.&lt;/strong&gt; The limit is for &lt;code&gt;free&lt;/code&gt; only. What a paid plan allows is not decided yet, and the trigger is the place where that decision will land.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refusals should be codes, not sentences
&lt;/h2&gt;

&lt;p&gt;The interesting part is not raising the error, it is getting it to the row that has to explain itself.&lt;/p&gt;

&lt;p&gt;I gave each rule an SQLSTATE of my own: &lt;code&gt;FLIMT&lt;/code&gt; for the limit, &lt;code&gt;FRTRD&lt;/code&gt; for a retired technology. PostgREST carries the code to the browser in the error object, so the page never parses a message:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BY_CODE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&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;FollowRejection&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;FLIMT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;limit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;FRTRD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;retired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;followRejectionFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;FollowRejection&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="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;BY_CODE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&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;unknown&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;p&gt;Anything I did not plan for, a dropped connection or an expired session, becomes &lt;code&gt;unknown&lt;/code&gt;, which still has a remedy to offer ("try again") instead of failing silently. Two rules, three outcomes, and the wording of the database error stays where it belongs: in the console.&lt;/p&gt;

&lt;p&gt;Pick codes in the &lt;code&gt;XX000&lt;/code&gt; custom space and write them down somewhere the next person will look. Mine are in the migration, in a comment right above the function.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug the first real refusal found
&lt;/h2&gt;

&lt;p&gt;The UI follows optimistically: the row flips, the count moves, and if the database says no the previous state goes back. I had built that with TanStack Query, with one &lt;code&gt;useMutation&lt;/code&gt; inside the follow toggle component.&lt;/p&gt;

&lt;p&gt;It worked perfectly until the first refusal arrived, and then the refusal was invisible.&lt;/p&gt;

&lt;p&gt;One mutation per row means the error lives in the component that was pressed. The row that has to show "this technology is retired" is a sibling; it never sees it. Worse, the count in the top bar is computed somewhere else again, so three parts of the same page can each hold a different idea of what just happened.&lt;/p&gt;

&lt;p&gt;The fix is dull and it is the whole lesson: one mutation for the page, in the provider, exposed through context.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Follows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;following&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;atLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;following&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;FREE_FOLLOW_LIMIT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;isFollowing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;following&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;following&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rejectionOn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&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="nx"&gt;technologyId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;rejected&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;rejection&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The count, the block at ten and the refusal now come from one place, so they cannot drift apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the interface still owes you
&lt;/h2&gt;

&lt;p&gt;Enforcing the rule in the database does not excuse the UI from anticipating it. At ten of ten the unpressed buttons get &lt;code&gt;aria-disabled="true"&lt;/code&gt; and point at a note that says what to do, and they stay in the tab order. A real &lt;code&gt;disabled&lt;/code&gt; would take them out of it, and a keyboard user would meet a control that has silently vanished and no explanation of why.&lt;/p&gt;

&lt;p&gt;There is a nice side effect. Playwright refuses to click a control marked &lt;code&gt;aria-disabled&lt;/code&gt;, so the test has to say &lt;code&gt;click({ force: true })&lt;/code&gt;. That line is the proof that the block is there: if the attribute went missing, the forced click would go through and the assertion after it would fail.&lt;/p&gt;

&lt;p&gt;The number 10 is now written in two places, &lt;code&gt;FREE_FOLLOW_LIMIT&lt;/code&gt; in the code and the trigger in the migration. That duplication bothers me a little, and it is the correct trade: the one in the migration is the one that decides, the one in the code is how the page explains itself before asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it
&lt;/h2&gt;

&lt;p&gt;The trigger is 25 lines. It ships with the schema, it applies to every client that will ever exist, including the ones I have not written yet, and it turned a plan limit from a claim into a fact.&lt;/p&gt;

&lt;p&gt;What it does not do is tell your users anything. That is still the interface's job, and the trigger only makes it easier, because now the page reacts to a rule instead of pretending to be one.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>supabase</category>
    </item>
    <item>
      <title>How I keep an LLM from inventing breaking changes</title>
      <dc:creator>robzepdev</dc:creator>
      <pubDate>Thu, 24 Sep 2026 14:13:05 +0000</pubDate>
      <link>https://dev.to/robzepdev/how-i-keep-an-llm-from-inventing-breaking-changes-5625</link>
      <guid>https://dev.to/robzepdev/how-i-keep-an-llm-from-inventing-breaking-changes-5625</guid>
      <description>&lt;p&gt;I'm building a tool that reads every new release of React, TypeScript, Vite and friends, and tells you two things: what changed, and whether you need to touch your code before upgrading.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ed01ig55fyna5a49m88.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ed01ig55fyna5a49m88.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The summarising part is easy. Any decent model will write you a tidy paragraph about a release.&lt;/p&gt;

&lt;p&gt;The part that decides whether the tool is worth anything is different: &lt;strong&gt;when it says "action required", is that true?&lt;/strong&gt; A tracker that invents a breaking change is worse than no tracker, because you'll go looking for a migration that doesn't exist. And a tracker that misses one is worse still.&lt;/p&gt;

&lt;p&gt;Here is everything I do about that, in the order I had to learn it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The model only sees the release notes
&lt;/h2&gt;

&lt;p&gt;The system prompt says it in the second line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Use only the release notes in the user message. Do not use what you know about the project from elsewhere.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This matters more than it sounds. A model asked about "React 19.2.0" has opinions about React 19 from training. Those opinions are often right, which is exactly what makes them dangerous: they're right until the release where they aren't, and you have no way to tell which one you're looking at.&lt;/p&gt;

&lt;p&gt;So the notes go into the user message, wrapped in a tag, and nothing else about the project does.&lt;/p&gt;

&lt;p&gt;Two consequences follow immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No notes, no summary.&lt;/strong&gt; If the release has no notes, the request is never built. The release stays in the catalogue with no summary rather than getting a plausible one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notes are never truncated.&lt;/strong&gt; There's a hard ceiling of 80,000 characters. Over it, the summary fails with a reason instead of being generated from the first half. A truncated summary is a confidently wrong one: the breaking change was probably in the part you cut.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&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;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;release notes are empty&lt;/span&gt;&lt;span class="dl"&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;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_NOTES_LENGTH&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;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`release notes are &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; characters, over the &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MAX_NOTES_LENGTH&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; limit`&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;Both are failures I'd rather see than paper over.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "Action required" is a definition, not a judgement
&lt;/h2&gt;

&lt;p&gt;The prompt doesn't ask the model whether you should act. It asks for a list:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"breakingChanges": every change the notes state or clearly describe as breaking, removed or incompatible, so that users must change code, configuration or runtime to upgrade.&lt;/p&gt;

&lt;p&gt;"actionRequired": true exactly when "breakingChanges" is not empty.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And then, flatly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never infer a breaking change the notes do not mention, even if the version number suggests one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last line earns its place. Without it, a &lt;code&gt;x.0.0&lt;/code&gt; invites the model to reason "this is a major, majors break things" and produce something. Version numbers are a &lt;em&gt;classification&lt;/em&gt; input for me, never evidence about content.&lt;/p&gt;

&lt;p&gt;The output shape is constrained with a schema, so a reply that isn't that shape never reaches the database. But keep reading, schemas do less than you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. I never ask the model for line numbers
&lt;/h2&gt;

&lt;p&gt;Each key point and each breaking change links back into the official notes, to the line it came from. On the page it looks like &lt;code&gt;release-notes:20&lt;/code&gt;, and clicking it opens the notes, scrolls to line 20 and puts a caret at the right column.&lt;/p&gt;

&lt;p&gt;The obvious implementation is to ask the model for the line number. Don't. Models count badly, and, worse, they count badly &lt;em&gt;invisibly&lt;/em&gt;: you get &lt;code&gt;42&lt;/code&gt;, it looks like a line number, and it's off by nine.&lt;/p&gt;

&lt;p&gt;So the model returns a &lt;strong&gt;verbatim quote&lt;/strong&gt; instead:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A "quote" is the stretch of the release notes the item comes from, copied character for character [...] Never adjust, shorten with an ellipsis or rewrite a quote to fit: a quote that does not appear verbatim in the notes is dropped.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And I compute the position myself, by searching my stored copy of the notes for that string:&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="nf"&gt;locateCitation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → { fromLine, toLine, column, length } | null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxo8tgp7e2ccnuyvo5e7t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxo8tgp7e2ccnuyvo5e7t.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;null&lt;/code&gt; means the quote isn't in the notes. Which means the model made it up. Which means no pointer is rendered.&lt;/p&gt;

&lt;p&gt;This is the part I'm most pleased with, because of what it collapses. "The model must not hallucinate citations" and "references must stay inside the document" sound like two separate things to test. They're the same thing, and it's a string search. The column offset for the caret falls out for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Then I stopped guessing and measured
&lt;/h2&gt;

&lt;p&gt;Everything above is reasoning about a prompt. Reasoning about a prompt is how you end up with a prompt that feels great and is wrong.&lt;/p&gt;

&lt;p&gt;So: an evaluation set. 14 real releases, from 8 projects, 5 of them not even in my catalogue, so I couldn't accidentally tune the product to the test. The notes are &lt;strong&gt;copied into the repo&lt;/strong&gt;, not read from the database. The catalogue changes; the set must not, or two reports a month apart mean nothing.&lt;/p&gt;

&lt;p&gt;The human judgement lives in a &lt;code&gt;set.json&lt;/code&gt; next to them, and it's more nuanced than a pass/fail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;required&lt;/strong&gt; breaking changes: miss one and the case fails;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tolerated&lt;/strong&gt; breaking changes: legitimately arguable, listing one isn't an error;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;actionRequired&lt;/code&gt;, which can be &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt;, or &lt;strong&gt;&lt;code&gt;"either"&lt;/code&gt;&lt;/strong&gt; for releases whose notes describe a behaviour change without ever calling it breaking. Some releases genuinely are a judgement call, and pretending otherwise just bakes my mood on the day into the test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The comparison itself is a pure function with its own unit tests. The script around it only does I/O. Two prompt versions, one batch each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;v3&lt;/th&gt;
&lt;th&gt;v4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;cases evaluated&lt;/td&gt;
&lt;td&gt;13/14&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14/14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"action required" correct&lt;/td&gt;
&lt;td&gt;13/13&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14/14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;missing breaking changes&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;invented citations&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;verbatim quotes&lt;/td&gt;
&lt;td&gt;66 of 72&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;77 of 81&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six of the fourteen are deliberate duds: patch releases whose notes say nothing but "see the changelog". All six came back "no action needed". That was the point of including them: I wanted evidence that the model shuts up when there's nothing to say, not just that it's eloquent when there is.&lt;/p&gt;

&lt;p&gt;Two full runs cost about 50 cents through the Batch API. Roughly $0.02 a summary.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Two bugs that only a real call could find
&lt;/h2&gt;

&lt;p&gt;Both of these survived a fake adapter, unit tests and integration tests. Both died within minutes of the first real batch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Line endings.&lt;/strong&gt; Release notes fetched from GitHub contain &lt;code&gt;CRLF&lt;/code&gt;. The model quotes them back with &lt;code&gt;LF&lt;/code&gt;. The page searches the normalised text. So any quote spanning more than one line silently failed to locate. No crash, no log, just a pointer that quietly wasn't there. In production. &lt;code&gt;locateCitation&lt;/code&gt; now normalises both sides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A sixth key point.&lt;/strong&gt; The prompt asks for 3 to 5 key points, and the schema says so too. A reply came back with six, failed validation, and threw away a summary I had already paid for.&lt;/p&gt;

&lt;p&gt;The lesson is worth stating plainly: &lt;strong&gt;structured output constrains the shape of a reply, never its counts.&lt;/strong&gt; You can guarantee "an array of objects with these fields". You cannot guarantee "at most five". So now the parser keeps the first five — they arrive in order of importance — and the prompt states the limit as a hard rule with the consequence spelled out. That change is what took the prompt to v4, and it's why the "cases evaluated" row went from 13 to 14.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Running out of credit is not a failure
&lt;/h2&gt;

&lt;p&gt;A smaller thing, but it's the same instinct. When the Anthropic account runs out of credit, the affected releases go back to &lt;code&gt;pending&lt;/code&gt;, not to &lt;code&gt;failed&lt;/code&gt;. &lt;code&gt;failed&lt;/code&gt; is final — those summaries would never have regenerated, even after topping up.&lt;/p&gt;

&lt;p&gt;And the case is &lt;strong&gt;typed&lt;/strong&gt;, not sniffed out of an error message: &lt;code&gt;submit&lt;/code&gt; throws a &lt;code&gt;CreditExhaustedError&lt;/code&gt;, &lt;code&gt;collect&lt;/code&gt; returns &lt;code&gt;credit_exhausted&lt;/code&gt;. "Other errors must not be mistaken for exhausted credit" is then something the compiler enforces, rather than something I promise.&lt;/p&gt;

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

&lt;p&gt;If I did this again from scratch, three things carry over:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ground the model in a document you also store.&lt;/strong&gt; Not because the model is untrustworthy, but because it gives you something to check the answer &lt;em&gt;against&lt;/em&gt;, mechanically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for quotes, derive everything else.&lt;/strong&gt; Anything positional the model computes is a silent error waiting to happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spend the fifty cents.&lt;/strong&gt; A fake adapter tells you your code works. A real call tells you your assumptions do. I had two bugs in production-bound code that no amount of local testing was ever going to surface.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tracker is live and collecting releases while I build the rest of it. The summaries aren't switched on in production yet — that's the next thing, and it's the first part of this project that costs money to leave running.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://release-tracker-drab.vercel.app/technologies/react" rel="noopener noreferrer"&gt;https://release-tracker-drab.vercel.app/technologies/react&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>TypeScript never ships x.y.0: classifying releases the way projects actually version</title>
      <dc:creator>robzepdev</dc:creator>
      <pubDate>Tue, 22 Sep 2026 14:13:13 +0000</pubDate>
      <link>https://dev.to/robzepdev/typescript-never-ships-xy0-classifying-releases-the-way-projects-actually-version-56a</link>
      <guid>https://dev.to/robzepdev/typescript-never-ships-xy0-classifying-releases-the-way-projects-actually-version-56a</guid>
      <description>&lt;p&gt;I'm building a tracker that tells developers which releases of their stack actually matter.&lt;br&gt;
The first problem wasn't AI or scraping. It was a deceptively simple question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Given a version string, how important is this release?&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why "just use SemVer" doesn't work
&lt;/h2&gt;

&lt;p&gt;The naive rule: &lt;code&gt;x.0.0&lt;/code&gt; is a major, &lt;code&gt;x.y.0&lt;/code&gt; is a minor, everything else is a patch.&lt;br&gt;
It holds for many libraries, and breaks for some of the most popular ones.&lt;/p&gt;

&lt;p&gt;I ended up with one &lt;em&gt;classification strategy&lt;/em&gt; per project, mapping a version&lt;br&gt;
to one of four buckets: &lt;strong&gt;Spotlight&lt;/strong&gt; (you should look), &lt;strong&gt;Latest&lt;/strong&gt; (new stuff),&lt;br&gt;
&lt;strong&gt;Digest&lt;/strong&gt; (patches, batched weekly) and &lt;strong&gt;Preview&lt;/strong&gt; (beta and rc).&lt;/p&gt;
&lt;h3&gt;
  
  
  1. &lt;code&gt;semver&lt;/code&gt;: the default
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;x.0.0&lt;/code&gt; → Spotlight, &lt;code&gt;x.y.0&lt;/code&gt; → Latest, patches → Digest. Vite fits perfectly:&lt;br&gt;
after 8.0.0 came 16 patches, 8.0.1 through 8.0.16, in under three months.&lt;br&gt;
A good tracker shows you 8.0.0 and quietly folds the rest into a digest.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. &lt;code&gt;node&lt;/code&gt;: even majors become LTS
&lt;/h3&gt;

&lt;p&gt;Node's even majors (20, 22, 24) become Long Term Support; odd ones don't.&lt;br&gt;
So an even &lt;code&gt;x.0.0&lt;/code&gt; goes to Spotlight, while odd majors and &lt;code&gt;x.y.0&lt;/code&gt; go to Latest.&lt;br&gt;
Same version shape, different meaning.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. &lt;code&gt;typescript&lt;/code&gt;: there is no stable x.y.0
&lt;/h3&gt;

&lt;p&gt;TypeScript doesn't follow SemVer: every &lt;code&gt;x.y&lt;/code&gt; can contain breaking changes.&lt;br&gt;
And the numbering is unusual:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5.9.0-beta → 5.9.1-rc → 5.9.2   ← first stable 5.9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rule that looks for &lt;code&gt;x.y.0&lt;/code&gt; would never flag TypeScript 5.9 at all.&lt;br&gt;
So the strategy is: &lt;strong&gt;the first stable version of each &lt;code&gt;x.y&lt;/code&gt; that the sources observed is Spotlight&lt;/strong&gt;;&lt;br&gt;
later ones are Digest. The strategy has to look at history, not just the string.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;zerover&lt;/code&gt;: in 0.x the minor breaks
&lt;/h3&gt;

&lt;p&gt;For libraries still in &lt;code&gt;0.x&lt;/code&gt;, &lt;code&gt;0.y.0&lt;/code&gt; is effectively a major.&lt;br&gt;
So &lt;code&gt;0.y.0&lt;/code&gt; → Spotlight, other &lt;code&gt;0.y.z&lt;/code&gt; → Digest, and from &lt;code&gt;1.0.0&lt;/code&gt; on it falls back to &lt;code&gt;semver&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Previews and noise
&lt;/h2&gt;

&lt;p&gt;Every strategy treats pre-releases the same way. Only suffixes that are exactly&lt;br&gt;
&lt;code&gt;beta&lt;/code&gt; or &lt;code&gt;rc&lt;/code&gt; (optionally numbered, like &lt;code&gt;-rc.1&lt;/code&gt;) count as previews.&lt;br&gt;
Canary, nightly, experimental, alpha and hash- or date-stamped builds&lt;br&gt;
(&lt;code&gt;-rc.0-next-…&lt;/code&gt;) aren't tracked at all. They're the bulk of the noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep it pure, test it on real versions
&lt;/h2&gt;

&lt;p&gt;Each strategy is a pure TypeScript function, with no I/O,&lt;br&gt;
tested against real version histories from npm and GitHub.&lt;br&gt;
Real data catches what toy examples don't: TypeScript's &lt;code&gt;.2&lt;/code&gt;, React's&lt;br&gt;
&lt;code&gt;-rc.0-next-&amp;lt;hash&amp;gt;&lt;/code&gt; tags, &lt;code&gt;v&lt;/code&gt;-prefixed tags.&lt;/p&gt;

&lt;p&gt;And when a rule is still wrong for an edge case, a manual override lives&lt;br&gt;
in the database and is never overwritten by the next sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Classification decides &lt;em&gt;what deserves your attention&lt;/em&gt;. The next step decides&lt;br&gt;
&lt;em&gt;what to do about it&lt;/em&gt;: an AI summary of each important release, with breaking changes,&lt;br&gt;
migration steps and an explicit &lt;strong&gt;Action required / Safe to upgrade&lt;/strong&gt; verdict.&lt;br&gt;
It will be grounded only in the official release notes. If they don't mention&lt;br&gt;
a breaking change, neither will the summary.&lt;/p&gt;

&lt;p&gt;You can see the classification at work on an early preview:&lt;br&gt;
&lt;a href="https://release-tracker-drab.vercel.app/technologies/vite" rel="noopener noreferrer"&gt;Vite's release page&lt;/a&gt;: 8.0.0 up front,&lt;br&gt;
the 16 patches folded into a digest. Name and domain are still provisional.&lt;/p&gt;

&lt;p&gt;I'm building this in public. If your stack has a versioning scheme that would&lt;br&gt;
break these rules, I'd love to hear about it in the comments.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>versioning</category>
    </item>
  </channel>
</rss>
