<?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: Info Inlet</title>
    <description>The latest articles on DEV Community by Info Inlet (@infoinlet1).</description>
    <link>https://dev.to/infoinlet1</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3941862%2Fcec34100-b85a-4c9e-8061-55c585109946.jpeg</url>
      <title>DEV Community: Info Inlet</title>
      <link>https://dev.to/infoinlet1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/infoinlet1"/>
    <language>en</language>
    <item>
      <title>We Deleted Our Vector Database. Postgres Was Faster.</title>
      <dc:creator>Info Inlet</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:52:41 +0000</pubDate>
      <link>https://dev.to/infoinlet1/we-deleted-our-vector-database-postgres-was-faster-2i73</link>
      <guid>https://dev.to/infoinlet1/we-deleted-our-vector-database-postgres-was-faster-2i73</guid>
      <description>&lt;p&gt;We paid for a vector database for a year. It was faster than Postgres at the one thing it does. We deleted it anyway.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- managed vector store      3.2M vectors, its own SLA, its own bill
- sync pipeline             1,100 lines keeping it in step with Postgres
+ one column                embedding  vector(1536)
+ one index                 hnsw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I want to be careful about the "faster," because the headline oversells it and the truth is more useful. On a pure nearest-neighbour benchmark — a bare &lt;code&gt;k&lt;/code&gt; nearest vectors, no filter, warm cache — the dedicated store won, every time. It is built for that and it is very good at it.&lt;/p&gt;

&lt;p&gt;Our problem was that we never once ran that query in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we actually asked it, every time
&lt;/h2&gt;

&lt;p&gt;Nobody in a real product searches the whole corpus. They search &lt;em&gt;their&lt;/em&gt; corpus. Every retrieval we ran looked like this in English:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find the chunks most similar to this question — &lt;strong&gt;among documents this user is allowed to see, that are published, in their workspace, not archived.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is not nearest-neighbour search. It is nearest-neighbour search with a &lt;code&gt;WHERE&lt;/code&gt; clause, and the &lt;code&gt;WHERE&lt;/code&gt; clause is the whole problem, because a dedicated vector store filters in one of two bad ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-filter&lt;/strong&gt;, and it walks the metadata first, then does brute-force similarity over what survives — throwing away the ANN index, the only reason you bought the thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Post-filter&lt;/strong&gt;, and it takes the top &lt;code&gt;k&lt;/code&gt; by vector distance and &lt;em&gt;then&lt;/em&gt; drops the ones that fail the filter. Ask for 10, match on similarity, discover 7 of them belong to a different tenant, return 3. To get 10 back you over-fetch — top 100, top 500 — and hope. Recall becomes a function of how badly the filter correlates with the vector space, which is to say: unknowable, and worst exactly when the filter is selective, which is exactly when you needed it.&lt;/p&gt;

&lt;p&gt;We shipped the post-filter version. Then we bolted a fetch-back onto it: take the surviving IDs, go to Postgres for the metadata the vector store didn't hold, filter again to be sure. A second network hop, on every query, to ask the database the question we should have asked it in the first place.&lt;/p&gt;




&lt;h2&gt;
  
  
  The seam that was actually driving all of it
&lt;/h2&gt;

&lt;p&gt;Here is the part that made me stop optimising the pipeline and delete it instead.&lt;/p&gt;

&lt;p&gt;The embedding is not data. It is a &lt;strong&gt;pure function of data&lt;/strong&gt; — run the document through the model, get the vector. It is derived, the way a thumbnail is derived from an image. And we were storing the derived thing in a different database from the thing it was derived from.&lt;/p&gt;

&lt;p&gt;Which means every write was two writes:&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;await&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;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;documents&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;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;          &lt;span class="c1"&gt;// system 1: the truth&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;vectorStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;embed&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="c1"&gt;// system 2: the copy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two systems. &lt;strong&gt;One of them can fail.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the embed-and-upsert fails after the document commits — the model API times out, the upsert 500s, the retry also fails and eventually logs a warning nobody reads — you now have a document whose row says one thing and whose searchable vector says another. No exception in your tracker. No alert. Just a document that quietly cannot be found, or worse, one that is found by its &lt;em&gt;old&lt;/em&gt; content because the vector is stale.&lt;/p&gt;

&lt;p&gt;The mirror case is uglier. Delete a document from Postgres, fail to delete it from the vector store, and now your search confidently returns a chunk of a document that no longer exists — to a user who may no longer be allowed to see it. A deleted row is a permission you revoked. A surviving vector is that permission, still granted, in a system nobody thinks of as holding permissions.&lt;/p&gt;

&lt;p&gt;This has a name, or near enough — it is the &lt;a href="https://en.wikipedia.org/wiki/Two-phase_commit_protocol" rel="noopener noreferrer"&gt;dual-write problem&lt;/a&gt;, the same seam the transactional-outbox people have been shouting about for a decade. If you run a vector store beside your database, you have shipped it. You may just be calling its symptoms "the index is a bit stale sometimes."&lt;/p&gt;

&lt;p&gt;The standard fix is a change-data-capture pipeline: tail the Postgres WAL, transform, re-embed, upsert, with a dead-letter for the embeds that fail and a reconciliation job that periodically re-scans everything because you know the pipeline drifts. That works. It is also a distributed system whose entire job is to paper over the fact that a derived value is stored away from its source. It was most of our 1,100 lines.&lt;/p&gt;

&lt;p&gt;We deleted the source of the drift instead of building a machine to chase it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The column
&lt;/h2&gt;

&lt;p&gt;This is the entire vector store now. It is a column on the table that already existed.&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="n"&gt;extension&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;vector&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="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;-- text-embedding-3-small&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vector(1536)&lt;/code&gt; is a first-class type from &lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;pgvector&lt;/a&gt;. The distance operators come with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;-&amp;gt;   L2 distance
&amp;lt;=&amp;gt;   cosine distance
&amp;lt;#&amp;gt;   negative inner product
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the index that makes it fast is one statement:&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;index&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ef_construction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HNSW — the good ANN index, the same graph algorithm the dedicated stores use — has been in pgvector since &lt;strong&gt;0.5.0, released August 2023.&lt;/strong&gt; This is the fact the "just use a real vector database" crowd is usually a year behind on. It is not IVFFlat, it is not a toy, it is the actual state of the art, running inside the database that already holds your data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query that was the whole point
&lt;/h2&gt;

&lt;p&gt;The filtered search that cost us two hops and a prayer is now one statement with one planner:&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
   &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'published'&lt;/span&gt;
   &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;archived&lt;/span&gt;
 &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;      &lt;span class="c1"&gt;-- $2 is the question's embedding&lt;/span&gt;
 &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;WHERE&lt;/code&gt; and the &lt;code&gt;ORDER BY embedding &amp;lt;=&amp;gt;&lt;/code&gt; are planned together. Postgres decides, per query, whether to use the HNSW index and check the filter, or use a btree on &lt;code&gt;workspace_id&lt;/code&gt; and sort by distance — based on how selective your filter actually is. The thing the dedicated store made me choose between at architecture time, the query planner now chooses at runtime, per query, with statistics.&lt;/p&gt;

&lt;p&gt;There is one detail that is not optional and is where people get burned, so it gets its own section.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detail nobody tells you: iterative scan
&lt;/h2&gt;

&lt;p&gt;An HNSW index returns a fixed number of candidates and &lt;em&gt;then&lt;/em&gt; your &lt;code&gt;WHERE&lt;/code&gt; clause filters them. Selective filter, and you hit the exact post-filter problem I described above — the index hands up 40 candidates, 37 fail your filter, you asked for 10 and get 3. For a while this was pgvector's real weakness and the honest reason to reach for a dedicated store.&lt;/p&gt;

&lt;p&gt;It was fixed. &lt;strong&gt;pgvector 0.8.0, October 2024&lt;/strong&gt;, added &lt;em&gt;iterative index scans&lt;/em&gt;: when a filtered search comes up short, the index keeps walking the graph and returns more candidates until your &lt;code&gt;LIMIT&lt;/code&gt; is satisfied or the search is exhausted.&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;set&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterative_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'strict_order'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ef_search&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;-- widen the candidate list; the recall/latency dial&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you evaluated pgvector before late 2024, filtered recall is the thing you found wanting, and it is the thing that changed. Re-run your benchmark. This is the single most important sentence in this post.&lt;/p&gt;




&lt;h2&gt;
  
  
  What got deleted along with the database
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The CDC pipeline.&lt;/strong&gt; WAL tail, transformer, embed worker, upsert, dead-letter, the lot. This was the 1,100 lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reconciliation job.&lt;/strong&gt; The nightly full-scan that existed only because we knew the pipeline drifted. You do not reconcile a system against itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fetch-back hop.&lt;/strong&gt; Metadata lives on the same row as the vector. The join is free; it is the same row.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A second SLA, a second bill, a second dashboard, a second thing to be paged about.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Is the index stale?"&lt;/strong&gt; — the question that preceded every "why can't the user find this document" investigation, and which no longer has a mechanism to be true.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Ninety days later
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Systems in the retrieval path&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 filtered retrieval&lt;/td&gt;
&lt;td&gt;220ms&lt;/td&gt;
&lt;td&gt;41ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sync / reconciliation code&lt;/td&gt;
&lt;td&gt;1,100 lines&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly vector-store bill&lt;/td&gt;
&lt;td&gt;$840&lt;/td&gt;
&lt;td&gt;$0 (folded into RDS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documents findable but deleted&lt;/td&gt;
&lt;td&gt;non-zero&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The latency number is the one people fixate on, so let me undersell it correctly: &lt;strong&gt;this is not pgvector's ANN beating the dedicated store's ANN.&lt;/strong&gt; It is not. On the bare benchmark theirs still wins. The 220ms was two network round-trips, a post-filter over-fetch, and a metadata fetch-back — and we deleted all three. We made the path shorter, not the search faster. If your retrieval is a single unfiltered ANN call inside your own VPC, you will not see this.&lt;/p&gt;

&lt;p&gt;The last row is the only one I would have done this for. "Documents findable but deleted" was a class of incident. Now it is a state the system cannot be in, because there is no second copy to be wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  The five objections, scored honestly
&lt;/h2&gt;

&lt;p&gt;Every one of these was said to me by someone who knew more about vector search than I did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. "Postgres can't do ANN at scale."&lt;/strong&gt;&lt;br&gt;
It has done HNSW since August 2023 — the same algorithm, in-process. "At scale" is the real question and it is objection 2. As a flat "can't," this is a year or two out of date. &lt;em&gt;Cargo cult.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. "It won't scale to billions of vectors."&lt;/strong&gt;&lt;br&gt;
Correct, and this is the honest boundary. Somewhere north of tens of millions of vectors at sustained high QPS, a purpose-built store earns its price: better memory layout, real quantization, distributed sharding you do not want to build on Postgres. Ours was 3.2 million at 30 queries a second. Ask what your number actually is before you architect for someone else's. &lt;em&gt;Real — check your number first.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "The HNSW index build will melt your database."&lt;/strong&gt;&lt;br&gt;
This one is real and under-warned. Building an HNSW index over millions of rows is memory-hungry and slow, and it competes with your live traffic. Build it with &lt;code&gt;maintenance_work_mem&lt;/code&gt; cranked up, use parallel workers, and treat a full rebuild as a maintenance event, not a migration you run at 5pm on a Friday. &lt;code&gt;halfvec&lt;/code&gt; (pgvector 0.7.0) halves the storage and the build cost with negligible recall loss at 1536 dims. &lt;em&gt;Real. Budget for it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. "You're coupling your search to your primary database."&lt;/strong&gt;&lt;br&gt;
We were already coupled — the vectors were &lt;em&gt;derived from&lt;/em&gt; the primary and had to be kept in step with it forever. What we removed was the pretence that a copy in another system had decoupled anything. A stale copy is tighter coupling than a column, because a column cannot lag. &lt;em&gt;Cargo cult, in our case — check whether it is in yours.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. "Dedicated stores have reranking, hybrid search, multi-tenancy features."&lt;/strong&gt;&lt;br&gt;
Some do, and if you are using them, this trade is different for you. But hybrid search is &lt;a href="https://github.com/pgvector/pgvector#hybrid-search" rel="noopener noreferrer"&gt;&lt;code&gt;tsvector&lt;/code&gt; and a vector column in the same &lt;code&gt;WHERE&lt;/code&gt;&lt;/a&gt;, reranking is a cross-encoder call you make after retrieval either way, and multi-tenancy is a column you already have. We were paying for a feature list to use one item on it. &lt;em&gt;Check the list against what you actually call.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  When this is the wrong call
&lt;/h2&gt;

&lt;p&gt;I would not do this if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You are past tens of millions of vectors at high, sustained QPS.&lt;/strong&gt; Buy the specialised store. The index-build tuning stops being a config change and becomes a full-time interest, and quantization and sharding are genuinely better over there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeddings are the product.&lt;/strong&gt; If you are running billion-scale semantic search as your core offering, this is your database, not a column, and you should treat it that way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your vectors have no source of truth in Postgres.&lt;/strong&gt; The entire argument here is that the embedding is derived from a row you already store. If the vectors stand alone — you never store the source text, only the vector — then there is no seam to delete and the calculus is just raw ANN performance, which the dedicated store wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need a distance metric or index type pgvector doesn't have.&lt;/strong&gt; Rare, but check. Do not discover it after the migration.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I would tell myself a year earlier
&lt;/h2&gt;

&lt;p&gt;The embedding was never a database. It was a column we had exiled to another system, and then hired a pipeline to visit it and keep its story straight.&lt;/p&gt;

&lt;p&gt;Before you stand up a vector database, ask the question we skipped: &lt;strong&gt;is the vector derived from a row I already store, and are my real queries filtered?&lt;/strong&gt; If both are yes, you are not buying nearest-neighbour search. You are buying a second copy of your data, a network hop, and a synchronisation problem — to get a &lt;code&gt;WHERE&lt;/code&gt; clause your database already had.&lt;/p&gt;

&lt;p&gt;pgvector shipped HNSW in 2023 and filtered iterative scan in 2024, and did not send anyone a migration notice.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Four questions I would genuinely like answered in the comments:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What is your actual vector count and peak QPS — the numbers in your dashboard, not the ones in your architecture doc?&lt;/li&gt;
&lt;li&gt;If you run a dedicated store, how do you handle deletes propagating from your primary — and are you sure they do?&lt;/li&gt;
&lt;li&gt;Has anyone benchmarked pgvector 0.8 iterative scan against a dedicated store &lt;em&gt;on filtered queries&lt;/em&gt; specifically? That is the comparison that matters and the one nobody posts.&lt;/li&gt;
&lt;li&gt;Who moved the other way — Postgres → dedicated store — and what number forced it?&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>ai</category>
      <category>postgres</category>
      <category>database</category>
      <category>rag</category>
    </item>
    <item>
      <title>We Replaced 3 Microservices With One Postgres Table</title>
      <dc:creator>Info Inlet</dc:creator>
      <pubDate>Sat, 05 Sep 2026 08:17:59 +0000</pubDate>
      <link>https://dev.to/infoinlet1/we-replaced-3-microservices-with-one-postgres-table-4dc4</link>
      <guid>https://dev.to/infoinlet1/we-replaced-3-microservices-with-one-postgres-table-4dc4</guid>
      <description>&lt;p&gt;Three services died in that pull request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- notification-svc     1,850 lines
- scheduler-svc        1,340 lines
- retry-svc              910 lines
+ job table               41 lines of SQL
+ one worker             560 lines
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing was wrong with any of them. That is the part that took eighteen months to see, and it is the only reason this post is worth your time — because if they had been badly built, the lesson would just be "we wrote bad services and then wrote better ones," which is not a lesson.&lt;/p&gt;

&lt;p&gt;They were well built. They were well built &lt;strong&gt;around a seam that did not need to exist.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What the three of them actually did
&lt;/h2&gt;

&lt;p&gt;A user places an order. Some time later, they get a receipt. Between those two facts sat:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;notification-svc&lt;/code&gt;&lt;/strong&gt; — owned templates and delivery. Talked to the email provider, the push provider, the SMS provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scheduler-svc&lt;/code&gt;&lt;/strong&gt; — owned &lt;em&gt;when&lt;/em&gt;. Send now, send in 20 minutes, send at 9am in the user's timezone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;retry-svc&lt;/code&gt;&lt;/strong&gt; — owned &lt;em&gt;what happens when the provider is down&lt;/em&gt;. Backoff, jitter, dead-letter, the alert when the dead-letter queue got deep.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Between them: two RabbitMQ queues, a Redis for scheduled sets, three deployments, three sets of dashboards, three on-call runbooks. Roughly 4,100 lines, not counting infrastructure.&lt;/p&gt;

&lt;p&gt;Write down what all three do in one sentence and the problem becomes visible:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All three existed to move a row from one place to another, later.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is not a domain. That is a queue. We had built three domain services around a data structure.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bug that was actually driving all of it
&lt;/h2&gt;

&lt;p&gt;Here is the thing that made me stop and rewrite instead of refactor.&lt;/p&gt;

&lt;p&gt;Every path into that system looked like this:&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;await&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;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// system 1&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;send_receipt&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;orderId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;   &lt;span class="c1"&gt;// system 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two systems. &lt;strong&gt;One of them can fail.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the publish fails after the insert commits, you now have an order in your database that nothing in the world knows about. No exception in your logs, because you caught it and retried, and the retry also failed, and eventually you gave up and logged a warning nobody reads. No alert. Just a customer who never got their receipt, and a row that looks completely fine.&lt;/p&gt;

&lt;p&gt;Do it the other way round and you get the mirror image: a receipt for an order that does not exist.&lt;/p&gt;

&lt;p&gt;This has a name — the &lt;strong&gt;transactional outbox problem&lt;/strong&gt; — and if you have ever shipped a queue, you have shipped this bug. You may not have found it yet.&lt;/p&gt;

&lt;p&gt;The standard fix is to add an outbox table, a relay process that reads it and publishes, and dedupe on the consumer because that relay is at-least-once. That works. It is also &lt;strong&gt;three more moving parts to paper over one seam&lt;/strong&gt;, and it was on our roadmap.&lt;/p&gt;

&lt;p&gt;We built the other thing instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;

&lt;p&gt;This is the entire queue.&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="n"&gt;job&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;kind&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;payload&lt;/span&gt;      &lt;span class="n"&gt;jsonb&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;run_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="n"&gt;attempts&lt;/span&gt;     &lt;span class="nb"&gt;int&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;max_attempts&lt;/span&gt; &lt;span class="nb"&gt;int&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="mi"&gt;8&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;'ready'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_error&lt;/span&gt;   &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- the only index that matters. Partial, so it stays small:&lt;/span&gt;
&lt;span class="c1"&gt;-- the ready set is a few thousand rows, the table is millions.&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;job_ready_idx&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ready'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;run_at&lt;/code&gt; is &lt;code&gt;scheduler-svc&lt;/code&gt;. &lt;code&gt;attempts&lt;/code&gt; and &lt;code&gt;max_attempts&lt;/code&gt; are &lt;code&gt;retry-svc&lt;/code&gt;. &lt;code&gt;kind&lt;/code&gt; and &lt;code&gt;payload&lt;/code&gt; are &lt;code&gt;notification-svc&lt;/code&gt;. Nine columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a worker takes a job
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="k"&gt;as&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;id&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;
   &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ready'&lt;/span&gt;
     &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;run_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="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt;
     &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="n"&gt;locked&lt;/span&gt;      &lt;span class="c1"&gt;-- ← this line is the whole post&lt;/span&gt;
   &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;
   &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="o"&gt;=&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;'5 minutes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; tells Postgres: lock these rows, and if another transaction already has one, &lt;strong&gt;don't wait — skip it and take the next.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Twenty workers can run that statement at the same time and no two of them will ever get the same row. No coordination, no leader, no lease service, no Redis. Postgres has shipped this since &lt;strong&gt;9.5, in 2016.&lt;/strong&gt; Most teams buy a broker to get a behaviour their database has had for a decade.&lt;/p&gt;

&lt;p&gt;Two details that are not optional:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claim, then commit, then work.&lt;/strong&gt; The transaction above ends the moment the rows are claimed. The worker does the actual sending outside it. Holding a transaction open for the duration of a job is the mistake that gives "Postgres as a queue" its bad reputation — it pins a connection and blocks vacuum for as long as the job runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Something has to reap.&lt;/strong&gt; &lt;code&gt;locked_until&lt;/code&gt; is a lease. A worker that dies mid-job leaves its rows in &lt;code&gt;running&lt;/code&gt; forever, so a small periodic statement moves expired leases 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;update&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ready'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;locked_until&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is your entire failure-recovery story. It replaced &lt;code&gt;retry-svc&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  And then the enqueue
&lt;/h2&gt;

&lt;p&gt;This is the part I actually care about, and it is one line:&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;begin&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;into&lt;/span&gt; &lt;span class="n"&gt;orders&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="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'send_receipt'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'order_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...));&lt;/span&gt;
&lt;span class="k"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order and the job that sends its receipt now &lt;strong&gt;commit together, or neither does.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not "usually." Not "we retry the publish three times." There is no publish. There is no second system. The class of bug where the row exists and the notification does not is no longer unlikely — it is &lt;strong&gt;unrepresentable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We did not solve the outbox problem. We deleted the seam it lives in.&lt;/p&gt;




&lt;h2&gt;
  
  
  What got deleted along with the services
&lt;/h2&gt;

&lt;p&gt;Things that had been real work, real tickets, real pages, and stopped existing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency keys on the consumer.&lt;/strong&gt; At-least-once delivery meant every handler had to be safe to run twice. Now a job is claimed by exactly one worker in one transaction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dead-letter queue and its dashboard.&lt;/strong&gt; &lt;code&gt;attempts &amp;gt;= max_attempts&lt;/code&gt; is a &lt;code&gt;WHERE&lt;/code&gt; clause. Inspecting failures is &lt;code&gt;select * from job where status = 'failed'&lt;/code&gt;. Retrying them is an &lt;code&gt;update&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue-vs-database drift.&lt;/strong&gt; There is no longer a state of the world where the queue believes one thing and the database believes another, because there is one place.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two RabbitMQ nodes, a Redis, and three deploy pipelines.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Which service is this in?"&lt;/strong&gt; — the question that ate the most engineer-hours of anything on this list, and does not appear in any postmortem.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Ninety days later
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Services&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lines of code&lt;/td&gt;
&lt;td&gt;4,100&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 enqueue → execute&lt;/td&gt;
&lt;td&gt;4.2s&lt;/td&gt;
&lt;td&gt;380ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pages / month&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Places a message can be lost&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The p99 number surprised people, so: it is not that Postgres is faster than RabbitMQ. It is that we deleted &lt;strong&gt;two network hops and a poll interval.&lt;/strong&gt; The old p99 was mostly scheduling latency and queue-hop overhead, not broker throughput. Nothing here makes Postgres a faster message bus. It makes the path shorter.&lt;/p&gt;

&lt;p&gt;The last row is the only one I would have done this for.&lt;/p&gt;




&lt;h2&gt;
  
  
  The five objections, scored honestly
&lt;/h2&gt;

&lt;p&gt;Every one of these was said to me, in a review, by someone competent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. "A database is not a queue."&lt;/strong&gt;&lt;br&gt;
It is a durable, transactional, ordered, indexed store with row-level locking and a purpose-built primitive for concurrent consumers. If that is not a queue, the word has stopped meaning anything. &lt;em&gt;Cargo cult.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. "It won't scale."&lt;/strong&gt;&lt;br&gt;
It will not scale &lt;em&gt;forever&lt;/em&gt;, which is a different sentence. On one unremarkable Postgres box this pattern handles a few thousand jobs a second before you have to think hard. Ask what your actual number is. Ours was 40 jobs a second at peak, and I would bet real money yours is closer to 40 than to 5,000. &lt;em&gt;Real, but check your number first.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "You'll blow up the table with dead tuples."&lt;/strong&gt;&lt;br&gt;
This one is correct and it is the one nobody warns you about loudly enough. A queue table is the highest-churn table you will ever own — every job is an insert, two updates and a delete. Autovacuum's defaults are tuned for tables that do not behave like that. Set them per-table:&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;table&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_vacuum_scale_factor&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="mi"&gt;02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_vacuum_cost_delay&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="n"&gt;autovacuum_analyze_scale_factor&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="mi"&gt;05&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And delete completed rows on a schedule rather than keeping them forever — move them to a &lt;code&gt;job_archive&lt;/code&gt; table if you need the history. &lt;em&gt;Real. Budget for it on day one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. "You're coupling your services to one database."&lt;/strong&gt;&lt;br&gt;
We had one database before. The services shared it. What we removed was the &lt;em&gt;pretence&lt;/em&gt; that they did not. &lt;em&gt;Cargo cult, in our case — check whether it is in yours.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. "This doesn't work for fan-out."&lt;/strong&gt;&lt;br&gt;
Correct, and it is the real limit. One row goes to exactly one worker; that is the entire design. If you need one event delivered independently to six consumers who each track their own position, you want a log, and you want Kafka, and no amount of SQL is going to be nicer. &lt;em&gt;Real. Different problem.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  When this is the wrong call
&lt;/h2&gt;

&lt;p&gt;I would not do this if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You are above a few thousand jobs a second.&lt;/strong&gt; Buy the broker. The vacuum tuning stops being a config change and starts being a full-time interest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need fan-out or replay.&lt;/strong&gt; See above. Different data structure, different tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your teams work in four languages.&lt;/strong&gt; A broker is a lingua franca. SQL and a shared schema are a coupling you have to socialise, and that cost is organisational, not technical, which makes it harder, not easier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The queue must outlive the database.&lt;/strong&gt; This is the real trade and it deserves saying plainly: your queue is now exactly as available as your primary. For most teams that is an upgrade, because the queue was never actually more available than the database it fed. But you should choose it on purpose rather than discover it at 3am.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I would tell myself eighteen months earlier
&lt;/h2&gt;

&lt;p&gt;Check one number before you build three services.&lt;/p&gt;

&lt;p&gt;Not "how many messages a second do we expect at scale" — the honest one: &lt;strong&gt;how many a second do we do today, and what is the multiple we would need before this hurts?&lt;/strong&gt; Ours was 40, and the answer was about 100×. We spent eighteen months and three services buying headroom for a load that has still not arrived.&lt;/p&gt;

&lt;p&gt;The queue is a solved problem that a lot of us keep re-buying, one service at a time. Postgres solved it in 2016 and did not put out a press release.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Four questions I would genuinely like answered in the comments:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What is your actual jobs-per-second at peak — not your projection, the number in your dashboard right now?&lt;/li&gt;
&lt;li&gt;Has anyone here found the autovacuum tuning insufficient at high churn, and what did you move to?&lt;/li&gt;
&lt;li&gt;If you moved the other way — Postgres → broker — what number finally forced it?&lt;/li&gt;
&lt;li&gt;Who has shipped the transactional outbox and would do it again, versus collapsing the seam instead?&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>postgres</category>
      <category>architecture</category>
      <category>backend</category>
      <category>sql</category>
    </item>
    <item>
      <title>I Replaced 200 Lines of Code With One AI Agent — Here's What Broke</title>
      <dc:creator>Info Inlet</dc:creator>
      <pubDate>Wed, 02 Sep 2026 05:18:33 +0000</pubDate>
      <link>https://dev.to/infoinlet1/i-replaced-200-lines-of-code-with-one-ai-agent-heres-what-broke-4dif</link>
      <guid>https://dev.to/infoinlet1/i-replaced-200-lines-of-code-with-one-ai-agent-heres-what-broke-4dif</guid>
      <description>&lt;p&gt;Here are 200 lines of the most boring code I have ever written, compressed to twelve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// intent-router.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ROUTES&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="na"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;invoice|receipt|bill&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;billing&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="na"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;refund|charge &lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;back|dispute&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refunds&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="na"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;password|2fa|locked out&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// … fourteen more, each with a test, each added the day a user hit it&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;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ROUTES&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;when&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fallback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It took eight months to accumulate. Every regex in it is a scar. It was 91% accurate on our 1,200-row eval set and nobody enjoyed touching it.&lt;/p&gt;

&lt;p&gt;Here is what replaced 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="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;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Handler&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="p"&gt;}&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;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;HANDLERS&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;handler&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six lines. Same eval set, same afternoon: &lt;strong&gt;96%&lt;/strong&gt;. Five points better than eight months of regexes, and I deleted 194 lines to get it.&lt;/p&gt;

&lt;p&gt;I want to be completely clear about this, because the rest of the article is going to sound like a warning and it isn't one: &lt;strong&gt;the agent was better at the job.&lt;/strong&gt; It never got worse. That number held. The measurement was real.&lt;/p&gt;

&lt;p&gt;It still cost me three weeks.&lt;/p&gt;

&lt;p&gt;What broke was never the accuracy. What broke was all the stuff the 200 lines were doing that nobody had written down, because nobody had ever needed to.&lt;/p&gt;




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

&lt;p&gt;Three weeks in, support forwarded a ticket. A customer had asked about a duplicate charge and been routed to &lt;code&gt;billing&lt;/code&gt; instead of &lt;code&gt;refunds&lt;/code&gt;. Wrong queue, four-day delay, angry customer.&lt;/p&gt;

&lt;p&gt;I went to look for the error.&lt;/p&gt;

&lt;p&gt;There was no error.&lt;/p&gt;

&lt;p&gt;The request had succeeded. Status 200. The agent had returned &lt;code&gt;billing&lt;/code&gt;, which is a real handler, spelled correctly, in the enum, on schema. Every check in the pipeline had passed because every check in the pipeline was checking whether the answer was &lt;em&gt;well-formed&lt;/em&gt;, and it was. The answer was well-formed and wrong, and those two things had never needed to be distinguished before — because when a regex is wrong it falls through to &lt;code&gt;fallback&lt;/code&gt;, and &lt;code&gt;fallback&lt;/code&gt; is loud.&lt;/p&gt;

&lt;p&gt;So I went to reproduce it. Same text, straight into the router.&lt;/p&gt;

&lt;p&gt;It returned &lt;code&gt;refunds&lt;/code&gt;. Correct.&lt;/p&gt;

&lt;p&gt;I ran it again. &lt;code&gt;refunds&lt;/code&gt;. Again. &lt;code&gt;refunds&lt;/code&gt;. Nine times out of ten it was right. The tenth time it wasn't, and I could not make the tenth time happen on purpose.&lt;/p&gt;

&lt;p&gt;That was the actual moment. Not the wrong answer — the &lt;em&gt;unreproducible&lt;/em&gt; wrong answer. I had spent a career on a foundation I'd never once articulated: &lt;strong&gt;if I run it again with the same input, I get the same thing.&lt;/strong&gt; Two hundred lines of ugly regex gave me that for free. Six lines of beautiful agent call took it away, and took about five other things with it that I only found by tripping over them one at a time.&lt;/p&gt;

&lt;p&gt;Here they are, roughly in the order they bit.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Failure stopped announcing itself
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;route()&lt;/code&gt; was regexes, a miss looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;route("my card got double charged") → 'fallback'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fallback&lt;/code&gt; is a real state. It's logged, it's counted, there's a dashboard tile for it, and when it spikes somebody adds a regex. The system's ignorance was a &lt;strong&gt;value&lt;/strong&gt;. It was in the type. You couldn't not handle it.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;route()&lt;/code&gt; is an agent, a miss looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;route("my card got double charged") → 'billing'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confident. Valid. Wrong. There is no &lt;code&gt;fallback&lt;/code&gt; because the agent will always pick something — that's what picking means. I had accidentally removed the only channel through which the system could say &lt;em&gt;I don't know&lt;/em&gt;, and I removed it in the same commit that made the system more accurate, which is why nobody caught it in review.&lt;/p&gt;

&lt;p&gt;The fix is not clever, but you have to decide to want 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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="p"&gt;}&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;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;HANDLERS&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;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fallback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;   &lt;span class="c1"&gt;// put the ignorance back&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The regexes couldn't be uncertain, so they expressed uncertainty structurally, by failing. The agent can be uncertain and expresses it by not mentioning it. If you don't ask, you don't get it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The cache started serving a wrong answer forever
&lt;/h2&gt;

