<?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: Chaminade Dondah ADJOLOU</title>
    <description>The latest articles on DEV Community by Chaminade Dondah ADJOLOU (@jwzqeygxtonh).</description>
    <link>https://dev.to/jwzqeygxtonh</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fthepracticaldev.s3.amazonaws.com%2Fi%2F99mvlsfu5tfj9m7ku25d.png</url>
      <title>DEV Community: Chaminade Dondah ADJOLOU</title>
      <link>https://dev.to/jwzqeygxtonh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jwzqeygxtonh"/>
    <language>en</language>
    <item>
      <title>Give Your AI Feature a Kill Switch, a Budget, and a Paper Trail</title>
      <dc:creator>Chaminade Dondah ADJOLOU</dc:creator>
      <pubDate>Fri, 23 Oct 2026 10:04:57 +0000</pubDate>
      <link>https://dev.to/jwzqeygxtonh/give-your-ai-feature-a-kill-switch-a-budget-and-a-paper-trail-ila</link>
      <guid>https://dev.to/jwzqeygxtonh/give-your-ai-feature-a-kill-switch-a-budget-and-a-paper-trail-ila</guid>
      <description>&lt;p&gt;The second-most dangerous demo is "watch the agent do everything." The most dangerous is shipping that demo to real users on Monday.&lt;/p&gt;

&lt;p&gt;I build web and mobile products with Node, React, and Flutter, and I speak about AI in community spaces. The pattern I keep seeing: teams add a chat box or an "AI assistant" tab, wire an API key, and hope prompt vibes will behave in production. Hope is not an architecture.&lt;/p&gt;

&lt;p&gt;This post is a practical starter kit for fullstack builders: three controls every AI feature should have before you invite non-technical users — a &lt;strong&gt;kill switch&lt;/strong&gt;, a &lt;strong&gt;budget&lt;/strong&gt;, and a &lt;strong&gt;paper trail&lt;/strong&gt;. No cloud-vendor lock-in assumed. You can implement these with boring Node middleware and a small React admin panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why demos lie
&lt;/h2&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%2Fm4n5qhqttcr3b0n11waw.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%2Fm4n5qhqttcr3b0n11waw.png" alt="Architecture: React admin controls kill switch, budget, audit before Node API calls the AI model" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure: gate every model call — kill switch, budget, paper trail.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In a Meetup demo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You are the only user&lt;/li&gt;
&lt;li&gt;You type polite, on-distribution prompts&lt;/li&gt;
&lt;li&gt;You ignore the bill&lt;/li&gt;
&lt;li&gt;You can Ctrl+C when it loops&lt;/li&gt;
&lt;li&gt;Nobody pastes a 40-page PDF of junk to "summarize"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users paste secrets by accident&lt;/li&gt;
&lt;li&gt;Prompts go adversarial or just confused&lt;/li&gt;
&lt;li&gt;A retry storm turns into a surprise invoice&lt;/li&gt;
&lt;li&gt;An agent tool call hits the wrong environment&lt;/li&gt;
&lt;li&gt;You need to explain &lt;em&gt;what happened&lt;/em&gt; three days later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Governance sounds enterprise. The core is simpler: &lt;strong&gt;assume the model will be wrong, expensive, or both — and design the blast radius.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Control 1: Kill switch (feature flags that actually stop spend)
&lt;/h2&gt;

&lt;p&gt;A kill switch is not a blog toggle in Notion. It is a runtime gate checked &lt;em&gt;before&lt;/em&gt; every model call and every tool invocation.&lt;/p&gt;

&lt;p&gt;Minimal shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/aiFlags.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;assertAiEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;featureKey&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;row&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flags&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;featureKey&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;row&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;enabled&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;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;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;AI_FEATURE_DISABLED&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AI_FEATURE_DISABLED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;throw&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it at the edge of your AI service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runAssistant&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;featureKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;assertAiEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;featureKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ... budget check, then model call&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire a React admin (or even a secured &lt;code&gt;POST /admin/flags&lt;/code&gt;) so you can disable &lt;code&gt;meetup-copilot&lt;/code&gt; without redeploying. When something smells wrong at 1 a.m., you want a button — not a pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mobile note:&lt;/strong&gt; Flutter clients should treat &lt;code&gt;AI_FEATURE_DISABLED&lt;/code&gt; as a first-class UI state ("Assistant temporarily off"), not a generic 500. Users forgive maintenance; they do not forgive spinning forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bonus: scoped kills
&lt;/h3&gt;

&lt;p&gt;Prefer per-feature flags over a global "AI off":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;chat.summarize&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;chat.reply-suggest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;agent.tools.github&lt;/code&gt; (keep this off until you are truly ready)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That way a broken experimental agent does not take down a simple title-rewriter that still helps organizers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control 2: Budget (tokens, calls, and blast radius)
&lt;/h2&gt;

&lt;p&gt;If you cannot answer "what is the max this feature can cost today?", you do not have a product — you have an open tab.&lt;/p&gt;

&lt;h3&gt;
  
  
  Soft limits per user / per org
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;assertWithinBudget&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;featureKey&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;usage&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;featureKey&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;limit&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limits&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;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;featureKey&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;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxCalls&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxTokens&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;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;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;AI_BUDGET_EXCEEDED&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AI_BUDGET_EXCEEDED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;throw&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Practical defaults for an early community product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cap calls per user per day&lt;/li&gt;
&lt;li&gt;Cap max input characters (reject the 2MB paste early)&lt;/li&gt;
&lt;li&gt;Cap max tool iterations for agents (e.g. 5 steps, then stop with a clear message)&lt;/li&gt;
&lt;li&gt;Separate "playground" keys from "production" keys&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Hard limits at the provider
&lt;/h3&gt;

&lt;p&gt;Also set provider-side spending caps where they exist. Application budgets fail closed when Redis hiccups; provider caps fail closed when your app has a bug. Use both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Agents need step budgets, not vibes
&lt;/h3&gt;

&lt;p&gt;If you are building anything agent-like (tool calling, multi-step plans):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Maximum steps&lt;/li&gt;
&lt;li&gt;Maximum wall-clock time&lt;/li&gt;
&lt;li&gt;Allowlist of tools (read calendar ✗ delete calendar until proven)&lt;/li&gt;
&lt;li&gt;Human confirmation for irreversible actions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pausing / resuming agents is a hot topic for a reason: long-running autonomy without checkpoints is how you get surprise side effects. Even a simple &lt;code&gt;job&lt;/code&gt; table with &lt;code&gt;status: running | paused | done | failed&lt;/code&gt; beats an unbounded while-loop in memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control 3: Paper trail (logs you can defend)
&lt;/h2&gt;

&lt;p&gt;When someone asks "why did the assistant tell my users X?", screenshots are not enough.&lt;/p&gt;

&lt;p&gt;Log structured events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&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;aiEvents&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;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;featureKey&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="nx"&gt;promptVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// e.g. "rsvp-helper@3"&lt;/span&gt;
  &lt;span class="nx"&gt;inputHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// hash raw input; store raw only if policy allows&lt;/span&gt;
  &lt;span class="na"&gt;toolCalls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...],&lt;/span&gt;   &lt;span class="c1"&gt;// names + args summaries&lt;/span&gt;
  &lt;span class="nx"&gt;outputHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tokensIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tokensOut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;latencyMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&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;h3&gt;
  
  
  Privacy-aware defaults
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Prefer hashes + short redacted previews over full prompts for sensitive products&lt;/li&gt;
&lt;li&gt;Never log API keys, auth headers, or payment fields&lt;/li&gt;
&lt;li&gt;Define retention (30/90 days) and stick to it&lt;/li&gt;
&lt;li&gt;Make "export my AI history / delete my AI history" a real endpoint if you serve EU users or simply respect people&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Prompt versioning is underrated
&lt;/h3&gt;

&lt;p&gt;Store prompts in git (&lt;code&gt;prompts/rsvp-helper.v3.md&lt;/code&gt;) and send &lt;code&gt;promptVersion&lt;/code&gt; with every call. When behavior changes, you can diff. "The model got dumber" is often "we edited the prompt on Tuesday and forgot."&lt;/p&gt;

&lt;h2&gt;
  
  
  A thin React admin that earns its keep
&lt;/h2&gt;

&lt;p&gt;You do not need a full observability suite on day one. A single authenticated page with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Feature flags table + toggle&lt;/li&gt;
&lt;li&gt;Today's spend / call counts per feature&lt;/li&gt;
&lt;li&gt;Last 50 AI events (status, latency, error code)&lt;/li&gt;
&lt;li&gt;Big red "disable all AI features" button&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;…will save you more pain than another model provider abstraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this maps to a real feature
&lt;/h2&gt;

&lt;p&gt;Suppose you add "Suggest event description" for organizers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Flag: &lt;code&gt;events.ai.suggest-description&lt;/code&gt; (default off in prod until soak test)&lt;/li&gt;
&lt;li&gt;Budget: 20 suggestions / organizer / day, 1k max input chars&lt;/li&gt;
&lt;li&gt;Paper trail: prompt version + hashes + tokens&lt;/li&gt;
&lt;li&gt;UX: show the suggestion as a diff the human must accept — never auto-publish&lt;/li&gt;
&lt;li&gt;Kill: if suggestions degrade, flip the flag; the rest of the app keeps working&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is product engineering. The model is a dependency, not the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Kill switches belong in the request path, not in a document.&lt;/li&gt;
&lt;li&gt;Budgets need daily caps, input caps, and agent step caps — plus provider hard limits.&lt;/li&gt;
&lt;li&gt;Paper trails need structured events, prompt versions, and privacy rules.&lt;/li&gt;
&lt;li&gt;Human confirmation for irreversible actions is not optional.&lt;/li&gt;
&lt;li&gt;Ship AI as a flaggable slice of a working app, not as the app's only spine.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are adding your first assistant to a Node/React or Flutter app, start with these three controls before you polish the avatar animation. Cool UI with no blast-radius design is how demos become incidents.&lt;/p&gt;

&lt;p&gt;What control do you wish you had added earlier — flag, budget, or audit log? I am curious what broke first for other builders.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>react</category>
      <category>node</category>
    </item>
    <item>
      <title>Retries Without Regret: Idempotency Keys in Spring Boot and Node</title>
      <dc:creator>Chaminade Dondah ADJOLOU</dc:creator>
      <pubDate>Wed, 21 Oct 2026 10:06:19 +0000</pubDate>
      <link>https://dev.to/jwzqeygxtonh/retries-without-regret-idempotency-keys-in-spring-boot-and-node-2g61</link>
      <guid>https://dev.to/jwzqeygxtonh/retries-without-regret-idempotency-keys-in-spring-boot-and-node-2g61</guid>
      <description>&lt;p&gt;A popular DEV discussion recently asked &lt;a href="https://dev.to/gramli/to-retry-or-not-to-retry-that-is-the-question-1j2l"&gt;to retry or not to retry&lt;/a&gt;. The honest answer is: retry, but only once you've made it safe. On mobile networks, where requests time out after the server has already done the work, the dangerous case isn't "the request failed". It's &lt;strong&gt;"the client doesn't know whether it succeeded."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post covers three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which requests are safe to retry by definition.&lt;/li&gt;
&lt;li&gt;How to make the unsafe ones (usually &lt;code&gt;POST&lt;/code&gt;) safe with an &lt;strong&gt;idempotency key&lt;/strong&gt; stored in Postgres, in both Spring Boot and Node.&lt;/li&gt;
&lt;li&gt;How to retry politely: exponential backoff with jitter.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  1. What HTTP already guarantees
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methods" rel="noopener noreferrer"&gt;RFC 9110&lt;/a&gt; defines a method as idempotent if the intended effect of several identical requests is the same as one. &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;HEAD&lt;/code&gt;, &lt;code&gt;OPTIONS&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; are idempotent; &lt;code&gt;POST&lt;/code&gt; and &lt;code&gt;PATCH&lt;/code&gt; are not.&lt;/p&gt;

&lt;p&gt;That's a contract about &lt;em&gt;intended effect&lt;/em&gt;, so your implementation has to honour it (a &lt;code&gt;PUT&lt;/code&gt; that appends a row isn't idempotent, whatever the RFC says). But it gives a clear starting point:&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%2Fwcqlktlmgy6sakf7y1tv.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%2Fwcqlktlmgy6sakf7y1tv.png" alt="Retry or not? matrix" width="800" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent methods:&lt;/strong&gt; retry with backoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;POST&lt;/code&gt; with an idempotency key:&lt;/strong&gt; retry with the &lt;em&gt;same&lt;/em&gt; key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;POST&lt;/code&gt; without a key:&lt;/strong&gt; don't auto-retry. Query the status first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4xx like 400/401/403/422:&lt;/strong&gt; don't retry; the request itself is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;429 / 503:&lt;/strong&gt; retry, honour &lt;code&gt;Retry-After&lt;/code&gt; if present, add jitter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Idempotency keys
&lt;/h2&gt;

&lt;p&gt;The pattern, popularised by payment APIs like &lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt; and being standardised by the IETF HTTPAPI working group as the &lt;a href="https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/" rel="noopener noreferrer"&gt;&lt;code&gt;Idempotency-Key&lt;/code&gt; header&lt;/a&gt; (an Internet-Draft, not yet an RFC), is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;client&lt;/strong&gt; generates a unique key (a UUID) once per logical operation and sends it on every attempt.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;server&lt;/strong&gt; records the key before doing the work. If it sees the same key again, it returns the stored result instead of redoing the work.&lt;/li&gt;
&lt;/ul&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%2Fhgeumkute95tetfxjfbq.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%2Fhgeumkute95tetfxjfbq.png" alt="Idempotency-Key request lifecycle" width="800" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The table
&lt;/h3&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt;           &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;       &lt;span class="nb"&gt;bigint&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;request_hash&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'in_progress'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;-- in_progress | done&lt;/span&gt;
  &lt;span class="n"&gt;response_code&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;response_body&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&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="k"&gt;key&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;Two details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scope keys per user/client.&lt;/strong&gt; Two users must never collide on the same key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store a hash of the request body.&lt;/strong&gt; If the same key arrives with a different payload, that's a client bug: reject it (Stripe and the IETF draft both treat key reuse with a different payload as an error; the draft suggests &lt;code&gt;422&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Claiming a key atomically