&lt;p&gt;This is the one that turned a bad day into three weeks.&lt;/p&gt;

&lt;p&gt;There was a cache in front of the router. Of course there was — it had been there for a year, it was keyed on a hash of the input text, and it had been correct every single day of that year, because a pure function of its input can be cached by its input. That is what pure means.&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="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;hit&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;cache&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;key&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;hit&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;hit&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&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;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;30d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it now, with a non-deterministic &lt;code&gt;route()&lt;/code&gt; behind it, and it stops being a cache. It's a &lt;strong&gt;coin flip with a 30-day memory.&lt;/strong&gt; The first time a phrasing came through, whatever the agent happened to say that time became the permanent answer for that phrasing. If that was the 2% roll, every user who ever phrased it that way for the next month went to the wrong queue. Consistently. Which made it look like a routing rule, not a flake, which is exactly why it took three weeks to find — I was looking for a bug in the rules and there were no rules.&lt;/p&gt;

&lt;p&gt;Nothing in that cache changed. Nobody touched it. It didn't break. &lt;strong&gt;The thing it was built on top of stopped being true, and it had no way to notice.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Go find your caches, your memos, your &lt;code&gt;useMemo&lt;/code&gt;, your idempotency keys, your dedupe-by-hash. Every one of them is a contract that says &lt;em&gt;same input, same output.&lt;/em&gt; When you put an agent under one, you are not adding a feature. You are invalidating a contract that something else in your codebase is already relying on, silently, from a file you have not opened.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Cost became a function of traffic
&lt;/h2&gt;

&lt;p&gt;The 200 lines cost the same at ten requests a day and ten million: nothing. CPU that rounds to zero. That's not a small property, it's the property that lets you not think about it — you can call &lt;code&gt;route()&lt;/code&gt; in a loop, call it twice because it's easier than plumbing the result through, call it on every keystroke.&lt;/p&gt;

&lt;p&gt;And we did. There was a place — a live-preview panel — that called &lt;code&gt;route()&lt;/code&gt; on input change, debounced at 150ms, because it was free.&lt;/p&gt;

&lt;p&gt;It is not free now. Nothing about that call site changed, no one edited that file, and its cost went from zero to a bill.&lt;/p&gt;

&lt;p&gt;Worse: the cost isn't just per call, it's per &lt;em&gt;token&lt;/em&gt;. A user pasting a long email into the box costs materially more than a user typing "refund". Your unit economics now have a variable in them that your users control and that nobody on the team is tracking, because when the code was regexes there was nothing to track.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. p99 stopped existing
&lt;/h2&gt;

&lt;p&gt;Old &lt;code&gt;route()&lt;/code&gt;: about 0.1ms, and the interesting thing was that the number had a &lt;strong&gt;ceiling&lt;/strong&gt;. Seventeen regexes against a bounded string. There is no input that makes it take a second.&lt;/p&gt;

&lt;p&gt;New &lt;code&gt;route()&lt;/code&gt;: p50 around 600ms, p99 around 4s, and — the part that matters — &lt;strong&gt;no ceiling at all.&lt;/strong&gt; Not a slow ceiling. None. The upstream can hang. It can rate-limit you. It can 503 in a region. Your p99 is now a property of somebody else's infrastructure and you will find out about it during their incident, not yours.&lt;/p&gt;

&lt;p&gt;So the timeout question, which has no good answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Timeout at 2s and you turn a 3% slow-tail into a 3% &lt;strong&gt;failure&lt;/strong&gt; rate.&lt;/li&gt;
&lt;li&gt;Timeout at 30s and you're holding connections open for half a minute for a routing decision.&lt;/li&gt;
&lt;li&gt;No timeout and one bad upstream day exhausts your connection pool and takes down endpoints that have nothing to do with routing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The regexes never made me pick. Synchronous, bounded, done. I hadn't appreciated that &lt;code&gt;route()&lt;/code&gt; being &lt;em&gt;synchronous&lt;/em&gt; was load-bearing until making it &lt;code&gt;async&lt;/code&gt; rippled through four call sites and one of them was in a hot loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The tests stopped testing anything
&lt;/h2&gt;

&lt;p&gt;Old test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reset my password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deterministic, instant, free, and it &lt;em&gt;fails&lt;/em&gt; when someone breaks routing. That is the entire job of a test.&lt;/p&gt;

&lt;p&gt;You cannot write that test against an agent. Well — you can, and it'll pass most of the time, and it'll go red on a Tuesday for no reason, and within two sprints somebody marks it flaky and skips it. That's not a hypothetical, that is just what happens to a test that fails 2% of the time for reasons no one can act on.&lt;/p&gt;

&lt;p&gt;So the tests mutate. They become:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HANDLERS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&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;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reset my password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which asserts that the agent returned &lt;em&gt;a handler&lt;/em&gt;. It does not assert that it returned &lt;em&gt;the right&lt;/em&gt; handler. It passes if routing is completely broken as long as it's broken into a valid enum value. The suite is still green. The suite is now decorative.&lt;/p&gt;

&lt;p&gt;What actually replaces it isn't a unit test at all — it's an &lt;strong&gt;eval set&lt;/strong&gt; that runs on a schedule and reports a &lt;em&gt;rate&lt;/em&gt;, plus an alert when the rate moves. That's a real answer and it works. But notice what it costs: it runs on a schedule, not in CI; it reports a distribution, not pass/fail; and it cannot block a merge, because you can't block a merge on a number that jitters. You have swapped a gate for a dashboard, and dashboards are things people have to remember to look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The code changed without a commit
&lt;/h2&gt;

&lt;p&gt;Six weeks after the swap, accuracy on the nightly eval dropped about a point and a half and stayed there. Nothing in our repo had changed — I checked, twice, and then checked the lockfile.&lt;/p&gt;

&lt;p&gt;The model behind the endpoint had been updated.&lt;/p&gt;

&lt;p&gt;Sit with that. &lt;strong&gt;The behaviour of my production code changed, and there is no commit, no diff, no review, and no line in &lt;code&gt;git log&lt;/code&gt; that I can point at.&lt;/strong&gt; Every instinct I have for "what changed?" is built on the assumption that the answer is in version control. For those six lines, it isn't. It's in somebody else's release notes, if they wrote any.&lt;/p&gt;

&lt;p&gt;That's the deepest one, and it's the one that has nothing to do with prompting better. Pin a version where the vendor lets you — and then own the fact that you now have a dependency that &lt;em&gt;expires&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The line I'd write on the wall
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Deleting code deletes its guarantees. Guarantees don't show up in the diff.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The pull request showed −194/+6 and a five-point accuracy win. It was, on the evidence available in the pull request, obviously correct, and I would approve it again today.&lt;/p&gt;

&lt;p&gt;What the diff could not show was that those 194 lines had been quietly providing determinism, a bounded latency, a zero marginal cost, a fallback state, testability, and a change history — none of which anybody had asked for, all of which other parts of the system had been silently built on top of.&lt;/p&gt;

&lt;p&gt;The regexes weren't the feature. They were the reason you could tell when the feature was wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I actually shipped in the end
&lt;/h2&gt;

&lt;p&gt;Not a rollback. The agent stayed, because it &lt;em&gt;is&lt;/em&gt; better. What changed is that it stopped being alone:&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;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;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Deterministic rules first — cheap, instant, and unambiguous when they hit.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;certain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RULES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&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="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;when&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;certain&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;certain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Agent for everything else, but it's allowed to say "no".&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="p"&gt;}&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;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;HANDLERS&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;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;CONFIDENCE_FLOOR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fallback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly 30 lines of rules survived, not 200 — only the ones that are genuinely unambiguous, where a hit means something. They take about 60% of live traffic at zero cost and zero latency, and, more usefully, they are the only thing in the system capable of &lt;em&gt;disagreeing&lt;/em&gt; with the agent. When the nightly eval shows the rules and the agent diverging on a case they both have an opinion on, that's a signal, and it arrives before a customer does.&lt;/p&gt;

&lt;p&gt;The cache is keyed on the input &lt;strong&gt;and&lt;/strong&gt; the model version, with a 24-hour TTL instead of 30 days. It's a cost optimisation now, not a correctness assumption.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;fallback&lt;/code&gt; is back in the type, where it was for eight months before I got clever.&lt;/p&gt;




&lt;h2&gt;
  
  
  The four questions I ask now
&lt;/h2&gt;

&lt;p&gt;Before deleting deterministic code in favour of an agent, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What reads this as if it were pure?&lt;/strong&gt; Grep for the caches, memos, dedupes and idempotency keys downstream. Each one is a promise you are about to break from a file you aren't editing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How does this say "I don't know"?&lt;/strong&gt; If the answer is "it can't", you have removed a state, not just a code path. Put it back before you merge, not after a customer finds it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's the ceiling?&lt;/strong&gt; On latency and on cost. If there isn't one, you now need a timeout and a budget, and both of those are product decisions with no correct answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What goes red when this regresses?&lt;/strong&gt; If nothing does — if the only monitor is a number a human reads on a dashboard — then you don't have a test, you have a hope.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of that is an argument against agents. I'd make the same swap again; five points is five points and I am never going back to maintaining seventeen regexes by hand.&lt;/p&gt;

&lt;p&gt;It's an argument against the thing I actually did wrong, which was to read a −194/+6 diff and believe I was looking at the whole change.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've had an agent replace something deterministic and watched a downstream assumption fall over — especially a cache — I'd genuinely like to hear which one. My money's on caches, but I've been wrong about this before.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>How to Stop an LLM From Returning Broken JSON — and Why Valid JSON Is the Easy Half</title>
      <dc:creator>Info Inlet</dc:creator>
      <pubDate>Tue, 01 Sep 2026 09:48:44 +0000</pubDate>
      <link>https://dev.to/infoinlet1/how-to-stop-an-llm-from-returning-broken-json-and-why-valid-json-is-the-easy-half-49jf</link>
      <guid>https://dev.to/infoinlet1/how-to-stop-an-llm-from-returning-broken-json-and-why-valid-json-is-the-easy-half-49jf</guid>
      <description>&lt;p&gt;Here is a model response from a pipeline that reads invoices. It parses. It matches the schema. Every field is the right type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&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;"verdicts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"doc_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"doc_a1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"risk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"block"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"reasons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"already_paid"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"explanation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INV-001 from Acme Supplies was already settled in &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;July payables&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"doc_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"doc_x9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"quote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Invoice INV-001 · Acme Supplies Ltd · Paid 1,000.00 USD"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.94&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;JSON.parse&lt;/code&gt; is happy. A validator checking types is happy. And the whole thing is fiction: &lt;code&gt;doc_x9&lt;/code&gt; was never sent to the model, and nothing in the batch says the word &lt;em&gt;paid&lt;/em&gt;. The model wrote a citation the way it writes everything else — by producing the most plausible next token — and a citation is a string, so a string is what came out.&lt;/p&gt;

&lt;p&gt;That response is going to be shown to a human next to a button that posts money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Getting valid JSON out of a model is the easy half of this problem, and it's the half everyone writes about.&lt;/strong&gt; The hard half is JSON that is valid, well-typed, on-schema, and wrong. Below are nine steps, in the order I'd apply them, that take a pipeline from &lt;em&gt;hoping&lt;/em&gt; to &lt;em&gt;enforcing&lt;/em&gt;. Every one of them is a few lines of ordinary code, and none of them are model-specific — they survive your next provider swap.&lt;/p&gt;




&lt;h2&gt;
  
  
  First: the four ways model JSON goes wrong
&lt;/h2&gt;

&lt;p&gt;They need different fixes, and lumping them together is why "just add a retry" doesn't work.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Looks like&lt;/th&gt;
&lt;th&gt;Fixed by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Unparseable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;fences, a preamble sentence, a trailing comma&lt;/td&gt;
&lt;td&gt;JSON mode, then bracket salvage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Parses, off-schema&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;confidence: "high"&lt;/code&gt;, a &lt;code&gt;risk&lt;/code&gt; you don't have a branch for&lt;/td&gt;
&lt;td&gt;field-by-field coercion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;On-schema, invented&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;an id you never sent, a quote nobody wrote&lt;/td&gt;
&lt;td&gt;pointers instead of prose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;On-schema, true, useless&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"confidence 0.97 is below the 1.00 bar"&lt;/td&gt;
&lt;td&gt;writing rules the schema can't hold&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Steps 1–3 below fix category 1. Step 4 fixes category 2. Steps 5–7 fix category 3 — the expensive one. Steps 8–9 are what happens when all of it fails anyway, which it will.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Stop asking for JSON in prose
&lt;/h2&gt;

&lt;p&gt;If your provider has a JSON or structured-output mode, use it, and set temperature to 0 for anything that is a judgement rather than a piece of writing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;complete_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;One call per batch — never per document.

    Ask for JSON (response_format / json mode if the provider has it) and temperature 0: two runs
    over the same books should not disagree about which bill is a repeat. On a parse failure return
    {} — `validate` turns that into &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not checked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; per item, which is the honest outcome and the
    safe one.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Temperature 0 is not about quality here. It's about &lt;strong&gt;two runs over the same input not disagreeing&lt;/strong&gt;, which is the difference between a check and a coin flip. If a user re-runs the same batch and gets a different verdict, you no longer have a feature — you have a slot machine with a business logo on it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule 1: sampling temperature is a product decision, not a tuning knob.&lt;/strong&gt; Anything a user can re-run and compare belongs at 0.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 2: Shrink the schema until most of it is a closed set
&lt;/h2&gt;

&lt;p&gt;Every free-form string in your schema is a place the model can be creative. Most of them don't need to be strings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;RISK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clear&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;REASONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;already_paid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duplicate_invoice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor_alias&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;overpaid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unallocated_payment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instalment_ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credit_note_offsets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;restated_invoice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency_mismatch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Fields a citation may point at. Anything outside this set is dropped — it is how "cite the
# document" stays a checkable instruction instead of a request for a nice sentence.
&lt;/span&gt;&lt;span class="n"&gt;CITABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;issue_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ledger&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things this buys you that a prose description of the same schema does not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An unknown value is detectable.&lt;/strong&gt; &lt;code&gt;"risk": "suspicious"&lt;/code&gt; is a bug you can see. &lt;code&gt;"risk": "this looks suspicious to me"&lt;/code&gt; is a bug you can only see once a user reports the UI is blank.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The set is a contract you can version.&lt;/strong&gt; Ours says, in the spec: &lt;em&gt;extend by asking us, not by inventing.&lt;/em&gt; A model that invents a tenth reason gets it dropped, not rendered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You stop translating slugs into sentences on the fly.&lt;/strong&gt; Keep the closed set for grouping and filtering; keep one free-form &lt;code&gt;explanation&lt;/code&gt; for the human. One creative field, not nine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Leave exactly as much free text as a person actually reads, and no more.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Salvage the parse — once, cheaply, and never with a regex
&lt;/h2&gt;

&lt;p&gt;Even in JSON mode, content arrives wrapped in fences or introduced by a sentence often enough to be worth handling. The whole fix is two &lt;code&gt;indexOf&lt;/code&gt; calls:&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="cm"&gt;/** Models wrap JSON in fences or a sentence often enough that reading the first bracketed array is
 *  worth more than trusting the response to be clean. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseRanking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="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;fit&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="nl"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastIndexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;arr&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="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&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="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="c1"&gt;// …validation continues in step 4&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire acceptable surface area of "JSON repair". First bracket, last matching bracket, one &lt;code&gt;JSON.parse&lt;/code&gt;, and a &lt;code&gt;catch&lt;/code&gt; that returns your empty case.&lt;/p&gt;

&lt;p&gt;What I'd argue against: the libraries and hand-rolled fixers that close unbalanced braces, strip trailing commas, and re-quote keys. They work, which is the problem — &lt;strong&gt;they turn a loud failure into a quiet guess.&lt;/strong&gt; A response truncated mid-object is a response where you don't know what was cut. Repairing the braces gives you a well-formed object that is missing half its verdicts, and nothing downstream can tell.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule 2: salvage formatting, never content.&lt;/strong&gt; If the bytes that describe the answer are incomplete, you do not have an answer, and step 8 is what happens next.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 4: Parsing is not validating — coerce every field, individually
&lt;/h2&gt;

&lt;p&gt;This is the step that most pipelines skip, because after &lt;code&gt;JSON.parse&lt;/code&gt; returns an object it &lt;em&gt;feels&lt;/em&gt; done. Types from a model are suggestions:&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;return&lt;/span&gt; &lt;span class="nx"&gt;arr&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;x&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;x&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&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;typeof&lt;/span&gt; &lt;span class="nx"&gt;x&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;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;fit&lt;/span&gt;&lt;span class="p"&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="mi"&gt;100&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;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;)&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="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read what each line assumes will go wrong, because each one has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;filter(typeof x?.id === 'string')&lt;/code&gt; — an entry with no id is not an entry. Drop it; don't default it to &lt;code&gt;""&lt;/code&gt; and carry a ghost through the rest of the pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Number(x.fit) || 0&lt;/code&gt; — handles &lt;code&gt;"87"&lt;/code&gt;, &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;undefined&lt;/code&gt; and &lt;code&gt;NaN&lt;/code&gt; in one expression.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Math.max(0, Math.min(100, …))&lt;/code&gt; — the model has been asked for 0–100 and will occasionally return 120, or 0.87 because it decided the scale was a fraction. Clamping means your progress bar never renders off the end of its container.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;typeof x.reason === 'string' ? x.reason : ''&lt;/code&gt; — a missing sentence is an empty sentence, not &lt;code&gt;undefined&lt;/code&gt; printed into your UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Python side does the same thing to the same values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clear&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;                                    &lt;span class="c1"&gt;# unknown → the safe branch, not a crash
&lt;/span&gt;&lt;span class="n"&gt;reasons&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasons&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;REASONS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# unknown members dropped
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what &lt;em&gt;isn't&lt;/em&gt; here: an exception thrown at the caller. A model returning something odd in one field of one item is an ordinary Tuesday, and it should cost you that field, not the request.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule 3: every field gets a defined behaviour for "the model said something else."&lt;/strong&gt; Written down, in code, next to the field. "That shouldn't happen" is not a behaviour.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 5: Let the model point. You render.
&lt;/h2&gt;

&lt;p&gt;Here is the single structural idea, and if you take one thing from this article take this one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model does not write the citation. It returns a pointer — &lt;code&gt;{doc_id, field}&lt;/code&gt; — and your code renders the text from its own copy of that record.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_render_evidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Evidence&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Turn (doc_id, field) pointers into text, from our own records.

    This is the step that makes a citation checkable: the model chooses WHICH fact to stand on, and
    the value comes from the data, so it cannot be embellished. A pointer at a document or field
    that was never sent simply does not render.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Evidence&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;
        &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doc_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;field&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CITABLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;record&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Evidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; · &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go back to the response at the top of this article. Under this scheme it cannot exist: &lt;code&gt;doc_x9&lt;/code&gt; isn't in &lt;code&gt;index&lt;/code&gt;, so the pointer doesn't resolve, so there is no evidence — and by step 7 the &lt;code&gt;block&lt;/code&gt; becomes a &lt;code&gt;review&lt;/code&gt;. The invented citation didn't get caught by a checker. &lt;strong&gt;It became structurally impossible to express.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same idea in three lines of TypeScript, on the ranking case:&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;byId&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;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobs&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;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&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;j&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;parsed&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;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;byId&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;r&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="p"&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;byId&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;r&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fit&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;fit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;JobMatch&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;j&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model ranks ids we sent. An id it invented finds nothing in the map and disappears. We never render a title, a company or a URL that the model produced — those come from our own row, keyed by an id the model was only allowed to &lt;em&gt;choose&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule 4: a model may choose among your facts. It may never author one.&lt;/strong&gt; Ids, quotes, prices, URLs, names — pointer in, render out.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is also the honest answer to "how do we stop hallucinated citations?" You don't detect them. You remove the ability to write one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Enforce cardinality, and make a missing answer say so
&lt;/h2&gt;

&lt;p&gt;You asked for one verdict per invoice. You will get: two for the same document, none for another, and one for a document that isn't in this batch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;wanted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;MAX_INVOICES&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdicts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;
    &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doc_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;wanted&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# not ours, or said twice
&lt;/span&gt;    &lt;span class="c1"&gt;# …coerce and store…
&lt;/span&gt;
&lt;span class="c1"&gt;# Exactly one verdict per invoice. A missing one is not silence — the client reads absence as
# "not checked", so say that rather than letting the row look clear.
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;risk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
                               &lt;span class="n"&gt;explanation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This invoice was not checked.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three separate guarantees in fifteen lines: &lt;strong&gt;only ours&lt;/strong&gt; (unknown ids dropped), &lt;strong&gt;at most one&lt;/strong&gt; (first wins), &lt;strong&gt;at least one&lt;/strong&gt; (gap filled), and the return is ordered by &lt;em&gt;your&lt;/em&gt; input rather than by the model's output order.&lt;/p&gt;

&lt;p&gt;The filled gap matters more than it looks. An item with no verdict renders as a row with no warning on it, which a person reads as &lt;em&gt;checked and fine&lt;/em&gt;. &lt;strong&gt;Absence of an answer must be rendered as an answer&lt;/strong&gt;, or your UI is quietly lying in the exact place it is meant to be reassuring.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Downgrade, don't reject
&lt;/h2&gt;

&lt;p&gt;When a rule is broken, the tempting move is to throw the item away. Usually the better one is to demote it to the outcome that costs least if you're wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;evidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_render_evidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;evidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Cite or downgrade. The prompt says it; this is what makes it so.
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;explanation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;explanation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;explanation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this pipeline the asymmetry is: &lt;em&gt;"held for a human to look at"&lt;/em&gt; costs a few seconds; &lt;em&gt;"cleared automatically"&lt;/em&gt; costs a duplicate payment. So the spec says the thing that makes every rule above resolvable:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Default to &lt;code&gt;review&lt;/code&gt; when unsure. &lt;code&gt;clear&lt;/code&gt; is a claim, not a fallback.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Find the equivalent sentence for your own pipeline before you write the validator, because it decides every &lt;code&gt;else&lt;/code&gt; branch in it. A moderation pipeline's safe default is not a search pipeline's safe default. What you must not do is let the safe default be &lt;em&gt;whatever the model happened to say when it broke a rule.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule 5: name your cheap direction of failure, in one sentence, and make it the value of every fallback.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 8: Choose your failure posture before you need it
&lt;/h2&gt;

&lt;p&gt;The model call will fail. Time out, 500, return prose, return &lt;code&gt;{}&lt;/code&gt;. There are three defensible answers and you must pick one per surface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;raw&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;complete_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SYSTEM_PROMPT&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;FEW_SHOT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# noqa: BLE001 — a model failure must not 500 someone's books
&lt;/span&gt;    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the check could not be completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;

&lt;span class="n"&gt;verdicts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{}&lt;/code&gt; flows into the same validator, which fills every row with &lt;em&gt;"This invoice was not checked."&lt;/em&gt; The user gets their books, plus an honest note, and nothing auto-posts. Compare the ranking case, where the failure posture is different because the stakes are:&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="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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;   &lt;span class="c1"&gt;// the caller still has the unranked list, and a search that shows nothing is&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;             &lt;span class="c1"&gt;// better than an error card&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules that fall out of doing this a few times:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The enrichment path degrades; the safety path escalates.&lt;/strong&gt; Ranking, summaries, suggested titles: fail to the unenriched version silently. Anything guarding money, deletion or publication: fail to &lt;em&gt;held for a human&lt;/em&gt;, loudly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry structural failures, not semantic ones.&lt;/strong&gt; An unparseable response or a 503 is worth one retry. A response that parsed and then failed your rules will fail them again — at temperature 0, identically. Change the input or fall back; don't spend a second call proving the first one wasn't a fluke.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 9: If the model generated logic, run it before you return it
&lt;/h2&gt;

&lt;p&gt;Last one, for the case where the JSON isn't data but a small program — a formula, a filter, a query, a tool definition. Schema validation says nothing about whether it works.&lt;/p&gt;

&lt;p&gt;Our tool builder turns &lt;em&gt;"age calculator from a birth date"&lt;/em&gt; into a spec with an &lt;code&gt;expression&lt;/code&gt; in it. The checklist it has to pass before it's allowed out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Valid JSON matching the envelope shape.&lt;/li&gt;
&lt;li&gt;Every field &lt;code&gt;id&lt;/code&gt; matches &lt;code&gt;^[A-Za-z_][A-Za-z0-9_]*$&lt;/code&gt;, is unique, snake_case.&lt;/li&gt;
&lt;li&gt;The expression references &lt;strong&gt;only&lt;/strong&gt; existing field ids and the functions in the allowed list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The expression parses and evaluates on a sample input&lt;/strong&gt; — every number field &lt;code&gt;2&lt;/code&gt;, every date field &lt;code&gt;2000-01-01&lt;/code&gt;. If it throws, fix it or fall back.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;icon&lt;/code&gt; and &lt;code&gt;category&lt;/code&gt; are members of their allowed sets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rule 4 is the one people leave out and the one that catches the most: a formula referencing a field that isn't there, a division that can only produce &lt;code&gt;Infinity&lt;/code&gt;, a date function given a number. &lt;strong&gt;You have a whole interpreter sitting right there. Use it as a validator.&lt;/strong&gt; It costs microseconds and it is the only check in the list that tests the thing the user actually asked for.&lt;/p&gt;

&lt;p&gt;The fallback matters too: &lt;em&gt;fix it or fall back to a safe generic tool&lt;/em&gt;. Not "return the spec and let the UI throw."&lt;/p&gt;




&lt;h2&gt;
  
  
  The 60-second audit
&lt;/h2&gt;

&lt;p&gt;Run these four greps against your own codebase. Each one took me under a minute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;JSON.parse(&lt;/code&gt; on anything that came from a model, with no &lt;code&gt;.filter&lt;/code&gt; or type guard after it.&lt;/strong&gt; That's steps 4 and 6 missing. Look at what the very next line assumes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any string field in your schema that holds an id, a quote, a URL or a price.&lt;/strong&gt; That's step 5 missing — every one of those is a value the model can invent and your UI will print with a straight face.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;catch {}&lt;/code&gt; around a model call.&lt;/strong&gt; Read what the caller does with the empty result. If the answer is "renders as success", you have a silent-lie path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enum-ish fields typed &lt;code&gt;string&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;risk: string&lt;/code&gt;, &lt;code&gt;status: string&lt;/code&gt;, &lt;code&gt;category: string&lt;/code&gt;. Any value outside your branches lands in whatever your &lt;code&gt;else&lt;/code&gt; does, and your &lt;code&gt;else&lt;/code&gt; was written for a bug you didn't have in mind.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd stop saying
&lt;/h2&gt;

&lt;p&gt;"Just use structured outputs" is now a complete answer to about a fifth of this problem — the fifth where the JSON is malformed. It's a genuine improvement and you should turn it on today.&lt;/p&gt;

&lt;p&gt;It does nothing about the other four fifths. Structured output mode will happily hand you a perfectly-formed citation of a document that does not exist, a verdict for an id you never sent, no verdict at all for the one row that mattered, and a confidence of &lt;code&gt;0.97&lt;/code&gt; where your threshold is &lt;code&gt;1.00&lt;/code&gt;, rendered to a user as a sentence they cannot act on.&lt;/p&gt;

&lt;p&gt;The schema you describe to the model is a request. &lt;strong&gt;The schema you enforce in code is the only one your product actually has.&lt;/strong&gt; Nine steps, a few dozen lines, and most of them are &lt;code&gt;filter&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
    </item>
    <item>
      <title>Maximum Update Depth Exceeded: 4 useEffect Dependency Bugs That All Passed Review</title>
      <dc:creator>Info Inlet</dc:creator>
      <pubDate>Mon, 31 Aug 2026 07:26:13 +0000</pubDate>
      <link>https://dev.to/infoinlet1/maximum-update-depth-exceeded-4-useeffect-dependency-bugs-that-all-passed-review-104</link>
      <guid>https://dev.to/infoinlet1/maximum-update-depth-exceeded-4-useeffect-dependency-bugs-that-all-passed-review-104</guid>
      <description>&lt;p&gt;Here's a dependency array that crashed React the first time a user hovered a button:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useLayoutEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;tip&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;boxRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;boxRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* … clamp to the window … */&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* … above the control, or below it … */&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;pos&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setPos&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&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="nx"&gt;tip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in it is wrong by the usual rules. It reads &lt;code&gt;tip&lt;/code&gt;, it reads &lt;code&gt;pos&lt;/code&gt;, and both are listed. &lt;code&gt;react-hooks/exhaustive-deps&lt;/code&gt; is perfectly happy. There's even a guard — that &lt;code&gt;if&lt;/code&gt; exists specifically to stop the loop.&lt;/p&gt;

&lt;p&gt;It loops anyway. Every time. &lt;code&gt;Maximum update depth exceeded&lt;/code&gt;, the first time any tooltip appeared.&lt;/p&gt;

&lt;p&gt;I went looking through our own history for the others, and there were three more — same category, four completely different disguises. A spreadsheet that yanked your scroll back to where you started. An onboarding step that showed an error for a request that was never sent. A guided tour that pointed at nothing.&lt;/p&gt;

&lt;p&gt;All four shipped. All four passed review. &lt;strong&gt;Three of them exist because someone did what the lint rule told them to.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what's actually going on, and the four questions I now ask any dependency array.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gap the linter can't see
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;react-hooks/exhaustive-deps&lt;/code&gt; answers one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What does this effect read?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Your dependency array is asked a different one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;When should this effect run again?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most of the time those two questions have the same answer, which is why the rule works and why we all stopped thinking about it. Every bug below is a case where they come apart — and because the linter only checks the first question, it will cheerfully sign off on the wrong answer to the second. Worse, it will &lt;em&gt;demand&lt;/em&gt; it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 1: the dependency you measure
&lt;/h2&gt;

&lt;p&gt;Back to the tooltip. Why doesn't the guard hold?&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;x&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt; are computed from &lt;code&gt;getBoundingClientRect()&lt;/code&gt; on an element that has a CSS transform on it. Measuring a translated box hands back &lt;strong&gt;subpixel-different widths run to run&lt;/strong&gt; — &lt;code&gt;247.99998474121094&lt;/code&gt;, then &lt;code&gt;248.00001525878906&lt;/code&gt;. So &lt;code&gt;pos.x !== x&lt;/code&gt; is true. Forever.&lt;/p&gt;

&lt;p&gt;The sequence is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;effect runs, measures, sets &lt;code&gt;pos&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pos&lt;/code&gt; changed, so the effect re-runs&lt;/li&gt;
&lt;li&gt;it measures again, gets a value a ten-thousandth of a pixel different&lt;/li&gt;
&lt;li&gt;the guard says "changed!", sets &lt;code&gt;pos&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;→ 2&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;React tears this down at its update-depth limit, which is why it presents as a crash rather than as a slow page.&lt;/p&gt;

&lt;p&gt;The fix is one character shorter than the bug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Keyed on `tip` ALONE. Re-running on `pos` and comparing the result to decide whether to settle is&lt;/span&gt;
&lt;span class="c1"&gt;// the shape that deadlocks: measuring a translated box hands back subpixel-different widths run to&lt;/span&gt;
&lt;span class="c1"&gt;// run, so the "has it changed?" test never came out false and React tore the render loop down at its&lt;/span&gt;
&lt;span class="c1"&gt;// update-depth limit. One measurement per hover is all this needs — the control does not move under&lt;/span&gt;
&lt;span class="c1"&gt;// the pointer, and anything that WOULD move it (scroll, resize, click) dismisses the tooltip.&lt;/span&gt;
&lt;span class="nf"&gt;useLayoutEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;tip&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;boxRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;boxRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="c1"&gt;// …&lt;/span&gt;
  &lt;span class="c1"&gt;// Whole pixels: keeps the text crisp, and keeps this off the subpixel treadmill above.&lt;/span&gt;
  &lt;span class="nf"&gt;setPos&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tip&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two changes worth separating, because only one of them is the actual fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;[tip]&lt;/code&gt;, not &lt;code&gt;[tip, pos]&lt;/code&gt;.&lt;/strong&gt; This is the fix. The effect doesn't need to re-run when its own output changes; it needs to run once per hover. The control cannot move under the pointer, and anything that &lt;em&gt;would&lt;/em&gt; move it — scroll, resize, click — dismisses the tooltip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Math.round&lt;/code&gt;.&lt;/strong&gt; This is the belt-and-braces. It also happens to make the text crisper, because subpixel-positioned text is subpixel-rendered text.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Question 1: is any dependency here something I &lt;em&gt;measured&lt;/em&gt; rather than something I was given?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Measured values — &lt;code&gt;getBoundingClientRect&lt;/code&gt;, &lt;code&gt;scrollHeight&lt;/code&gt;, &lt;code&gt;offsetWidth&lt;/code&gt;, anything downstream of layout — are not stable identities. A dependency you measure is a dependency that will never equal itself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The general shape to be afraid of: &lt;strong&gt;an effect that takes its own output as an input, and uses a comparison to decide when to stop.&lt;/strong&gt; That comparison is now load-bearing, and it's comparing floats you didn't produce.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 2: the dependency whose identity churns
&lt;/h2&gt;

&lt;p&gt;Different file, same family. A spreadsheet keeps the active cell in view — necessary, because the grid is virtualised, so the selected cell may not be rendered at all after keyboard nav or a formula jump.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&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;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gridRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;di&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;display&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;di&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;zoom&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;offsetTop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;di&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&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="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;z&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;top&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt;
  &lt;span class="k"&gt;else&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;bottom&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientHeight&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;HEADER_H&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="cm"&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;active&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;display&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offsetTop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Textbook exhaustive deps. It reads five things; it lists five things. The linter would have &lt;em&gt;added&lt;/em&gt; the last four if you hadn't.&lt;/p&gt;

&lt;p&gt;The symptom: scroll away from the cell you clicked, and the view snaps straight back to it.&lt;/p&gt;

&lt;p&gt;Why: &lt;code&gt;display&lt;/code&gt; is a fresh array and &lt;code&gt;offsetTop&lt;/code&gt; / &lt;code&gt;rowHeight&lt;/code&gt; are fresh callbacks &lt;strong&gt;every time the view grid grows&lt;/strong&gt; — and growing the view grid is precisely what scrolling toward an edge does. So the sequence is: you scroll → the sheet grows → the memo produces a new array identity → the effect re-runs → it scrolls you back to &lt;code&gt;active.r&lt;/code&gt;. In both directions, because growing &lt;em&gt;columns&lt;/em&gt; invalidates the same memo as growing rows.&lt;/p&gt;

&lt;p&gt;The intent was always "when the selection moves". The array said "when any of these five identities change". Those diverged the moment virtualisation was added, and nothing flagged it, because nothing &lt;em&gt;could&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The layout facts the scroll-into-view effect below needs, held in a ref so they can't RE-TRIGGER&lt;/span&gt;
&lt;span class="c1"&gt;// it. `display` is a fresh array (and offsetTop/rowHeight fresh callbacks) every time the view grid&lt;/span&gt;
&lt;span class="c1"&gt;// grows — which is exactly what scrolling toward an edge does — so having them as deps meant every&lt;/span&gt;
&lt;span class="c1"&gt;// scroll that grew the sheet yanked the view straight back to the cell you had clicked, in both&lt;/span&gt;
&lt;span class="c1"&gt;// directions (growing COLUMNS invalidates the same memo as growing rows). Scrolling away from the&lt;/span&gt;
&lt;span class="c1"&gt;// selection is deliberate; only a change of selection should pull the view back.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scrollLayout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;display&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offsetTop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;scrollLayout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;display&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offsetTop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&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;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gridRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;display&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offsetTop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scrollLayout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;
  &lt;span class="c1"&gt;// … same body …&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;active&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the pattern the &lt;code&gt;useEffectEvent&lt;/code&gt; RFC exists to make official, and you can have it today with four lines and a ref. The rule it encodes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Question 2: does this effect &lt;em&gt;react to&lt;/em&gt; this value, or does it merely &lt;em&gt;read&lt;/em&gt; it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Things it reacts to go in the array. Things it reads go in a ref. The linter cannot tell these apart and will file everything under "reacts to".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you take one thing from this article, take this one. It's the most common version by a distance, and unlike the subpixel case it never crashes — it just makes your app feel possessed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 3: the dependency that is your own echo
&lt;/h2&gt;

&lt;p&gt;This one produced the strangest bug report of the four: typing a single letter into "What do you want to make?" printed &lt;strong&gt;"Could not check that just now. Try again"&lt;/strong&gt; — instantly, with no request in flight, and none ever sent.&lt;/p&gt;

&lt;p&gt;The setup is one every form in every wizard has. The parent owns the text so it survives stepping away from the step. The child is controlled, and pushes changes up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;need&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&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="nf"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;need&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="nx"&gt;need&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;// "sync down when the question changes"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parent hands that value straight back as &lt;code&gt;need&lt;/code&gt;. So &lt;code&gt;need&lt;/code&gt; changes for two completely different reasons, and this effect cannot tell them apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;somebody navigated back into this step with a saved sentence → &lt;strong&gt;genuinely new, should reset&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;the user typed a character, which we pushed up, which came back down → &lt;strong&gt;our own echo, should do nothing&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every keystroke re-entered the reset effect. Results wiped. &lt;code&gt;ranFor&lt;/code&gt; set to half-typed text. And the render read &lt;em&gt;"no result plus a non-empty question"&lt;/em&gt; as a failed check — so for anyone who'd signed up without going through the landing search, the failure notice appeared on the first letter.&lt;/p&gt;