&lt;/h3&gt;

&lt;p&gt;Postgres' &lt;a href="https://www.postgresql.org/docs/current/sql-insert.html" rel="noopener noreferrer"&gt;&lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt;&lt;/a&gt; gives you an atomic "claim": exactly one concurrent request inserts the row, the others get zero rows back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;key&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;request_hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&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="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One row returned:&lt;/strong&gt; you own the key, do the work, then update &lt;code&gt;status='done'&lt;/code&gt; with the response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No row returned:&lt;/strong&gt; load the existing row.

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;done&lt;/code&gt; and same hash → replay the stored response.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;in_progress&lt;/code&gt; → return &lt;code&gt;409 Conflict&lt;/code&gt; so the client retries later.&lt;/li&gt;
&lt;li&gt;different hash → &lt;code&gt;422&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ideally, do the business write and the "done" update in the same transaction, so you never end up with work done and no stored result.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spring Boot
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/payments"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Idempotency-Key"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                &lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;PaymentRequest&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                &lt;span class="nd"&gt;@AuthenticationPrincipal&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DigestUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sha256Hex&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;writeValueAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""
        INSERT INTO idempotency_keys (key, user_id, request_hash)
        VALUES (?, ?, ?) ON CONFLICT (user_id, key) DO NOTHING"""&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;idempotency&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replayOrConflict&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// stores result + status='done'&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;DigestUtils&lt;/code&gt; here is Apache Commons Codec; &lt;code&gt;replayOrConflict&lt;/code&gt; implements the three branches above. Exception handling trimmed for readability.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Node (Express + node-postgres)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/payments&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &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;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Idempotency-Key&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;key&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&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="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;Idempotency-Key required&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;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&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="nf"&gt;stringify&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;body&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;claim&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`INSERT INTO idempotency_keys (key, user_id, request_hash)
     VALUES ($1, $2, $3) ON CONFLICT (user_id, key) DO NOTHING RETURNING key`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hash&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;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;replayOrConflict&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hash&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;executePayment&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;body&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;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// stores result, status='done'&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;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&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="nx"&gt;result&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;h3&gt;
  
  
  Expiry
&lt;/h3&gt;

&lt;p&gt;Keys don't need to live forever. Stripe documents (for its API v1) that keys can be removed after they're at least 24 hours old. Pick a window longer than your longest client retry, and clean up with a scheduled job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Retrying politely: backoff with jitter
&lt;/h2&gt;

&lt;p&gt;If every client retries at exactly 1s, 2s, 4s, they all hit your recovering server at the same moments. AWS's architecture blog post on &lt;a href="https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/" rel="noopener noreferrer"&gt;exponential backoff and jitter&lt;/a&gt; compares strategies and shows that adding randomness spreads retries out. "Full jitter" is the simplest:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withRetry&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&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;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&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;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isRetryable&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="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&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;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crucially, the idempotency key is created &lt;strong&gt;outside&lt;/strong&gt; this function, once, so every attempt reuses 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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withRetry&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/payments&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&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;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&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;
  
  
  When not to retry
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Validation and auth errors.&lt;/strong&gt; Retrying a 422 just produces another 422.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-idempotent calls without a key.&lt;/strong&gt; Show the user a "checking status" state and query the server instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the user is waiting.&lt;/strong&gt; Cap total retry time. A spinner that lasts a minute is worse than a clear error with a "try again" button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries at every layer.&lt;/strong&gt; If the mobile client, the API gateway and the backend's HTTP client all retry 3 times, one failure can become 27 calls. Retry at one layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every non-idempotent endpoint that clients may retry accepts an &lt;code&gt;Idempotency-Key&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Keys are scoped per user and stored with a request hash.&lt;/li&gt;
&lt;li&gt;[ ] The claim uses &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] In-progress duplicates get &lt;code&gt;409&lt;/code&gt;, mismatched payloads get &lt;code&gt;422&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Clients generate the key once, outside the retry loop.&lt;/li&gt;
&lt;li&gt;[ ] Backoff uses jitter and a cap; only one layer retries.&lt;/li&gt;
&lt;li&gt;[ ] Keys expire after a documented window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's the worst duplicate-request bug you've shipped (or caught just in time)?&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>node</category>
      <category>postgres</category>
      <category>backend</category>
    </item>
    <item>
      <title>Supabase Stops Auto-Exposing New Tables on Oct 30: The Checklist to Run This Week</title>
      <dc:creator>Chaminade Dondah ADJOLOU</dc:creator>
      <pubDate>Tue, 20 Oct 2026 10:04:21 +0000</pubDate>
      <link>https://dev.to/jwzqeygxtonh/supabase-stops-auto-exposing-new-tables-on-oct-30-the-checklist-to-run-this-week-32ph</link>
      <guid>https://dev.to/jwzqeygxtonh/supabase-stops-auto-exposing-new-tables-on-oct-30-the-checklist-to-run-this-week-32ph</guid>
      <description>&lt;p&gt;Supabase is a popular backend for hackathons, side projects and production apps alike, and for good reason. You create a table, call it from &lt;code&gt;supabase-js&lt;/code&gt;, and you have a working app before lunch. That convenience came from a default: every new table in the &lt;code&gt;public&lt;/code&gt; schema was automatically reachable through the Data API.&lt;/p&gt;

&lt;p&gt;That default is going away. Supabase announced it in its changelog in April, it's already the default for new projects, and &lt;strong&gt;on October 30, 2026 it will be applied to all existing projects&lt;/strong&gt;. That's about ten days from now.&lt;/p&gt;

&lt;p&gt;Whether you have a weekend hackathon build you want to keep alive, a side project you touch once a month, or an app with real users, there are two ways this can hurt you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It breaks:&lt;/strong&gt; a tutorial, starter repo or AI-generated migration written for the old behavior creates a table and your client gets &lt;code&gt;42501 permission denied&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It leaks:&lt;/strong&gt; you "fix" that error the fast way, or you keep adding tables to an older project where new tables are still auto-granted, and you end up with a table that anyone holding your public key can read, update and delete.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post explains exactly what's changing (from Supabase's own changelog), the mental model you need, and a checklist with SQL you can paste into your migrations this week, before the change reaches your project.&lt;/p&gt;

&lt;h2&gt;
  
  
  What exactly is changing
&lt;/h2&gt;

&lt;p&gt;From the official changelog entry, "Breaking Change: Tables not exposed to Data and GraphQL API automatically":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;April 28, 2026&lt;/td&gt;
&lt;td&gt;Opt-in setting available at project creation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;May 30, 2026&lt;/td&gt;
&lt;td&gt;New behavior becomes the default for new projects (gradual rollout over a few weeks)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;October 30, 2026&lt;/td&gt;
&lt;td&gt;New behavior applied to all existing projects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; when you created a project with default settings, &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;insert&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt; and &lt;code&gt;delete&lt;/code&gt; were granted on every table in &lt;code&gt;public&lt;/code&gt; to the &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt; roles. Every table was reachable via the Data API on creation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt; new tables in &lt;code&gt;public&lt;/code&gt; "require an explicit opt-in (via a Postgres &lt;code&gt;grant&lt;/code&gt;) before the Data API can see them."&lt;/p&gt;

&lt;p&gt;Four clarifications from the same page that matter a lot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Existing tables are not affected.&lt;/strong&gt; They "keep their current grants and stay reachable." Your running app doesn't go dark on October 30.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It only concerns the Data API&lt;/strong&gt; (the auto-generated REST and GraphQL layer that &lt;code&gt;supabase-js&lt;/code&gt; and the other client libraries call). If your app talks to Postgres over a direct connection — psql, an ORM, an app server with a connection string — "this change will not affect you."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RLS behavior is unchanged.&lt;/strong&gt; Grants and Row Level Security are separate layers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the &lt;code&gt;public&lt;/code&gt; schema is concerned.&lt;/strong&gt; Per the changelog FAQ, tables in &lt;code&gt;storage&lt;/code&gt;, &lt;code&gt;auth&lt;/code&gt;, &lt;code&gt;realtime&lt;/code&gt; and custom schemas keep their current grants and defaults.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When a grant is missing, PostgREST returns an explicit error instead of failing silently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"42501"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"permission denied for table your_table"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Grant the required privileges to the current role with: GRANT SELECT ON public.your_table TO anon;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&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%2F0y1c06suf9pyyjxq82mx.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%2F0y1c06suf9pyyjxq82mx.png" alt="Timeline of the rollout: April 28 opt-in, May 30 default for new projects, October 30 enforced on existing projects" width="800" height="307"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The rollout dates come from Supabase's changelog. If your project was created before the new default, October 30 is your date.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Supabase also says it is emailing owners and admins of active projects (a five-week notice went out on September 23, and a final notice is scheduled for October 23), and that the Security Advisor flags affected tables with remediation SQL. If you got one of those emails and filed it away, this is the week to open it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Who gets caught by this
&lt;/h2&gt;

&lt;p&gt;Code written fast, by several people at once, frequently with an AI assistant, and without a review step is exactly the kind of code this change targets. Supabase's changelog names that context as the reason for the change: today, "agents, CLI scripts, and AI platforms create tables too, and many of those operations do not have a human reviewing the diff."&lt;/p&gt;

&lt;p&gt;Hackathon projects fit that description perfectly, but so do side projects and plenty of production apps. Here are the three scenarios I'd expect to see around October 30:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario A — You start a brand-new project.&lt;/strong&gt; New projects have had the new behavior by default since the May 30 rollout. Any tutorial, video or starter template written before that assumes your tables are reachable right after &lt;code&gt;create table&lt;/code&gt;. They won't be. You'll hit &lt;code&gt;42501&lt;/code&gt; and lose time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario B — You keep building on an older project.&lt;/strong&gt; Until October 30, new tables on that project still receive the old default grants. Create a table from a migration or a script without enabling RLS, and it is immediately readable and writable by the &lt;code&gt;anon&lt;/code&gt; role. Then, after October 30, the next table you add to the same project will behave differently from the ones you created last week, which is a confusing way to discover the change. This is the most common case for hackathon builds that turned into side projects, and for production apps that have been running for a while.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario C — You rebuild from migrations later.&lt;/strong&gt; You spin up a fresh project, a preview branch, or reset your local database. Tables created by migrations without explicit grants will not be reachable through the Data API anymore. The app that worked in the demo or in production "suddenly" breaks in the new environment.&lt;/p&gt;

&lt;p&gt;The fix for all three is the same: make access an explicit, written decision in your migrations.&lt;/p&gt;
&lt;h2&gt;
  
  
  The mental model: GRANT and RLS are two different doors
&lt;/h2&gt;

&lt;p&gt;This is the part I'd want every teammate to understand before touching the database.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GRANT&lt;/strong&gt; decides whether a role can touch a table &lt;em&gt;at all&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RLS&lt;/strong&gt; decides &lt;em&gt;which rows&lt;/em&gt; that role sees once it can.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And you need to know who the roles are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;anon&lt;/code&gt;&lt;/strong&gt; is what requests run as when nobody is signed in. Your anon (or publishable) key ships in your frontend bundle. Anyone can extract it with the browser's dev tools. &lt;strong&gt;Whatever &lt;code&gt;anon&lt;/code&gt; can do, the whole internet can do.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;authenticated&lt;/code&gt;&lt;/strong&gt; is what signed-in users run as. If anyone can sign up for your app, this is almost as public as &lt;code&gt;anon&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;service_role&lt;/code&gt;&lt;/strong&gt; is for server-side code only. It bypasses RLS. It must never reach a browser or a mobile app bundle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put grants and RLS together and you get four situations:&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%2Faw81b4sxroon85i3pt4i.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%2Faw81b4sxroon85i3pt4i.png" alt="Matrix of GRANT and RLS combinations: GRANT without RLS exposes every row; GRANT with RLS and no policy returns nothing; GRANT with RLS and a policy returns allowed rows; no GRANT returns 42501" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Red is the leak. Blue is what new tables look like after the change.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Only the red row is dangerous, and it's an easy one to land in. A table created through plain SQL starts with RLS &lt;strong&gt;off&lt;/strong&gt; (that's the Postgres default). Under the old default grants, that meant: readable and writable by &lt;code&gt;anon&lt;/code&gt; from the moment it existed.&lt;/p&gt;

&lt;p&gt;The October 30 change moves new tables to the blue row. That's safer. The danger is in how people get &lt;em&gt;out&lt;/em&gt; of the blue row.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trap 1: "fixing" 42501 with GRANT ALL
&lt;/h2&gt;

&lt;p&gt;You create a table, the app throws &lt;code&gt;42501&lt;/code&gt;, you search the error (or ask your AI assistant), and you find something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Don't do this on its own&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;table&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;notes&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error disappears. If RLS isn't enabled on that table, you have just moved it straight to the red row. The DEV author x3blank made this point well in a recent DEV post, and it's worth repeating: the most common quick fix for this error re-opens exactly the hole the change closes.&lt;/p&gt;

&lt;p&gt;Even the hint in the official error message suggests granting &lt;code&gt;SELECT&lt;/code&gt; to &lt;code&gt;anon&lt;/code&gt;. That's correct for a public catalog. It's wrong for a table of user submissions. The hint tells you &lt;em&gt;what&lt;/em&gt; is missing, not &lt;em&gt;whether&lt;/em&gt; that role should have it.&lt;/p&gt;

&lt;p&gt;The same caution applies to the changelog's own rollback FAQ, which shows how to bulk-grant &lt;code&gt;select, insert, update, delete on all tables in schema public&lt;/code&gt; if an early opt-in breaks your app. That's a legitimate emergency rollback, but it puts every table back in reach of &lt;code&gt;anon&lt;/code&gt;, so check RLS on each of them afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: before Oct 30, adding a GRANT doesn't remove the default ones
&lt;/h2&gt;

&lt;p&gt;If you're working on an older project (Scenario B), &lt;code&gt;create table&lt;/code&gt; still gives &lt;code&gt;anon&lt;/code&gt; full CRUD until the change reaches your project. Adding &lt;code&gt;grant select ... to authenticated&lt;/code&gt; doesn't take anything away from &lt;code&gt;anon&lt;/code&gt;, because &lt;code&gt;GRANT&lt;/code&gt; only adds privileges.&lt;/p&gt;

&lt;p&gt;So the order that's safe both before and after October 30 is: &lt;strong&gt;revoke first, then enable RLS, then write policies, then grant only what's needed.&lt;/strong&gt; The revoke is a harmless no-op once the new default applies.&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%2F9tk8q3nnzb7xgmn4nary.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%2F9tk8q3nnzb7xgmn4nary.png" alt="Six steps in one migration: create table, revoke defaults, enable RLS, write policies, grant only what's needed, test with the anon key" width="800" height="347"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same order in every migration. Step 6 is the one that tells you the truth.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The migration template to paste into your repo
&lt;/h2&gt;

&lt;p&gt;Let's say you're building a small app where signed-in users file reports, and there's a public list of categories anyone can read. Two tables, two very different access needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A public, read-only table&lt;/strong&gt;&lt;br&gt;
&lt;/p&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;table&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;categories&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 1. Remove anything that was auto-granted (no-op on projects with the new default)&lt;/span&gt;
&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;table&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;categories&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;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. RLS on, always&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&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;categories&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Policy: anyone may read&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"categories are public"&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;categories&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. Minimal grants: read for clients, full access for server code&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;table&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;categories&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;table&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;categories&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A private, owner-only table&lt;/strong&gt;&lt;br&gt;
&lt;/p&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;table&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;reports&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;char_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;table&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;reports&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;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&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;reports&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"users read their own reports"&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;reports&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"users create their own reports"&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;reports&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

&lt;span class="c1"&gt;-- No grant to anon at all. Signed-in users can read and insert, nothing else.&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&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;table&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;reports&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;table&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;reports&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;anon&lt;/code&gt; gets nothing on &lt;code&gt;reports&lt;/code&gt;.&lt;/strong&gt; Not "nothing because of RLS", but nothing because there is no grant. Two independent layers both say no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;authenticated&lt;/code&gt; doesn't get &lt;code&gt;update&lt;/code&gt; or &lt;code&gt;delete&lt;/code&gt;.&lt;/strong&gt; If your feature doesn't need it, don't grant it. You can add it with a policy later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grants, RLS and policies live in the same file.&lt;/strong&gt; Supabase's changelog recommends exactly this: treat the three steps "as a unit" and bundle the &lt;code&gt;GRANT&lt;/code&gt; statements alongside &lt;code&gt;ENABLE ROW LEVEL SECURITY&lt;/code&gt; and the policies in the same migration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If an insert fails with a permission error on a &lt;em&gt;sequence&lt;/em&gt; (for example if you used &lt;code&gt;serial&lt;/code&gt; instead of an identity column), grant &lt;code&gt;usage, select&lt;/code&gt; on that specific sequence to the role that inserts. Don't reach for a schema-wide grant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: October 30 doesn't fix tables that are already open
&lt;/h2&gt;

&lt;p&gt;"Existing tables keep their grants" means nothing breaks. It also means any table that is in the red row today &lt;em&gt;stays&lt;/em&gt; in the red row. Whether it's last month's hackathon project or an app with real users, audit it before you add anything new.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check 1: ask Postgres which tables &lt;code&gt;anon&lt;/code&gt; can read with RLS off
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;has_table_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SELECT'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any row this returns is a table readable with your public key. Views don't have RLS, so check &lt;code&gt;relkind = 'v'&lt;/code&gt; separately.&lt;/p&gt;

&lt;p&gt;A subtle point from the x3blank post: if you audit with &lt;code&gt;information_schema.role_table_grants&lt;/code&gt; from a restricted role, you can get &lt;em&gt;zero rows&lt;/em&gt; and wrongly conclude there are no grants. That view only shows privileges where the current role is involved as grantor or grantee. &lt;code&gt;has_table_privilege()&lt;/code&gt; doesn't have that problem.&lt;/p&gt;

&lt;p&gt;The dashboard can help too: the changelog points to the Data API exposure badge in the Table Editor and to the Security Advisor as the places that list the tables worth reviewing.&lt;/p&gt;

&lt;p&gt;If a table should not be reachable through the Data API at all, the changelog gives the per-table revoke:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;table&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;your_table&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Check 2: use the anon key like an attacker would
&lt;/h3&gt;

&lt;p&gt;This is the check I trust most, because it tests the real path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://&amp;lt;project-ref&amp;gt;.supabase.co/rest/v1/reports?select=*&amp;amp;limit=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a private table, a &lt;code&gt;200&lt;/code&gt; that returns rows is a red flag. An error or an empty result is what you want. Run it for every table before you call the audit done, and again after every migration that adds a table.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check 3: opt in early so there are no surprises
&lt;/h3&gt;

&lt;p&gt;You don't have to wait for October 30. The changelog gives the SQL to adopt the new behavior on an existing project today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;sequences&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These change the defaults for &lt;em&gt;future&lt;/em&gt; tables and sequences created by the &lt;code&gt;postgres&lt;/code&gt; role in &lt;code&gt;public&lt;/code&gt;. Existing tables keep their current grants. Keep the &lt;code&gt;for role postgres&lt;/code&gt; part. You can confirm what defaults remain with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;pg_get_userbyid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;defaclrole&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;grantor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;defaclnamespace&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regnamespace&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;defaclobjtype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;defaclacl&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_default_acl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Doing this this week means you experience the new behavior on your own schedule, on a day you're watching for it, instead of discovering it the first time someone adds a table after October 30.&lt;/p&gt;

&lt;h2&gt;
  
  
  If an AI assistant writes your migrations
&lt;/h2&gt;

&lt;p&gt;Supabase's changelog explicitly suggests updating your AI tool's system prompt, or adopting the Supabase agent skill, so that grants are part of table creation. Whatever tool you use, I'd add three lines to its instructions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Every CREATE TABLE in public must be followed, in the same migration, by:
  revoke defaults, enable RLS, explicit policies, and minimal GRANTs per role.
- Never grant anything to anon unless the data is meant to be public.
- Never use GRANT ALL to fix a 42501 error. Explain which role needs which privilege and why.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then review what it produces. The error-message hint is designed so agents can self-correct, but an agent that self-corrects by granting &lt;code&gt;SELECT&lt;/code&gt; to &lt;code&gt;anon&lt;/code&gt; on a private table has fixed the error and created a leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same principle, outside Supabase
&lt;/h2&gt;

&lt;p&gt;None of this is Supabase-specific thinking. It's least privilege, made explicit. Two examples from my own projects, with different stacks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TwinFlow&lt;/strong&gt; (a Go sidecar in front of PostgreSQL) says it plainly in its README: "It is a proxy, not a permission system: the Postgres role you give it is the access it has." The tool doesn't pretend to add security on top of a role that's too powerful. The role is the boundary, so you choose the role carefully. Its contributing guide also forbids logging SQL arguments, row payloads, tokens or connection strings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ezoato&lt;/strong&gt; (a past-exam-papers platform with a PHP/MySQL backend) documents that its AI session endpoint only returns sessions belonging to the current JWT, responding with a 404 otherwise, and ships a security test script that covers this kind of ownership check (IDOR) alongside input validation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Different databases, same rule: decide who can touch what, write it down in code, and test it from the outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist to run before October 30
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;This week, on every existing project&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] List the projects that use the Data API (&lt;code&gt;supabase-js&lt;/code&gt;, any client library, &lt;code&gt;/rest/v1/&lt;/code&gt; or &lt;code&gt;/graphql/v1&lt;/code&gt;). Projects that only use a direct Postgres connection can skip the rest.&lt;/li&gt;
&lt;li&gt;[ ] Run the &lt;code&gt;has_table_privilege&lt;/code&gt; audit and fix any red-row tables (enable RLS with policies, or revoke per table).&lt;/li&gt;
&lt;li&gt;[ ] Check the Security Advisor and the Data API exposure badge for flagged tables.&lt;/li&gt;
&lt;li&gt;[ ] Decide whether to run the official opt-in SQL now, so the project behaves like a new one on your schedule.&lt;/li&gt;
&lt;li&gt;[ ] Make sure the &lt;code&gt;service_role&lt;/code&gt; key is only in server-side environment variables, never in a &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;/&lt;code&gt;VITE_&lt;/code&gt;/Flutter asset file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;In your repo&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Put the two migration templates above in your repo (&lt;code&gt;supabase/migrations/&lt;/code&gt; or a &lt;code&gt;sql/&lt;/code&gt; folder).&lt;/li&gt;
&lt;li&gt;[ ] Find every migration, seed script or provisioning step that creates a table in &lt;code&gt;public&lt;/code&gt; and add explicit grants next to it.&lt;/li&gt;
&lt;li&gt;[ ] Add the three instructions above to your AI assistant's system prompt or project rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Every time you add a table&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Create → revoke → RLS → policies → minimal grants, in one file.&lt;/li&gt;
&lt;li&gt;[ ] When you see &lt;code&gt;42501&lt;/code&gt;: ask which role needs which privilege, never &lt;code&gt;GRANT ALL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Review AI-generated SQL for grants to &lt;code&gt;anon&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Run the anon-key &lt;code&gt;curl&lt;/code&gt; against the new table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Before October 30&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Rebuild from migrations once (new project, branch or local reset) to prove the migrations are complete on their own.&lt;/li&gt;
&lt;li&gt;[ ] Delete abandoned tables &lt;em&gt;and&lt;/em&gt; the SQL files that create them, so they can't come back on the next rebuild.&lt;/li&gt;
&lt;li&gt;[ ] Run the &lt;code&gt;has_table_privilege&lt;/code&gt; query one last time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The October 30 change makes new tables safe by default. Your job is to not undo that with a rushed &lt;code&gt;GRANT ALL&lt;/code&gt; the first time something breaks.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Supabase changelog, "Breaking Change: Tables not exposed to Data and GraphQL API automatically": &lt;a href="https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically" rel="noopener noreferrer"&gt;https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase CLI PR #5524 (local default for &lt;code&gt;auto_expose_new_tables&lt;/code&gt;): &lt;a href="https://github.com/supabase/cli/pull/5524" rel="noopener noreferrer"&gt;https://github.com/supabase/cli/pull/5524&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;x3blank on DEV, "Supabase stops auto-granting new tables on Oct 30. Don't fix the 42501 with GRANT ALL": &lt;a href="https://dev.to/x3blank/supabase-stops-auto-granting-new-tables-on-oct-30-dont-fix-the-42501-with-grant-all-3lom"&gt;https://dev.to/x3blank/supabase-stops-auto-granting-new-tables-on-oct-30-dont-fix-the-42501-with-grant-all-3lom&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;TwinFlow README and CONTRIBUTING: &lt;a href="https://github.com/Donchaminade/twinflow" rel="noopener noreferrer"&gt;https://github.com/Donchaminade/twinflow&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Ezoato backend docs: &lt;a href="https://github.com/Donchaminade/ezoato" rel="noopener noreferrer"&gt;https://github.com/Donchaminade/ezoato&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;All diagrams in this post are mine, made for this article.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Threads, Pools, Promise.all: Why Your Postgres Falls Over First (Spring Boot vs Node)</title>
      <dc:creator>Chaminade Dondah ADJOLOU</dc:creator>
      <pubDate>Mon, 19 Oct 2026 09:59:47 +0000</pubDate>
      <link>https://dev.to/jwzqeygxtonh/threads-pools-promiseall-why-your-postgres-falls-over-first-spring-boot-vs-node-ke9</link>
      <guid>https://dev.to/jwzqeygxtonh/threads-pools-promiseall-why-your-postgres-falls-over-first-spring-boot-vs-node-ke9</guid>
      <description>&lt;p&gt;Two recent DEV posts describe the same incident from opposite sides of the stack. In one, a Spring Boot service learned that &lt;a href="https://dev.to/rohitbabugaddeti/threads-are-not-connections-a-spring-boot-concurrency-incident-27lb"&gt;threads are not connections&lt;/a&gt;. In the other, a Node service found out &lt;a href="https://dev.to/subashthiruppathy_5e0f532/why-await-promiseall-can-crash-your-database-and-how-to-throttle-concurrent-async-tasks-in-3859"&gt;why &lt;code&gt;await Promise.all()&lt;/code&gt; can crash your database&lt;/a&gt;. There's also a write-up of &lt;a href="https://dev.to/rbonweb/inngests-database-connection-pool-collapsed-twice-in-one-day-and-restarting-made-it-worse-aei"&gt;a connection pool that collapsed twice in a day, where restarting made it worse&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The pattern is the same everywhere. Concurrency in your app has become almost free: virtual threads in Java 21+, &lt;code&gt;Promise.all&lt;/code&gt; in Node. Concurrency in your database has not. Postgres has a hard &lt;code&gt;max_connections&lt;/code&gt;, and each connection is a real backend process on the server. The app scales, the pool saturates, and the database (or the pool in front of it) is the first thing to fall over.&lt;/p&gt;

&lt;p&gt;Instead of arguing about it, I measured it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL 18.4&lt;/strong&gt;, local, with &lt;code&gt;max_connections = 25&lt;/code&gt; (deliberately small, so the limits show up fast). As usual, 3 slots are reserved for superusers, which leaves 22 for the app role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Java 21&lt;/strong&gt; with &lt;strong&gt;HikariCP 7.0.2&lt;/strong&gt; (the version Spring Boot 4.1.1 manages) and the PostgreSQL JDBC driver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node 20&lt;/strong&gt; with &lt;strong&gt;node-postgres (&lt;code&gt;pg&lt;/code&gt;)&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;p-limit&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The workload is a burst of &lt;strong&gt;1,000 concurrent queries&lt;/strong&gt;, each &lt;code&gt;select pg_sleep(0.05)&lt;/code&gt;: a 50 ms query, roughly a real indexed lookup plus some network.&lt;/li&gt;
&lt;li&gt;The pool max is &lt;strong&gt;10&lt;/strong&gt; on both stacks, which is the default for both HikariCP and node-postgres.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are runs on one machine, not a benchmark suite. The &lt;em&gt;shape&lt;/em&gt; of the results is the point, not the exact milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The math nobody does first
&lt;/h2&gt;

&lt;p&gt;A pool of 10 connections running 50 ms queries can serve at most:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10 connections / 0.05 s per query = 200 queries per second
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So 1,000 queries need &lt;strong&gt;at least 5 seconds&lt;/strong&gt;, however many threads or promises you throw at them. Everything below is about what happens to the requests that are waiting during those 5 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 1: instances × pool size must fit &lt;code&gt;max_connections&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The first failure doesn't even involve a burst. You scale your Node API to three instances, each with the default &lt;code&gt;max: 10&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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;pools&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;QUERY&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3 instances x max=10: ok=57/300
  'remaining connection slots are reserved for roles with the SUPERUSER attribute': 108
  'sorry, too many clients already': 135
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 30 wanted connections against 22 available. Most queries didn't just wait: they &lt;strong&gt;failed&lt;/strong&gt;, because Postgres refuses connections beyond its limit and &lt;code&gt;pg&lt;/code&gt; passes the error to your query.&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%2Fl09a14mexlrm7vn9750w.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%2Fl09a14mexlrm7vn9750w.png" alt="Connections multiply: instances × pool size must fit max_connections" width="800" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The budget has to cover everything that connects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;instances × pool max  +  migrations  +  cron/workers  +  admin/monitoring  ≤  max_connections − reserved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly what bites during autoscaling and rolling deploys, when old and new instances are briefly up at the same time. It's also why hosted Postgres providers put a pooler in front of the database. Supabase, for example, offers Supavisor in session mode (port 5432) and transaction mode (port 6543). Transaction mode lets many short-lived clients share few connections, but per the &lt;a href="https://supabase.com/docs/guides/database/connecting-to-postgres" rel="noopener noreferrer"&gt;Supabase docs&lt;/a&gt; it doesn't support prepared statements or session state (&lt;code&gt;SET&lt;/code&gt;, advisory locks, &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;). With JDBC, for instance, they list &lt;code&gt;prepareThreshold=0&lt;/code&gt;. Know which mode your connection string uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 2: the same burst, both stacks
&lt;/h2&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%2Fcl80bfr0rev7uc0ykwa6.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%2Fcl80bfr0rev7uc0ykwa6.png" alt="Measured results for 1,000 concurrent 50 ms queries" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spring Boot / HikariCP&lt;/strong&gt; (connection timeout = how long a caller waits for a free connection):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scenario                              ok   poolTimeout  rejected  p50     p99     wall
platform threads (200), timeout 1s   576   424          0         1742ms  3013ms  3089ms
virtual threads, timeout 1s          175   825          0         1003ms  1009ms  1063ms
virtual + bulkhead(20, 100ms)         28     0        972          195ms   266ms   319ms
virtual threads, timeout 30s (dflt) 1000     0          0         2695ms  5173ms  5220ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Node / node-postgres:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Promise.all x1000, max=10, acquire timeout 1s:  ok=200/1000  ('timeout exceeded when trying to connect': 800)
Promise.all x1000, max=10, no timeout:          ok=1000/1000 in 5089 ms   peak waitingCount = 990
p-limit(10) x1000, max=10, acquire timeout 1s:  ok=1000/1000 in 5100 ms   peak waitingCount = 0
p-limit(10) + shed when queue &amp;gt; 100:            ok=110, busy(503)=890 in 583 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this tells us:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Virtual threads removed the queue you didn't know you had.&lt;/strong&gt; With a fixed pool of 200 platform threads (Tomcat's default &lt;code&gt;server.tomcat.threads.max&lt;/code&gt; is 200), at most 200 requests were waiting on Hikari at once. The rest waited in the executor's queue, where Hikari's timeout doesn't apply. With virtual threads, all 1,000 requests hit the pool at the same moment (998 threads waiting at peak), and with a 1 s timeout, 82% failed. Turning on &lt;code&gt;spring.threads.virtual.enabled=true&lt;/code&gt; is great for I/O-bound apps, but it moves the bottleneck straight to the pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long timeouts "fix" errors by hiding latency.&lt;/strong&gt; With Hikari's default 30 s connection timeout, or with node-postgres's default &lt;code&gt;connectionTimeoutMillis: 0&lt;/code&gt; (wait forever), everything succeeded, with p99 above 5 s. That's fine for a batch job, but for a mobile client with a 10 s HTTP timeout and a user tapping "retry", it's a slow outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A limiter doesn't make anything faster.&lt;/strong&gt; &lt;code&gt;p-limit(10)&lt;/code&gt; took the same ~5.1 s as the unbounded &lt;code&gt;Promise.all&lt;/code&gt;; the pool's waiting count just dropped from 990 to 0. What it gives you is &lt;em&gt;control&lt;/em&gt;: the queue now lives in your code, where you can measure it and cap it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load shedding is the only option that keeps latency bounded.&lt;/strong&gt; The bulkhead (Java) and the queue cap (Node) rejected most of the burst within ~0.3–0.6 s, with a clean "busy" error the client can retry with backoff. Whether that's right depends on the endpoint. For a user-facing read, failing fast is usually better than a 5-second spinner.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Node pattern
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pLimit&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p-limit&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;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PG_POOL_MAX&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;// per process!&lt;/span&gt;
  &lt;span class="na"&gt;connectionTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// default is 0 = wait forever&lt;/span&gt;
  &lt;span class="na"&gt;idleTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&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;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PG_POOL_MAX&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;10&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;MAX_QUEUE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                          &lt;span class="c1"&gt;// beyond this, shed load&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BusyError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&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;withDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&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;limit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pendingCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_QUEUE&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BusyError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;database busy, retry later&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="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// usage&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;withDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;select ... where id = $1&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]))));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Map &lt;code&gt;BusyError&lt;/code&gt; to &lt;code&gt;503&lt;/code&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; header in your error middleware. And when you see &lt;code&gt;Promise.all(items.map(query))&lt;/code&gt; over a list of user-controlled size, that's a code-review comment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spring Boot pattern
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# application.properties
&lt;/span&gt;&lt;span class="py"&gt;spring.threads.virtual.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spring.datasource.hikari.maximum-pool-size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;span class="py"&gt;spring.datasource.hikari.connection-timeout&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DbBulkhead&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Semaphore&lt;/span&gt; &lt;span class="n"&gt;permits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ~2x pool size&lt;/span&gt;

    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;T&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Callable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;permits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tryAcquire&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MILLISECONDS&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ResponseStatusException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SERVICE_UNAVAILABLE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"busy, retry later"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;permits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Semaphore&lt;/code&gt; logic is exactly what I measured above (in a plain Java harness against HikariCP). The Spring wiring is shown as a sketch. If you already use Resilience4j, its &lt;code&gt;Bulkhead&lt;/code&gt; does the same job with metrics built in.&lt;/p&gt;

&lt;p&gt;Two Spring-specific traps make pool exhaustion worse:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Holding a connection while doing something slow.&lt;/strong&gt; A &lt;code&gt;@Transactional&lt;/code&gt; method that calls an external HTTP API (or an LLM) keeps its connection for the whole call. Do the slow call outside the transaction, then open a short transaction to write the result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open Session in View.&lt;/strong&gt; With Spring Data JPA, &lt;code&gt;spring.jpa.open-in-view&lt;/code&gt; is enabled by default (Boot logs a warning about it), which can keep a connection tied to the request while the view renders. For APIs, set it to &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How big should the pool be?
&lt;/h2&gt;

&lt;p&gt;Usually smaller than you think. HikariCP's own &lt;a href="https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing" rel="noopener noreferrer"&gt;pool sizing guide&lt;/a&gt; starts from &lt;code&gt;connections ≈ (core_count × 2) + effective_spindle_count&lt;/code&gt; on the &lt;em&gt;database&lt;/em&gt; server, and argues that a small pool with a queue in front of it beats a big pool. Practical steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start from the database's capacity and the budget formula above, not from your thread count.&lt;/li&gt;
&lt;li&gt;Divide by the maximum number of app instances you can run at once (including during deploys).&lt;/li&gt;
&lt;li&gt;Load-test, and increase only if the database has CPU headroom while the pool is saturated.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The metrics to watch before it breaks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hikari&lt;/strong&gt; (via Micrometer/Actuator): &lt;code&gt;hikaricp.connections.pending&lt;/code&gt; (threads waiting), &lt;code&gt;hikaricp.connections.active&lt;/code&gt;, &lt;code&gt;hikaricp.connections.acquire&lt;/code&gt; (time to get a connection) and &lt;code&gt;hikaricp.connections.timeout&lt;/code&gt; (a counter you want to stay at zero).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;node-postgres&lt;/strong&gt;: &lt;code&gt;pool.totalCount&lt;/code&gt;, &lt;code&gt;pool.idleCount&lt;/code&gt;, &lt;code&gt;pool.waitingCount&lt;/code&gt;, plus your limiter's &lt;code&gt;pendingCount&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres&lt;/strong&gt;: &lt;code&gt;select state, count(*) from pg_stat_activity group by state;&lt;/code&gt;. Lots of &lt;code&gt;idle in transaction&lt;/code&gt; means connections are held by code that isn't talking to the database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Alert on &lt;em&gt;waiting&lt;/em&gt; and &lt;em&gt;acquire time&lt;/em&gt;, not just on errors. In every run above, the waiting count spiked long before the first error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Threads and promises are cheap; connections are a budget. Write the budget down.&lt;/li&gt;
&lt;li&gt;Virtual threads and &lt;code&gt;Promise.all&lt;/code&gt; push all of your concurrency onto the pool. Put a limit in front of it.&lt;/li&gt;
&lt;li&gt;Pick a timeout on purpose: short for user-facing paths, longer for batch jobs.&lt;/li&gt;
&lt;li&gt;Shed load with a clear 503 rather than letting latency grow without limit.&lt;/li&gt;
&lt;li&gt;Measure pending/waiting, and look at &lt;code&gt;pg_stat_activity&lt;/code&gt; before you resize anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which default bit you first: Hikari's 30 seconds, or node-postgres's "wait forever"? Tell me in the comments.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>springboot</category>
      <category>node</category>
      <category>postgres</category>
    </item>
    <item>
      <title>You Don't Need Python to Ship AI: Adding an LLM Feature to a Spring Boot API</title>
      <dc:creator>Chaminade Dondah ADJOLOU</dc:creator>
      <pubDate>Mon, 19 Oct 2026 09:57:10 +0000</pubDate>
      <link>https://dev.to/jwzqeygxtonh/you-dont-need-python-to-ship-ai-adding-an-llm-feature-to-a-spring-boot-api-20di</link>
      <guid>https://dev.to/jwzqeygxtonh/you-dont-need-python-to-ship-ai-adding-an-llm-feature-to-a-spring-boot-api-20di</guid>
      <description>&lt;p&gt;AI owns DEV's front page this week: &lt;a href="https://dev.to/sylwia-lask/your-browser-can-do-what-5-ai-features-that-actually-work-1blb"&gt;AI features running in the browser&lt;/a&gt; on one side, &lt;a href="https://dev.to/francistrdev/ai-fatigue-yea-i-know-f9"&gt;AI fatigue&lt;/a&gt; on the other. The Java corner of that conversation is still quiet. A few posts cover &lt;a href="https://dev.to/shamprakash2000/giving-your-spring-ai-agent-a-memory-conversations-that-actually-make-sense-10im"&gt;agent memory with Spring AI&lt;/a&gt; or &lt;a href="https://dev.to/vitalii_sukach_42bd48a1b9/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist-2gpo"&gt;RAG with LangChain4j&lt;/a&gt;, but most teams with a Spring Boot backend still assume that "adding AI" means standing up a Python service.&lt;/p&gt;

&lt;p&gt;For a lot of features, it doesn't. If what you need is &lt;em&gt;"call a model, get structured data back, use it in my existing API"&lt;/em&gt;, you can do that in the service you already deploy, monitor and secure.&lt;/p&gt;

&lt;p&gt;This post builds one such feature end to end: &lt;strong&gt;support ticket triage&lt;/strong&gt;. A ticket comes in and the API returns a category, a priority, a short summary and tags. The goal isn't a demo. It's the boring parts that make it safe to ship: typed output, a deadline, a budget and a fallback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I actually ran:&lt;/strong&gt; Spring Boot 4.1.1, Spring AI 2.0.1 (&lt;code&gt;spring-ai-starter-model-openai&lt;/code&gt;), Java 21, against a &lt;strong&gt;local mock OpenAI-compatible server&lt;/strong&gt;. The mock let me force each failure mode on demand: valid JSON, prose instead of JSON, a hang, a 500. I didn't call a real paid model for this post. The numbers below measure the code's behavior, not model quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Spring AI or LangChain4j?
&lt;/h2&gt;

&lt;p&gt;Both are mature options for the JVM. A quick way to choose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spring AI&lt;/strong&gt; if you're already on Spring Boot and want auto-configuration, &lt;code&gt;application.properties&lt;/code&gt; settings, Micrometer observations and a fluent &lt;code&gt;ChatClient&lt;/code&gt;. It feels like &lt;code&gt;RestClient&lt;/code&gt; for models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LangChain4j&lt;/strong&gt; if you're not on Spring (Quarkus, Micronaut, plain Java), or if you want its higher-level "AI services" (interface-based agents) and its integrations catalogue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both support OpenAI-compatible APIs, which covers many hosted and local models (including servers like Ollama or vLLM that expose that API). This post uses Spring AI, but sections 3 to 5 apply to either.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;parent&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-parent&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;4.1.1&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/parent&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dependencyManagement&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dependencies&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.ai&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-ai-bom&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;2.0.1&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;type&amp;gt;&lt;/span&gt;pom&lt;span class="nt"&gt;&amp;lt;/type&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;scope&amp;gt;&lt;/span&gt;import&lt;span class="nt"&gt;&amp;lt;/scope&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dependencies&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependencyManagement&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dependencies&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-web&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.ai&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-ai-starter-model-openai&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependencies&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.ai.openai.api-key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${OPENAI_API_KEY}&lt;/span&gt;
&lt;span class="py"&gt;spring.ai.openai.base-url&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${OPENAI_BASE_URL}&lt;/span&gt;
&lt;span class="py"&gt;spring.ai.openai.chat.options.model&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${LLM_MODEL}&lt;/span&gt;
&lt;span class="py"&gt;spring.ai.openai.chat.options.temperature&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0.0&lt;/span&gt;
&lt;span class="py"&gt;spring.ai.openai.chat.options.max-tokens&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;300&lt;/span&gt;
&lt;span class="py"&gt;spring.ai.openai.chat.max-retries&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;

&lt;span class="py"&gt;triage.timeout&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;6s&lt;/span&gt;
&lt;span class="py"&gt;triage.max-concurrent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API key comes from the environment, never from the repo. &lt;code&gt;max-tokens&lt;/code&gt; is your first cost control: a triage answer is small, so cap it. I'll come back to &lt;code&gt;max-retries&lt;/code&gt;, because the default surprised me.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Ask for a record, not for text
&lt;/h2&gt;

&lt;p&gt;Define exactly what you want back, and keep it small. Every field is a promise the model has to keep.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** What we ask the model for. */&lt;/span&gt;
&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;LlmTriage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Category&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Priority&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Category&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="no"&gt;BILLING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;BUG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ACCOUNT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FEATURE_REQUEST&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;OTHER&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Priority&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="no"&gt;LOW&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;MEDIUM&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;HIGH&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/** What our API returns. "source" is ours, not the model's. */&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;TicketTriage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LlmTriage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Category&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LlmTriage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Priority&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                           &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;TicketTriage&lt;/span&gt; &lt;span class="nf"&gt;fromLlm&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LlmTriage&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TicketTriage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"llm"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;TicketTriage&lt;/span&gt; &lt;span class="nf"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TicketTriage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LlmTriage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Category&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;OTHER&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LlmTriage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Priority&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MEDIUM&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"fallback"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why two records? In my first version I passed the API record (with &lt;code&gt;source&lt;/code&gt;) straight to &lt;code&gt;.entity(...)&lt;/code&gt;. When I logged the outgoing request, Spring AI had generated a JSON Schema from it and listed &lt;code&gt;source&lt;/code&gt; as a &lt;strong&gt;required&lt;/strong&gt; field for the model to fill. The model doesn't know whether it's a fallback; your code does. Keep the model-facing type separate from your API type.&lt;/p&gt;

&lt;p&gt;Using enums is the cheapest guardrail available. The generated schema includes &lt;code&gt;"enum": ["BILLING", "BUG", ...]&lt;/code&gt;, and if the model invents &lt;code&gt;"PAYMENTS"&lt;/code&gt;, parsing fails instead of letting an unknown category leak into your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The service: deadline, budget, fallback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TriageService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Logger&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoggerFactory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLogger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TriageService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;SYSTEM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""
        You triage customer support tickets for a web and mobile app.
        Classify the ticket. Keep the summary under 25 words.
        Never include personal data (names, phone numbers, emails) in the summary or tags.
        """&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ChatClient&lt;/span&gt; &lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ExecutorService&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Executors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newVirtualThreadPerTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Semaphore&lt;/span&gt; &lt;span class="n"&gt;inFlight&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;TriageService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Builder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                  &lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${triage.timeout:5s}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                  &lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${triage.max-concurrent:8}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxConcurrent&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;chat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultSystem&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SYSTEM&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inFlight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxConcurrent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;TicketTriage&lt;/span&gt; &lt;span class="nf"&gt;triage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;ticketText&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticketText&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ticketText&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ticketText&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4_000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ticket text must be 1..4000 characters"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tryAcquire&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;        &lt;span class="c1"&gt;// budget: never queue unbounded LLM calls&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"triage: too many in-flight calls, using fallback"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;TicketTriage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="nc"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LlmTriage&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;submit&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Ticket:\n&amp;lt;ticket&amp;gt;\n{ticket}\n&amp;lt;/ticket&amp;gt;"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ticket"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ticketText&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LlmTriage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;LlmTriage&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toMillis&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MILLISECONDS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;TicketTriage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;TicketTriage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromLlm&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TimeoutException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cancel&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"triage: LLM call exceeded {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;TicketTriage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InterruptedException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentThread&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;interrupt&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;TicketTriage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ExecutionException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"triage: LLM call failed: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCause&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;TicketTriage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;inFlight&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a plain controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@RestController&lt;/span&gt;
&lt;span class="nd"&gt;@RequestMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/tickets"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TriageController&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;TriageService&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;TriageController&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TriageService&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;TriageRequest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;

    &lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/triage"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;TicketTriage&lt;/span&gt; &lt;span class="nf"&gt;triage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;TriageRequest&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;triage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&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%2Fnpxwnsuejby3c9c4smsh.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%2Fnpxwnsuejby3c9c4smsh.png" alt="Request path: controller, guard, ChatClient, deadline, response" width="800" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The design choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input limits first.&lt;/strong&gt; A 4,000-character cap protects both your token bill and your latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ticket is data, not instructions.&lt;/strong&gt; It goes inside &lt;code&gt;&amp;lt;ticket&amp;gt;&lt;/code&gt; tags via a template parameter, and the system prompt states the rules. That doesn't make prompt injection impossible, but combined with enum-only output it limits what an injected instruction can achieve: the worst case is a wrong category, not arbitrary text in your UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A deadline the caller can rely on.&lt;/strong&gt; The endpoint answers within &lt;code&gt;triage.timeout&lt;/code&gt;, whatever the provider does. Virtual threads make it cheap to wrap a blocking call in a &lt;code&gt;Future&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A concurrency budget.&lt;/strong&gt; The &lt;code&gt;Semaphore&lt;/code&gt; caps simultaneous model calls. When it's full, you degrade instead of queueing, which protects both your rate limit and your threads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fallback that keeps the product working.&lt;/strong&gt; &lt;code&gt;source: "fallback"&lt;/code&gt; tells the UI (and your metrics) that a human should triage this one. The feature degrades gracefully; it doesn't cause an outage.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. What happened when things went wrong
&lt;/h2&gt;

&lt;p&gt;I pointed the app at the mock server and forced each failure. These are the actual results:&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%2F51lhs7y3z75ymqgmbydn.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%2F51lhs7y3z75ymqgmbydn.png" alt="Failure modes tested against a mock OpenAI-compatible server" width="800" height="304"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;valid JSON         → {"category":"BILLING","priority":"HIGH","summary":"Customer charged twice ...","source":"llm"}   ~0.7 s
prose, not JSON    → source=fallback   ~0.01 s   (StreamReadException: Unrecognized token 'Sure')
model hangs 8 s    → source=fallback   6.0 s     (our deadline fired)
HTTP 500           → source=fallback   ~0.5 s    (2 upstream calls with max-retries=1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two findings are worth calling out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retries stack.&lt;/strong&gt; With the defaults, a single failing user request produced &lt;strong&gt;four&lt;/strong&gt; upstream calls to the model endpoint. In Spring AI 2.0, the OpenAI integration is built on the official &lt;code&gt;openai-java&lt;/code&gt; SDK, which has its own retry setting (&lt;code&gt;spring.ai.openai.chat.max-retries&lt;/code&gt;, default 3 in the configuration metadata). For a user-facing endpoint with a fallback, that's three extra billed attempts the user is waiting for. Setting &lt;code&gt;spring.ai.openai.chat.max-retries=1&lt;/code&gt; brought it down to two. Also note that &lt;code&gt;spring.ai.openai.chat.options.max-retries&lt;/code&gt; also exists in the metadata, but it had no effect in my test. The non-&lt;code&gt;options&lt;/code&gt; property did. Count upstream calls in your provider's logs before you trust any retry setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep your own deadline.&lt;/strong&gt; The metadata also lists &lt;code&gt;spring.ai.openai.chat.timeout&lt;/code&gt; (default 60s). In my test, setting it to 4s didn't cut the hanging call before my 6-second application deadline did. I didn't dig further, so treat this as "verify in your setup". Either way, an application-level deadline is something you control and can unit-test.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. When to stay in Java, and when to add a Python service
&lt;/h2&gt;

&lt;p&gt;Stay in your Spring Boot API when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the feature is &lt;strong&gt;"call a model, parse structured output, store or return it"&lt;/strong&gt; (classification, extraction, summarization, drafting replies);&lt;/li&gt;
&lt;li&gt;the model is &lt;strong&gt;hosted&lt;/strong&gt; (a provider API or an OpenAI-compatible server you run separately);&lt;/li&gt;
&lt;li&gt;you want it behind the same auth, rate limits, logging and deployment you already have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reach for a separate Python service when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you &lt;strong&gt;train or fine-tune&lt;/strong&gt; models, or run data science pipelines (that ecosystem is still Python-first);&lt;/li&gt;
&lt;li&gt;you need &lt;strong&gt;in-process model inference&lt;/strong&gt; with libraries that only exist in Python;&lt;/li&gt;
&lt;li&gt;a team that owns the ML work wants its own deploy cycle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even then, the Spring Boot side looks the same as above: a typed call with a deadline and a fallback. Only the URL changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you ship: a checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Output is a small record with enums wherever possible; the model-facing type is separate from the API type.&lt;/li&gt;
&lt;li&gt;[ ] Input is length-capped and passed as a template parameter, not concatenated into the instructions.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;max-tokens&lt;/code&gt; is set; temperature is low for classification.&lt;/li&gt;
&lt;li&gt;[ ] An application deadline is shorter than your HTTP client's and your users' patience.&lt;/li&gt;
&lt;li&gt;[ ] Retries are counted end to end (SDK + yours + the client's).&lt;/li&gt;
&lt;li&gt;[ ] Concurrency is capped; overflow degrades to a fallback.&lt;/li&gt;
&lt;li&gt;[ ] The response says whether it came from the model or the fallback, and you graph that ratio.&lt;/li&gt;
&lt;li&gt;[ ] You know what personal data leaves your system in the prompt, and your provider's data-retention terms allow it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Spring AI 2.0 targets Spring Boot 4.x. If you're still on Boot 3.x, use Spring AI 1.x; the &lt;code&gt;ChatClient&lt;/code&gt; and &lt;code&gt;.entity(...)&lt;/code&gt; API shown here exists there too, but property names may differ.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.entity(...)&lt;/code&gt; also accepts reliability switches (&lt;code&gt;spec -&amp;gt; spec.validateSchema()&lt;/code&gt; to retry with error feedback, &lt;code&gt;useProviderStructuredOutput()&lt;/code&gt; to send the schema as a provider-level constraint). I didn't test them against the mock, so they aren't in the code above. Read the &lt;a href="https://docs.spring.io/spring-ai/reference/api/structured-output.html" rel="noopener noreferrer"&gt;Structured Output reference&lt;/a&gt; before enabling them, and remember that each validation retry is another billed call.&lt;/li&gt;
&lt;li&gt;If you add springdoc to the same project, check for a &lt;code&gt;swagger-annotations&lt;/code&gt; version conflict (I had to pin 2.2.55).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Are you adding LLM features in Java, or did your team go the Python-microservice route? I'd like to hear why in the comments.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>springboot</category>
      <category>ai</category>
      <category>java</category>
    </item>
    <item>
      <title>Your Java long IDs Are Lying to Your React Native App: Contract Rules Between Spring Boot and TypeScript</title>
      <dc:creator>Chaminade Dondah ADJOLOU</dc:creator>
      <pubDate>Mon, 19 Oct 2026 09:50:03 +0000</pubDate>
      <link>https://dev.to/jwzqeygxtonh/your-java-long-ids-are-lying-to-your-react-native-app-contract-rules-between-spring-boot-and-1m0</link>
      <guid>https://dev.to/jwzqeygxtonh/your-java-long-ids-are-lying-to-your-react-native-app-contract-rules-between-spring-boot-and-1m0</guid>
      <description>&lt;p&gt;This bug doesn't throw. Your Spring Boot API returns an order with id &lt;code&gt;1849203948123456789&lt;/code&gt;. Your React Native app shows the order and lets the user tap "Cancel". Then it sends &lt;code&gt;DELETE /api/orders/1849203948123456800&lt;/code&gt;. The server answers 404, or worse, it cancels a different order.&lt;/p&gt;

&lt;p&gt;Nothing crashed and every type checked. The API and the client simply disagree about what a number is.&lt;/p&gt;

&lt;p&gt;A popular DEV post this week asked &lt;a href="https://dev.to/nyaomaru/how-do-you-validate-unknown-in-typescript-without-a-schema-library-1a11"&gt;how to validate &lt;code&gt;unknown&lt;/code&gt; in TypeScript without a schema library&lt;/a&gt;, and another reminded us that &lt;a href="https://dev.to/gabrielanhaia/twitter-added-idstr-in-2010-because-javascript-cant-hold-a-java-long-1p5a"&gt;Twitter added &lt;code&gt;id_str&lt;/code&gt; in 2010 because JavaScript can't hold a Java &lt;code&gt;long&lt;/code&gt;&lt;/a&gt;. This post puts the two together, from the point of view of a full-stack developer who owns both ends: a Spring Boot backend and a TypeScript client (React, React Native or both).&lt;/p&gt;

&lt;p&gt;Everything below was run on &lt;strong&gt;Spring Boot 4.1.1 (Jackson 3.1.5)&lt;/strong&gt;, &lt;strong&gt;springdoc-openapi 3.1.1&lt;/strong&gt;, &lt;strong&gt;openapi-typescript 7.13&lt;/strong&gt;, &lt;strong&gt;Zod 4.6&lt;/strong&gt; and &lt;strong&gt;Node 20&lt;/strong&gt;. Version notes are in the last section.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The precision cliff: 2^53
&lt;/h2&gt;

&lt;p&gt;Java's &lt;code&gt;long&lt;/code&gt; is a 64-bit integer. A JavaScript &lt;code&gt;number&lt;/code&gt; is a 64-bit IEEE 754 float, so it can represent every integer exactly only up to &lt;code&gt;Number.MAX_SAFE_INTEGER&lt;/code&gt;, which is 2^53 − 1 = 9,007,199,254,740,991. Past that, integers get rounded to the nearest representable value.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;JSON.parse&lt;/code&gt; doesn't warn you. It produces the nearest double:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Node 20, run as-is&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;o&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="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"id":1849203948123456789,"customerId":9007199254740993}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                      &lt;span class="c1"&gt;// 1849203948123456800&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// 9007199254740992  (off by one)&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSafeInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1849203948123456789&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1849203948123456800&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true (!)&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%2Ffz7b0dvzsfc3ftmg0uoj.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%2Ffz7b0dvzsfc3ftmg0uoj.png" alt="Table: what JSON.parse does to a Java long" width="800" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"My IDs come from a Postgres &lt;code&gt;bigserial&lt;/code&gt;, they'll never reach 2^53," you might say. That's often true for auto-increment keys. But many modern ID schemes deliberately use the high bits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Snowflake-style IDs&lt;/strong&gt; (timestamp + worker + sequence), used by many social and messaging APIs;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TSID / time-sorted IDs&lt;/strong&gt;, which are popular in Java backends because they index well;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;external IDs&lt;/strong&gt; from payment providers, telcos or partners that you store as &lt;code&gt;long&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these sit far above 2^53. The day you switch ID generators, or integrate a partner, your client starts rounding.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Rule 1: 64-bit integers travel as strings
&lt;/h2&gt;

&lt;p&gt;Treat an ID as an opaque token. You never do arithmetic on it, so it doesn't need to be a number on the wire.&lt;/p&gt;

&lt;p&gt;On the Spring Boot side you have two options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per field&lt;/strong&gt;, with an annotation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.fasterxml.jackson.annotation.JsonFormat&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;OrderDto&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@JsonFormat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;JsonFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Shape&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STRING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Globally&lt;/strong&gt;, so nobody can forget. With Spring Boot 4 (Jackson 3), register a module through a &lt;code&gt;JsonMapperBuilderCustomizer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.context.annotation.Bean&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.context.annotation.Configuration&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;tools.jackson.databind.module.SimpleModule&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;tools.jackson.databind.ser.std.ToStringSerializer&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;JsonConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Every Long / long in every response body is written as a JSON string.&lt;/span&gt;
    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="nc"&gt;JsonMapperBuilderCustomizer&lt;/span&gt; &lt;span class="nf"&gt;longsAsStrings&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;SimpleModule&lt;/span&gt; &lt;span class="n"&gt;module&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SimpleModule&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"longs-as-strings"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addSerializer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ToStringSerializer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addSerializer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TYPE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ToStringSerializer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addModule&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response now looks like this (actual output from the test app):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1849203948123456789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"customerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"9007199254740993"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"createdAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-10-19T09:30:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"PAID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"15000.50"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"note"&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="nl"&gt;"itemCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;itemCount&lt;/code&gt; is an &lt;code&gt;int&lt;/code&gt;, so it stays a number. That's what you want: small integers are safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inbound, Jackson is lenient.&lt;/strong&gt; By default, it accepted both &lt;code&gt;"customerId":"9007199254740993"&lt;/code&gt; and &lt;code&gt;"customerId":9007199254740993&lt;/code&gt; for a &lt;code&gt;Long&lt;/code&gt; field in a request body, and it returned 400 for &lt;code&gt;"abc"&lt;/code&gt;. So the client can send strings without any change on the server. Clients that still send numbers won't break during a migration either.&lt;/p&gt;

&lt;p&gt;The global option has a trade-off: it's a breaking change for existing clients that expect numbers. Roll it out behind a new API version, or start with the per-field annotation on IDs only.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Rule 2: money is a decimal string, not a float
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;BigDecimal&lt;/code&gt; has the same problem in a different form. Jackson writes &lt;code&gt;new BigDecimal("15000.50")&lt;/code&gt; as the JSON number &lt;code&gt;15000.50&lt;/code&gt;, and &lt;code&gt;JSON.parse&lt;/code&gt; turns it into &lt;code&gt;15000.5&lt;/code&gt;. The scale is gone. For amounts above 2^53 minor units (rare, but possible in currencies with large nominal values), precision goes too. And then someone writes &lt;code&gt;0.1 + 0.2&lt;/code&gt; in the client and gets &lt;code&gt;0.30000000000000004&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Send money as a decimal string (&lt;code&gt;"15000.50"&lt;/code&gt;), or as an integer count of minor units &lt;em&gt;plus&lt;/em&gt; a currency code. On the client, format it for display and do any arithmetic with a decimal library, or on the server.&lt;/p&gt;

&lt;p&gt;I hit a subtle trap while testing this. I annotated &lt;code&gt;total&lt;/code&gt; with &lt;code&gt;@Schema(type = "string")&lt;/code&gt; for the docs, but Jackson still serialized it as a number. &lt;strong&gt;The OpenAPI annotation documents the contract; it doesn't enforce it.&lt;/strong&gt; You need &lt;code&gt;@JsonFormat(shape = STRING)&lt;/code&gt; (or a global &lt;code&gt;BigDecimal&lt;/code&gt; serializer) &lt;em&gt;and&lt;/em&gt; the schema annotation. Section 6 shows how to catch that mismatch automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Rule 3: dates, enums and nulls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Dates.&lt;/strong&gt; Use &lt;code&gt;Instant&lt;/code&gt; (or &lt;code&gt;OffsetDateTime&lt;/code&gt;) on the server and ISO-8601 with an offset on the wire: &lt;code&gt;"2026-10-19T09:30:00Z"&lt;/code&gt;. With Spring Boot 4 / Jackson 3, the test app produced that by default, with no &lt;code&gt;WRITE_DATES_AS_TIMESTAMPS&lt;/code&gt; tweak needed. On Spring Boot 3 / Jackson 2, check your config: older setups often emit epoch numbers. Avoid &lt;code&gt;LocalDateTime&lt;/code&gt; in APIs. It has no zone, so "09:30" means something different in Lomé, Paris and São Paulo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enums.&lt;/strong&gt; Java enums serialize as their names. That's fine, as long as the client is ready for a value it has never seen. When you add &lt;code&gt;REFUNDED&lt;/code&gt; next quarter, an old app build on someone's phone will receive it. On mobile you can't force everyone to update, so the client must have a fallback branch ("unknown status") instead of crashing in a &lt;code&gt;switch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nulls vs missing.&lt;/strong&gt; &lt;code&gt;"note": null&lt;/code&gt; and a missing &lt;code&gt;note&lt;/code&gt; are different things in TypeScript (&lt;code&gt;string | null&lt;/code&gt; vs &lt;code&gt;note?: string&lt;/code&gt;). Pick one convention per API and write it down. The setup below makes required-but-nullable explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Rule 4: generate the TypeScript types, don't hand-copy them
&lt;/h2&gt;

&lt;p&gt;Hand-written &lt;code&gt;interface Order { id: number }&lt;/code&gt; files are where these bugs live. Generate the client types from the server's OpenAPI document instead.&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%2Fpob3yp1rjj5d8t5n3h6j.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%2Fpob3yp1rjj5d8t5n3h6j.png" alt="Pipeline: Java record → Jackson → springdoc → openapi-typescript → Zod" width="800" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server: describe the DTO precisely.&lt;/strong&gt; springdoc reads your records. Without hints it describes &lt;code&gt;Long&lt;/code&gt; as &lt;code&gt;integer/int64&lt;/code&gt; (true for Java, wrong for your JSON) and marks every field optional. Annotate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;static&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;swagger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;v3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;oas&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;media&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RequiredMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REQUIRED&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;OrderDto&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@Schema&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"string"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requiredMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;REQUIRED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;example&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1849203948123456789"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@Schema&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"string"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requiredMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;REQUIRED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@Schema&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requiredMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;REQUIRED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@Schema&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requiredMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;REQUIRED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@JsonFormat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;JsonFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Shape&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STRING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Schema&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"string"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requiredMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;REQUIRED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;example&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"15000.50"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@Schema&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requiredMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;REQUIRED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"string"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"null"&lt;/span&gt;&lt;span class="o"&gt;})&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@Schema&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requiredMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;REQUIRED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;itemCount&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Client: generate types.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:8080/v3/api-docs &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; api-docs.json
npx openapi-typescript api-docs.json &lt;span class="nt"&gt;-o&lt;/span&gt; src/api/schema.d.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated type, unedited:&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="nx"&gt;OrderDto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/** @example 1849203948123456789 */&lt;/span&gt;
    &lt;span class="nl"&gt;id&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;customerId&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="cm"&gt;/** Format: date-time */&lt;/span&gt;
    &lt;span class="nl"&gt;createdAt&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="cm"&gt;/** @enum {string} */&lt;/span&gt;
    &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PENDING&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;PAID&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;SHIPPED&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;CANCELLED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="cm"&gt;/** @example 15000.50 */&lt;/span&gt;
    &lt;span class="nl"&gt;total&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;note&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="cm"&gt;/** Format: int32 */&lt;/span&gt;
    &lt;span class="nl"&gt;itemCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;Before I added &lt;code&gt;requiredMode&lt;/code&gt;, every field came out as &lt;code&gt;id?: string&lt;/code&gt;, and the client had to null-check everything. Run this generation in CI and commit the output, so a backend change shows up as a diff in the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Rule 5: check at the boundary, at runtime
&lt;/h2&gt;

&lt;p&gt;Generated types are a compile-time promise. The bytes that reach a phone over a flaky mobile network are not. A proxy can return HTML, an old server version can still be deployed, or someone can forget &lt;code&gt;@JsonFormat&lt;/code&gt;. Validate once, where &lt;code&gt;unknown&lt;/code&gt; enters the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;components&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./api/schema&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;OrderDto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;components&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;schemas&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="s1"&gt;OrderDto&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;Id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{1,19}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expected a numeric string id&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;const&lt;/span&gt; &lt;span class="nx"&gt;OrderSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;offset&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PENDING&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="s1"&gt;PAID&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="s1"&gt;SHIPPED&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="s1"&gt;CANCELLED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^-&lt;/span&gt;&lt;span class="se"&gt;?\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;itemCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Compile-time check: runtime schema and generated type must agree both ways.&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;OrderSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;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;_a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrderDto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Parsed&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;_b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;OrderDto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the server regresses and sends a numeric id, parsing fails loudly instead of rounding silently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OrderSchema.safeParse({ ...order, id: 1849203948123456789 })
→ success: false, "Invalid input: expected string, received number"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two &lt;code&gt;_a&lt;/code&gt; / &lt;code&gt;_b&lt;/code&gt; lines are a cheap trick: if someone edits the Zod schema or regenerates the types and they drift, &lt;code&gt;tsc&lt;/code&gt; fails. For enums on mobile, swap &lt;code&gt;z.enum([...])&lt;/code&gt; for &lt;code&gt;z.string()&lt;/code&gt; plus a mapping with an "unknown" fallback, so a new server value doesn't take down old app builds (see rule 3).&lt;/p&gt;

&lt;p&gt;Don't count on clever &lt;code&gt;JSON.parse&lt;/code&gt; tricks either. Newer JavaScript engines expose the raw source text to revivers, but you can't rely on that across every runtime your app ships on (Hermes, older WebViews). Fix the wire format instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for your next API review
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every 64-bit ID is a JSON string (globally or per field).&lt;/li&gt;
&lt;li&gt;[ ] Money is a decimal string or integer minor units + currency, never a float.&lt;/li&gt;
&lt;li&gt;[ ] Timestamps are &lt;code&gt;Instant&lt;/code&gt;/&lt;code&gt;OffsetDateTime&lt;/code&gt;, ISO-8601 with offset.&lt;/li&gt;
&lt;li&gt;[ ] The client handles unknown enum values without crashing.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;null&lt;/code&gt; vs missing is a documented convention; required fields are marked.&lt;/li&gt;
&lt;li&gt;[ ] TS types are generated from &lt;code&gt;/v3/api-docs&lt;/code&gt; in CI, not hand-written.&lt;/li&gt;
&lt;li&gt;[ ] A runtime schema (Zod or hand-written guards) validates responses at the boundary.&lt;/li&gt;
&lt;li&gt;[ ] A test asserts that the serialized JSON matches the OpenAPI types (catches the &lt;code&gt;@Schema&lt;/code&gt; ≠ &lt;code&gt;@JsonFormat&lt;/code&gt; trap).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Version notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spring Boot 4 uses Jackson 3.&lt;/strong&gt; Packages moved from &lt;code&gt;com.fasterxml.jackson&lt;/code&gt; to &lt;code&gt;tools.jackson&lt;/code&gt;, except &lt;code&gt;com.fasterxml.jackson.annotation&lt;/code&gt; (so &lt;code&gt;@JsonFormat&lt;/code&gt; keeps its import). &lt;code&gt;Jackson2ObjectMapperBuilderCustomizer&lt;/code&gt; became &lt;code&gt;JsonMapperBuilderCustomizer&lt;/code&gt;. On Spring Boot 3.x, use the &lt;code&gt;com.fasterxml&lt;/code&gt; imports and the old customizer name; the idea is identical.&lt;/li&gt;
&lt;li&gt;springdoc 3.x targets Spring Boot 4. In a project that also pulled in Spring AI, I had to pin &lt;code&gt;swagger-annotations&lt;/code&gt; to the version springdoc expects (2.2.55), otherwise &lt;code&gt;/v3/api-docs&lt;/code&gt; threw &lt;code&gt;NoSuchMethodError&lt;/code&gt;. Check &lt;code&gt;mvn dependency:tree -Dincludes=io.swagger.core.v3&lt;/code&gt; if you see that.&lt;/li&gt;
&lt;li&gt;The code samples are from a small test app I ran locally. Adapt package names and versions to your project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's your team's convention: IDs as strings everywhere, or only above 2^53? Tell me in the comments.&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>typescript</category>
      <category>reactnative</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I vibe-coded a cassette deck for family memory — K7 Mémoire (Sanity Challenge Path 2)</title>
      <dc:creator>Chaminade Dondah ADJOLOU</dc:creator>
      <pubDate>Fri, 02 Oct 2026 15:36:05 +0000</pubDate>
      <link>https://dev.to/jwzqeygxtonh/i-vibe-coded-a-cassette-deck-for-family-memory-k7-memoire-sanity-challenge-path-2-4b3f</link>
      <guid>https://dev.to/jwzqeygxtonh/i-vibe-coded-a-cassette-deck-for-family-memory-k7-memoire-sanity-challenge-path-2-4b3f</guid>
      <description>&lt;h1&gt;
  
  
  I vibe-coded a cassette deck for family memory — K7 Mémoire (Sanity Challenge Path 2)
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Author:&lt;/strong&gt; Chaminade (Dondah Chamiande Adjolou) · Lomé, Togo&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Challenge:&lt;/strong&gt; &lt;a href="https://www.sanity.io/" rel="noopener noreferrer"&gt;Sanity Challenge&lt;/a&gt; Path 2 — &lt;em&gt;Vibe-code something strange&lt;/em&gt; (Sep 18–Oct 4, 2026 PDT)&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Tag:&lt;/strong&gt; &lt;code&gt;#sanitychallenge&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Live deck: &lt;a href="https://k7-memoire.vercel.app" rel="noopener noreferrer"&gt;https://k7-memoire.vercel.app&lt;/a&gt;&lt;br&gt;&lt;br&gt;
Embedded Studio: &lt;a href="https://k7-memoire.vercel.app/studio" rel="noopener noreferrer"&gt;https://k7-memoire.vercel.app/studio&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Sanity project ID:&lt;/strong&gt; &lt;code&gt;nh4vx26w&lt;/code&gt; · &lt;strong&gt;Dataset:&lt;/strong&gt; &lt;code&gt;production&lt;/code&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Why something strange
&lt;/h2&gt;

&lt;p&gt;In Lomé, family history often travels by voice before it travels by page. I did not want another blog layout for that. Path 2 of the Sanity Challenge asks for something strange — so I vibe-coded &lt;strong&gt;K7 Mémoire&lt;/strong&gt;: a Next.js cassette / radio UI that treats Sanity documents as tapes you can tune, filter by mood, and “play.”&lt;/p&gt;

&lt;p&gt;The copy is French, Lomé-flavored. The chrome is a deck: FM dial, mood filters, spinning reels, counter, VU meters, rolling transcript, and a kente stripe. No article list. No card grid. Stories are &lt;strong&gt;bandes&lt;/strong&gt;; narrators are &lt;strong&gt;voix&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This sits next to earlier work I care about — like a family-tree project for Hacktoberfest — but K7 Mémoire is its own object: oral memory as an instrument, not a CMS demo with a pretty skin.&lt;/p&gt;


&lt;h2&gt;
  
  
  The product prompt (and why process matters)
&lt;/h2&gt;

&lt;p&gt;Judges for this challenge weigh &lt;strong&gt;process&lt;/strong&gt; as much as the result. I kept a journal in the repo: &lt;code&gt;PROMPT_LOG.md&lt;/code&gt;. The brief I gave the agent was essentially one product prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An oral archive for Lomé, stored in Sanity, played by a cassette-deck UI, with Studio, seed stories, README, and this log.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;From there, the log records what landed and what broke. Scaffolding with &lt;code&gt;create-next-app&lt;/code&gt; (Next.js App Router, TypeScript, Tailwind) into a subdirectory, then moving to the repo root, was more reliable than aiming the CLI at the root. Studio landed at &lt;code&gt;/studio&lt;/code&gt; with &lt;code&gt;next-sanity&lt;/code&gt;, Sanity v6, Vision, and a custom desk (&lt;strong&gt;Bandes&lt;/strong&gt; / &lt;strong&gt;Voix&lt;/strong&gt;). One GROQ string is shared by the live client and by &lt;code&gt;groq-js&lt;/code&gt; over the seed documents — tests assert eight tapes and resolved narrator names.&lt;/p&gt;

&lt;p&gt;Failures were part of the craft: no Sanity credentials in the first environment (so the deck shipped a labeled &lt;strong&gt;demo mode&lt;/strong&gt; using the &lt;em&gt;same&lt;/em&gt; query, not a second content model); Vitest peer friction (switched to &lt;code&gt;tsx --test&lt;/code&gt;); a Turbopack production build that choked on &lt;code&gt;swr&lt;/code&gt;’s &lt;code&gt;react-server&lt;/code&gt; export until &lt;code&gt;swr&lt;/code&gt; was aliased in &lt;code&gt;next.config.ts&lt;/code&gt;; Studio pulled into the RSC graph until the config lived behind a client boundary.&lt;/p&gt;

&lt;p&gt;Left undone on purpose in that early pass: no audio files on demo tapes (&lt;code&gt;audioUrl&lt;/code&gt; optional — Play falls back to the transcript), and no Cursor branding in the public brief.&lt;/p&gt;

&lt;p&gt;That honesty is the submission as much as the UI.&lt;/p&gt;


&lt;h2&gt;
  
  
  Two schemas: tape and voice
&lt;/h2&gt;

&lt;p&gt;Content is not files on the host. Stories stay in Sanity. The model is small on purpose:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document&lt;/th&gt;
&lt;th&gt;Studio title&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;story&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bande&lt;/td&gt;
&lt;td&gt;A tape: title, slug, narrator ref, place, era, transcript (portable text), optional &lt;code&gt;audioUrl&lt;/code&gt;, tags, mood&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;narrator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Narrateur&lt;/td&gt;
&lt;td&gt;A voice: name, short bio (≤280), optional photo with hotspot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The narrator’s &lt;strong&gt;name lives on the narrator document&lt;/strong&gt;. The story only holds a reference. That keeps voices reusable across bandes and keeps the deck’s display join honest: &lt;code&gt;narrator-&amp;gt;name&lt;/code&gt; in GROQ, not a duplicated string on every tape.&lt;/p&gt;

&lt;p&gt;Mood is a required string from a shared list (the filters on the dial). Era is approximate text (e.g. « vers 1987 ») and is what we sort on. Transcript is required portable text so the machine always has something to roll when there is no audio file.&lt;/p&gt;


&lt;h2&gt;
  
  
  One GROQ string, two sources
&lt;/h2&gt;

&lt;p&gt;Whether the deck reads the Content Lake or the local seed drawer, the projection is the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*[_type == "story"] | order(era asc) {
  _id,
  title,
  "slug": slug.current,
  place,
  era,
  mood,
  tags,
  audioUrl,
  transcript,
  "narratorName": narrator-&amp;gt;name,
  "narratorBio": narrator-&amp;gt;bio,
  "narratorPhoto": narrator-&amp;gt;photo.asset-&amp;gt;url
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single string is the contract between Studio editors and the strange front end. An empty lake shows an open tray, not a crash. A failed query shows the error and no tapes. Demo mode is labeled — same query shape, seed documents in &lt;code&gt;src/sanity/seed/documents.ts&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Studio at &lt;code&gt;/studio&lt;/code&gt;, then the Lake
&lt;/h2&gt;

&lt;p&gt;Studio is embedded in the Next app at &lt;code&gt;/studio&lt;/code&gt; (Bandes / Voix desk + Vision). The path from vibe-coded demo to a real Content Lake was deliberate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create project + public &lt;code&gt;production&lt;/code&gt; dataset in Sanity Manage.
&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;NEXT_PUBLIC_SANITY_PROJECT_ID&lt;/code&gt; / &lt;code&gt;NEXT_PUBLIC_SANITY_DATASET&lt;/code&gt; (defaults already point at &lt;strong&gt;&lt;code&gt;nh4vx26w&lt;/code&gt;&lt;/strong&gt; / &lt;strong&gt;&lt;code&gt;production&lt;/code&gt;&lt;/strong&gt;).
&lt;/li&gt;
&lt;li&gt;Editor write token only for seed — never committed.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm run seed&lt;/code&gt; → &lt;code&gt;createOrReplace&lt;/code&gt; &lt;strong&gt;4 narrators&lt;/strong&gt; and &lt;strong&gt;8 stories&lt;/strong&gt;.
&lt;/li&gt;
&lt;li&gt;Add CORS origins (local + &lt;code&gt;https://k7-memoire.vercel.app&lt;/code&gt;), deploy, open Studio.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Public project ID and dataset are intentional: the deck can read without a token (watch Sanity’s public ACL: &lt;code&gt;path("*")&lt;/code&gt; only matches IDs without dots — our seed uses hyphen IDs like &lt;code&gt;story-radio-agoe&lt;/code&gt;). Write stays server-side for seed only.&lt;/p&gt;




&lt;h2&gt;
  
  
  What “strange” looks like on screen
&lt;/h2&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%2Fk7-memoire.vercel.app" 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%2Fk7-memoire.vercel.app" alt="K7 Mémoire live deck" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Screenshots: seeded deck on live — open the app for the full FM dial + kente chrome.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You do not scroll a feed. You tune. Mood filters narrow the drawer. Play spins the reels and advances a counter while the transcript rolls — or audio plays when &lt;code&gt;audioUrl&lt;/code&gt; is set. The on-screen service manual even prints the GROQ, so the “machine” explains how it listens to the Lake.&lt;/p&gt;

&lt;p&gt;Stack in short: Next.js (App Router) + TypeScript + Tailwind + Sanity Studio (&lt;code&gt;next-sanity&lt;/code&gt;, Sanity v6) on Vercel.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links for judges and readers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Live app&lt;/td&gt;
&lt;td&gt;&lt;a href="https://k7-memoire.vercel.app" rel="noopener noreferrer"&gt;k7-memoire.vercel.app&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Studio&lt;/td&gt;
&lt;td&gt;&lt;a href="https://k7-memoire.vercel.app/studio" rel="noopener noreferrer"&gt;k7-memoire.vercel.app/studio&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sanity project ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;nh4vx26w&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dataset&lt;/td&gt;
&lt;td&gt;&lt;code&gt;production&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seeded content&lt;/td&gt;
&lt;td&gt;4 narrators · 8 stories&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Origin source of truth&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://cursor.com/codebase/donchaminade/k7-memoire" rel="noopener noreferrer"&gt;cursor.com/codebase/donchaminade/k7-memoire&lt;/a&gt; (private)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Process log&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PROMPT_LOG.md&lt;/code&gt; in that repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/Donchaminade" rel="noopener noreferrer"&gt;Donchaminade&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.linkedin.com/in/chaminadeadjolou" rel="noopener noreferrer"&gt;chaminadeadjolou&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What I’d vibe next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Real family recordings behind optional &lt;code&gt;audioUrl&lt;/code&gt; (consent and quiet UX first).
&lt;/li&gt;
&lt;li&gt;More Lomé places and eras as the Lake grows — still one GROQ contract.
&lt;/li&gt;
&lt;li&gt;A small “donate a tape” flow that creates a draft &lt;code&gt;story&lt;/code&gt; in Studio without breaking the deck metaphor.
&lt;/li&gt;
&lt;li&gt;Vision-assisted queries from the service manual for editors learning GROQ beside the reels.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Strange does not mean unserious. A cassette deck is a joke until it is how your aunt’s voice finds the next generation. Sanity’s schemas, Studio, and Content Lake made that joke into something I can seed, ship, and keep editing from Lomé.&lt;/p&gt;

&lt;p&gt;Thanks to the Sanity Challenge for Path 2 — and to everyone who still presses Play on memory.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#sanitychallenge&lt;/code&gt; · nextjs · sanity · africa&lt;/p&gt;

</description>
      <category>sanitychallenge</category>
      <category>nextjs</category>
      <category>sanity</category>
      <category>africa</category>
    </item>
    <item>
      <title>Lignée — A Family Tree Built for Someone I Love (and Generations I'll Never Meet)</title>
      <dc:creator>Chaminade Dondah ADJOLOU</dc:creator>
      <pubDate>Fri, 02 Oct 2026 09:27:11 +0000</pubDate>
      <link>https://dev.to/jwzqeygxtonh/lignee-a-family-tree-built-for-someone-i-love-and-generations-ill-never-meet-23kd</link>
      <guid>https://dev.to/jwzqeygxtonh/lignee-a-family-tree-built-for-someone-i-love-and-generations-ill-never-meet-23kd</guid>
      <description>&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%2Fyhz2y0nkt62ywygkhsol.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%2Fyhz2y0nkt62ywygkhsol.png" alt="Cover" width="799" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Lignée — A Family Tree Built for Someone I Love (and Generations I'll Never Meet)
&lt;/h1&gt;

&lt;p&gt;I did not build this for a portfolio. I built it for my family.&lt;/p&gt;

&lt;p&gt;For the Adjolou and Chamiande lineage — for the people who raised me in Lomé, Togo, and for the children who will one day ask &lt;em&gt;who came before us?&lt;/em&gt; This is my Hacktoberfest Weekend Challenge submission under &lt;strong&gt;Build for a Friend&lt;/strong&gt;. The "friend" is not a stranger on the internet. It is someone I love: my family, including future generations I may never meet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lignée&lt;/strong&gt; (French for &lt;em&gt;lineage&lt;/em&gt;) is an open-source family tree that turns oral family stories into durable digital memory — with open-source AI at the core, a mind-map you can edit on your phone, and a promise that our kinship will not vanish when the last elder who remembers stops speaking.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who it's for
&lt;/h2&gt;

&lt;p&gt;Someone I love is not a single person. In West African extended kinship, "family" is wider than a nuclear unit: grandparents, aunts, uncles, cousins, marriages that braid two names together, in-laws who become &lt;em&gt;our&lt;/em&gt; people.&lt;/p&gt;

&lt;p&gt;I am &lt;strong&gt;Dondah Chamiande Adjolou&lt;/strong&gt;, from Lomé, Togo. Like many families in the diaspora — and many still at home — we carry our history in conversations: around a table, on a phone call, in a funeral speech, in a name whispered to a newborn. That memory is rich. It is also fragile.&lt;/p&gt;

&lt;p&gt;Lignée is for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Elders&lt;/strong&gt; whose stories deserve more than a fading recollection
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parents&lt;/strong&gt; who want their children to know the tree they grew from
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cousins scattered across cities and continents&lt;/strong&gt; who still share one lineage
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Future generations&lt;/strong&gt; who will open a phone and see faces and names instead of silence
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not a generic genealogy product. It is a gift I intend to hand over — to my family first, and then to anyone who needs the same thing for theirs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem: oral memory is fading
&lt;/h2&gt;

&lt;p&gt;In many African families, lineage is oral. That is beautiful — and it is at risk.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Elders pass away, and with them go the precise links: who married whom, which child belonged to which house, how two surnames became one story.
&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;diaspora&lt;/strong&gt; stretches kinship across borders. A cousin in Europe and a cousin in Lomé may share blood and still not share a shared map of the family.
&lt;/li&gt;
&lt;li&gt;Young people grow up knowing &lt;em&gt;that&lt;/em&gt; they come from somewhere, without knowing &lt;em&gt;how&lt;/em&gt; the branches connect.
&lt;/li&gt;
&lt;li&gt;Paper notebooks get lost. WhatsApp threads scroll away. A Facebook album is not a tree.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If we do nothing, the next generation inherits love without structure — affection without ancestry they can navigate. I did not want my family's story to depend on whoever happens to remember next Sunday.&lt;/p&gt;

&lt;p&gt;I wanted something &lt;strong&gt;durable&lt;/strong&gt;, &lt;strong&gt;editable&lt;/strong&gt;, &lt;strong&gt;installable on a phone&lt;/strong&gt;, and &lt;strong&gt;honest about privacy&lt;/strong&gt; — because family data is not the kind of data you casually hand to a closed cloud.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Lignée does
&lt;/h2&gt;

&lt;p&gt;Lignée is a web app (and a &lt;strong&gt;PWA&lt;/strong&gt; you can install on your phone) that lets a family build and explore its tree as a living mind-map.&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%2Fti6oi7i9hq4gi46k8q3p.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%2Fti6oi7i9hq4gi46k8q3p.png" alt="Tree mind-map" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A mind-map of kinship, not a stiff chart
&lt;/h3&gt;

&lt;p&gt;Using &lt;strong&gt;React Flow&lt;/strong&gt;, the tree is a visual graph you can pan, zoom, and rearrange. People are nodes. Marriages, parent–child links, cousins, and in-laws are edges. It feels like thinking on a whiteboard — not like filling a bureaucratic form.&lt;/p&gt;

&lt;h3&gt;
  
  
  Click-to-edit, rich people cards
&lt;/h3&gt;

&lt;p&gt;Tap a person. Open their card. Add or correct a name, dates, notes, relationships. No ceremony — if Auntie remembers a detail at dinner, you fix it in thirty seconds.&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%2Fwn53i23149pcy4j90k5h.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%2Fwn53i23149pcy4j90k5h.png" alt="Person card" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Relationships that match how we actually live
&lt;/h3&gt;

&lt;p&gt;Nuclear-only trees fail African (and many diaspora) families. Lignée models:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Marriages&lt;/strong&gt; and unions that connect two branches
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cousins&lt;/strong&gt; and extended kinship, not only parents and children
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-laws&lt;/strong&gt; — the people who become family by bond, not only by blood
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Install it on your phone (PWA)
&lt;/h3&gt;

&lt;p&gt;Family conversations happen on phones. Lignée is a Progressive Web App: install it to the home screen, open it like a native app, and keep the tree close when someone starts telling a story.&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%2Fapmwuzckktni7ns32phe.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%2Fapmwuzckktni7ns32phe.png" alt="PWA phone" width="799" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Story import — from &lt;em&gt;récit familial&lt;/em&gt; to nodes
&lt;/h3&gt;

&lt;p&gt;This is the heart of the project. You paste (or type) a family story — a &lt;em&gt;récit familial&lt;/em&gt; — and open-source AI helps turn narrative into structured people and relationships. Then you review, correct, and own the result.&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%2Fii78urciuvkykjvkz365.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%2Fii78urciuvkykjvkz365.png" alt="Story import" width="670" height="1024"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Home view, shown before opening the tree:&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%2Fc7rvph3cr3jk0iziiso2.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%2Fc7rvph3cr3jk0iziiso2.png" alt="Home screen" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Demo walkthrough (screenshots above):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the tree — see the Adjolou/Chamiande mind-map.
&lt;/li&gt;
&lt;li&gt;Tap a person — edit their card.
&lt;/li&gt;
&lt;li&gt;Paste a short family story — run story import.
&lt;/li&gt;
&lt;li&gt;Review suggested nodes and links — accept what is right, fix what is not.
&lt;/li&gt;
&lt;li&gt;Install the PWA on your phone — take the tree with you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://story-lineage.vercel.app" rel="noopener noreferrer"&gt;https://story-lineage.vercel.app&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Source (primary public link):&lt;/strong&gt; &lt;a href="https://github.com/Donchaminade/story-lineage" rel="noopener noreferrer"&gt;https://github.com/Donchaminade/story-lineage&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Origin mirror (optional):&lt;/strong&gt; &lt;a href="https://cursor.com/codebase/donchaminade/story-lineage" rel="noopener noreferrer"&gt;https://cursor.com/codebase/donchaminade/story-lineage&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Demo note:&lt;/strong&gt; The Vercel serverless deployment uses ephemeral storage, so demo seed data may reload on cold instances; edits made on a prior instance may not persist.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Open-source AI at the core
&lt;/h2&gt;

&lt;p&gt;I refuse to build a family memory tool that only works if a closed vendor stays friendly, cheap, and online.&lt;/p&gt;

&lt;h3&gt;
  
  
  How story import works
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;You provide a natural-language family story (French or English — the language of the telling).
&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;open AI&lt;/strong&gt; pipeline (designed for &lt;strong&gt;Ollama&lt;/strong&gt; and local inference, with a &lt;strong&gt;local fallback&lt;/strong&gt;) extracts candidate people, generations, and relationships.
&lt;/li&gt;
&lt;li&gt;The app proposes &lt;strong&gt;nodes and edges&lt;/strong&gt; on the tree.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A human always reviews.&lt;/strong&gt; AI suggests; the family decides.
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No magic black box that silently rewrites your lineage. Suggestion, then consent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why open matters for family data
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Why open-source AI helps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Privacy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Family names, dates, and stories are intimate. Local inference (e.g. Ollama) keeps data on machines you control.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No vendor lock-in&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Models and providers can be swapped. Your tree is not hostage to one API's pricing or terms.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Local option&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;When the network is weak — or trust is low — local fallback still lets story import run.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Inspectability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Open models and open code mean the community can audit how narrative becomes structure.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Closed AI might be convenient. For &lt;em&gt;family lineage&lt;/em&gt;, convenience is not enough. &lt;strong&gt;Sovereignty over memory&lt;/strong&gt; is the feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I built it (honest technical notes)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stack&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; — app framework, routing, and a clean path to a PWA
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite&lt;/strong&gt; — simple, portable persistence; easy to back up; no heavy ops tax for a family project
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React Flow&lt;/strong&gt; — interactive mind-map UX (pan, zoom, nodes, edges)
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PWA&lt;/strong&gt; — installable on Android/iOS home screens for real family use
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open AI / Ollama + local fallback&lt;/strong&gt; — story import without requiring a single proprietary cloud
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What went well&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React Flow made "kinship as a graph" feel natural quickly — marriages and cousins are just edges with meaning.
&lt;/li&gt;
&lt;li&gt;SQLite kept the data model honest and movable: a family's tree should be something you can copy, backup, and keep.
&lt;/li&gt;
&lt;li&gt;Designing for &lt;strong&gt;phone-first&lt;/strong&gt; editing forced clarity: click-to-edit beats multi-step wizards when an elder is mid-story.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What was hard (and still is)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Modeling &lt;strong&gt;extended kinship&lt;/strong&gt; without drowning the UI — African families are dense graphs; the UX has to stay readable.
&lt;/li&gt;
&lt;li&gt;Story import is only as good as the review loop. Hallucinated cousins are worse than missing ones — so the product bias is: &lt;em&gt;suggest carefully, confirm always&lt;/em&gt;.
&lt;/li&gt;
&lt;li&gt;Bilingual reality (French oral culture, English for this challenge write-up and global open source) — the product must respect the language of the &lt;em&gt;récit&lt;/em&gt;, not only the language of the README.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I would improve next&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Richer media on person cards (photos, voice notes of elders telling the story)
&lt;/li&gt;
&lt;li&gt;Stronger conflict resolution when two relatives disagree on a link
&lt;/li&gt;
&lt;li&gt;Export/import formats for long-term archival beyond one app
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This was a weekend challenge build with a long-horizon intention. The code is a starting lineage of its own: it will grow.&lt;/p&gt;




&lt;h2&gt;
  
  
  Handing it over — what it means for future generations
&lt;/h2&gt;

&lt;p&gt;When I imagine a child in twenty years opening Lignée, I do not imagine a product demo. I imagine them tapping a great-grandparent's card and reading a note that someone bothered to write down — a marriage that joined Chamiande and Adjolou, a cousin who left and came back, a name that explains their own.&lt;/p&gt;

&lt;p&gt;Oral memory gave us warmth. Digital lineage can give us &lt;strong&gt;continuity&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I am handing this to my family as a living gift: editable, installable, private by design, open in its AI so we are not tenants on someone else's memory server. If other families fork it for their own surnames and stories, that is the point of open source — &lt;strong&gt;lineage that multiplies without being owned&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For Hacktoberfest, this is my weekend challenge. For my family, this is unfinished on purpose: trees grow.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Overall only.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;(This submission does not claim partner tool categories such as Copilot, Cloudflare, Docker, etc., unless those tools were genuinely central to the build. The core stack here is Next.js, SQLite, React Flow, PWA, and open-source AI / Ollama with local fallback.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://story-lineage.vercel.app" rel="noopener noreferrer"&gt;https://story-lineage.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repository (primary public link):&lt;/strong&gt; &lt;a href="https://github.com/Donchaminade/story-lineage" rel="noopener noreferrer"&gt;https://github.com/Donchaminade/story-lineage&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin mirror (optional):&lt;/strong&gt; &lt;a href="https://cursor.com/codebase/donchaminade/story-lineage" rel="noopener noreferrer"&gt;https://cursor.com/codebase/donchaminade/story-lineage&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Challenge tags (for DEV.to)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;#devchallenge&lt;/code&gt; &lt;code&gt;#weekendchallenge&lt;/code&gt; &lt;code&gt;#hf26challenge&lt;/code&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with care in Lomé's wider story — for the Adjolou/Chamiande lineage, and for anyone who refuses to let family memory evaporate.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
    </item>
  </channel>
</rss>