&lt;p&gt;The fix is to remember what you pushed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** The last value we pushed UP through `onQueryChange`.
 *
 *  The caller hands it straight back as `need` — setup keeps the sentence in its own state so it
 *  survives stepping away from the step — and that echo used to re-enter the reset effect below as
 *  if it were a new question from outside. Every keystroke therefore wiped the results the user was
 *  reading and, for anyone who had signed up without picking anything first, replaced them with a
 *  failure notice for a request that had never been sent. Only a change we did not cause is a new
 *  question. */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;echoed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;need&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;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&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="c1"&gt;// Only a question changed from OUTSIDE is a new question — see `echoed`.&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;started&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;need&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;echoed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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;started&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;echoed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;need&lt;/span&gt;
  &lt;span class="nf"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;need&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&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;need&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Question 3: can this dependency change because of something &lt;em&gt;this component&lt;/em&gt; did?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If yes, &lt;code&gt;[dep]&lt;/code&gt; is not "when the outside world changes" — it's "when the outside world changes, &lt;strong&gt;or when I change&lt;/strong&gt;". You need to be able to tell those apart, and only you can.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's a second lesson buried in that bug report, and it's the one I'd actually put on a wall. The error message was rendered by &lt;strong&gt;inferring&lt;/strong&gt; state from what was on screen: no result + non-empty query = failed. So the fix included storing it instead — &lt;code&gt;idle | loading | ok | failed&lt;/code&gt; — and rendering the failure line only when a call actually came back empty. Derived state is a guess about the past. If a user can tell the difference between "hasn't asked yet" and "asked and it failed", your component has to be able to as well.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 4: the dependency that wasn't there at all
&lt;/h2&gt;

&lt;p&gt;The last one has an empty-ish array and still breaks, which is why it belongs here — the mistake is the same one wearing a different coat.&lt;/p&gt;

&lt;p&gt;A guided tour is handed over the instant setup finishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;            &lt;span class="c1"&gt;// navigates to /app&lt;/span&gt;
&lt;span class="nf"&gt;startLaunchTour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// …in the same tick&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;drive&lt;/code&gt; (driver.js) filters out every step whose anchor &lt;strong&gt;is not visible at that moment&lt;/strong&gt;. One tick before the shell paints, that's all of them. So the personalised welcome tour silently degraded to the two popovers that happen to point at nothing.&lt;/p&gt;

&lt;p&gt;Nothing here is a dependency-array bug in the literal sense. It's the same misconception one level up: &lt;strong&gt;treating "the component ran" as "the thing it points at exists."&lt;/strong&gt; An effect body running is a statement about React's tree, not about the DOM your library is about to query.&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="cm"&gt;/** Wait for the workspace to actually be on screen.
 *
 *  Setup is a route now, so the tour is started from a page that is in the middle of being replaced
 *  by the shell — `close()` navigates, then hands the tour over in the same tick. `drive` filters out
 *  every step whose anchor is not visible AT THAT MOMENT, so without this the welcome tour arrived
 *  before the rail existed and quietly degraded to the two popovers that point at nothing.
 *
 *  The rail or the composer is enough: both belong to the shell, and either one being laid out means
 *  the workspace has painted. Bounded, because on a narrow layout the rail genuinely never appears
 *  and a tour that waits forever is worse than one that runs over what IS there. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForShell&lt;/span&gt;&lt;span class="p"&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="k"&gt;void&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;anchors&lt;/span&gt; &lt;span class="o"&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;[data-tour="surfaces"]&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;[data-tour="composer"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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;resolve&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;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tick&lt;/span&gt; &lt;span class="o"&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="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;anchors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tourTargetVisible&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="k"&gt;else&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;tick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;tick&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;Note the deadline. On a narrow layout the rail genuinely never appears, and a tour that waits forever is worse than one that runs over what &lt;em&gt;is&lt;/em&gt; there. Any wait-for-the-DOM helper without a bound is a hang you haven't met yet.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Question 4: does this effect assume something outside React has finished?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Mount is not paint. Navigation is not arrival. If you're handing an element to a library that queries the DOM, wait for the element — with a deadline.&lt;/p&gt;
&lt;/blockquote&gt;




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

&lt;p&gt;Print these next to the lint rule, not instead of it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is any dependency here something I measured?&lt;/strong&gt; Measured values never equal themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does this effect &lt;em&gt;react to&lt;/em&gt; this value, or merely &lt;em&gt;read&lt;/em&gt; it?&lt;/strong&gt; Reads go in a ref.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can this dependency change because of something this component did?&lt;/strong&gt; Then you must be able to recognise your own echo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does this assume something outside React has finished?&lt;/strong&gt; Mount is not paint. Wait for the element, with a deadline.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The 60-second audit
&lt;/h2&gt;

&lt;p&gt;Grep your own codebase for these four shapes. In our case each one took under a minute to confirm once I knew what I was looking at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;setX&lt;/code&gt; inside an effect that lists &lt;code&gt;x&lt;/code&gt;.&lt;/strong&gt; Every one of these is either a loop or a guard doing load-bearing work. Both are worth a second look.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any dep that is an array, object or function from &lt;code&gt;useMemo&lt;/code&gt;/&lt;code&gt;useCallback&lt;/code&gt;.&lt;/strong&gt; Ask what invalidates that memo. If the answer includes anything the user does continuously — scrolling, typing, resizing — you have bug 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A controlled child whose parent feeds the value back as a prop.&lt;/strong&gt; Trace the round trip. If the child can't distinguish its own echo, you have bug 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;querySelector&lt;/code&gt; in an effect, or any third-party library handed an anchor.&lt;/strong&gt; Ask what guarantees it's there. "The effect ran" is not an answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd stop saying
&lt;/h2&gt;

&lt;p&gt;I don't think "just add it to the dependency array" is good advice any more, and I'd said it plenty. It's the right answer to the question the linter asks and the wrong answer often enough to the question your effect is actually asking.&lt;/p&gt;

&lt;p&gt;The rule is a great smoke detector. It is not a design review. It cannot tell you that the value you added is measured, or churns, or is your own echo coming home — and in three of the four bugs above, doing exactly what it suggested is what shipped the bug.&lt;/p&gt;

&lt;p&gt;The dependency array is not a list of what you read. It's a list of the reasons this should happen again. Write it as an answer to that question and most of this category disappears.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>frontend</category>
    </item>
    <item>
      <title>40 Lines of Go That Cut Our LLM Bill by 71%</title>
      <dc:creator>Info Inlet</dc:creator>
      <pubDate>Sun, 30 Aug 2026 06:49:55 +0000</pubDate>
      <link>https://dev.to/infoinlet1/40-lines-of-go-that-cut-our-llm-bill-by-71-4do1</link>
      <guid>https://dev.to/infoinlet1/40-lines-of-go-that-cut-our-llm-bill-by-71-4do1</guid>
      <description>&lt;p&gt;On July 30, OpenAI cut GPT-5.6 Luna to &lt;strong&gt;$0.20 per million input tokens and $1.20 per million output&lt;/strong&gt; — down from $1 and $6. An 80% cut. Azure matched it on August 1.&lt;/p&gt;

&lt;p&gt;We did what most teams did with that news: nothing. Our gateway sent every request to the strong model, because it always had, and because "just use the cheap model" sounds like a decision that comes back as a support ticket three weeks later.&lt;/p&gt;

&lt;p&gt;Then someone put the bill next to the traffic mix, and the awkward part was obvious. The overwhelming majority of our requests were &lt;strong&gt;title this thread&lt;/strong&gt;, &lt;strong&gt;summarise this diff&lt;/strong&gt;, &lt;strong&gt;extract the fields from this form&lt;/strong&gt;, &lt;strong&gt;name this file&lt;/strong&gt;. We were paying frontier prices to generate three-word document titles.&lt;/p&gt;

&lt;p&gt;Here's what we shipped instead. It's about forty lines of Go, it moved 81% of requests off the expensive model, and it cut the bill by 71%.&lt;/p&gt;

&lt;p&gt;It also broke four things, and those are the interesting part.&lt;/p&gt;




&lt;h2&gt;
  
  
  The approach that doesn't work: classify the prompt
&lt;/h2&gt;

&lt;p&gt;The obvious design — the one everyone writes first — is a classifier in front of the router:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Look at the incoming request. Decide if it's &lt;em&gt;easy&lt;/em&gt; or &lt;em&gt;hard&lt;/em&gt;. Send it to the model that matches.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We built this. It's worse than it looks, for three reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need a model to run the classifier.&lt;/strong&gt; Now every request pays an extra call before any work happens. A cheap classifier is another Luna call and another 300ms; an accurate classifier is a strong-model call, which is the cost you were trying to avoid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short is not easy.&lt;/strong&gt; "Fix the timezone bug" is eleven characters and needs everything you've got. "Summarise the following 4,000-word RFC" is long and trivial. Length, token count, keyword lists — every cheap heuristic we tried correlated with the &lt;em&gt;shape&lt;/em&gt; of the request and not with the difficulty of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're predicting the answer before you've seen it.&lt;/strong&gt; That's the actual problem. Difficulty is a property of the work, and the only honest way to learn it is to do the work.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; don't classify the prompt. Run the cheap model and judge the output.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Cheap-first with an escalation gate
&lt;/h2&gt;

&lt;p&gt;The design that works is embarrassingly simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Send it to the cheap model.&lt;/li&gt;
&lt;li&gt;Look at what came back.&lt;/li&gt;
&lt;li&gt;If it fails a gate, run it again on the strong model and return that instead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The thing that makes this viable is arithmetic that surprised us. At our average request shape — roughly 3,000 input tokens and 700 output — a Luna call costs about &lt;strong&gt;$0.0014&lt;/strong&gt; and a strong-model call about &lt;strong&gt;$0.0145&lt;/strong&gt;. Ten to one.&lt;/p&gt;

&lt;p&gt;So a request that gets escalated costs &lt;code&gt;0.0014 + 0.0145 = $0.0159&lt;/code&gt; instead of &lt;code&gt;$0.0145&lt;/code&gt;. About 10% more.&lt;/p&gt;

&lt;p&gt;Work out the break-even escalation rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cheap + (p × strong) &amp;lt; strong
p &amp;lt; (strong − cheap) / strong
p &amp;lt; (0.0145 − 0.0014) / 0.0145
p &amp;lt; 0.90
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;You would have to escalate nine times out of ten before the cheap attempt costs you money.&lt;/strong&gt; We escalate 15% of the time. Cost is not the constraint here, and if you're arguing about whether cheap-first is worth the double billing, you're arguing about the wrong resource.&lt;/p&gt;

&lt;p&gt;Latency is the constraint. We'll come back to that.&lt;/p&gt;




&lt;h2&gt;
  
  
  The router
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Result&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Text&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Model&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Tokens&lt;/span&gt; &lt;span class="n"&gt;Usage&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// A gate returns the reason this answer can't be trusted, or "" to accept it.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Gate&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fin&lt;/span&gt; &lt;span class="n"&gt;FinishReason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&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="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ForceStrong&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alwaysStrong&lt;/span&gt;&lt;span class="p"&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;Kind&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;cheap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cheap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// Availability, not quality. Fall through, don't fail the request.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&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="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gates&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cheap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cheap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Finish&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Escalate&lt;/span&gt;&lt;span class="p"&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;Kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// NB: req, not req+cheap.Text&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cheap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="c"&gt;// degraded, not failed&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Accept&lt;/span&gt;&lt;span class="p"&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;Kind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cheap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines in there are load-bearing and neither is obvious.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;return r.call(ctx, r.strong, req)&lt;/code&gt; when the &lt;em&gt;cheap call errors&lt;/em&gt; — a 429 or a timeout is an availability problem, and availability problems should not surface to the user as a failed request when you have a second provider sitting right there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;return cheap, nil&lt;/code&gt; when the &lt;em&gt;strong call errors&lt;/em&gt; — you already have an answer. It may be a worse answer. Shipping a worse answer beats shipping a spinner.&lt;/p&gt;

&lt;p&gt;And the comment on the escalation call is the one people get wrong, which is failure mode #4 below.&lt;/p&gt;




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

&lt;p&gt;The gates are the whole product. The router is plumbing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;defaultGates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Gate&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;SchemaInvalid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c"&gt;// had to be JSON matching a schema, and wasn't&lt;/span&gt;
    &lt;span class="n"&gt;ToolArgsMissing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// called a tool, omitted a required argument&lt;/span&gt;
    &lt;span class="n"&gt;Truncated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c"&gt;// finish reason != stop&lt;/span&gt;
    &lt;span class="n"&gt;EmptyOrHedged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c"&gt;// under 24 chars, or matches the hedge set&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They are ordered by how cheap they are to evaluate, and every one of them is &lt;strong&gt;structural&lt;/strong&gt;. None of them asks a model to grade another model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SchemaInvalid&lt;/code&gt;&lt;/strong&gt; does the most work by a distance. Anything with a defined output shape — field extraction, classification, structured summaries — gets validated against the schema you already have. If it doesn't parse or doesn't conform, escalate. This gate alone catches about 60% of our escalations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ToolArgsMissing&lt;/code&gt;&lt;/strong&gt; is the same idea for function calls. The cheap model picks the right tool far more reliably than it fills in the right arguments, and a missing required argument is a free, exact signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Truncated&lt;/code&gt;&lt;/strong&gt; is one field comparison and people skip it constantly. A &lt;code&gt;finish_reason&lt;/code&gt; of &lt;code&gt;length&lt;/code&gt; means you have a sentence that stops mid-&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;EmptyOrHedged&lt;/code&gt;&lt;/strong&gt; is the weakest one, and I want to be specific about how weak, because it's the one everybody wants to build first.&lt;/p&gt;

&lt;p&gt;We started with the intuitive version: ask the cheap model to say when it isn't confident, then escalate on that. It fired on &lt;strong&gt;0.4%&lt;/strong&gt; of responses. Our measured error rate on the same traffic was around 15%. The model's self-reported uncertainty was not a signal, it was decoration.&lt;/p&gt;

&lt;p&gt;What actually works in that slot is a small, boring list: empty, under 24 characters, or an exact-ish match against a hedge set you build by reading two hundred real failures (&lt;code&gt;"I don't have enough information"&lt;/code&gt;, &lt;code&gt;"As an AI"&lt;/code&gt;, &lt;code&gt;"Could you clarify"&lt;/code&gt;). Not confidence. Refusal.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; gate on structure you can check, not on the model's opinion of itself.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The four things that broke
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. p50 improved. p95 got worse.
&lt;/h3&gt;

&lt;p&gt;This was immediate and it's the real cost of the design.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;All-strong&lt;/th&gt;
&lt;th&gt;Cheap-first + gate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;p50&lt;/td&gt;
&lt;td&gt;2.9s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.4s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;7.8s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.6s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;11.2s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16.4s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Luna is fast, so the 85% that get accepted got much faster. The 15% that escalate pay for both calls, serially, and they land in your tail.&lt;/p&gt;

&lt;p&gt;If you have a latency SLO, that tail is where the design either survives or doesn't. Two things helped: run the gates on the &lt;em&gt;streamed head&lt;/em&gt; rather than the finished response where you can, and put a hard &lt;code&gt;escalateBudget&lt;/code&gt; on the clock — if the cheap call already burned 4 seconds, return it and log the miss rather than starting a second call you can't afford.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. You can't stream an answer you might throw away
&lt;/h3&gt;

&lt;p&gt;The whole architecture assumes you get to look at the output before deciding. Streaming assumes you've already committed.&lt;/p&gt;

&lt;p&gt;There is no clever fix, only a choice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Buffer, then decide.&lt;/strong&gt; Correct, and you've given up time-to-first-token — which for chat is the number users actually feel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stream, and never escalate.&lt;/strong&gt; Fine for anything conversational.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split your traffic by shape.&lt;/strong&gt; This is what we do. Structured, non-streamed work — extraction, titling, classification, summarisation, tool selection — goes through the router. Free-form chat streams straight from whichever model that surface is pinned to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of the money was in the first bucket anyway. Structured background work is high volume and nobody is watching a cursor blink at it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The cheap model fails the correctness test long before it fails the eye test
&lt;/h3&gt;

&lt;p&gt;The output is fluent. It's well-formatted. It uses your headings, it hits your tone, it's the right length. It's just wrong.&lt;/p&gt;

&lt;p&gt;That's why "does this look like a good answer" gates — including LLM-as-judge in the hot path — did badly for us. Fluency is exactly the axis where the price gap has closed most. Judgement, multi-step reasoning, and &lt;em&gt;knowing what it doesn't know&lt;/em&gt; are where it hasn't closed at all.&lt;/p&gt;

&lt;p&gt;Structural gates work because they don't have an opinion. Valid JSON is valid JSON.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Don't show the strong model the cheap model's answer
&lt;/h3&gt;

&lt;p&gt;Our first version passed the failed attempt along as context — &lt;em&gt;here's a draft, improve it&lt;/em&gt;. It seemed obviously more efficient.&lt;/p&gt;

&lt;p&gt;It anchors, badly. The strong model inherits the cheap one's framing, keeps its structure, and corrects wording rather than reasoning. On the escalations we hand-checked, the "improve this draft" path was worse than a clean run about a third of the time — and it was worse in the specific way that matters, because it repeated the mistake that triggered the escalation while polishing the prose around it.&lt;/p&gt;

&lt;p&gt;Escalation is not a retry. It's a fresh attempt by someone better. Send the original request.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it did
&lt;/h2&gt;

&lt;p&gt;Per 1,000 requests, at our mix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;All-strong&lt;/th&gt;
&lt;th&gt;Cheap-first + gate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requests attempted on cheap&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;950&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Escalated&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;143&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests touching the strong model&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;193&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;$14.50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$4.16&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;71% off.&lt;/strong&gt; 81% of requests are served entirely by a model that costs a tenth as much, and the 5% we force to the strong model never enter the router at all.&lt;/p&gt;

&lt;p&gt;The forced list is short and it is a policy decision, not a measurement: anything a user is going to send to a customer, anything that writes to production, and anything in the app builder's codegen path. Those never touch the cheap model regardless of what a gate would have said.&lt;/p&gt;




&lt;h2&gt;
  
  
  When not to do this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Low volume.&lt;/strong&gt; Under a few hundred thousand requests a month, 71% of your bill is not worth a new component in the hot path. Go negotiate your seat pricing instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long tool-calling chains.&lt;/strong&gt; Errors compound across steps and the gates only see one step at a time. A cheap model that's right 85% of the time per call is right 44% of the time across five calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything with a user-visible retry.&lt;/strong&gt; If your surface already shows a spinner, doubling the tail is worse than paying the bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regulated or audited output.&lt;/strong&gt; "Which model produced this" becomes a question you have to answer per request. Log &lt;code&gt;Result.Model&lt;/code&gt; from day one if there's any chance you're in this bucket — retrofitting it is miserable.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The version you can ship this week
&lt;/h2&gt;

&lt;p&gt;You don't need the whole thing to get most of the money.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick one high-volume, non-streamed, structured job.&lt;/strong&gt; Titling. Field extraction. Tagging. One.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add one gate:&lt;/strong&gt; validate against the schema you already have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shadow it for a day.&lt;/strong&gt; Run both models, serve the strong one, log where the gate would have fired. This is the step people skip and it's the one that tells you whether your escalation rate is 15% or 60%.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flip it, with &lt;code&gt;Result.Model&lt;/code&gt; in your logs and a kill switch on the config.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then measure the escalation rate per request kind, because that's the number the whole design lives on — and it's the number that tells you which job to move next.&lt;/p&gt;

&lt;p&gt;The pitch for cheap models in 2026 isn't that they got good enough to replace the frontier. It's that they got cheap enough that &lt;em&gt;checking whether they were good enough&lt;/em&gt; is now free.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>llm</category>
      <category>go</category>
    </item>
    <item>
      <title>5 Reasons Your MCP Server Is Slower Than a CLI (and When to Just Delete It)</title>
      <dc:creator>Info Inlet</dc:creator>
      <pubDate>Sat, 29 Aug 2026 05:47:24 +0000</pubDate>
      <link>https://dev.to/infoinlet1/5-reasons-your-mcp-server-is-slower-than-a-cli-and-when-to-just-delete-it-2dpf</link>
      <guid>https://dev.to/infoinlet1/5-reasons-your-mcp-server-is-slower-than-a-cli-and-when-to-just-delete-it-2dpf</guid>
      <description>&lt;p&gt;There are two ways to let an agent use your product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A)&lt;/strong&gt; Ship an MCP server. Define tools, describe them, run a process, the model calls them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B)&lt;/strong&gt; Ship a CLI. &lt;code&gt;yourtool do-the-thing --json&lt;/code&gt;. The agent runs it in a shell like a human would.&lt;/p&gt;

&lt;p&gt;For about a year the answer was obviously A. In 2026 a lot of teams quietly went back to B — and the ones who did aren't posting "MCP is dead" takes, they're posting latency graphs.&lt;/p&gt;

&lt;p&gt;Here's what's actually happening. Five costs, in the order they hit you.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. You pay for every tool, every turn — before the model does anything
&lt;/h2&gt;

&lt;p&gt;An MCP server's tool definitions are loaded into the context window &lt;strong&gt;up front&lt;/strong&gt;. Not when a tool is used. Always.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;40 tools × a real JSON schema with descriptions ≈ tens of thousands of tokens&lt;/li&gt;
&lt;li&gt;That's paid on turn 1, turn 2, and turn 60&lt;/li&gt;
&lt;li&gt;The model has done zero work at this point&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A CLI costs you one line in the system prompt. If the agent needs the details it runs &lt;code&gt;yourtool --help&lt;/code&gt; — &lt;strong&gt;once&lt;/strong&gt;, only when it matters, and the output can be 30 lines instead of 40 schemas.&lt;/p&gt;

&lt;p&gt;Anthropic &lt;a href="https://www.anthropic.com/engineering/code-execution-with-mcp" rel="noopener noreferrer"&gt;measured the extreme version of this&lt;/a&gt;: a workflow that burned about &lt;strong&gt;150,000 tokens&lt;/strong&gt; passing tool definitions and intermediate data through the model dropped to about &lt;strong&gt;2,000 tokens&lt;/strong&gt; when the same tools were exposed as code the agent calls — a 98.7% cut. That gap is not a micro-optimisation. That's the whole bill.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; tool definitions are rent, not purchase. You pay it on every single turn until you delete the tool.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  2. A CLI composes. MCP tools don't.
&lt;/h2&gt;

&lt;p&gt;Watch what a shell lets the model write in &lt;strong&gt;one&lt;/strong&gt; action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yourtool list &lt;span class="nt"&gt;--json&lt;/span&gt; | jq &lt;span class="s1"&gt;'.[] | select(.status=="failed") | .id'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One round trip. One result in context.&lt;/p&gt;

&lt;p&gt;Now the MCP version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;list_items&lt;/code&gt; → 800 rows come back into the context window&lt;/li&gt;
&lt;li&gt;model filters them &lt;em&gt;in its own head&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_item&lt;/code&gt; × 20&lt;/li&gt;
&lt;li&gt;model assembles the answer&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Four round trips minimum, and step 1 already poisoned the window with 780 rows nobody wanted.&lt;/p&gt;

&lt;p&gt;The shell has had composition for fifty years — pipes, redirection, &lt;code&gt;xargs&lt;/code&gt;, exit codes. MCP has a list of function calls. Every "combine two tools" case becomes the model's job, done in tokens, done badly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; if your users would chain your tools, a protocol with no chaining primitive is the wrong shape.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  3. Every byte travels through the model
&lt;/h2&gt;

&lt;p&gt;This is the one that actually shows up as &lt;em&gt;slow&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;An MCP tool result goes &lt;strong&gt;into the context window&lt;/strong&gt;. There is no other destination. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 5 MB JSON response doesn't just cost money — it evicts everything useful and you get the classic mid-session quality slide&lt;/li&gt;
&lt;li&gt;The model then re-reads that blob on every subsequent turn&lt;/li&gt;
&lt;li&gt;You cannot "process and discard"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a shell, intermediate data can just… stay on disk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yourtool &lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/out.json      &lt;span class="c"&gt;# 5 MB, never enters context&lt;/span&gt;
jq &lt;span class="s1"&gt;'.summary'&lt;/span&gt; /tmp/out.json                  &lt;span class="c"&gt;# 3 lines do&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent sees three lines. The five megabytes never existed as far as the context window is concerned.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; MCP has no &lt;code&gt;&amp;gt; file&lt;/code&gt;. Every result is a broadcast to the model. Design return values like you're paying per character, because you are.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  4. A protocol hop that buys you nothing (on a machine with a shell)
&lt;/h2&gt;

&lt;p&gt;Stack them up:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;CLI&lt;/th&gt;
&lt;th&gt;MCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;exec a binary&lt;/td&gt;
&lt;td&gt;spawn/connect a server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;argv&lt;/td&gt;
&lt;td&gt;JSON-RPC over stdio or HTTP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exit code&lt;/td&gt;
&lt;td&gt;protocol errors + tool errors + transport errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stdout&lt;/td&gt;
&lt;td&gt;structured content blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;handshake, capability negotiation, lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every row is latency and a failure mode. And they're failure modes your agent handles &lt;em&gt;worse&lt;/em&gt; than a non-zero exit code, because "the server disconnected" is not something the model can retry intelligently.&lt;/p&gt;

&lt;p&gt;The 2026 spec revision made this concrete for a lot of people: &lt;strong&gt;protocol-level sessions were dropped in favour of a stateless core&lt;/strong&gt;. Sensible for enterprise scale-out — and it broke every server that had quietly built state on top of the session. Nobody's &lt;code&gt;--help&lt;/code&gt; output broke that week.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; a transport you don't need is not free. It's latency plus a category of error your caller can't reason about.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  5. The model already knows &lt;code&gt;git&lt;/code&gt;. It has never heard of &lt;code&gt;create_document_v2&lt;/code&gt;.
&lt;/h2&gt;

&lt;p&gt;This is the underrated one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;psql&lt;/code&gt;, &lt;code&gt;ffmpeg&lt;/code&gt;, &lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt; — the model has seen millions of examples of these. It knows the flags, the idioms, the error messages, and what to do when one fails.&lt;/p&gt;

&lt;p&gt;Your bespoke tool surface has zero training examples. So you compensate with description text — which is cost #1 — and you &lt;em&gt;still&lt;/em&gt; get the classic failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;two tools that overlap 80%, picked by coin flip&lt;/li&gt;
&lt;li&gt;a tool chosen because its name was closest, not because it was right&lt;/li&gt;
&lt;li&gt;the model inventing a parameter that reads plausible and doesn't exist&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A CLI that follows Unix conventions inherits all that prior knowledge for free. &lt;code&gt;--json&lt;/code&gt;, &lt;code&gt;--dry-run&lt;/code&gt;, non-zero exit on failure, errors on stderr. The model has strong priors about every one of those.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; conventions are pretrained context you don't pay for.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The honest part: when MCP is clearly right
&lt;/h2&gt;

&lt;p&gt;None of the above is an argument that MCP is bad. It's an argument that MCP is being used in the one environment where it's weakest — a coding agent that already has a terminal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the MCP server when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;There is no shell.&lt;/strong&gt; ChatGPT, Claude's chat surfaces, a mobile app, an embedded assistant. This is the real answer and it's a big one. A CLI is worth nothing to a user who has no machine to run it on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You don't want to hand the model a shell.&lt;/strong&gt; MCP is a permission boundary you control, tool by tool. &lt;code&gt;bash&lt;/code&gt; is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your users aren't engineers.&lt;/strong&gt; They will never &lt;code&gt;brew install&lt;/code&gt; anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need what the protocol actually provides&lt;/strong&gt; — resources, subscriptions, sampling, an auth flow that a CLI would have to reinvent badly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're a hosted service.&lt;/strong&gt; There's no binary to install in the first place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Delete it — or shrink it hard — when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your consumers are coding agents that already have a terminal&lt;/li&gt;
&lt;li&gt;Your MCP server is a thin wrapper over your own CLI or public API&lt;/li&gt;
&lt;li&gt;You have more than ~20 tools and no progressive disclosure&lt;/li&gt;
&lt;li&gt;Your tools' most common use is being chained together&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The middle path (what most teams should actually do)
&lt;/h2&gt;

&lt;p&gt;You don't have to choose. The pattern that's winning:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep MCP thin.&lt;/strong&gt; Three or four tools — &lt;code&gt;search&lt;/code&gt;, &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;execute&lt;/code&gt; — not forty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expose the rest as code, not schemas.&lt;/strong&gt; Let the agent discover and call your API from a sandbox instead of loading every definition up front. That's where the 150k→2k number comes from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship the CLI too.&lt;/strong&gt; It's usually a day of work over an API you already have, and terminal agents will prefer it without being told.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make results small by default.&lt;/strong&gt; &lt;code&gt;--json&lt;/code&gt; plus a summary field, with full data behind a flag or a file path.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The 60-second audit
&lt;/h2&gt;

&lt;p&gt;Ask these about your own server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many tokens are my tool definitions? (Count them. Actually count them.)&lt;/li&gt;
&lt;li&gt;What's my p50 result size? What's my p99?&lt;/li&gt;
&lt;li&gt;Are two of my tools doing the same job?&lt;/li&gt;
&lt;li&gt;If I deleted the server today, could a competent agent do this with &lt;code&gt;curl&lt;/code&gt; and my docs?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the answer to the last one is yes and your users have terminals — you're paying protocol rent for nothing.&lt;/p&gt;

&lt;p&gt;MCP's win was never that it was faster. It was that it made your product reachable from surfaces that have no shell. Use it for exactly that, and stop paying for it where it isn't buying anything.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why Your AI Agent Gets Dumber After 30 Turns and How to Stop It</title>
      <dc:creator>Info Inlet</dc:creator>
      <pubDate>Wed, 26 Aug 2026 04:21:58 +0000</pubDate>
      <link>https://dev.to/infoinlet1/why-your-ai-agent-gets-dumber-after-30-turns-and-how-to-stop-it-eil</link>
      <guid>https://dev.to/infoinlet1/why-your-ai-agent-gets-dumber-after-30-turns-and-how-to-stop-it-eil</guid>
      <description>&lt;p&gt;You start a session with an agent. The first half hour is great. It reads your code, follows your instructions, fixes what you asked.&lt;/p&gt;

&lt;p&gt;Then somewhere around the middle of the afternoon it starts doing this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It breaks a rule you gave it at the very start of the session.&lt;/li&gt;
&lt;li&gt;It reintroduces a bug it fixed forty minutes ago.&lt;/li&gt;
&lt;li&gt;It "helpfully" rewrites a file you told it not to touch.&lt;/li&gt;
&lt;li&gt;It gives you a confident answer that contradicts something it said earlier in the same conversation.&lt;/li&gt;
&lt;li&gt;It gives up on a task it would have finished an hour ago.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The usual reaction is to blame the model — &lt;em&gt;it got lazy&lt;/em&gt;, &lt;em&gt;they nerfed it&lt;/em&gt;, &lt;em&gt;this one is worse than last week's&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;It's almost never that. What you're looking at has a name, it's been measured, and once you can see it you can design around it.&lt;/p&gt;

&lt;p&gt;It's called &lt;strong&gt;context rot&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one-sentence version
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;An LLM does not use its context window evenly. The more you put in, the less reliably it uses &lt;em&gt;any&lt;/em&gt; of it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the whole idea. Everything below is why it happens and what to do.&lt;/p&gt;

&lt;p&gt;The important word is &lt;strong&gt;reliably&lt;/strong&gt;. Nothing is deleted. Nothing falls out. The rule you set at turn 3 is still sitting right there in the context at turn 60, perfectly intact — the model has simply become worse at &lt;em&gt;acting on it&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is why the bug feels supernatural. You can scroll up and see the instruction with your own eyes. It's there. The model can "see" it too. And it still isn't following it.&lt;/p&gt;




&lt;h2&gt;
  
  
  It isn't your model, and it isn't your prompt
&lt;/h2&gt;

&lt;p&gt;In July 2025, a team at Chroma — Kelly Hong, Anton Troynikov and Jeff Huber — ran a study called &lt;a href="https://www.trychroma.com/research/context-rot" rel="noopener noreferrer"&gt;Context Rot&lt;/a&gt;. They tested &lt;strong&gt;18 models&lt;/strong&gt;: Claude Opus 4, Sonnet 4, Sonnet 3.7, Sonnet 3.5 and Haiku 3.5; o3, the GPT-4.1 family, GPT-4o, GPT-4 Turbo and GPT-3.5 Turbo; Gemini 2.5 Pro, 2.5 Flash and 2.0 Flash; and three Qwen3 models.&lt;/p&gt;

&lt;p&gt;The headline result is short:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every single one got worse as input length grew.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not the cheap ones. Not the old ones. All eighteen, including the frontier models of that moment. So if you have been switching models to fix this, that's the wrong lever — you're changing a variable that isn't the cause.&lt;/p&gt;

&lt;p&gt;And the reason this matters &lt;em&gt;more&lt;/em&gt; now than it did in 2025, not less: context windows got enormous. A million tokens is normal now. Bigger windows didn't fix context rot. They removed the guardrail that used to stop you from hitting it — when the window was small, you got a hard error. Now you get a silent slide in quality with nothing to tell you it started.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "30 turns"? It isn't the turns.
&lt;/h2&gt;

&lt;p&gt;Let's kill the number in the title, because it's a symptom, not a cause.&lt;/p&gt;

&lt;p&gt;Nothing happens at turn 30. What happens is that by turn 30, a coding agent has typically accumulated something like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What's in the window&lt;/th&gt;
&lt;th&gt;Roughly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your system prompt / rules file&lt;/td&gt;
&lt;td&gt;1–3k tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool definitions (often 20–50 of them)&lt;/td&gt;
&lt;td&gt;5–15k tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files it read — &lt;strong&gt;including versions it has since edited&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;20–100k tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool outputs: test runs, stack traces, &lt;code&gt;grep&lt;/code&gt; results, build logs&lt;/td&gt;
&lt;td&gt;20–80k tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The actual conversation between you and it&lt;/td&gt;
&lt;td&gt;5–15k tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice the proportions. &lt;strong&gt;The part that is actually a conversation is the smallest part of the conversation.&lt;/strong&gt; Almost everything in the window is machine output that scrolled past you.&lt;/p&gt;

&lt;p&gt;So "30 turns" is really just shorthand for "the point where your context is mostly noise". Ten turns of running a failing test suite will get you there faster than a hundred turns of chatting.&lt;/p&gt;

&lt;p&gt;The right unit isn't turns. It's &lt;strong&gt;how much of the window is still relevant&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The four things that actually make it worse
&lt;/h2&gt;

&lt;p&gt;The Chroma study didn't only measure length. It varied the &lt;em&gt;shape&lt;/em&gt; of what was in the window, and this is the useful part, because shape is something you control.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Length alone
&lt;/h3&gt;

&lt;p&gt;Even on a task with no reasoning in it at all — copy this list of words back to me, with one odd word inserted — accuracy fell as the list got longer. They tested from 25 words up to 10,000.&lt;/p&gt;

&lt;p&gt;At the long end, models stopped copying and started &lt;em&gt;inventing&lt;/em&gt;. Gemini models began emitting words that were never in the input at around 500–750 words. Qwen3-8B started producing nonsense from about 5,000.&lt;/p&gt;

&lt;p&gt;Let that sit for a second. This is a task with &lt;strong&gt;zero difficulty&lt;/strong&gt;. The instruction is "repeat this." Length alone was enough to break it.&lt;/p&gt;

&lt;p&gt;There's a mechanical reason underneath. Attention is quadratic: every token is weighed against every other token. At 100,000 tokens that's 10 billion pairwise relationships to spread a fixed amount of attention across. Nothing is deleted — it's diluted.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. When what you need isn't worded like your question
&lt;/h3&gt;

&lt;p&gt;They scored how similar each hidden fact was to the question being asked, and found: &lt;strong&gt;the less the answer resembles the question, the faster performance falls off with length.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This one has a direct consequence for how you work. When you ask &lt;em&gt;"why is the login flow slow?"&lt;/em&gt;, the answer might be a line about connection pooling in a config file that never uses the word "login" or "slow". That's a low-similarity target — exactly the kind that degrades fastest in a big context.&lt;/p&gt;

&lt;p&gt;Translation: &lt;strong&gt;the harder your question is to keyword-match, the more the length is hurting you.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Distractors — the big one for coding agents
&lt;/h3&gt;

&lt;p&gt;They added plausible-but-wrong content near the real answer. One distractor measurably hurt. Four compounded it.&lt;/p&gt;

&lt;p&gt;They also found the damage isn't uniform — some distractors were far worse than others, in ways that weren't predictable in advance.&lt;/p&gt;

&lt;p&gt;Now think about what a coding agent's context looks like after an hour:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Version 1 of the file, which it read&lt;/li&gt;
&lt;li&gt;Version 2, after it edited it&lt;/li&gt;
&lt;li&gt;Version 3, after it edited it again&lt;/li&gt;
&lt;li&gt;A stack trace from a bug that no longer exists&lt;/li&gt;
&lt;li&gt;A test failure it already fixed&lt;/li&gt;
&lt;li&gt;A plan it wrote, then abandoned&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Every stale version is a distractor.&lt;/strong&gt; They are plausible. They are relevant-looking. They're in the same language, about the same code, sitting right next to the truth. This is the single biggest source of context rot in agentic coding, and almost nobody thinks of it as pollution — it looks like history.&lt;/p&gt;

&lt;p&gt;One more detail from the study, worth knowing when you pick a model for long sessions: hallucination behaviour under distractors differed by family. Claude models had the &lt;strong&gt;lowest&lt;/strong&gt; hallucination rates; GPT models the highest. Claude's failure mode was more often to abstain — to say it wasn't sure — where others confidently made something up.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Structure — and this one is genuinely strange
&lt;/h3&gt;

&lt;p&gt;They compared a context where the text flowed logically against one where the same sentences were &lt;strong&gt;randomly shuffled&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Models did &lt;strong&gt;better on the shuffled version.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nobody has a clean explanation for it yet. But it's a useful warning shot for a habit a lot of us have: carefully assembling a beautiful, well-ordered, coherent block of context on the assumption that tidiness helps the model. On this benchmark, coherent structure did not help. It hurt.&lt;/p&gt;

&lt;p&gt;Don't over-read it — this is one benchmark, not a law. But it should stop you from &lt;em&gt;assuming&lt;/em&gt; that neatly organising a huge context is doing anything for you. The thing that reliably helps is having less in there.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 2026 finding almost nobody is talking about
&lt;/h2&gt;

&lt;p&gt;Most articles about context rot stop at the 2025 study. There's a newer one that reframes the whole problem, and I think it's the more useful mental model.&lt;/p&gt;

&lt;p&gt;In June 2026, Shijie Xia, Yikun Wang, Zhen Huang and Pengfei Liu published &lt;a href="https://arxiv.org/abs/2606.29718" rel="noopener noreferrer"&gt;&lt;em&gt;Diagnosing and Mitigating Context Rot in Long-horizon Search&lt;/em&gt;&lt;/a&gt;. They studied four flagship models across three benchmarks, and named something they call &lt;strong&gt;premature termination&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Under extensive context, models &lt;strong&gt;give up&lt;/strong&gt; — or hand back an uncertain wrong answer — long before the context window is anywhere near full.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the rate of premature termination &lt;strong&gt;rises with context length&lt;/strong&gt;, even after controlling for how hard the question is.&lt;/p&gt;

&lt;p&gt;This is a different failure from "it couldn't find the answer". The model stops &lt;em&gt;looking&lt;/em&gt;. It ends the search early, on a task it has plenty of room and plenty of capability to keep working on.&lt;/p&gt;

&lt;p&gt;If you use coding agents, you have watched this happen and probably filed it under laziness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It searches two files, doesn't find it, and asks you where it is.&lt;/li&gt;
&lt;li&gt;It stops after fixing one of the three things you listed.&lt;/li&gt;
&lt;li&gt;It says "this would require a larger refactor" for something it happily did this morning.&lt;/li&gt;
&lt;li&gt;It hands back something hedged and half-done rather than finishing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's not attitude. That's a measurable effect of a full context window.&lt;/p&gt;

&lt;p&gt;The paper also evaluated seven context-management methods across three categories, and their conclusion about &lt;em&gt;why&lt;/em&gt; those methods work is the part worth internalising: the methods help because &lt;strong&gt;they reduce the premature-termination rate, which lets the model keep exploring.&lt;/strong&gt; You're not cleaning the context to help it remember. You're cleaning it to stop it from quitting.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fixes: four moves, and that's the whole toolbox
&lt;/h2&gt;

&lt;p&gt;Everything people call "context engineering" collapses into four moves. The clearest formulation of this is LangChain's — &lt;a href="https://www.langchain.com/blog/context-engineering-for-agents" rel="noopener noreferrer"&gt;write, select, compress, isolate&lt;/a&gt;. Here's each one in plain terms, with what it looks like when you're using an agent rather than building one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Write — put it somewhere other than the window
&lt;/h3&gt;

&lt;p&gt;Get durable information out of the conversation and into a file the agent can read back when it needs it.&lt;/p&gt;

&lt;p&gt;In practice: a rules file (&lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;.cursorrules&lt;/code&gt;, &lt;code&gt;AGENTS.md&lt;/code&gt;), and a plan file the agent keeps updating as it works.&lt;/p&gt;

&lt;p&gt;Why this works is subtle and worth spelling out: a decision written in chat at turn 4 is a &lt;em&gt;distractor&lt;/em&gt; by turn 40, because five later messages have partially contradicted it. The same decision in a file is a &lt;strong&gt;single current version&lt;/strong&gt;. Files don't accumulate stale copies of themselves the way a conversation does.&lt;/p&gt;

&lt;h3&gt;
  
  
  Select — bring in only what this step needs
&lt;/h3&gt;

&lt;p&gt;Don't front-load. Pull in the file when it's time to touch the file.&lt;/p&gt;

&lt;p&gt;The counter-intuitive habit to break: dumping your whole schema, all your types and six related files "so it has context." Every one of those is a distractor for the 95% of the task they aren't relevant to.&lt;/p&gt;

&lt;p&gt;Even tool definitions count. One result LangChain cites: applying retrieval to &lt;em&gt;tool descriptions&lt;/em&gt; — showing the model only the relevant tools instead of all of them — improved tool selection accuracy &lt;strong&gt;threefold&lt;/strong&gt;. If you've wired up thirty MCP tools and your agent keeps picking the wrong one, that's not the model being dim; that's forty tool descriptions competing for attention on every single turn.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compress — keep the meaning, drop the tokens
&lt;/h3&gt;

&lt;p&gt;Summarise and move on. Claude Code does this automatically with auto-compact once you cross about 95% of the window.&lt;/p&gt;

&lt;p&gt;The mistake is treating compaction as an emergency measure that fires on its own at the end. By 95% you have already been degrading for a long while. Compact &lt;strong&gt;at the seams&lt;/strong&gt; — when you finish a subtask, when you switch files, when a test finally goes green. You know where the boundaries are. The auto-trigger doesn't.&lt;/p&gt;

&lt;p&gt;And trim the biggest offenders by hand. A 400-line stack trace from a bug you already fixed is pure distractor. So is the full output of a test run from three fixes ago.&lt;/p&gt;

&lt;h3&gt;
  
  
  Isolate — give separate work separate windows
&lt;/h3&gt;

&lt;p&gt;Sub-agents. Anthropic's multi-agent research system had subagents running in parallel, each with &lt;strong&gt;its own context window&lt;/strong&gt;, and only the final result came back to the main agent — not the dozens of tool calls that produced it.&lt;/p&gt;

&lt;p&gt;That last clause is the entire benefit. "Search the codebase for every place we handle refunds" might burn 40,000 tokens of grep output to produce one paragraph of answer. Done in the main window, you keep all 40,000. Done in a sub-agent, you keep the paragraph.&lt;/p&gt;

&lt;p&gt;The honest tradeoff, from the same source: that system used up to &lt;strong&gt;15× more tokens&lt;/strong&gt; than ordinary chat. Isolation buys reliability with money. Sometimes that's a great trade and sometimes it isn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to actually do tomorrow
&lt;/h2&gt;

&lt;p&gt;Ordered by effort, cheapest first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start a new session at task boundaries.&lt;/strong&gt; Free, instant, and it's the single most effective thing on this list. Finished the feature? New session. A fresh window with a good rules file beats a long window with perfect history, essentially always.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move durable decisions into a file, not the chat.&lt;/strong&gt; Anything you'd be annoyed to repeat is a rules-file line, not a message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compact deliberately, at seams.&lt;/strong&gt; Don't wait for the automatic trigger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop pasting things "just in case."&lt;/strong&gt; Every extra file is a distractor with a cost and no benefit until it's needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prune your tool list.&lt;/strong&gt; If your agent has thirty tools loaded and uses four, the other twenty-six are tax on every turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push searching and exploring into sub-agents&lt;/strong&gt;, so the exploration doesn't live in your main window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When quality drops, restart — don't argue.&lt;/strong&gt; Correcting a rotting context adds tokens to a window whose problem is that it has too many. You are treating the disease with more of the disease.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Six signs you have it right now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The agent breaks a rule it followed correctly earlier in the same session.&lt;/li&gt;
&lt;li&gt;It reintroduces something it already fixed.&lt;/li&gt;
&lt;li&gt;It starts hedging and abstaining on tasks it was completing an hour ago.&lt;/li&gt;
&lt;li&gt;It gives up early — "you may want to check X yourself" for something it was doing itself before.&lt;/li&gt;
&lt;li&gt;It edits a stale version of a file, one that matches what it read but not what's on disk.&lt;/li&gt;
&lt;li&gt;Your fix for all of the above is to explain more, and explaining more makes it worse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the tell. &lt;strong&gt;If adding context makes it worse, context is the problem.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The reframe
&lt;/h2&gt;

&lt;p&gt;Most of us learned to treat the context window as memory: put the important stuff in, and the model will remember it.&lt;/p&gt;

&lt;p&gt;The research points somewhere else. It behaves less like memory and more like a &lt;strong&gt;shared workspace with finite attention&lt;/strong&gt;. Everything you set on the table competes with everything else on the table — including the six things you put there an hour ago and stopped needing.&lt;/p&gt;

&lt;p&gt;So the skill isn't getting more into the window. It's knowing what to take &lt;em&gt;out&lt;/em&gt;, and when.&lt;/p&gt;

&lt;p&gt;A million-token context window is not an invitation to use a million tokens. It's an invitation to be much more careful about which ten thousand you use.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Which of the six signs do you hit most? I'm most curious whether anyone has found a good rule for **when&lt;/em&gt;* to restart a session — I still do it on instinct, and instinct is usually about twenty turns too late.*&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your Bundler's Default Target Ships a Blank Screen to iOS 15</title>
      <dc:creator>Info Inlet</dc:creator>
      <pubDate>Tue, 25 Aug 2026 04:26:18 +0000</pubDate>
      <link>https://dev.to/infoinlet1/your-bundlers-default-target-ships-a-blank-screen-to-ios-15-4hag</link>
      <guid>https://dev.to/infoinlet1/your-bundlers-default-target-ships-a-blank-screen-to-ios-15-4hag</guid>
      <description>&lt;p&gt;Four tests were failing in our mobile repo before I started last week's work. I fixed the two that had outgrown their screens in about twenty minutes.&lt;/p&gt;

&lt;p&gt;The other two were right, and both described bugs that were live, on both app stores, for anyone who happened to own the wrong phone or add the wrong city.&lt;/p&gt;

&lt;p&gt;One of them had never produced an error message in its life. The other threw a perfectly good, perfectly loud exception that no one was ever in a position to see.&lt;/p&gt;

&lt;p&gt;They are opposite failures, and our test suite was blind to both for exactly the same reason.&lt;/p&gt;

&lt;p&gt;We build &lt;a href="https://xenition.com" rel="noopener noreferrer"&gt;Xenition&lt;/a&gt;, an AI workspace — and the mobile app is Flutter, 382 Dart files, on &lt;a href="https://apps.apple.com/app/id6790250577" rel="noopener noreferrer"&gt;iOS&lt;/a&gt; and &lt;a href="https://play.google.com/store/apps/details?id=com.infoinlet.xenition" rel="noopener noreferrer"&gt;Android&lt;/a&gt;. Some of its surfaces are not Flutter at all. The diagram canvas is Excalidraw, the 3D surface is three.js, and the notebook runs Python through Pyodide — each one a real web bundle, vendored into the app's assets and rendered in a &lt;code&gt;WebView&lt;/code&gt;, because rewriting Excalidraw in Dart is not a thing a small team gets to do.&lt;/p&gt;

&lt;p&gt;That decision is defensible. What follows is the invoice for it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a bundled WebView fails in a way native code cannot
&lt;/h2&gt;

&lt;p&gt;When Dart throws, you get a stack trace. When a Flutter widget overflows, the screen turns into yellow-and-black hazard stripes and shouts at you. The framework is built on the assumption that a failure should be loud.&lt;/p&gt;

&lt;p&gt;A JavaScript module loaded into a &lt;code&gt;WebView&lt;/code&gt; has none of that, and one specific failure inside it is completely silent:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A module that fails to parse reports nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not a thrown exception. Not a rejected promise. Not an &lt;code&gt;onerror&lt;/code&gt;. The parse happens before any of your code exists, so there is no code there to notice it failed. The &lt;code&gt;WebView&lt;/code&gt; loads, the host page renders, your Flutter side sees a healthy page load event, and the module — the entire canvas — never runs. The user gets a spinner that spins forever.&lt;/p&gt;

&lt;p&gt;Now add the two things that make this specific to mobile:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your users' WebView is not your WebView.&lt;/strong&gt; On iOS it's WebKit, at whatever version the OS shipped with, and it does not update independently of the OS.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your build target is a number you set once, in a different repo, probably by accident.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those two facts met in our app and produced a feature that had never worked for a slice of our users.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 1: the canvas never started on iOS 15.0–16.3
&lt;/h2&gt;

&lt;p&gt;The Excalidraw bundle is built in our web repo, by &lt;code&gt;npm run build:diagram-host&lt;/code&gt;, and the vite config said:&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;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OUT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;emptyOutDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es2022&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;sourcemap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;es2022&lt;/code&gt; is a perfectly reasonable thing to type. It's recent-but-not-bleeding-edge, every desktop browser has supported it for years, and on the web — where that bundle also runs, and where the user's Chrome updates itself every six weeks — it is entirely correct.&lt;/p&gt;

&lt;p&gt;The problem is one feature in that standard: &lt;strong&gt;class static blocks&lt;/strong&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;class&lt;/span&gt; &lt;span class="nc"&gt;Thing&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// initialisation that needs statements, not just an expression&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;WebKit only parses those from &lt;strong&gt;Safari 16.4&lt;/strong&gt;. Our app's &lt;code&gt;ios/Podfile&lt;/code&gt; says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="ss"&gt;:ios&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'15.0'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So every user on iOS 15.0 through 16.3 — a real population, on iPhone 6s and 7 hardware that Apple stopped updating — loaded a module that WebKit refused to parse. &lt;code&gt;SyntaxError&lt;/code&gt;, thrown by the parser, before a single line of Excalidraw existed to catch it.&lt;/p&gt;

&lt;p&gt;What those users saw: they tapped Diagram, and nothing happened. Forever. No error, no toast, no crash we could see in Crashlytics, because nothing crashed. From the app's perspective the page loaded fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixed in both places it can be fixed
&lt;/h3&gt;

&lt;p&gt;The permanent fix is one line, and it's in the web repo:&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;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OUT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;emptyOutDir&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="c1"&gt;// NOT es2022: that emits class static blocks, which WebKit only parses from&lt;/span&gt;
  &lt;span class="c1"&gt;// Safari 16.4. The mobile app supports iOS 15.0 (ios/Podfile), and a module&lt;/span&gt;
  &lt;span class="c1"&gt;// that fails to parse reports nothing — the diagram canvas would simply&lt;/span&gt;
  &lt;span class="c1"&gt;// never start, silently, for every user on 15.0–16.3.&lt;/span&gt;
  &lt;span class="c1"&gt;// mobile/test/vendored_bundle_syntax_test.dart fails if this regresses.&lt;/span&gt;
  &lt;span class="nx"&gt;target&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="s1"&gt;safari15&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;chrome90&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;// Android WebView never ships these; skipping them keeps the APK smaller.&lt;/span&gt;
  &lt;span class="nx"&gt;sourcemap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the shape of that comment. It says what not to do, why, who it hurts, and what will fail if you undo it. A bare &lt;code&gt;target: ['safari15', 'chrome90']&lt;/code&gt; reads as arbitrary conservatism and the next person to touch the file bumps it back to something modern, for good reasons, in a repo where nothing tests iOS.&lt;/p&gt;

&lt;p&gt;But changing the config only helps the &lt;em&gt;next&lt;/em&gt; build. The 23 modules already vendored into the app's assets were still the old ones, so they got lowered in place with esbuild:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"assets/diagram/assets"&lt;/span&gt;
&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"safari15,chrome90"&lt;/span&gt;
&lt;span class="nv"&gt;VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"esbuild@0.24.0"&lt;/span&gt;
&lt;span class="nv"&gt;MARKER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'static{'&lt;/span&gt;   &lt;span class="c"&gt;# class static blocks — Safari 16.4+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that script that took longer to get right than the fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only rewrite modules that actually carry the offending syntax.&lt;/strong&gt; Re-minifying an already-minified module produces cosmetically different output every time, so touching every module in the bundle would churn all of it on every run and make every diff unreadable. Matching on the marker keeps it idempotent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exported names and import specifiers are preserved&lt;/strong&gt;, so the module graph is unchanged. Local identifiers get renamed and that's fine — nothing outside the module can see them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The part that makes the fix survive
&lt;/h3&gt;

&lt;p&gt;Here is the uncomfortable truth about a vendored bundle: &lt;strong&gt;the fix does not survive a rebuild.&lt;/strong&gt; Anybody who runs &lt;code&gt;npm run build:diagram-host&lt;/code&gt; overwrites &lt;code&gt;assets/diagram/&lt;/code&gt; and brings the modern syntax straight back — silently, again, because the failure was silent the first time.&lt;/p&gt;

&lt;p&gt;So the actual deliverable isn't the lowered bundle. It's this test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// Guards the vendored Excalidraw bundle against syntax old WebViews can't parse.&lt;/span&gt;
&lt;span class="c1"&gt;///&lt;/span&gt;
&lt;span class="c1"&gt;/// Covers every vendored JS bundle, not just Excalidraw's: the 3D surface ships&lt;/span&gt;
&lt;span class="c1"&gt;/// a three.js host built the same way, and it would fail the same way.&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&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;final&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'assets/diagram/assets'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'assets/model3d'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;$path&lt;/span&gt;&lt;span class="s"&gt; parses on the oldest iOS we support'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&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="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Directory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;modules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;listSync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;whereType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'.js'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;reason:&lt;/span&gt; &lt;span class="s"&gt;'the bundle should contain JS modules'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Class static blocks — `class A { static { … } }`. This is the newest&lt;/span&gt;
  &lt;span class="c1"&gt;// syntax an es2022 build emits and the one that actually breaks iOS 15, so&lt;/span&gt;
  &lt;span class="c1"&gt;// it doubles as the marker for "this bundle was rebuilt at the wrong&lt;/span&gt;
  &lt;span class="c1"&gt;// target". Matching the minified form avoids hits on the `static` keyword&lt;/span&gt;
  &lt;span class="c1"&gt;// used for ordinary class members.&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;offenders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;modules&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="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readAsStringSync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'static{'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pathSegments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;last&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;offenders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;reason:&lt;/span&gt; &lt;span class="s"&gt;'These modules use class static blocks, which WebKit only parses '&lt;/span&gt;
        &lt;span class="s"&gt;'from Safari 16.4 — on iOS 15.0-16.3 the canvas will never start, '&lt;/span&gt;
        &lt;span class="s"&gt;'silently: a module that fails to parse reports nothing.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;
        &lt;span class="s"&gt;'Fix it in one of two ways:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;
        &lt;span class="s"&gt;"  * permanent: in web/excalidraw-host/vite.config.ts set target: ['safari15', 'chrome90']&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
        &lt;span class="s"&gt;'  * here and now: run ./tool/lower_diagram_bundle.sh from mobile/&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;
        &lt;span class="s"&gt;'Offending modules: &lt;/span&gt;&lt;span class="si"&gt;$offenders&lt;/span&gt;&lt;span class="s"&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;It is a &lt;code&gt;grep&lt;/code&gt; in a trench coat. It does not launch a simulator, it does not parse JavaScript, it does not know what Excalidraw is. It reads files and looks for four characters.&lt;/p&gt;

&lt;p&gt;And it is the single highest-value test in that repo, because it converts a silent, device-specific, unreproducible-on-your-machine failure into a red line in CI that explains itself and names both fixes. The &lt;code&gt;reason:&lt;/code&gt; string is longer than the assertion. That's deliberate — the person who hits this will be someone who ran an unrelated &lt;code&gt;npm&lt;/code&gt; command in an unrelated repo, and every word they need has to be right there.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A guard test's job is not to be clever. It is to be the thing that shouts when the environment quietly changes underneath you.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The generalisation past our stack:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Your build target is a claim about your users' runtimes, and nobody validates it for you.&lt;/strong&gt; Every bundler defaults to something modern. Every WebView on a phone is frozen to an OS. Those two facts are on a collision course in every app that ships a bundle, and the collision is silent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you ship any JS inside a mobile WebView, go and check that number now. It'll take ninety seconds and there's a real chance you find what we found.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 2: the loud one nobody could hear
&lt;/h2&gt;

&lt;p&gt;The second failure is the opposite kind, and that is why it's here.&lt;/p&gt;

&lt;p&gt;The world clock — a small utility surface, 56 cities — threw &lt;code&gt;LocationNotFoundException&lt;/code&gt;. On &lt;code&gt;Africa/Accra&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No ambiguity, no silence, a named exception with a stack trace. Everything bug 1 wasn't. And it had been shipping just as long, because a loud error in a code path nothing executes is exactly as invisible as a silent one.&lt;/p&gt;

&lt;p&gt;Accra is not obscure. It is a capital city, it has been in the IANA time zone database for decades, and &lt;code&gt;Africa/Accra&lt;/code&gt; is exactly the id you'd expect to use. The reason it isn't there is that &lt;strong&gt;it is a link, not a zone.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The IANA database distinguishes canonical zones from backward-compatibility links — ids that used to be zones, or that duplicate one, kept alive so old configs don't break. &lt;code&gt;Africa/Accra&lt;/code&gt; is one of them; the canonical zone it points at is &lt;code&gt;Africa/Abidjan&lt;/code&gt;. Same offset, and neither has ever observed daylight saving.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;package:timezone&lt;/code&gt;'s &lt;code&gt;data/latest.dart&lt;/code&gt; ships &lt;strong&gt;the canonical zones and none of the links.&lt;/strong&gt; Not a bug — links are a compatibility layer, they roughly double the table, and a mobile app has every reason to want the smaller one. But it means an id that is correct, current, and documented throws at runtime.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;_zones&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;{&lt;/span&gt;
  &lt;span class="s"&gt;'UTC'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'UTC'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'London'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'Europe/London'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'Lisbon'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'Europe/Lisbon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Africa/Accra is a backward-compatibility LINK in the IANA database and&lt;/span&gt;
  &lt;span class="c1"&gt;// data/latest.dart carries none of those. Abidjan is the canonical zone it&lt;/span&gt;
  &lt;span class="c1"&gt;// points at: same offset, and neither has ever observed DST.&lt;/span&gt;
  &lt;span class="s"&gt;'Accra'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'Africa/Abidjan'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  And then the same trap from the other side
&lt;/h3&gt;

&lt;p&gt;While fixing that I found &lt;code&gt;UTC&lt;/code&gt; throws too.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;getLocation('UTC')&lt;/code&gt; — the most obviously valid timezone identifier that exists, the one you'd use in a test as the &lt;em&gt;safe&lt;/em&gt; value — raises &lt;code&gt;LocationNotFoundException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The reason is almost philosophical: &lt;strong&gt;UTC is not a row in the database. It is the origin every row is measured from.&lt;/strong&gt; So the package hands it over as a constant instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// Now, in that city — with whatever rule is in force today.&lt;/span&gt;
&lt;span class="c1"&gt;///&lt;/span&gt;
&lt;span class="c1"&gt;/// UTC is not a row in the zone database; it is the origin every row is&lt;/span&gt;
&lt;span class="c1"&gt;/// measured from, and `getLocation('UTC')` throws. The package hands it over&lt;/span&gt;
&lt;span class="c1"&gt;/// as a constant instead.&lt;/span&gt;
&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TZDateTime&lt;/span&gt; &lt;span class="nf"&gt;_now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_zones&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TZDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;'UTC'&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTC&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLocation&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two ids, opposite reasons, identical exception. One is too old to be a zone; the other is too fundamental to be one.&lt;/p&gt;

&lt;p&gt;And the blast radius was the same for both: adding either city to your clock threw during build, and &lt;strong&gt;the whole tool went down&lt;/strong&gt; — not the one row, the entire surface. A user who added Accra didn't get a broken Accra card. They got a broken world clock, and the only way back was to work out that a city they'd added was the cause.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A lookup that throws instead of returning null turns one bad row into a dead screen.&lt;/strong&gt; If a table is user-extensible, every lookup against it needs a policy for the id that isn't there.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why 821 tests caught neither
&lt;/h2&gt;

&lt;p&gt;This is the part I'd want someone to take away, because it's the one that transfers to codebases that ship no JavaScript and no timezones.&lt;/p&gt;

&lt;p&gt;We had a smoke test for the tools surface. It built the world clock. It passed, every run, for months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It built the world clock with its four default cities.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;London, New York, Tokyo, Sydney. All canonical zones. All present in &lt;code&gt;data/latest.dart&lt;/code&gt;. The test exercised the widget, the state, the rendering, the offset labels, the layout — everything except the fifty-two rows where the bug was.&lt;/p&gt;

&lt;p&gt;The replacement is boring and it is the whole point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// tools_smoke_test builds this tool with its four default cities, which is why&lt;/span&gt;
&lt;span class="c1"&gt;/// it caught nothing for either of them. This one puts every city on the clock&lt;/span&gt;
&lt;span class="c1"&gt;/// at once, so the whole table gets looked up.&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&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="n"&gt;cities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;[&lt;/span&gt;
    &lt;span class="s"&gt;'UTC'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'London'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'Lisbon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'Accra'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'Casablanca'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'Paris'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'Berlin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;'Madrid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'Rome'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'Lagos'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'Cairo'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'Athens'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'Johannesburg'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'Istanbul'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// … all 56 …&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="n"&gt;testWidgets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'every city on the clock at once resolves and renders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&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;Fifty-six cities, one widget, one test. It runs in the same time as the old one because the lookup is a map read.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A smoke test that builds the defaults tests the defaults.&lt;/strong&gt; Every static table your app ships — timezones, currencies, locales, country codes, MIME types, unit conversions — is a list of rows nobody has ever exercised, sitting behind a lookup that throws. The test that covers it is a &lt;code&gt;for&lt;/code&gt; loop, and it is nearly free.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We ship 36 locales and 10,002 translation keys. I know exactly which test I'm writing next.&lt;/p&gt;




&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;What I'd actually run against any app that renders bundled web content:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] What is your bundler's &lt;code&gt;target&lt;/code&gt;? Write it down. Compare it to your &lt;strong&gt;minimum OS&lt;/strong&gt;, not your test device.&lt;/li&gt;
&lt;li&gt;[ ] Does anything in your build pipeline overwrite vendored assets? If yes, is there a test that fails when it does?&lt;/li&gt;
&lt;li&gt;[ ] Is the failure of your &lt;code&gt;WebView&lt;/code&gt; content visible from the native side at all — or does a dead module look exactly like a slow one?&lt;/li&gt;
&lt;li&gt;[ ] Do you have a timeout on "the canvas is starting"? A spinner with no deadline is how a silent failure becomes a support ticket instead of a bug report.&lt;/li&gt;
&lt;li&gt;[ ] For every static table you ship: is there a test that touches &lt;strong&gt;every row&lt;/strong&gt;, or only the defaults?&lt;/li&gt;
&lt;li&gt;[ ] For every lookup against those tables: what happens for an id that isn't there — a null you handle, or an exception that takes the screen down?&lt;/li&gt;
&lt;li&gt;[ ] Do your device-lab / TestFlight testers include anyone on your &lt;strong&gt;oldest&lt;/strong&gt; supported OS? If not, your minimum is a number in a Podfile, not a claim you've verified.&lt;/li&gt;
&lt;li&gt;[ ] When a test fails: is the claim it makes still true? Decide that before deciding what to edit.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I'd tell someone shipping a WebView in a mobile app
&lt;/h2&gt;

&lt;p&gt;The vendoring decision was right. We would not have a diagram canvas, a 3D surface or a Python notebook on a phone if we'd insisted on writing all three natively, and users don't grade you on which rendering engine drew the thing.&lt;/p&gt;

&lt;p&gt;But bundling web content into a native app means &lt;strong&gt;you have inherited a second runtime, and you don't control its version.&lt;/strong&gt; Everything the web taught you about "just target modern browsers" is downstream of automatic browser updates, and an iPhone that stopped getting iOS updates in 2021 is still in someone's pocket, still on your store listing's supported-devices list, still tapping the button that does nothing.&lt;/p&gt;

&lt;p&gt;Our diagram canvas never started for those users. Nobody filed a bug, because there was nothing to describe — you tap Diagram, and the app is just a bit rubbish, in a way that's hard to put in an email. It was caught by a test that reads files looking for four characters, on a laptop, in about forty milliseconds.&lt;/p&gt;

&lt;p&gt;Both fixes are live. 821 tests pass, and the two silent ones are silent no more.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you ship a bundle inside a WebView, I'd genuinely like to know what your &lt;code&gt;target&lt;/code&gt; says — and whether you knew before you looked. That number is doing more work than almost anything else in your build config, and I'd never once checked ours.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on &lt;a href="https://xenition.com" rel="noopener noreferrer"&gt;Xenition&lt;/a&gt; — one AI workspace for documents, decks, code, apps and media, free to start, on &lt;a href="https://xenition.com" rel="noopener noreferrer"&gt;web&lt;/a&gt;, desktop, and both app stores: &lt;a href="https://apps.apple.com/app/id6790250577" rel="noopener noreferrer"&gt;iOS&lt;/a&gt; and &lt;a href="https://play.google.com/store/apps/details?id=com.infoinlet.xenition" rel="noopener noreferrer"&gt;Android&lt;/a&gt;. The mobile app is Flutter — 382 Dart files, 26 feature modules, 36 languages, and, until last week, one canvas that never started.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>ChatGPT Plugin Rejected? Here's What the Review Actually Checks</title>
      <dc:creator>Info Inlet</dc:creator>
      <pubDate>Mon, 24 Aug 2026 08:11:17 +0000</pubDate>
      <link>https://dev.to/infoinlet1/we-submitted-our-mcp-server-to-the-chatgpt-apps-directory-it-was-rejected-in-nine-days-509o</link>
      <guid>https://dev.to/infoinlet1/we-submitted-our-mcp-server-to-the-chatgpt-apps-directory-it-was-rejected-in-nine-days-509o</guid>
      <description>&lt;p&gt;We submitted our app to OpenAI's directory on a Thursday. Nine days later, this arrived:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One or more of your test cases did not produce correct results. Please re-run all submitted test cases and align tool behavior/output with the documented expected outcomes. Ensure the same test cases pass consistently on both ChatGPT web and mobile.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the entire rejection. No tool named, no transcript, no log line. One paragraph and a link to appeal if we thought they'd made a mistake.&lt;/p&gt;

&lt;p&gt;They had not made a mistake. Every symptom in that paragraph was real, and finding out why took a full day of running our own tools against production like a stranger. There were &lt;strong&gt;four&lt;/strong&gt; failures: three server bugs, and one that was in the test cases themselves. Two of the three bugs meant tools that had &lt;strong&gt;never worked once&lt;/strong&gt;, in production, for anyone, since the day they shipped.&lt;/p&gt;

&lt;p&gt;We build &lt;a href="https://xenition.com" rel="noopener noreferrer"&gt;Xenition&lt;/a&gt;, an AI workspace, and the submission was our MCP server — 49 tools that let ChatGPT create and edit real artifacts in a user's workspace: documents, decks, boards, ledgers, notes, spreadsheets. If you're preparing a submission this month, this article is the thing I wish I'd read the week before we hit Submit — every bug named, and the one review technique that would have caught all four.&lt;/p&gt;

&lt;p&gt;I'll name our own bugs exactly rather than paraphrase them. None of it is product-specific.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this review is harder to pass than an app store review
&lt;/h2&gt;

&lt;p&gt;A normal store reviewer taps through your UI. Your UI is a thing you have looked at ten thousand times.&lt;/p&gt;

&lt;p&gt;A directory reviewer does something you have almost certainly never done: they take your written test cases, hand the prompts to a model, and watch &lt;strong&gt;what your tools actually return&lt;/strong&gt; — then do it again on a second client. That's the part that broke us. The review surface isn't your product. It's:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A cold account.&lt;/strong&gt; No state you seeded by hand while debugging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A one-turn tool call.&lt;/strong&gt; No follow-up. Whatever your tool returns, that's the whole conversation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two clients that word things differently.&lt;/strong&gt; ChatGPT web and ChatGPT mobile do not phrase the same user intent identically, so your tool receives two different strings for one test case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Someone reading the model's sentence&lt;/strong&gt;, not your JSON.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every one of our four failures lives in that list. None of them was reachable from our own app.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 1: the tools read an empty copy of everything
&lt;/h2&gt;

&lt;p&gt;Our artifact store has a &lt;code&gt;List&lt;/code&gt; that selects &lt;code&gt;content&lt;/code&gt; and &lt;code&gt;meta&lt;/code&gt; as &lt;code&gt;NULL&lt;/code&gt;. That is not a bug — it's the query behind the library grid, which renders cards. A card needs a title, a type and a timestamp; shipping every document's full body to draw a wall of tiles would be absurd.&lt;/p&gt;

&lt;p&gt;Then the workspace tools arrived — &lt;code&gt;list_tasks&lt;/code&gt;, &lt;code&gt;add_task&lt;/code&gt;, &lt;code&gt;query_ledger&lt;/code&gt;, &lt;code&gt;add_ledger_entry&lt;/code&gt;, &lt;code&gt;search_notes&lt;/code&gt; — and their lookup helper went through that same &lt;code&gt;List&lt;/code&gt;. Every one of those callers then parses the document it just asked for.&lt;/p&gt;

&lt;p&gt;Here is what a user of a perfectly healthy workspace got:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Reality&lt;/th&gt;
&lt;th&gt;What the tool said&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_tasks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a board with 30 cards&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0 tasks&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;add_task&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;same board&lt;/td&gt;
&lt;td&gt;"the board is empty"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;add_ledger_entry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a ledger with real invoices&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;success&lt;/strong&gt; — having saved a brand-new ledger over the real one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that last row again, because it's the one that scared me. Enter an invoice for Acme, then one for Globex, and you are left with a ledger containing &lt;strong&gt;only Globex&lt;/strong&gt;. Not an error. Not a warning. A success message, and the user's data replaced with a single row. The empty parse looked exactly like an empty document, so "add a row to the existing ledger" and "create a ledger with one row in it" became the same code path.&lt;/p&gt;

&lt;p&gt;The dates are the humbling part. The &lt;code&gt;List&lt;/code&gt; change that started blanking content landed on 20 July. These tools shipped on top of it on 10 August. We submitted on 13 August. &lt;strong&gt;They never worked, not once, in production — and three days of our own use didn't reveal it&lt;/strong&gt;, because nothing in our own app calls the workspace through MCP.&lt;/p&gt;

&lt;p&gt;The fix is four lines and a rename:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// ListFull is List with content and meta loaded. Callers that have to READ what is inside each&lt;/span&gt;
&lt;span class="c"&gt;// artifact — the board's cards, the ledger's rows — need this one, not List.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ListFull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workspaceID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;typeFilter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;Artifact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&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;and at the call site, with the reason written down so nobody quietly swaps it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// ListFull, not List: every caller here reads the artifact's document — the board's cards, the&lt;/span&gt;
&lt;span class="c"&gt;// ledger's rows, the note's body. List blanks content for the library grid.&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;artifacts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListFull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson generalises well past MCP:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A read path optimised for one caller is a data-loss bug waiting for its second caller.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;List&lt;/code&gt; returning blank content was a &lt;em&gt;feature&lt;/em&gt; for the grid. It became silent destruction the moment a writer used it to decide whether something already existed. If a query drops fields for performance, the name has to say so — &lt;code&gt;List&lt;/code&gt; vs &lt;code&gt;ListFull&lt;/code&gt; — because the type signature doesn't. Both return &lt;code&gt;[]Artifact&lt;/code&gt;. Both compile. Only one of them tells the truth about what's inside.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 2: our clarifying question had nobody to ask
&lt;/h2&gt;

&lt;p&gt;Our orchestrator has a clarify gate. Vague brief in, questions back out — good behaviour in a chat window, where a human answers and generation continues.&lt;/p&gt;

&lt;p&gt;An MCP tool call is &lt;strong&gt;one turn&lt;/strong&gt;. There is no human in it. So a thin brief made &lt;code&gt;create_slides&lt;/code&gt; come back in about two seconds with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the engine did not return a deck&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Correct, technically. The engine had returned a set of questions and there was nobody to answer them, so the tool reported the only thing it could see.&lt;/p&gt;

&lt;p&gt;Now the detail that turns this from an ordinary bug into &lt;em&gt;the exact wording of the rejection&lt;/em&gt;: &lt;strong&gt;whether the gate fires depends on how the brief reads&lt;/strong&gt;, and ChatGPT phrases the same user intent differently on web than on mobile. Same test case, same account, same minute — passes on one client, fails on the other. "Ensure the same test cases pass consistently on both ChatGPT web and mobile" was not boilerplate. It was a description of our bug, written by someone who had just watched it happen.&lt;/p&gt;

&lt;p&gt;The fix is to tell the engine there is nobody home:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// NoClarify because there is nobody here to answer. On a vague brief the orchestrator's clarify&lt;/span&gt;
&lt;span class="c"&gt;// gate returns questions instead of an artifact, and an MCP call is one turn.&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;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UserID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Skill&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;skill&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NoClarify&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A flag is a promise, though, and promises get broken by the next person to touch the&lt;br&gt;
orchestrator — so there is also a guard behind it, and the comment on the guard is the part&lt;br&gt;
worth copying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Belt-and-braces: NoClarify above should stop this, but if the gate ever fires again the&lt;/span&gt;
&lt;span class="c"&gt;// caller gets something it can act on rather than "the engine did not return a deck".&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the gate does fire, the tool now returns the questions themselves. A model that receives&lt;br&gt;
three questions can ask the user them; a model that receives "the engine did not return a&lt;br&gt;
deck" can only apologise.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Any interactive gate in a pipeline becomes a hang or a lie when the caller is a machine.&lt;/strong&gt; Clarify loops, consent prompts, "are you sure?", rate-limit backoff that waits for a retry someone has to press — find every one of them before a reviewer does.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  Bug 3: an unknown id reported as "Still working…"
&lt;/h2&gt;

&lt;p&gt;Small bug, worst optics of the three.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;check_agent_run&lt;/code&gt; polls a background agent. Ask it about a run id that does not exist and it answered:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Still working…&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For &lt;strong&gt;any&lt;/strong&gt; id. Forever. A typo'd id, a made-up id, an id from a different account — all of them "still working". A model that receives that will tell the user to be patient, then tell them again, and the user waits for a job that was never created.&lt;/p&gt;

&lt;p&gt;Fixed by making a 404 an error rather than an absence of information, and the error names the recovery:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;no agent run found with id "nope" — use the missionId that run_agent returned&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The general form: &lt;strong&gt;"not found" and "not finished" must never collapse into the same reply.&lt;/strong&gt; One of them is a completed fact about the world; the other is a request to wait. Any status endpoint that answers &lt;code&gt;pending&lt;/code&gt; when the truth is &lt;code&gt;nonexistent&lt;/code&gt; will strand somebody.&lt;/p&gt;


&lt;h2&gt;
  
  
  Problem 4: our test cases depended on each other
&lt;/h2&gt;

&lt;p&gt;This one had no server fix, and it's the one I'd bet most submissions get wrong.&lt;/p&gt;

&lt;p&gt;Our eight cases looked reasonable in the portal. Case 1 created a deck. Case 2 searched the workspace for the deck case 1 had created — and the expected-outcome text said, helpfully, &lt;em&gt;run case 1 first&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A reviewer who runs them in a different order, in a fresh chat, or on the second device sees case 2 fail. &lt;strong&gt;And they are right to.&lt;/strong&gt; We had written a suite that only passes if it is executed as a script by someone who read our footnotes, and then handed it to strangers on two clients.&lt;/p&gt;

&lt;p&gt;Rewritten, every case stands alone. These are the rules we wrote at the top of the file so the next set can't regress:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-contained.&lt;/strong&gt; Seed the demo workspace so the read cases have something to find without a write case running first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specific briefs.&lt;/strong&gt; A vague brief is what fired the clarify gate. &lt;code&gt;NoClarify&lt;/code&gt; fixes it server-side, but a specific brief also keeps the output stable enough to describe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expected outcomes describe shape, not exact words.&lt;/strong&gt; A deck's title comes from a model and differs run to run. "A 5-slide deck is created and rendered" passes every time; asserting a literal title does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No dependence on which artifact is "most recent"&lt;/strong&gt; unless the outcome is phrased to allow any of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every case verified against production&lt;/strong&gt;, from a cold account, on both clients.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An example of the difference, from the same tool:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Expected outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rejected&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Returns the deck created in case 1, titled "Orbit Sales Deck".&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resubmitted&lt;/td&gt;
&lt;td&gt;&lt;em&gt;&lt;code&gt;search_artifacts&lt;/code&gt; returns the workspace's sales-onboarding documents as a results list, each row with an "Open" link back into the app. Read-only: nothing is created or changed.&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second one is falsifiable by a stranger. That's the only property that matters.&lt;/p&gt;


&lt;h2&gt;
  
  
  The part I got right by accident: annotations
&lt;/h2&gt;

&lt;p&gt;While preparing the submission we annotated all 49 tools — &lt;code&gt;readOnlyHint&lt;/code&gt;, &lt;code&gt;destructiveHint&lt;/code&gt;, &lt;code&gt;openWorldHint&lt;/code&gt; — and had to write a justification for each hint on each tool. 147 short paragraphs. It felt like paperwork.&lt;/p&gt;

&lt;p&gt;It wasn't, because of one line in the spec that I would have bet money against:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// DestructiveHint and OpenWorldHint are *bool in the Go SDK, and the spec's default when they&lt;/span&gt;
&lt;span class="c"&gt;// are ABSENT is true.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Absent means &lt;strong&gt;destructive&lt;/strong&gt;. Absent means &lt;strong&gt;open-world&lt;/strong&gt;. Before we filled these in, every tool we shipped — &lt;code&gt;search_artifacts&lt;/code&gt;, a pure read, included — was advertising itself to every client as a destructive, open-world operation. Not a cosmetic problem: clients use these hints to decide what needs a confirmation dialog and what can run unattended.&lt;/p&gt;

&lt;p&gt;We settled it with constructors instead of scattered literals, one per behaviour class, each carrying the definition in its comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// hintsRead: reads the user's own workspace and nothing else.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;hintsRead&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolAnnotations&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ReadOnlyHint&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DestructiveHint&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;no&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OpenWorldHint&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;no&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// hintsAdd: adds something new to the workspace; nothing that already exists is replaced.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;hintsAdd&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolAnnotations&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DestructiveHint&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;no&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OpenWorldHint&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;no&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// hintsAct: hands work to a system whose effects this call does not bound — a background agent, or&lt;/span&gt;
&lt;span class="c"&gt;// a pending action against a connected third-party app. Destructive because what runs is open-ended.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;hintsAct&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolAnnotations&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;yes&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DestructiveHint&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;yes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OpenWorldHint&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;yes&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;Constructors, not package-level vars, so no two tools can ever share and mutate one annotations value.&lt;/p&gt;

&lt;p&gt;Writing 147 justifications also forced the question &lt;em&gt;what does this tool actually do&lt;/em&gt; onto tools nobody had asked it about in a while, and three of ours turned out to be annotated the opposite of what their names suggest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;create_app&lt;/code&gt; is read-only.&lt;/strong&gt; It persists nothing. It builds a pre-filled deep link into the builder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;check_3d_model&lt;/code&gt; is not read-only.&lt;/strong&gt; When the poll succeeds it writes the finished mesh onto the artifact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;approve_action&lt;/code&gt; is destructive and open-world; &lt;code&gt;deny_action&lt;/code&gt; is neither.&lt;/strong&gt; Approving releases a queued action into a third-party app. Denying closes a request locally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reviewer ever questions our annotations, it will be those three, and the justification text already explains each one. Annotate by &lt;em&gt;what the call does&lt;/em&gt;, never by the verb in its name.&lt;/p&gt;




&lt;h2&gt;
  
  
  The demo account is part of the submission
&lt;/h2&gt;

&lt;p&gt;Two things here cost me hours and would cost you the same.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A duplicate row can be load-bearing.&lt;/strong&gt; Our seeded workspace held two documents with the same title, so a search case returned the same name twice. It looked sloppy, so I deleted the newer copy — and the grounded-answer case went flaky. It had been returning the same six-step answer every run; afterwards one run answered properly and the next said &lt;em&gt;the provided passages do not describe the onboarding process&lt;/em&gt;. Removing the duplicate had thinned the grounding corpus below whatever threshold that answer needed.&lt;/p&gt;

&lt;p&gt;Flaky is the precise failure we'd just been rejected for. The copy went back, the answer was consistent across three runs again, and the doc now says so in writing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A duplicate row reads better than an unreliable answer.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Clean out what your own debugging left behind.&lt;/strong&gt; Verifying all 49 tools against production left the demo account full of junk — a "Dev Board", "A Budget", four near-identical sales decks. A reviewer opening that account should find a workspace that looks like a real user's, not the wreckage of a test sweep.&lt;/p&gt;




&lt;h2&gt;
  
  
  Portal friction, so you can plan around it
&lt;/h2&gt;

&lt;p&gt;Four things that cost time and are nobody's bug:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The tool list is virtualised.&lt;/strong&gt; Page-reading tools only ever return part of it. Filling in each tool's fields one tool at a time was the only reliable way through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The SPA has a load race.&lt;/strong&gt; Navigating straight to a deep-linked section sometimes renders &lt;em&gt;ask an organization admin to assign you a role with the api.apps.read permission&lt;/em&gt;, and the Skills tab briefly showed no uploaded skill at all. Both times a full reload through the plugin list showed the real state. &lt;strong&gt;It is not a permissions problem&lt;/strong&gt; — I nearly filed a support ticket about a role I already had.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan Tools needs an OAuth authorization first.&lt;/strong&gt; It opens your own consent page and somebody has to sign in there before the scan will run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The skill safety scan is slow.&lt;/strong&gt; The portal warns up to two hours. Don't schedule your submit for the last hour of the day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one thing worth knowing before you plan a launch: &lt;strong&gt;approval does not publish.&lt;/strong&gt; When it passes, the portal unlocks a publish option and a human presses it. The go-live moment stays yours.&lt;/p&gt;




&lt;h2&gt;
  
  
  The pre-submit checklist
&lt;/h2&gt;

&lt;p&gt;Everything above, as the list I'll actually run next time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Run &lt;strong&gt;every&lt;/strong&gt; tool against production, from an account with &lt;strong&gt;no state you created by hand&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;[ ] For each read tool: does it see content, or an optimised-away copy of it? Check against a &lt;em&gt;known&lt;/em&gt; row count.&lt;/li&gt;
&lt;li&gt;[ ] For each write tool: does "I found nothing" ever become "so I'll create a fresh one"? That's a data-loss path.&lt;/li&gt;
&lt;li&gt;[ ] Does any tool sit in front of an interactive gate — clarify, consent, confirm — that has no human to answer it?&lt;/li&gt;
&lt;li&gt;[ ] Does any status tool report an unknown id as pending?&lt;/li&gt;
&lt;li&gt;[ ] Do the test cases pass &lt;strong&gt;in any order&lt;/strong&gt;, in a &lt;strong&gt;fresh chat&lt;/strong&gt;, on &lt;strong&gt;web and mobile&lt;/strong&gt;?&lt;/li&gt;
&lt;li&gt;[ ] Does any expected outcome assert model-generated wording? Assert shape instead.&lt;/li&gt;
&lt;li&gt;[ ] Are &lt;code&gt;destructiveHint&lt;/code&gt; / &lt;code&gt;openWorldHint&lt;/code&gt; set explicitly on every tool? Absent means &lt;code&gt;true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Is each annotation justified by what the call &lt;em&gt;does&lt;/em&gt;, not by its name?&lt;/li&gt;
&lt;li&gt;[ ] Is the demo account seeded, and cleaned of what your own testing left behind?&lt;/li&gt;
&lt;li&gt;[ ] Did you read the &lt;strong&gt;model's sentence&lt;/strong&gt; for each case, not just your JSON?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I'd tell someone submitting next week
&lt;/h2&gt;

&lt;p&gt;The rejection email is one paragraph and it will feel unfair. Take it literally instead of personally: "did not produce correct results" and "consistently on both web and mobile" were, in our case, precise technical descriptions of three bugs and a broken suite. We didn't appeal, because there was nothing to appeal — they had run our tools as a user and seen our product lie to them.&lt;/p&gt;

&lt;p&gt;The uncomfortable takeaway isn't about any directory. It's this: &lt;strong&gt;an MCP server is a second product&lt;/strong&gt;, and it has its own users, its own state, and its own bugs. Ours had two tools that had never once worked in production, and our own app was structurally incapable of noticing, because our app doesn't call itself through MCP. Every hour of that review found something real.&lt;/p&gt;

&lt;p&gt;At the time of writing this, the fixes are live on production, re-verified there — &lt;code&gt;list_tasks&lt;/code&gt; reads 30 and goes to 31 after &lt;code&gt;add_task&lt;/code&gt;, the ledger keeps both invoices, an unknown run id is an error — and the resubmission hasn't gone in yet. If the second attempt teaches me anything new, I'll write that one too.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've built a harness that calls your own MCP server the way a client does — cold account, one turn, both clients, reading the model's sentence rather than your JSON — I'd like to read about it. That's the piece I'm still missing, and it's the only thing that would have caught all four of these before a stranger did.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on &lt;a href="https://xenition.com" rel="noopener noreferrer"&gt;Xenition&lt;/a&gt; — one AI workspace for documents, decks, code, apps and media, free to start, on &lt;a href="https://xenition.com" rel="noopener noreferrer"&gt;web&lt;/a&gt;, desktop and both app stores. Its MCP server has 49 tools. Two of them, until recently, had never worked.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>chatgpt</category>
      <category>openai</category>
      <category>programming</category>
    </item>
    <item>
      <title>Nobody Argues With a Prefilled Field</title>
      <dc:creator>Info Inlet</dc:creator>
      <pubDate>Sun, 23 Aug 2026 06:23:34 +0000</pubDate>
      <link>https://dev.to/infoinlet1/nobody-argues-with-a-prefilled-field-113m</link>
      <guid>https://dev.to/infoinlet1/nobody-argues-with-a-prefilled-field-113m</guid>
      <description>&lt;p&gt;An agent that remembers you is almost always judged in chat. It recalls something, it says the thing, and if the thing is wrong you can see that it's wrong — it's a sentence, in your reading flow, and you push back. "No, I moved." Memory corrected, one turn, no damage.&lt;/p&gt;

&lt;p&gt;Then we wired the same memory into an input box.&lt;/p&gt;

&lt;p&gt;We build &lt;a href="https://xenition.com" rel="noopener noreferrer"&gt;Xenition&lt;/a&gt;, an AI workspace — chat on one side, and on the other a couple of hundred small things you can actually open and use: documents, decks, forms, calculators, converters. Two surfaces, one signed-in user, one memory. Connecting them was the obvious feature, and it took us a while to notice we'd changed what memory &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A user tells the assistant, in passing, in some conversation weeks ago, that they're 173 cm and 70 kg. Later they open the BMI calculator and both fields are already filled. It feels like magic the first time. It is genuinely good product.&lt;/p&gt;

&lt;p&gt;It is also the exact moment the safety properties you were relying on quietly stop applying — because &lt;strong&gt;a prefilled field doesn't get read. It gets submitted.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every example below is our own code rather than a paraphrase, because those are the ones I can quote exactly. None of it is product-specific — if your agent remembers anything and your UI has a form, you have this problem too.&lt;/p&gt;

&lt;p&gt;I want to write about that transition, because the agent-memory conversation right now is almost entirely about what memory &lt;em&gt;says&lt;/em&gt;. Three of this week's most-read &lt;code&gt;#agents&lt;/code&gt; posts are about provenance, authority ranking, and remembering decisions instead of data. All correct. But nothing changes the risk profile of a memory system as much as changing where its output &lt;em&gt;lands&lt;/em&gt;, and I haven't seen anyone write that part down.&lt;/p&gt;




&lt;h2&gt;
  
  
  A remembered fact has two consumers, and only one of them can hedge
&lt;/h2&gt;

&lt;p&gt;This is the whole idea, so let me put it plainly.&lt;/p&gt;

&lt;p&gt;When a remembered fact becomes a &lt;strong&gt;sentence&lt;/strong&gt;, it can carry doubt. &lt;em&gt;"I think you mentioned 70 kg — still right?"&lt;/em&gt; is a completely legitimate output. Hedging is free. It's one clause.&lt;/p&gt;

&lt;p&gt;When the same fact becomes a &lt;strong&gt;value&lt;/strong&gt;, hedging is impossible. There is no way to type "probably 70" into a number input. The field is either empty or it is an assertion — and it's an assertion sitting inside the user's form, above the user's submit button, which means it is now attributed to &lt;em&gt;the user&lt;/em&gt;, not to the model.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;fact as a sentence&lt;/th&gt;
&lt;th&gt;fact as a field value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Attributed to&lt;/td&gt;
&lt;td&gt;the assistant&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the user&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrongness looks like&lt;/td&gt;
&lt;td&gt;a claim you can dispute&lt;/td&gt;
&lt;td&gt;a default you skim past&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hedging&lt;/td&gt;
&lt;td&gt;natural, free&lt;/td&gt;
&lt;td&gt;impossible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost to correct&lt;/td&gt;
&lt;td&gt;one reply&lt;/td&gt;
&lt;td&gt;you have to notice it first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blast radius&lt;/td&gt;
&lt;td&gt;that turn&lt;/td&gt;
&lt;td&gt;whatever the form does&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two consequences fall out of this, and they're the load-bearing rules for everything below:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The confidence bar for writing into a field is higher than the bar for mentioning in chat.&lt;/strong&gt; A hunch is an acceptable sentence. A hunch is not an acceptable default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The UI has to supply the hedge the value can't carry.&lt;/strong&gt; If the value can't say "probably," the interface around it has to.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The shape of the thing
&lt;/h2&gt;

&lt;p&gt;For context, the pipeline Xenition ended up with. Nothing exotic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chat turn (signed in)
  → engine extracts durable personal facts → long-term memory, keyed by verified user id

later, user opens a tool/form
  → client sends the field SCHEMA (name, label, type) + instantly-known profile facts
  → server does extractive recall over that user's memory
  → returns { values: { heightCm: 173, weightKg: 70 } }
  → client merges into empty fields only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We built it for forms first — smart forms that fill themselves from what you've already told the assistant — then went to extend it to the roughly two hundred tools next door — and it was that second pass that forced us to actually write the rules down, because the first version had at least three of the bugs below and we'd been lucky.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rule 1: the user id must be injected by whoever verified it
&lt;/h2&gt;

&lt;p&gt;Start with the one that's a security bug rather than a UX bug.&lt;/p&gt;

&lt;p&gt;Most "assist" endpoints in a gateway are a thin passthrough. Client posts a body, gateway forwards it to the engine, engine answers. That's completely fine while the body contains only &lt;em&gt;what the user typed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Memory recall is the first endpoint where the body has to say &lt;strong&gt;whose memory to read&lt;/strong&gt;. And if that identifier arrives from the client, you have built a cross-tenant read that anyone can perform by editing one JSON field in devtools.&lt;/p&gt;

&lt;p&gt;So this endpoint doesn't get the generic passthrough. It gets its own handler, whose entire job is to throw away any client-supplied id and inject the one the auth layer verified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// dedicated handler, NOT the generic /v1/assist/* passthrough&lt;/span&gt;
&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c"&gt;// verified at the edge, never from the body&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;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;                          &lt;span class="c"&gt;// anonymous: profile facts only, no recall&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;                     &lt;span class="c"&gt;// overwrite unconditionally&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ours is 63 lines and exists purely for that overwrite. Worth every one of them. The rule generalizes: &lt;strong&gt;the first time a request body needs to name a principal, your proxy stops being a proxy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Anonymous is a real case and should work — it just falls back to facts the client already legitimately has (locale, timezone, currency). No recall, no error, no login wall.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rule 2: extractive only. Empty beats plausible.
&lt;/h2&gt;

&lt;p&gt;If you hand a generative model a field labelled "Weight (kg)" and ask it to fill the form, it will fill the form. It will not return empty. Models are extremely good at producing a plausible adult human.&lt;/p&gt;

&lt;p&gt;And plausible is precisely the failure mode, because &lt;strong&gt;at the point of use, an invented value and a recalled value are indistinguishable.&lt;/strong&gt; They're both just a number in a box. The user has no way to tell which one they're about to submit.&lt;/p&gt;

&lt;p&gt;So the recall path is strictly extractive: if the fact isn't in memory, the field stays empty. Not "estimated." Not "typical." Empty.&lt;/p&gt;

&lt;p&gt;An empty field costs the user four seconds of typing. An invented field costs them a wrong answer with their own name on it.&lt;/p&gt;

&lt;p&gt;This is also the single easiest thing to get wrong when you swap the model or rewrite the prompt, and it will not show up in any test that only asserts the response shape — the shape is perfect. Assert on the &lt;em&gt;behaviour&lt;/em&gt;: given a user with no stored weight, the weight key must be absent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rule 3: precedence, and a merge that cannot clobber
&lt;/h2&gt;

&lt;p&gt;Once more than one thing can fill a field, you need a stated order. Ours, highest wins:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What the user has currently typed&lt;/strong&gt; — always, no exceptions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An explicit one-shot prefill&lt;/strong&gt; — they clicked "Open" on a suggestion carrying values&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory recall&lt;/strong&gt; — the background thing we're discussing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context defaults&lt;/strong&gt; — currency from locale, language from browser&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the merge is fill-empty-only. Never a spread, never a patch that assumes it arrived first:&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;patch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recalled&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;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&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;isEmpty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;cur&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isEmpty&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug this prevents is not hypothetical and it is &lt;em&gt;horrible&lt;/em&gt; when it happens: the user types their real weight, the recall lands 300 ms later, and the field changes under their cursor to a number from last year. They will not notice. They will submit it.&lt;/p&gt;

&lt;p&gt;Which brings us to the timing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rule 4: recall races the user's first keystroke, and you lose that race in public
&lt;/h2&gt;

&lt;p&gt;Recall is async and the form is already on screen. So every apply has to re-check the world at &lt;em&gt;apply&lt;/em&gt; time, not at request time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;openId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctrl&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;AbortController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getState&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;openId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;      &lt;span class="c1"&gt;// they already closed it or switched&lt;/span&gt;
    &lt;span class="nf"&gt;applyFillEmptyOnly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;openId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// re-reads current input, see Rule 3&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&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="nx"&gt;openId&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three separate guards — aborted, still-the-same-surface, still-empty — and all three fire in practice. The middle one matters more than it looks: users open a tool, glance, close it, open a different one. A recall for the previous surface landing in the current one writes a stranger's-looking value into an unrelated form.&lt;/p&gt;

&lt;p&gt;There's an honest limitation here. Views that hold field state in local &lt;code&gt;useState&lt;/code&gt; at mount won't see a late-arriving recall at all. You either lift those fields to the store, or you fall back to the affordance in Rule 5 — which, it turns out, is the better answer anyway.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rule 5: give the value the hedge it can't carry — and respect fact half-life
&lt;/h2&gt;

&lt;p&gt;Back to the thesis. The value can't say "probably," so the interface has to.&lt;/p&gt;

&lt;p&gt;The cheap version is a chip in the footer rather than a silent write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fill from memory: 173 cm, 70 kg   [Apply]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One tap, same convenience, completely different accountability — the user &lt;em&gt;chose&lt;/em&gt; it, so it's genuinely theirs now.&lt;/p&gt;

&lt;p&gt;The question is when to spend that tap, and the answer nobody seems to write down is that &lt;strong&gt;personal facts decay at wildly different rates.&lt;/strong&gt; Provenance discussions usually stop at "where did this come from." The more actionable axis is "how long is this true for":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fact&lt;/th&gt;
&lt;th&gt;Practical half-life&lt;/th&gt;
&lt;th&gt;Prefill silently?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Date of birth&lt;/td&gt;
&lt;td&gt;Never expires&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Name, email&lt;/td&gt;
&lt;td&gt;Years&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Height (adult)&lt;/td&gt;
&lt;td&gt;Years&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Home city&lt;/td&gt;
&lt;td&gt;Months to years&lt;/td&gt;
&lt;td&gt;Yes — but never overwrite a location they set by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weight&lt;/td&gt;
&lt;td&gt;Weeks&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Offer, don't fill&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Salary, job title&lt;/td&gt;
&lt;td&gt;Steps at unpredictable moments&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Offer, don't fill&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Current project," "what I'm working on"&lt;/td&gt;
&lt;td&gt;Days&lt;/td&gt;
&lt;td&gt;Don't store as a fact at all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Date of birth is the ideal memory fact: stated once, true forever, tedious to type. In Xenition it silently fills the age calculator, the date-difference tool, and half a dozen forms, and it has never once been wrong. Weight is the worst: it changes silently, the user never thinks to update the assistant, and it feeds calculations that look authoritative. Same storage, same retrieval, entirely different write policy.&lt;/p&gt;

&lt;p&gt;If you keep one thing from this post, keep that: &lt;strong&gt;the decay rate of a fact should decide whether it gets written or offered.&lt;/strong&gt; Not the recall confidence score. The confidence is about whether they said it. It tells you nothing about whether it's still true.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rule 6: "memory off" has to kill the read, not just the write
&lt;/h2&gt;

&lt;p&gt;Every product with memory has a switch in settings. Two things to check, because the second is regularly missed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The switch must short-circuit &lt;strong&gt;recall&lt;/strong&gt;, not just storage. A user who turns memory off and then sees a form politely fill itself with facts about them has learned something about your switch, and they're going to post about it.&lt;/li&gt;
&lt;li&gt;The client-side check is a courtesy that saves a round trip. &lt;strong&gt;The server-side check is the feature.&lt;/strong&gt; Anything enforced only in the client is enforced nowhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And while you're there: recall must never throw and never block. If the memory service is down, the form opens empty and on time. &lt;code&gt;{}&lt;/code&gt; on any failure, no spinner, no error toast. Memory is an enhancement, and an enhancement that can break the primary interaction isn't one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rule 7: the schema is a contract, and nothing tells you when it breaks
&lt;/h2&gt;

&lt;p&gt;The client declares each field as &lt;code&gt;{ name, label, type }&lt;/code&gt;, and &lt;code&gt;name&lt;/code&gt; has to exactly match the state key the view reads. That's the whole contract.&lt;/p&gt;

&lt;p&gt;Which means renaming a state key — an ordinary, safe-looking refactor — silently disables prefill for that field. No error. No type failure, because it's a &lt;code&gt;Record&amp;lt;string, unknown&amp;gt;&lt;/code&gt; crossing a network boundary. No test failure, because the endpoint still returns 200 with a perfectly-shaped body. The feature just quietly stops existing, and you find out in a month when someone asks whether prefill was always this bad.&lt;/p&gt;

&lt;p&gt;The check I want is a build-time assertion that every declared field name appears in the state keys its view actually reads. I don't have it yet — it's the honest gap in this post. Right now what we have is one end-to-end test per prefillable surface, which is more work and less coverage than the static check would be.&lt;/p&gt;




&lt;h2&gt;
  
  
  What still doesn't work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;We don't store per-fact timestamps yet.&lt;/strong&gt; So the half-life table above is a policy expressed in an extraction prompt, not something enforced by data. It should be a field on the memory record. It will be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's no fact editor.&lt;/strong&gt; Users can turn memory off entirely; they cannot open a list and fix the one number that's wrong. "Off" is a blunt instrument to hand someone whose only complaint is a stale weight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalization is invisible from the client.&lt;/strong&gt; "I'm 5'8"" → &lt;code&gt;173&lt;/code&gt; happens engine-side. When it's wrong, the client sees a plausible number and has no way to know it was derived rather than stated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No static check on the field-name contract&lt;/strong&gt; (Rule 7).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We measure recall coverage, not recall correctness.&lt;/strong&gt; Percentage of fields filled is easy and slightly dangerous — it goes up when the system gets more willing to guess, which is the direction you don't want.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;If you're about to let agent memory touch a form:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inject the user id server-side.&lt;/strong&gt; The moment a body names a principal, stop using the generic passthrough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extractive only.&lt;/strong&gt; No stored fact → empty field. Test the absence, not the shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State a precedence order&lt;/strong&gt; and merge fill-empty-only — never overwrite what the user typed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-check at apply time&lt;/strong&gt;, not request time: aborted, same surface, still empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sort facts by half-life,&lt;/strong&gt; not by confidence. Slow-decaying facts fill; fast-decaying facts offer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mark anything that came from memory&lt;/strong&gt; and make clearing it one click.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Memory off" kills recall too,&lt;/strong&gt; enforced on the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recall never throws, never blocks, never spins.&lt;/strong&gt; &lt;code&gt;{}&lt;/code&gt; and move on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this makes memory smarter. It makes it &lt;em&gt;accountable&lt;/em&gt;, which at the point where a remembered fact turns into a value someone is about to submit under their own name, matters considerably more.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've built per-fact expiry — real timestamps and a decay policy, not a prompt asking the model to be sensible about it — I'd like to read about it. That's the piece I'm missing.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on &lt;a href="https://xenition.com" rel="noopener noreferrer"&gt;Xenition&lt;/a&gt; — one AI workspace for documents, decks, code, apps and media, with the chat and the tools sharing a single memory. Free to start, on &lt;a href="https://xenition.com" rel="noopener noreferrer"&gt;web&lt;/a&gt;, desktop and both app stores. It remembers your date of birth. It asks about your weight.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>agents</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your Postgres Can Do That</title>
      <dc:creator>Info Inlet</dc:creator>
      <pubDate>Sat, 22 Aug 2026 06:41:48 +0000</pubDate>
      <link>https://dev.to/infoinlet1/your-postgres-can-do-that-2l2e</link>
      <guid>https://dev.to/infoinlet1/your-postgres-can-do-that-2l2e</guid>
      <description>&lt;p&gt;Here is the architecture diagram for a product with roughly zero users:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Postgres      ← the actual data
Redis         ← cache, and also the queue, and also rate limits
SQS / Kafka   ← the real queue, for when Redis isn't durable enough
Pinecone      ← embeddings
Elasticsearch ← search
A cron box    ← nightly jobs, currently a $5 VPS nobody has SSH'd into since April
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six systems. Six sets of credentials, six failure modes, six things to upgrade, six bills, six places for state to disagree with the other five. A distributed systems problem, chosen voluntarily, before the first paying customer.&lt;/p&gt;

&lt;p&gt;The Go gateway behind &lt;a href="https://xenition.com" rel="noopener noreferrer"&gt;Xenition&lt;/a&gt; has no Redis in &lt;code&gt;go.mod&lt;/code&gt;. Jobs are claimed with &lt;code&gt;SKIP LOCKED&lt;/code&gt;, search runs on &lt;code&gt;tsvector&lt;/code&gt;, and a large share of the schema is &lt;code&gt;jsonb&lt;/code&gt;. Not because Postgres is magic — because every extra system has to &lt;em&gt;earn&lt;/em&gt; its place by solving a problem we actually have, and at our scale most of them can't clear the bar.&lt;/p&gt;

&lt;p&gt;This isn't "Postgres scales forever." It doesn't. It's that the point where it stops is much further out than the diagram above assumes, and everything before that point is operational cost you're paying for scale you don't have.&lt;/p&gt;

&lt;p&gt;Below: six things people add a service for, the SQL that does them instead, and — the section that matters most — when you should genuinely leave.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. A job queue: &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the one that surprises people, so it goes first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; landed in Postgres 9.5. It makes the classic "many workers, one table, nobody takes the same row twice" problem trivially correct — a reader skips rows another transaction has locked instead of blocking on them.&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="n"&gt;jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="n"&gt;bigserial&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;kind&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;payload&lt;/span&gt;      &lt;span class="n"&gt;jsonb&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;run_after&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="n"&gt;attempts&lt;/span&gt;     &lt;span class="nb"&gt;int&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;max_attempts&lt;/span&gt; &lt;span class="nb"&gt;int&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="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="n"&gt;timestamptz&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;'pending'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- The index that makes the claim fast. Partial: pending rows only, so it&lt;/span&gt;
&lt;span class="c1"&gt;-- stays small even when the table holds millions of finished jobs.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;jobs_claim_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The claim, which is the whole trick:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;attempts&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="o"&gt;=&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;'5 minutes'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&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;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;run_after&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="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;run_after&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;     &lt;span class="c1"&gt;-- ← the entire feature&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&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;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run twenty workers against that. None of them will hand you the same job twice, and none will block waiting on another. No broker, no consumer groups, no offset management.&lt;/p&gt;

&lt;p&gt;You get the rest of a real queue for the price of ordinary SQL:&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;-- Retry with exponential backoff&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;run_after&lt;/span&gt; &lt;span class="o"&gt;=&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'10 seconds'&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;power&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="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Dead letter&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'dead'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Reap workers that died holding a lock&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;locked_until&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last query is your visibility timeout — the thing SQS charges for. Four lines, on a timer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The part nobody mentions&lt;/strong&gt;, and it's the real argument:&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;BEGIN&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;INTO&lt;/span&gt; &lt;span class="n"&gt;invoices&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="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'send_invoice_email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The row and the job commit together, or neither does. With an external broker this is the dual-write problem, and the industry's answer is the &lt;em&gt;transactional outbox pattern&lt;/em&gt; — a table, a relay process, a whole named design pattern — whose entire purpose is to buy back the atomicity you gave away by putting the queue somewhere else. If the queue lives in the database, you never gave it away. There is nothing to buy back.&lt;/p&gt;

&lt;p&gt;Throughput: a modest Postgres handles thousands of claims per second this way. Know your actual number before assuming you're above it.&lt;/p&gt;

&lt;p&gt;Two of these run in the Xenition gateway right now: outbound webhook delivery for the marketplace, and scheduled social posts. The comment sitting above that second query says exactly what the feature buys — &lt;em&gt;"&lt;code&gt;SKIP LOCKED&lt;/code&gt; so two gateways cannot double-post."&lt;/em&gt; That is the entire coordination story between instances. There is no second sentence, no leader election, no lease.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Pub/sub: &lt;code&gt;LISTEN&lt;/code&gt; / &lt;code&gt;NOTIFY&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Need to tell every app server something changed — invalidate a cache, push an SSE event, refresh a dashboard?&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;-- Fires when the transaction commits, not before. This is the good part.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'artifact_changed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"LISTEN artifact_changed"&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="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WaitForNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three caveats, because this one is genuinely limited and you should know before building on it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Not durable.&lt;/strong&gt; A disconnected listener misses the message entirely. There is no replay.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;8000 byte payload limit.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivered after commit&lt;/strong&gt; — which is exactly what you want, and better than most external brokers manage without effort.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pattern that makes it safe: &lt;strong&gt;notify an id, not a payload.&lt;/strong&gt; The listener reads the row itself. A missed notification then degrades into staleness your next poll or reconnect fixes, instead of a permanently lost update. If you need durable fan-out with replay, that's a real Kafka use case — but be sure you need replay, not just delivery.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Cache: it's a table
&lt;/h2&gt;

&lt;p&gt;Yes, really, for a large class of caching:&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="n"&gt;UNLOGGED&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;cache&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;      &lt;span class="c1"&gt;-- UNLOGGED: no WAL, much faster writes,&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;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;-- contents lost on crash. It's a cache.&lt;/span&gt;
  &lt;span class="n"&gt;value&lt;/span&gt;      &lt;span class="n"&gt;jsonb&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;expires_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="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="k"&gt;cache&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;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&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="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="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="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;UPDATE&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expires_at&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;FROM&lt;/span&gt; &lt;span class="k"&gt;cache&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;expires_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="c1"&gt;-- on a timer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An indexed primary-key lookup on a warm table costs a fraction of a millisecond. If your alternative is a network hop to Redis, the gap is smaller than intuition suggests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redis genuinely wins&lt;/strong&gt; when you're doing 50k+ ops/sec of pure key-value work, when you want its data structures (sorted sets for leaderboards, streams, HyperLogLog), or when you're deliberately keeping load off a database that &lt;em&gt;is&lt;/em&gt; the bottleneck. Those are real reasons. "It's the caching layer, that's just what you use" is not one — adding Redis to relieve a database under no pressure adds a failure mode and removes nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Rate limiting
&lt;/h2&gt;

&lt;p&gt;A counter and an upsert:&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="n"&gt;rate_limits&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;subject&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="c1"&gt;-- user id, ip, api key&lt;/span&gt;
  &lt;span class="n"&gt;window_start&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="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;        &lt;span class="nb"&gt;int&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="mi"&gt;0&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;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="p"&gt;)&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;INTO&lt;/span&gt; &lt;span class="n"&gt;rate_limits&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&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="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'minute'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&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="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;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_start&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;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rate_limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One round trip, atomic, returns the new count so you decide immediately. Honest caveat: a single very hot subject serialises on one row, and at extreme rates you want a sliding window or token bucket rather than fixed buckets. For per-user API limits on a normal product this is enough — and it has the property Redis-based limiters famously don't: &lt;strong&gt;it's consistent with the rest of your data&lt;/strong&gt;, so "did we charge them for this request" and "did we count this request" cannot disagree.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Vector search: &lt;code&gt;pgvector&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;vector&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="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&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 sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&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;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;              &lt;span class="c1"&gt;-- ← the thing dedicated vector DBs&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;             &lt;span class="c1"&gt;--    make you fight for&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that &lt;code&gt;WHERE&lt;/code&gt; clause, because it's the entire argument.&lt;/p&gt;

&lt;p&gt;Embeddings are never the whole query in a real product. It's always &lt;em&gt;this user's&lt;/em&gt; documents, not deleted, in this workspace, maybe from the last 90 days. In a separate vector store, each of those filters is either a metadata field you must duplicate and keep in sync, or a post-filter that ruins your &lt;code&gt;LIMIT&lt;/code&gt; — you asked for 10, filtering leaves 2, now you're re-querying with a bigger limit and guessing.&lt;/p&gt;

&lt;p&gt;And the sync problem is permanent. Two systems, one source of truth, no shared transaction. A document deleted here and still present there is a &lt;strong&gt;data leak&lt;/strong&gt;, not a stale cache.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;pgvector&lt;/code&gt; the embedding is a column on the row. Deleting the row deletes the embedding, in the same transaction, forever. That's the feature.&lt;/p&gt;

&lt;p&gt;Dedicated vector databases earn their keep at scale — hundreds of millions of vectors, heavy filtered ANN, sharding across nodes. Below that, you're operating a second database to avoid a column.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Full-text search, and cron
&lt;/h2&gt;

&lt;p&gt;Search:&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="k"&gt;search&lt;/span&gt; &lt;span class="n"&gt;tsvector&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="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;setweight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="s1"&gt;'A'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;setweight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&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="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="s1"&gt;'B'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;documents_search_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;search&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&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;rank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;websearch_to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&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="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;search&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GENERATED ALWAYS AS ... STORED&lt;/code&gt; means the column maintains itself — no trigger to write, no update to forget. And &lt;code&gt;websearch_to_tsquery&lt;/code&gt; accepts what users actually type, quoted phrases and &lt;code&gt;-exclusions&lt;/code&gt; included.&lt;/p&gt;

&lt;p&gt;Elasticsearch still wins for fuzzy matching, sophisticated analyzers, faceting at scale, and typo tolerance. It does not win for "let people find their own documents."&lt;/p&gt;

&lt;p&gt;Concretely, on our side: team-channel message search and the support inbox both run on &lt;code&gt;tsvector&lt;/code&gt;. More interesting is the app builder — Xenition generates working apps for users, and search ships &lt;em&gt;to the generated app&lt;/em&gt; as a Postgres full-text scan over that app's own schema. No external engine to provision, per app, forever. That's the multiplier people miss: with search in the database, per-tenant search is an index and a &lt;code&gt;WHERE&lt;/code&gt; clause. With a search cluster, every tenant is an operations decision.&lt;/p&gt;

&lt;p&gt;Cron: &lt;code&gt;pg_cron&lt;/code&gt; if your host offers it — most managed Postgres does now:&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;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'reap-jobs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'* * * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'running'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;locked_until&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="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If not, run the loop in your app and guard it with an advisory lock so only one instance acts:&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_try_advisory_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hashtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightly-rollup'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's leader election in one line. No ZooKeeper, no etcd, no lease renewal — and the lock releases automatically when the connection dies, which is precisely the failure mode you'd otherwise handle by hand.&lt;/p&gt;




&lt;h2&gt;
  
  
  When you should actually leave
&lt;/h2&gt;

&lt;p&gt;The credibility section. Every one of these is a real reason, and if you hit one, go:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Move to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Queue sustained above ~10k jobs/sec, or you need replay, or multiple independent consumer groups&lt;/td&gt;
&lt;td&gt;Kafka / NATS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache above ~50k ops/sec, or you want sorted sets / streams&lt;/td&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hundreds of millions of vectors, or heavy filtered ANN across shards&lt;/td&gt;
&lt;td&gt;A dedicated vector DB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fuzzy matching, typo tolerance, heavy faceting, multi-language analyzers&lt;/td&gt;
&lt;td&gt;Elasticsearch / Typesense&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytical scans over billions of rows fighting your OLTP traffic&lt;/td&gt;
&lt;td&gt;A column store — ClickHouse, DuckDB, a warehouse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your write master is genuinely saturated and read replicas are exhausted&lt;/td&gt;
&lt;td&gt;Shard, or split the workload out&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note what all six have in common: &lt;strong&gt;each is a measurement, not a vibe.&lt;/strong&gt; "We might need it later" is not on the list. Neither is "this is the standard architecture."&lt;/p&gt;

&lt;p&gt;The ordering matters too. Adding a service is a one-way door in practice — once two systems hold state, everything downstream inherits the consistency problem permanently. Extracting later is a migration. Adding early is a tax you pay every day. Do it when the number says so.&lt;/p&gt;




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

&lt;p&gt;It isn't that Postgres is secretly six products. It's this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every system you add is a state boundary, and every state boundary is a place where your data can be two different things at once.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The queue that fired for a row that rolled back. The vector still returned for a document you deleted. The cache that says the user is on the paid plan and the database that says they cancelled. None of those bugs exist inside a single transaction. All of them are the routine, expected cost of running six systems — outbox patterns, CDC pipelines and reconciliation jobs are all standard machinery for managing a problem you chose to have.&lt;/p&gt;

&lt;p&gt;Start with one database. Add the second system when a measurement, not an architecture diagram, tells you to. You will be astonished how long that takes, and how much time you get back in the meantime.&lt;/p&gt;

&lt;p&gt;For us that's a Go gateway and one Postgres: &lt;code&gt;SKIP LOCKED&lt;/code&gt; for jobs, &lt;code&gt;tsvector&lt;/code&gt; for search, SSE straight off the same connection pool for realtime, and &lt;code&gt;jsonb&lt;/code&gt; in the several hundred places where the shape genuinely varies. Xenition is a full AI workspace — documents, spreadsheets, decks, boards, team chat, an app builder — and none of that has yet produced a number that says add Redis. The day one of the thresholds above trips, we'll add exactly the thing it points at. Not before, and not the other five along with it.&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;-- Everything above, in one line of philosophy:&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;-- the row, the job, the embedding, the counter, the search index&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>postgres</category>
      <category>backend</category>
      <category>database</category>
    </item>
  </channel>
</rss>
